fix(core): establish request context at every server-code entry point

Wraps the three additional entry points where user server code runs
outside fetchHandler's own context wrap:
- the server-function RPC path (/__wrnexus/rpc) intercepted before
  handlers.fetch in the dev server (index.ts) - what server.fn() travels
- the service RPC path (isRpcPath) inside fetchHandler, which runs
  implement()/implementStream() service code before ctx existed
- the HMR-sync handler, which runs real load blocks/actions via dispatch()

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-20 02:55:18 +05:30
co-authored by Claude Opus 5
parent 680ea73975
commit c7e40154ca
3 changed files with 94 additions and 32 deletions
+29 -2
View File
@@ -8,7 +8,14 @@
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 type { Middleware, Mode, SecurityConfig, SeoConfig } from "@wrnexus/core"; import {
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,
@@ -88,6 +95,25 @@ export function validateRpcCsrf(request: Request): boolean {
return Boolean(cookieToken && headerToken && decodeURIComponent(cookieToken) === headerToken); 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";
import type { ObservabilityConfig, TenancyConfig } from "@wrnexus/styles"; import type { ObservabilityConfig, TenancyConfig } from "@wrnexus/styles";
@@ -603,6 +629,7 @@ export async function startServer(opts: ServeOptions): Promise<RunningServer> {
}, },
validateCsrf: validateRpcCsrf, validateCsrf: validateRpcCsrf,
}); });
const serverFnRpcHandler = withServerFnRequestContext(rpcHandler);
/* /*
* Hot rebuilds retain their predecessors (see recycle.ts). Only dev reloads * Hot rebuilds retain their predecessors (see recycle.ts). Only dev reloads
@@ -628,7 +655,7 @@ export async function startServer(opts: ServeOptions): Promise<RunningServer> {
...(opts.tls ? { tls: opts.tls } : {}), ...(opts.tls ? { tls: opts.tls } : {}),
fetch(request, server) { fetch(request, server) {
recycle?.recordRequest(Date.now()); recycle?.recordRequest(Date.now());
if (new URL(request.url).pathname === "/__wrnexus/rpc") return rpcHandler(request); if (new URL(request.url).pathname === "/__wrnexus/rpc") return serverFnRpcHandler(request);
return handlers.fetch(request, server); return handlers.fetch(request, server);
}, },
websocket: handlers.websocket, websocket: handlers.websocket,
+38 -30
View File
@@ -1048,22 +1048,28 @@ export function createHandlers(deps: RuntimeDeps): Handlers {
withSecurityHeaders(req, res, mode, runtimeSecurity, nonce); withSecurityHeaders(req, res, mode, runtimeSecurity, nonce);
if (isRpcPath(url.pathname)) { if (isRpcPath(url.pathname)) {
let services: Map<string, ServiceImplementation | StreamImplementation>; // Service RPC handlers run user code (implement()/implementStream()), so
try { // they need the same request context an ordinary API route gets.
services = await loadServices(); const rpcCtx = createContext(req, url);
} catch (error) { const rpcOutcome = await runWithRequestContext(rpcCtx, async () => {
const app = process.env.WRNEXUS_APP_NAME ?? "app"; let services: Map<string, ServiceImplementation | StreamImplementation>;
const detail = error instanceof Error ? (error.stack ?? error.message) : String(error); try {
console.error(`[wrnexus] RPC request failed to load services (${app})\n${detail}`); services = await loadServices();
return secure( } catch (error) {
Response.json( const app = process.env.WRNEXUS_APP_NAME ?? "app";
{ ok: false, code: "RPC_UNKNOWN", message: "Service unavailable", retryable: false }, const detail = error instanceof Error ? (error.stack ?? error.message) : String(error);
{ headers: { "cache-control": "private, no-store" } }, 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 },
const rpcResponse = await handleRpcRequest(req, url, services); { headers: { "cache-control": "private, no-store" } },
if (rpcResponse) return secure(rpcResponse); ),
);
}
const rpcResponse = await handleRpcRequest(req, url, services);
return rpcResponse ? secure(rpcResponse) : undefined;
});
if (rpcOutcome) return rpcOutcome;
} }
const preflight = createCorsPreflightResponse(req, deps.security); const preflight = createCorsPreflightResponse(req, deps.security);
@@ -2179,21 +2185,23 @@ export function createHandlers(deps: RuntimeDeps): Handlers {
headers.set("x-wrnexus-hmr", "1"); headers.set("x-wrnexus-hmr", "1");
const req = new Request(url, { headers }); const req = new Request(url, { headers });
const ctx = createContext(req, url); const ctx = createContext(req, url);
initializeRequestCache(ctx); const html = await runWithRequestContext(ctx, async () => {
ctx.locals.cspNonce = randomNonce(); initializeRequestCache(ctx);
if (deps.i18n) { ctx.locals.cspNonce = randomNonce();
ctx.lang = resolveLang( if (deps.i18n) {
deps.i18n, ctx.lang = resolveLang(
ctx.cookies.get(deps.i18n.cookie.name), deps.i18n,
req.headers.get("accept-language"), ctx.cookies.get(deps.i18n.cookie.name),
req.headers.get("accept-language"),
);
ctx.t = makeT(deps.i18n, ctx.lang);
}
const res = withContextHeaders(
ctx,
await runMiddleware(await resolveMiddleware(), ctx, () => dispatch(ctx)),
); );
ctx.t = makeT(deps.i18n, ctx.lang); return res.text();
} });
const res = withContextHeaders(
ctx,
await runMiddleware(await resolveMiddleware(), ctx, () => dispatch(ctx)),
);
const html = await res.text();
ws.send(JSON.stringify({ type: "html", html })); ws.send(JSON.stringify({ type: "html", html }));
} catch (err) { } catch (err) {
if (mode === "development") console.error(err); if (mode === "development") console.error(err);
@@ -0,0 +1,27 @@
import { expect, test } from "bun:test";
import { getRequestContext } from "@wrnexus/core";
import { withServerFnRequestContext } from "../src/index.ts";
// The server-function RPC path ("/__wrnexus/rpc", what server.fn() calls from
// the browser) is intercepted before fetchHandler in the dev server and would
// otherwise run entirely outside the AsyncLocalStorage wrap. This is the
// property that was broken: a handler invoked through that path must see the
// request context, not `undefined`.
test("a handler invoked through the server-function RPC path can read the request context", async () => {
let seen: unknown;
const innerHandler = async (request: Request): Promise<Response> => {
seen = getRequestContext();
return new Response("ok");
};
const wrapped = withServerFnRequestContext(innerHandler);
const request = new Request("http://localhost/__wrnexus/rpc", { method: "POST" });
await wrapped(request);
expect(seen).toBeDefined();
expect((seen as { req: Request }).req).toBe(request);
});
test("there is no context outside the wrap", () => {
expect(getRequestContext()).toBeUndefined();
});