Files
Shade/packages/shade-files/src/integration/files-namespace.ts
Sterister da93b97cce
Some checks failed
Test / test (push) Has been cancelled
Docker build and publish / docker (push) Has been cancelled
Publish / publish (push) Has been cancelled
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>
2026-05-03 22:08:14 +02:00

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'));
},
};
}