mirror of
https://github.com/sotashimozono/obsidian-remote-ssh.git
synced 2026-07-22 17:10:32 +00:00
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.
145 lines
5.4 KiB
TypeScript
145 lines
5.4 KiB
TypeScript
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
|
import { EventEmitter } from 'events';
|
|
import { RpcClient } from '../src/transport/RpcClient';
|
|
import { RpcError } from '../src/transport/RpcError';
|
|
|
|
/**
|
|
* A FramedDuplex stand-in just rich enough for RpcClient: it exposes
|
|
* the same events (`message`, `close`, `error`) and captures anything
|
|
* written via `writeMessage` so tests can assert the wire shape.
|
|
*/
|
|
class FakeFramed extends EventEmitter {
|
|
public sent: Buffer[] = [];
|
|
public closed = false;
|
|
writeMessage(body: Buffer): boolean {
|
|
if (this.closed) throw new Error('closed');
|
|
this.sent.push(body);
|
|
return true;
|
|
}
|
|
close(): void {
|
|
if (this.closed) return;
|
|
this.closed = true;
|
|
this.emit('close');
|
|
}
|
|
|
|
/** Drive a response back to the client; for tests only. */
|
|
pushMessage(envelope: unknown): void {
|
|
this.emit('message', Buffer.from(JSON.stringify(envelope), 'utf8'));
|
|
}
|
|
}
|
|
|
|
function setup() {
|
|
const framed = new FakeFramed();
|
|
const client = new RpcClient(framed as unknown as import('../src/transport/framing').FramedDuplex);
|
|
return { framed, client };
|
|
}
|
|
|
|
describe('RpcClient', () => {
|
|
it('correlates a call with its response by id', async () => {
|
|
const { framed, client } = setup();
|
|
const pending = client.call('server.info', {});
|
|
expect(framed.sent.length).toBe(1);
|
|
const req = JSON.parse(framed.sent[0].toString('utf8')) as { id: number };
|
|
framed.pushMessage({ jsonrpc: '2.0', id: req.id, result: { version: '1.0.0', protocolVersion: 1, capabilities: [], vaultRoot: '/v' } });
|
|
const result = await pending;
|
|
expect(result.version).toBe('1.0.0');
|
|
});
|
|
|
|
it('rejects with RpcError when the response is an error envelope', async () => {
|
|
const { framed, client } = setup();
|
|
const pending = client.call('fs.stat', { path: 'missing.md' });
|
|
const req = JSON.parse(framed.sent[0].toString('utf8')) as { id: number };
|
|
framed.pushMessage({ jsonrpc: '2.0', id: req.id, error: { code: -32010, message: 'no such file' } });
|
|
|
|
await expect(pending).rejects.toBeInstanceOf(RpcError);
|
|
try {
|
|
await pending;
|
|
} catch (e) {
|
|
expect((e as RpcError).code).toBe(-32010);
|
|
expect((e as RpcError).is(-32010 as never)).toBe(true);
|
|
}
|
|
});
|
|
|
|
it('demultiplexes concurrent calls', async () => {
|
|
const { framed, client } = setup();
|
|
const p1 = client.call('fs.stat', { path: 'a.md' });
|
|
const p2 = client.call('fs.stat', { path: 'b.md' });
|
|
expect(framed.sent.length).toBe(2);
|
|
const [id1, id2] = framed.sent.map(b => (JSON.parse(b.toString('utf8')) as { id: number }).id);
|
|
// Deliver out of order.
|
|
framed.pushMessage({ jsonrpc: '2.0', id: id2, result: null });
|
|
framed.pushMessage({ jsonrpc: '2.0', id: id1, result: { type: 'file', mtime: 1, size: 0, mode: 0 } });
|
|
const [r1, r2] = await Promise.all([p1, p2]);
|
|
expect(r2).toBeNull();
|
|
expect(r1).not.toBeNull();
|
|
});
|
|
|
|
it('delivers notifications to registered handlers', () => {
|
|
const { framed, client } = setup();
|
|
const handler = vi.fn();
|
|
client.onNotification('fs.changed', handler);
|
|
framed.pushMessage({
|
|
jsonrpc: '2.0',
|
|
method: 'fs.changed',
|
|
params: { subscriptionId: 's1', path: 'a.md', event: 'modified', mtime: 99 },
|
|
});
|
|
expect(handler).toHaveBeenCalledTimes(1);
|
|
expect(handler.mock.calls[0][0]).toMatchObject({ path: 'a.md', event: 'modified' });
|
|
});
|
|
|
|
it('unregisters a notification handler via the returned disposer', () => {
|
|
const { framed, client } = setup();
|
|
const handler = vi.fn();
|
|
const off = client.onNotification('fs.changed', handler);
|
|
off();
|
|
framed.pushMessage({
|
|
jsonrpc: '2.0',
|
|
method: 'fs.changed',
|
|
params: { subscriptionId: 's1', path: 'a.md', event: 'modified' },
|
|
});
|
|
expect(handler).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('rejects pending calls when the stream closes', async () => {
|
|
const { framed, client } = setup();
|
|
const pending = client.call('fs.stat', { path: 'a.md' });
|
|
framed.close();
|
|
await expect(pending).rejects.toBeInstanceOf(RpcError);
|
|
});
|
|
|
|
it('fires onClose handlers with no error on a clean close', () => {
|
|
const { framed, client } = setup();
|
|
const cb = vi.fn();
|
|
client.onClose(cb);
|
|
framed.close();
|
|
expect(cb).toHaveBeenCalledTimes(1);
|
|
expect(cb.mock.calls[0][0]).toBeUndefined();
|
|
});
|
|
|
|
it('fires onClose handlers with the error on an abort', () => {
|
|
const { framed, client } = setup();
|
|
const cb = vi.fn();
|
|
client.onClose(cb);
|
|
framed.emit('error', new Error('boom'));
|
|
expect(cb).toHaveBeenCalledTimes(1);
|
|
expect((cb.mock.calls[0][0] as Error).message).toBe('boom');
|
|
});
|
|
|
|
it('rejects call() after close()', async () => {
|
|
const { client } = setup();
|
|
client.close();
|
|
await expect(client.call('server.info', {})).rejects.toBeInstanceOf(RpcError);
|
|
});
|
|
|
|
it('ignores malformed server responses without killing the session', async () => {
|
|
const { framed, client } = setup();
|
|
const pending = client.call('server.info', {});
|
|
const req = JSON.parse(framed.sent[0].toString('utf8')) as { id: number };
|
|
|
|
// Garbage message first — should be silently dropped.
|
|
framed.emit('message', Buffer.from('not-json-at-all', 'utf8'));
|
|
// Valid response afterwards — promise still resolves.
|
|
framed.pushMessage({ jsonrpc: '2.0', id: req.id, result: { version: '1.0.0', protocolVersion: 1, capabilities: [], vaultRoot: '' } });
|
|
await pending;
|
|
});
|
|
});
|