Critical: - router: fail loudly (WRN-SERVICE-COLLISION) when two app/services files scan to the same service name, instead of silently letting directory-walk order pick a winner. Important: - server.ts: wrap a throwing input schema so its raw message cannot escape invoke(); returns RPC_INVALID and logs server-side instead. - client.ts: race timeoutMs against transport.call so a stalled transport cannot hang the caller; rejects with a ServiceError(RPC_TRANSPORT). - client.ts: the proxy returns undefined for undeclared properties (incl. then/catch/finally) instead of a function that throws, closing the await-client thenable trap. - gateway.ts / rpc-dispatch.ts: import RPC_PATH_PREFIX / RPC_INTERNAL_HEADER from @wrnexus/rpc instead of hardcoding local copies. - gateway.test.ts: cover the RPC-prefix edge block and internal-header stripping across casing variants. - http.test.ts / client.test.ts: cover anonymous-call header omission, the internal marker, the retryable-status sweep, network/malformed/HTML failures, AbortSignal propagation, the timeout path, and timer cleanup. Minor: - transport.ts: Object.hasOwn for handler lookup; note the entry-only abort check. - client.ts: wrap a missing/invalid WRNEXUS_RPC_SECRET as a ServiceError (RPC_IDENTITY) instead of a bare Error. - rpc/package.json: drop the unused @wrnexus/authz dependency. - server.ts: implement() now throws at construction time if a declared procedure has no own handler. Verified: reverting the service-collision check and the client timeout race each make their new test fail, then restore green. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
189 lines
6.1 KiB
TypeScript
189 lines
6.1 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 original = { ...process.env };
|
|
afterEach(() => {
|
|
process.env = { ...original };
|
|
});
|
|
|
|
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");
|
|
});
|
|
});
|