/** * 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 = { 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; 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 { 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( schema: { parse(input: unknown): TArgs }, rawArgs: unknown, options: Omit & { 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(schema: ZodTypeAny, raw: unknown): T { return schema.parse(raw) as T; }