Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions packages/vinext/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
7 changes: 6 additions & 1 deletion packages/vinext/src/entries/pages-server-entry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
11 changes: 9 additions & 2 deletions packages/vinext/src/server/api-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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) {
Expand Down
69 changes: 69 additions & 0 deletions packages/vinext/src/shims/request-store.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown> scoped to the current request.
*/
export function getRequestStore(): Map<string, unknown> {
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;
}
9 changes: 7 additions & 2 deletions packages/vinext/src/shims/unified-request-context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>;
}

// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -92,6 +96,7 @@ export function createRequestContext(opts?: Partial<UnifiedRequestContext>): Uni
_privateCache: null,
currentRequestTags: [],
executionContext: _getInheritedExecutionContext(), // inherits from standalone ALS if present
userStore: new Map(),
ssrContext: null,
ssrHeadChildren: [],
...opts,
Expand Down Expand Up @@ -129,8 +134,8 @@ export function runWithUnifiedStateMutation<T>(
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
Expand Down