Files
WRNexusJS/packages/rpc/src/client.ts
T
ClintchizandClaude Opus 5 3eec9fd8c6 fix(rpc): close the four final-review blockers on inter-app RPC
- Resolve RPC call origins via a new WRNEXUS_INTERNAL_ORIGINS map (loopback
  origins the gateway hands each child before spawning it), falling back to
  the public appOrigin only when it is absent. Calls previously always went
  to the public gateway origin, which the gateway unconditionally 404s on
  the RPC prefix by design — every real cross-app call failed.
- Stop loadServices() from running ahead of routing and stop memoizing a
  rejected load: one bad file under app/services/ no longer permanently
  breaks every route in the app. A failed load logs loudly, is retried on
  the next RPC request, and the RPC path gets a structured RPC_UNKNOWN
  instead of an unhandled throw.
- Reject a service whose contract.name does not match the filename it is
  mounted under, naming both, instead of silently mounting under the
  filename while the typed client calls by contract name.
- Let ServiceError accept an explicit retryable and have the client pass the
  wire value through, instead of recomputing (and silently flipping) it from
  the error code alone.
- Document the gateway/X-Forwarded-* deployment requirement in the RPC
  README.

Each of the three code blockers has a new/extended test that was verified to
fail when its fix was reverted (rpc/test/integration.test.ts,
dev-server/test/rpc-services-loading.test.ts).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-05 20:36:31 +05:30

87 lines
3.5 KiB
TypeScript

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<Procedures extends AnyProcedures> = {
[K in keyof Procedures]: (
input: InferProcedureInput<Procedures[K]>,
) => Promise<InferProcedureOutput<Procedures[K]>>;
};
const DEFAULT_TIMEOUT_MS = 10_000;
export function serviceClient<Procedures extends AnyProcedures>(
contract: ServiceContract<Procedures>,
options: ServiceClientOptions,
): ServiceClient<Procedures> {
const app = options.app ?? contract.name;
const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
return new Proxy({} as ServiceClient<Procedures>, {
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<never>((_, 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, ...(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(() => {});
}
};
},
});
}