- 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>
218 lines
7.4 KiB
TypeScript
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 });
|
|
});
|
|
});
|