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
+14
View File
@@ -305,6 +305,7 @@ export function buildRouter(appDir: string, opts: RouterOptions = {}): Router {
}
const services: ComponentRef[] = [];
const serviceFilesByName = new Map<string, string>();
for (const f of scanDir(join(appDir, "services"), [".js"])) {
if (!/\.(ts|js)$/.test(f.file) || /[.]gen[.](ts|js)$/.test(f.file)) continue;
const name = basename(f.file).replace(/\.(ts|js)$/, "");
@@ -312,6 +313,19 @@ export function buildRouter(appDir: string, opts: RouterOptions = {}): Router {
console.warn(`[wrnexus] skipping service with unsafe name: ${name}`);
continue;
}
// A service name is a routable identity (/__wrnexus/rpc/<name>/...), so a
// collision is a configuration error, not something to resolve by
// directory-walk precedence like `components` silently does. Fail loudly,
// naming both files, instead of letting whichever file is visited last
// win non-deterministically.
const existing = serviceFilesByName.get(name);
if (existing) {
throw new Error(
`WRN-SERVICE-COLLISION: two services are both named "${name}": ${existing} and ${f.file}. ` +
`Rename one of the files — service names must be unique across app/services.`,
);
}
serviceFilesByName.set(name, f.file);
services.push({ name, file: f.file });
}