import { afterEach, expect, test } from "bun:test"; import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { join } from "node:path"; import { tmpdir } from "node:os"; import { buildRouter } from "@wrnexus/router"; import { defineService, implement, procedure } from "@wrnexus/rpc"; import { v } from "@wrnexus/validation"; import { createHandlers, type RuntimeDeps } from "../src/runtime.ts"; const roots: string[] = []; afterEach(() => roots.splice(0).forEach((root) => rmSync(root, { recursive: true, force: true }))); const greeter = defineService({ name: "greeter", procedures: { greet: procedure .input(v.object({ name: v.string() })) .output<{ message: string }>() .build(), }, }); function makeHandlers(app: string, loadModule: RuntimeDeps["loadModule"]) { return createHandlers({ mode: "production", hmr: false, router: buildRouter(app), loadModule, getMiddleware: async () => [], assets: { serve: async () => null }, } satisfies RuntimeDeps); } test("a bad file under app/services does not break unrelated routes", async () => { const root = mkdtempSync(join(tmpdir(), "wrnexus-rpc-bad-service-")); roots.push(root); const app = join(root, "app"); mkdirSync(join(app, "pages"), { recursive: true }); mkdirSync(join(app, "services"), { recursive: true }); writeFileSync(join(app, "pages/home.ts"), "export default () => '';\n"); // A co-located helper with no default export — the natural thing a // developer drops next to a real service. writeFileSync(join(app, "services/types.ts"), "export type Foo = string;\n"); const handlers = makeHandlers(app, async (file) => { const normalized = file.replace(/\\/g, "/"); if (normalized.endsWith("services/types.ts")) return {}; // no default export if (normalized.endsWith("pages/home.ts")) return { default: () => "
home
" }; throw new Error(`unexpected module: ${file}`); }); const res = await handlers.fetch(new Request("https://example.test/home"), { upgrade: () => false, }); expect(await res!.text()).toContain("home"); }); test("the RPC path returns a structured failure instead of throwing when service load fails", async () => { const root = mkdtempSync(join(tmpdir(), "wrnexus-rpc-load-fail-")); roots.push(root); const app = join(root, "app"); mkdirSync(join(app, "services"), { recursive: true }); writeFileSync(join(app, "services/types.ts"), "export type Foo = string;\n"); const handlers = makeHandlers(app, async () => ({})); // no default export const res = await handlers.fetch( new Request("https://example.test/__wrnexus/rpc/types/greet", { method: "POST", headers: { "content-type": "application/json", "x-wrnexus-internal": "1" }, body: "{}", }), { upgrade: () => false }, ); expect(res).toBeDefined(); expect(res!.status).toBeLessThan(500); const body = await res!.json(); expect(body).toMatchObject({ ok: false, code: "RPC_UNKNOWN" }); }); test("a request after a load failure re-attempts rather than serving a cached rejection", async () => { const root = mkdtempSync(join(tmpdir(), "wrnexus-rpc-retry-")); roots.push(root); const app = join(root, "app"); mkdirSync(join(app, "services"), { recursive: true }); writeFileSync(join(app, "services/greeter.ts"), "export default {};\n"); let attempt = 0; const handlers = makeHandlers(app, async () => { attempt += 1; if (attempt === 1) return {}; // fails: no default export return { default: implement( greeter, { greet: async ({ name }) => ({ message: `Hi ${name}` }) }, { selfApp: "greeter", }, ), }; }); const call = () => handlers.fetch( new Request("https://example.test/__wrnexus/rpc/greeter/greet", { method: "POST", headers: { "content-type": "application/json", "x-wrnexus-internal": "1" }, body: JSON.stringify({ name: "Ada" }), }), { upgrade: () => false }, ); const first = await call(); expect(await first!.json()).toMatchObject({ ok: false, code: "RPC_UNKNOWN" }); const second = await call(); expect(await second!.json()).toEqual({ ok: true, value: { message: "Hi Ada" } }); expect(attempt).toBe(2); }); test("a contract name that does not match its mounted filename fails loudly, naming both", async () => { const root = mkdtempSync(join(tmpdir(), "wrnexus-rpc-name-mismatch-")); roots.push(root); const app = join(root, "app"); mkdirSync(join(app, "services"), { recursive: true }); writeFileSync(join(app, "services/greeter.ts"), "export default {};\n"); const billing = defineService({ name: "billing", procedures: { createInvoice: procedure .input(v.object({ amountCents: v.number() })) .output<{ invoiceId: string }>() .build(), }, }); const service = implement( billing, { createInvoice: async ({ amountCents }) => ({ invoiceId: `inv_${amountCents}` }) }, { selfApp: "billing" }, ); const handlers = makeHandlers(app, async () => ({ default: service })); // The typed client would call /__wrnexus/rpc/billing/... (contract name), // but the file mounts as "greeter" — the client's request 404s as unknown. const res = await handlers.fetch( new Request("https://example.test/__wrnexus/rpc/billing/createInvoice", { method: "POST", headers: { "content-type": "application/json", "x-wrnexus-internal": "1" }, body: JSON.stringify({ amountCents: 5 }), }), { upgrade: () => false }, ); const body = await res!.json(); expect(body).toMatchObject({ ok: false, code: "RPC_UNKNOWN" }); // And the actual mounted name ("greeter") also fails: the implementation's // contract does not match the filename it was mounted under. const res2 = await handlers.fetch( new Request("https://example.test/__wrnexus/rpc/greeter/createInvoice", { method: "POST", headers: { "content-type": "application/json", "x-wrnexus-internal": "1" }, body: JSON.stringify({ amountCents: 5 }), }), { upgrade: () => false }, ); const body2 = await res2!.json(); expect(body2).toMatchObject({ ok: false, code: "RPC_UNKNOWN" }); });