127 lines
3.9 KiB
TypeScript
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;
|
||
|
|
}
|