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 { 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 {
resolveThemeConfig,
@@ -88,6 +95,25 @@ export function validateRpcCsrf(request: Request): boolean {
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 { createPluginRunner, discoverPlugins, type PluginInput } from "@wrnexus/plugin";
import type { ObservabilityConfig, TenancyConfig } from "@wrnexus/styles";
@@ -603,6 +629,7 @@ export async function startServer(opts: ServeOptions): Promise<RunningServer> {
},
validateCsrf: validateRpcCsrf,
});
const serverFnRpcHandler = withServerFnRequestContext(rpcHandler);
/*
* 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 } : {}),
fetch(request, server) {
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);
},
websocket: handlers.websocket,
+38 -30
View File
@@ -1048,22 +1048,28 @@ export function createHandlers(deps: RuntimeDeps): Handlers {
withSecurityHeaders(req, res, mode, runtimeSecurity, nonce);
if (isRpcPath(url.pathname)) {
let services: Map<string, ServiceImplementation | StreamImplementation>;
try {
services = await loadServices();
} catch (error) {
const app = process.env.WRNEXUS_APP_NAME ?? "app";
const detail = error instanceof Error ? (error.stack ?? error.message) : String(error);
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 },
{ headers: { "cache-control": "private, no-store" } },
),
);
}
const rpcResponse = await handleRpcRequest(req, url, services);
if (rpcResponse) return secure(rpcResponse);
// Service RPC handlers run user code (implement()/implementStream()), so
// they need the same request context an ordinary API route gets.
const rpcCtx = createContext(req, url);
const rpcOutcome = await runWithRequestContext(rpcCtx, async () => {
let services: Map<string, ServiceImplementation | StreamImplementation>;
try {
services = await loadServices();
} catch (error) {
const app = process.env.WRNEXUS_APP_NAME ?? "app";
const detail = error instanceof Error ? (error.stack ?? error.message) : String(error);
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 },
{ headers: { "cache-control": "private, no-store" } },
),
);
}
const rpcResponse = await handleRpcRequest(req, url, services);
return rpcResponse ? secure(rpcResponse) : undefined;
});
if (rpcOutcome) return rpcOutcome;
}
const preflight = createCorsPreflightResponse(req, deps.security);
@@ -2179,21 +2185,23 @@ export function createHandlers(deps: RuntimeDeps): Handlers {
headers.set("x-wrnexus-hmr", "1");
const req = new Request(url, { headers });
const ctx = createContext(req, url);
initializeRequestCache(ctx);
ctx.locals.cspNonce = randomNonce();
if (deps.i18n) {
ctx.lang = resolveLang(
deps.i18n,
ctx.cookies.get(deps.i18n.cookie.name),
req.headers.get("accept-language"),
const html = await runWithRequestContext(ctx, async () => {
initializeRequestCache(ctx);
ctx.locals.cspNonce = randomNonce();
if (deps.i18n) {
ctx.lang = resolveLang(
deps.i18n,
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);
}
const res = withContextHeaders(
ctx,
await runMiddleware(await resolveMiddleware(), ctx, () => dispatch(ctx)),
);
const html = await res.text();
return res.text();
});
ws.send(JSON.stringify({ type: "html", html }));
} catch (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();
});