Files
Shade/packages/shade-files/src/client/http-client.ts
Sterister 7520b11b25
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.2.0): pull-mode streams for browser @shade/files
4.1.0's HTTP RPC for browsers capped at inline payloads (≤ 256 KiB).
4.2.0 unlocks streams: server queues outbound chunks + control
envelopes per peer, browser long-polls the queue. Browser-to-server
writes ride the existing /v1/transfer/<id>/chunk POST routes
unchanged.

For Dispatch this unlocks mod-jar uploads (50 MB) and world-backup
downloads (100+ MB) — the actual reason browser-side @shade/files
matters.

### New API

@shade/sdk:
- shade.transferQueueRoute(opts?) — Hono app with /queue +
  /v1/transfer/* routes. Auto-configures the queue transport.
- shade.configureTransfers extended: transport + envelopeTransport
  override slots; resolveBaseUrl optional when both supplied.

@shade/transfer:
- OutboundQueue — per-peer monotonic event log with long-poll
  semantics, idle-eviction GC, ring-buffered to maxEventsPerPeer.
- QueueTransferTransport — enqueues instead of POSTing.

@shade/files:
- httpClient({ outboundQueueUrl, transferBaseUrl }) — when set,
  starts a long-poll drainer + builds a streams-bridge. fs.read /
  fs.write of >256 KiB work end-to-end.
- startQueueDrainer(shade, opts) — exported helper for advanced
  consumers driving their own drainer.

### Implementation notes

- ClientStreamsBridge's TransformStream had HWM=0 by default which
  stalled the drainer's await chain at chunk 4 (writer.write pended
  before the consumer's reader was attached). Bumped to HWM=64 so
  the receive loop can buffer ahead of the consumer.

### Tests

3 new integration tests in tests/integration/http-rpc-streams.test.ts:
4 MiB streamed read round-trip, inline-only error path, idle-timeout
long-poll behaviour.

Wire-compatible. Source-compatible. Lockstep bump to 4.2.0.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 23:27:06 +02:00

591 lines
21 KiB
TypeScript

/**
* 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<CreateFileClientOptions, 'streamsBridge'> {
/**
* 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<string, string>;
/**
* 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 `<base>/v1/transfer/<streamId>/chunk`
* - control envelopes to `<base>/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 `<transferBaseUrl>/v1/transfer/<streamId>/chunk`
// and outbound control envelopes POST to
// `<transferBaseUrl>/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<ClientStreamsBridge> | 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<ClientStreamsBridge> {
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<TResult>(
kind: string,
op: 'list' | 'stat' | 'mkdir' | 'delete' | 'move' | 'read' | 'write' | 'getThumbnail' | 'custom',
args: unknown,
resultSchema: ZodTypeAny,
opts: RoundTripOpts | undefined,
): Promise<TResult> {
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<ArrayBufferLike>` 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<ListResult> {
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<ListResult>(
KIND_LIST_V1,
'list',
args,
ListResultSchema,
opts,
);
},
async stat(path, opts): Promise<StatResult> {
const args = StatArgsSchema.parse({ path });
return await roundTrip<StatResult>(KIND_STAT_V1, 'stat', args, StatResultSchema, opts);
},
async mkdir(path, opts): Promise<MkdirResult> {
const args = MkdirArgsSchema.parse({
path,
...(opts?.recursive !== undefined ? { recursive: opts.recursive } : {}),
});
return await roundTrip<MkdirResult>(
KIND_MKDIR_V1,
'mkdir',
args,
MkdirResultSchema,
opts,
);
},
async delete(path, opts): Promise<DeleteResult> {
const args = DeleteArgsSchema.parse({
path,
...(opts?.recursive !== undefined ? { recursive: opts.recursive } : {}),
});
return await roundTrip<DeleteResult>(
KIND_DELETE_V1,
'delete',
args,
DeleteResultSchema,
opts,
);
},
async move(src, dst, opts): Promise<MoveResult> {
const args = MoveArgsSchema.parse({
src,
dst,
...(opts?.overwrite !== undefined ? { overwrite: opts.overwrite } : {}),
});
return await roundTrip<MoveResult>(KIND_MOVE_V1, 'move', args, MoveResultSchema, opts);
},
async read(path, opts: ReadOpts = {}): Promise<ReadOutput> {
const args = ReadArgsSchema.parse({
path,
...(opts.range !== undefined ? { range: opts.range } : {}),
...(opts.preferInline !== undefined ? { preferInline: opts.preferInline } : {}),
});
const wire = await roundTrip<import('../schemas/ops.js').ReadResult>(
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<WriteResult> {
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<WriteResult>(
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<WriteResult>(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<ThumbnailResult> {
const args = GetThumbnailArgsSchema.parse({
path,
size,
...(opts?.format !== undefined ? { format: opts.format } : {}),
});
const raw = await roundTrip<import('../schemas/ops.js').GetThumbnailResult>(
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<unknown> {
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;
}