/** * Browser-friendly request-response `FileClient` for `@shade/files`. * * The default `shade.files.client(peer)` ships RPC envelopes via * `Shade.send` + `Shade.deliverControlEnvelope`, which means the * server has to be able to call back outbound to the client. That * doesn't work for browser tabs (no inbound HTTP listener). This * client posts each RPC envelope to a single server endpoint and * reads the encrypted response from the same HTTP response — pure * request-response, no inbound channel required. * * Inline payloads only (≤ 256 KiB). For larger reads/writes, use the * stateful path: `shade.files.client(peer)` server-to-server, with * `@shade/transfer` chunk routes for content I/O. * * @see {@link createFilesRpcRoute} for the matching server-side route. */ import type { ZodTypeAny } from 'zod'; import { decodeEnvelope, encodeEnvelope as encodeWireEnvelope } from '@shade/proto'; import type { ShadeBridge } from '../integration/shade-bridge.js'; import { encodeEnvelope as encodeRpcEnvelope, tryParseEnvelope, } from '../protocol/envelope-codec.js'; import { KIND_CUSTOM_V1, KIND_DELETE_V1, KIND_GET_THUMBNAIL_V1, KIND_LIST_V1, KIND_MKDIR_V1, KIND_MOVE_V1, KIND_READ_V1, KIND_STAT_V1, KIND_WRITE_V1, } from '../protocol/kinds.js'; import { CustomArgsSchema, CustomResultSchema, DeleteArgsSchema, DeleteResultSchema, GetThumbnailArgsSchema, GetThumbnailResultSchema, ListArgsSchema, ListResultSchema, MkdirArgsSchema, MkdirResultSchema, MoveArgsSchema, MoveResultSchema, ReadArgsSchema, ReadResultSchema, StatArgsSchema, StatResultSchema, WriteArgsSchema, WriteResultSchema, type ListResult, type MkdirResult, type DeleteResult, type MoveResult, type StatResult, type ThumbnailSize, type WriteResult, } from '../schemas/ops.js'; import { fileErrorFromPayload, CancelledError, InternalFileError, ConflictError, } from '../schemas/errors.js'; import { buildRpcRequest } from '../protocol/rpc-builder.js'; import { decideInline, INLINE_THRESHOLD, type WriteSource } from './inline-threshold.js'; import { base64ToBytes, bytesToBase64 } from '../protocol/canonical.js'; import { startQueueDrainer, type QueueDrainerHandle } from './queue-drainer.js'; import { createClientStreamsBridge, type ClientStreamsBridge, } from './streams-bridge.js'; import type { FileClient, ReadOpts, ReadOutput, ThumbnailResult, WriteOpts, CreateFileClientOptions, BaseOpts, } from './client.js'; export interface FilesHttpClientOptions extends Omit { /** * Server endpoint that hosts `createFilesRpcRoute(...)`. Typically: * `https://server.example.com/api/v1/shade-files/rpc`. */ rpcUrl: string; /** * Optional `fetch` override. Defaults to `globalThis.fetch`. Wire a * custom `fetch` to thread auth-cookies, CSRF tokens, or * service-worker interception. */ fetch?: typeof globalThis.fetch; /** * Extra HTTP headers applied to every RPC POST. Useful for app-level * auth (CSRF, session cookies via custom header, etc.) — these are * orthogonal to the ratchet authentication on the envelope itself. */ headers?: Record; /** * Server endpoint that hosts `transferQueueRoute()`'s long-poll * endpoint. Typically: * `https://server.example.com/api/v1/shade-files/queue`. * * When supplied, the client starts a background long-poll that * drains queued envelopes + chunks from the server and dispatches * them via `shade.acceptTransferEnvelope`. This unlocks * **streamed reads** (>256 KiB) for browser-style consumers. */ outboundQueueUrl?: string; /** * Base URL for outbound transfer routes (browser → server). Required * alongside `outboundQueueUrl` to enable streamed writes. Typically: * `https://server.example.com/api/v1/shade-files`. * * The client POSTs: * - chunks to `/v1/transfer//chunk` * - control envelopes to `/v1/transfer/control` */ transferBaseUrl?: string; /** * Long-poll block timeout, milliseconds. Default 30_000. Server * clamps to its own `maxBlockMs` (default 55_000). */ queueBlockMs?: number; } interface RoundTripOpts { signal?: AbortSignal; timeoutMs?: number; idempotencyKey?: string; } /** * Create a request-response `FileClient` bound to `peerAddress` and a * server-side RPC URL. The session must already be established * (via `shade.initSessionFromBundle(peerAddress, bundle)` or an * incoming first-message). Otherwise the first RPC will fail with * "decrypt failed: no session for peer". * * When `outboundQueueUrl` + `transferBaseUrl` are supplied, the * client also unlocks **streamed reads/writes** for files larger than * the inline threshold (256 KiB). The browser polls the server's * outbound queue for chunks/envelopes and POSTs its own outbound * chunks to the server's transfer-receive routes. */ export function createFilesHttpClient( shade: ShadeBridge, peerAddress: string, options: FilesHttpClientOptions, ): FileClient { const rpcUrl = options.rpcUrl; const fetchFn = options.fetch ?? globalThis.fetch.bind(globalThis); const extraHeaders = options.headers ?? {}; const defaultTimeoutMs = options.defaultTimeoutMs ?? 30_000; const ioTimeoutMs = options.ioTimeoutMs ?? 60_000; const signRequest = options.signRequest; const senderAddress = shade.myAddress; // ─── Streamed-mode bootstrap ───────────────────────────────── // // When `outboundQueueUrl` is supplied, the client: // 1. Configures `shade.configureTransfers(...)` so outbound // chunks POST to `/v1/transfer//chunk` // and outbound control envelopes POST to // `/v1/transfer/control`. // 2. Spawns a streams-bridge so streamed reads can be awaited. // 3. Starts a long-poll drainer that pulls queued envelopes + // chunks from the server and dispatches via // `shade.acceptTransferEnvelope`. let drainer: QueueDrainerHandle | null = null; let streamsBridgePromise: Promise | null = null; let streamsBridge: ClientStreamsBridge | null = null; if (options.outboundQueueUrl !== undefined) { const outboundQueueUrl = options.outboundQueueUrl; if (options.transferBaseUrl === undefined) { throw new Error( 'createFilesHttpClient: outboundQueueUrl was supplied without transferBaseUrl. Pass `transferBaseUrl` (the server prefix that hosts /v1/transfer/...) so outbound chunks have a destination.', ); } if (shade.configureTransfers === undefined) { throw new Error( 'createFilesHttpClient: shade.configureTransfers is required for streamed mode (the underlying ShadeBridge must surface it).', ); } const transferBaseUrl = options.transferBaseUrl.replace(/\/$/, ''); shade.configureTransfers({ resolveBaseUrl: async (peer) => { if (peer !== peerAddress) { throw new Error( `httpClient is bound to peer "${peerAddress}" — refusing to resolve outgoing chunks for "${peer}" without a multi-peer registry. Use shade.files.client(peer) for server-to-server multi-peer.`, ); } return transferBaseUrl; }, }); // Build the streams-bridge eagerly. The engine's incoming-transfer // subscription has to be in place BEFORE the drainer dispatches the // first stream-init envelope, otherwise the engine emits the // IncomingTransfer to zero handlers and the read silently never // accepts. We kick off the drainer once the bridge has subscribed. streamsBridgePromise = createClientStreamsBridge(shade).then((bridge) => { streamsBridge = bridge; drainer = startQueueDrainer(shade, { outboundQueueUrl, peerAddress, senderAddress, ...(options.fetch !== undefined ? { fetch: options.fetch } : {}), ...(options.headers !== undefined ? { headers: options.headers } : {}), ...(options.queueBlockMs !== undefined ? { blockMs: options.queueBlockMs } : {}), }); return bridge; }); // Surface bridge-construction failures eagerly via a rejected // promise the next read/write picks up. streamsBridgePromise.catch(() => { /* observed via getStreamsBridge() */ }); } async function getStreamsBridge(): Promise { if (streamsBridge !== null) return streamsBridge; if (streamsBridgePromise === null) { throw new ConflictError( `http RPC client supports inline writes/reads only (≤ ${INLINE_THRESHOLD} bytes) — pass { outboundQueueUrl, transferBaseUrl } to enable streamed transfers.`, ); } streamsBridge = await streamsBridgePromise; return streamsBridge; } /** * Encrypt + POST + decrypt + parse one RPC round-trip. * * Throws a typed `FileError` subclass when the server returns an * encrypted `RpcError`, or `InternalFileError` for transport-level * failures (network, 4xx/5xx, malformed body). */ async function roundTrip( kind: string, op: 'list' | 'stat' | 'mkdir' | 'delete' | 'move' | 'read' | 'write' | 'getThumbnail' | 'custom', args: unknown, resultSchema: ZodTypeAny, opts: RoundTripOpts | undefined, ): Promise { const requestEnv = await buildRpcRequest({ senderAddress, kind, op, args, ...(opts?.idempotencyKey !== undefined ? { idempotencyKey: opts.idempotencyKey } : {}), ...(signRequest !== undefined ? { signRequest } : {}), }); const plaintext = encodeRpcEnvelope(requestEnv); const ratchetEnvelope = await shade.send(peerAddress, plaintext); const wireBytes = encodeWireEnvelope(ratchetEnvelope); const ac = new AbortController(); const timeoutMs = opts?.timeoutMs ?? defaultTimeoutMs; const timer = setTimeout( () => ac.abort(new Error(`RPC timeout after ${timeoutMs}ms`)), timeoutMs, ); (timer as unknown as { unref?: () => void }).unref?.(); if (opts?.signal !== undefined) { const userSignal = opts.signal; if (userSignal.aborted) ac.abort(userSignal.reason); else userSignal.addEventListener('abort', () => ac.abort(userSignal.reason), { once: true }); } let response: Response; try { // Wrap the wire bytes in a Blob so the body type satisfies the // common-denominator `BodyInit` across DOM, Bun, and node-fetch // (some runtimes accept `Uint8Array` directly, others don't). // Cast through `unknown` because TS's `bun-types` and `lib.dom` // disagree about whether `Uint8Array` is itself // a `BlobPart`; the runtime accepts it on every platform. response = await fetchFn(rpcUrl, { method: 'POST', body: new Blob([wireBytes as unknown as ArrayBuffer]), signal: ac.signal, headers: { 'Content-Type': 'application/octet-stream', 'X-Shade-Sender-Address': senderAddress, ...extraHeaders, }, }); } catch (err) { clearTimeout(timer); if ((err as Error).name === 'AbortError') { throw new CancelledError(`RPC ${kind} aborted: ${(err as Error).message}`); } throw new InternalFileError(`RPC ${kind} fetch failed: ${(err as Error).message}`); } clearTimeout(timer); if (!response.ok) { let body: { error?: string } | null = null; try { body = (await response.json()) as { error?: string }; } catch { /* server emitted non-JSON body */ } throw new InternalFileError( `RPC ${kind} → ${response.status} ${response.statusText}: ${ body?.error ?? '(no error body)' }`, ); } const ab = await response.arrayBuffer(); if (ab.byteLength === 0) { throw new InternalFileError(`RPC ${kind}: empty response body`); } let responseRatchet; try { responseRatchet = decodeEnvelope(new Uint8Array(ab)); } catch (err) { throw new InternalFileError( `RPC ${kind}: response body is not a valid wire envelope: ${(err as Error).message}`, ); } let responsePlaintext: string; try { responsePlaintext = await shade.receive(peerAddress, responseRatchet); } catch (err) { throw new InternalFileError( `RPC ${kind}: response decrypt failed: ${(err as Error).message}`, ); } const classified = tryParseEnvelope(responsePlaintext); if (classified === null) { throw new InternalFileError( `RPC ${kind}: response plaintext is not a valid @shade/files envelope`, ); } if (classified.kind === 'error') { throw fileErrorFromPayload(classified.envelope.error); } if (classified.kind !== 'response') { throw new InternalFileError( `RPC ${kind}: unexpected response envelope kind: ${classified.kind}`, ); } if (classified.envelope.id !== requestEnv.id) { throw new InternalFileError( `RPC ${kind}: response correlation id mismatch (got ${classified.envelope.id}, expected ${requestEnv.id})`, ); } return resultSchema.parse(classified.envelope.result) as TResult; } return { async list(path, opts): Promise { const args = ListArgsSchema.parse({ path, ...(opts?.cursor !== undefined ? { cursor: opts.cursor } : {}), ...(opts?.pageSize !== undefined ? { pageSize: opts.pageSize } : {}), ...(opts?.filter !== undefined ? { filter: opts.filter } : {}), }); return await roundTrip( KIND_LIST_V1, 'list', args, ListResultSchema, opts, ); }, async stat(path, opts): Promise { const args = StatArgsSchema.parse({ path }); return await roundTrip(KIND_STAT_V1, 'stat', args, StatResultSchema, opts); }, async mkdir(path, opts): Promise { const args = MkdirArgsSchema.parse({ path, ...(opts?.recursive !== undefined ? { recursive: opts.recursive } : {}), }); return await roundTrip( KIND_MKDIR_V1, 'mkdir', args, MkdirResultSchema, opts, ); }, async delete(path, opts): Promise { const args = DeleteArgsSchema.parse({ path, ...(opts?.recursive !== undefined ? { recursive: opts.recursive } : {}), }); return await roundTrip( KIND_DELETE_V1, 'delete', args, DeleteResultSchema, opts, ); }, async move(src, dst, opts): Promise { const args = MoveArgsSchema.parse({ src, dst, ...(opts?.overwrite !== undefined ? { overwrite: opts.overwrite } : {}), }); return await roundTrip(KIND_MOVE_V1, 'move', args, MoveResultSchema, opts); }, async read(path, opts: ReadOpts = {}): Promise { const args = ReadArgsSchema.parse({ path, ...(opts.range !== undefined ? { range: opts.range } : {}), ...(opts.preferInline !== undefined ? { preferInline: opts.preferInline } : {}), }); const wire = await roundTrip( KIND_READ_V1, 'read', args, ReadResultSchema, opts, ); if (wire.kind === 'inline') { const bytes = base64ToBytes(wire.bytesB64); const out: ReadOutput = { kind: 'inline', bytes, size: wire.size, sha256: wire.sha256, ...(wire.contentType !== undefined ? { contentType: wire.contentType } : {}), }; return out; } // Streamed read — only supported when the queue drainer is wired. if (drainer === null) { throw new InternalFileError( `http RPC client received a streamed read (size ${wire.size}) but is in inline-only mode. Pass { outboundQueueUrl, transferBaseUrl } when constructing the client to enable streamed reads.`, ); } const bridge = await getStreamsBridge(); const bridgeSignal = opts.signal ?? new AbortController().signal; const parked = await bridge.awaitRead(wire.streamId, { expectedFrom: peerAddress, signal: bridgeSignal, timeoutMs: ioTimeoutMs, }); const out: ReadOutput = { kind: 'streams', stream: parked.readable, size: wire.size, sha256: wire.sha256, ...(wire.contentType !== undefined ? { contentType: wire.contentType } : {}), done: async () => { await parked.done; }, }; return out; }, async write(path, input: WriteSource, opts: WriteOpts = {}): Promise { const decision = await decideInline(input); const overwrite = opts.overwrite ?? false; const contentType = opts.contentType ?? decision.contentType; if (decision.kind === 'inline' || opts.forceInline === true) { const bytes = decision.kind === 'inline' ? decision.bytes : null; if (bytes === null) { // forceInline === true with a streams-typed decision — // decideInline always produced a `streams` shape because the // input was a bare ReadableStream. We can't drain a stream // synchronously here without a streams-bridge. throw new ConflictError( 'http RPC client cannot forceInline a streamed input — pass a Uint8Array / Blob, or pre-buffer the stream.', ); } if (bytes.byteLength > INLINE_THRESHOLD) { throw new ConflictError( `inline write exceeds ${INLINE_THRESHOLD}-byte threshold (got ${bytes.byteLength}); pass forceInline=true to override`, ); } const args = WriteArgsSchema.parse({ kind: 'inline', path, bytesB64: bytesToBase64(bytes), ...(contentType !== undefined ? { contentType } : {}), overwrite, }); return await roundTrip( KIND_WRITE_V1, 'write', args, WriteResultSchema, opts, ); } // Streamed write — requires the queue drainer + streams-bridge. if (drainer === null) { throw new ConflictError( `http RPC client supports inline writes only (≤ ${INLINE_THRESHOLD} bytes). The supplied input was promoted to streams (size ${decision.size ?? 'unknown'}). Pass { outboundQueueUrl, transferBaseUrl } to enable streamed writes.`, ); } const bridge = await getStreamsBridge(); const size = decision.size; if (size === undefined) { throw new ConflictError( 'streams write requires a known plaintext size; pass `{ stream, size }` instead of a bare ReadableStream', ); } const { writeId, handle } = await bridge.initiateWrite({ peer: peerAddress, stream: decision.stream, size, ...(contentType !== undefined ? { contentType } : {}), name: path, ...(opts.signal !== undefined ? { signal: opts.signal } : {}), }); const args = WriteArgsSchema.parse({ kind: 'streams', path, size, ...(contentType !== undefined ? { contentType } : {}), overwrite, writeId, }); try { const [result] = await Promise.all([ roundTrip(KIND_WRITE_V1, 'write', args, WriteResultSchema, opts), handle.done(), ]); return result; } catch (err) { await handle.abort('rpc-failed').catch(() => undefined); throw err; } }, async getThumbnail(path, size: ThumbnailSize, opts): Promise { const args = GetThumbnailArgsSchema.parse({ path, size, ...(opts?.format !== undefined ? { format: opts.format } : {}), }); const raw = await roundTrip( KIND_GET_THUMBNAIL_V1, 'getThumbnail', args, GetThumbnailResultSchema, opts, ); return { bytes: base64ToBytes(raw.bytesB64), format: raw.format, width: raw.width, height: raw.height, sha256: raw.sha256, }; }, async custom(name, args, opts?: BaseOpts): Promise { const wireArgs = CustomArgsSchema.parse({ name, args }); return await roundTrip(KIND_CUSTOM_V1, 'custom', wireArgs, CustomResultSchema, opts); }, close(): void { // Stop the long-poll drainer + tear down the streams-bridge if // we built one. Idempotent — safe to call multiple times. drainer?.stop(); drainer = null; if (streamsBridge !== null) { void streamsBridge.destroy().catch(() => undefined); streamsBridge = null; } streamsBridgePromise = null; }, } as FileClient; }