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>
237 lines
8.7 KiB
TypeScript
237 lines
8.7 KiB
TypeScript
/**
|
|
* High-level `FilesNamespace` — the entry point that the SDK exposes via
|
|
* `Shade.files`. Memoizes the underlying `ShadeFileRpcChannel` and bridges
|
|
* 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,
|
|
attachFileHandler,
|
|
createClientStreamsBridge,
|
|
createFileClient,
|
|
createFileHandler,
|
|
createFilesHttpClient,
|
|
createFilesRpcRoute,
|
|
createServerStreamsBridge,
|
|
PendingRpcRegistry,
|
|
ShadeFileRpcChannel,
|
|
type ClientStreamsBridge,
|
|
type CreateFileClientOptions,
|
|
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,
|
|
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>;
|
|
}
|
|
|
|
interface NamespaceState {
|
|
channel: ShadeFileRpcChannel;
|
|
pending: PendingRpcRegistry;
|
|
serverBridge: ServerStreamsBridge | null;
|
|
clientBridge: ClientStreamsBridge | null;
|
|
serverHandler: FileHandler | null;
|
|
serverDetach: (() => void) | null;
|
|
clientDetach: (() => void) | null;
|
|
destroyed: boolean;
|
|
}
|
|
|
|
/**
|
|
* Construct a `FilesNamespace` bound to a Shade instance. The SDK's
|
|
* `Shade.files` getter calls this lazily and memoizes the result.
|
|
*/
|
|
export function createFilesNamespace(shade: ShadeBridge): FilesNamespace {
|
|
const state: NamespaceState = {
|
|
channel: new ShadeFileRpcChannel(shade),
|
|
pending: new PendingRpcRegistry(),
|
|
serverBridge: null,
|
|
clientBridge: null,
|
|
serverHandler: null,
|
|
serverDetach: null,
|
|
clientDetach: null,
|
|
destroyed: false,
|
|
};
|
|
|
|
function ensureAlive(): void {
|
|
if (state.destroyed) throw new Error('FilesNamespace: destroyed');
|
|
}
|
|
|
|
return {
|
|
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 — 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,
|
|
...(state.serverBridge !== null
|
|
? { streamsBridge: state.serverBridge }
|
|
: {}),
|
|
...(handlerConfig.observability === undefined && inheritedObservability !== undefined
|
|
? { observability: inheritedObservability }
|
|
: {}),
|
|
});
|
|
// 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;
|
|
|
|
// Wire BackgroundHooks.onPruneFiles to the new handler's idempotency
|
|
// cache. Use the symbol-exposed internals (works because FileHandler
|
|
// attaches them via Object.assign).
|
|
const internals = (handler as unknown as { [k: symbol]: { idempotency: IdempotencyCache } })[
|
|
Symbol.for('@shade/files/internal')
|
|
];
|
|
const background = (shade as unknown as { background?: { setHook?: (n: string, f: () => void) => void } }).background;
|
|
if (background?.setHook !== undefined && internals !== undefined) {
|
|
background.setHook('onPruneFiles', () => {
|
|
internals.idempotency.prune();
|
|
});
|
|
}
|
|
|
|
return async () => {
|
|
if (state.serverDetach !== null) state.serverDetach();
|
|
state.serverHandler = null;
|
|
state.serverDetach = null;
|
|
if (background?.setHook !== undefined) {
|
|
background.setHook('onPruneFiles', undefined as unknown as () => void);
|
|
}
|
|
};
|
|
},
|
|
|
|
async client(peer, opts = {}) {
|
|
ensureAlive();
|
|
// Lazy client-side streams bridge.
|
|
if (state.clientBridge === null) {
|
|
state.clientBridge = await createClientStreamsBridge(shade);
|
|
}
|
|
// Attach response routing once.
|
|
if (state.clientDetach === null) {
|
|
state.clientDetach = attachClientRouting(state.channel, state.pending);
|
|
}
|
|
return createFileClient(shade, state.channel, state.pending, peer, {
|
|
...opts,
|
|
streamsBridge: state.clientBridge,
|
|
});
|
|
},
|
|
|
|
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;
|
|
if (state.serverDetach !== null) state.serverDetach();
|
|
if (state.clientDetach !== null) state.clientDetach();
|
|
if (state.serverHandler !== null) state.serverHandler.destroy();
|
|
if (state.serverBridge !== null) await state.serverBridge.destroy();
|
|
if (state.clientBridge !== null) await state.clientBridge.destroy();
|
|
state.channel.destroy();
|
|
state.pending.rejectAll(new Error('FilesNamespace destroyed'));
|
|
},
|
|
};
|
|
}
|