release(v4.1.0): browser-friendly HTTP RPC for @shade/files
Default shade.files.client(peer) requires both peers to be mutually
addressable over HTTP — the response round-trips through
Shade.deliverControlEnvelope (POST to peer's /v1/transfer/control).
Browser tabs can't host an HTTP server, so they couldn't consume
@shade/files at all. Dispatch's filutforsker (admin-panel browser UI)
is the canonical use-case.
This release adds a parallel request-response transport: one POST per
RPC, encrypted envelope in the body, encrypted response in the same
HTTP response. No inbound channel needed on the client.
### New API
- shade.files.rpcRoute(opts?) — Hono app exposing POST /rpc.
- shade.files.httpClient(peer, opts) — request-response FileClient.
- FilesNamespace.serve(handler, { inlineOnly: true }) — skip streams-
bridge (and its configureTransfers pre-condition); also skip
channel-based dispatch so requests aren't double-dispatched.
### Limitations (v1)
Inline only (≤ 256 KiB). Streamed reads/writes throw clear errors
directing to shade.files.client(peer) on a server-to-server deploy.
### Tests
7 integration tests in tests/integration/http-rpc.test.ts covering
round-trip + negative cases (sender header, empty/garbage body,
maxBodyBytes, rpcRoute-without-serve).
### Symmetry
Mirrors @shade/server's shade-auth-middleware: encrypted envelope in
request body, decrypted via existing ratchet, response in same HTTP
roundtrip. No WebSocket, no SSE, no outbound from server.
Wire-compatible. Source-compatible. Lockstep bump to 4.1.0.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
321
packages/shade-files/tests/integration/http-rpc.test.ts
Normal file
321
packages/shade-files/tests/integration/http-rpc.test.ts
Normal file
@@ -0,0 +1,321 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import { createShade } from '@shade/sdk';
|
||||
import {
|
||||
createPrekeyServer,
|
||||
MemoryPrekeyStore,
|
||||
PrekeyServerEvents,
|
||||
} from '@shade/server';
|
||||
import { SubtleCryptoProvider } from '@shade/crypto-web';
|
||||
|
||||
const crypto = new SubtleCryptoProvider();
|
||||
|
||||
/**
|
||||
* Stand up a prekey server + two Shades + Bob's file handler + RPC route
|
||||
* mounted on Bun.serve, then return Alice's HTTP-only `FileClient`.
|
||||
*
|
||||
* Mirrors the request-response setup a browser client would use against a
|
||||
* Bun-style server.
|
||||
*/
|
||||
async function setupHttpRig(opts: {
|
||||
bobHandler: Parameters<NonNullable<Awaited<ReturnType<typeof createShade>>['files']>['serve']>[0];
|
||||
}) {
|
||||
// 1. Prekey server.
|
||||
const prekeyEvents = new PrekeyServerEvents();
|
||||
const prekey = createPrekeyServer({
|
||||
crypto,
|
||||
store: new MemoryPrekeyStore(),
|
||||
disableRateLimit: true,
|
||||
events: prekeyEvents,
|
||||
});
|
||||
const prekeyServer = Bun.serve({ port: 0, fetch: prekey.fetch });
|
||||
const prekeyUrl = `http://localhost:${prekeyServer.port}`;
|
||||
|
||||
// 2. Two Shades. Alice plays the browser client (no transferRoute);
|
||||
// Bob is the server.
|
||||
const alice = await createShade({ prekeyServer: prekeyUrl, address: 'alice' });
|
||||
const bob = await createShade({ prekeyServer: prekeyUrl, address: 'bob' });
|
||||
|
||||
// 3. Bob: register file handler (HTTP-only — no streams) + mount
|
||||
// the RPC route.
|
||||
await bob.files.serve(opts.bobHandler, { inlineOnly: true });
|
||||
const rpcRoute = bob.files.rpcRoute({ acceptFirstMessage: true });
|
||||
const bobServer = Bun.serve({ port: 0, fetch: rpcRoute.fetch });
|
||||
const rpcUrl = `http://localhost:${bobServer.port}/rpc`;
|
||||
|
||||
// 5. Alice: build the HTTP-only file client.
|
||||
const fs = alice.files.httpClient('bob', { rpcUrl, defaultTimeoutMs: 5000 });
|
||||
|
||||
return {
|
||||
alice,
|
||||
bob,
|
||||
fs,
|
||||
rpcUrl,
|
||||
teardown: async () => {
|
||||
await alice.shutdown();
|
||||
await bob.shutdown();
|
||||
bobServer.stop();
|
||||
prekeyServer.stop();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe('@shade/files HTTP RPC — round-trip', () => {
|
||||
test('list → mkdir → stat → write inline → read inline → delete via httpClient', async () => {
|
||||
interface VfsEntry {
|
||||
kind: 'file' | 'dir';
|
||||
bytes?: Uint8Array;
|
||||
contentType?: string;
|
||||
}
|
||||
const vfs = new Map<string, VfsEntry>([
|
||||
['/', { kind: 'dir' }],
|
||||
['/photos', { kind: 'dir' }],
|
||||
]);
|
||||
|
||||
const rig = await setupHttpRig({
|
||||
bobHandler: {
|
||||
list: async (ctx) => {
|
||||
const prefix = ctx.path.endsWith('/') ? ctx.path : `${ctx.path}/`;
|
||||
const entries = Array.from(vfs.entries())
|
||||
.filter(([p]) => p.startsWith(prefix) && p !== ctx.path && !p.slice(prefix.length).includes('/'))
|
||||
.map(([p, e]) => ({
|
||||
name: p.slice(prefix.length) || p,
|
||||
kind: e.kind,
|
||||
size: e.bytes?.byteLength ?? 0,
|
||||
mtime: 0,
|
||||
metadata: {},
|
||||
}));
|
||||
return { entries, hasMore: false };
|
||||
},
|
||||
stat: async (ctx) => {
|
||||
const e = vfs.get(ctx.path);
|
||||
if (!e) throw new (await import('../../src/index.js')).NotFoundError(`stat ${ctx.path}`);
|
||||
return {
|
||||
name: ctx.path.split('/').pop() ?? ctx.path,
|
||||
kind: e.kind,
|
||||
size: e.bytes?.byteLength ?? 0,
|
||||
mtime: 0,
|
||||
metadata: {},
|
||||
...(e.contentType !== undefined ? { contentType: e.contentType } : {}),
|
||||
};
|
||||
},
|
||||
mkdir: async (ctx) => {
|
||||
vfs.set(ctx.path, { kind: 'dir' });
|
||||
return { entry: { name: ctx.path.split('/').pop() ?? ctx.path, kind: 'dir' as const, size: 0, mtime: 0, metadata: {} } };
|
||||
},
|
||||
delete: async (ctx) => {
|
||||
if (!vfs.has(ctx.path)) {
|
||||
throw new (await import('../../src/index.js')).NotFoundError(`delete ${ctx.path}`);
|
||||
}
|
||||
vfs.delete(ctx.path);
|
||||
return { deletedCount: 1 };
|
||||
},
|
||||
read: async (ctx) => {
|
||||
const e = vfs.get(ctx.path);
|
||||
if (!e || e.kind !== 'file' || !e.bytes) {
|
||||
throw new (await import('../../src/index.js')).NotFoundError(`read ${ctx.path}`);
|
||||
}
|
||||
// Omit sha256 — dispatcher computes it from the bytes.
|
||||
return {
|
||||
kind: 'inline' as const,
|
||||
bytes: e.bytes,
|
||||
...(e.contentType !== undefined ? { contentType: e.contentType } : {}),
|
||||
};
|
||||
},
|
||||
write: async (ctx) => {
|
||||
if (ctx.args.content.kind !== 'inline') {
|
||||
throw new (await import('../../src/index.js')).ConflictError('streams not supported in this test handler');
|
||||
}
|
||||
vfs.set(ctx.args.path, {
|
||||
kind: 'file',
|
||||
bytes: ctx.args.content.bytes,
|
||||
...(ctx.args.contentType !== undefined ? { contentType: ctx.args.contentType } : {}),
|
||||
});
|
||||
return {
|
||||
entry: {
|
||||
name: ctx.args.path.split('/').pop() ?? ctx.args.path,
|
||||
kind: 'file' as const,
|
||||
size: ctx.args.content.bytes.byteLength,
|
||||
mtime: 0,
|
||||
metadata: {},
|
||||
...(ctx.args.contentType !== undefined ? { contentType: ctx.args.contentType } : {}),
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
// list
|
||||
const listed = await rig.fs.list('/');
|
||||
expect(listed.entries.map((e) => e.name).sort()).toContain('photos');
|
||||
|
||||
// mkdir
|
||||
await rig.fs.mkdir('/docs');
|
||||
const stat = await rig.fs.stat('/docs');
|
||||
expect(stat.kind).toBe('dir');
|
||||
|
||||
// write inline
|
||||
const payload = new TextEncoder().encode('hello browser-friendly world');
|
||||
const writeResult = await rig.fs.write('/docs/greeting.txt', payload, {
|
||||
contentType: 'text/plain',
|
||||
});
|
||||
expect(writeResult.entry.size).toBe(payload.byteLength);
|
||||
|
||||
// read inline
|
||||
const readResult = await rig.fs.read('/docs/greeting.txt');
|
||||
expect(readResult.kind).toBe('inline');
|
||||
if (readResult.kind === 'inline') {
|
||||
expect(new TextDecoder().decode(readResult.bytes)).toBe('hello browser-friendly world');
|
||||
expect(readResult.contentType).toBe('text/plain');
|
||||
}
|
||||
|
||||
// delete
|
||||
const del = await rig.fs.delete('/docs/greeting.txt');
|
||||
expect(del.deletedCount).toBe(1);
|
||||
|
||||
// stat the deleted path → typed NotFoundError
|
||||
const { NotFoundError } = await import('../../src/index.js');
|
||||
await expect(rig.fs.stat('/docs/greeting.txt')).rejects.toBeInstanceOf(NotFoundError);
|
||||
} finally {
|
||||
await rig.teardown();
|
||||
}
|
||||
});
|
||||
|
||||
test('streamed write (> 256 KiB) is rejected with a clear error', async () => {
|
||||
const rig = await setupHttpRig({
|
||||
bobHandler: {
|
||||
write: async () => ({
|
||||
entry: { name: 'unused', kind: 'file' as const, size: 0, mtime: 0, metadata: {} },
|
||||
}),
|
||||
},
|
||||
});
|
||||
try {
|
||||
const big = new Uint8Array(257 * 1024);
|
||||
const { ConflictError } = await import('../../src/index.js');
|
||||
await expect(rig.fs.write('/big.bin', big)).rejects.toBeInstanceOf(ConflictError);
|
||||
} finally {
|
||||
await rig.teardown();
|
||||
}
|
||||
});
|
||||
|
||||
test('rpcRoute() throws when no handler is attached', async () => {
|
||||
// Don't call shade.files.serve(...) — rpcRoute() should refuse.
|
||||
const prekeyEvents = new PrekeyServerEvents();
|
||||
const prekey = createPrekeyServer({
|
||||
crypto,
|
||||
store: new MemoryPrekeyStore(),
|
||||
disableRateLimit: true,
|
||||
events: prekeyEvents,
|
||||
});
|
||||
const prekeyServer = Bun.serve({ port: 0, fetch: prekey.fetch });
|
||||
const bob = await createShade({
|
||||
prekeyServer: `http://localhost:${prekeyServer.port}`,
|
||||
address: 'bob',
|
||||
});
|
||||
try {
|
||||
expect(() => bob.files.rpcRoute()).toThrow(/no handler attached/);
|
||||
} finally {
|
||||
await bob.shutdown();
|
||||
prekeyServer.stop();
|
||||
}
|
||||
});
|
||||
|
||||
test('missing X-Shade-Sender-Address header → 400', async () => {
|
||||
const rig = await setupHttpRig({
|
||||
bobHandler: {
|
||||
stat: async () => ({
|
||||
name: '_', kind: 'dir' as const, size: 0, mtime: 0, metadata: {},
|
||||
}),
|
||||
},
|
||||
});
|
||||
try {
|
||||
const res = await fetch(rig.rpcUrl, {
|
||||
method: 'POST',
|
||||
body: new Uint8Array([0]),
|
||||
});
|
||||
expect(res.status).toBe(400);
|
||||
const body = (await res.json()) as { error: string };
|
||||
expect(body.error).toMatch(/X-Shade-Sender-Address/);
|
||||
} finally {
|
||||
await rig.teardown();
|
||||
}
|
||||
});
|
||||
|
||||
test('empty body → 400', async () => {
|
||||
const rig = await setupHttpRig({
|
||||
bobHandler: {
|
||||
stat: async () => ({
|
||||
name: '_', kind: 'dir' as const, size: 0, mtime: 0, metadata: {},
|
||||
}),
|
||||
},
|
||||
});
|
||||
try {
|
||||
const res = await fetch(rig.rpcUrl, {
|
||||
method: 'POST',
|
||||
headers: { 'X-Shade-Sender-Address': 'alice' },
|
||||
});
|
||||
expect(res.status).toBe(400);
|
||||
} finally {
|
||||
await rig.teardown();
|
||||
}
|
||||
});
|
||||
|
||||
test('garbage body → 401 decrypt failure', async () => {
|
||||
const rig = await setupHttpRig({
|
||||
bobHandler: {
|
||||
stat: async () => ({
|
||||
name: '_', kind: 'dir' as const, size: 0, mtime: 0, metadata: {},
|
||||
}),
|
||||
},
|
||||
});
|
||||
try {
|
||||
const res = await fetch(rig.rpcUrl, {
|
||||
method: 'POST',
|
||||
headers: { 'X-Shade-Sender-Address': 'alice' },
|
||||
body: new Uint8Array([0x02, 0xff, 0xff, 0xff]),
|
||||
});
|
||||
// 400 from envelope decode failure or 401 from decrypt failure.
|
||||
expect([400, 401]).toContain(res.status);
|
||||
} finally {
|
||||
await rig.teardown();
|
||||
}
|
||||
});
|
||||
|
||||
test('body past maxBodyBytes → 413', async () => {
|
||||
const prekeyEvents = new PrekeyServerEvents();
|
||||
const prekey = createPrekeyServer({
|
||||
crypto,
|
||||
store: new MemoryPrekeyStore(),
|
||||
disableRateLimit: true,
|
||||
events: prekeyEvents,
|
||||
});
|
||||
const prekeyServer = Bun.serve({ port: 0, fetch: prekey.fetch });
|
||||
const bob = await createShade({
|
||||
prekeyServer: `http://localhost:${prekeyServer.port}`,
|
||||
address: 'bob',
|
||||
});
|
||||
await bob.files.serve(
|
||||
{
|
||||
stat: async () => ({
|
||||
name: '_', kind: 'dir' as const, size: 0, mtime: 0, metadata: {},
|
||||
}),
|
||||
},
|
||||
{ inlineOnly: true },
|
||||
);
|
||||
const route = bob.files.rpcRoute({ maxBodyBytes: 1024 });
|
||||
const server = Bun.serve({ port: 0, fetch: route.fetch });
|
||||
try {
|
||||
const big = new Uint8Array(2048);
|
||||
const res = await fetch(`http://localhost:${server.port}/rpc`, {
|
||||
method: 'POST',
|
||||
headers: { 'X-Shade-Sender-Address': 'alice' },
|
||||
body: big,
|
||||
});
|
||||
expect(res.status).toBe(413);
|
||||
} finally {
|
||||
await bob.shutdown();
|
||||
server.stop();
|
||||
prekeyServer.stop();
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user