diff --git a/packages/dev-server/src/index.ts b/packages/dev-server/src/index.ts index 1a208d66..ad2189d5 100644 --- a/packages/dev-server/src/index.ts +++ b/packages/dev-server/src/index.ts @@ -8,14 +8,7 @@ import { mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { resolve, dirname, isAbsolute, join } from "node:path"; -import { - createContext, - runWithRequestContext, - type Middleware, - type Mode, - type SecurityConfig, - type SeoConfig, -} from "@wrnexus/core"; +import { type Middleware, type Mode, type SecurityConfig, type SeoConfig } from "@wrnexus/core"; import { buildRouter, type Router } from "@wrnexus/router"; import { resolveThemeConfig, @@ -83,36 +76,11 @@ export { getWrnCompileMetrics, resetWrnCompileMetrics } from "./pipeline.ts"; export type { WrnCompileMetrics } from "./pipeline.ts"; import { resetDevCache } from "./cache.ts"; -export function validateRpcCsrf(request: Request): boolean { - const url = new URL(request.url); - const origin = request.headers.get("origin"); - if (origin && origin !== url.origin) return false; - const cookieHeader = request.headers.get("cookie") ?? ""; - const cookieToken = - /(?:^|;\s*)wrn-csrf=([^;]+)/.exec(cookieHeader)?.[1] ?? - /(?:^|;\s*)wrnexus_csrf=([^;]+)/.exec(cookieHeader)?.[1]; - const headerToken = request.headers.get("x-csrf-token") ?? request.headers.get("x-wrnexus-csrf"); - return Boolean(cookieToken && headerToken && decodeURIComponent(cookieToken) === headerToken); -} - -/** - * Wrap the server-function RPC handler so it runs inside the request's - * AsyncLocalStorage context. `/__wrnexus/rpc` is intercepted BEFORE - * `handlers.fetch` (fetchHandler) in the dev server, so a server function - * called via `server.fn()` from the browser runs entirely outside - * fetchHandler's own context wrap. It runs user code directly, so — like - * every other entry point that runs user server code — it needs the request - * context too. - */ -export function withServerFnRequestContext( - handler: (request: Request) => Promise, -): (request: Request) => Promise { - return (request: Request) => { - const url = new URL(request.url); - const ctx = createContext(request, url); - return runWithRequestContext(ctx, () => handler(request)); - }; -} +// Shared with prod.ts — kept in their own module to avoid a circular import +// (index.ts re-exports createProductionServer/createProductionHandlers from +// prod.ts, so prod.ts cannot import these back from index.ts). +import { validateRpcCsrf, withServerFnRequestContext } from "./rpc-shared.ts"; +export { validateRpcCsrf, withServerFnRequestContext }; import type { DevToolbarConfig } from "@wrnexus/dev-toolbar/types"; import { createPluginRunner, discoverPlugins, type PluginInput } from "@wrnexus/plugin"; diff --git a/packages/dev-server/src/prod.ts b/packages/dev-server/src/prod.ts index d083840d..21746129 100644 --- a/packages/dev-server/src/prod.ts +++ b/packages/dev-server/src/prod.ts @@ -54,6 +54,8 @@ import { createHandlers, type AssetServer, type WsData } from "./runtime.ts"; import { servePublicAsset } from "./public.ts"; import type { ClientRuntimeDefinition } from "@wrnexus/plugin"; import { HmrHub } from "./hmr.ts"; +import { createRpcHandler, type RpcManifestContract } from "@wrnexus/ssr/rpc"; +import { validateRpcCsrf, withServerFnRequestContext } from "./rpc-shared.ts"; type RouteModule = Record; @@ -429,6 +431,48 @@ function createProdAssetServer(opts: ProdOptions): AssetServer { * wraps it in `Bun.serve`, `serveNode` bridges it onto `node:http`, and edge or * serverless targets can call `fetch` directly. */ +/** + * Resolve the `.wrn` server-function module owning `rawComponent`, from the + * manifest's already-loaded static imports (production has no dynamic module + * loading, so unlike dev's `resolve` this is a synchronous in-memory search). + * Searches components (preferring an exact name match), then pages, then + * layouts — mirroring the candidate order dev's `resolve` walks in + * index.ts, minus `router.stores`, which the production router never + * populates (see `buildProdRouter` above). + */ +function resolveProdServerFunctions( + manifest: ProdManifest, + rawComponent: string, +): { + functions: Record any>; + manifest: RpcManifestContract[]; +} | null { + const componentName = rawComponent.split(":", 1)[0] ?? rawComponent; + const preferred = manifest.components.find( + (entry) => entry.name.toLowerCase() === componentName.toLowerCase(), + ); + const candidates: RouteModule[] = [ + ...(preferred ? [preferred.mod] : []), + ...manifest.pages.map((entry) => entry.mod), + ...manifest.layouts.map((entry) => entry.mod), + ...manifest.components.filter((entry) => entry !== preferred).map((entry) => entry.mod), + ]; + for (const mod of new Set(candidates)) { + const functions = mod.__wrnexusServerFunctions; + const rpcManifest = mod.__wrnexusRpcManifest; + if (!functions || typeof functions !== "object" || !Array.isArray(rpcManifest)) continue; + const ownsComponent = rpcManifest.some( + (entry: any) => String(entry?.component ?? "").toLowerCase() === componentName.toLowerCase(), + ); + if (!ownsComponent) continue; + return { + functions: functions as Record any>, + manifest: rpcManifest as RpcManifestContract[], + }; + } + return null; +} + export function createProductionHandlers( manifest: ProdManifest, opts: ProdOptions, @@ -529,7 +573,22 @@ export function createProductionHandlers( realtimeBus: realtimeBusFromConfig(opts.realtime), }); - return handlers; + // `/__wrnexus/rpc` (server.fn() calls from the browser) must be intercepted + // BEFORE handlers.fetch, exactly as dev does in index.ts — it is NOT an app + // route and must never fall through to isRpcPath/handleRpcRequest (the + // internal-caller-gated inter-app service RPC), which would 404 it. + const rpcHandler = createRpcHandler({ + resolve: async (rawComponent) => resolveProdServerFunctions(manifest, rawComponent), + validateCsrf: validateRpcCsrf, + }); + const serverFnRpcHandler = withServerFnRequestContext(rpcHandler); + const appFetch = handlers.fetch; + const fetchWithServerFnRpc: typeof handlers.fetch = (request, server) => { + if (new URL(request.url).pathname === "/__wrnexus/rpc") return serverFnRpcHandler(request); + return appFetch(request, server); + }; + + return { ...handlers, fetch: fetchWithServerFnRpc }; } /** diff --git a/packages/dev-server/src/rpc-shared.ts b/packages/dev-server/src/rpc-shared.ts new file mode 100644 index 00000000..d1024346 --- /dev/null +++ b/packages/dev-server/src/rpc-shared.ts @@ -0,0 +1,46 @@ +/** + * Shared server-function RPC helpers used by both the dev server (index.ts) + * and the production server (prod.ts). Kept in their own module so prod.ts + * can import them without creating a circular dependency on index.ts (which + * itself re-exports `createProductionServer`/`createProductionHandlers` from + * prod.ts). + */ + +import { createContext, runWithRequestContext } from "@wrnexus/core"; + +/** + * CSRF check for `/__wrnexus/rpc` (the server-function RPC endpoint the + * browser runtime calls for `server.fn()`). Requires a same-origin request + * carrying a matching CSRF cookie + header pair — the standard double-submit + * cookie pattern. + */ +export function validateRpcCsrf(request: Request): boolean { + const url = new URL(request.url); + const origin = request.headers.get("origin"); + if (origin && origin !== url.origin) return false; + const cookieHeader = request.headers.get("cookie") ?? ""; + const cookieToken = + /(?:^|;\s*)wrn-csrf=([^;]+)/.exec(cookieHeader)?.[1] ?? + /(?:^|;\s*)wrnexus_csrf=([^;]+)/.exec(cookieHeader)?.[1]; + const headerToken = request.headers.get("x-csrf-token") ?? request.headers.get("x-wrnexus-csrf"); + return Boolean(cookieToken && headerToken && decodeURIComponent(cookieToken) === headerToken); +} + +/** + * Wrap the server-function RPC handler so it runs inside the request's + * AsyncLocalStorage context. `/__wrnexus/rpc` is intercepted BEFORE + * `handlers.fetch` (fetchHandler) in both dev and production, so a server + * function called via `server.fn()` from the browser runs entirely outside + * fetchHandler's own context wrap. It runs user code directly, so — like + * every other entry point that runs user server code — it needs the request + * context too. + */ +export function withServerFnRequestContext( + handler: (request: Request) => Promise, +): (request: Request) => Promise { + return (request: Request) => { + const url = new URL(request.url); + const ctx = createContext(request, url); + return runWithRequestContext(ctx, () => handler(request)); + }; +} diff --git a/packages/dev-server/test/prod-server-fn-rpc.test.ts b/packages/dev-server/test/prod-server-fn-rpc.test.ts new file mode 100644 index 00000000..98c33228 --- /dev/null +++ b/packages/dev-server/test/prod-server-fn-rpc.test.ts @@ -0,0 +1,103 @@ +import { describe, expect, test } from "bun:test"; +import { createProductionHandlers, type ProdManifest } from "../src/prod.ts"; + +/** + * Production-only regression test for server.fn() RPC. The browser runtime + * posts server-function calls to `/__wrnexus/rpc`. Dev intercepts that path + * with a handler built from `createRpcHandler` (index.ts). Production had no + * such route: the request fell through to `isRpcPath`/`handleRpcRequest` — + * the *inter-app service* RPC, gated by an internal-caller header a browser + * never sends — and 404'd. See `resolveProdServerFunctions` in prod.ts. + */ + +// Shape emitted by the compiler for a `.wrn` file with server functions +// (packages/compiler/src/server-codegen.ts): a plain functions object plus a +// manifest array of `{ component, function, parameters?, returnType? }`. +function serverModule(component: string) { + return { + __wrnexusServerFunctions: { + async greet(name: string) { + return `hello ${name}`; + }, + }, + __wrnexusRpcManifest: [ + { + component, + function: "greet", + parameters: [{ name: "name", type: "string", optional: false }], + returnType: "string", + }, + ], + }; +} + +function manifest(): ProdManifest { + return { + pages: [], + api: [], + realtime: [], + middleware: [], + components: [{ name: "Greeter", mod: serverModule("Greeter") }], + layouts: [], + }; +} + +const CSRF_TOKEN = "test-token"; +const CSRF_HEADERS = { + origin: "http://localhost", + cookie: `wrn-csrf=${CSRF_TOKEN}`, + "x-csrf-token": CSRF_TOKEN, +}; + +function rpcRequest(body: unknown, headers: Record = CSRF_HEADERS): Request { + return new Request("http://localhost/__wrnexus/rpc", { + method: "POST", + headers: { "content-type": "application/json", ...headers }, + body: JSON.stringify(body), + }); +} + +describe("production server.fn() RPC (/__wrnexus/rpc)", () => { + test("a valid call to an existing server function succeeds, not 404", async () => { + const handlers = createProductionHandlers(manifest(), {}); + const req = rpcRequest({ component: "Greeter", function: "greet", args: ["world"] }); + const res = await handlers.fetch(req, {} as never); + expect(res).toBeDefined(); + expect(res!.status).toBe(200); + const body = await res!.json(); + expect(body).toMatchObject({ ok: true, value: "hello world" }); + }); + + test("a request without a valid CSRF token is rejected", async () => { + const handlers = createProductionHandlers(manifest(), {}); + const req = rpcRequest( + { component: "Greeter", function: "greet", args: ["world"] }, + { "content-type": "application/json" }, + ); + const res = await handlers.fetch(req, {} as never); + expect(res).toBeDefined(); + expect(res!.status).toBe(403); + const body = await res!.json(); + expect(body.ok).toBe(false); + }); + + test("an unknown component fails cleanly (404), not with an internal error", async () => { + const handlers = createProductionHandlers(manifest(), {}); + const req = rpcRequest({ component: "NoSuchComponent", function: "greet", args: [] }); + const res = await handlers.fetch(req, {} as never); + expect(res).toBeDefined(); + expect(res!.status).toBe(404); + const body = await res!.json(); + expect(body.ok).toBe(false); + }); + + test("an unknown function on a known component fails cleanly (404)", async () => { + const handlers = createProductionHandlers(manifest(), {}); + const req = rpcRequest({ component: "Greeter", function: "notARealFunction", args: [] }); + const res = await handlers.fetch(req, {} as never); + expect(res).toBeDefined(); + expect(res!.status).toBe(404); + const body = await res!.json(); + expect(body.ok).toBe(false); + }); +});