diff --git a/packages/dev-server/src/index.ts b/packages/dev-server/src/index.ts index 71c7bc78..1a208d66 100644 --- a/packages/dev-server/src/index.ts +++ b/packages/dev-server/src/index.ts @@ -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, +): (request: Request) => Promise { + 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 { }, 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 { ...(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, diff --git a/packages/dev-server/src/runtime.ts b/packages/dev-server/src/runtime.ts index ca0e6da2..567b1631 100644 --- a/packages/dev-server/src/runtime.ts +++ b/packages/dev-server/src/runtime.ts @@ -1048,22 +1048,28 @@ export function createHandlers(deps: RuntimeDeps): Handlers { withSecurityHeaders(req, res, mode, runtimeSecurity, nonce); if (isRpcPath(url.pathname)) { - let services: Map; - 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; + 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); diff --git a/packages/dev-server/test/server-fn-rpc-request-context.test.ts b/packages/dev-server/test/server-fn-rpc-request-context.test.ts new file mode 100644 index 00000000..d08732d9 --- /dev/null +++ b/packages/dev-server/test/server-fn-rpc-request-context.test.ts @@ -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 => { + 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(); +});