feat(core): carry the request context in an AsyncLocalStorage
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -11,6 +11,11 @@ export type {
|
|||||||
TFunction,
|
TFunction,
|
||||||
} from "./context.ts";
|
} from "./context.ts";
|
||||||
export { createContext, withContextHeaders } 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 { createExecutionContext, executionContextFromHttp } from "./execution-context.ts";
|
||||||
export type {
|
export type {
|
||||||
ExecutionContext,
|
ExecutionContext,
|
||||||
|
|||||||
@@ -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<Context>();
|
||||||
|
|
||||||
|
export function runWithRequestContext<T>(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;
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
@@ -23,6 +23,7 @@ import {
|
|||||||
isSafeRequestPath,
|
isSafeRequestPath,
|
||||||
renderError,
|
renderError,
|
||||||
renderNotFound,
|
renderNotFound,
|
||||||
|
runWithRequestContext,
|
||||||
withContextHeaders,
|
withContextHeaders,
|
||||||
withSecurityHeaders,
|
withSecurityHeaders,
|
||||||
resolveRequestUrl,
|
resolveRequestUrl,
|
||||||
@@ -1266,71 +1267,73 @@ export function createHandlers(deps: RuntimeDeps): Handlers {
|
|||||||
return secure(new Response("Expected a WebSocket upgrade request", { status: 426 }));
|
return secure(new Response("Expected a WebSocket upgrade request", { status: 426 }));
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
const ctx = createContext(req, url);
|
||||||
const ctx = createContext(req, url);
|
return runWithRequestContext(ctx, async () => {
|
||||||
initializeRequestCache(ctx);
|
try {
|
||||||
ctx.ip = server.requestIP?.(req)?.address ?? undefined;
|
initializeRequestCache(ctx);
|
||||||
ctx.locals.cspNonce = nonce; // available to pages for their own inline scripts
|
ctx.ip = server.requestIP?.(req)?.address ?? undefined;
|
||||||
if (!["GET", "HEAD", "OPTIONS"].includes(req.method.toUpperCase())) {
|
ctx.locals.cspNonce = nonce; // available to pages for their own inline scripts
|
||||||
const contentType = req.headers.get("content-type") ?? "";
|
if (!["GET", "HEAD", "OPTIONS"].includes(req.method.toUpperCase())) {
|
||||||
if (contentType.includes("form")) {
|
const contentType = req.headers.get("content-type") ?? "";
|
||||||
try {
|
if (contentType.includes("form")) {
|
||||||
const form = await req.clone().formData();
|
try {
|
||||||
const token = form.get("_csrf");
|
const form = await req.clone().formData();
|
||||||
if (typeof token === "string") ctx.locals._csrf = token;
|
const token = form.get("_csrf");
|
||||||
} catch {
|
if (typeof token === "string") ctx.locals._csrf = token;
|
||||||
// The endpoint will return its normal malformed-input response.
|
} catch {
|
||||||
|
// The endpoint will return its normal malformed-input response.
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
// Resolve the request language so both pages and API can translate.
|
||||||
// Resolve the request language so both pages and API can translate.
|
if (deps.i18n) {
|
||||||
if (deps.i18n) {
|
ctx.lang = resolveLang(
|
||||||
ctx.lang = resolveLang(
|
deps.i18n,
|
||||||
deps.i18n,
|
ctx.cookies.get(deps.i18n.cookie.name),
|
||||||
ctx.cookies.get(deps.i18n.cookie.name),
|
req.headers.get("accept-language"),
|
||||||
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<Response> {
|
async function dispatch(ctx: Context): Promise<Response> {
|
||||||
|
|||||||
Reference in New Issue
Block a user