/** * 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>; /** * 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): Promise; /** * 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; } 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')); }, }; }