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:
126
packages/shade-files/src/protocol/rpc-builder.ts
Normal file
126
packages/shade-files/src/protocol/rpc-builder.ts
Normal file
@@ -0,0 +1,126 @@
|
||||
/**
|
||||
* Shared RPC-request construction.
|
||||
*
|
||||
* Both the channel-based `FileClient` (`createFileClient`) and the
|
||||
* HTTP-based `FilesHttpClient` build identical `RpcRequest` envelopes
|
||||
* — they differ only in *transport* (channel.send → ratchet via
|
||||
* Shade.send/onMessage vs HTTP POST → ratchet via single
|
||||
* request-response). This module is the single source of truth for
|
||||
* the wire shape so the two clients can never drift.
|
||||
*/
|
||||
import type { ZodTypeAny } from 'zod';
|
||||
import {
|
||||
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,
|
||||
MUTATION_OPS,
|
||||
type StandardOp,
|
||||
} from './kinds.js';
|
||||
import { generateIdempotencyKey, generateRequestId } from './correlate.js';
|
||||
import { canonicalRpcBytes, hashArgs } from './canonical.js';
|
||||
import type { RpcRequest } from '../schemas/envelope.js';
|
||||
|
||||
export const KIND_BY_OP: Record<StandardOp, string> = {
|
||||
list: KIND_LIST_V1,
|
||||
stat: KIND_STAT_V1,
|
||||
mkdir: KIND_MKDIR_V1,
|
||||
delete: KIND_DELETE_V1,
|
||||
move: KIND_MOVE_V1,
|
||||
read: KIND_READ_V1,
|
||||
write: KIND_WRITE_V1,
|
||||
getThumbnail: KIND_GET_THUMBNAIL_V1,
|
||||
};
|
||||
|
||||
export type SignRequest = (canonicalBytes: Uint8Array) => Promise<string> | string;
|
||||
|
||||
export interface BuildRpcRequestOptions {
|
||||
/** Address that this RPC call originates from. */
|
||||
senderAddress: string;
|
||||
/** RPC kind — `KIND_*_V1` constants for standard ops, `KIND_CUSTOM_V1` otherwise. */
|
||||
kind: string;
|
||||
/** Op classifier so mutations get an auto-generated idempotency key. */
|
||||
op: StandardOp | 'custom';
|
||||
/** Validated args object. The caller is responsible for `Zod.parse(args)`. */
|
||||
args: unknown;
|
||||
/** Caller-supplied idempotency key. Mutations get one auto-generated. */
|
||||
idempotencyKey?: string;
|
||||
/** Optional Ed25519-style signer over the canonical bytes. */
|
||||
signRequest?: SignRequest;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a single `RpcRequest` envelope. Generates `id`, `signedAt`,
|
||||
* idempotency key (mutations only), and the canonical signature.
|
||||
*/
|
||||
export async function buildRpcRequest(
|
||||
options: BuildRpcRequestOptions,
|
||||
): Promise<RpcRequest> {
|
||||
const { senderAddress, kind, op, args, signRequest } = options;
|
||||
const requestId = generateRequestId();
|
||||
const isMutation = MUTATION_OPS.has(op);
|
||||
const idempotencyKey =
|
||||
options.idempotencyKey ?? (isMutation ? generateIdempotencyKey() : undefined);
|
||||
const signedAt = Date.now();
|
||||
|
||||
let sig = 'unsigned';
|
||||
if (signRequest !== undefined) {
|
||||
const canonical = canonicalRpcBytes({
|
||||
address: senderAddress,
|
||||
signedAt,
|
||||
kind,
|
||||
id: requestId,
|
||||
argsHash: hashArgs(args),
|
||||
});
|
||||
sig = await signRequest(canonical);
|
||||
}
|
||||
|
||||
const env: RpcRequest = {
|
||||
kind,
|
||||
id: requestId,
|
||||
args,
|
||||
...(idempotencyKey !== undefined ? { idempotencyKey } : {}),
|
||||
sig,
|
||||
signedAt,
|
||||
};
|
||||
return env;
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper for the typed standard ops: validates args via the supplied Zod
|
||||
* schema, calls {@link buildRpcRequest}, and forwards the result.
|
||||
*
|
||||
* The HTTP and channel clients both call this from each op-method to
|
||||
* guarantee identical wire-shape. Custom ops use {@link buildRpcRequest}
|
||||
* directly because they ship `KIND_CUSTOM_V1` plus runtime-validated args.
|
||||
*/
|
||||
export async function buildStandardRpcRequest<TArgs>(
|
||||
schema: { parse(input: unknown): TArgs },
|
||||
rawArgs: unknown,
|
||||
options: Omit<BuildRpcRequestOptions, 'kind' | 'op' | 'args'> & {
|
||||
op: StandardOp;
|
||||
},
|
||||
): Promise<{ request: RpcRequest; args: TArgs }> {
|
||||
const args = schema.parse(rawArgs) as TArgs;
|
||||
const kind = KIND_BY_OP[options.op];
|
||||
const request = await buildRpcRequest({
|
||||
...options,
|
||||
kind,
|
||||
op: options.op,
|
||||
args,
|
||||
});
|
||||
return { request, args };
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a returned RpcResponse `result` against the supplied schema.
|
||||
* Centralised so the HTTP and channel clients fail-loudly with the same
|
||||
* error type when a server returns an off-spec response.
|
||||
*/
|
||||
export function parseResult<T>(schema: ZodTypeAny, raw: unknown): T {
|
||||
return schema.parse(raw) as T;
|
||||
}
|
||||
Reference in New Issue
Block a user