mirror of
https://github.com/sotashimozono/obsidian-remote-ssh.git
synced 2026-07-22 06:52:07 +00:00
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>
254 lines
9.6 KiB
TypeScript
254 lines
9.6 KiB
TypeScript
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');
|
|
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);
|
|
});
|
|
|
|
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);
|
|
});
|
|
|
|
// ─── 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);
|
|
});
|
|
});
|