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:
218
packages/shade-files/src/server/rpc-route.ts
Normal file
218
packages/shade-files/src/server/rpc-route.ts
Normal file
@@ -0,0 +1,218 @@
|
||||
/**
|
||||
* Request-response RPC route for `@shade/files`.
|
||||
*
|
||||
* Mounts a single `POST /rpc` Hono endpoint that accepts an encrypted
|
||||
* `RpcRequest` envelope, dispatches it through the file handler, and
|
||||
* returns the encrypted `RpcResponse` (or `RpcError`) envelope in the
|
||||
* SAME HTTP response.
|
||||
*
|
||||
* This is the browser-friendly transport: the server never needs to
|
||||
* make outbound calls back to the client, so a browser tab — which
|
||||
* cannot host an HTTP server — can fully consume `@shade/files`.
|
||||
*
|
||||
* ### Wire contract
|
||||
*
|
||||
* Request:
|
||||
* ```
|
||||
* POST <mount>/rpc HTTP/1.1
|
||||
* Content-Type: application/octet-stream
|
||||
* X-Shade-Sender-Address: <peer address>
|
||||
*
|
||||
* <wire-encoded ShadeEnvelope (0x01 PreKeyMessage or 0x02 RatchetMessage)
|
||||
* containing JSON-encoded RpcRequest>
|
||||
* ```
|
||||
*
|
||||
* Response (success):
|
||||
* ```
|
||||
* 200 OK
|
||||
* Content-Type: application/octet-stream
|
||||
*
|
||||
* <wire-encoded ShadeEnvelope (0x02 RatchetMessage)
|
||||
* containing JSON-encoded RpcResponse | RpcError>
|
||||
* ```
|
||||
*
|
||||
* Response (transport-level failure — no session, undecryptable, etc.):
|
||||
* ```
|
||||
* 4xx
|
||||
* Content-Type: application/json
|
||||
*
|
||||
* { "error": "..." }
|
||||
* ```
|
||||
*
|
||||
* ### Symmetry with shade-auth-middleware
|
||||
*
|
||||
* The shape mirrors `@shade/server`'s shade-auth-middleware: an
|
||||
* encrypted envelope rides the request body, the server decrypts via
|
||||
* the existing ratchet session, performs the protected operation,
|
||||
* and returns an encrypted envelope in the response. No bidirectional
|
||||
* channel required.
|
||||
*
|
||||
* @see {@link createFilesHttpClient} for the matching browser client.
|
||||
*/
|
||||
import { Hono } from 'hono';
|
||||
import { decodeEnvelope, encodeEnvelope } from '@shade/proto';
|
||||
import type { ShadeBridge } from '../integration/shade-bridge.js';
|
||||
import {
|
||||
encodeEnvelope as encodeRpcEnvelope,
|
||||
tryParseEnvelope,
|
||||
} from '../protocol/envelope-codec.js';
|
||||
import type { FileHandler } from './handler.js';
|
||||
import type { RpcError, RpcRequest, RpcResponse } from '../schemas/envelope.js';
|
||||
import { KIND_ERROR_V1 } from '../protocol/kinds.js';
|
||||
|
||||
export interface FilesRpcRouteOptions {
|
||||
/**
|
||||
* Maximum request body size in bytes. Default 1 MiB. Inline payloads
|
||||
* are capped at 256 KiB by the protocol; the headroom is for
|
||||
* custom-op payloads and base64 inflation.
|
||||
*/
|
||||
maxBodyBytes?: number;
|
||||
/**
|
||||
* Allow this server to accept the very first message (PreKeyMessage,
|
||||
* `0x01`) over the RPC route. Disabled by default — most browser
|
||||
* clients establish a session via `shade.initSessionFromBundle`
|
||||
* before the first RPC. Enable when you want the RPC route to also
|
||||
* be the X3DH carrier (uncommon but supported).
|
||||
*/
|
||||
acceptFirstMessage?: boolean;
|
||||
}
|
||||
|
||||
const DEFAULT_MAX_BODY_BYTES = 1 * 1024 * 1024;
|
||||
|
||||
/**
|
||||
* Build a Hono app with a single `POST /rpc` route. Mount under any
|
||||
* base path: `app.route('/api/v1/shade-files', shade.files.rpcRoute())`.
|
||||
*
|
||||
* The `handler` must already be attached (typically via
|
||||
* `shade.files.serve(handlerConfig)`); this route only ships the
|
||||
* transport — it does not register a new file handler.
|
||||
*/
|
||||
export function createFilesRpcRoute(
|
||||
shade: ShadeBridge,
|
||||
handler: FileHandler,
|
||||
options: FilesRpcRouteOptions = {},
|
||||
): Hono {
|
||||
const maxBodyBytes = options.maxBodyBytes ?? DEFAULT_MAX_BODY_BYTES;
|
||||
const app = new Hono();
|
||||
|
||||
app.post('/rpc', async (c) => {
|
||||
const senderAddress = c.req.header('X-Shade-Sender-Address');
|
||||
if (senderAddress === undefined || senderAddress === '') {
|
||||
return c.json({ error: 'missing X-Shade-Sender-Address header' }, 400);
|
||||
}
|
||||
|
||||
const contentLengthHeader = c.req.header('Content-Length');
|
||||
if (contentLengthHeader !== undefined) {
|
||||
const contentLength = Number.parseInt(contentLengthHeader, 10);
|
||||
if (Number.isFinite(contentLength) && contentLength > maxBodyBytes) {
|
||||
return c.json(
|
||||
{ error: `body exceeds maxBodyBytes (${contentLength} > ${maxBodyBytes})` },
|
||||
413,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
let bodyBytes: Uint8Array;
|
||||
try {
|
||||
const ab = await c.req.arrayBuffer();
|
||||
if (ab.byteLength > maxBodyBytes) {
|
||||
return c.json(
|
||||
{ error: `body exceeds maxBodyBytes (${ab.byteLength} > ${maxBodyBytes})` },
|
||||
413,
|
||||
);
|
||||
}
|
||||
bodyBytes = new Uint8Array(ab);
|
||||
} catch (err) {
|
||||
return c.json({ error: `failed to read request body: ${(err as Error).message}` }, 400);
|
||||
}
|
||||
|
||||
if (bodyBytes.byteLength === 0) {
|
||||
return c.json({ error: 'empty request body' }, 400);
|
||||
}
|
||||
|
||||
// Decode the wire envelope. `decodeEnvelope` handles both `0x01`
|
||||
// PreKeyMessage and `0x02` RatchetMessage shapes.
|
||||
let plaintext: string;
|
||||
try {
|
||||
const envelope = decodeEnvelope(bodyBytes);
|
||||
// First-message gate: only allow `prekey` envelopes when the
|
||||
// operator has explicitly opted in.
|
||||
if (options.acceptFirstMessage !== true && envelope.type === 'prekey') {
|
||||
return c.json(
|
||||
{
|
||||
error:
|
||||
'PreKeyMessage envelopes are not accepted on this RPC route — establish the session first via shade.initSessionFromBundle, or set acceptFirstMessage: true',
|
||||
},
|
||||
400,
|
||||
);
|
||||
}
|
||||
plaintext = await shade.receive(senderAddress, envelope);
|
||||
} catch (err) {
|
||||
// Decryption failure — could be no session, corrupted envelope,
|
||||
// or sender address mismatch. Treat as 401 since the envelope is
|
||||
// self-authenticating: a valid sender would decrypt cleanly.
|
||||
return c.json({ error: `decrypt failed: ${(err as Error).message}` }, 401);
|
||||
}
|
||||
|
||||
// Parse the plaintext as an RpcRequest.
|
||||
const classified = tryParseEnvelope(plaintext);
|
||||
if (classified === null) {
|
||||
return c.json({ error: 'plaintext is not a valid @shade/files envelope' }, 400);
|
||||
}
|
||||
if (classified.kind !== 'request') {
|
||||
// Cancel envelopes are silently dropped — RPC route is request/
|
||||
// response only. Cancellation across HTTP is achieved via
|
||||
// AbortController on the client side, not protocol-level.
|
||||
if (classified.kind === 'cancel') {
|
||||
handler.handleCancel(senderAddress, classified.envelope);
|
||||
// No response body — the cancel was best-effort.
|
||||
return new Response(null, { status: 204 });
|
||||
}
|
||||
return c.json(
|
||||
{ error: `unexpected envelope kind on RPC route: ${classified.kind}` },
|
||||
400,
|
||||
);
|
||||
}
|
||||
|
||||
const request: RpcRequest = classified.envelope;
|
||||
|
||||
// Dispatch through the file handler.
|
||||
let result: RpcResponse | RpcError;
|
||||
try {
|
||||
result = await handler.handleRequest(senderAddress, request);
|
||||
} catch (err) {
|
||||
// Should never happen — handler.handleRequest catches its own
|
||||
// errors and returns RpcError. If it didn't, that's a bug; emit
|
||||
// a generic transport-level RpcError so the client can surface
|
||||
// it deterministically.
|
||||
result = {
|
||||
kind: KIND_ERROR_V1,
|
||||
id: request.id,
|
||||
error: {
|
||||
code: 'INTERNAL',
|
||||
message: `handler raised: ${(err as Error).message}`,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// Encrypt the response and return it as wire bytes.
|
||||
let responseBytes: Uint8Array;
|
||||
try {
|
||||
const responsePlaintext = encodeRpcEnvelope(result);
|
||||
const responseEnvelope = await shade.send(senderAddress, responsePlaintext);
|
||||
responseBytes = encodeEnvelope(responseEnvelope);
|
||||
} catch (err) {
|
||||
return c.json(
|
||||
{ error: `failed to encrypt response: ${(err as Error).message}` },
|
||||
500,
|
||||
);
|
||||
}
|
||||
|
||||
return new Response(new Blob([responseBytes as unknown as ArrayBuffer]), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/octet-stream' },
|
||||
});
|
||||
});
|
||||
|
||||
return app;
|
||||
}
|
||||
Reference in New Issue
Block a user