Files
WRNexusJS/packages/rpc/test/client.test.ts
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

218 lines
7.4 KiB
TypeScript

import { afterEach, describe, expect, test } from "bun:test";
import type { Context } from "@wrnexus/core";
import { v } from "@wrnexus/validation";
import { serviceClient } from "../src/client.ts";
import { defineService, procedure } from "../src/contract.ts";
import { RPC_ERROR_CODES, ServiceError, failure, success } from "../src/errors.ts";
import type { RpcTarget } from "../src/transport.ts";
const originalRpcSecret = process.env.WRNEXUS_RPC_SECRET;
const originalAppName = process.env.WRNEXUS_APP_NAME;
afterEach(() => {
if (originalRpcSecret === undefined) delete process.env.WRNEXUS_RPC_SECRET;
else process.env.WRNEXUS_RPC_SECRET = originalRpcSecret;
if (originalAppName === undefined) delete process.env.WRNEXUS_APP_NAME;
else process.env.WRNEXUS_APP_NAME = originalAppName;
});
function configure(appName = "web") {
process.env.WRNEXUS_RPC_SECRET = "test-rpc-secret-at-least-32-chars-long";
process.env.WRNEXUS_APP_NAME = appName;
}
const billing = defineService({
name: "billing",
procedures: {
createInvoice: procedure
.input(v.object({ amountCents: v.number() }))
.output<{ invoiceId: string }>()
.build(),
},
});
describe("serviceClient", () => {
test("returns the handler's value unwrapped", async () => {
const client = serviceClient(billing, {
transport: { call: async () => success({ invoiceId: "inv_1" }) },
});
expect(await client.createInvoice({ amountCents: 1 })).toEqual({ invoiceId: "inv_1" });
});
test("throws a ServiceError carrying code and retryable on failure", async () => {
const client = serviceClient(billing, {
transport: { call: async () => failure(RPC_ERROR_CODES.transport, "down") },
});
let caught: unknown;
try {
await client.createInvoice({ amountCents: 1 });
} catch (err) {
caught = err;
}
expect(caught).toBeInstanceOf(ServiceError);
expect(caught).toMatchObject({ code: RPC_ERROR_CODES.transport, retryable: true });
});
test("attaches an identity token when given a context and omits it for an anonymous context", async () => {
configure("web");
let seenIdentity: string | undefined = "unset";
const client = serviceClient(billing, {
as: { user: { id: "u1" }, locals: {} } as unknown as Context,
transport: {
call: async (_target, _payload, options) => {
seenIdentity = options.identity;
return success({ invoiceId: "inv_1" });
},
},
});
await client.createInvoice({ amountCents: 1 });
expect(seenIdentity).toBeTypeOf("string");
let seenAnon: unknown = "unset";
const anonClient = serviceClient(billing, {
transport: {
call: async (_target, _payload, options) => {
seenAnon = options.identity;
return success({ invoiceId: "inv_1" });
},
},
});
await anonClient.createInvoice({ amountCents: 1 });
expect(seenAnon).toBeUndefined();
});
test("an undeclared procedure is undefined rather than a function that throws", async () => {
let called = false;
const client = serviceClient(billing, {
transport: {
call: async () => {
called = true;
return success({});
},
},
});
const proxy = client as unknown as Record<string, unknown>;
expect(proxy.deleteEverything).toBeUndefined();
expect(called).toBe(false);
});
test("the proxy has no `then` escape — await/return does not trigger a call", async () => {
let called = false;
const client = serviceClient(billing, {
transport: {
call: async () => {
called = true;
return success({ invoiceId: "inv_1" });
},
},
});
const proxy = client as unknown as Record<string, unknown>;
expect(proxy.then).toBeUndefined();
expect(proxy.catch).toBeUndefined();
expect(proxy.finally).toBeUndefined();
// `await client` must resolve to the proxy object itself, not reject.
const awaited = await client;
expect(awaited).toBe(client);
expect(called).toBe(false);
});
test("the signal arrives at the transport", async () => {
let seenSignal: AbortSignal | undefined;
const client = serviceClient(billing, {
transport: {
call: async (_target, _payload, options) => {
seenSignal = options.signal;
return success({ invoiceId: "inv_1" });
},
},
});
await client.createInvoice({ amountCents: 1 });
expect(seenSignal).toBeInstanceOf(AbortSignal);
});
test("the call aborts at timeoutMs when the transport ignores the signal", async () => {
const client = serviceClient(billing, {
timeoutMs: 20,
transport: {
call: () => new Promise(() => {}), // never resolves; ignores the signal
},
});
let caught: unknown;
const start = Date.now();
try {
await client.createInvoice({ amountCents: 1 });
} catch (err) {
caught = err;
}
const elapsed = Date.now() - start;
expect(caught).toBeInstanceOf(ServiceError);
expect(caught).toMatchObject({ code: RPC_ERROR_CODES.transport });
expect(elapsed).toBeLessThan(500);
});
test("the timer is cleared on both success and failure", async () => {
const originalClearTimeout = globalThis.clearTimeout;
let clearCount = 0;
globalThis.clearTimeout = ((...args: Parameters<typeof clearTimeout>) => {
clearCount++;
return originalClearTimeout(...args);
}) as typeof clearTimeout;
try {
const okClient = serviceClient(billing, {
transport: { call: async () => success({ invoiceId: "inv_1" }) },
});
await okClient.createInvoice({ amountCents: 1 });
expect(clearCount).toBe(1);
const failClient = serviceClient(billing, {
transport: { call: async () => failure(RPC_ERROR_CODES.transport, "down") },
});
await expect(failClient.createInvoice({ amountCents: 1 })).rejects.toBeInstanceOf(
ServiceError,
);
expect(clearCount).toBe(2);
} finally {
globalThis.clearTimeout = originalClearTimeout;
}
});
test("the app defaults to the service name", async () => {
let seenTarget: RpcTarget | undefined;
const client = serviceClient(billing, {
transport: {
call: async (target) => {
seenTarget = target;
return success({ invoiceId: "inv_1" });
},
},
});
await client.createInvoice({ amountCents: 1 });
expect(seenTarget?.app).toBe("billing");
expect(seenTarget?.service).toBe("billing");
});
test("a wire-level non-retryable failure (e.g. a 403) surfaces at the client as retryable: false", async () => {
// RPC_TRANSPORT recomputes to retryable: true from the code alone — that
// is correct for a 5xx, but the transport already determined a 403 is
// NOT retryable via the bounded status check. The client must pass that
// wire value through rather than recompute it from the code.
const client = serviceClient(billing, {
transport: {
call: async () => ({
ok: false,
code: RPC_ERROR_CODES.transport,
message: "Service returned 403",
retryable: false,
}),
},
});
let caught: unknown;
try {
await client.createInvoice({ amountCents: 1 });
} catch (err) {
caught = err;
}
expect(caught).toBeInstanceOf(ServiceError);
expect(caught).toMatchObject({ code: RPC_ERROR_CODES.transport, retryable: false });
});
});