feat(plugin): RpcClient + RpcRemoteFsClient transport layer
Plumbing for the daemon-backed transport that will replace SftpClient
in a follow-up PR. Nothing here is wired into the runtime yet — every
new module is tree-shaken out of the production bundle until the
adapter switchover lands.
src/transport/framing.ts
- FramedDuplex wraps any Node Duplex (TCP socket, ssh2 stream) with
LSP-style framing:
Content-Length: <N>\r\n
\r\n
<N bytes of JSON>
- Emits 'message', 'close', 'error'. Copies the message body out of
the shared rolling buffer so later shrinks don't mutate it.
- Configurable maxMessageBytes (default 16 MiB). Oversized payloads
close the stream with an error.
- Tolerates bare LF framing for lenient senders, matching the
parallel tolerance in the Go daemon's ReadFrame.
- Tests feed one byte at a time to exercise the partial-frame path.
src/transport/RpcError.ts
- Small typed Error carrying a code, optional data payload, and an
`is(code)` helper that plays nicely with the ErrorCode constants
already shipped in proto/types.ts.
src/transport/RpcClient.ts
- Correlates outbound call(method, params) requests with their reply
by numeric id. Returns a typed Result<M> via Params<M>/Result<M>.
- Demultiplexes concurrent calls.
- onNotification(method, cb) dispatches server-push events (fs.changed
lands in a later phase); returns a disposer.
- Stream close rejects every pending call with an RpcError(-32603),
so no handler hangs when the SSH tunnel drops. onClose listeners
see the underlying error when there was one.
- Malformed server messages are dropped rather than killing the
session — the only way a pending call is never answered is via the
close path, which always fires.
src/adapter/RemoteFsClient.ts
- Narrow interface for what SftpDataAdapter actually needs from its
client: lifecycle (isAlive + onClose), read side (stat/exists/
list/readBinary), and write side (writeBinary/mkdirp/remove/
rmdir/rename/copy). Deliberately smaller than the full SFTP
surface — atomic-write and parent-dir creation are the server's
responsibility under the α model.
src/adapter/RpcRemoteFsClient.ts
- Implementation backed by RpcClient. Forwards each method to the
matching daemon call, translating the wire DTOs (proto.Stat /
proto.Entry / base64 string) into the existing RemoteStat /
RemoteEntry / Buffer shapes so the adapter above is unchanged.
- stat returns null from the daemon → thrown as an RpcError with
ErrorCode.FileNotFound so the adapter sees the same "missing"
signal it used to get from ssh2.
- mkdirp → fs.mkdir{ recursive: true }; rmdir defaults recursive=false.
Tests (vitest)
- tests/framing.test.ts (7): duplex pair round-trip, multi-frame
pipelining, byte-at-a-time reassembly, missing Content-Length,
oversized-message rejection, bare-LF tolerance, close() refuses
further writes.
- tests/RpcClient.test.ts (11): id correlation, error envelope →
RpcError with matching code, out-of-order concurrent calls,
notification delivery + disposer, pending-call rejection on
close, onClose handler with/without error, call() after close,
malformed payloads ignored without hanging other calls.
- tests/RpcRemoteFsClient.test.ts (11): DTO conversion for stat /
list, exists / readBinary / writeBinary round-trip, mkdirp →
fs.mkdir recursive, rmdir default, rename / copy param shapes,
isAlive mirrors RpcClient.isClosed, stat(null) → FileNotFound.
Verification
- cd plugin && npx tsc --noEmit clean
- cd plugin && npm test 108 / 108 pass (29 new)
- cd plugin && npm run build bundle unchanged at 369 KB
(transport modules are not
reachable from runtime code
until Phase 5-D.2 wires
them in).
Follow-up
- Phase 5-D.2: SshTunnel (ssh2 openssh_forwardOutStreamLocal wrap) +
SftpDataAdapter refactor to RemoteFsClient + runtime wiring.
2026-04-25 02:59:06 +00:00
|
|
|
import { describe, it, expect, vi } from 'vitest';
|
|
|
|
|
import { RpcRemoteFsClient } from '../src/adapter/RpcRemoteFsClient';
|
|
|
|
|
import { RpcError } from '../src/transport/RpcError';
|
|
|
|
|
import type { RpcClient } from '../src/transport/RpcClient';
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* RpcRemoteFsClient is a pure delegation layer: each method forwards
|
|
|
|
|
* to a single RpcClient.call and reshapes the DTO if needed. Testing
|
|
|
|
|
* it with a mocked RpcClient confirms the per-method method name,
|
|
|
|
|
* params shape, and DTO conversion.
|
|
|
|
|
*/
|
|
|
|
|
function mockRpc(responders: Record<string, (params: unknown) => unknown>): RpcClient {
|
|
|
|
|
return {
|
|
|
|
|
isClosed: () => false,
|
|
|
|
|
onClose: () => () => { /* noop */ },
|
|
|
|
|
call: vi.fn(async (method: string, params: unknown) => {
|
|
|
|
|
const handler = responders[method];
|
|
|
|
|
if (!handler) throw new RpcError(-32601, `no fake for method ${method}`);
|
|
|
|
|
return handler(params);
|
|
|
|
|
}),
|
|
|
|
|
} as unknown as RpcClient;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
describe('RpcRemoteFsClient', () => {
|
|
|
|
|
it('stat reshapes proto.Stat into RemoteStat', async () => {
|
|
|
|
|
const client = new RpcRemoteFsClient(mockRpc({
|
|
|
|
|
'fs.stat': (p) => {
|
|
|
|
|
expect(p).toEqual({ path: 'note.md' });
|
|
|
|
|
return { type: 'file', mtime: 123, size: 4, mode: 0o100644 };
|
|
|
|
|
},
|
|
|
|
|
}));
|
|
|
|
|
const s = await client.stat('note.md');
|
|
|
|
|
expect(s.isFile).toBe(true);
|
|
|
|
|
expect(s.isDirectory).toBe(false);
|
|
|
|
|
expect(s.mtime).toBe(123);
|
|
|
|
|
expect(s.size).toBe(4);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('stat throws FileNotFound when the server returns null', async () => {
|
|
|
|
|
const client = new RpcRemoteFsClient(mockRpc({
|
|
|
|
|
'fs.stat': () => null,
|
|
|
|
|
}));
|
|
|
|
|
await expect(client.stat('gone.md')).rejects.toBeInstanceOf(RpcError);
|
|
|
|
|
try {
|
|
|
|
|
await client.stat('gone.md');
|
|
|
|
|
} catch (e) {
|
|
|
|
|
expect((e as RpcError).code).toBe(-32010);
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('exists unwraps the boolean', async () => {
|
|
|
|
|
const client = new RpcRemoteFsClient(mockRpc({
|
|
|
|
|
'fs.exists': () => ({ exists: false }),
|
|
|
|
|
}));
|
|
|
|
|
expect(await client.exists('x')).toBe(false);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('list reshapes Entry[] into RemoteEntry[]', async () => {
|
|
|
|
|
const client = new RpcRemoteFsClient(mockRpc({
|
|
|
|
|
'fs.list': () => ({
|
|
|
|
|
entries: [
|
|
|
|
|
{ name: 'a.md', type: 'file', mtime: 1, size: 2 },
|
|
|
|
|
{ name: 'docs', type: 'folder', mtime: 3, size: 0 },
|
|
|
|
|
{ name: 'link.md', type: 'symlink', mtime: 5, size: 0 },
|
|
|
|
|
],
|
|
|
|
|
}),
|
|
|
|
|
}));
|
|
|
|
|
const entries = await client.list('');
|
|
|
|
|
expect(entries.length).toBe(3);
|
|
|
|
|
expect(entries[0].isFile).toBe(true);
|
|
|
|
|
expect(entries[1].isDirectory).toBe(true);
|
|
|
|
|
expect(entries[2].isSymbolicLink).toBe(true);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('readBinary base64-decodes the server payload', async () => {
|
|
|
|
|
const client = new RpcRemoteFsClient(mockRpc({
|
|
|
|
|
'fs.readBinary': () => ({
|
|
|
|
|
contentBase64: Buffer.from([10, 20, 30]).toString('base64'),
|
|
|
|
|
mtime: 0,
|
|
|
|
|
size: 3,
|
|
|
|
|
}),
|
|
|
|
|
}));
|
|
|
|
|
const buf = await client.readBinary('x.bin');
|
|
|
|
|
expect([...buf]).toEqual([10, 20, 30]);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('writeBinary base64-encodes the payload', async () => {
|
|
|
|
|
const calls: Array<{ method: string; params: unknown }> = [];
|
|
|
|
|
const client = new RpcRemoteFsClient({
|
|
|
|
|
isClosed: () => false,
|
|
|
|
|
onClose: () => () => { /* noop */ },
|
|
|
|
|
call: vi.fn(async (method: string, params: unknown) => {
|
|
|
|
|
calls.push({ method, params });
|
|
|
|
|
if (method === 'fs.writeBinary') return { mtime: 1 };
|
|
|
|
|
throw new RpcError(-32601, method);
|
|
|
|
|
}),
|
|
|
|
|
} as unknown as RpcClient);
|
|
|
|
|
|
|
|
|
|
await client.writeBinary('x.bin', Buffer.from([1, 2, 3]));
|
|
|
|
|
expect(calls.length).toBe(1);
|
|
|
|
|
expect(calls[0].method).toBe('fs.writeBinary');
|
|
|
|
|
const params = calls[0].params as { path: string; contentBase64: string };
|
|
|
|
|
expect(params.path).toBe('x.bin');
|
|
|
|
|
expect(Buffer.from(params.contentBase64, 'base64').toString('hex')).toBe('010203');
|
feat(plugin): write-conflict detection via expectedMtime (Tier 2-C)
Two clients on the same remote vault could silently overwrite each
other's edits: each writes through `fs.write` with no precondition,
last-writer-wins. The proto already had `expectedMtime` in the
write params (rejected with PreconditionFailed = -32020 when the
remote mtime moved), but the plugin never set it.
- `RemoteFsClient.writeBinary` gains an optional `expectedMtime`
arg. `RpcRemoteFsClient` forwards it into the proto call;
`SftpRemoteFsClient` ignores it (SFTP can't enforce a precondition
atomically — conflict detection is RPC-only, by design).
- `SftpDataAdapter.writeBuffer` looks up `readCache.peek(remote)`
before a write. When there's a hit, the cached mtime goes through
as `expectedMtime` so a concurrent edit on the remote rejects the
write. First-touch writes (no cache entry) skip the precondition,
avoiding false positives on files we never read.
- New optional `onWriteConflict?: (vaultPath) => Promise<boolean>`
on the adapter. On PreconditionFailed it asks the callback; true
retries without `expectedMtime` (overwrite), false re-throws.
- `WriteConflictModal` (new): two-button Obsidian Modal that
prompts "Overwrite remote / Cancel". Closing without picking
resolves to false so the promise always settles.
- main.ts wires the modal as the adapter's onWriteConflict in
`patchAdapter`.
The PreconditionFailed check is duck-typed on `e.code === -32020`
so the adapter doesn't have to import RpcError from the transport
layer.
Tests: 7 new SftpDataAdapter cases (cached mtime forwarded, no
mtime forwarded for first-touch, overwrite retries without
precondition, cancel re-throws, no callback re-throws, non-
precondition error bypasses callback) + 1 RpcRemoteFsClient case
for the new arg threading. 247 total green.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 01:22:48 +00:00
|
|
|
expect((params as { expectedMtime?: number }).expectedMtime).toBeUndefined();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('writeBinary forwards expectedMtime when provided', async () => {
|
|
|
|
|
const calls: Array<{ method: string; params: unknown }> = [];
|
|
|
|
|
const client = new RpcRemoteFsClient({
|
|
|
|
|
isClosed: () => false,
|
|
|
|
|
onClose: () => () => { /* noop */ },
|
|
|
|
|
call: vi.fn(async (method: string, params: unknown) => {
|
|
|
|
|
calls.push({ method, params });
|
|
|
|
|
if (method === 'fs.writeBinary') return { mtime: 2 };
|
|
|
|
|
throw new RpcError(-32601, method);
|
|
|
|
|
}),
|
|
|
|
|
} as unknown as RpcClient);
|
|
|
|
|
|
|
|
|
|
await client.writeBinary('x.bin', Buffer.from([1]), 1234);
|
|
|
|
|
const params = calls[0].params as { expectedMtime?: number };
|
|
|
|
|
expect(params.expectedMtime).toBe(1234);
|
feat(plugin): RpcClient + RpcRemoteFsClient transport layer
Plumbing for the daemon-backed transport that will replace SftpClient
in a follow-up PR. Nothing here is wired into the runtime yet — every
new module is tree-shaken out of the production bundle until the
adapter switchover lands.
src/transport/framing.ts
- FramedDuplex wraps any Node Duplex (TCP socket, ssh2 stream) with
LSP-style framing:
Content-Length: <N>\r\n
\r\n
<N bytes of JSON>
- Emits 'message', 'close', 'error'. Copies the message body out of
the shared rolling buffer so later shrinks don't mutate it.
- Configurable maxMessageBytes (default 16 MiB). Oversized payloads
close the stream with an error.
- Tolerates bare LF framing for lenient senders, matching the
parallel tolerance in the Go daemon's ReadFrame.
- Tests feed one byte at a time to exercise the partial-frame path.
src/transport/RpcError.ts
- Small typed Error carrying a code, optional data payload, and an
`is(code)` helper that plays nicely with the ErrorCode constants
already shipped in proto/types.ts.
src/transport/RpcClient.ts
- Correlates outbound call(method, params) requests with their reply
by numeric id. Returns a typed Result<M> via Params<M>/Result<M>.
- Demultiplexes concurrent calls.
- onNotification(method, cb) dispatches server-push events (fs.changed
lands in a later phase); returns a disposer.
- Stream close rejects every pending call with an RpcError(-32603),
so no handler hangs when the SSH tunnel drops. onClose listeners
see the underlying error when there was one.
- Malformed server messages are dropped rather than killing the
session — the only way a pending call is never answered is via the
close path, which always fires.
src/adapter/RemoteFsClient.ts
- Narrow interface for what SftpDataAdapter actually needs from its
client: lifecycle (isAlive + onClose), read side (stat/exists/
list/readBinary), and write side (writeBinary/mkdirp/remove/
rmdir/rename/copy). Deliberately smaller than the full SFTP
surface — atomic-write and parent-dir creation are the server's
responsibility under the α model.
src/adapter/RpcRemoteFsClient.ts
- Implementation backed by RpcClient. Forwards each method to the
matching daemon call, translating the wire DTOs (proto.Stat /
proto.Entry / base64 string) into the existing RemoteStat /
RemoteEntry / Buffer shapes so the adapter above is unchanged.
- stat returns null from the daemon → thrown as an RpcError with
ErrorCode.FileNotFound so the adapter sees the same "missing"
signal it used to get from ssh2.
- mkdirp → fs.mkdir{ recursive: true }; rmdir defaults recursive=false.
Tests (vitest)
- tests/framing.test.ts (7): duplex pair round-trip, multi-frame
pipelining, byte-at-a-time reassembly, missing Content-Length,
oversized-message rejection, bare-LF tolerance, close() refuses
further writes.
- tests/RpcClient.test.ts (11): id correlation, error envelope →
RpcError with matching code, out-of-order concurrent calls,
notification delivery + disposer, pending-call rejection on
close, onClose handler with/without error, call() after close,
malformed payloads ignored without hanging other calls.
- tests/RpcRemoteFsClient.test.ts (11): DTO conversion for stat /
list, exists / readBinary / writeBinary round-trip, mkdirp →
fs.mkdir recursive, rmdir default, rename / copy param shapes,
isAlive mirrors RpcClient.isClosed, stat(null) → FileNotFound.
Verification
- cd plugin && npx tsc --noEmit clean
- cd plugin && npm test 108 / 108 pass (29 new)
- cd plugin && npm run build bundle unchanged at 369 KB
(transport modules are not
reachable from runtime code
until Phase 5-D.2 wires
them in).
Follow-up
- Phase 5-D.2: SshTunnel (ssh2 openssh_forwardOutStreamLocal wrap) +
SftpDataAdapter refactor to RemoteFsClient + runtime wiring.
2026-04-25 02:59:06 +00:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('mkdirp maps to fs.mkdir with recursive=true', async () => {
|
|
|
|
|
const spy = vi.fn(async () => ({}));
|
|
|
|
|
const client = new RpcRemoteFsClient({
|
|
|
|
|
isClosed: () => false,
|
|
|
|
|
onClose: () => () => { /* noop */ },
|
|
|
|
|
call: spy,
|
|
|
|
|
} as unknown as RpcClient);
|
|
|
|
|
await client.mkdirp('docs/sub');
|
|
|
|
|
expect(spy).toHaveBeenCalledWith('fs.mkdir', { path: 'docs/sub', recursive: true });
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('remove maps to fs.remove', async () => {
|
|
|
|
|
const spy = vi.fn(async () => ({}));
|
|
|
|
|
const client = new RpcRemoteFsClient({
|
|
|
|
|
isClosed: () => false,
|
|
|
|
|
onClose: () => () => { /* noop */ },
|
|
|
|
|
call: spy,
|
|
|
|
|
} as unknown as RpcClient);
|
|
|
|
|
await client.remove('a.md');
|
|
|
|
|
expect(spy).toHaveBeenCalledWith('fs.remove', { path: 'a.md' });
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('rmdir defaults recursive to false', async () => {
|
|
|
|
|
const spy = vi.fn(async () => ({}));
|
|
|
|
|
const client = new RpcRemoteFsClient({
|
|
|
|
|
isClosed: () => false,
|
|
|
|
|
onClose: () => () => { /* noop */ },
|
|
|
|
|
call: spy,
|
|
|
|
|
} as unknown as RpcClient);
|
|
|
|
|
await client.rmdir('empty-dir');
|
|
|
|
|
expect(spy).toHaveBeenCalledWith('fs.rmdir', { path: 'empty-dir', recursive: false });
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('rename forwards old/new paths', async () => {
|
|
|
|
|
const spy = vi.fn(async () => ({ mtime: 42 }));
|
|
|
|
|
const client = new RpcRemoteFsClient({
|
|
|
|
|
isClosed: () => false,
|
|
|
|
|
onClose: () => () => { /* noop */ },
|
|
|
|
|
call: spy,
|
|
|
|
|
} as unknown as RpcClient);
|
|
|
|
|
await client.rename('old.md', 'new.md');
|
|
|
|
|
expect(spy).toHaveBeenCalledWith('fs.rename', { oldPath: 'old.md', newPath: 'new.md' });
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('copy forwards src/dest paths', async () => {
|
|
|
|
|
const spy = vi.fn(async () => ({ mtime: 42 }));
|
|
|
|
|
const client = new RpcRemoteFsClient({
|
|
|
|
|
isClosed: () => false,
|
|
|
|
|
onClose: () => () => { /* noop */ },
|
|
|
|
|
call: spy,
|
|
|
|
|
} as unknown as RpcClient);
|
|
|
|
|
await client.copy('a.md', 'b.md');
|
|
|
|
|
expect(spy).toHaveBeenCalledWith('fs.copy', { srcPath: 'a.md', destPath: 'b.md' });
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('isAlive mirrors the RpcClient closed state', () => {
|
|
|
|
|
const fake = { isClosed: vi.fn(() => false), onClose: () => () => { /* noop */ }, call: vi.fn() };
|
|
|
|
|
const client = new RpcRemoteFsClient(fake as unknown as RpcClient);
|
|
|
|
|
expect(client.isAlive()).toBe(true);
|
|
|
|
|
fake.isClosed = vi.fn(() => true);
|
|
|
|
|
expect(client.isAlive()).toBe(false);
|
|
|
|
|
});
|
feat(rpc): fs.readBinaryRange + ResourceBridge fast path (#134, 0.4.83)
True partial reads in the daemon protocol so the bridge no longer
loads the whole file into memory just to slice — meaningful for
streaming video, large PDFs, and constrained-memory remote hosts.
Server side
- proto: ReadBinaryRangeParams { path, offset, length, expectedMtime? }
+ ReadBinaryRangeResult { contentBase64, mtime, size }. Size always
reports the TOTAL on-disk file size so HTTP Content-Range
responses can build `bytes start-end/<total>` without a separate
stat round-trip.
- handlers: FsReadBinaryRange uses os.File.ReadAt (pread on POSIX,
no full-file load). Reads past EOF clamp silently. Negative offset
or length are rejected as InvalidParams. expectedMtime != 0
failure surfaces as PreconditionFailed so range-aware callers can
detect a mid-read edit and restart from offset 0.
- registration in cmd/main.go so server.info advertises the method
in capabilities (existing dispatcher.Methods() machinery
auto-discovers from disp.Handle calls).
- 9 new unit tests (happy / clamp / past-EOF / mtime mismatch /
mtime match / negative offset / negative length / directory /
outside-vault / not-found).
Plugin side
- proto/types.ts: TS mirror of params + result, MethodMap row,
MethodName union arm.
- RpcRemoteFsClient.readBinaryRange(path, offset, length,
expectedMtime?) — reshapes contentBase64 → Buffer, returns
{ data, mtime, size }.
- ResourceBridge: new optional FetchBinaryRangeFn callback in
start(); when present AND the request carries an explicit
`bytes=N-M` Range header, bridge issues a single fs.readBinaryRange
RPC instead of fetching+slicing the whole file. `bytes=N-` /
`bytes=-N` (which need total size to parse) still fall through
to the legacy full-file path. New parseExplicitByteRange helper
is exported and unit-tested. On any range-fetcher error the
bridge logs and falls back transparently.
- main.ts: makeBinaryRangeFetcherIfSupported() mirrors the existing
thumbnail capability gate — wires the callback only when
conn.info.capabilities includes 'fs.readBinaryRange'. Older
daemons keep working unchanged via the legacy slice path.
- 11 new unit tests covering the fast-path callback wiring, the
parser, fall-through cases (no callback / non-explicit form /
fetcher throws / no Range header), 416 on past-EOF start, and
daemon-side EOF clamping.
Backwards compatibility
- ResourceBridge.start signature gained an optional 3rd param;
existing 1-arg and 2-arg call sites compile unchanged.
- Older daemons that don't register fs.readBinaryRange simply
don't advertise the capability → makeBinaryRangeFetcherIfSupported
returns null → bridge stays on the existing full-file path.
Closes #134
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-30 01:10:15 +00:00
|
|
|
|
|
|
|
|
// ─── readBinaryRange (#134) ───────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
it('readBinaryRange forwards offset/length and decodes the slice', async () => {
|
|
|
|
|
const calls: Array<{ method: string; params: unknown }> = [];
|
|
|
|
|
const slice = Buffer.from([100, 101, 102, 103]);
|
|
|
|
|
const client = new RpcRemoteFsClient({
|
|
|
|
|
isClosed: () => false,
|
|
|
|
|
onClose: () => () => { /* noop */ },
|
|
|
|
|
call: vi.fn(async (method: string, params: unknown) => {
|
|
|
|
|
calls.push({ method, params });
|
|
|
|
|
if (method === 'fs.readBinaryRange') {
|
|
|
|
|
return { contentBase64: slice.toString('base64'), mtime: 12345, size: 1024 };
|
|
|
|
|
}
|
|
|
|
|
throw new RpcError(-32601, method);
|
|
|
|
|
}),
|
|
|
|
|
} as unknown as RpcClient);
|
|
|
|
|
|
|
|
|
|
const result = await client.readBinaryRange('big.bin', 100, 4);
|
|
|
|
|
expect(calls.length).toBe(1);
|
|
|
|
|
expect(calls[0].method).toBe('fs.readBinaryRange');
|
|
|
|
|
expect(calls[0].params).toEqual({ path: 'big.bin', offset: 100, length: 4 });
|
|
|
|
|
expect([...result.data]).toEqual([100, 101, 102, 103]);
|
|
|
|
|
expect(result.mtime).toBe(12345);
|
|
|
|
|
expect(result.size).toBe(1024); // total file size, not slice length
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('readBinaryRange forwards expectedMtime when provided', async () => {
|
|
|
|
|
const calls: Array<{ method: string; params: unknown }> = [];
|
|
|
|
|
const client = new RpcRemoteFsClient({
|
|
|
|
|
isClosed: () => false,
|
|
|
|
|
onClose: () => () => { /* noop */ },
|
|
|
|
|
call: vi.fn(async (method: string, params: unknown) => {
|
|
|
|
|
calls.push({ method, params });
|
|
|
|
|
return { contentBase64: '', mtime: 999, size: 50 };
|
|
|
|
|
}),
|
|
|
|
|
} as unknown as RpcClient);
|
|
|
|
|
|
|
|
|
|
await client.readBinaryRange('x.bin', 0, 10, 999);
|
|
|
|
|
const params = calls[0].params as { expectedMtime?: number };
|
|
|
|
|
expect(params.expectedMtime).toBe(999);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('readBinaryRange omits expectedMtime when not provided', async () => {
|
|
|
|
|
const calls: Array<{ method: string; params: unknown }> = [];
|
|
|
|
|
const client = new RpcRemoteFsClient({
|
|
|
|
|
isClosed: () => false,
|
|
|
|
|
onClose: () => () => { /* noop */ },
|
|
|
|
|
call: vi.fn(async (method: string, params: unknown) => {
|
|
|
|
|
calls.push({ method, params });
|
|
|
|
|
return { contentBase64: '', mtime: 0, size: 0 };
|
|
|
|
|
}),
|
|
|
|
|
} as unknown as RpcClient);
|
|
|
|
|
|
|
|
|
|
await client.readBinaryRange('x.bin', 0, 10);
|
|
|
|
|
const params = calls[0].params as Record<string, unknown>;
|
|
|
|
|
expect('expectedMtime' in params).toBe(false);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('readBinaryRange returns an empty Buffer when the slice is empty (past EOF)', async () => {
|
|
|
|
|
const client = new RpcRemoteFsClient(mockRpc({
|
|
|
|
|
'fs.readBinaryRange': () => ({ contentBase64: '', mtime: 0, size: 50 }),
|
|
|
|
|
}));
|
|
|
|
|
const result = await client.readBinaryRange('x.bin', 100, 10);
|
|
|
|
|
expect(result.data.byteLength).toBe(0);
|
|
|
|
|
expect(result.size).toBe(50);
|
|
|
|
|
});
|
feat(plugin): RpcClient + RpcRemoteFsClient transport layer
Plumbing for the daemon-backed transport that will replace SftpClient
in a follow-up PR. Nothing here is wired into the runtime yet — every
new module is tree-shaken out of the production bundle until the
adapter switchover lands.
src/transport/framing.ts
- FramedDuplex wraps any Node Duplex (TCP socket, ssh2 stream) with
LSP-style framing:
Content-Length: <N>\r\n
\r\n
<N bytes of JSON>
- Emits 'message', 'close', 'error'. Copies the message body out of
the shared rolling buffer so later shrinks don't mutate it.
- Configurable maxMessageBytes (default 16 MiB). Oversized payloads
close the stream with an error.
- Tolerates bare LF framing for lenient senders, matching the
parallel tolerance in the Go daemon's ReadFrame.
- Tests feed one byte at a time to exercise the partial-frame path.
src/transport/RpcError.ts
- Small typed Error carrying a code, optional data payload, and an
`is(code)` helper that plays nicely with the ErrorCode constants
already shipped in proto/types.ts.
src/transport/RpcClient.ts
- Correlates outbound call(method, params) requests with their reply
by numeric id. Returns a typed Result<M> via Params<M>/Result<M>.
- Demultiplexes concurrent calls.
- onNotification(method, cb) dispatches server-push events (fs.changed
lands in a later phase); returns a disposer.
- Stream close rejects every pending call with an RpcError(-32603),
so no handler hangs when the SSH tunnel drops. onClose listeners
see the underlying error when there was one.
- Malformed server messages are dropped rather than killing the
session — the only way a pending call is never answered is via the
close path, which always fires.
src/adapter/RemoteFsClient.ts
- Narrow interface for what SftpDataAdapter actually needs from its
client: lifecycle (isAlive + onClose), read side (stat/exists/
list/readBinary), and write side (writeBinary/mkdirp/remove/
rmdir/rename/copy). Deliberately smaller than the full SFTP
surface — atomic-write and parent-dir creation are the server's
responsibility under the α model.
src/adapter/RpcRemoteFsClient.ts
- Implementation backed by RpcClient. Forwards each method to the
matching daemon call, translating the wire DTOs (proto.Stat /
proto.Entry / base64 string) into the existing RemoteStat /
RemoteEntry / Buffer shapes so the adapter above is unchanged.
- stat returns null from the daemon → thrown as an RpcError with
ErrorCode.FileNotFound so the adapter sees the same "missing"
signal it used to get from ssh2.
- mkdirp → fs.mkdir{ recursive: true }; rmdir defaults recursive=false.
Tests (vitest)
- tests/framing.test.ts (7): duplex pair round-trip, multi-frame
pipelining, byte-at-a-time reassembly, missing Content-Length,
oversized-message rejection, bare-LF tolerance, close() refuses
further writes.
- tests/RpcClient.test.ts (11): id correlation, error envelope →
RpcError with matching code, out-of-order concurrent calls,
notification delivery + disposer, pending-call rejection on
close, onClose handler with/without error, call() after close,
malformed payloads ignored without hanging other calls.
- tests/RpcRemoteFsClient.test.ts (11): DTO conversion for stat /
list, exists / readBinary / writeBinary round-trip, mkdirp →
fs.mkdir recursive, rmdir default, rename / copy param shapes,
isAlive mirrors RpcClient.isClosed, stat(null) → FileNotFound.
Verification
- cd plugin && npx tsc --noEmit clean
- cd plugin && npm test 108 / 108 pass (29 new)
- cd plugin && npm run build bundle unchanged at 369 KB
(transport modules are not
reachable from runtime code
until Phase 5-D.2 wires
them in).
Follow-up
- Phase 5-D.2: SshTunnel (ssh2 openssh_forwardOutStreamLocal wrap) +
SftpDataAdapter refactor to RemoteFsClient + runtime wiring.
2026-04-25 02:59:06 +00:00
|
|
|
});
|