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:
@@ -4,6 +4,7 @@
|
||||
* so a single Shade can simultaneously serve files AND consume them from
|
||||
* peers without paying the setup cost twice.
|
||||
*/
|
||||
import type { Hono } from 'hono';
|
||||
import type { ShadeBridge } from './shade-bridge.js';
|
||||
import {
|
||||
attachClientRouting,
|
||||
@@ -11,6 +12,8 @@ import {
|
||||
createClientStreamsBridge,
|
||||
createFileClient,
|
||||
createFileHandler,
|
||||
createFilesHttpClient,
|
||||
createFilesRpcRoute,
|
||||
createServerStreamsBridge,
|
||||
PendingRpcRegistry,
|
||||
ShadeFileRpcChannel,
|
||||
@@ -19,22 +22,79 @@ import {
|
||||
type FileClient,
|
||||
type FileHandler,
|
||||
type FileHandlerConfig,
|
||||
type FilesHttpClientOptions,
|
||||
type FilesRpcRouteOptions,
|
||||
type ServerStreamsBridge,
|
||||
} from '../index.js';
|
||||
import { IdempotencyCache } from '../server/idempotency-cache.js';
|
||||
|
||||
export interface ServeOptions {
|
||||
/**
|
||||
* Skip the streams bridge setup. Required for deployments that only
|
||||
* use the HTTP RPC route ({@link FilesNamespace.rpcRoute}) — those
|
||||
* deployments don't need to configure `@shade/transfer` because the
|
||||
* RPC route only services inline payloads (≤ 256 KiB). Without this
|
||||
* flag, `serve()` calls `createServerStreamsBridge(shade)` which
|
||||
* eagerly instantiates the transfer engine and fails when
|
||||
* `configureTransfers({ resolveBaseUrl })` has not been called.
|
||||
*
|
||||
* Default: `false` (build the streams bridge — full server-to-server
|
||||
* stack with streamed reads/writes).
|
||||
*/
|
||||
inlineOnly?: boolean;
|
||||
}
|
||||
|
||||
export interface FilesNamespace {
|
||||
/**
|
||||
* Register a file handler. Throws if a handler is already attached on
|
||||
* this Shade — only one server per Shade. The returned function detaches
|
||||
* the handler and tears down its idempotency / retention timers.
|
||||
*
|
||||
* Pass `{ inlineOnly: true }` for HTTP-RPC-only deployments to skip
|
||||
* the streams-bridge setup (and the implied `configureTransfers`
|
||||
* pre-condition).
|
||||
*/
|
||||
serve(handler: FileHandlerConfig): Promise<() => Promise<void>>;
|
||||
serve(
|
||||
handler: FileHandlerConfig,
|
||||
options?: ServeOptions,
|
||||
): Promise<() => Promise<void>>;
|
||||
/**
|
||||
* Build a typed file client for `peer`. Multiple concurrent clients to
|
||||
* different peers share the same channel + streams bridge.
|
||||
*
|
||||
* Use this for **server-to-server** deployments where both peers can
|
||||
* receive inbound HTTP. For browser clients (no inbound listener),
|
||||
* use {@link httpClient} instead.
|
||||
*/
|
||||
client(peer: string, opts?: Omit<CreateFileClientOptions, 'streamsBridge'>): Promise<FileClient>;
|
||||
/**
|
||||
* Build a request-response `FileClient` for browser-style consumers.
|
||||
* Each RPC is one HTTP POST to the supplied `rpcUrl`; the encrypted
|
||||
* response rides back in the same response body. No inbound channel
|
||||
* required on the client side.
|
||||
*
|
||||
* Inline payloads only (≤ 256 KiB). Streamed reads/writes throw a
|
||||
* clear error directing callers to {@link client} instead.
|
||||
*
|
||||
* Pre-condition: the session for `peer` must already be established
|
||||
* (typically via `shade.initSessionFromBundle(peer, bundle)`).
|
||||
*/
|
||||
httpClient(peer: string, opts: FilesHttpClientOptions): FileClient;
|
||||
/**
|
||||
* Mount the server-side request-response RPC route. Returns a Hono
|
||||
* app exposing `POST /rpc` that accepts encrypted file-RPC envelopes
|
||||
* and returns encrypted responses in the same HTTP roundtrip.
|
||||
*
|
||||
* Mount under any base path:
|
||||
* ```ts
|
||||
* app.route('/api/v1/shade-files', shade.files.rpcRoute());
|
||||
* ```
|
||||
*
|
||||
* Requires `shade.files.serve(...)` to have been called first —
|
||||
* the route dispatches incoming requests through the attached
|
||||
* handler.
|
||||
*/
|
||||
rpcRoute(opts?: FilesRpcRouteOptions): Hono;
|
||||
/** Tear down channel + bridges. After destroy(), serve()/client() throw. */
|
||||
destroy(): Promise<void>;
|
||||
}
|
||||
@@ -71,24 +131,39 @@ export function createFilesNamespace(shade: ShadeBridge): FilesNamespace {
|
||||
}
|
||||
|
||||
return {
|
||||
async serve(handlerConfig) {
|
||||
async serve(handlerConfig, options = {}) {
|
||||
ensureAlive();
|
||||
if (state.serverHandler !== null) {
|
||||
throw new Error('FilesNamespace: a handler is already registered (one per Shade)');
|
||||
}
|
||||
// Lazy server-side streams bridge.
|
||||
if (state.serverBridge === null) {
|
||||
// Lazy server-side streams bridge — skip when the deployment is
|
||||
// HTTP-RPC-only and does not need `@shade/transfer` wired up.
|
||||
if (!options.inlineOnly && state.serverBridge === null) {
|
||||
state.serverBridge = await createServerStreamsBridge(shade);
|
||||
}
|
||||
const inheritedObservability = shade.getObservability?.();
|
||||
const handler = createFileHandler(shade, {
|
||||
...handlerConfig,
|
||||
streamsBridge: state.serverBridge,
|
||||
...(state.serverBridge !== null
|
||||
? { streamsBridge: state.serverBridge }
|
||||
: {}),
|
||||
...(handlerConfig.observability === undefined && inheritedObservability !== undefined
|
||||
? { observability: inheritedObservability }
|
||||
: {}),
|
||||
});
|
||||
const detach = attachFileHandler(state.channel, handler);
|
||||
// In inlineOnly mode, the rpc-route is the sole inbound path —
|
||||
// do NOT also subscribe the channel's onMessage handler to this
|
||||
// file handler, because that would cause every incoming request
|
||||
// to be dispatched twice (once by the rpc-route's direct call,
|
||||
// once by the channel's onMessage handler) and the channel-side
|
||||
// response would attempt an outbound POST via
|
||||
// `deliverControlEnvelope`, which is exactly the path that fails
|
||||
// for browser clients.
|
||||
const detach: () => void = options.inlineOnly
|
||||
? () => {
|
||||
/* no channel subscription to detach */
|
||||
}
|
||||
: attachFileHandler(state.channel, handler);
|
||||
state.serverHandler = handler;
|
||||
state.serverDetach = detach;
|
||||
|
||||
@@ -131,6 +206,21 @@ export function createFilesNamespace(shade: ShadeBridge): FilesNamespace {
|
||||
});
|
||||
},
|
||||
|
||||
httpClient(peer, opts) {
|
||||
ensureAlive();
|
||||
return createFilesHttpClient(shade, peer, opts);
|
||||
},
|
||||
|
||||
rpcRoute(opts = {}) {
|
||||
ensureAlive();
|
||||
if (state.serverHandler === null) {
|
||||
throw new Error(
|
||||
'FilesNamespace.rpcRoute(): no handler attached. Call shade.files.serve(...) before mounting the RPC route.',
|
||||
);
|
||||
}
|
||||
return createFilesRpcRoute(shade, state.serverHandler, opts);
|
||||
},
|
||||
|
||||
async destroy() {
|
||||
if (state.destroyed) return;
|
||||
state.destroyed = true;
|
||||
|
||||
Reference in New Issue
Block a user