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>
This commit is contained in:
399
packages/shade-files/src/client/http-client.ts
Normal file
399
packages/shade-files/src/client/http-client.ts
Normal file
@@ -0,0 +1,399 @@
|
||||
/**
|
||||
* 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 type {
|
||||
FileClient,
|
||||
ReadOpts,
|
||||
ReadOutput,
|
||||
ThumbnailResult,
|
||||
WriteOpts,
|
||||
CreateFileClientOptions,
|
||||
BaseOpts,
|
||||
} from './client.js';
|
||||
|
||||
export interface FilesHttpClientOptions
|
||||
extends Omit<CreateFileClientOptions, 'streamsBridge' | 'ioTimeoutMs'> {
|
||||
/**
|
||||
* 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>;
|
||||
}
|
||||
|
||||
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".
|
||||
*/
|
||||
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 signRequest = options.signRequest;
|
||||
const senderAddress = shade.myAddress;
|
||||
|
||||
/**
|
||||
* 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') {
|
||||
// The HTTP RPC route does not service streamed reads — there is
|
||||
// no place to stream from in pure request-response.
|
||||
throw new InternalFileError(
|
||||
`http RPC client received a streamed read (size ${wire.size}). Use shade.files.client(peer) on a server-to-server deployment, or pass { preferInline: true } when the file is known to fit 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;
|
||||
},
|
||||
|
||||
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') {
|
||||
throw new ConflictError(
|
||||
`http RPC client supports inline writes only (≤ ${INLINE_THRESHOLD} bytes). The supplied input was promoted to streams (size ${decision.size ?? 'unknown'}). Use shade.files.client(peer) for streamed writes, or pre-buffer the input below the inline threshold.`,
|
||||
);
|
||||
}
|
||||
const args = WriteArgsSchema.parse({
|
||||
kind: 'inline',
|
||||
path,
|
||||
bytesB64: bytesToBase64(decision.bytes),
|
||||
...(contentType !== undefined ? { contentType } : {}),
|
||||
overwrite,
|
||||
});
|
||||
return await roundTrip<WriteResult>(
|
||||
KIND_WRITE_V1,
|
||||
'write',
|
||||
args,
|
||||
WriteResultSchema,
|
||||
opts,
|
||||
);
|
||||
},
|
||||
|
||||
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 {
|
||||
// Stateless — nothing to release. Exists for FileClient symmetry.
|
||||
},
|
||||
} as FileClient;
|
||||
}
|
||||
Reference in New Issue
Block a user