143 lines
4.9 KiB
TypeScript
143 lines
4.9 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 { 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<void>>;
|
||
|
|
/**
|
||
|
|
* Build a typed file client for `peer`. Multiple concurrent clients to
|
||
|
|
* different peers share the same channel + streams bridge.
|
||
|
|
*/
|
||
|
|
client(peer: string, opts?: Omit<CreateFileClientOptions, 'streamsBridge'>): Promise<FileClient>;
|
||
|
|
/** 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: 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'));
|
||
|
|
},
|
||
|
|
};
|
||
|
|
}
|