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:
@@ -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 });
|
||||
}
|
||||
|
||||
|
||||
@@ -6,16 +6,69 @@ import { buildRouter } from "../src/index.ts";
|
||||
const root = join(import.meta.dir, ".tmp-services");
|
||||
mkdirSync(root, { recursive: true });
|
||||
|
||||
function makeApp(): string {
|
||||
const base = mkdtempSync(join(root, "app-"));
|
||||
mkdirSync(join(base, "app", "pages"), { recursive: true });
|
||||
return join(base, "app");
|
||||
}
|
||||
|
||||
describe("service discovery", () => {
|
||||
test("discovers source services and skips generated files", () => {
|
||||
const base = mkdtempSync(join(root, "app-"));
|
||||
const services = join(base, "app", "services");
|
||||
const appDir = makeApp();
|
||||
const services = join(appDir, "services");
|
||||
mkdirSync(services, { recursive: true });
|
||||
mkdirSync(join(base, "app", "pages"), { recursive: true });
|
||||
writeFileSync(join(services, "billing.ts"), "export default {};");
|
||||
writeFileSync(join(services, "types.gen.ts"), "export type T = string;");
|
||||
expect(buildRouter(join(base, "app")).services.map((service) => service.name)).toEqual([
|
||||
"billing",
|
||||
]);
|
||||
expect(buildRouter(appDir).services.map((service) => service.name)).toEqual(["billing"]);
|
||||
});
|
||||
|
||||
test("throws naming both files when two services collide on name", () => {
|
||||
const appDir = makeApp();
|
||||
const services = join(appDir, "services");
|
||||
const nested = join(services, "legacy");
|
||||
mkdirSync(nested, { recursive: true });
|
||||
const top = join(services, "billing.ts");
|
||||
const shadow = join(nested, "billing.ts");
|
||||
writeFileSync(top, "export default {};");
|
||||
writeFileSync(shadow, "export default {};");
|
||||
expect(() => buildRouter(appDir)).toThrow(/WRN-SERVICE-COLLISION/);
|
||||
try {
|
||||
buildRouter(appDir);
|
||||
} catch (error) {
|
||||
const message = (error as Error).message;
|
||||
expect(message).toContain(top);
|
||||
expect(message).toContain(shadow);
|
||||
}
|
||||
});
|
||||
|
||||
test("unsafe service names are skipped with a warning", () => {
|
||||
const appDir = makeApp();
|
||||
const services = join(appDir, "services");
|
||||
mkdirSync(services, { recursive: true });
|
||||
writeFileSync(join(services, "bad name.ts"), "export default {};");
|
||||
const originalWarn = console.warn;
|
||||
let warned = false;
|
||||
console.warn = (...args: unknown[]) => {
|
||||
if (String(args[0]).includes("skipping service with unsafe name")) warned = true;
|
||||
};
|
||||
try {
|
||||
expect(buildRouter(appDir).services).toEqual([]);
|
||||
expect(warned).toBe(true);
|
||||
} finally {
|
||||
console.warn = originalWarn;
|
||||
}
|
||||
});
|
||||
|
||||
test("a missing services directory yields []", () => {
|
||||
const appDir = makeApp();
|
||||
expect(buildRouter(appDir).services).toEqual([]);
|
||||
});
|
||||
|
||||
test("discovers .js service files", () => {
|
||||
const appDir = makeApp();
|
||||
const services = join(appDir, "services");
|
||||
mkdirSync(services, { recursive: true });
|
||||
writeFileSync(join(services, "reports.js"), "export default {};");
|
||||
expect(buildRouter(appDir).services.map((service) => service.name)).toEqual(["reports"]);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user