Skip to content
Open
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 @@ -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"
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() 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();
Expand Down
10 changes: 8 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,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) {
Expand Down
83 changes: 83 additions & 0 deletions packages/vinext/src/shims/request-store.ts
Original file line number Diff line number Diff line change
@@ -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<PropertyKey, unknown>;
const _fallbackAls = (_g[_FALLBACK_KEY] ??=
new AsyncLocalStorage<Map<string, unknown>>()) as AsyncLocalStorage<Map<string, unknown>>;

/**
* 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<string, unknown> scoped to the current request.
*/
export function getRequestStore(): Map<string, unknown> {
// 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<T>(fn: () => T | Promise<T>): T | Promise<T> {
return _fallbackAls.run(new Map(), fn);
}
5 changes: 5 additions & 0 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-cleared between requests. */
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