diff --git a/packages/vinext/package.json b/packages/vinext/package.json index 76191ee4..7c93f52e 100644 --- a/packages/vinext/package.json +++ b/packages/vinext/package.json @@ -48,6 +48,10 @@ "./server/worker-utils": { "types": "./dist/server/worker-utils.d.ts", "import": "./dist/server/worker-utils.js" + }, + "./request-store": { + "types": "./dist/shims/request-store.d.ts", + "import": "./dist/shims/request-store.js" } }, "scripts": { diff --git a/packages/vinext/src/entries/pages-server-entry.ts b/packages/vinext/src/entries/pages-server-entry.ts index de96eb2d..2f19272c 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() and other + // per-request APIs work in Pages Router API routes. + 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..a4584d7a 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,11 @@ 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() and other per-request APIs work in Pages Router + // API routes (previously only App Router had this scope). + 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..284edaaa --- /dev/null +++ b/packages/vinext/src/shims/request-store.ts @@ -0,0 +1,69 @@ +/** + * 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"; // or pg + * + * 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; + * } + * ``` + * + * @example + * ```ts + * // Works with Drizzle too: + * import { getRequestStore } from "vinext/request-store"; + * import { drizzle } from "drizzle-orm/neon-http"; + * + * export function getDb(connectionString: string) { + * const store = getRequestStore(); + * let db = store.get("db"); + * if (!db) { + * db = drizzle(connectionString); + * store.set("db", db); + * } + * return db; + * } + * ``` + * + * @module + */ + +import { getRequestContext, isInsideUnifiedScope } from "./unified-request-context.js"; + +/** + * Get the per-request key-value store. + * + * Inside a request scope: returns the request-scoped Map (isolated, auto-cleaned). + * Outside a request scope: returns a fresh empty Map each time to prevent + * cross-call data leakage and unbounded growth. + * + * @returns A Map scoped to the current request. + */ +export function getRequestStore(): Map { + if (!isInsideUnifiedScope()) { + // Return a fresh Map — not a shared global — to prevent data leaking + // across unrelated calls outside request scope (dev server, tests). + return new Map(); + } + return getRequestContext().userStore; +} diff --git a/packages/vinext/src/shims/unified-request-context.ts b/packages/vinext/src/shims/unified-request-context.ts index eab6aa47..a27ddae4 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-cleaned on request end. */ + 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, @@ -129,8 +134,8 @@ export function runWithUnifiedStateMutation( const childCtx = { ...parentCtx }; // NOTE: This is a shallow clone. Array fields (pendingSetCookies, // serverInsertedHTMLCallbacks, currentRequestTags, ssrHeadChildren), the - // _privateCache Map, and object fields (headersContext, i18nContext, - // serverContext, ssrContext, executionContext, requestScopedCacheLife) + // _privateCache Map, the userStore Map, and object fields (headersContext, + // i18nContext, serverContext, ssrContext, executionContext, requestScopedCacheLife) // still share references with the parent until replaced. The mutate // callback must replace those reference-typed slices (for example // `ctx.currentRequestTags = []`) rather than mutating them in-place (for