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:
@@ -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,
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user