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:
@@ -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 };
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user