fix(rpc): close the service-collision fail-open and the fix-wave gaps

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>
This commit is contained in:
2026-08-05 20:10:20 +05:30
co-authored by Claude Opus 5
parent 7c4b484d0a
commit ce68803471
11 changed files with 347 additions and 31 deletions
+83 -5
View File
@@ -76,7 +76,7 @@ describe("serviceClient", () => {
expect(seenAnon).toBeUndefined();
});
test("calling an undeclared procedure throws rather than issuing a call", async () => {
test("an undeclared procedure is undefined rather than a function that throws", async () => {
let called = false;
const client = serviceClient(billing, {
transport: {
@@ -86,13 +86,91 @@ describe("serviceClient", () => {
},
},
});
const proxy = client as unknown as Record<string, (input: unknown) => Promise<unknown>>;
await expect(proxy.deleteEverything!({})).rejects.toMatchObject({
code: RPC_ERROR_CODES.unknown,
});
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, {
+80 -2
View File
@@ -1,7 +1,7 @@
import { describe, expect, test } from "bun:test";
import { RPC_ERROR_CODES } from "../src/errors.ts";
import { RPC_ERROR_CODES, isRetryableStatus } from "../src/errors.ts";
import { RPC_IDENTITY_HEADER } from "../src/identity.ts";
import { httpTransport, rpcPath } from "../src/http.ts";
import { RPC_INTERNAL_HEADER, httpTransport, rpcPath } from "../src/http.ts";
const target = { app: "billing", service: "billing", procedure: "createInvoice" };
@@ -42,4 +42,82 @@ describe("httpTransport", () => {
test("uses the stable reserved path", () => {
expect(rpcPath("billing", "createInvoice")).toBe("/__wrnexus/rpc/billing/createInvoice");
});
test("omits the identity header entirely for an anonymous call", async () => {
const transport = transportWith(async (request) => {
expect(request.headers.has(RPC_IDENTITY_HEADER)).toBe(false);
return Response.json({ ok: true, value: {} });
});
await transport.call(target, {}, {});
});
test("sets the internal-marker header", async () => {
const transport = transportWith(async (request) => {
expect(request.headers.get(RPC_INTERNAL_HEADER)).toBe("1");
return Response.json({ ok: true, value: {} });
});
await transport.call(target, {}, {});
});
test("the full retryable-status sweep matches isRetryableStatus", async () => {
// 600 is covered directly on isRetryableStatus in errors.test.ts — the
// Fetch API cannot construct a Response with a status outside 200599.
const statuses = [200, 400, 403, 404, 408, 409, 429, 500, 503, 599];
for (const status of statuses) {
if (status === 200) continue; // handled by the success-path test above
const transport = transportWith(() => new Response("x", { status }));
const result = await transport.call(target, {}, {});
expect(result).toMatchObject({
ok: false,
code: RPC_ERROR_CODES.transport,
retryable: isRetryableStatus(status),
});
}
});
test("a network throw yields a structured failure with no host/address surviving", async () => {
const transport = httpTransport({
resolveOrigin: () => "http://internal-billing-host.private:4821",
fetch: (async () => {
throw new TypeError("fetch failed: connect ECONNREFUSED 10.0.0.7:4821");
}) as unknown as typeof fetch,
});
const result = await transport.call(target, {}, {});
expect(result).toMatchObject({ ok: false, code: RPC_ERROR_CODES.transport });
if (!result.ok) {
expect(result.message).not.toContain("10.0.0.7");
expect(result.message).not.toContain("internal-billing-host");
}
});
test("a malformed JSON body yields a structured failure with no body content surviving", async () => {
const transport = transportWith(() => new Response("{not json", { status: 200 }));
const result = await transport.call(target, {}, {});
expect(result).toMatchObject({ ok: false, code: RPC_ERROR_CODES.malformed });
if (!result.ok) expect(result.message).not.toContain("{not json");
});
test("an HTML error page yields a structured failure with no page content surviving", async () => {
const transport = transportWith(
() =>
new Response("<html><body>500 Internal Server Error at db-host-42</body></html>", {
status: 200,
headers: { "content-type": "text/html" },
}),
);
const result = await transport.call(target, {}, {});
expect(result).toMatchObject({ ok: false, code: RPC_ERROR_CODES.malformed });
if (!result.ok) expect(result.message).not.toContain("db-host-42");
});
test("the AbortSignal reaches fetch", async () => {
const controller = new AbortController();
let seenSignal: AbortSignal | undefined;
const transport = transportWith(async (request) => {
seenSignal = request.signal;
return Response.json({ ok: true, value: {} });
});
await transport.call(target, {}, { signal: controller.signal });
expect(seenSignal).toBeDefined();
});
});