diff --git a/packages/vinext/package.json b/packages/vinext/package.json index 76191ee4..71e843f7 100644 --- a/packages/vinext/package.json +++ b/packages/vinext/package.json @@ -21,6 +21,10 @@ "types": "./dist/index.d.ts", "import": "./dist/index.js" }, + "./request-store": { + "types": "./dist/shims/request-store.d.ts", + "import": "./dist/shims/request-store.js" + }, "./shims/*": { "types": "./dist/shims/*.d.ts", "import": "./dist/shims/*.js" diff --git a/packages/vinext/src/entries/pages-server-entry.ts b/packages/vinext/src/entries/pages-server-entry.ts index de96eb2d..a333f195 100644 --- a/packages/vinext/src/entries/pages-server-entry.ts +++ b/packages/vinext/src/entries/pages-server-entry.ts @@ -1177,7 +1177,12 @@ export async function handleApiRoute(request, url) { } const { req, res, responsePromise } = createReqRes(request, url, query, body); - await handler(req, res); + // Wrap in unified request context so getRequestStore() works + // in Pages Router API routes (prod/Workers path). + const __apiCtx = _createUnifiedCtx({ + executionContext: _getRequestExecutionContext(), + }); + await _runWithUnifiedCtx(__apiCtx, () => handler(req, res)); // If handler didn't call res.end(), end it now. // The end() method is idempotent — safe to call twice. res.end(); diff --git a/packages/vinext/src/server/api-handler.ts b/packages/vinext/src/server/api-handler.ts index 64d11207..ff967d75 100644 --- a/packages/vinext/src/server/api-handler.ts +++ b/packages/vinext/src/server/api-handler.ts @@ -12,6 +12,10 @@ import { decode as decodeQueryString } from "node:querystring"; import { type Route, matchRoute } from "../routing/pages-router.js"; import { reportRequestError, importModule, type ModuleImporter } from "./instrumentation.js"; import { addQueryParam } from "../utils/query.js"; +import { + runWithRequestContext, + createRequestContext, +} from "../shims/unified-request-context.js"; /** * Extend the Node.js request with Next.js-style helpers. @@ -230,8 +234,10 @@ export async function handleApiRoute( // Enhance req/res with Next.js helpers const { apiReq, apiRes } = enhanceApiObjects(req, res, query, body); - // Call the handler - await handler(apiReq, apiRes); + // Call the handler inside a unified request context so that + // getRequestStore() works in Pages Router API routes. + const __uCtx = createRequestContext(); + await runWithRequestContext(__uCtx, () => handler(apiReq, apiRes)); return true; } catch (e) { if (e instanceof ApiBodyParseError) { diff --git a/packages/vinext/src/shims/request-store.ts b/packages/vinext/src/shims/request-store.ts new file mode 100644 index 00000000..263193ac --- /dev/null +++ b/packages/vinext/src/shims/request-store.ts @@ -0,0 +1,83 @@ +/** + * Per-request key-value store for user-defined data. + * + * Backed by vinext's unified AsyncLocalStorage scope — values are + * automatically isolated per request and garbage-collected when the + * request completes. No manual cleanup needed. + * + * Primary use case: per-request database clients (Prisma, Drizzle, etc.) + * on Cloudflare Workers, where global singletons cause alternating + * connection failures (see https://github.com/cloudflare/vinext/issues/537). + * + * @example + * ```ts + * import { getRequestStore } from "vinext/request-store"; + * import { PrismaClient } from "@prisma/client"; + * import { PrismaPg } from "@prisma/adapter-pg"; + * import { Pool } from "@neondatabase/serverless"; + * + * export function getPrisma(connectionString: string): PrismaClient { + * const store = getRequestStore(); + * let prisma = store.get("prisma") as PrismaClient | undefined; + * if (!prisma) { + * const pool = new Pool({ connectionString }); + * prisma = new PrismaClient({ adapter: new PrismaPg(pool) }); + * store.set("prisma", prisma); + * } + * return prisma; + * } + * ``` + * + * @module + */ + +import { AsyncLocalStorage } from "node:async_hooks"; +import { getRequestContext, isInsideUnifiedScope } from "./unified-request-context.js"; +import { getRequestExecutionContext } from "./request-context.js"; + +// Fallback ALS for code paths that have an ExecutionContext but not a +// unified scope (e.g. Pages Router middleware). Keyed on the execution +// context identity so stores don't leak across requests. +const _FALLBACK_KEY = Symbol.for("vinext.requestStore.fallback"); +const _g = globalThis as unknown as Record; +const _fallbackAls = (_g[_FALLBACK_KEY] ??= + new AsyncLocalStorage>()) as AsyncLocalStorage>; + +/** + * Get the per-request key-value store. + * + * Resolution order: + * 1. Unified scope (App Router, Pages Router API routes) → userStore from context + * 2. ExecutionContext scope (middleware) → fallback ALS store + * 3. Outside any scope (tests, top-level) → fresh empty Map + * + * @returns A Map scoped to the current request. + */ +export function getRequestStore(): Map { + // Preferred: unified request context (App Router + Pages Router API routes) + if (isInsideUnifiedScope()) { + return getRequestContext().userStore; + } + // Fallback: ExecutionContext scope (middleware) + const fallbackStore = _fallbackAls.getStore(); + if (fallbackStore) { + return fallbackStore; + } + // Outside any request scope: return a detached empty Map. + // Callers should not rely on persistence here. + return new Map(); +} + +/** + * Run `fn` with a request store available via `getRequestStore()`. + * + * Use this in code paths that have an ExecutionContext but no unified + * scope (e.g. custom worker entries wrapping middleware). The store is + * isolated per call and cleaned up when `fn` resolves. + * + * Not needed for App Router or Pages Router API routes — those are + * already wrapped by vinext's unified request context. + */ +export function runWithRequestStore(fn: () => T | Promise): T | Promise { + return _fallbackAls.run(new Map(), fn); +} diff --git a/packages/vinext/src/shims/unified-request-context.ts b/packages/vinext/src/shims/unified-request-context.ts index eab6aa47..1b7b2bdf 100644 --- a/packages/vinext/src/shims/unified-request-context.ts +++ b/packages/vinext/src/shims/unified-request-context.ts @@ -47,6 +47,10 @@ export interface UnifiedRequestContext // ── request-context.ts ───────────────────────────────────────────── /** Cloudflare Workers ExecutionContext, or null on Node.js dev. */ executionContext: ExecutionContextLike | null; + + // ── request-store.ts ────────────────────────────────────────────── + /** User-defined per-request key-value store. Auto-cleared between requests. */ + userStore: Map; } // --------------------------------------------------------------------------- @@ -92,6 +96,7 @@ export function createRequestContext(opts?: Partial): Uni _privateCache: null, currentRequestTags: [], executionContext: _getInheritedExecutionContext(), // inherits from standalone ALS if present + userStore: new Map(), ssrContext: null, ssrHeadChildren: [], ...opts,