From bb72353353f38209fedbead2ae476927f300fec6 Mon Sep 17 00:00:00 2001 From: Dominik Ferber Date: Wed, 1 Apr 2026 20:50:37 +0300 Subject: [PATCH 01/26] [flags] avoid re-imports Avoid re-importing next/headers since it adds unnecessary microtask queue overhead --- .changeset/polite-cycles-grab.md | 13 +++++++++++++ packages/flags/src/next/index.ts | 12 ++++++++++-- 2 files changed, 23 insertions(+), 2 deletions(-) create mode 100644 .changeset/polite-cycles-grab.md diff --git a/.changeset/polite-cycles-grab.md b/.changeset/polite-cycles-grab.md new file mode 100644 index 00000000..fc172e68 --- /dev/null +++ b/.changeset/polite-cycles-grab.md @@ -0,0 +1,13 @@ +--- +"flags": patch +--- + +Improve performance by caching `next/headers` imports. + +Previously every flag evaluation in Next.js App Router would run +`await import("next/headers")`. The imported module is cached by +the runtime, but we would still go through the event loop unnecessarily. + +Now we cache the resolved module in a local variable so only the +first call awaits the dynamic import; subsequent calls skip the +microtask entirely. diff --git a/packages/flags/src/next/index.ts b/packages/flags/src/next/index.ts index cfeda6f2..270b964c 100644 --- a/packages/flags/src/next/index.ts +++ b/packages/flags/src/next/index.ts @@ -220,6 +220,9 @@ type Run = (options: { request?: Parameters>[0]; }) => Promise; +let headersModulePromise: Promise; +let headersModule: typeof import('next/headers') | undefined; + function getRun( definition: FlagDeclaration, decide: Decide, @@ -240,8 +243,13 @@ function getRun( // app router // async import required as turbopack errors in Pages Router - // when next/headers is imported at the top-level - const { headers, cookies } = await import('next/headers'); + // 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(), From ecfcc324c449bb7ef9604f04f6986f63d158ea02 Mon Sep 17 00:00:00 2001 From: Dominik Ferber Date: Wed, 1 Apr 2026 21:11:38 +0300 Subject: [PATCH 02/26] [flags] avoid iife microtask queue overhead --- .changeset/remove-async-iife-decide.md | 5 ++ packages/flags/src/next/index.ts | 81 +++++++++++++------------- 2 files changed, 47 insertions(+), 39 deletions(-) create mode 100644 .changeset/remove-async-iife-decide.md diff --git a/.changeset/remove-async-iife-decide.md b/.changeset/remove-async-iife-decide.md new file mode 100644 index 00000000..d775f205 --- /dev/null +++ b/.changeset/remove-async-iife-decide.md @@ -0,0 +1,5 @@ +--- +"flags": patch +--- + +Reduce microtask queue overhead in flag evaluation by replacing the async IIFE around `decide()` with a direct call and `Promise.resolve()`. diff --git a/packages/flags/src/next/index.ts b/packages/flags/src/next/index.ts index 270b964c..9dbcad3e 100644 --- a/packages/flags/src/next/index.ts +++ b/packages/flags/src/next/index.ts @@ -301,52 +301,55 @@ function getRun( return decision; } - // We use an async iife to ensure we can catch both sync and async errors of - // the original decide function, as that one is not guaranted to be async. - // - // Also fall back to defaultValue when the decide function returns undefined or throws an error. - const decisionPromise = (async () => { - return decide({ + // Normalize the result of decide() into a promise. decide() may return + // synchronously or asynchronously, and may also throw synchronously. + // Fall back to defaultValue when decide returns undefined or throws. + let decisionResult: ValueType | PromiseLike; + try { + decisionResult = 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, }); - })() - // catch errors in async "decide" functions - .then( - (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; + } 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, + ); } - console.warn( - `flags: Flag "${definition.key}" could not be evaluated`, - ); - throw error; - }, - ); + return definition.defaultValue; + } + console.warn(`flags: Flag "${definition.key}" could not be evaluated`); + throw error; + }, + ); setCachedValuePromise( readonlyHeaders, From df5281a35d0fb215696fa119fe3264a2b10455db Mon Sep 17 00:00:00 2001 From: Dominik Ferber Date: Wed, 1 Apr 2026 21:21:00 +0300 Subject: [PATCH 03/26] [flags] skip awaits where possible --- packages/flags/src/next/index.ts | 28 ++++++++++++++++------------ 1 file changed, 16 insertions(+), 12 deletions(-) diff --git a/packages/flags/src/next/index.ts b/packages/flags/src/next/index.ts index 9dbcad3e..93b15a25 100644 --- a/packages/flags/src/next/index.ts +++ b/packages/flags/src/next/index.ts @@ -55,9 +55,7 @@ function getCachedValuePromise( flagKey: string, entitiesKey: string, ): any { - const map = evaluationCache.get(headers)?.get(flagKey); - if (!map) return undefined; - return map.get(entitiesKey); + return evaluationCache.get(headers)?.get(flagKey)?.get(entitiesKey); } function setCachedValuePromise( @@ -260,17 +258,23 @@ function getRun( dedupeCacheKey = headersStore; } - const overrides = await getOverrides( - readonlyCookies.get('vercel-flag-overrides')?.value, - ); + // skip microtask if cookie does not exist or is empty + const override = readonlyCookies.get('vercel-flag-overrides')?.value; + const overrides = + typeof override === 'string' && override !== '' + ? await getOverrides(override) + : null; // the flag is being used in app router - const entities = (await getEntities( - options.identify, - dedupeCacheKey, - readonlyHeaders, - readonlyCookies, - )) as EntitiesType | undefined; + // skip microtask if identify does not exist + const entities = options.identify + ? ((await getEntities( + options.identify, + dedupeCacheKey, + readonlyHeaders, + readonlyCookies, + )) as EntitiesType | undefined) + : undefined; // check cache const entitiesKey = JSON.stringify(entities) ?? ''; From f6934133d9b0abf836d505998d61317056196f2c Mon Sep 17 00:00:00 2001 From: Dominik Ferber Date: Wed, 1 Apr 2026 21:40:13 +0300 Subject: [PATCH 04/26] [flags] allow bulk eval --- packages/flags/src/next/index.ts | 89 +++++++++++++++++++++++++++++--- 1 file changed, 82 insertions(+), 7 deletions(-) diff --git a/packages/flags/src/next/index.ts b/packages/flags/src/next/index.ts index 93b15a25..49c075ca 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 { @@ -193,6 +194,62 @@ function getDecide( }; } +interface BulkStoreData { + headers: ReadonlyHeaders; + cookies: ReadonlyRequestCookies; + dedupeCacheKey: Headers; + overrides: Record | null; +} + +const bulkStore = new AsyncLocalStorage(); + +type BulkFlags = Record>; +type BulkResult = { + [K in keyof T]: T[K] extends Flag ? V : never; +}; + +export async function bulk( + flags: T, +): 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 + return bulkStore.run(storeData, async () => { + const entries = Object.entries(flags); + const values = await Promise.all(entries.map(([, flagFn]) => flagFn())); + const result = {} as BulkResult; + for (let i = 0; i < entries.length; i++) { + (result as any)[entries[i]![0]] = values[i]; + } + return result; + }); +} + function getIdentify( definition: FlagDeclaration, ): Identify { @@ -231,12 +288,30 @@ function getRun( 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 @@ -256,14 +331,14 @@ function getRun( 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; - const overrides = - typeof override === 'string' && override !== '' - ? await getOverrides(override) - : null; + // 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 From fe9605aa1f40f47ddc9ae1f3aa694776c36bd0cb Mon Sep 17 00:00:00 2001 From: Dominik Ferber Date: Wed, 13 May 2026 10:13:46 +0300 Subject: [PATCH 05/26] wip --- apps/playground/app/page.tsx | 98 ++++++- apps/playground/flags.ts | 494 ++++++++++++++++++++++++++++++++++- package.json | 1 + 3 files changed, 587 insertions(+), 6 deletions(-) diff --git a/apps/playground/app/page.tsx b/apps/playground/app/page.tsx index 5d5ab5c5..8c6b7948 100644 --- a/apps/playground/app/page.tsx +++ b/apps/playground/app/page.tsx @@ -1,8 +1,61 @@ import Image from 'next/image'; -import { jsonFlag } from '../flags'; +import Link from 'next/link'; +import * as flags from '../flags'; -export default async function Home() { - const data = await jsonFlag(); +type Mode = 'sequential' | 'parallel' | 'bulk'; + +async function measureSequential() { + const resolved: Record = {}; + const before = Date.now(); + for (const [, flag] of Object.entries(flags)) { + const value = await flag(); + resolved[flag.key] = value; + } + const after = Date.now(); + const duration = after - before; + + return { duration, resolved }; +} + +async function measureParallel() { + const resolved: Record = {}; + const before = Date.now(); + const promises = Object.entries(flags).map(async ([, flag]) => { + const value = await flag(); + resolved[flag.key] = value; + }); + await Promise.all(promises); + const after = Date.now(); + const duration = after - before; + + return { duration, resolved }; +} + +async function measureBulk() { + return measureParallel(); +} + +function getMode(modeParam: string | undefined): Mode { + return modeParam === 'parallel' + ? 'parallel' + : modeParam === 'bulk' + ? 'bulk' + : 'sequential'; +} + +export default async function Home({ + searchParams, +}: { + searchParams: Promise<{ mode?: string }>; +}) { + const { mode: modeParam } = await searchParams; + const mode: Mode = getMode(modeParam); + const result = + mode === 'parallel' + ? await measureParallel() + : mode === 'bulk' + ? await measureBulk() + : await measureSequential(); return (
@@ -16,7 +69,44 @@ export default async function Home() { priority />
-
{JSON.stringify(data, null, 2)}
+
+ + Sequential + + + Parallel + + + Bulk + +
+
+            {JSON.stringify({ mode, duration: result.duration }, null, 2)}
+          

To get started, edit the page.tsx file.

diff --git a/apps/playground/flags.ts b/apps/playground/flags.ts index 2c6fa084..3c7fb663 100644 --- a/apps/playground/flags.ts +++ b/apps/playground/flags.ts @@ -1,7 +1,497 @@ import { vercelAdapter } from '@flags-sdk/vercel'; import { flag } from 'flags/next'; -export const jsonFlag = flag({ - key: 'json-flag', +// export const jsonFlag = flag({ +// key: 'json-flag', +// adapter: vercelAdapter(), +// }); + +export const flag0 = flag({ + key: 'flag0', + adapter: vercelAdapter(), +}); + +export const flag1 = flag({ + key: 'flag1', + adapter: vercelAdapter(), +}); + +export const flag2 = flag({ + key: 'flag2', + adapter: vercelAdapter(), +}); + +export const flag3 = flag({ + key: 'flag3', + adapter: vercelAdapter(), +}); + +export const flag4 = flag({ + key: 'flag4', + adapter: vercelAdapter(), +}); + +export const flag5 = flag({ + key: 'flag5', + adapter: vercelAdapter(), +}); + +export const flag6 = flag({ + key: 'flag6', + adapter: vercelAdapter(), +}); + +export const flag7 = flag({ + key: 'flag7', + adapter: vercelAdapter(), +}); + +export const flag8 = flag({ + key: 'flag8', + adapter: vercelAdapter(), +}); + +export const flag9 = flag({ + key: 'flag9', + adapter: vercelAdapter(), +}); + +export const flag10 = flag({ + key: 'flag10', + adapter: vercelAdapter(), +}); + +export const flag11 = flag({ + key: 'flag11', + adapter: vercelAdapter(), +}); + +export const flag12 = flag({ + key: 'flag12', + adapter: vercelAdapter(), +}); + +export const flag13 = flag({ + key: 'flag13', + adapter: vercelAdapter(), +}); + +export const flag14 = flag({ + key: 'flag14', + adapter: vercelAdapter(), +}); + +export const flag15 = flag({ + key: 'flag15', + adapter: vercelAdapter(), +}); + +export const flag16 = flag({ + key: 'flag16', + adapter: vercelAdapter(), +}); + +export const flag17 = flag({ + key: 'flag17', + adapter: vercelAdapter(), +}); + +export const flag18 = flag({ + key: 'flag18', + adapter: vercelAdapter(), +}); + +export const flag19 = flag({ + key: 'flag19', + adapter: vercelAdapter(), +}); + +export const flag20 = flag({ + key: 'flag20', + adapter: vercelAdapter(), +}); + +export const flag21 = flag({ + key: 'flag21', + adapter: vercelAdapter(), +}); + +export const flag22 = flag({ + key: 'flag22', + adapter: vercelAdapter(), +}); + +export const flag23 = flag({ + key: 'flag23', + adapter: vercelAdapter(), +}); + +export const flag24 = flag({ + key: 'flag24', + adapter: vercelAdapter(), +}); + +export const flag25 = flag({ + key: 'flag25', + adapter: vercelAdapter(), +}); + +export const flag26 = flag({ + key: 'flag26', + adapter: vercelAdapter(), +}); + +export const flag27 = flag({ + key: 'flag27', + adapter: vercelAdapter(), +}); + +export const flag28 = flag({ + key: 'flag28', + adapter: vercelAdapter(), +}); + +export const flag29 = flag({ + key: 'flag29', + adapter: vercelAdapter(), +}); + +export const flag30 = flag({ + key: 'flag30', + adapter: vercelAdapter(), +}); + +export const flag31 = flag({ + key: 'flag31', + adapter: vercelAdapter(), +}); + +export const flag32 = flag({ + key: 'flag32', + adapter: vercelAdapter(), +}); + +export const flag33 = flag({ + key: 'flag33', + adapter: vercelAdapter(), +}); + +export const flag34 = flag({ + key: 'flag34', + adapter: vercelAdapter(), +}); + +export const flag35 = flag({ + key: 'flag35', + adapter: vercelAdapter(), +}); + +export const flag36 = flag({ + key: 'flag36', + adapter: vercelAdapter(), +}); + +export const flag37 = flag({ + key: 'flag37', + adapter: vercelAdapter(), +}); + +export const flag38 = flag({ + key: 'flag38', + adapter: vercelAdapter(), +}); + +export const flag39 = flag({ + key: 'flag39', + adapter: vercelAdapter(), +}); + +export const flag40 = flag({ + key: 'flag40', + adapter: vercelAdapter(), +}); + +export const flag41 = flag({ + key: 'flag41', + adapter: vercelAdapter(), +}); + +export const flag42 = flag({ + key: 'flag42', + adapter: vercelAdapter(), +}); + +export const flag43 = flag({ + key: 'flag43', + adapter: vercelAdapter(), +}); + +export const flag44 = flag({ + key: 'flag44', + adapter: vercelAdapter(), +}); + +export const flag45 = flag({ + key: 'flag45', + adapter: vercelAdapter(), +}); + +export const flag46 = flag({ + key: 'flag46', + adapter: vercelAdapter(), +}); + +export const flag47 = flag({ + key: 'flag47', + adapter: vercelAdapter(), +}); + +export const flag48 = flag({ + key: 'flag48', + adapter: vercelAdapter(), +}); + +export const flag49 = flag({ + key: 'flag49', + adapter: vercelAdapter(), +}); + +export const flag50 = flag({ + key: 'flag50', + adapter: vercelAdapter(), +}); + +export const flag51 = flag({ + key: 'flag51', + adapter: vercelAdapter(), +}); + +export const flag52 = flag({ + key: 'flag52', + adapter: vercelAdapter(), +}); + +export const flag53 = flag({ + key: 'flag53', + adapter: vercelAdapter(), +}); + +export const flag54 = flag({ + key: 'flag54', + adapter: vercelAdapter(), +}); + +export const flag55 = flag({ + key: 'flag55', + adapter: vercelAdapter(), +}); + +export const flag56 = flag({ + key: 'flag56', + adapter: vercelAdapter(), +}); + +export const flag57 = flag({ + key: 'flag57', + adapter: vercelAdapter(), +}); + +export const flag58 = flag({ + key: 'flag58', + adapter: vercelAdapter(), +}); + +export const flag59 = flag({ + key: 'flag59', + adapter: vercelAdapter(), +}); + +export const flag60 = flag({ + key: 'flag60', + adapter: vercelAdapter(), +}); + +export const flag61 = flag({ + key: 'flag61', + adapter: vercelAdapter(), +}); + +export const flag62 = flag({ + key: 'flag62', + adapter: vercelAdapter(), +}); + +export const flag63 = flag({ + key: 'flag63', + adapter: vercelAdapter(), +}); + +export const flag64 = flag({ + key: 'flag64', + adapter: vercelAdapter(), +}); + +export const flag65 = flag({ + key: 'flag65', + adapter: vercelAdapter(), +}); + +export const flag66 = flag({ + key: 'flag66', + adapter: vercelAdapter(), +}); + +export const flag67 = flag({ + key: 'flag67', + adapter: vercelAdapter(), +}); + +export const flag68 = flag({ + key: 'flag68', + adapter: vercelAdapter(), +}); + +export const flag69 = flag({ + key: 'flag69', + adapter: vercelAdapter(), +}); + +export const flag70 = flag({ + key: 'flag70', + adapter: vercelAdapter(), +}); + +export const flag71 = flag({ + key: 'flag71', + adapter: vercelAdapter(), +}); + +export const flag72 = flag({ + key: 'flag72', + adapter: vercelAdapter(), +}); + +export const flag73 = flag({ + key: 'flag73', + adapter: vercelAdapter(), +}); + +export const flag74 = flag({ + key: 'flag74', + adapter: vercelAdapter(), +}); + +export const flag75 = flag({ + key: 'flag75', + adapter: vercelAdapter(), +}); + +export const flag76 = flag({ + key: 'flag76', + adapter: vercelAdapter(), +}); + +export const flag77 = flag({ + key: 'flag77', + adapter: vercelAdapter(), +}); + +export const flag78 = flag({ + key: 'flag78', + adapter: vercelAdapter(), +}); + +export const flag79 = flag({ + key: 'flag79', + adapter: vercelAdapter(), +}); + +export const flag80 = flag({ + key: 'flag80', + adapter: vercelAdapter(), +}); + +export const flag81 = flag({ + key: 'flag81', + adapter: vercelAdapter(), +}); + +export const flag82 = flag({ + key: 'flag82', + adapter: vercelAdapter(), +}); + +export const flag83 = flag({ + key: 'flag83', + adapter: vercelAdapter(), +}); + +export const flag84 = flag({ + key: 'flag84', + adapter: vercelAdapter(), +}); + +export const flag85 = flag({ + key: 'flag85', + adapter: vercelAdapter(), +}); + +export const flag86 = flag({ + key: 'flag86', + adapter: vercelAdapter(), +}); + +export const flag87 = flag({ + key: 'flag87', + adapter: vercelAdapter(), +}); + +export const flag88 = flag({ + key: 'flag88', + adapter: vercelAdapter(), +}); + +export const flag89 = flag({ + key: 'flag89', + adapter: vercelAdapter(), +}); + +export const flag90 = flag({ + key: 'flag90', + adapter: vercelAdapter(), +}); + +export const flag91 = flag({ + key: 'flag91', + adapter: vercelAdapter(), +}); + +export const flag92 = flag({ + key: 'flag92', + adapter: vercelAdapter(), +}); + +export const flag93 = flag({ + key: 'flag93', + adapter: vercelAdapter(), +}); + +export const flag94 = flag({ + key: 'flag94', + adapter: vercelAdapter(), +}); + +export const flag95 = flag({ + key: 'flag95', + adapter: vercelAdapter(), +}); + +export const flag96 = flag({ + key: 'flag96', + adapter: vercelAdapter(), +}); + +export const flag97 = flag({ + key: 'flag97', adapter: vercelAdapter(), }); diff --git a/package.json b/package.json index 61ad0717..815c3849 100644 --- a/package.json +++ b/package.json @@ -27,6 +27,7 @@ "shirt-shop": "pnpm dev -F shirt-shop", "shirt-shop-api": "pnpm dev -F shirt-shop-api", "snippets": "pnpm dev -F snippets", + "playground": "pnpm dev -F playground", "svelte": "pnpm dev -F svelte-example", "test": "turbo test", "test:e2e": "turbo test:e2e", From 099fda87de24d45b7f14af2edd9fa2a5ebcc8ced Mon Sep 17 00:00:00 2001 From: Dominik Ferber Date: Wed, 13 May 2026 13:26:38 +0300 Subject: [PATCH 06/26] add bulk mode to playground --- apps/playground/app/page.tsx | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/apps/playground/app/page.tsx b/apps/playground/app/page.tsx index 8c6b7948..b35ddc93 100644 --- a/apps/playground/app/page.tsx +++ b/apps/playground/app/page.tsx @@ -1,3 +1,4 @@ +import { bulk } from 'flags/next'; import Image from 'next/image'; import Link from 'next/link'; import * as flags from '../flags'; @@ -32,7 +33,12 @@ async function measureParallel() { } async function measureBulk() { - return measureParallel(); + const before = Date.now(); + const resolved = await bulk(flags); + const after = Date.now(); + const duration = after - before; + + return { duration, resolved }; } function getMode(modeParam: string | undefined): Mode { From aa0dfbaebc13f96d5201323af5d1bcd8d90a1454 Mon Sep 17 00:00:00 2001 From: Dominik Ferber Date: Wed, 13 May 2026 13:29:21 +0300 Subject: [PATCH 07/26] first try of bulk eval --- .../vercel-flags-core/src/controller-fns.ts | 101 ++++++++++++- .../src/create-raw-client.ts | 18 +++ .../vercel-flags-core/src/evaluate.test.ts | 135 +++++++++++++++++- packages/vercel-flags-core/src/evaluate.ts | 39 +++++ .../vercel-flags-core/src/index.next-js.ts | 5 + packages/vercel-flags-core/src/types.ts | 24 ++++ 6 files changed, 320 insertions(+), 2 deletions(-) diff --git a/packages/vercel-flags-core/src/controller-fns.ts b/packages/vercel-flags-core/src/controller-fns.ts index 6a3cb8db..77066df7 100644 --- a/packages/vercel-flags-core/src/controller-fns.ts +++ b/packages/vercel-flags-core/src/controller-fns.ts @@ -1,10 +1,16 @@ -import { evaluate as evalFlag } from './evaluate'; +import { + type BulkEvaluationInput, + bulkEvaluate as bulkEvalFlags, + evaluate as evalFlag, +} from './evaluate'; import { internalReportValue } from './lib/report-value'; import type { + BulkEvaluateInput, BundledDefinitions, ControllerInterface, Datafile, EvaluationResult, + Metrics, Packed, } from './types'; import { ErrorCode, ResolutionReason } from './types'; @@ -129,3 +135,96 @@ export async function evaluate>( }, }); } + +export async function bulkEvaluate>( + id: number, + flags: BulkEvaluateInput[], + entities?: E, +): Promise>> { + const controller = getInstance(id).controller; + + let datafile: Datafile; + try { + datafile = await controller.read(); + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : 'Failed to read datafile'; + + const results: Record> = {}; + for (const flag of flags) { + results[flag.flagKey] = { + value: flag.defaultValue, + reason: ResolutionReason.ERROR, + errorMessage, + }; + } + return results; + } + + const baseMetrics: Metrics = { + readMs: datafile.metrics.readMs, + source: datafile.metrics.source, + cacheStatus: datafile.metrics.cacheStatus, + connectionState: datafile.metrics.connectionState, + mode: datafile.metrics.mode, + }; + + const projectId = datafile.projectId; + const results: Record> = {}; + const toEvaluate: Record> = {}; + + for (const flag of flags) { + const { flagKey, defaultValue } = flag; + const flagDefinition = datafile.definitions[ + flagKey + ] as Packed.FlagDefinition; + + if (flagDefinition === undefined) { + if (projectId) { + internalReportValue(flagKey, defaultValue, { + originProjectId: projectId, + originProvider: 'vercel', + reason: ResolutionReason.ERROR, + }); + } + results[flagKey] = { + value: defaultValue, + reason: ResolutionReason.ERROR, + errorCode: ErrorCode.FLAG_NOT_FOUND, + errorMessage: `@vercel/flags-core: Definition not found for flag "${flagKey}"`, + metrics: { evaluationMs: 0, ...baseMetrics }, + }; + continue; + } + + toEvaluate[flagKey] = { definition: flagDefinition, defaultValue }; + } + + const evalStartTime = Date.now(); + const evaluated = bulkEvalFlags(toEvaluate, { + entities: (entities ?? {}) as Record, + environment: datafile.environment, + segments: datafile.segments, + }); + const evaluationDurationMs = Date.now() - evalStartTime; + + for (const flagKey in toEvaluate) { + const result = evaluated[flagKey]!; + if (projectId) { + internalReportValue(flagKey, result.value, { + originProjectId: projectId, + originProvider: 'vercel', + reason: result.reason, + outcomeType: + result.reason !== ResolutionReason.ERROR + ? result.outcomeType + : undefined, + }); + } + results[flagKey] = Object.assign(result, { + metrics: { evaluationMs: evaluationDurationMs, ...baseMetrics }, + }); + } + + return results; +} diff --git a/packages/vercel-flags-core/src/create-raw-client.ts b/packages/vercel-flags-core/src/create-raw-client.ts index a9d2494d..49890862 100644 --- a/packages/vercel-flags-core/src/create-raw-client.ts +++ b/packages/vercel-flags-core/src/create-raw-client.ts @@ -1,4 +1,5 @@ import type { + bulkEvaluate, evaluate, getDatafile, getFallbackDatafile, @@ -10,6 +11,7 @@ import { controllerInstanceMap, } from './controller-fns'; import type { + BulkEvaluateInput, BundledDefinitions, ControllerInterface, EvaluationResult, @@ -38,6 +40,7 @@ export function createCreateRawClient(fns: { shutdown: typeof shutdown; getFallbackDatafile: typeof getFallbackDatafile; evaluate: typeof evaluate; + bulkEvaluate: typeof bulkEvaluate; getDatafile: typeof getDatafile; }) { return function createRawClient>({ @@ -108,6 +111,21 @@ export function createCreateRawClient(fns: { } return fns.evaluate(id, flagKey, defaultValue, entities); }, + bulkEvaluate: async ( + flags: BulkEvaluateInput[], + entities?: E, + ): Promise>> => { + const instance = controllerInstanceMap.get(id); + if (!instance?.initialized) { + try { + await api.initialize(); + } catch { + // Initialization failed — let bulkEvaluate() handle the fallback + // chain (last known value → datafile → bundled → defaultValue → throw) + } + } + return fns.bulkEvaluate(id, flags, entities); + }, }; return api; }; diff --git a/packages/vercel-flags-core/src/evaluate.test.ts b/packages/vercel-flags-core/src/evaluate.test.ts index 03713e3d..8abec6dd 100644 --- a/packages/vercel-flags-core/src/evaluate.test.ts +++ b/packages/vercel-flags-core/src/evaluate.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { evaluate } from './evaluate'; +import { bulkEvaluate, evaluate } from './evaluate'; import { Comparator, type EvaluationResult, @@ -2213,3 +2213,136 @@ describe('evaluate', () => { }); }); }); + +describe('bulkEvaluate', () => { + it('evaluates multiple flags against shared entities, segments, and environment', () => { + const activeDef: Packed.FlagDefinition = { + environments: { production: { fallthrough: 1 } }, + variants: [false, true], + }; + const pausedDef: Packed.FlagDefinition = { + environments: { production: 0 }, + variants: [false, true], + }; + const ruleDef: Packed.FlagDefinition = { + environments: { + production: { + rules: [ + { + conditions: [[['user', 'name'], Comparator.EQ, 'Joe']], + outcome: 1, + }, + ], + fallthrough: 0, + }, + }, + variants: [false, true], + }; + + expect( + bulkEvaluate( + { + active: { definition: activeDef }, + paused: { definition: pausedDef }, + ruled: { definition: ruleDef }, + }, + { + environment: 'production', + entities: { user: { name: 'Joe' } }, + }, + ), + ).toEqual({ + active: { + value: true, + reason: ResolutionReason.FALLTHROUGH, + outcomeType: OutcomeType.VALUE, + }, + paused: { + value: false, + reason: ResolutionReason.PAUSED, + outcomeType: OutcomeType.VALUE, + }, + ruled: { + value: true, + reason: ResolutionReason.RULE_MATCH, + outcomeType: OutcomeType.VALUE, + }, + }); + }); + + it('returns per-flag defaultValue on error', () => { + const definition: Packed.FlagDefinition = { + environments: { production: 0 }, + variants: [false, true], + }; + + const results = bulkEvaluate( + { + a: { definition, defaultValue: true }, + b: { definition, defaultValue: false }, + }, + { environment: 'this-env-does-not-exist', entities: {} }, + ); + + expect(results.a).toEqual({ + value: true, + reason: ResolutionReason.ERROR, + errorMessage: 'Could not find envConfig for "this-env-does-not-exist"', + }); + expect(results.b).toEqual({ + value: false, + reason: ResolutionReason.ERROR, + errorMessage: 'Could not find envConfig for "this-env-does-not-exist"', + }); + }); + + it('shares segments across flags', () => { + const definition: Packed.FlagDefinition = { + environments: { + production: { + rules: [ + { + conditions: [['segment', Comparator.EQ, 'segment1']], + outcome: 1, + }, + ], + fallthrough: 0, + }, + }, + variants: [false, true], + }; + + const results = bulkEvaluate( + { + a: { definition }, + b: { definition }, + }, + { + environment: 'production', + entities: { user: { name: 'Joe' } }, + segments: { + segment1: { + rules: [ + { + conditions: [[['user', 'name'], Comparator.EQ, 'Joe']], + outcome: 1, + }, + ], + }, + }, + }, + ); + + const expected: EvaluationResult = { + value: true, + reason: ResolutionReason.RULE_MATCH, + outcomeType: OutcomeType.VALUE, + }; + expect(results.a).toEqual(expected); + expect(results.b).toEqual(expected); + }); + + it('returns an empty object when no flags are provided', () => { + expect(bulkEvaluate({}, { environment: 'production' })).toEqual({}); + }); +}); diff --git a/packages/vercel-flags-core/src/evaluate.ts b/packages/vercel-flags-core/src/evaluate.ts index 470ab8a7..cb7687b7 100644 --- a/packages/vercel-flags-core/src/evaluate.ts +++ b/packages/vercel-flags-core/src/evaluate.ts @@ -484,6 +484,45 @@ export function evaluate( }) satisfies EvaluationResult; } +export type BulkEvaluationInput = { + definition: Packed.FlagDefinition; + defaultValue?: T; +}; + +/** + * Evaluates multiple feature flags against the same entities, segments, and + * environment. + * + * Reuses a single shared `EvaluationParams` object across flags so callers + * avoid the overhead of constructing one per call (and don't need to spawn + * parallel promises just to fan out independent sync evaluations). + */ +export function bulkEvaluate( + flags: Record>, + shared: { + entities?: Record; + environment: string; + segments?: EvaluationParams['segments']; + }, +): Record> { + const params: EvaluationParams = { + entities: shared.entities, + environment: shared.environment, + segments: shared.segments, + definition: undefined as unknown as Packed.FlagDefinition, + defaultValue: undefined, + }; + + const results: Record> = {}; + for (const key in flags) { + const flag = flags[key]!; + params.definition = flag.definition; + params.defaultValue = flag.defaultValue; + results[key] = evaluate(params); + } + return results; +} + /** * Find the weighted index that the given value falls into. * diff --git a/packages/vercel-flags-core/src/index.next-js.ts b/packages/vercel-flags-core/src/index.next-js.ts index 7cbb9127..00054f7d 100644 --- a/packages/vercel-flags-core/src/index.next-js.ts +++ b/packages/vercel-flags-core/src/index.next-js.ts @@ -58,6 +58,11 @@ const cachedFns: Parameters[0] = { setCacheLife(); return fns.evaluate(...args); }, + bulkEvaluate: async (...args) => { + 'use cache'; + setCacheLife(); + return fns.bulkEvaluate(...args); + }, }; export * from './index.common'; diff --git a/packages/vercel-flags-core/src/types.ts b/packages/vercel-flags-core/src/types.ts index fcc4f1e2..614e2676 100644 --- a/packages/vercel-flags-core/src/types.ts +++ b/packages/vercel-flags-core/src/types.ts @@ -115,6 +115,14 @@ export type Source = { projectSlug: string; }; +/** + * Input for a single flag in a bulk evaluation call. + */ +export type BulkEvaluateInput = { + flagKey: string; + defaultValue?: T; +}; + /** * A client for Vercel Flags */ @@ -141,6 +149,22 @@ export type FlagsClient> = { defaultValue?: T, entities?: E, ) => Promise>; + /** + * Evaluate multiple feature flags against the same entities in a single call. + * + * Avoids the per-flag overhead of separate `evaluate()` invocations (in particular, + * the parallel promises and repeated datafile reads they would entail). + * + * Requires initialize() to have been called and awaited first. + * + * @param flags Array of `{ flagKey, defaultValue? }` entries to evaluate. + * @param entities Shared entities used for every flag in the bulk call. + * @returns Object mapping each flagKey to its EvaluationResult. + */ + bulkEvaluate: ( + flags: BulkEvaluateInput[], + entities?: E, + ) => Promise>>; /** * Retrieve the latest datafile during startup, and set up subscriptions if needed. */ From 7cb1aebd380462f5f382b8d35897ad60dc07d125 Mon Sep 17 00:00:00 2001 From: Dominik Ferber Date: Wed, 13 May 2026 14:19:19 +0300 Subject: [PATCH 08/26] add bulk evaluation to adapters --- packages/adapter-vercel/src/index.test.ts | 66 ++++ packages/adapter-vercel/src/index.ts | 21 ++ packages/flags/src/next/index.test.ts | 266 +++++++++++++++- packages/flags/src/next/index.ts | 364 ++++++++++++++++------ packages/flags/src/next/types.ts | 10 + packages/flags/src/types.ts | 29 ++ 6 files changed, 660 insertions(+), 96 deletions(-) diff --git a/packages/adapter-vercel/src/index.test.ts b/packages/adapter-vercel/src/index.test.ts index 0275455a..a0213134 100644 --- a/packages/adapter-vercel/src/index.test.ts +++ b/packages/adapter-vercel/src/index.test.ts @@ -117,6 +117,72 @@ describe('createVercelAdapter', () => { .toHaveProperty('entities') .toEqualTypeOf(); }); + + describe('adapterId', () => { + it('shares one adapterId across all adapters from the same factory call', () => { + const adapter = createVercelAdapter(flagsClient); + const a = adapter(); + const b = adapter(); + expect(a).not.toBe(b); + expect(a.adapterId).toBeDefined(); + expect(a.adapterId).toBe(b.adapterId); + }); + + it('uses different adapterIds across separate factory calls', () => { + const adapterA = createVercelAdapter('vf_client_key_a'); + const adapterB = createVercelAdapter('vf_client_key_b'); + expect(adapterA().adapterId).not.toBe(adapterB().adapterId); + }); + }); + + describe('bulkDecide', () => { + it('forwards to flagsClient.bulkEvaluate with mapped flags and entities', async () => { + const bulkEvaluateMock = vi + .fn() + .mockResolvedValue({ a: { value: 'x' }, b: { value: 'y' } }); + const fakeClient = { + origin: { provider: 'vercel', sdkKey: 'vf_x' }, + bulkEvaluate: bulkEvaluateMock, + } as unknown as typeof flagsClient; + + const adapter = createVercelAdapter(fakeClient)(); + const result = await adapter.bulkDecide!({ + flags: [{ key: 'a', defaultValue: 'da' }, { key: 'b' }], + entities: { user: { id: 'u1' } } as any, + headers: undefined as any, + cookies: undefined as any, + }); + + expect(bulkEvaluateMock).toHaveBeenCalledTimes(1); + expect(bulkEvaluateMock).toHaveBeenCalledWith( + [ + { flagKey: 'a', defaultValue: 'da' }, + { flagKey: 'b', defaultValue: undefined }, + ], + { user: { id: 'u1' } }, + ); + expect(result).toEqual({ a: 'x', b: 'y' }); + }); + + it('omits keys whose EvaluationResult.value is undefined', async () => { + const fakeClient = { + origin: { provider: 'vercel', sdkKey: 'vf_x' }, + bulkEvaluate: vi.fn().mockResolvedValue({ + a: { value: 'ok' }, + b: { value: undefined, reason: 'error', errorMessage: 'nope' }, + }), + } as unknown as typeof flagsClient; + + const adapter = createVercelAdapter(fakeClient)(); + const result = await adapter.bulkDecide!({ + flags: [{ key: 'a' }, { key: 'b' }], + headers: undefined as any, + cookies: undefined as any, + }); + expect(result).toEqual({ a: 'ok' }); + expect('b' in result).toBe(false); + }); + }); }); describe('when used with getProviderData', () => { diff --git a/packages/adapter-vercel/src/index.ts b/packages/adapter-vercel/src/index.ts index db495de3..c91cf3a5 100644 --- a/packages/adapter-vercel/src/index.ts +++ b/packages/adapter-vercel/src/index.ts @@ -30,11 +30,18 @@ export function createVercelAdapter( ? createClient(sdkKeyOrFlagsClient) : sdkKeyOrFlagsClient; + // 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. + const adapterId = Symbol('vercelAdapter'); + return function vercelAdapter(): Adapter< ValueType, EntitiesType > { return { + adapterId, origin: flagsClient.origin, config: { reportValue: false }, async decide({ key, entities }): Promise { @@ -57,6 +64,20 @@ export function createVercelAdapter( // when there was an error but the defaultValue was set return evaluationResult.value; }, + async bulkDecide({ flags, entities }) { + const results = await flagsClient.bulkEvaluate( + flags.map((f) => ({ flagKey: f.key, defaultValue: f.defaultValue })), + entities, + ); + const out: Record = {}; + for (const key in results) { + const r = results[key]!; + // Omit undefined so the SDK applies the per-flag `defaultValue` + // fallback (matches single-decide semantics). + if (r.value !== undefined) out[key] = r.value; + } + return out; + }, }; }; } diff --git a/packages/flags/src/next/index.test.ts b/packages/flags/src/next/index.test.ts index 7d3701db..d7cd4cf0 100644 --- a/packages/flags/src/next/index.test.ts +++ b/packages/flags/src/next/index.test.ts @@ -4,7 +4,13 @@ import { Readable } from 'node:stream'; import type { NextApiRequestCookies } from 'next/dist/server/api-utils'; import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest'; import { type Adapter, encryptOverrides } from '..'; -import { clearDedupeCacheForCurrentRequest, dedupe, flag, precompute } from '.'; +import { + bulk, + clearDedupeCacheForCurrentRequest, + dedupe, + flag, + precompute, +} from '.'; const mocks = vi.hoisted(() => { return { @@ -747,3 +753,261 @@ describe('adapters', () => { expect(await exampleFlag()).toBe(outerValue); }); }); + +describe('bulk', () => { + beforeAll(() => { + process.env.FLAGS_SECRET = 'yuhyxaVI0Zue85SguKlMIUQojvJyBPzm95fFYvOa4Rc'; + }); + + afterEach(() => { + if (previousRequestContext === undefined) { + Reflect.deleteProperty(globalThis, requestContextSymbol); + return; + } + Reflect.set(globalThis, requestContextSymbol, previousRequestContext); + }); + + // Factory that mints adapters all sharing the same closure-captured id. + // Each call returns a fresh adapter object (mirroring the playground + // pattern: every flag does `adapter: makeAdapter()`). + function makeBulkAdapter(opts?: { + bulkDecide?: Adapter['bulkDecide']; + decide?: Adapter['decide']; + identify?: Adapter['identify']; + omitAdapterId?: boolean; + omitBulkDecide?: boolean; + }) { + const id = Symbol('test-adapter'); + return (): Adapter => ({ + ...(opts?.omitAdapterId ? {} : { adapterId: id }), + origin: 'test://origin', + decide: + opts?.decide ?? + (() => { + throw new Error('decide should not be called in bulk path'); + }), + identify: opts?.identify, + ...(opts?.omitBulkDecide ? {} : { bulkDecide: opts?.bulkDecide }), + }); + } + + it('calls bulkDecide once for flags sharing an adapterId and identify source', async () => { + const bulkDecideMock = vi.fn().mockResolvedValue({ a: 'A', b: 'B' }); + const decideMock = vi.fn(); + const adapter = makeBulkAdapter({ + bulkDecide: bulkDecideMock, + decide: decideMock, + }); + + const a = flag({ key: 'a', adapter: adapter() }); + const b = flag({ key: 'b', adapter: adapter() }); + + mocks.headers.mockReturnValueOnce(new Headers()); + await expect(bulk({ a, b })).resolves.toEqual({ a: 'A', b: 'B' }); + + expect(bulkDecideMock).toHaveBeenCalledTimes(1); + expect(bulkDecideMock).toHaveBeenCalledWith( + expect.objectContaining({ + flags: [ + { key: 'a', defaultValue: undefined }, + { key: 'b', defaultValue: undefined }, + ], + entities: undefined, + }), + ); + expect(decideMock).not.toHaveBeenCalled(); + }); + + it('splits into separate bulkDecide calls when identify sources differ', async () => { + const bulkDecideMock = vi + .fn() + .mockImplementation(({ flags }: { flags: { key: string }[] }) => + Object.fromEntries(flags.map((f) => [f.key, `v-${f.key}`])), + ); + const identifyA = () => ({ user: 'alice' }); + const identifyB = () => ({ user: 'bob' }); + + const adapter = makeBulkAdapter({ bulkDecide: bulkDecideMock }); + const a = flag({ + key: 'a', + adapter: adapter(), + identify: identifyA, + }); + const b = flag({ + key: 'b', + adapter: adapter(), + identify: identifyB, + }); + + mocks.headers.mockReturnValueOnce(new Headers()); + await expect(bulk({ a, b })).resolves.toEqual({ a: 'v-a', b: 'v-b' }); + expect(bulkDecideMock).toHaveBeenCalledTimes(2); + }); + + it('splits into separate bulkDecide calls when adapterIds differ', async () => { + const bulkA = vi.fn().mockResolvedValue({ a: 'A' }); + const bulkB = vi.fn().mockResolvedValue({ b: 'B' }); + const adapterA = makeBulkAdapter({ bulkDecide: bulkA }); + const adapterB = makeBulkAdapter({ bulkDecide: bulkB }); + + const a = flag({ key: 'a', adapter: adapterA() }); + const b = flag({ key: 'b', adapter: adapterB() }); + + mocks.headers.mockReturnValueOnce(new Headers()); + await expect(bulk({ a, b })).resolves.toEqual({ a: 'A', b: 'B' }); + expect(bulkA).toHaveBeenCalledTimes(1); + expect(bulkB).toHaveBeenCalledTimes(1); + }); + + it('falls back to per-flag decide when adapter has no adapterId', async () => { + const bulkDecideMock = vi.fn(); + const decideMock = vi.fn().mockResolvedValue('from-decide'); + const adapter = makeBulkAdapter({ + bulkDecide: bulkDecideMock, + decide: decideMock, + omitAdapterId: true, + }); + + const a = flag({ key: 'a', adapter: adapter() }); + + mocks.headers.mockReturnValueOnce(new Headers()); + await expect(bulk({ a })).resolves.toEqual({ a: 'from-decide' }); + expect(bulkDecideMock).not.toHaveBeenCalled(); + expect(decideMock).toHaveBeenCalledTimes(1); + }); + + it('falls back to per-flag decide when adapter has no bulkDecide', async () => { + const decideMock = vi.fn().mockResolvedValue('single'); + const adapter = makeBulkAdapter({ + decide: decideMock, + omitBulkDecide: true, + }); + + const a = flag({ key: 'a', adapter: adapter() }); + + mocks.headers.mockReturnValueOnce(new Headers()); + await expect(bulk({ a })).resolves.toEqual({ a: 'single' }); + expect(decideMock).toHaveBeenCalledTimes(1); + }); + + it('keeps inline-decide flags out of the bulk path', async () => { + const inlineDecide = vi.fn(() => 'inline-result'); + const bulkDecideMock = vi.fn().mockResolvedValue({ b: 'bulk-result' }); + + const adapter = makeBulkAdapter({ bulkDecide: bulkDecideMock }); + const a = flag({ key: 'a', decide: inlineDecide }); + const b = flag({ key: 'b', adapter: adapter() }); + + mocks.headers.mockReturnValueOnce(new Headers()); + await expect(bulk({ a, b })).resolves.toEqual({ + a: 'inline-result', + b: 'bulk-result', + }); + expect(inlineDecide).toHaveBeenCalledTimes(1); + expect(bulkDecideMock).toHaveBeenCalledTimes(1); + }); + + it('falls back to defaultValue when bulkDecide throws', async () => { + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const bulkDecideMock = vi.fn().mockRejectedValue(new Error('bulk failed')); + const adapter = makeBulkAdapter({ bulkDecide: bulkDecideMock }); + + const a = flag({ + key: 'a', + adapter: adapter(), + defaultValue: 'fa', + }); + const b = flag({ + key: 'b', + adapter: adapter(), + defaultValue: 'fb', + }); + + mocks.headers.mockReturnValueOnce(new Headers()); + await expect(bulk({ a, b })).resolves.toEqual({ a: 'fa', b: 'fb' }); + expect(bulkDecideMock).toHaveBeenCalledTimes(1); + expect(warnSpy).toHaveBeenCalled(); + warnSpy.mockRestore(); + }); + + it('rejects when bulkDecide throws and a flag has no defaultValue', async () => { + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const bulkDecideMock = vi.fn().mockRejectedValue(new Error('bulk failed')); + const adapter = makeBulkAdapter({ bulkDecide: bulkDecideMock }); + + const a = flag({ + key: 'a', + adapter: adapter(), + defaultValue: 'fa', + }); + const b = flag({ key: 'b', adapter: adapter() }); + + mocks.headers.mockReturnValueOnce(new Headers()); + await expect(bulk({ a, b })).rejects.toThrow('bulk failed'); + warnSpy.mockRestore(); + }); + + it('falls back to defaultValue for keys bulkDecide omits', async () => { + const bulkDecideMock = vi.fn().mockResolvedValue({ a: 'A' }); + const adapter = makeBulkAdapter({ bulkDecide: bulkDecideMock }); + + const a = flag({ key: 'a', adapter: adapter() }); + const b = flag({ + key: 'b', + adapter: adapter(), + defaultValue: 'fb', + }); + + mocks.headers.mockReturnValueOnce(new Headers()); + await expect(bulk({ a, b })).resolves.toEqual({ a: 'A', b: 'fb' }); + }); + + it('lets overrides win over bulkDecide results', async () => { + const bulkDecideMock = vi.fn().mockResolvedValue({ a: 'bulk-value' }); + const adapter = makeBulkAdapter({ bulkDecide: bulkDecideMock }); + + const a = flag({ key: 'a', adapter: adapter() }); + + const override = await encryptOverrides({ a: true }); + const cookieMock = vi.fn((name: string) => + name === 'vercel-flag-overrides' + ? { name: 'vercel-flag-overrides', value: override } + : undefined, + ); + mocks.headers.mockReturnValueOnce(new Headers()); + mocks.cookies.mockReturnValueOnce({ get: cookieMock }); + + await expect(bulk({ a })).resolves.toEqual({ a: true }); + }); + + it('populates the evaluation cache so a subsequent flagFn() hits cache', async () => { + const bulkDecideMock = vi.fn().mockResolvedValue({ a: 'A' }); + const adapter = makeBulkAdapter({ bulkDecide: bulkDecideMock }); + + const a = flag({ key: 'a', adapter: adapter() }); + + const headers = new Headers(); + mocks.headers.mockReturnValue(headers); + await expect(bulk({ a })).resolves.toEqual({ a: 'A' }); + expect(bulkDecideMock).toHaveBeenCalledTimes(1); + + // Subsequent direct call in the same "request" (same headers object) + // should return the cached value without re-calling bulkDecide or decide. + await expect(a()).resolves.toEqual('A'); + expect(bulkDecideMock).toHaveBeenCalledTimes(1); + }); + + it('preserves input key order in the result', async () => { + const adapter = makeBulkAdapter({ + bulkDecide: ({ flags }: { flags: { key: string }[] }) => + Object.fromEntries(flags.map((f) => [f.key, f.key])), + }); + + const zebra = flag({ key: 'zebra', adapter: adapter() }); + const apple = flag({ key: 'apple', adapter: adapter() }); + + mocks.headers.mockReturnValueOnce(new Headers()); + const result = await bulk({ zebra, apple }); + expect(Object.keys(result)).toEqual(['zebra', 'apple']); + }); +}); diff --git a/packages/flags/src/next/index.ts b/packages/flags/src/next/index.ts index 49c075ca..98f8cdcf 100644 --- a/packages/flags/src/next/index.ts +++ b/packages/flags/src/next/index.ts @@ -19,6 +19,7 @@ import { RequestCookiesAdapter, } from '../spec-extension/adapters/request-cookies'; import type { + Adapter, Decide, FlagDeclaration, FlagParamsType, @@ -42,6 +43,12 @@ 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, @@ -238,13 +245,128 @@ export async function bulk( overrides, }; - // Run all flags within the bulk store context + // 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 values = await Promise.all(entries.map(([, flagFn]) => flagFn())); + + type Entry = { name: string; flagFn: Flag }; + const standalone: Entry[] = []; + // adapterId -> identifyRef -> { adapter, entries } + const groups = new Map< + string | symbol, + Map; entries: Entry[] }> + >(); + + for (const [name, flagFn] of entries) { + const entry: 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) { + 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) ?? ''; + + // Call bulkDecide. If it throws, every flag in the group 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; + try { + bulkResult = await adapter.bulkDecide!({ + flags: list.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]; + }); + })(), + ); + } + + console.log('groupPromises', groupPromises.length); + console.log('standalone', standalone.length); + + await Promise.all(groupPromises); + const result = {} as BulkResult; for (let i = 0; i < entries.length; i++) { - (result as any)[entries[i]![0]] = values[i]; + (result as any)[entries[i]![0]] = valuesByName[entries[i]![0]]; } return result; }); @@ -278,6 +400,122 @@ type Run = (options: { let headersModulePromise: Promise; 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, @@ -351,102 +589,22 @@ function getRun( )) as EntitiesType | undefined) : undefined; - // check cache const entitiesKey = JSON.stringify(entities) ?? ''; - const cachedValue = getCachedValuePromise( - readonlyHeaders, - definition.key, - entitiesKey, - ); - if (cachedValue !== undefined) { - setSpanAttribute('method', 'cached'); - const value = await cachedValue; - return value; - } - - 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 decide() into a promise. decide() may return - // synchronously or asynchronously, and may also throw synchronously. - // Fall back to defaultValue when decide returns undefined or throws. - let decisionResult: ValueType | PromiseLike; - try { - decisionResult = 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, - }); - } 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( + return applyResult({ + definition, 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; + 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, + }), + }); }; } @@ -553,6 +711,22 @@ export function flag< name: 'run', attributes: { key: definition.key }, }); + 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. + (api as any)[BULK_IDENTIFY_REF] = + definition.identify ?? definition.adapter?.identify ?? null; + (api as any)[BULKABLE] = + !definition.decide && + !!definition.adapter?.bulkDecide && + definition.adapter.adapterId !== undefined; return api; } diff --git a/packages/flags/src/next/types.ts b/packages/flags/src/next/types.ts index 1c90bbeb..dc975059 100644 --- a/packages/flags/src/next/types.ts +++ b/packages/flags/src/next/types.ts @@ -46,6 +46,16 @@ type FlagMeta = { * This function can establish entities which the `decide` function will be called with. */ 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. + */ + adapter?: FlagDeclaration['adapter']; + /** + * Flag-level configuration (e.g. `reportValue`). + */ + config?: FlagDeclaration['config']; /** * Evaluates a feature flag with custom entities. * diff --git a/packages/flags/src/types.ts b/packages/flags/src/types.ts index 911e24f5..440c2d88 100644 --- a/packages/flags/src/types.ts +++ b/packages/flags/src/types.ts @@ -146,6 +146,18 @@ export interface Adapter { config?: { reportValue?: boolean; }; + /** + * Stable identifier for the underlying resource this adapter talks to + * (e.g. an SDK key, shared client, or factory closure). Adapter authors + * should set this once per "logical adapter" — typically inside a factory + * 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 + * `identify` source through a single `bulkDecide` call). Adapters without + * an `adapterId` are never batched. + */ + adapterId?: string | symbol; decide: (params: { key: string; entities?: EntitiesType; @@ -153,6 +165,23 @@ export interface Adapter { cookies: ReadonlyRequestCookies; defaultValue?: ValueType; }) => 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. + * + * - Return `Record`. Missing keys or `value: undefined` + * trigger the per-flag `defaultValue` fallback in the SDK. + * - Throwing causes per-flag `defaultValue` fallback (and rejection for + * flags without a `defaultValue`). + */ + bulkDecide?: (params: { + flags: { key: string; defaultValue?: ValueType }[]; + entities?: EntitiesType; + headers: ReadonlyHeaders; + cookies: ReadonlyRequestCookies; + }) => Promise> | Record; } /** From ea6d164edcf81d8581121c4c7e099c5a995f17c8 Mon Sep 17 00:00:00 2001 From: Dominik Ferber Date: Thu, 14 May 2026 17:44:49 +0300 Subject: [PATCH 09/26] reuse --- packages/adapter-vercel/package.json | 4 ++-- packages/flags/package.json | 2 +- packages/vercel-flags-core/package.json | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/adapter-vercel/package.json b/packages/adapter-vercel/package.json index 4e5ad9e5..320d0bbe 100644 --- a/packages/adapter-vercel/package.json +++ b/packages/adapter-vercel/package.json @@ -1,6 +1,6 @@ { "name": "@flags-sdk/vercel", - "version": "1.3.0", + "version": "1.3.0-bulk.0", "description": "", "keywords": [], "license": "MIT", @@ -35,7 +35,7 @@ "type-check": "tsc --noEmit" }, "dependencies": { - "@vercel/flags-core": "workspace:*" + "@vercel/flags-core": "https://67qyyqfpszu3ptbw.public.blob.vercel-storage.com/flags-sdk-bulk-eval/vercel-flags-core-1.4.0-bulk.0.tgz" }, "devDependencies": { "@types/node": "20.11.17", diff --git a/packages/flags/package.json b/packages/flags/package.json index c75f185d..f1f46b2b 100644 --- a/packages/flags/package.json +++ b/packages/flags/package.json @@ -1,6 +1,6 @@ { "name": "flags", - "version": "4.0.6", + "version": "4.0.6-bulk.0", "description": "Flags SDK by Vercel - The feature flags toolkit for Next.js and SvelteKit", "keywords": [ "feature flags", diff --git a/packages/vercel-flags-core/package.json b/packages/vercel-flags-core/package.json index b0350227..d3aaab6c 100644 --- a/packages/vercel-flags-core/package.json +++ b/packages/vercel-flags-core/package.json @@ -1,6 +1,6 @@ { "name": "@vercel/flags-core", - "version": "1.4.0", + "version": "1.4.0-bulk.0", "description": "", "keywords": [], "license": "MIT", From cb04cde6846d1f9d6a15aea2e1d8931735cac681 Mon Sep 17 00:00:00 2001 From: Dominik Ferber Date: Tue, 19 May 2026 12:06:30 +0300 Subject: [PATCH 10/26] simplify --- packages/adapter-vercel/package.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/adapter-vercel/package.json b/packages/adapter-vercel/package.json index 320d0bbe..4e5ad9e5 100644 --- a/packages/adapter-vercel/package.json +++ b/packages/adapter-vercel/package.json @@ -1,6 +1,6 @@ { "name": "@flags-sdk/vercel", - "version": "1.3.0-bulk.0", + "version": "1.3.0", "description": "", "keywords": [], "license": "MIT", @@ -35,7 +35,7 @@ "type-check": "tsc --noEmit" }, "dependencies": { - "@vercel/flags-core": "https://67qyyqfpszu3ptbw.public.blob.vercel-storage.com/flags-sdk-bulk-eval/vercel-flags-core-1.4.0-bulk.0.tgz" + "@vercel/flags-core": "workspace:*" }, "devDependencies": { "@types/node": "20.11.17", From 7fd2e5ebd8c82f0c45b3bdfe5b910c0c0478045e Mon Sep 17 00:00:00 2001 From: Dominik Ferber Date: Tue, 19 May 2026 12:07:21 +0300 Subject: [PATCH 11/26] versions --- packages/flags/package.json | 2 +- packages/vercel-flags-core/package.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/flags/package.json b/packages/flags/package.json index f1f46b2b..c75f185d 100644 --- a/packages/flags/package.json +++ b/packages/flags/package.json @@ -1,6 +1,6 @@ { "name": "flags", - "version": "4.0.6-bulk.0", + "version": "4.0.6", "description": "Flags SDK by Vercel - The feature flags toolkit for Next.js and SvelteKit", "keywords": [ "feature flags", diff --git a/packages/vercel-flags-core/package.json b/packages/vercel-flags-core/package.json index d3aaab6c..b0350227 100644 --- a/packages/vercel-flags-core/package.json +++ b/packages/vercel-flags-core/package.json @@ -1,6 +1,6 @@ { "name": "@vercel/flags-core", - "version": "1.4.0-bulk.0", + "version": "1.4.0", "description": "", "keywords": [], "license": "MIT", From e47519862c49f9f522a7b6c4e3414b0d140a3345 Mon Sep 17 00:00:00 2001 From: Dominik Ferber Date: Tue, 19 May 2026 13:33:16 +0300 Subject: [PATCH 12/26] rm outdated changeset --- .changeset/polite-cycles-grab.md | 13 ------------- 1 file changed, 13 deletions(-) delete mode 100644 .changeset/polite-cycles-grab.md diff --git a/.changeset/polite-cycles-grab.md b/.changeset/polite-cycles-grab.md deleted file mode 100644 index fc172e68..00000000 --- a/.changeset/polite-cycles-grab.md +++ /dev/null @@ -1,13 +0,0 @@ ---- -"flags": patch ---- - -Improve performance by caching `next/headers` imports. - -Previously every flag evaluation in Next.js App Router would run -`await import("next/headers")`. The imported module is cached by -the runtime, but we would still go through the event loop unnecessarily. - -Now we cache the resolved module in a local variable so only the -first call awaits the dynamic import; subsequent calls skip the -microtask entirely. From 2e1b113f0ec6ac6a2bc9d7c1ce70095f53f6a838 Mon Sep 17 00:00:00 2001 From: Dominik Ferber Date: Tue, 19 May 2026 13:33:38 +0300 Subject: [PATCH 13/26] rm outdated changeset --- .changeset/remove-async-iife-decide.md | 5 ----- 1 file changed, 5 deletions(-) delete mode 100644 .changeset/remove-async-iife-decide.md diff --git a/.changeset/remove-async-iife-decide.md b/.changeset/remove-async-iife-decide.md deleted file mode 100644 index d775f205..00000000 --- a/.changeset/remove-async-iife-decide.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"flags": patch ---- - -Reduce microtask queue overhead in flag evaluation by replacing the async IIFE around `decide()` with a direct call and `Promise.resolve()`. From 930a9e06a7d8829225dff75dd00b7e81df031343 Mon Sep 17 00:00:00 2001 From: Dominik Ferber Date: Tue, 19 May 2026 13:34:34 +0300 Subject: [PATCH 14/26] revert playground --- apps/playground/app/page.tsx | 104 ++--------------------------------- 1 file changed, 4 insertions(+), 100 deletions(-) diff --git a/apps/playground/app/page.tsx b/apps/playground/app/page.tsx index b35ddc93..5d5ab5c5 100644 --- a/apps/playground/app/page.tsx +++ b/apps/playground/app/page.tsx @@ -1,67 +1,8 @@ -import { bulk } from 'flags/next'; import Image from 'next/image'; -import Link from 'next/link'; -import * as flags from '../flags'; +import { jsonFlag } from '../flags'; -type Mode = 'sequential' | 'parallel' | 'bulk'; - -async function measureSequential() { - const resolved: Record = {}; - const before = Date.now(); - for (const [, flag] of Object.entries(flags)) { - const value = await flag(); - resolved[flag.key] = value; - } - const after = Date.now(); - const duration = after - before; - - return { duration, resolved }; -} - -async function measureParallel() { - const resolved: Record = {}; - const before = Date.now(); - const promises = Object.entries(flags).map(async ([, flag]) => { - const value = await flag(); - resolved[flag.key] = value; - }); - await Promise.all(promises); - const after = Date.now(); - const duration = after - before; - - return { duration, resolved }; -} - -async function measureBulk() { - const before = Date.now(); - const resolved = await bulk(flags); - const after = Date.now(); - const duration = after - before; - - return { duration, resolved }; -} - -function getMode(modeParam: string | undefined): Mode { - return modeParam === 'parallel' - ? 'parallel' - : modeParam === 'bulk' - ? 'bulk' - : 'sequential'; -} - -export default async function Home({ - searchParams, -}: { - searchParams: Promise<{ mode?: string }>; -}) { - const { mode: modeParam } = await searchParams; - const mode: Mode = getMode(modeParam); - const result = - mode === 'parallel' - ? await measureParallel() - : mode === 'bulk' - ? await measureBulk() - : await measureSequential(); +export default async function Home() { + const data = await jsonFlag(); return (
@@ -75,44 +16,7 @@ export default async function Home({ priority />
-
- - Sequential - - - Parallel - - - Bulk - -
-
-            {JSON.stringify({ mode, duration: result.duration }, null, 2)}
-          
+
{JSON.stringify(data, null, 2)}

To get started, edit the page.tsx file.

From 35e518b10ced77454236d6c94ca4becc40da8e0d Mon Sep 17 00:00:00 2001 From: Dominik Ferber Date: Tue, 19 May 2026 13:35:07 +0300 Subject: [PATCH 15/26] revert playground flags --- apps/playground/flags.ts | 494 +-------------------------------------- 1 file changed, 2 insertions(+), 492 deletions(-) diff --git a/apps/playground/flags.ts b/apps/playground/flags.ts index 3c7fb663..2c6fa084 100644 --- a/apps/playground/flags.ts +++ b/apps/playground/flags.ts @@ -1,497 +1,7 @@ import { vercelAdapter } from '@flags-sdk/vercel'; import { flag } from 'flags/next'; -// export const jsonFlag = flag({ -// key: 'json-flag', -// adapter: vercelAdapter(), -// }); - -export const flag0 = flag({ - key: 'flag0', - adapter: vercelAdapter(), -}); - -export const flag1 = flag({ - key: 'flag1', - adapter: vercelAdapter(), -}); - -export const flag2 = flag({ - key: 'flag2', - adapter: vercelAdapter(), -}); - -export const flag3 = flag({ - key: 'flag3', - adapter: vercelAdapter(), -}); - -export const flag4 = flag({ - key: 'flag4', - adapter: vercelAdapter(), -}); - -export const flag5 = flag({ - key: 'flag5', - adapter: vercelAdapter(), -}); - -export const flag6 = flag({ - key: 'flag6', - adapter: vercelAdapter(), -}); - -export const flag7 = flag({ - key: 'flag7', - adapter: vercelAdapter(), -}); - -export const flag8 = flag({ - key: 'flag8', - adapter: vercelAdapter(), -}); - -export const flag9 = flag({ - key: 'flag9', - adapter: vercelAdapter(), -}); - -export const flag10 = flag({ - key: 'flag10', - adapter: vercelAdapter(), -}); - -export const flag11 = flag({ - key: 'flag11', - adapter: vercelAdapter(), -}); - -export const flag12 = flag({ - key: 'flag12', - adapter: vercelAdapter(), -}); - -export const flag13 = flag({ - key: 'flag13', - adapter: vercelAdapter(), -}); - -export const flag14 = flag({ - key: 'flag14', - adapter: vercelAdapter(), -}); - -export const flag15 = flag({ - key: 'flag15', - adapter: vercelAdapter(), -}); - -export const flag16 = flag({ - key: 'flag16', - adapter: vercelAdapter(), -}); - -export const flag17 = flag({ - key: 'flag17', - adapter: vercelAdapter(), -}); - -export const flag18 = flag({ - key: 'flag18', - adapter: vercelAdapter(), -}); - -export const flag19 = flag({ - key: 'flag19', - adapter: vercelAdapter(), -}); - -export const flag20 = flag({ - key: 'flag20', - adapter: vercelAdapter(), -}); - -export const flag21 = flag({ - key: 'flag21', - adapter: vercelAdapter(), -}); - -export const flag22 = flag({ - key: 'flag22', - adapter: vercelAdapter(), -}); - -export const flag23 = flag({ - key: 'flag23', - adapter: vercelAdapter(), -}); - -export const flag24 = flag({ - key: 'flag24', - adapter: vercelAdapter(), -}); - -export const flag25 = flag({ - key: 'flag25', - adapter: vercelAdapter(), -}); - -export const flag26 = flag({ - key: 'flag26', - adapter: vercelAdapter(), -}); - -export const flag27 = flag({ - key: 'flag27', - adapter: vercelAdapter(), -}); - -export const flag28 = flag({ - key: 'flag28', - adapter: vercelAdapter(), -}); - -export const flag29 = flag({ - key: 'flag29', - adapter: vercelAdapter(), -}); - -export const flag30 = flag({ - key: 'flag30', - adapter: vercelAdapter(), -}); - -export const flag31 = flag({ - key: 'flag31', - adapter: vercelAdapter(), -}); - -export const flag32 = flag({ - key: 'flag32', - adapter: vercelAdapter(), -}); - -export const flag33 = flag({ - key: 'flag33', - adapter: vercelAdapter(), -}); - -export const flag34 = flag({ - key: 'flag34', - adapter: vercelAdapter(), -}); - -export const flag35 = flag({ - key: 'flag35', - adapter: vercelAdapter(), -}); - -export const flag36 = flag({ - key: 'flag36', - adapter: vercelAdapter(), -}); - -export const flag37 = flag({ - key: 'flag37', - adapter: vercelAdapter(), -}); - -export const flag38 = flag({ - key: 'flag38', - adapter: vercelAdapter(), -}); - -export const flag39 = flag({ - key: 'flag39', - adapter: vercelAdapter(), -}); - -export const flag40 = flag({ - key: 'flag40', - adapter: vercelAdapter(), -}); - -export const flag41 = flag({ - key: 'flag41', - adapter: vercelAdapter(), -}); - -export const flag42 = flag({ - key: 'flag42', - adapter: vercelAdapter(), -}); - -export const flag43 = flag({ - key: 'flag43', - adapter: vercelAdapter(), -}); - -export const flag44 = flag({ - key: 'flag44', - adapter: vercelAdapter(), -}); - -export const flag45 = flag({ - key: 'flag45', - adapter: vercelAdapter(), -}); - -export const flag46 = flag({ - key: 'flag46', - adapter: vercelAdapter(), -}); - -export const flag47 = flag({ - key: 'flag47', - adapter: vercelAdapter(), -}); - -export const flag48 = flag({ - key: 'flag48', - adapter: vercelAdapter(), -}); - -export const flag49 = flag({ - key: 'flag49', - adapter: vercelAdapter(), -}); - -export const flag50 = flag({ - key: 'flag50', - adapter: vercelAdapter(), -}); - -export const flag51 = flag({ - key: 'flag51', - adapter: vercelAdapter(), -}); - -export const flag52 = flag({ - key: 'flag52', - adapter: vercelAdapter(), -}); - -export const flag53 = flag({ - key: 'flag53', - adapter: vercelAdapter(), -}); - -export const flag54 = flag({ - key: 'flag54', - adapter: vercelAdapter(), -}); - -export const flag55 = flag({ - key: 'flag55', - adapter: vercelAdapter(), -}); - -export const flag56 = flag({ - key: 'flag56', - adapter: vercelAdapter(), -}); - -export const flag57 = flag({ - key: 'flag57', - adapter: vercelAdapter(), -}); - -export const flag58 = flag({ - key: 'flag58', - adapter: vercelAdapter(), -}); - -export const flag59 = flag({ - key: 'flag59', - adapter: vercelAdapter(), -}); - -export const flag60 = flag({ - key: 'flag60', - adapter: vercelAdapter(), -}); - -export const flag61 = flag({ - key: 'flag61', - adapter: vercelAdapter(), -}); - -export const flag62 = flag({ - key: 'flag62', - adapter: vercelAdapter(), -}); - -export const flag63 = flag({ - key: 'flag63', - adapter: vercelAdapter(), -}); - -export const flag64 = flag({ - key: 'flag64', - adapter: vercelAdapter(), -}); - -export const flag65 = flag({ - key: 'flag65', - adapter: vercelAdapter(), -}); - -export const flag66 = flag({ - key: 'flag66', - adapter: vercelAdapter(), -}); - -export const flag67 = flag({ - key: 'flag67', - adapter: vercelAdapter(), -}); - -export const flag68 = flag({ - key: 'flag68', - adapter: vercelAdapter(), -}); - -export const flag69 = flag({ - key: 'flag69', - adapter: vercelAdapter(), -}); - -export const flag70 = flag({ - key: 'flag70', - adapter: vercelAdapter(), -}); - -export const flag71 = flag({ - key: 'flag71', - adapter: vercelAdapter(), -}); - -export const flag72 = flag({ - key: 'flag72', - adapter: vercelAdapter(), -}); - -export const flag73 = flag({ - key: 'flag73', - adapter: vercelAdapter(), -}); - -export const flag74 = flag({ - key: 'flag74', - adapter: vercelAdapter(), -}); - -export const flag75 = flag({ - key: 'flag75', - adapter: vercelAdapter(), -}); - -export const flag76 = flag({ - key: 'flag76', - adapter: vercelAdapter(), -}); - -export const flag77 = flag({ - key: 'flag77', - adapter: vercelAdapter(), -}); - -export const flag78 = flag({ - key: 'flag78', - adapter: vercelAdapter(), -}); - -export const flag79 = flag({ - key: 'flag79', - adapter: vercelAdapter(), -}); - -export const flag80 = flag({ - key: 'flag80', - adapter: vercelAdapter(), -}); - -export const flag81 = flag({ - key: 'flag81', - adapter: vercelAdapter(), -}); - -export const flag82 = flag({ - key: 'flag82', - adapter: vercelAdapter(), -}); - -export const flag83 = flag({ - key: 'flag83', - adapter: vercelAdapter(), -}); - -export const flag84 = flag({ - key: 'flag84', - adapter: vercelAdapter(), -}); - -export const flag85 = flag({ - key: 'flag85', - adapter: vercelAdapter(), -}); - -export const flag86 = flag({ - key: 'flag86', - adapter: vercelAdapter(), -}); - -export const flag87 = flag({ - key: 'flag87', - adapter: vercelAdapter(), -}); - -export const flag88 = flag({ - key: 'flag88', - adapter: vercelAdapter(), -}); - -export const flag89 = flag({ - key: 'flag89', - adapter: vercelAdapter(), -}); - -export const flag90 = flag({ - key: 'flag90', - adapter: vercelAdapter(), -}); - -export const flag91 = flag({ - key: 'flag91', - adapter: vercelAdapter(), -}); - -export const flag92 = flag({ - key: 'flag92', - adapter: vercelAdapter(), -}); - -export const flag93 = flag({ - key: 'flag93', - adapter: vercelAdapter(), -}); - -export const flag94 = flag({ - key: 'flag94', - adapter: vercelAdapter(), -}); - -export const flag95 = flag({ - key: 'flag95', - adapter: vercelAdapter(), -}); - -export const flag96 = flag({ - key: 'flag96', - adapter: vercelAdapter(), -}); - -export const flag97 = flag({ - key: 'flag97', +export const jsonFlag = flag({ + key: 'json-flag', adapter: vercelAdapter(), }); From e076dd0152484f78ae8982221a4098c0d069665d Mon Sep 17 00:00:00 2001 From: Dominik Ferber Date: Tue, 19 May 2026 13:49:15 +0300 Subject: [PATCH 16/26] rm logs --- packages/flags/src/next/index.test.ts | 4 ++-- packages/flags/src/next/index.ts | 3 --- 2 files changed, 2 insertions(+), 5 deletions(-) diff --git a/packages/flags/src/next/index.test.ts b/packages/flags/src/next/index.test.ts index d7cd4cf0..b6c7be34 100644 --- a/packages/flags/src/next/index.test.ts +++ b/packages/flags/src/next/index.test.ts @@ -768,8 +768,8 @@ describe('bulk', () => { }); // Factory that mints adapters all sharing the same closure-captured id. - // Each call returns a fresh adapter object (mirroring the playground - // pattern: every flag does `adapter: makeAdapter()`). + // Each call returns a fresh adapter object (mirroring the + // pattern where every flag does `adapter: adapter()`). function makeBulkAdapter(opts?: { bulkDecide?: Adapter['bulkDecide']; decide?: Adapter['decide']; diff --git a/packages/flags/src/next/index.ts b/packages/flags/src/next/index.ts index 0a51ab00..6d680c1b 100644 --- a/packages/flags/src/next/index.ts +++ b/packages/flags/src/next/index.ts @@ -359,9 +359,6 @@ export async function bulk( ); } - console.log('groupPromises', groupPromises.length); - console.log('standalone', standalone.length); - await Promise.all(groupPromises); const result = {} as BulkResult; From 7a6c28d0764b35a578bcf5a633e65c9164165757 Mon Sep 17 00:00:00 2001 From: Dominik Ferber Date: Tue, 19 May 2026 13:52:33 +0300 Subject: [PATCH 17/26] reuse cache of resolved flags for bulk --- packages/flags/src/next/index.ts | 44 +++++++++++++++++++++----------- 1 file changed, 29 insertions(+), 15 deletions(-) diff --git a/packages/flags/src/next/index.ts b/packages/flags/src/next/index.ts index 6d680c1b..afaefcb1 100644 --- a/packages/flags/src/next/index.ts +++ b/packages/flags/src/next/index.ts @@ -307,24 +307,38 @@ export async function bulk( : undefined; const entitiesKey = JSON.stringify(entities) ?? ''; - // Call bulkDecide. If it throws, every flag in the group still - // goes through `applyResult` — its producer just rethrows, so - // the catch arm handles the per-flag defaultValue fallback (or + // 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; - try { - bulkResult = await adapter.bulkDecide!({ - flags: list.map(({ flagFn }) => ({ - key: flagFn.key, - defaultValue: flagFn.defaultValue, - })), - entities, - headers: readonlyHeaders, - cookies: readonlyCookies, - }); - } catch (err) { - bulkError = err; + 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( From a576379977b34ec2fcfc78651a6d54fd1c84a009 Mon Sep 17 00:00:00 2001 From: Dominik Ferber Date: Tue, 19 May 2026 15:17:12 +0300 Subject: [PATCH 18/26] simplify --- packages/flags/src/next/index.ts | 19 ++++++++++++------- 1 file changed, 12 insertions(+), 7 deletions(-) diff --git a/packages/flags/src/next/index.ts b/packages/flags/src/next/index.ts index afaefcb1..ad129ce5 100644 --- a/packages/flags/src/next/index.ts +++ b/packages/flags/src/next/index.ts @@ -254,16 +254,21 @@ export async function bulk( return bulkStore.run(storeData, async () => { const entries = Object.entries(flags); - type Entry = { name: string; flagFn: Flag }; - const standalone: Entry[] = []; + const standalone: { name: string; flagFn: Flag }[] = []; // adapterId -> identifyRef -> { adapter, entries } const groups = new Map< string | symbol, - Map; entries: Entry[] }> + Map< + unknown, + { + adapter: Adapter; + entries: { name: string; flagFn: Flag }[]; + } + > >(); for (const [name, flagFn] of entries) { - const entry: Entry = { name, flagFn }; + const entry = { name, flagFn }; if (!(flagFn as any)[BULKABLE]) { standalone.push(entry); continue; @@ -289,7 +294,7 @@ export async function bulk( const valuesByName: Record = {}; const groupPromises: Promise[] = []; - for (const [, byIdentify] of groups) { + for (const byIdentify of groups.values()) { for (const [identifyRef, { adapter, entries: list }] of byIdentify) { groupPromises.push( (async () => { @@ -376,8 +381,8 @@ export async function bulk( await Promise.all(groupPromises); const result = {} as BulkResult; - for (let i = 0; i < entries.length; i++) { - (result as any)[entries[i]![0]] = valuesByName[entries[i]![0]]; + for (const [name] of entries) { + (result as any)[name] = valuesByName[name]; } return result; }); From 5b2dc37041e24811f735beede41fc8c29f508cb7 Mon Sep 17 00:00:00 2001 From: Dominik Ferber Date: Tue, 19 May 2026 16:40:54 +0300 Subject: [PATCH 19/26] changesets --- .changeset/adapter-bulk-evaluation.md | 9 +++++++++ .changeset/core-bulk-evaluation.md | 20 ++++++++++++++++++++ .changeset/flags-bulk-evaluation.md | 18 ++++++++++++++++++ 3 files changed, 47 insertions(+) create mode 100644 .changeset/adapter-bulk-evaluation.md create mode 100644 .changeset/core-bulk-evaluation.md create mode 100644 .changeset/flags-bulk-evaluation.md diff --git a/.changeset/adapter-bulk-evaluation.md b/.changeset/adapter-bulk-evaluation.md new file mode 100644 index 00000000..14db73d0 --- /dev/null +++ b/.changeset/adapter-bulk-evaluation.md @@ -0,0 +1,9 @@ +--- +'@flags-sdk/vercel': minor +--- + +Faster evaluation of flags when using the Vercel adapter via `bulk()`. + +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 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. diff --git a/.changeset/core-bulk-evaluation.md b/.changeset/core-bulk-evaluation.md new file mode 100644 index 00000000..b3d23201 --- /dev/null +++ b/.changeset/core-bulk-evaluation.md @@ -0,0 +1,20 @@ +--- +'@vercel/flags-core': minor +--- + +Add `bulkEvaluate` method to `FlagsClient` for resolving multiple flags against shared entities in a single call. + +```ts +const results = await client.bulkEvaluate( + [ + { flagKey: 'a', defaultValue: false }, + { flagKey: 'b', defaultValue: 'off' }, + ], + entities, +); + +results.a; // EvaluationResult +results.b; // EvaluationResult +``` + +Avoids the per-flag overhead of separate `evaluate()` calls — the datafile is read once, entities are resolved once, and all flags share the same environment/segments lookup. Each entry in the returned record is a full `EvaluationResult` with `value`, `reason`, `outcomeType`, and `metrics`. diff --git a/.changeset/flags-bulk-evaluation.md b/.changeset/flags-bulk-evaluation.md new file mode 100644 index 00000000..5a414ebd --- /dev/null +++ b/.changeset/flags-bulk-evaluation.md @@ -0,0 +1,18 @@ +--- +'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. From 380d72594b52cb86dd3fc4fe135cd2145d5b25e9 Mon Sep 17 00:00:00 2001 From: Dominik Ferber Date: Tue, 19 May 2026 16:42:45 +0300 Subject: [PATCH 20/26] support bulk([]) and bulk({}) --- packages/flags/src/next/index.ts | 23 +++++++++++++++-------- 1 file changed, 15 insertions(+), 8 deletions(-) diff --git a/packages/flags/src/next/index.ts b/packages/flags/src/next/index.ts index ad129ce5..6208ad2b 100644 --- a/packages/flags/src/next/index.ts +++ b/packages/flags/src/next/index.ts @@ -210,14 +210,21 @@ interface BulkStoreData { const bulkStore = new AsyncLocalStorage(); -type BulkFlags = Record>; -type BulkResult = { - [K in keyof T]: T[K] extends Flag ? V : never; -}; +// 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( +export async function bulk>>( + flags: T, +): Promise<{ [K in keyof T]: BulkValue }>; +export async function bulk[]>( flags: T, -): Promise> { +): 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; @@ -380,9 +387,9 @@ export async function bulk( await Promise.all(groupPromises); - const result = {} as BulkResult; + const result: any = Array.isArray(flags) ? new Array(entries.length) : {}; for (const [name] of entries) { - (result as any)[name] = valuesByName[name]; + result[name] = valuesByName[name]; } return result; }); From 66b3a2324163051f23203242653e2a649131c49a Mon Sep 17 00:00:00 2001 From: Dominik Ferber Date: Tue, 19 May 2026 16:53:06 +0300 Subject: [PATCH 21/26] =?UTF-8?q?flagKey=20=E2=86=92=20key?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/adapter-vercel/src/index.test.ts | 4 ++-- packages/adapter-vercel/src/index.ts | 2 +- .../vercel-flags-core/src/controller-fns.ts | 24 +++++++++---------- packages/vercel-flags-core/src/types.ts | 6 ++--- 4 files changed, 17 insertions(+), 19 deletions(-) diff --git a/packages/adapter-vercel/src/index.test.ts b/packages/adapter-vercel/src/index.test.ts index a0213134..e0017dc6 100644 --- a/packages/adapter-vercel/src/index.test.ts +++ b/packages/adapter-vercel/src/index.test.ts @@ -156,8 +156,8 @@ describe('createVercelAdapter', () => { expect(bulkEvaluateMock).toHaveBeenCalledTimes(1); expect(bulkEvaluateMock).toHaveBeenCalledWith( [ - { flagKey: 'a', defaultValue: 'da' }, - { flagKey: 'b', defaultValue: undefined }, + { key: 'a', defaultValue: 'da' }, + { key: 'b', defaultValue: undefined }, ], { user: { id: 'u1' } }, ); diff --git a/packages/adapter-vercel/src/index.ts b/packages/adapter-vercel/src/index.ts index c91cf3a5..fc231b32 100644 --- a/packages/adapter-vercel/src/index.ts +++ b/packages/adapter-vercel/src/index.ts @@ -66,7 +66,7 @@ export function createVercelAdapter( }, async bulkDecide({ flags, entities }) { const results = await flagsClient.bulkEvaluate( - flags.map((f) => ({ flagKey: f.key, defaultValue: f.defaultValue })), + flags.map((f) => ({ key: f.key, defaultValue: f.defaultValue })), entities, ); const out: Record = {}; diff --git a/packages/vercel-flags-core/src/controller-fns.ts b/packages/vercel-flags-core/src/controller-fns.ts index 77066df7..f6b779b9 100644 --- a/packages/vercel-flags-core/src/controller-fns.ts +++ b/packages/vercel-flags-core/src/controller-fns.ts @@ -152,7 +152,7 @@ export async function bulkEvaluate>( const results: Record> = {}; for (const flag of flags) { - results[flag.flagKey] = { + results[flag.key] = { value: flag.defaultValue, reason: ResolutionReason.ERROR, errorMessage, @@ -174,30 +174,28 @@ export async function bulkEvaluate>( const toEvaluate: Record> = {}; for (const flag of flags) { - const { flagKey, defaultValue } = flag; - const flagDefinition = datafile.definitions[ - flagKey - ] as Packed.FlagDefinition; + const { key, defaultValue } = flag; + const flagDefinition = datafile.definitions[key] as Packed.FlagDefinition; if (flagDefinition === undefined) { if (projectId) { - internalReportValue(flagKey, defaultValue, { + internalReportValue(key, defaultValue, { originProjectId: projectId, originProvider: 'vercel', reason: ResolutionReason.ERROR, }); } - results[flagKey] = { + results[key] = { value: defaultValue, reason: ResolutionReason.ERROR, errorCode: ErrorCode.FLAG_NOT_FOUND, - errorMessage: `@vercel/flags-core: Definition not found for flag "${flagKey}"`, + errorMessage: `@vercel/flags-core: Definition not found for flag "${key}"`, metrics: { evaluationMs: 0, ...baseMetrics }, }; continue; } - toEvaluate[flagKey] = { definition: flagDefinition, defaultValue }; + toEvaluate[key] = { definition: flagDefinition, defaultValue }; } const evalStartTime = Date.now(); @@ -208,10 +206,10 @@ export async function bulkEvaluate>( }); const evaluationDurationMs = Date.now() - evalStartTime; - for (const flagKey in toEvaluate) { - const result = evaluated[flagKey]!; + for (const key in toEvaluate) { + const result = evaluated[key]!; if (projectId) { - internalReportValue(flagKey, result.value, { + internalReportValue(key, result.value, { originProjectId: projectId, originProvider: 'vercel', reason: result.reason, @@ -221,7 +219,7 @@ export async function bulkEvaluate>( : undefined, }); } - results[flagKey] = Object.assign(result, { + results[key] = Object.assign(result, { metrics: { evaluationMs: evaluationDurationMs, ...baseMetrics }, }); } diff --git a/packages/vercel-flags-core/src/types.ts b/packages/vercel-flags-core/src/types.ts index b7a4e3df..c4f7f3f5 100644 --- a/packages/vercel-flags-core/src/types.ts +++ b/packages/vercel-flags-core/src/types.ts @@ -119,7 +119,7 @@ export type Source = { * Input for a single flag in a bulk evaluation call. */ export type BulkEvaluateInput = { - flagKey: string; + key: string; defaultValue?: T; }; @@ -157,9 +157,9 @@ export type FlagsClient> = { * * Requires initialize() to have been called and awaited first. * - * @param flags Array of `{ flagKey, defaultValue? }` entries to evaluate. + * @param flags Array of `{ key, defaultValue? }` entries to evaluate. * @param entities Shared entities used for every flag in the bulk call. - * @returns Object mapping each flagKey to its EvaluationResult. + * @returns Object mapping each key to its EvaluationResult. */ bulkEvaluate: ( flags: BulkEvaluateInput[], From bf8033616fd251f763e5663ab3503a8ac3e26085 Mon Sep 17 00:00:00 2001 From: Dominik Ferber Date: Wed, 20 May 2026 17:31:22 +0300 Subject: [PATCH 22/26] allow any type in expectPermutations --- packages/flags/src/next/precompute.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/flags/src/next/precompute.test.ts b/packages/flags/src/next/precompute.test.ts index 502cd976..c6251ef1 100644 --- a/packages/flags/src/next/precompute.test.ts +++ b/packages/flags/src/next/precompute.test.ts @@ -18,7 +18,7 @@ import { * @param expected the expected permutations */ async function expectPermutations( - group: Flag[], + group: Flag[], expected: unknown[], filter?: ((permutation: Record) => boolean) | null, ) { From c65406bb0fbd79cb190150c4fee88db265cbb1df Mon Sep 17 00:00:00 2001 From: Dominik Ferber Date: Mon, 25 May 2026 14:55:33 +0300 Subject: [PATCH 23/26] avoid unnecessary mapping --- packages/adapter-vercel/src/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/adapter-vercel/src/index.ts b/packages/adapter-vercel/src/index.ts index fc231b32..9a1d9363 100644 --- a/packages/adapter-vercel/src/index.ts +++ b/packages/adapter-vercel/src/index.ts @@ -66,7 +66,7 @@ export function createVercelAdapter( }, async bulkDecide({ flags, entities }) { const results = await flagsClient.bulkEvaluate( - flags.map((f) => ({ key: f.key, defaultValue: f.defaultValue })), + flags, entities, ); const out: Record = {}; From 9575a4aae3cfd3ea10d5e3dbf982bad7e7278e75 Mon Sep 17 00:00:00 2001 From: Dominik Ferber Date: Mon, 25 May 2026 15:02:53 +0300 Subject: [PATCH 24/26] fix changeset --- .changeset/core-bulk-evaluation.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.changeset/core-bulk-evaluation.md b/.changeset/core-bulk-evaluation.md index b3d23201..00674913 100644 --- a/.changeset/core-bulk-evaluation.md +++ b/.changeset/core-bulk-evaluation.md @@ -7,8 +7,8 @@ Add `bulkEvaluate` method to `FlagsClient` for resolving multiple flags against ```ts const results = await client.bulkEvaluate( [ - { flagKey: 'a', defaultValue: false }, - { flagKey: 'b', defaultValue: 'off' }, + { key: 'a', defaultValue: false }, + { key: 'b', defaultValue: 'off' }, ], entities, ); From 254480e6feae0bdce2a52792ad58cce998a88109 Mon Sep 17 00:00:00 2001 From: Dominik Ferber Date: Mon, 25 May 2026 15:29:09 +0300 Subject: [PATCH 25/26] validate package.json fields --- .github/workflows/quality.yml | 14 ++++ scripts/validate-packages.mjs | 131 ++++++++++++++++++++++++++++++++++ 2 files changed, 145 insertions(+) create mode 100644 scripts/validate-packages.mjs diff --git a/.github/workflows/quality.yml b/.github/workflows/quality.yml index 9142f516..4d1a551a 100644 --- a/.github/workflows/quality.yml +++ b/.github/workflows/quality.yml @@ -77,6 +77,20 @@ jobs: - name: Validate SKILL.md files run: node scripts/validate-skills.mjs + packages: + name: "Packages" + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - uses: actions/setup-node@v3 + with: + node-version-file: ".node-version" + + - name: Validate package.json files + run: node scripts/validate-packages.mjs + publint: name: "publint" runs-on: ubuntu-latest diff --git a/scripts/validate-packages.mjs b/scripts/validate-packages.mjs new file mode 100644 index 00000000..370f0b0c --- /dev/null +++ b/scripts/validate-packages.mjs @@ -0,0 +1,131 @@ +import { existsSync, readdirSync, readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; + +const GREEN = '\x1b[32m'; +const RED = '\x1b[31m'; +const BOLD = '\x1b[1m'; +const DIM = '\x1b[2m'; +const RESET = '\x1b[0m'; + +let hasErrors = false; + +const root = resolve(import.meta.dirname, '..'); +const packagesDir = resolve(root, 'packages'); + +const dirs = readdirSync(packagesDir, { withFileTypes: true }).filter((d) => + d.isDirectory(), +); + +const files = dirs + .map((d) => ({ + dir: d.name, + path: resolve(packagesDir, d.name, 'package.json'), + })) + .filter(({ path }) => existsSync(path)); + +if (files.length === 0) { + console.log('No package.json files found under packages/.'); + process.exit(0); +} + +function isPlainObject(value) { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function checkPackage(pkg) { + const checks = []; + + if (typeof pkg.description !== 'string' || pkg.description.trim() === '') { + checks.push({ + ok: false, + label: 'description', + detail: 'must be a non-empty string', + }); + } else { + checks.push({ ok: true, label: 'description', detail: 'non-empty string' }); + } + + if (!Array.isArray(pkg.keywords) || pkg.keywords.length === 0) { + checks.push({ + ok: false, + label: 'keywords', + detail: 'must be a non-empty array', + }); + } else { + checks.push({ + ok: true, + label: 'keywords', + detail: `${pkg.keywords.length} entries`, + }); + } + + if (pkg.license !== 'MIT') { + checks.push({ + ok: false, + label: 'license', + detail: `must be "MIT" (got ${JSON.stringify(pkg.license)})`, + }); + } else { + checks.push({ ok: true, label: 'license', detail: '"MIT"' }); + } + + if (isPlainObject(pkg.author)) { + checks.push({ ok: true, label: 'author', detail: 'object' }); + } else if (typeof pkg.author === 'string' && pkg.author.trim() !== '') { + checks.push({ ok: true, label: 'author', detail: 'non-empty string' }); + } else { + checks.push({ + ok: false, + label: 'author', + detail: 'must be an object or non-empty string', + }); + } + + if (!isPlainObject(pkg.repository)) { + checks.push({ + ok: false, + label: 'repository', + detail: 'must be an object', + }); + } else { + checks.push({ ok: true, label: 'repository', detail: 'object' }); + } + + return checks; +} + +for (const { dir, path: file } of files) { + const relPath = file.replace(`${root}/`, ''); + + let pkg; + try { + pkg = JSON.parse(readFileSync(file, 'utf8')); + } catch (e) { + console.log(`\n${BOLD}${dir}${RESET} ${DIM}(${relPath})${RESET}`); + console.error(` ${RED}✗${RESET} Invalid JSON: ${e.message}`); + hasErrors = true; + continue; + } + + const checks = checkPackage(pkg); + const failed = checks.filter((c) => !c.ok).length; + const status = + failed === 0 + ? `${GREEN}all ${checks.length} checks passed${RESET}` + : `${RED}${failed}/${checks.length} failed${RESET}`; + + console.log(`\n${BOLD}${dir}${RESET} ${DIM}(${relPath})${RESET} — ${status}`); + for (const c of checks) { + const icon = c.ok ? `${GREEN}✓${RESET}` : `${RED}✗${RESET}`; + console.log(` ${icon} ${c.label}: ${c.detail}`); + } + + if (failed > 0) hasErrors = true; +} + +if (hasErrors) { + console.error(`\n${RED}Package validation failed.${RESET}`); + process.exit(1); +} else { + console.log(`\n${GREEN}All packages valid.${RESET}`); +} From 2225248d31cb7513b59bd4d5248194b32d045e61 Mon Sep 17 00:00:00 2001 From: Dominik Ferber Date: Mon, 25 May 2026 15:30:01 +0300 Subject: [PATCH 26/26] update package.json fields --- package.json | 1 + packages/adapter-edge-config/package.json | 16 +++++++++++++--- packages/adapter-flagsmith/package.json | 2 +- packages/adapter-growthbook/package.json | 8 ++++++++ packages/adapter-hypertune/package.json | 16 +++++++++++++--- packages/adapter-launchdarkly/package.json | 2 +- packages/adapter-openfeature/package.json | 2 +- packages/adapter-optimizely/package.json | 13 ++++++++++--- packages/adapter-posthog/package.json | 2 +- packages/adapter-reflag/package.json | 2 +- packages/adapter-split/package.json | 16 +++++++++++++--- packages/adapter-statsig/package.json | 1 + packages/adapter-vercel/package.json | 16 +++++++++++++--- packages/flags/package.json | 1 + packages/prepare-flags-definitions/package.json | 15 ++++++++++++--- packages/vercel-flags-core/package.json | 16 +++++++++++++--- 16 files changed, 103 insertions(+), 26 deletions(-) diff --git a/package.json b/package.json index b8ca07aa..04ec9a6e 100644 --- a/package.json +++ b/package.json @@ -33,6 +33,7 @@ "test:e2e": "turbo test:e2e", "test:integration": "turbo test:integration", "type-check": "turbo type-check", + "validate-packages": "node scripts/validate-packages.mjs", "validate-skills": "node scripts/validate-skills.mjs", "version-packages": "changeset version && pnpm i --no-frozen-lockfile && git add ." }, diff --git a/packages/adapter-edge-config/package.json b/packages/adapter-edge-config/package.json index cbd07ced..c8abd869 100644 --- a/packages/adapter-edge-config/package.json +++ b/packages/adapter-edge-config/package.json @@ -1,10 +1,20 @@ { "name": "@flags-sdk/edge-config", "version": "0.1.2", - "description": "", - "keywords": [], + "description": "A Flags SDK adapter for Edge Config", + "keywords": [ + "vercel", + "flags", + "vercel flags", + "feature flags", + "flags sdk" + ], "license": "MIT", - "author": "", + "author": "Dominik Ferber ", + "repository": { + "type": "git", + "url": "git+https://github.com/vercel/flags.git" + }, "sideEffects": false, "type": "module", "exports": { diff --git a/packages/adapter-flagsmith/package.json b/packages/adapter-flagsmith/package.json index 6015a443..f4cd8d02 100644 --- a/packages/adapter-flagsmith/package.json +++ b/packages/adapter-flagsmith/package.json @@ -18,7 +18,7 @@ "url": "git+https://github.com/vercel/flags.git" }, "license": "MIT", - "author": "", + "author": "Dominik Ferber ", "sideEffects": false, "type": "module", "exports": { diff --git a/packages/adapter-growthbook/package.json b/packages/adapter-growthbook/package.json index a8439639..be2df0c7 100644 --- a/packages/adapter-growthbook/package.json +++ b/packages/adapter-growthbook/package.json @@ -10,6 +10,14 @@ "type": "git", "url": "git+https://github.com/vercel/flags.git" }, + "author": "Dominik Ferber ", + "keywords": [ + "growthbook", + "flags", + "flags sdk", + "experimentation", + "ab testing" + ], "license": "MIT", "sideEffects": false, "type": "module", diff --git a/packages/adapter-hypertune/package.json b/packages/adapter-hypertune/package.json index fb8fca00..dfacb73a 100644 --- a/packages/adapter-hypertune/package.json +++ b/packages/adapter-hypertune/package.json @@ -1,10 +1,19 @@ { "name": "@flags-sdk/hypertune", "version": "0.3.2", - "description": "", - "keywords": [], + "description": "A HyperTune adapter for the Flags SDK", + "keywords": [ + "hypertune", + "flags", + "flags sdk", + "experimentation", + "ab testing" + ], "license": "MIT", - "author": "", + "repository": { + "type": "git", + "url": "git+https://github.com/vercel/flags.git" + }, "sideEffects": false, "type": "module", "exports": { @@ -13,6 +22,7 @@ "require": "./dist/index.cjs" } }, + "author": "Miraan Tabrez ", "main": "./dist/index.js", "typesVersions": { "*": { diff --git a/packages/adapter-launchdarkly/package.json b/packages/adapter-launchdarkly/package.json index 2646141f..dfa58aad 100644 --- a/packages/adapter-launchdarkly/package.json +++ b/packages/adapter-launchdarkly/package.json @@ -19,7 +19,7 @@ "url": "git+https://github.com/vercel/flags.git" }, "license": "MIT", - "author": "", + "author": "Dominik Ferber ", "sideEffects": false, "type": "module", "exports": { diff --git a/packages/adapter-openfeature/package.json b/packages/adapter-openfeature/package.json index 5186d6af..c37b0953 100644 --- a/packages/adapter-openfeature/package.json +++ b/packages/adapter-openfeature/package.json @@ -18,7 +18,7 @@ "url": "git+https://github.com/vercel/flags.git" }, "license": "MIT", - "author": "", + "author": "Dominik Ferber ", "sideEffects": false, "type": "module", "exports": { diff --git a/packages/adapter-optimizely/package.json b/packages/adapter-optimizely/package.json index aba02250..e8247d05 100644 --- a/packages/adapter-optimizely/package.json +++ b/packages/adapter-optimizely/package.json @@ -1,12 +1,19 @@ { "name": "@flags-sdk/optimizely", "version": "0.1.1", - "description": "", - "keywords": [], + "description": "A provider for the Flags Explorer", + "keywords": [ + "optimizely", + "flags explorer" + ], "license": "MIT", - "author": "", + "author": "Dominik Ferber ", "sideEffects": false, "type": "module", + "repository": { + "type": "git", + "url": "git+https://github.com/vercel/flags.git" + }, "exports": { ".": { "import": "./dist/index.js", diff --git a/packages/adapter-posthog/package.json b/packages/adapter-posthog/package.json index a180390b..5aa5135c 100644 --- a/packages/adapter-posthog/package.json +++ b/packages/adapter-posthog/package.json @@ -19,7 +19,7 @@ "url": "git+https://github.com/vercel/flags.git" }, "license": "MIT", - "author": "", + "author": "Aaron Morris ", "sideEffects": false, "type": "module", "exports": { diff --git a/packages/adapter-reflag/package.json b/packages/adapter-reflag/package.json index 02cf3713..aa331bee 100644 --- a/packages/adapter-reflag/package.json +++ b/packages/adapter-reflag/package.json @@ -19,7 +19,7 @@ "url": "git+https://github.com/vercel/flags.git" }, "license": "MIT", - "author": "", + "author": "Ron Cohen ", "sideEffects": false, "type": "module", "exports": { diff --git a/packages/adapter-split/package.json b/packages/adapter-split/package.json index 99c0c342..1508f17c 100644 --- a/packages/adapter-split/package.json +++ b/packages/adapter-split/package.json @@ -1,12 +1,22 @@ { "name": "@flags-sdk/split", "version": "0.1.1", - "description": "", - "keywords": [], + "description": "A Split adapter for the Flags SDK", + "keywords": [ + "split", + "flags", + "flags sdk", + "experimentation", + "ab testing" + ], "license": "MIT", - "author": "", + "author": "Dominik Ferber ", "sideEffects": false, "type": "module", + "repository": { + "type": "git", + "url": "git+https://github.com/vercel/flags.git" + }, "exports": { ".": { "import": "./dist/index.js", diff --git a/packages/adapter-statsig/package.json b/packages/adapter-statsig/package.json index 4563bf50..4733e2ef 100644 --- a/packages/adapter-statsig/package.json +++ b/packages/adapter-statsig/package.json @@ -12,6 +12,7 @@ "experimentation", "ab testing" ], + "author": "Aaron Morris ", "homepage": "https://flags-sdk.dev", "bugs": { "url": "https://github.com/vercel/flags/issues" diff --git a/packages/adapter-vercel/package.json b/packages/adapter-vercel/package.json index 4e5ad9e5..de583dc4 100644 --- a/packages/adapter-vercel/package.json +++ b/packages/adapter-vercel/package.json @@ -1,10 +1,20 @@ { "name": "@flags-sdk/vercel", "version": "1.3.0", - "description": "", - "keywords": [], + "description": "A Flags SDK adapter for Vercel Flags", + "keywords": [ + "vercel", + "flags", + "vercel flags", + "feature flags", + "flags sdk" + ], "license": "MIT", - "author": "", + "repository": { + "type": "git", + "url": "git+https://github.com/vercel/flags.git" + }, + "author": "Dominik Ferber ", "sideEffects": false, "type": "module", "exports": { diff --git a/packages/flags/package.json b/packages/flags/package.json index c75f185d..b6982a56 100644 --- a/packages/flags/package.json +++ b/packages/flags/package.json @@ -10,6 +10,7 @@ "overrides", "SvelteKit" ], + "author": "Dominik Ferber ", "homepage": "https://flags-sdk.dev", "bugs": { "url": "https://github.com/vercel/flags/issues" diff --git a/packages/prepare-flags-definitions/package.json b/packages/prepare-flags-definitions/package.json index b4b9efdf..7b6bc95c 100644 --- a/packages/prepare-flags-definitions/package.json +++ b/packages/prepare-flags-definitions/package.json @@ -1,10 +1,19 @@ { "name": "@vercel/prepare-flags-definitions", "version": "0.2.1", - "description": "", - "keywords": [], + "description": "A utility for preparing flags definitions for embedding", + "keywords": [ + "flags", + "flags sdk", + "experimentation", + "ab testing" + ], "license": "MIT", - "author": "", + "author": "Dominik Ferber ", + "repository": { + "type": "git", + "url": "git+https://github.com/vercel/flags.git" + }, "sideEffects": false, "type": "module", "exports": { diff --git a/packages/vercel-flags-core/package.json b/packages/vercel-flags-core/package.json index b0350227..cc1a4c3f 100644 --- a/packages/vercel-flags-core/package.json +++ b/packages/vercel-flags-core/package.json @@ -1,10 +1,20 @@ { "name": "@vercel/flags-core", "version": "1.4.0", - "description": "", - "keywords": [], + "description": "A server-side client for Vercel Flags", + "keywords": [ + "vercel", + "flags", + "vercel flags", + "feature flags", + "flags sdk" + ], + "repository": { + "type": "git", + "url": "git+https://github.com/vercel/flags.git" + }, "license": "MIT", - "author": "", + "author": "Dominik Ferber ", "sideEffects": false, "type": "module", "exports": {