fix(dev-server): route server.fn() RPC to a handler in production

server.fn() posts to POST /__wrnexus/rpc. Dev intercepts that path before
handlers.fetch and routes it to a createRpcHandler instance built from
loadWrnServerModule; createProductionServer/createProductionHandlers had no
such route, so the request fell through to the internal-caller-gated
inter-app service RPC and 404'd.

Add resolveProdServerFunctions(), a synchronous equivalent of dev's resolve
that searches the already-statically-imported ProdManifest components/pages/
layouts for __wrnexusServerFunctions + __wrnexusRpcManifest, and wire it into
createProductionHandlers with the same validateCsrf + withServerFnRequestContext
wrapping dev uses. Move those two helpers into a new rpc-shared.ts so prod.ts
can use them without a circular import through index.ts.

Add packages/dev-server/test/prod-server-fn-rpc.test.ts covering a successful
call, CSRF rejection, and clean 404s for an unknown component/function.
This commit is contained in:
2026-08-20 06:41:32 +05:30
parent c7e40154ca
commit 768074ac0a
4 changed files with 215 additions and 39 deletions
+6 -38
View File
@@ -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<Response>,
): (request: Request) => Promise<Response> {
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";
+60 -1
View File
@@ -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<string, unknown>;
@@ -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<string, (...args: any[]) => 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<string, (...args: any[]) => 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 };
}
/**
+46
View File
@@ -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<Response>,
): (request: Request) => Promise<Response> {
return (request: Request) => {
const url = new URL(request.url);
const ctx = createContext(request, url);
return runWithRequestContext(ctx, () => handler(request));
};
}