/** * 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 { Shade } from '@shade/sdk'; import { attachClientRouting, attachFileHandler, createClientStreamsBridge, createFileClient, createFileHandler, createServerStreamsBridge, PendingRpcRegistry, ShadeFileRpcChannel, type ClientStreamsBridge, type CreateFileClientOptions, type FileClient, type FileHandler, type FileHandlerConfig, type ServerStreamsBridge, } from '../index.js'; import { IdempotencyCache } from '../server/idempotency-cache.js'; 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. */ serve(handler: FileHandlerConfig): Promise<() => Promise>; /** * Build a typed file client for `peer`. Multiple concurrent clients to * different peers share the same channel + streams bridge. */ client(peer: string, opts?: Omit): Promise; /** 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: Shade): 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) { 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) { state.serverBridge = await createServerStreamsBridge(shade); } const handler = createFileHandler(shade, { ...handlerConfig, streamsBridge: state.serverBridge, }); const 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, }); }, 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')); }, }; }