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:
@@ -8,14 +8,7 @@
|
|||||||
|
|
||||||
import { mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
import { mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
||||||
import { resolve, dirname, isAbsolute, join } from "node:path";
|
import { resolve, dirname, isAbsolute, join } from "node:path";
|
||||||
import {
|
import { type Middleware, type Mode, type SecurityConfig, type SeoConfig } from "@wrnexus/core";
|
||||||
createContext,
|
|
||||||
runWithRequestContext,
|
|
||||||
type Middleware,
|
|
||||||
type Mode,
|
|
||||||
type SecurityConfig,
|
|
||||||
type SeoConfig,
|
|
||||||
} from "@wrnexus/core";
|
|
||||||
import { buildRouter, type Router } from "@wrnexus/router";
|
import { buildRouter, type Router } from "@wrnexus/router";
|
||||||
import {
|
import {
|
||||||
resolveThemeConfig,
|
resolveThemeConfig,
|
||||||
@@ -83,36 +76,11 @@ export { getWrnCompileMetrics, resetWrnCompileMetrics } from "./pipeline.ts";
|
|||||||
export type { WrnCompileMetrics } from "./pipeline.ts";
|
export type { WrnCompileMetrics } from "./pipeline.ts";
|
||||||
import { resetDevCache } from "./cache.ts";
|
import { resetDevCache } from "./cache.ts";
|
||||||
|
|
||||||
export function validateRpcCsrf(request: Request): boolean {
|
// Shared with prod.ts — kept in their own module to avoid a circular import
|
||||||
const url = new URL(request.url);
|
// (index.ts re-exports createProductionServer/createProductionHandlers from
|
||||||
const origin = request.headers.get("origin");
|
// prod.ts, so prod.ts cannot import these back from index.ts).
|
||||||
if (origin && origin !== url.origin) return false;
|
import { validateRpcCsrf, withServerFnRequestContext } from "./rpc-shared.ts";
|
||||||
const cookieHeader = request.headers.get("cookie") ?? "";
|
export { validateRpcCsrf, withServerFnRequestContext };
|
||||||
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));
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
import type { DevToolbarConfig } from "@wrnexus/dev-toolbar/types";
|
import type { DevToolbarConfig } from "@wrnexus/dev-toolbar/types";
|
||||||
import { createPluginRunner, discoverPlugins, type PluginInput } from "@wrnexus/plugin";
|
import { createPluginRunner, discoverPlugins, type PluginInput } from "@wrnexus/plugin";
|
||||||
|
|||||||
@@ -54,6 +54,8 @@ import { createHandlers, type AssetServer, type WsData } from "./runtime.ts";
|
|||||||
import { servePublicAsset } from "./public.ts";
|
import { servePublicAsset } from "./public.ts";
|
||||||
import type { ClientRuntimeDefinition } from "@wrnexus/plugin";
|
import type { ClientRuntimeDefinition } from "@wrnexus/plugin";
|
||||||
import { HmrHub } from "./hmr.ts";
|
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>;
|
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
|
* wraps it in `Bun.serve`, `serveNode` bridges it onto `node:http`, and edge or
|
||||||
* serverless targets can call `fetch` directly.
|
* 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(
|
export function createProductionHandlers(
|
||||||
manifest: ProdManifest,
|
manifest: ProdManifest,
|
||||||
opts: ProdOptions,
|
opts: ProdOptions,
|
||||||
@@ -529,7 +573,22 @@ export function createProductionHandlers(
|
|||||||
realtimeBus: realtimeBusFromConfig(opts.realtime),
|
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 };
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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));
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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<string, string> = 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user