Files
Shade/packages/shade-files/src/protocol/rpc-builder.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

127 lines
3.9 KiB
TypeScript

/**
* 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;
}