From 40a05c7aa650bddac92b8339d886d697427a0374 Mon Sep 17 00:00:00 2001 From: Dominik Ferber Date: Wed, 6 May 2026 21:20:31 +0300 Subject: [PATCH 1/3] add requestStorage --- packages/flags/src/next/index.test.ts | 153 +++++++++++++++++++++++++- packages/flags/src/next/index.ts | 74 ++++++++++--- 2 files changed, 207 insertions(+), 20 deletions(-) diff --git a/packages/flags/src/next/index.test.ts b/packages/flags/src/next/index.test.ts index 7d3701db..90cb44d1 100644 --- a/packages/flags/src/next/index.test.ts +++ b/packages/flags/src/next/index.test.ts @@ -2,9 +2,24 @@ import { IncomingMessage } from 'node:http'; import type { Socket } from 'node:net'; import { Readable } from 'node:stream'; import type { NextApiRequestCookies } from 'next/dist/server/api-utils'; -import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest'; +import { + afterEach, + beforeAll, + beforeEach, + describe, + expect, + it, + vi, +} from 'vitest'; import { type Adapter, encryptOverrides } from '..'; -import { clearDedupeCacheForCurrentRequest, dedupe, flag, precompute } from '.'; +import { + attach, + clearDedupeCacheForCurrentRequest, + dedupe, + flag, + precompute, + requestStorage, +} from '.'; const mocks = vi.hoisted(() => { return { @@ -84,6 +99,13 @@ describe('exports', () => { it('should export clearDedupeCacheForCurrentRequest', () => { expect(typeof clearDedupeCacheForCurrentRequest).toBe('function'); }); + it('should export attach', () => { + expect(typeof attach).toBe('function'); + }); + it('should export requestStorage', () => { + expect(typeof requestStorage.run).toBe('function'); + expect(typeof requestStorage.getStore).toBe('function'); + }); }); describe('flag on app router', () => { @@ -668,6 +690,133 @@ describe('flag on pages router', () => { }); }); +describe('flag with requestStorage', () => { + beforeAll(() => { + // a random secret for testing purposes + process.env.FLAGS_SECRET = 'yuhyxaVI0Zue85SguKlMIUQojvJyBPzm95fFYvOa4Rc'; + }); + + beforeEach(() => { + mocks.headers.mockClear(); + mocks.cookies.mockClear(); + }); + + it('reads headers from the stored Request and never calls next/headers', async () => { + const decide = vi.fn( + ({ headers }: { headers: Headers }) => headers.get('x-test') === 'on', + ); + const f = flag({ key: 'first-flag', decide }); + + const request = new Request('http://localhost/test', { + headers: { 'x-test': 'on' }, + }); + + await requestStorage.run(request, async () => { + await expect(f()).resolves.toEqual(true); + }); + + expect(decide).toHaveBeenCalledTimes(1); + expect(mocks.headers).not.toHaveBeenCalled(); + expect(mocks.cookies).not.toHaveBeenCalled(); + }); + + it('attach() forwards the request and additional args', async () => { + const decide = vi.fn( + ({ headers }: { headers: Headers }) => headers.get('x-test') === 'on', + ); + const f = flag({ key: 'first-flag', decide }); + + const handler = attach(async (request, ctx: { params: { id: string } }) => { + const value = await f(); + return { value, id: ctx.params.id, url: request.url }; + }); + + const request = new Request('http://localhost/test', { + headers: { 'x-test': 'on' }, + }); + + await expect(handler(request, { params: { id: '42' } })).resolves.toEqual({ + value: true, + id: '42', + url: 'http://localhost/test', + }); + + expect(mocks.headers).not.toHaveBeenCalled(); + expect(mocks.cookies).not.toHaveBeenCalled(); + }); + + it('caches across multiple flag calls within the same scope', async () => { + let i = 0; + const decide = vi.fn(() => i++); + const f = flag({ key: 'first-flag', decide }); + + const request = new Request('http://localhost/test'); + + await requestStorage.run(request, async () => { + await expect(f()).resolves.toEqual(0); + await expect(f()).resolves.toEqual(0); + }); + + expect(decide).toHaveBeenCalledTimes(1); + + // a second request gets a fresh evaluation + await requestStorage.run(new Request('http://localhost/test'), async () => { + await expect(f()).resolves.toEqual(1); + }); + + expect(decide).toHaveBeenCalledTimes(2); + }); + + it('respects overrides parsed from the request cookie header', async () => { + const decide = vi.fn(() => false); + const f = flag({ key: 'first-flag', decide }); + const override = await encryptOverrides({ 'first-flag': true }); + + const request = new Request('http://localhost/test', { + headers: { cookie: `vercel-flag-overrides=${override}` }, + }); + + await requestStorage.run(request, async () => { + await expect(f()).resolves.toEqual(true); + }); + + expect(decide).not.toHaveBeenCalled(); + expect(mocks.cookies).not.toHaveBeenCalled(); + }); + + it('passes sealed headers and cookies to identify', async () => { + const identify = vi.fn(({ headers, cookies }) => ({ + user: headers.get('x-user'), + session: cookies.get('session')?.value, + })); + const decide = vi.fn( + ({ entities }: { entities?: { user: string | null } }) => + entities?.user === 'alice', + ); + const f = flag({ + key: 'first-flag', + identify, + decide, + }); + + const request = new Request('http://localhost/test', { + headers: { 'x-user': 'alice', cookie: 'session=abc' }, + }); + + await requestStorage.run(request, async () => { + await expect(f()).resolves.toEqual(true); + // second call hits the per-request decide cache + await expect(f()).resolves.toEqual(true); + }); + + expect(decide).toHaveBeenCalledTimes(1); + expect(identify).toHaveBeenCalled(); + const [params] = identify.mock.calls[0] ?? []; + expect(params.headers.get('x-user')).toBe('alice'); + expect(params.cookies.get('session')?.value).toBe('abc'); + }); +}); + describe('dynamic io', () => { it('should re-throw dynamic usage erorrs even when a defaultValue is present', async () => { const mockDecide = vi.fn(() => { diff --git a/packages/flags/src/next/index.ts b/packages/flags/src/next/index.ts index 57912a93..49ecc84b 100644 --- a/packages/flags/src/next/index.ts +++ b/packages/flags/src/next/index.ts @@ -1,3 +1,4 @@ +import { AsyncLocalStorage } from 'node:async_hooks'; import type { IncomingHttpHeaders } from 'node:http'; import { RequestCookies } from '@edge-runtime/cookies'; import { @@ -41,6 +42,34 @@ export { } from './precompute'; export type { Flag } from './types'; +/** + * AsyncLocalStorage holding the current `Request` for App Router contexts + * (e.g. Route Handlers) where the request is available directly. When a + * request is set here, `flag()` reads headers and cookies from it instead + * of importing and calling `next/headers`. + */ +export const requestStorage = new AsyncLocalStorage(); + +/** + * Wraps a Route Handler so its execution runs inside `requestStorage`. + * + * @example + * ```ts + * import { attach } from 'flags/next'; + * + * export const GET = attach(async (request) => { + * const value = await someFlag(); + * return Response.json({ value }); + * }); + * ``` + */ +export function attach( + handler: (request: Request, ...args: TArgs) => TReturn | Promise, +): (request: Request, ...args: TArgs) => Promise { + return async (request, ...args) => + requestStorage.run(request, async () => handler(request, ...args)); +} + // a map of (headers, flagKey, entitiesKey) => value const evaluationCache = new WeakMap< Headers | IncomingHttpHeaders, @@ -238,24 +267,33 @@ function getRun( readonlyCookies = sealCookies(headers); dedupeCacheKey = options.request.headers; } else { - // app router - - // async import required as turbopack errors in Pages Router - // when next/headers is imported at the top-level. - // - // cache import so we don't await on every call since this adds - // additional microtask queue overhead - if (!headersModulePromise) headersModulePromise = import('next/headers'); - if (!headersModule) headersModule = await headersModulePromise; - const { headers, cookies } = headersModule; - - const [headersStore, cookiesStore] = await Promise.all([ - headers(), - cookies(), - ]); - readonlyHeaders = headersStore as ReadonlyHeaders; - readonlyCookies = cookiesStore as ReadonlyRequestCookies; - dedupeCacheKey = headersStore; + const storedRequest = requestStorage.getStore(); + if (storedRequest) { + // route handler / explicit request via AsyncLocalStorage + readonlyHeaders = sealHeaders(storedRequest.headers); + readonlyCookies = sealCookies(storedRequest.headers); + dedupeCacheKey = storedRequest.headers; + } else { + // app router + + // async import required as turbopack errors in Pages Router + // when next/headers is imported at the top-level. + // + // cache import so we don't await on every call since this adds + // additional microtask queue overhead + if (!headersModulePromise) + headersModulePromise = import('next/headers'); + if (!headersModule) headersModule = await headersModulePromise; + const { headers, cookies } = headersModule; + + const [headersStore, cookiesStore] = await Promise.all([ + headers(), + cookies(), + ]); + readonlyHeaders = headersStore as ReadonlyHeaders; + readonlyCookies = cookiesStore as ReadonlyRequestCookies; + dedupeCacheKey = headersStore; + } } // skip microtask if cookie does not exist or is empty From d5516cf2daa5b6431ff7468cb637db0e615a286b Mon Sep 17 00:00:00 2001 From: Dominik Ferber Date: Wed, 6 May 2026 21:35:44 +0300 Subject: [PATCH 2/3] changset --- .changeset/flags-next-request-storage.md | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) create mode 100644 .changeset/flags-next-request-storage.md diff --git a/.changeset/flags-next-request-storage.md b/.changeset/flags-next-request-storage.md new file mode 100644 index 00000000..beff884e --- /dev/null +++ b/.changeset/flags-next-request-storage.md @@ -0,0 +1,20 @@ +--- +'flags': minor +--- + +Add `requestStorage` and `attach` to `flags/next` for passing the `Request` +into flag evaluation via `AsyncLocalStorage`. + +When a request is set on `requestStorage` (directly via `requestStorage.run` +or implicitly through the `attach()` Route Handler wrapper), `flag()` reads +headers and cookies from `request.headers` and skips the dynamic +`import('next/headers')` and `headers()` / `cookies()` calls entirely. + +```ts +import { attach } from 'flags/next'; + +export const GET = attach(async (request) => { + const value = await someFlag(); + return Response.json({ value }); +}); +``` From 54af771bbd2edf1c512476cbef802a3c5835a258 Mon Sep 17 00:00:00 2001 From: Dominik Ferber Date: Wed, 6 May 2026 21:47:52 +0300 Subject: [PATCH 3/3] pages router --- .changeset/flags-next-request-storage.md | 21 +++-- packages/flags/src/next/index.test.ts | 60 +++++++++++++ packages/flags/src/next/index.ts | 103 ++++++++++++++--------- 3 files changed, 139 insertions(+), 45 deletions(-) diff --git a/.changeset/flags-next-request-storage.md b/.changeset/flags-next-request-storage.md index beff884e..1393448f 100644 --- a/.changeset/flags-next-request-storage.md +++ b/.changeset/flags-next-request-storage.md @@ -2,19 +2,30 @@ 'flags': minor --- -Add `requestStorage` and `attach` to `flags/next` for passing the `Request` -into flag evaluation via `AsyncLocalStorage`. +Add `requestStorage` and `attach` to `flags/next` for passing the request +into flag evaluation via `AsyncLocalStorage`. Works with both App Router +Route Handlers (Web `Request`) and Pages Router API Routes (Node +`IncomingMessage`). When a request is set on `requestStorage` (directly via `requestStorage.run` -or implicitly through the `attach()` Route Handler wrapper), `flag()` reads -headers and cookies from `request.headers` and skips the dynamic -`import('next/headers')` and `headers()` / `cookies()` calls entirely. +or implicitly through the `attach()` wrapper), `flag()` reads headers and +cookies from the request and skips the dynamic `import('next/headers')` and +`headers()` / `cookies()` calls entirely. ```ts +// App Router Route Handler import { attach } from 'flags/next'; export const GET = attach(async (request) => { const value = await someFlag(); return Response.json({ value }); }); + +// Pages Router API Route +import { attach } from 'flags/next'; + +export default attach(async (req, res) => { + const value = await someFlag(); + res.json({ value }); +}); ``` diff --git a/packages/flags/src/next/index.test.ts b/packages/flags/src/next/index.test.ts index 90cb44d1..0350d441 100644 --- a/packages/flags/src/next/index.test.ts +++ b/packages/flags/src/next/index.test.ts @@ -815,6 +815,66 @@ describe('flag with requestStorage', () => { expect(params.headers.get('x-user')).toBe('alice'); expect(params.cookies.get('session')?.value).toBe('abc'); }); + + it('reads headers from a stored IncomingMessage (Pages Router)', async () => { + const decide = vi.fn( + ({ headers }: { headers: Headers }) => headers.get('x-test') === 'on', + ); + const f = flag({ key: 'first-flag', decide }); + + const [request, socket] = createRequest(); + request.headers['x-test'] = 'on'; + + await requestStorage.run(request, async () => { + await expect(f()).resolves.toEqual(true); + }); + + expect(decide).toHaveBeenCalledTimes(1); + expect(mocks.headers).not.toHaveBeenCalled(); + expect(mocks.cookies).not.toHaveBeenCalled(); + socket.destroy(); + }); + + it('attach() works with a Pages Router IncomingMessage and forwards res', async () => { + const decide = vi.fn( + ({ headers }: { headers: Headers }) => headers.get('x-test') === 'on', + ); + const f = flag({ key: 'first-flag', decide }); + + const [request, socket] = createRequest(); + request.headers['x-test'] = 'on'; + + const res = { json: vi.fn() }; + const handler = attach( + async (req: typeof request, response: typeof res) => { + const value = await f(); + response.json({ value }); + return value; + }, + ); + + await expect(handler(request, res)).resolves.toEqual(true); + expect(res.json).toHaveBeenCalledWith({ value: true }); + expect(mocks.headers).not.toHaveBeenCalled(); + socket.destroy(); + }); + + it('respects overrides parsed from the cookie header of a stored IncomingMessage', async () => { + const decide = vi.fn(() => false); + const f = flag({ key: 'first-flag', decide }); + const override = await encryptOverrides({ 'first-flag': true }); + + const [request, socket] = createRequest({ + 'vercel-flag-overrides': override, + }); + + await requestStorage.run(request, async () => { + await expect(f()).resolves.toEqual(true); + }); + + expect(decide).not.toHaveBeenCalled(); + socket.destroy(); + }); }); describe('dynamic io', () => { diff --git a/packages/flags/src/next/index.ts b/packages/flags/src/next/index.ts index 49ecc84b..2d35b149 100644 --- a/packages/flags/src/next/index.ts +++ b/packages/flags/src/next/index.ts @@ -1,5 +1,5 @@ import { AsyncLocalStorage } from 'node:async_hooks'; -import type { IncomingHttpHeaders } from 'node:http'; +import type { IncomingHttpHeaders, IncomingMessage } from 'node:http'; import { RequestCookies } from '@edge-runtime/cookies'; import { type FlagDefinitionsType, @@ -43,17 +43,26 @@ export { export type { Flag } from './types'; /** - * AsyncLocalStorage holding the current `Request` for App Router contexts - * (e.g. Route Handlers) where the request is available directly. When a - * request is set here, `flag()` reads headers and cookies from it instead - * of importing and calling `next/headers`. + * Either a Web `Request` (App Router Route Handler) or a Node + * `IncomingMessage` (Pages Router API Route). */ -export const requestStorage = new AsyncLocalStorage(); +type StoredRequest = Request | IncomingMessage; /** - * Wraps a Route Handler so its execution runs inside `requestStorage`. + * AsyncLocalStorage holding the current request. When a request is set here, + * `flag()` reads headers and cookies from it instead of importing and calling + * `next/headers`. Accepts either a Web `Request` (App Router Route Handler) + * or a Node `IncomingMessage` (Pages Router API Route). + */ +export const requestStorage = new AsyncLocalStorage(); + +/** + * Wraps a Route Handler or API Route so its execution runs inside + * `requestStorage`. Accepts either a Web `Request` (App Router) or a Node + * `IncomingMessage` (Pages Router) as the first argument; remaining arguments + * (e.g. `{ params }` in App Router, `res` in Pages Router) are forwarded. * - * @example + * @example App Router Route Handler * ```ts * import { attach } from 'flags/next'; * @@ -62,10 +71,24 @@ export const requestStorage = new AsyncLocalStorage(); * return Response.json({ value }); * }); * ``` + * + * @example Pages Router API Route + * ```ts + * import { attach } from 'flags/next'; + * + * export default attach(async (req, res) => { + * const value = await someFlag(); + * res.json({ value }); + * }); + * ``` */ -export function attach( - handler: (request: Request, ...args: TArgs) => TReturn | Promise, -): (request: Request, ...args: TArgs) => Promise { +export function attach< + TRequest extends StoredRequest, + TArgs extends unknown[], + TReturn, +>( + handler: (request: TRequest, ...args: TArgs) => TReturn | Promise, +): (request: TRequest, ...args: TArgs) => Promise { return async (request, ...args) => requestStorage.run(request, async () => handler(request, ...args)); } @@ -260,40 +283,40 @@ function getRun( let readonlyCookies: ReadonlyRequestCookies; let dedupeCacheKey: Headers | IncomingHttpHeaders; - if (options.request) { - // pages router - const headers = transformToHeaders(options.request.headers); - readonlyHeaders = sealHeaders(headers); - readonlyCookies = sealCookies(headers); - dedupeCacheKey = options.request.headers; - } else { - const storedRequest = requestStorage.getStore(); - if (storedRequest) { - // route handler / explicit request via AsyncLocalStorage + const storedRequest = options.request ?? requestStorage.getStore(); + if (storedRequest) { + if (storedRequest instanceof Request) { + // app router route handler with a Web Request via AsyncLocalStorage readonlyHeaders = sealHeaders(storedRequest.headers); readonlyCookies = sealCookies(storedRequest.headers); dedupeCacheKey = storedRequest.headers; } else { - // app router - - // async import required as turbopack errors in Pages Router - // when next/headers is imported at the top-level. - // - // cache import so we don't await on every call since this adds - // additional microtask queue overhead - if (!headersModulePromise) - headersModulePromise = import('next/headers'); - if (!headersModule) headersModule = await headersModulePromise; - const { headers, cookies } = headersModule; - - const [headersStore, cookiesStore] = await Promise.all([ - headers(), - cookies(), - ]); - readonlyHeaders = headersStore as ReadonlyHeaders; - readonlyCookies = cookiesStore as ReadonlyRequestCookies; - dedupeCacheKey = headersStore; + // pages router api route — either passed explicitly to the flag or + // set on requestStorage + const headers = transformToHeaders(storedRequest.headers); + readonlyHeaders = sealHeaders(headers); + readonlyCookies = sealCookies(headers); + dedupeCacheKey = storedRequest.headers; } + } else { + // app router + + // async import required as turbopack errors in Pages Router + // when next/headers is imported at the top-level. + // + // cache import so we don't await on every call since this adds + // additional microtask queue overhead + if (!headersModulePromise) headersModulePromise = import('next/headers'); + if (!headersModule) headersModule = await headersModulePromise; + const { headers, cookies } = headersModule; + + const [headersStore, cookiesStore] = await Promise.all([ + headers(), + cookies(), + ]); + readonlyHeaders = headersStore as ReadonlyHeaders; + readonlyCookies = cookiesStore as ReadonlyRequestCookies; + dedupeCacheKey = headersStore; } // skip microtask if cookie does not exist or is empty