fix(rpc): close the four final-review blockers on inter-app RPC

- Resolve RPC call origins via a new WRNEXUS_INTERNAL_ORIGINS map (loopback
  origins the gateway hands each child before spawning it), falling back to
  the public appOrigin only when it is absent. Calls previously always went
  to the public gateway origin, which the gateway unconditionally 404s on
  the RPC prefix by design — every real cross-app call failed.
- Stop loadServices() from running ahead of routing and stop memoizing a
  rejected load: one bad file under app/services/ no longer permanently
  breaks every route in the app. A failed load logs loudly, is retried on
  the next RPC request, and the RPC path gets a structured RPC_UNKNOWN
  instead of an unhandled throw.
- Reject a service whose contract.name does not match the filename it is
  mounted under, naming both, instead of silently mounting under the
  filename while the typed client calls by contract name.
- Let ServiceError accept an explicit retryable and have the client pass the
  wire value through, instead of recomputing (and silently flipping) it from
  the error code alone.
- Document the gateway/X-Forwarded-* deployment requirement in the RPC
  README.

Each of the three code blockers has a new/extended test that was verified to
fail when its fix was reverted (rpc/test/integration.test.ts,
dev-server/test/rpc-services-loading.test.ts).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-05 20:36:31 +05:30
co-authored by Claude Opus 5
parent 6aaf21aa06
commit 3eec9fd8c6
11 changed files with 394 additions and 20 deletions
+10
View File
@@ -461,6 +461,14 @@ export async function startGateway(opts: GatewayOptions): Promise<RunningGateway
opts.apps.map((app) => [app.name, app.publicOrigin ?? `http://${app.domains[0]}:${port}`]),
),
);
// Loopback-only origins, computed up front (ports are assigned by index
// before any child spawns) so every child can reach every other child
// directly — bypassing the gateway, which 404s the RPC prefix by design.
const internalOriginsEnv: Readonly<Record<string, string>> = Object.freeze(
Object.fromEntries(
opts.apps.map((app, i) => [app.name, `http://127.0.0.1:${app.port ?? port + 1 + i}`]),
),
);
const displayHost = hostname === "0.0.0.0" || hostname === "::" ? "localhost" : hostname;
// When the CLI is executed directly from a framework checkout, keep child
// apps on that same source tree. Resolving the package name from an external
@@ -498,6 +506,7 @@ export async function startGateway(opts: GatewayOptions): Promise<RunningGateway
WRNEXUS_APP_NAME: app.name,
WRNEXUS_APP_ORIGIN: app.publicOrigin ?? `http://${app.domains[0]}:${port}`,
WRNEXUS_WORKSPACE_ORIGINS: JSON.stringify(workspaceOrigins),
WRNEXUS_INTERNAL_ORIGINS: JSON.stringify(internalOriginsEnv),
},
})
: spawn(
@@ -523,6 +532,7 @@ export async function startGateway(opts: GatewayOptions): Promise<RunningGateway
WRNEXUS_APP_NAME: app.name,
WRNEXUS_APP_ORIGIN: app.publicOrigin ?? `http://${app.domains[0]}:${port}`,
WRNEXUS_WORKSPACE_ORIGINS: JSON.stringify(workspaceOrigins),
WRNEXUS_INTERNAL_ORIGINS: JSON.stringify(internalOriginsEnv),
},
},
);
+54 -15
View File
@@ -89,7 +89,7 @@ import {
type ResolvedI18n,
} from "@wrnexus/i18n";
import { runMiddleware } from "./pipeline.ts";
import { handleRpcRequest } from "./rpc-dispatch.ts";
import { handleRpcRequest, isRpcPath } from "./rpc-dispatch.ts";
import type { ServiceImplementation } from "@wrnexus/rpc";
import type { HmrHub } from "./hmr.ts";
import type {
@@ -884,20 +884,43 @@ export function createHandlers(deps: RuntimeDeps): Handlers {
...(await getMiddleware()),
];
const maxBodyBytes = deps.maxBodyBytes ?? 10 * 1024 * 1024; // 10 MB default
// Not memoized across failures: a single bad file in app/services/ (e.g. a
// co-located helper with no default export) must not permanently break
// every route in the app. Only a SUCCESSFUL load is cached; a failed
// attempt logs loudly and is retried on the next RPC request.
let servicesPromise: Promise<Map<string, ServiceImplementation>> | undefined;
const loadServices = () =>
(servicesPromise ??= (async () => {
const services = new Map<string, ServiceImplementation>();
for (const entry of router.services) {
const imported = await loadModule(entry.file);
const implementation = imported.default as ServiceImplementation | undefined;
if (!implementation || typeof implementation.invoke !== "function") {
throw new Error(`RPC service ${entry.file} must default-export implement(...)`);
const loadServices = (): Promise<Map<string, ServiceImplementation>> => {
if (!servicesPromise) {
servicesPromise = (async () => {
const services = new Map<string, ServiceImplementation>();
for (const entry of router.services) {
const imported = await loadModule(entry.file);
const implementation = imported.default as ServiceImplementation | undefined;
if (!implementation || typeof implementation.invoke !== "function") {
throw new Error(`RPC service ${entry.file} must default-export implement(...)`);
}
if (implementation.contract.name !== entry.name) {
throw new Error(
`RPC service file ${entry.file} is mounted as "${entry.name}" (its filename) ` +
`but its contract is named "${implementation.contract.name}". Rename the file to ` +
`match the contract, or rename the contract to match the file.`,
);
}
services.set(entry.name, implementation);
}
services.set(entry.name, implementation);
}
return services;
})());
return services;
})().catch((error) => {
servicesPromise = undefined;
const app = process.env.WRNEXUS_APP_NAME ?? "app";
console.error(
`[wrnexus] failed to load RPC services (${app}):`,
error instanceof Error ? (error.stack ?? error.message) : error,
);
throw error;
});
}
return servicesPromise;
};
// Server-side realtime room manager (shared by every `defineRoom` connection).
const realtime = createRealtimeRegistry();
@@ -964,8 +987,24 @@ export function createHandlers(deps: RuntimeDeps): Handlers {
const secure = (res: Response): Response =>
withSecurityHeaders(req, res, mode, runtimeSecurity, nonce);
const rpcResponse = await handleRpcRequest(req, url, await loadServices());
if (rpcResponse) return secure(rpcResponse);
if (isRpcPath(url.pathname)) {
let services: Map<string, ServiceImplementation>;
try {
services = await loadServices();
} catch (error) {
const app = process.env.WRNEXUS_APP_NAME ?? "app";
const detail = error instanceof Error ? (error.stack ?? error.message) : String(error);
console.error(`[wrnexus] RPC request failed to load services (${app})\n${detail}`);
return secure(
Response.json(
{ ok: false, code: "RPC_UNKNOWN", message: "Service unavailable", retryable: false },
{ headers: { "cache-control": "private, no-store" } },
),
);
}
const rpcResponse = await handleRpcRequest(req, url, services);
if (rpcResponse) return secure(rpcResponse);
}
const preflight = createCorsPreflightResponse(req, deps.security);
if (preflight) return secure(preflight);
@@ -0,0 +1,170 @@
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: () => "<p>home</p>" };
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" });
});