diff --git a/.changeset/adapter-bulk-evaluation.md b/.changeset/adapter-bulk-evaluation.md index 14db73d0..a6ea333d 100644 --- a/.changeset/adapter-bulk-evaluation.md +++ b/.changeset/adapter-bulk-evaluation.md @@ -2,8 +2,8 @@ '@flags-sdk/vercel': minor --- -Faster evaluation of flags when using the Vercel adapter via `bulk()`. +Faster evaluation of flags when using the Vercel adapter via `evaluate()`. -This version of `flags-sdk/vercel` implements `bulkDecide` on the Vercel Flags adapter so flags can be evaluated together via `bulk()` from `flags/next`. +This version of `flags-sdk/vercel` implements `bulkDecide` on the Vercel Flags adapter so flags can be evaluated together via `evaluate()` from `flags/next`. -This improves performance by avoiding the per-flag overhead of separate `evaluate()` calls. We've seen a 10x improvement in evaluation time for large batches of flags. +This improves performance by avoiding the per-flag overhead of separate `decide()` calls. We've seen a 10x improvement in evaluation time for large batches of flags. diff --git a/.changeset/flags-bulk-evaluation.md b/.changeset/flags-bulk-evaluation.md deleted file mode 100644 index 5a414ebd..00000000 --- a/.changeset/flags-bulk-evaluation.md +++ /dev/null @@ -1,18 +0,0 @@ ---- -'flags': minor ---- - -Add `bulk()` function for evaluating multiple flags in a single call from the `flags/next` entry point. - -```tsx -import { bulk } from 'flags/next'; -import { flagA, flagB } from '../flags'; - -// pass a list of flags -const [valueA, valueB] = await bulk([flagA, flagB]); - -// pass an object -const { a, b } = await bulk({ a: flagA, b: flagB }); -``` - -Adapters can now opt into batched evaluation by implementing an optional `bulkDecide` method and setting a stable `adapterId`. When both are present, `bulk()` groups flags that share the same `adapterId` and `identify` source and invokes `bulkDecide` once per group instead of calling `decide` per flag. Flags without a bulk-capable adapter (or with an inline `decide`) still resolve through the normal per-flag path inside `bulk()` and benefit from the shared per-request headers, cookies, and overrides reads. diff --git a/.changeset/flags-evaluate.md b/.changeset/flags-evaluate.md new file mode 100644 index 00000000..c01ff36e --- /dev/null +++ b/.changeset/flags-evaluate.md @@ -0,0 +1,18 @@ +--- +'flags': minor +--- + +Extend `evaluate()` from the `flags/next` entry point to resolve multiple flags in a single call. + +```tsx +import { evaluate } from 'flags/next'; +import { flagA, flagB } from '../flags'; + +// pass a list of flags +const [valueA, valueB] = await evaluate([flagA, flagB]); + +// pass an object +const { a, b } = await evaluate({ a: flagA, b: flagB }); +``` + +Adapters can now opt into batched evaluation by implementing an optional `bulkDecide` method and setting a stable `adapterId`. When both are present, `evaluate()` groups flags that share the same `adapterId` and `identify` source and invokes `bulkDecide` once per group instead of calling `decide` per flag. Flags without a bulk-capable adapter (or with an inline `decide`) still resolve through the normal per-flag path inside `evaluate()` and benefit from the shared per-request headers, cookies, and overrides reads. diff --git a/packages/adapter-vercel/src/index.ts b/packages/adapter-vercel/src/index.ts index 1708a260..1a094b86 100644 --- a/packages/adapter-vercel/src/index.ts +++ b/packages/adapter-vercel/src/index.ts @@ -32,8 +32,8 @@ export function createVercelAdapter( // Stable identity for this adapter's underlying flagsClient. Captured in // the closure so every adapter object the factory below returns shares it, - // letting `bulk()` group flags from multiple `vercelAdapter()` calls into - // a single `bulkDecide` invocation. + // letting `evaluate()` group flags from multiple `vercelAdapter()` calls + // into a single `bulkDecide` invocation. const adapterId = Symbol('vercelAdapter'); return function vercelAdapter(): Adapter< diff --git a/packages/flags/src/next/evaluate.ts b/packages/flags/src/next/evaluate.ts new file mode 100644 index 00000000..44c683fe --- /dev/null +++ b/packages/flags/src/next/evaluate.ts @@ -0,0 +1,628 @@ +import { AsyncLocalStorage } from 'node:async_hooks'; +import type { IncomingHttpHeaders } from 'node:http'; +import { RequestCookies } from '@edge-runtime/cookies'; +import { reportValue } from '..'; +import { internalReportValue } from '../lib/report-value'; +import { setSpanAttribute } from '../lib/tracing'; +import { + HeadersAdapter, + type ReadonlyHeaders, +} from '../spec-extension/adapters/headers'; +import { + type ReadonlyRequestCookies, + RequestCookiesAdapter, +} from '../spec-extension/adapters/request-cookies'; +import type { + Adapter, + Decide, + FlagDeclaration, + FlagParamsType, +} from '../types'; +import { isInternalNextError } from './is-internal-next-error'; +import { getOverrides } from './overrides'; +import type { Flag, PagesRouterRequest } from './types'; + +// Internal markers stamped on the flag api by `flag()`. Read by `evaluate()` +// to partition flags into adapter groups. +// +// - BULK_IDENTIFY_REF: the raw identify source for reference-equality +// comparison across flags. The wrapped `api.identify` is created per +// `flag()` call, so it can't be used for grouping. +// - BULKABLE: whether the flag can participate in adapter-level bulk +// evaluation. An inline `definition.decide` disqualifies the flag +// because `getDecide` prefers it over the adapter's decide. +export const BULK_IDENTIFY_REF = Symbol('flags.bulkIdentifyRef'); +export const BULKABLE = Symbol('flags.bulkable'); + +// a map of (headers, flagKey, entitiesKey) => value +const evaluationCache = new WeakMap< + Headers | IncomingHttpHeaders, + Map> +>(); + +function getCachedValuePromise( + /** + * supports Headers for App Router and IncomingHttpHeaders for Pages Router + */ + headers: Headers | IncomingHttpHeaders, + flagKey: string, + entitiesKey: string, +): any { + return evaluationCache.get(headers)?.get(flagKey)?.get(entitiesKey); +} + +function setCachedValuePromise( + /** + * supports Headers for App Router and IncomingHttpHeaders for Pages Router + */ + headers: Headers | IncomingHttpHeaders, + flagKey: string, + entitiesKey: string, + flagValue: any, +): any { + const byHeaders = evaluationCache.get(headers); + + if (!byHeaders) { + evaluationCache.set( + headers, + new Map([[flagKey, new Map([[entitiesKey, flagValue]])]]), + ); + return; + } + + const byFlagKey = byHeaders.get(flagKey); + if (!byFlagKey) { + byHeaders.set(flagKey, new Map([[entitiesKey, flagValue]])); + return; + } + + byFlagKey.set(entitiesKey, flagValue); +} + +type IdentifyArgs = Parameters< + Exclude['identify'], undefined> +>; +const transformMap = new WeakMap(); +const headersMap = new WeakMap(); +const cookiesMap = new WeakMap(); +const identifyArgsMap = new WeakMap< + Headers | IncomingHttpHeaders, + IdentifyArgs +>(); + +/** + * Transforms IncomingHttpHeaders to Headers + */ +function transformToHeaders(incomingHeaders: IncomingHttpHeaders): Headers { + const cached = transformMap.get(incomingHeaders); + if (cached !== undefined) return cached; + + const headers = new Headers(); + for (const [key, value] of Object.entries(incomingHeaders)) { + if (Array.isArray(value)) { + // If the value is an array, add each item separately + value.forEach((item) => { + headers.append(key, item); + }); + } else if (value !== undefined) { + // If it's a single value, add it directly + headers.append(key, value); + } + } + + transformMap.set(incomingHeaders, headers); + return headers; +} + +function sealHeaders(headers: Headers): ReadonlyHeaders { + const cached = headersMap.get(headers); + if (cached !== undefined) return cached; + + const sealed = HeadersAdapter.seal(headers); + headersMap.set(headers, sealed); + return sealed; +} + +function sealCookies(headers: Headers): ReadonlyRequestCookies { + const cached = cookiesMap.get(headers); + if (cached !== undefined) return cached; + + const sealed = RequestCookiesAdapter.seal(new RequestCookies(headers)); + cookiesMap.set(headers, sealed); + return sealed; +} + +function isIdentifyFunction( + identify: FlagDeclaration['identify'] | EntitiesType, +): identify is FlagDeclaration['identify'] { + return typeof identify === 'function'; +} + +async function getEntities( + identify: FlagDeclaration['identify'] | EntitiesType, + dedupeCacheKey: Headers | IncomingHttpHeaders, + readonlyHeaders: ReadonlyHeaders, + readonlyCookies: ReadonlyRequestCookies, +): Promise { + if (!identify) return undefined; + if (!isIdentifyFunction(identify)) return identify; + + const args = identifyArgsMap.get(dedupeCacheKey); + if (args) return identify(...(args as [FlagParamsType])); + + const nextArgs: IdentifyArgs = [ + { headers: readonlyHeaders, cookies: readonlyCookies }, + ]; + identifyArgsMap.set(dedupeCacheKey, nextArgs); + return identify(...(nextArgs as [FlagParamsType])); +} + +interface BulkStoreData { + headers: ReadonlyHeaders; + cookies: ReadonlyRequestCookies; + dedupeCacheKey: Headers | IncomingHttpHeaders; + overrides: Record | null; +} + +const bulkStore = new AsyncLocalStorage(); + +let headersModulePromise: Promise | undefined; +let headersModule: typeof import('next/headers') | undefined; + +/** + * Subset of a flag declaration / flag function that `applyResult` reads. + * `FlagDeclaration` (passed from `getRun`) and the `api` (passed from + * `evaluate()`) both satisfy this shape after `flag()` stamps `config` onto + * the api. + */ +type FlagInfo = { + key: string; + defaultValue?: ValueType; + config?: { reportValue?: boolean }; +}; + +/** + * Finalize a flag evaluation given an already-computed `entitiesKey`. + * + * Shared by `getRun` (single-flag path) and `evaluate()` (group path). Handles, + * in order: cache hit → override → produce → defaultValue/error normalization + * → cache write → reportValue. Override and cache writes write to the same + * `evaluationCache` either path uses, so a subsequent `flagFn()` in the same + * request hits cache regardless of which path populated it. + */ +async function applyResult(args: { + definition: FlagInfo; + readonlyHeaders: ReadonlyHeaders; + entitiesKey: string; + overrides: Record | null; + produce: () => ValueType | PromiseLike; +}): Promise { + const { definition, readonlyHeaders, entitiesKey, overrides, produce } = args; + + const cachedValue = getCachedValuePromise( + readonlyHeaders, + definition.key, + entitiesKey, + ); + if (cachedValue !== undefined) { + setSpanAttribute('method', 'cached'); + return await cachedValue; + } + + if (overrides && overrides[definition.key] !== undefined) { + setSpanAttribute('method', 'override'); + const decision = overrides[definition.key] as ValueType; + setCachedValuePromise( + readonlyHeaders, + definition.key, + entitiesKey, + Promise.resolve(decision), + ); + internalReportValue(definition.key, decision, { + reason: 'override', + }); + return decision; + } + + // Normalize the result of produce() into a promise. produce() may return + // synchronously or asynchronously, and may also throw synchronously. + // Fall back to defaultValue when produce returns undefined or throws. + let decisionResult: ValueType | PromiseLike; + try { + decisionResult = produce(); + } catch (error) { + decisionResult = Promise.reject(error); + } + + const decisionPromise = Promise.resolve(decisionResult).then< + ValueType, + ValueType + >( + (value) => { + if (value !== undefined) return value; + if (definition.defaultValue !== undefined) return definition.defaultValue; + throw new Error( + `flags: Flag "${definition.key}" must have a defaultValue or a decide function that returns a value`, + ); + }, + (error: Error) => { + if (isInternalNextError(error)) throw error; + + // try to recover if defaultValue is set + if (definition.defaultValue !== undefined) { + if (process.env.NODE_ENV === 'development') { + console.info( + `flags: Flag "${definition.key}" is falling back to its defaultValue`, + ); + } else { + console.warn( + `flags: Flag "${definition.key}" is falling back to its defaultValue after catching the following error`, + error, + ); + } + return definition.defaultValue; + } + console.warn(`flags: Flag "${definition.key}" could not be evaluated`); + throw error; + }, + ); + + setCachedValuePromise( + readonlyHeaders, + definition.key, + entitiesKey, + decisionPromise, + ); + + const decision = await decisionPromise; + + if (definition.config?.reportValue !== false) { + // Only check `config.reportValue` for the result of `decide`. + // No need to check it for `override` since the client will have + // be short circuited in that case. + reportValue(definition.key, decision); + } + + return decision; +} + +type Run = (options: { + entities?: EntitiesType; + identify?: + | FlagDeclaration['identify'] + | EntitiesType; + /** + * For Pages Router only + */ + request?: PagesRouterRequest; +}) => Promise; + +/** + * Builds the runtime function used by a single flag. Handles Pages Router, + * App Router, and reuse of pre-read data when called from inside `evaluate()`. + */ +export function getRun( + definition: FlagDeclaration, + decide: Decide, +): Run { + // use cache to guarantee flags only decide once per request + return async function run(options): Promise { + let readonlyHeaders: ReadonlyHeaders; + let readonlyCookies: ReadonlyRequestCookies; + let dedupeCacheKey: Headers | IncomingHttpHeaders; + + // Check if running inside evaluate() — reuse pre-read headers/cookies/overrides + const bulkData = bulkStore.getStore(); + + let overrides: Record | null; + + if (options.request) { + // pages router + const headers = transformToHeaders(options.request.headers); + readonlyHeaders = sealHeaders(headers); + readonlyCookies = sealCookies(headers); + dedupeCacheKey = options.request.headers; + + // skip microtask if cookie does not exist or is empty + const override = readonlyCookies.get('vercel-flag-overrides')?.value; + overrides = + typeof override === 'string' && override !== '' + ? await getOverrides(override) + : null; + } else if (bulkData) { + // app router — evaluate() mode, everything pre-read + readonlyHeaders = bulkData.headers; + readonlyCookies = bulkData.cookies; + dedupeCacheKey = bulkData.dedupeCacheKey; + overrides = bulkData.overrides; + } 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 + const override = readonlyCookies.get('vercel-flag-overrides')?.value; + overrides = + typeof override === 'string' && override !== '' + ? await getOverrides(override) + : null; + } + + // the flag is being used in app router + // skip microtask if identify does not exist + const entities = options.identify + ? ((await getEntities( + options.identify, + dedupeCacheKey, + readonlyHeaders, + readonlyCookies, + )) as EntitiesType | undefined) + : undefined; + + const entitiesKey = JSON.stringify(entities) ?? ''; + + return applyResult({ + definition, + readonlyHeaders, + entitiesKey, + overrides, + produce: () => + decide({ + // @ts-expect-error TypeScript will not be able to process `getPrecomputed` when added to `Decide`. It is, however, part of the `Adapter` type + defaultValue: definition.defaultValue, + headers: readonlyHeaders, + cookies: readonlyCookies, + entities, + }), + }); + }; +} + +// Distributive value extraction. `Flag` is itself a union +// (AppRouterFlag | PagesRouterFlag | PrecomputedFlag), so inferring V against +// a union element type only works when the conditional's check type is a +// naked type parameter — hence the helper. +type BulkValue = F extends Flag ? V : never; + +type EvaluateRequest = PagesRouterRequest | Request; + +/** + * Resolves a set of flags in a single call. + * + * Pre-reads headers, cookies, and the override cookie once for the whole + * batch, then partitions flags by `(adapterId, identify)` so adapters that + * implement `bulkDecide` can evaluate an entire group through a single call. + * Flags whose adapters don't opt into bulk evaluation (no `adapterId` or no + * `bulkDecide`) and flags with an inline `decide` fall back to the per-flag + * path — they still benefit from the shared pre-read of headers, cookies, and + * overrides. + * + * Accepts either an array of flags (positional results) or an object whose + * values are flags (keyed results). + * + * Pass a `request` as the second argument when calling outside App Router — + * an `IncomingMessage` from Pages Router (`getServerSideProps`, API routes) + * or a `NextRequest` / Web `Request` from routing middleware. Without it, + * `evaluate()` reads from `next/headers`, which is only available in App + * Router and routing middleware. + */ +export async function evaluate[]>( + flags: T, + request?: EvaluateRequest, +): Promise<{ [K in keyof T]: BulkValue }>; +export async function evaluate>>( + flags: T, + request?: EvaluateRequest, +): Promise<{ [K in keyof T]: BulkValue }>; +export async function evaluate( + flags: Record> | readonly Flag[], + request?: EvaluateRequest, +): Promise { + // Skip the `next/headers` read when there's nothing to evaluate. This also + // lets `precompute([])` return `__no_flags__` outside a request scope (e.g. + // during static generation), which is the documented behavior of an empty + // precompute group. + if ( + Array.isArray(flags) ? flags.length === 0 : Object.keys(flags).length === 0 + ) { + return Array.isArray(flags) ? [] : {}; + } + + let readonlyHeaders: ReadonlyHeaders; + let readonlyCookies: ReadonlyRequestCookies; + let dedupeCacheKey: Headers | IncomingHttpHeaders; + + if (request) { + // Derive headers/cookies from the request, skipping the `next/headers` + // import. Discriminate by whether `.headers` is already a `Headers` + // instance (NextRequest / Web Request) or an `IncomingHttpHeaders` plain + // object (Pages Router `IncomingMessage`). + const headers = + request.headers instanceof Headers + ? request.headers + : transformToHeaders(request.headers); + readonlyHeaders = sealHeaders(headers); + readonlyCookies = sealCookies(headers); + dedupeCacheKey = request.headers; + } else { + // app router — read headers & cookies via `next/headers`. + 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; + } + + // Read overrides once + const override = readonlyCookies.get('vercel-flag-overrides')?.value; + const overrides = + typeof override === 'string' && override !== '' + ? await getOverrides(override) + : null; + + const storeData: BulkStoreData = { + headers: readonlyHeaders, + cookies: readonlyCookies, + dedupeCacheKey, + overrides, + }; + + return bulkStore.run(storeData, async () => { + const entries = Object.entries(flags); + + const standalone: { name: string; flagFn: Flag }[] = []; + // adapterId -> identifyRef -> { adapter, entries } + const groups = new Map< + string | symbol, + Map< + unknown, + { + adapter: Adapter; + entries: { name: string; flagFn: Flag }[]; + } + > + >(); + + for (const [name, flagFn] of entries) { + const entry = { name, flagFn }; + if (!(flagFn as any)[BULKABLE]) { + standalone.push(entry); + continue; + } + const adapter = flagFn.adapter as Adapter; + const groupId = adapter.adapterId as string | symbol; + const identifyRef = (flagFn as any)[BULK_IDENTIFY_REF] ?? null; + let byIdentify = groups.get(groupId); + if (!byIdentify) { + byIdentify = new Map(); + groups.set(groupId, byIdentify); + } + let bucket = byIdentify.get(identifyRef); + if (!bucket) { + // Capture the first adapter for this group — any adapter with the + // same adapterId must wrap the same underlying resource. + bucket = { adapter, entries: [] }; + byIdentify.set(identifyRef, bucket); + } + bucket.entries.push(entry); + } + + const valuesByName: Record = {}; + const groupPromises: Promise[] = []; + + for (const byIdentify of groups.values()) { + for (const [identifyRef, { adapter, entries: list }] of byIdentify) { + groupPromises.push( + (async () => { + // Resolve entities once for the entire group. The dedupe key is + // the same one `getRun` uses (`request.headers` for Pages Router, + // the `headers()` store for App Router), so any flag called + // individually before/after `evaluate()` reuses the cached + // identify args from `identifyArgsMap`. + const entities = identifyRef + ? await getEntities( + identifyRef as any, + dedupeCacheKey, + readonlyHeaders, + readonlyCookies, + ) + : undefined; + const entitiesKey = JSON.stringify(entities) ?? ''; + + // Skip flags already resolved this request — `applyResult` would + // discard the bulk result for them anyway. If every flag in the + // group is cached, the adapter call is avoided entirely. + const uncached = list.filter( + ({ flagFn }) => + getCachedValuePromise( + readonlyHeaders, + flagFn.key, + entitiesKey, + ) === undefined, + ); + + // Call bulkDecide. If it throws, every uncached flag still goes + // through `applyResult` — its producer just rethrows, so the + // catch arm handles the per-flag defaultValue fallback (or + // rejects for flags without a defaultValue). + let bulkResult: Record | null = null; + let bulkError: unknown = null; + if (uncached.length > 0) { + try { + bulkResult = await adapter.bulkDecide!({ + flags: uncached.map(({ flagFn }) => ({ + key: flagFn.key, + defaultValue: flagFn.defaultValue, + })), + entities, + headers: readonlyHeaders, + cookies: readonlyCookies, + }); + } catch (err) { + bulkError = err; + } + } + + await Promise.all( + list.map(async ({ name, flagFn }) => { + valuesByName[name] = await applyResult({ + definition: flagFn, + readonlyHeaders, + entitiesKey, + overrides, + produce: () => { + if (bulkError) throw bulkError; + return bulkResult![flagFn.key]; + }, + }); + }), + ); + })(), + ); + } + } + + if (standalone.length > 0) { + groupPromises.push( + (async () => { + const values = await Promise.all( + standalone.map(({ flagFn }) => flagFn()), + ); + standalone.forEach(({ name }, i) => { + valuesByName[name] = values[i]; + }); + })(), + ); + } + + await Promise.all(groupPromises); + + const result: any = Array.isArray(flags) ? new Array(entries.length) : {}; + for (const [name] of entries) { + result[name] = valuesByName[name]; + } + return result; + }); +} diff --git a/packages/flags/src/next/index.test.ts b/packages/flags/src/next/index.test.ts index ab1bae58..0d84e1e7 100644 --- a/packages/flags/src/next/index.test.ts +++ b/packages/flags/src/next/index.test.ts @@ -5,9 +5,9 @@ import type { NextApiRequestCookies } from 'next/dist/server/api-utils'; import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest'; import { type Adapter, encryptOverrides } from '..'; import { - bulk, clearDedupeCacheForCurrentRequest, dedupe, + evaluate, flag, precompute, } from '.'; @@ -754,7 +754,7 @@ describe('adapters', () => { }); }); -describe('bulk', () => { +describe('evaluate', () => { beforeAll(() => { process.env.FLAGS_SECRET = 'yuhyxaVI0Zue85SguKlMIUQojvJyBPzm95fFYvOa4Rc'; }); @@ -803,7 +803,7 @@ describe('bulk', () => { const b = flag({ key: 'b', adapter: adapter() }); mocks.headers.mockReturnValueOnce(new Headers()); - await expect(bulk({ a, b })).resolves.toEqual({ a: 'A', b: 'B' }); + await expect(evaluate({ a, b })).resolves.toEqual({ a: 'A', b: 'B' }); expect(bulkDecideMock).toHaveBeenCalledTimes(1); expect(bulkDecideMock).toHaveBeenCalledWith( @@ -840,7 +840,7 @@ describe('bulk', () => { }); mocks.headers.mockReturnValueOnce(new Headers()); - await expect(bulk({ a, b })).resolves.toEqual({ a: 'v-a', b: 'v-b' }); + await expect(evaluate({ a, b })).resolves.toEqual({ a: 'v-a', b: 'v-b' }); expect(bulkDecideMock).toHaveBeenCalledTimes(2); }); @@ -854,7 +854,7 @@ describe('bulk', () => { const b = flag({ key: 'b', adapter: adapterB() }); mocks.headers.mockReturnValueOnce(new Headers()); - await expect(bulk({ a, b })).resolves.toEqual({ a: 'A', b: 'B' }); + await expect(evaluate({ a, b })).resolves.toEqual({ a: 'A', b: 'B' }); expect(bulkA).toHaveBeenCalledTimes(1); expect(bulkB).toHaveBeenCalledTimes(1); }); @@ -871,7 +871,7 @@ describe('bulk', () => { const a = flag({ key: 'a', adapter: adapter() }); mocks.headers.mockReturnValueOnce(new Headers()); - await expect(bulk({ a })).resolves.toEqual({ a: 'from-decide' }); + await expect(evaluate({ a })).resolves.toEqual({ a: 'from-decide' }); expect(bulkDecideMock).not.toHaveBeenCalled(); expect(decideMock).toHaveBeenCalledTimes(1); }); @@ -886,7 +886,7 @@ describe('bulk', () => { const a = flag({ key: 'a', adapter: adapter() }); mocks.headers.mockReturnValueOnce(new Headers()); - await expect(bulk({ a })).resolves.toEqual({ a: 'single' }); + await expect(evaluate({ a })).resolves.toEqual({ a: 'single' }); expect(decideMock).toHaveBeenCalledTimes(1); }); @@ -899,7 +899,7 @@ describe('bulk', () => { const b = flag({ key: 'b', adapter: adapter() }); mocks.headers.mockReturnValueOnce(new Headers()); - await expect(bulk({ a, b })).resolves.toEqual({ + await expect(evaluate({ a, b })).resolves.toEqual({ a: 'inline-result', b: 'bulk-result', }); @@ -924,7 +924,7 @@ describe('bulk', () => { }); mocks.headers.mockReturnValueOnce(new Headers()); - await expect(bulk({ a, b })).resolves.toEqual({ a: 'fa', b: 'fb' }); + await expect(evaluate({ a, b })).resolves.toEqual({ a: 'fa', b: 'fb' }); expect(bulkDecideMock).toHaveBeenCalledTimes(1); expect(warnSpy).toHaveBeenCalled(); warnSpy.mockRestore(); @@ -943,7 +943,7 @@ describe('bulk', () => { const b = flag({ key: 'b', adapter: adapter() }); mocks.headers.mockReturnValueOnce(new Headers()); - await expect(bulk({ a, b })).rejects.toThrow('bulk failed'); + await expect(evaluate({ a, b })).rejects.toThrow('bulk failed'); warnSpy.mockRestore(); }); @@ -959,7 +959,7 @@ describe('bulk', () => { }); mocks.headers.mockReturnValueOnce(new Headers()); - await expect(bulk({ a, b })).resolves.toEqual({ a: 'A', b: 'fb' }); + await expect(evaluate({ a, b })).resolves.toEqual({ a: 'A', b: 'fb' }); }); it('lets overrides win over bulkDecide results', async () => { @@ -977,7 +977,7 @@ describe('bulk', () => { mocks.headers.mockReturnValueOnce(new Headers()); mocks.cookies.mockReturnValueOnce({ get: cookieMock }); - await expect(bulk({ a })).resolves.toEqual({ a: true }); + await expect(evaluate({ a })).resolves.toEqual({ a: true }); }); it('populates the evaluation cache so a subsequent flagFn() hits cache', async () => { @@ -988,7 +988,7 @@ describe('bulk', () => { const headers = new Headers(); mocks.headers.mockReturnValue(headers); - await expect(bulk({ a })).resolves.toEqual({ a: 'A' }); + await expect(evaluate({ a })).resolves.toEqual({ a: 'A' }); expect(bulkDecideMock).toHaveBeenCalledTimes(1); // Subsequent direct call in the same "request" (same headers object) @@ -1007,7 +1007,90 @@ describe('bulk', () => { const apple = flag({ key: 'apple', adapter: adapter() }); mocks.headers.mockReturnValueOnce(new Headers()); - const result = await bulk({ zebra, apple }); + const result = await evaluate({ zebra, apple }); expect(Object.keys(result)).toEqual(['zebra', 'apple']); }); + + describe('with request argument', () => { + it('resolves flags using a Pages Router IncomingMessage without touching next/headers', async () => { + const bulkDecideMock = vi.fn().mockResolvedValue({ a: 'A', b: 'B' }); + const adapter = makeBulkAdapter({ bulkDecide: bulkDecideMock }); + + const a = flag({ key: 'a', adapter: adapter() }); + const b = flag({ key: 'b', adapter: adapter() }); + + mocks.headers.mockClear(); + const [request, socket] = createRequest(); + await expect(evaluate({ a, b }, request)).resolves.toEqual({ + a: 'A', + b: 'B', + }); + expect(mocks.headers).not.toHaveBeenCalled(); + expect(bulkDecideMock).toHaveBeenCalledTimes(1); + socket.destroy(); + }); + + it('accepts a web Request (NextRequest) and skips next/headers', async () => { + const bulkDecideMock = vi.fn().mockResolvedValue({ a: 'A', b: 'B' }); + const adapter = makeBulkAdapter({ bulkDecide: bulkDecideMock }); + + const a = flag({ key: 'a', adapter: adapter() }); + const b = flag({ key: 'b', adapter: adapter() }); + + mocks.headers.mockClear(); + const webRequest = new Request('http://example.com/', { + headers: { cookie: 'foo=bar' }, + }); + await expect(evaluate({ a, b }, webRequest)).resolves.toEqual({ + a: 'A', + b: 'B', + }); + expect(mocks.headers).not.toHaveBeenCalled(); + expect(bulkDecideMock).toHaveBeenCalledTimes(1); + + // bulkDecide receives the request's own headers (not a copy via + // transformToHeaders) — verify by checking a header round-trips. + const [callArgs] = bulkDecideMock.mock.calls; + expect(callArgs[0].headers.get('cookie')).toBe('foo=bar'); + }); + + it('array overload preserves order and skips next/headers', async () => { + const bulkDecideMock = vi.fn().mockResolvedValue({ z: 'Z', a: 'A' }); + const adapter = makeBulkAdapter({ bulkDecide: bulkDecideMock }); + + const z = flag({ key: 'z', adapter: adapter() }); + const a = flag({ key: 'a', adapter: adapter() }); + + mocks.headers.mockClear(); + const [request, socket] = createRequest(); + const result = await evaluate([z, a], request); + // positional: index 0 → z, index 1 → a + expect(result).toEqual(['Z', 'A']); + expect(mocks.headers).not.toHaveBeenCalled(); + socket.destroy(); + }); + + it('shares the per-request cache with direct flag(req) calls', async () => { + const bulkDecideMock = vi.fn().mockResolvedValue({ a: 'A' }); + const decideMock = vi.fn(() => 'inline'); + const adapter = makeBulkAdapter({ bulkDecide: bulkDecideMock }); + + const a = flag({ key: 'a', adapter: adapter() }); + const b = flag({ key: 'b', decide: decideMock }); + + const [request, socket] = createRequest(); + await expect(evaluate({ a, b }, request)).resolves.toEqual({ + a: 'A', + b: 'inline', + }); + + // Subsequent direct call in the same request should hit cache, + // not re-invoke bulkDecide/decide. + await expect(a(request)).resolves.toEqual('A'); + await expect(b(request)).resolves.toEqual('inline'); + expect(bulkDecideMock).toHaveBeenCalledTimes(1); + expect(decideMock).toHaveBeenCalledTimes(1); + socket.destroy(); + }); + }); }); diff --git a/packages/flags/src/next/index.ts b/packages/flags/src/next/index.ts index 6208ad2b..52e0ddde 100644 --- a/packages/flags/src/next/index.ts +++ b/packages/flags/src/next/index.ts @@ -1,41 +1,21 @@ -import { AsyncLocalStorage } from 'node:async_hooks'; -import type { IncomingHttpHeaders } from 'node:http'; -import { RequestCookies } from '@edge-runtime/cookies'; -import { - type FlagDefinitionsType, - type FlagDefinitionType, - type ProviderData, - reportValue, -} from '..'; +import type { FlagDefinitionsType, FlagDefinitionType, ProviderData } from '..'; import { normalizeOptions } from '../lib/normalize-options'; -import { internalReportValue } from '../lib/report-value'; import { setSpanAttribute, trace } from '../lib/tracing'; -import { - HeadersAdapter, - type ReadonlyHeaders, -} from '../spec-extension/adapters/headers'; -import { - type ReadonlyRequestCookies, - RequestCookiesAdapter, -} from '../spec-extension/adapters/request-cookies'; import type { - Adapter, Decide, FlagDeclaration, - FlagParamsType, Identify, JsonValue, Origin, } from '../types'; -import { isInternalNextError } from './is-internal-next-error'; -import { getOverrides } from './overrides'; +import { BULK_IDENTIFY_REF, BULKABLE, getRun } from './evaluate'; import { getPrecomputed } from './precompute'; import type { Flag, PagesRouterFlag, PrecomputedFlag } from './types'; +export { evaluate } from './evaluate'; export { combine, deserialize, - evaluate, generatePermutations, getPrecomputed, precompute, @@ -43,135 +23,6 @@ export { } from './precompute'; export type { Flag } from './types'; -// Internal markers stamped on the flag api by `flag()`. Read by `bulk()`. -// Kept off the public FlagMeta type — they're an implementation detail of -// how we partition flags for bulk evaluation. -const BULK_IDENTIFY_REF = Symbol('flags.bulkIdentifyRef'); -const BULKABLE = Symbol('flags.bulkable'); - -// a map of (headers, flagKey, entitiesKey) => value -const evaluationCache = new WeakMap< - Headers | IncomingHttpHeaders, - Map> ->(); - -function getCachedValuePromise( - /** - * supports Headers for App Router and IncomingHttpHeaders for Pages Router - */ - headers: Headers | IncomingHttpHeaders, - flagKey: string, - entitiesKey: string, -): any { - return evaluationCache.get(headers)?.get(flagKey)?.get(entitiesKey); -} - -function setCachedValuePromise( - /** - * supports Headers for App Router and IncomingHttpHeaders for Pages Router - */ - headers: Headers | IncomingHttpHeaders, - flagKey: string, - entitiesKey: string, - flagValue: any, -): any { - const byHeaders = evaluationCache.get(headers); - - if (!byHeaders) { - evaluationCache.set( - headers, - new Map([[flagKey, new Map([[entitiesKey, flagValue]])]]), - ); - return; - } - - const byFlagKey = byHeaders.get(flagKey); - if (!byFlagKey) { - byHeaders.set(flagKey, new Map([[entitiesKey, flagValue]])); - return; - } - - byFlagKey.set(entitiesKey, flagValue); -} - -type IdentifyArgs = Parameters< - Exclude['identify'], undefined> ->; -const transformMap = new WeakMap(); -const headersMap = new WeakMap(); -const cookiesMap = new WeakMap(); -const identifyArgsMap = new WeakMap< - Headers | IncomingHttpHeaders, - IdentifyArgs ->(); - -/** - * Transforms IncomingHttpHeaders to Headers - */ -function transformToHeaders(incomingHeaders: IncomingHttpHeaders): Headers { - const cached = transformMap.get(incomingHeaders); - if (cached !== undefined) return cached; - - const headers = new Headers(); - for (const [key, value] of Object.entries(incomingHeaders)) { - if (Array.isArray(value)) { - // If the value is an array, add each item separately - value.forEach((item) => { - headers.append(key, item); - }); - } else if (value !== undefined) { - // If it's a single value, add it directly - headers.append(key, value); - } - } - - transformMap.set(incomingHeaders, headers); - return headers; -} - -function sealHeaders(headers: Headers): ReadonlyHeaders { - const cached = headersMap.get(headers); - if (cached !== undefined) return cached; - - const sealed = HeadersAdapter.seal(headers); - headersMap.set(headers, sealed); - return sealed; -} - -function sealCookies(headers: Headers): ReadonlyRequestCookies { - const cached = cookiesMap.get(headers); - if (cached !== undefined) return cached; - - const sealed = RequestCookiesAdapter.seal(new RequestCookies(headers)); - cookiesMap.set(headers, sealed); - return sealed; -} - -function isIdentifyFunction( - identify: FlagDeclaration['identify'] | EntitiesType, -): identify is FlagDeclaration['identify'] { - return typeof identify === 'function'; -} - -async function getEntities( - identify: FlagDeclaration['identify'] | EntitiesType, - dedupeCacheKey: Headers | IncomingHttpHeaders, - readonlyHeaders: ReadonlyHeaders, - readonlyCookies: ReadonlyRequestCookies, -): Promise { - if (!identify) return undefined; - if (!isIdentifyFunction(identify)) return identify; - - const args = identifyArgsMap.get(dedupeCacheKey); - if (args) return identify(...(args as [FlagParamsType])); - - const nextArgs: IdentifyArgs = [ - { headers: readonlyHeaders, cookies: readonlyCookies }, - ]; - identifyArgsMap.set(dedupeCacheKey, nextArgs); - return identify(...(nextArgs as [FlagParamsType])); -} - function getDecide( definition: FlagDeclaration, ): Decide { @@ -201,200 +52,6 @@ function getDecide( }; } -interface BulkStoreData { - headers: ReadonlyHeaders; - cookies: ReadonlyRequestCookies; - dedupeCacheKey: Headers; - overrides: Record | null; -} - -const bulkStore = new AsyncLocalStorage(); - -// Distributive value extraction. `Flag` is itself a union -// (AppRouterFlag | PagesRouterFlag | PrecomputedFlag), so inferring V against -// a union element type only works when the conditional's check type is a -// naked type parameter — hence the helper. -type BulkValue = F extends Flag ? V : never; - -export async function bulk>>( - flags: T, -): Promise<{ [K in keyof T]: BulkValue }>; -export async function bulk[]>( - flags: T, -): Promise<{ [K in keyof T]: BulkValue }>; -export async function bulk( - flags: Record> | readonly Flag[], -): Promise { - // Read headers & cookies once - if (!headersModulePromise) headersModulePromise = import('next/headers'); - if (!headersModule) headersModule = await headersModulePromise; - const { headers, cookies } = headersModule; - - const [headersStore, cookiesStore] = await Promise.all([ - headers(), - cookies(), - ]); - - const readonlyHeaders = headersStore as ReadonlyHeaders; - const readonlyCookies = cookiesStore as ReadonlyRequestCookies; - - // Read overrides once - const override = readonlyCookies.get('vercel-flag-overrides')?.value; - const overrides = - typeof override === 'string' && override !== '' - ? await getOverrides(override) - : null; - - const storeData: BulkStoreData = { - headers: readonlyHeaders, - cookies: readonlyCookies, - dedupeCacheKey: headersStore, - overrides, - }; - - // Run all flags within the bulk store context. We partition flags by - // (adapterId, identifyRef) so adapters that implement `bulkDecide` can - // evaluate an entire group in a single call. Flags whose adapters don't - // opt into bulk (no `adapterId` or no `bulkDecide`) and flags with an - // inline `decide` fall back to the per-flag `flagFn()` path — which still - // benefits from the pre-read headers/cookies/overrides via `bulkStore`. - return bulkStore.run(storeData, async () => { - const entries = Object.entries(flags); - - const standalone: { name: string; flagFn: Flag }[] = []; - // adapterId -> identifyRef -> { adapter, entries } - const groups = new Map< - string | symbol, - Map< - unknown, - { - adapter: Adapter; - entries: { name: string; flagFn: Flag }[]; - } - > - >(); - - for (const [name, flagFn] of entries) { - const entry = { name, flagFn }; - if (!(flagFn as any)[BULKABLE]) { - standalone.push(entry); - continue; - } - const adapter = flagFn.adapter as Adapter; - const groupId = adapter.adapterId as string | symbol; - const identifyRef = (flagFn as any)[BULK_IDENTIFY_REF] ?? null; - let byIdentify = groups.get(groupId); - if (!byIdentify) { - byIdentify = new Map(); - groups.set(groupId, byIdentify); - } - let bucket = byIdentify.get(identifyRef); - if (!bucket) { - // Capture the first adapter for this group — any adapter with the - // same adapterId must wrap the same underlying resource. - bucket = { adapter, entries: [] }; - byIdentify.set(identifyRef, bucket); - } - bucket.entries.push(entry); - } - - const valuesByName: Record = {}; - const groupPromises: Promise[] = []; - - for (const byIdentify of groups.values()) { - for (const [identifyRef, { adapter, entries: list }] of byIdentify) { - groupPromises.push( - (async () => { - // Resolve entities once for the entire group. The dedupe key is - // the raw `headersStore` (same key getRun uses), so any flag - // called individually after `bulk()` reuses the cached identify - // args from `identifyArgsMap`. - const entities = identifyRef - ? await getEntities( - identifyRef as any, - headersStore, - readonlyHeaders, - readonlyCookies, - ) - : undefined; - const entitiesKey = JSON.stringify(entities) ?? ''; - - // Skip flags already resolved this request — `applyResult` would - // discard the bulk result for them anyway. If every flag in the - // group is cached, the adapter call is avoided entirely. - const uncached = list.filter( - ({ flagFn }) => - getCachedValuePromise( - readonlyHeaders, - flagFn.key, - entitiesKey, - ) === undefined, - ); - - // Call bulkDecide. If it throws, every uncached flag still goes - // through `applyResult` — its producer just rethrows, so the - // catch arm handles the per-flag defaultValue fallback (or - // rejects for flags without a defaultValue). - let bulkResult: Record | null = null; - let bulkError: unknown = null; - if (uncached.length > 0) { - try { - bulkResult = await adapter.bulkDecide!({ - flags: uncached.map(({ flagFn }) => ({ - key: flagFn.key, - defaultValue: flagFn.defaultValue, - })), - entities, - headers: readonlyHeaders, - cookies: readonlyCookies, - }); - } catch (err) { - bulkError = err; - } - } - - await Promise.all( - list.map(async ({ name, flagFn }) => { - valuesByName[name] = await applyResult({ - definition: flagFn, - readonlyHeaders, - entitiesKey, - overrides, - produce: () => { - if (bulkError) throw bulkError; - return bulkResult![flagFn.key]; - }, - }); - }), - ); - })(), - ); - } - } - - if (standalone.length > 0) { - groupPromises.push( - (async () => { - const values = await Promise.all( - standalone.map(({ flagFn }) => flagFn()), - ); - standalone.forEach(({ name }, i) => { - valuesByName[name] = values[i]; - }); - })(), - ); - } - - await Promise.all(groupPromises); - - const result: any = Array.isArray(flags) ? new Array(entries.length) : {}; - for (const [name] of entries) { - result[name] = valuesByName[name]; - } - return result; - }); -} - function getIdentify( definition: FlagDeclaration, ): Identify { @@ -409,228 +66,6 @@ function getIdentify( }; } -type Run = (options: { - entities?: EntitiesType; - identify?: - | FlagDeclaration['identify'] - | EntitiesType; - /** - * For Pages Router only - */ - request?: Parameters>[0]; -}) => Promise; - -let headersModulePromise: Promise | undefined; -let headersModule: typeof import('next/headers') | undefined; - -/** - * Subset of a flag declaration / flag function that `applyResult` reads. - * `FlagDeclaration` (passed from `getRun`) and the `api` (passed from `bulk()`) - * both satisfy this shape after `flag()` stamps `config` onto the api. - */ -type FlagInfo = { - key: string; - defaultValue?: ValueType; - config?: { reportValue?: boolean }; -}; - -/** - * Finalize a flag evaluation given an already-computed `entitiesKey`. - * - * Shared by `getRun` (single-flag path) and `bulk()` (group path). Handles, in - * order: cache hit → override → produce → defaultValue/error normalization → - * cache write → reportValue. Override and cache writes write to the same - * `evaluationCache` either path uses, so a subsequent `flagFn()` in the same - * request hits cache regardless of which path populated it. - */ -async function applyResult(args: { - definition: FlagInfo; - readonlyHeaders: ReadonlyHeaders; - entitiesKey: string; - overrides: Record | null; - produce: () => ValueType | PromiseLike; -}): Promise { - const { definition, readonlyHeaders, entitiesKey, overrides, produce } = args; - - const cachedValue = getCachedValuePromise( - readonlyHeaders, - definition.key, - entitiesKey, - ); - if (cachedValue !== undefined) { - setSpanAttribute('method', 'cached'); - return await cachedValue; - } - - if (overrides && overrides[definition.key] !== undefined) { - setSpanAttribute('method', 'override'); - const decision = overrides[definition.key] as ValueType; - setCachedValuePromise( - readonlyHeaders, - definition.key, - entitiesKey, - Promise.resolve(decision), - ); - internalReportValue(definition.key, decision, { - reason: 'override', - }); - return decision; - } - - // Normalize the result of produce() into a promise. produce() may return - // synchronously or asynchronously, and may also throw synchronously. - // Fall back to defaultValue when produce returns undefined or throws. - let decisionResult: ValueType | PromiseLike; - try { - decisionResult = produce(); - } catch (error) { - decisionResult = Promise.reject(error); - } - - const decisionPromise = Promise.resolve(decisionResult).then< - ValueType, - ValueType - >( - (value) => { - if (value !== undefined) return value; - if (definition.defaultValue !== undefined) return definition.defaultValue; - throw new Error( - `flags: Flag "${definition.key}" must have a defaultValue or a decide function that returns a value`, - ); - }, - (error: Error) => { - if (isInternalNextError(error)) throw error; - - // try to recover if defaultValue is set - if (definition.defaultValue !== undefined) { - if (process.env.NODE_ENV === 'development') { - console.info( - `flags: Flag "${definition.key}" is falling back to its defaultValue`, - ); - } else { - console.warn( - `flags: Flag "${definition.key}" is falling back to its defaultValue after catching the following error`, - error, - ); - } - return definition.defaultValue; - } - console.warn(`flags: Flag "${definition.key}" could not be evaluated`); - throw error; - }, - ); - - setCachedValuePromise( - readonlyHeaders, - definition.key, - entitiesKey, - decisionPromise, - ); - - const decision = await decisionPromise; - - if (definition.config?.reportValue !== false) { - // Only check `config.reportValue` for the result of `decide`. - // No need to check it for `override` since the client will have - // be short circuited in that case. - reportValue(definition.key, decision); - } - - return decision; -} - -function getRun( - definition: FlagDeclaration, - decide: Decide, -): Run { - // use cache to guarantee flags only decide once per request - return async function run(options): Promise { - let readonlyHeaders: ReadonlyHeaders; - let readonlyCookies: ReadonlyRequestCookies; - let dedupeCacheKey: Headers | IncomingHttpHeaders; - - // Check if running inside bulk() — reuse pre-read headers/cookies/overrides - const bulkData = bulkStore.getStore(); - - let overrides: Record | null; - - if (options.request) { - // pages router - const headers = transformToHeaders(options.request.headers); - readonlyHeaders = sealHeaders(headers); - readonlyCookies = sealCookies(headers); - dedupeCacheKey = options.request.headers; - - // skip microtask if cookie does not exist or is empty - const override = readonlyCookies.get('vercel-flag-overrides')?.value; - overrides = - typeof override === 'string' && override !== '' - ? await getOverrides(override) - : null; - } else if (bulkData) { - // app router — bulk mode, everything pre-read - readonlyHeaders = bulkData.headers; - readonlyCookies = bulkData.cookies; - dedupeCacheKey = bulkData.dedupeCacheKey; - overrides = bulkData.overrides; - } 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 - const override = readonlyCookies.get('vercel-flag-overrides')?.value; - overrides = - typeof override === 'string' && override !== '' - ? await getOverrides(override) - : null; - } - - // the flag is being used in app router - // skip microtask if identify does not exist - const entities = options.identify - ? ((await getEntities( - options.identify, - dedupeCacheKey, - readonlyHeaders, - readonlyCookies, - )) as EntitiesType | undefined) - : undefined; - - const entitiesKey = JSON.stringify(entities) ?? ''; - - return applyResult({ - definition, - readonlyHeaders, - entitiesKey, - overrides, - produce: () => - decide({ - // @ts-expect-error TypeScript will not be able to process `getPrecomputed` when added to `Decide`. It is, however, part of the `Adapter` type - defaultValue: definition.defaultValue, - headers: readonlyHeaders, - cookies: readonlyCookies, - entities, - }), - }); - }; -} - function getOrigin( definition: FlagDeclaration, ): string | Origin | undefined { @@ -737,13 +172,8 @@ export function flag< api.adapter = definition.adapter; api.config = definition.config; - // Internal markers used by `bulk()` to partition flags into adapter groups. - // - BULK_IDENTIFY_REF: the raw identify source for reference-equality - // comparison across flags. `api.identify` is a wrapper created per - // `flag()` call, so it can't be used for grouping. - // - BULKABLE: whether the flag can participate in adapter-level bulk - // evaluation. An inline `definition.decide` disqualifies the flag - // because `getDecide` prefers it over the adapter's decide. + // Internal markers used by `evaluate()` to partition flags into adapter + // groups. See `./evaluate.ts` for the symbol definitions. (api as any)[BULK_IDENTIFY_REF] = definition.identify ?? definition.adapter?.identify ?? null; (api as any)[BULKABLE] = diff --git a/packages/flags/src/next/precompute.ts b/packages/flags/src/next/precompute.ts index e4571b96..af219c4a 100644 --- a/packages/flags/src/next/precompute.ts +++ b/packages/flags/src/next/precompute.ts @@ -1,23 +1,11 @@ import type { JsonValue } from '..'; import * as s from '../lib/serialization'; +import { evaluate } from './evaluate'; import type { Flag } from './types'; type FlagsArray = readonly Flag[]; type ValuesArray = readonly any[]; -/** - * Resolves a list of flags - * @param flags - list of flags - * @returns - an array of evaluated flag values with one entry per flag - */ -export async function evaluate( - flags: T, -): Promise<{ [K in keyof T]: Awaited> }> { - return Promise.all(flags.map((flag) => flag())) as Promise<{ - [K in keyof T]: Awaited>; - }>; -} - /** * Evaluate a list of feature flags and generate a signed string representing their values. * diff --git a/packages/flags/src/next/types.ts b/packages/flags/src/next/types.ts index dc975059..88d69684 100644 --- a/packages/flags/src/next/types.ts +++ b/packages/flags/src/next/types.ts @@ -6,6 +6,13 @@ type NextApiRequestCookies = Partial<{ [key: string]: string; }>; +/** + * The Pages Router request shape accepted by `flag(req)` and `evaluate(flags, req)`. + */ +export type PagesRouterRequest = IncomingMessage & { + cookies: NextApiRequestCookies; +}; + /** * Metadata on a feature flag function */ @@ -47,9 +54,9 @@ type FlagMeta = { */ identify?: FlagDeclaration['identify']; /** - * The adapter used to evaluate this flag, if any. Exposed so `bulk()` can - * group flags that share an `adapterId` and call `adapter.bulkDecide` once - * per group. + * The adapter used to evaluate this flag, if any. Exposed so `evaluate()` + * can group flags that share an `adapterId` and call `adapter.bulkDecide` + * once per group. */ adapter?: FlagDeclaration['adapter']; /** @@ -65,7 +72,7 @@ type FlagMeta = { identify: | FlagDeclaration['identify'] | EntitiesType; - request?: Parameters>[0]; + request?: PagesRouterRequest; }) => Promise; }; @@ -74,9 +81,7 @@ export type AppRouterFlag = export type PagesRouterFlag = { (): never; - ( - request: IncomingMessage & { cookies: NextApiRequestCookies }, - ): Promise; + (request: PagesRouterRequest): Promise; } & FlagMeta; export type PrecomputedFlag = { diff --git a/packages/flags/src/types.ts b/packages/flags/src/types.ts index bd809fc9..cdd1cbc4 100644 --- a/packages/flags/src/types.ts +++ b/packages/flags/src/types.ts @@ -153,7 +153,7 @@ export interface Adapter { * function so every adapter object the factory returns shares the same id. * * The Flags SDK uses this for cross-instance grouping (most notably, - * `bulk()` batches flags whose adapters share an `adapterId` and an + * `evaluate()` batches flags whose adapters share an `adapterId` and an * `identify` source through a single `bulkDecide` call). Adapters without * an `adapterId` are never batched. */ @@ -169,10 +169,10 @@ export interface Adapter { defaultValue?: unknown; }) => Promise | ValueType; /** - * Optional batch hook used by `bulk()` to evaluate many flags that share - * this adapter's `adapterId` and the same `identify` source in a single - * call. When implemented (and `adapterId` is set), `bulk()` calls this - * once per group instead of invoking `decide` per flag. + * Optional batch hook used by `evaluate()` to resolve many flags that + * share this adapter's `adapterId` and the same `identify` source in a + * single call. When implemented (and `adapterId` is set), `evaluate()` + * calls this once per group instead of invoking `decide` per flag. * * - Return `Record`. Missing keys or `value: undefined` * trigger the per-flag `defaultValue` fallback in the SDK.