From 680ea73975c8f8487d504e327d8d971477891153 Mon Sep 17 00:00:00 2001 From: Ajay Ghanwat Date: Thu, 20 Aug 2026 02:46:24 +0530 Subject: [PATCH] feat(core): carry the request context in an AsyncLocalStorage Co-Authored-By: Claude Opus 5 --- packages/core/src/index.ts | 5 + packages/core/src/request-context.ts | 33 ++++++ packages/core/test/request-context.test.ts | 49 ++++++++ packages/dev-server/src/runtime.ts | 125 +++++++++++---------- 4 files changed, 151 insertions(+), 61 deletions(-) create mode 100644 packages/core/src/request-context.ts create mode 100644 packages/core/test/request-context.test.ts diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index dbf0d0ba..4fd7c34b 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -11,6 +11,11 @@ export type { TFunction, } from "./context.ts"; export { createContext, withContextHeaders } from "./context.ts"; +export { + runWithRequestContext, + getRequestContext, + requireRequestContext, +} from "./request-context.ts"; export { createExecutionContext, executionContextFromHttp } from "./execution-context.ts"; export type { ExecutionContext, diff --git a/packages/core/src/request-context.ts b/packages/core/src/request-context.ts new file mode 100644 index 00000000..7937a1ea --- /dev/null +++ b/packages/core/src/request-context.ts @@ -0,0 +1,33 @@ +import { AsyncLocalStorage } from "node:async_hooks"; +import type { Context } from "./context.ts"; + +/** + * The request context for the currently executing server work. + * + * A server-side API call needs the request's cookies, session, and URL, but + * `ctx` is not in scope everywhere server code runs: load blocks have it, + * schema actions take it as a parameter, and plain actions and server + * functions have neither. Threading it through every signature would make the + * call site differ between server and browser, which defeats the point. + */ +const storage = new AsyncLocalStorage(); + +export function runWithRequestContext(ctx: Context, fn: () => T): T { + return storage.run(ctx, fn); +} + +export function getRequestContext(): Context | undefined { + return storage.getStore(); +} + +export function requireRequestContext(what: string): Context { + const ctx = storage.getStore(); + + if (!ctx) { + throw new Error( + `${what} needs a request context. It ran outside a request — server-side API calls are only available while handling one.`, + ); + } + + return ctx; +} diff --git a/packages/core/test/request-context.test.ts b/packages/core/test/request-context.test.ts new file mode 100644 index 00000000..c6d05756 --- /dev/null +++ b/packages/core/test/request-context.test.ts @@ -0,0 +1,49 @@ +import { expect, test } from "bun:test"; +import { + getRequestContext, + requireRequestContext, + runWithRequestContext, +} from "../src/request-context.ts"; + +const ctx = { marker: "the-request" } as never; + +test("the context is visible inside the run", () => { + runWithRequestContext(ctx, () => { + expect(getRequestContext()).toBe(ctx); + }); +}); + +test("the context is visible across an await", async () => { + // The whole point is that it survives async boundaries a caller cannot see. + await runWithRequestContext(ctx, async () => { + await new Promise((resolve) => setTimeout(resolve, 1)); + expect(getRequestContext()).toBe(ctx); + }); +}); + +test("there is no context outside a run", () => { + expect(getRequestContext()).toBeUndefined(); +}); + +test("requireRequestContext throws a message naming the caller", () => { + expect(() => requireRequestContext("api.searchUsers")).toThrow(/api\.searchUsers/); +}); + +test("concurrent runs do not see each other's context", async () => { + const first = { id: 1 } as never; + const second = { id: 2 } as never; + const seen: unknown[] = []; + + await Promise.all([ + runWithRequestContext(first, async () => { + await new Promise((resolve) => setTimeout(resolve, 5)); + seen.push(getRequestContext()); + }), + runWithRequestContext(second, async () => { + seen.push(getRequestContext()); + }), + ]); + + expect(seen).toContain(first); + expect(seen).toContain(second); +}); diff --git a/packages/dev-server/src/runtime.ts b/packages/dev-server/src/runtime.ts index 50f82bab..ca0e6da2 100644 --- a/packages/dev-server/src/runtime.ts +++ b/packages/dev-server/src/runtime.ts @@ -23,6 +23,7 @@ import { isSafeRequestPath, renderError, renderNotFound, + runWithRequestContext, withContextHeaders, withSecurityHeaders, resolveRequestUrl, @@ -1266,71 +1267,73 @@ export function createHandlers(deps: RuntimeDeps): Handlers { return secure(new Response("Expected a WebSocket upgrade request", { status: 426 })); } - try { - const ctx = createContext(req, url); - initializeRequestCache(ctx); - ctx.ip = server.requestIP?.(req)?.address ?? undefined; - ctx.locals.cspNonce = nonce; // available to pages for their own inline scripts - if (!["GET", "HEAD", "OPTIONS"].includes(req.method.toUpperCase())) { - const contentType = req.headers.get("content-type") ?? ""; - if (contentType.includes("form")) { - try { - const form = await req.clone().formData(); - const token = form.get("_csrf"); - if (typeof token === "string") ctx.locals._csrf = token; - } catch { - // The endpoint will return its normal malformed-input response. + const ctx = createContext(req, url); + return runWithRequestContext(ctx, async () => { + try { + initializeRequestCache(ctx); + ctx.ip = server.requestIP?.(req)?.address ?? undefined; + ctx.locals.cspNonce = nonce; // available to pages for their own inline scripts + if (!["GET", "HEAD", "OPTIONS"].includes(req.method.toUpperCase())) { + const contentType = req.headers.get("content-type") ?? ""; + if (contentType.includes("form")) { + try { + const form = await req.clone().formData(); + const token = form.get("_csrf"); + if (typeof token === "string") ctx.locals._csrf = token; + } catch { + // The endpoint will return its normal malformed-input response. + } } } - } - // Resolve the request language so both pages and API can translate. - if (deps.i18n) { - ctx.lang = resolveLang( - deps.i18n, - ctx.cookies.get(deps.i18n.cookie.name), - req.headers.get("accept-language"), + // Resolve the request language so both pages and API can translate. + 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 mws = await resolveMiddleware(); + const res = secure( + withContextHeaders(ctx, await runMiddleware(mws, ctx, () => dispatch(ctx))), ); - ctx.t = makeT(deps.i18n, ctx.lang); + return compressResponse(req, res); + } catch (err) { + const app = process.env.WRNEXUS_APP_NAME ?? "app"; + let detail = + err instanceof Error ? (err.stack ?? `${err.name}: ${err.message}`) : String(err); + const message = err instanceof Error ? `${err.name}: ${err.message}` : String(err); + // AggregateError (e.g. Bun.build() "Bundle failed") hides its real cause in + // `.errors` — the top-level message/stack alone is useless for diagnosing a + // failed bundle. Print every nested error so the actual failure is visible. + const nested = (err as { errors?: unknown[] } | undefined)?.errors; + if (Array.isArray(nested) && nested.length) { + detail += + "\n caused by:\n" + + nested + .map((e, i) => ` [${i}] ${e instanceof Error ? (e.stack ?? e.message) : String(e)}`) + .join("\n"); + } + console.error( + `[wrnexus] unhandled request error (${app}) ${req.method} ${url.pathname}\n${detail}`, + ); + deps.devToolbar?.collector.add( + issueFromError(err, { + ruleId: "server/request-error", + category: "server", + title: "Request processing failed", + pathname: url.pathname, + }), + ); + const response = secure(renderError(err, mode)); + // Gateway-managed production apps bind to loopback. Carry a bounded, + // encoded diagnostic to the parent gateway so centralized log collectors + // can explain child failures; the gateway always strips this header. + response.headers.set("x-wrnexus-internal-error", encodeURIComponent(message.slice(0, 500))); + return compressResponse(req, response); } - const mws = await resolveMiddleware(); - const res = secure( - withContextHeaders(ctx, await runMiddleware(mws, ctx, () => dispatch(ctx))), - ); - return compressResponse(req, res); - } catch (err) { - const app = process.env.WRNEXUS_APP_NAME ?? "app"; - let detail = - err instanceof Error ? (err.stack ?? `${err.name}: ${err.message}`) : String(err); - const message = err instanceof Error ? `${err.name}: ${err.message}` : String(err); - // AggregateError (e.g. Bun.build() "Bundle failed") hides its real cause in - // `.errors` — the top-level message/stack alone is useless for diagnosing a - // failed bundle. Print every nested error so the actual failure is visible. - const nested = (err as { errors?: unknown[] } | undefined)?.errors; - if (Array.isArray(nested) && nested.length) { - detail += - "\n caused by:\n" + - nested - .map((e, i) => ` [${i}] ${e instanceof Error ? (e.stack ?? e.message) : String(e)}`) - .join("\n"); - } - console.error( - `[wrnexus] unhandled request error (${app}) ${req.method} ${url.pathname}\n${detail}`, - ); - deps.devToolbar?.collector.add( - issueFromError(err, { - ruleId: "server/request-error", - category: "server", - title: "Request processing failed", - pathname: url.pathname, - }), - ); - const response = secure(renderError(err, mode)); - // Gateway-managed production apps bind to loopback. Carry a bounded, - // encoded diagnostic to the parent gateway so centralized log collectors - // can explain child failures; the gateway always strips this header. - response.headers.set("x-wrnexus-internal-error", encodeURIComponent(message.slice(0, 500))); - return compressResponse(req, response); - } + }); } async function dispatch(ctx: Context): Promise {