import type { Context } from "@wrnexus/core"; import { RPC_ERROR_CODES, ServiceError } from "./errors.ts"; import { exportSubjectContext } from "./identity.ts"; import type { Transport } from "./transport.ts"; import type { AnyProcedures, InferProcedureInput, InferProcedureOutput, ServiceContract, } from "./types.ts"; export interface ServiceClientOptions { app?: string; transport: Transport; as?: Context; timeoutMs?: number; } export type ServiceClient = { [K in keyof Procedures]: ( input: InferProcedureInput, ) => Promise>; }; const DEFAULT_TIMEOUT_MS = 10_000; export function serviceClient( contract: ServiceContract, options: ServiceClientOptions, ): ServiceClient { const app = options.app ?? contract.name; const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS; return new Proxy({} as ServiceClient, { get(_target, property) { // Every declared procedure is a string key; anything else (including // `then`/`catch`/`finally`) is not one of ours. Returning a function for // those makes `await client` or `return client` from an async function // read the proxy as thenable — the runtime then calls `then(resolve, // reject)`, which throws "Unknown procedure". Returning undefined lets // the caller be treated as a plain (non-thenable) object instead. if (typeof property !== "string" || !Object.hasOwn(contract.procedures, property)) { return undefined; } return async (input: unknown) => { let identity: string | undefined; if (options.as) { try { identity = await exportSubjectContext(options.as, app); } catch (error) { // A missing/misconfigured WRNEXUS_RPC_SECRET otherwise rejects with // a bare Error, so a caller matching on ServiceError treats // misconfiguration as a crash instead of a handled RPC failure. // The operator-facing message names no secret value, so it is safe // to preserve. const message = error instanceof Error ? error.message : String(error); throw new ServiceError(RPC_ERROR_CODES.identity, message); } } const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), timeoutMs); const timeout = new Promise((_, reject) => { controller.signal.addEventListener("abort", () => { reject(new ServiceError(RPC_ERROR_CODES.transport, "Call timed out")); }); }); const callPromise = options.transport.call( { app, service: contract.name, procedure: property }, input, { signal: controller.signal, idempotent: contract.procedures[property as keyof Procedures].idempotent === true, ...(identity ? { identity } : {}), }, ); try { const result = await Promise.race([callPromise, timeout]); if (result.ok) return result.value; throw new ServiceError(result.code, result.message, result.retryable); } finally { clearTimeout(timer); // If the timeout won the race, the transport call may still settle // later — a transport that ignores the abort signal keeps running. // Nothing awaits it again, so swallow a late rejection here rather // than let it surface as an unhandled promise rejection. callPromise.catch(() => {}); } }; }, }); }