From bb72353353f38209fedbead2ae476927f300fec6 Mon Sep 17 00:00:00 2001 From: Dominik Ferber Date: Wed, 1 Apr 2026 20:50:37 +0300 Subject: [PATCH 01/42] [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/42] [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/42] [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/42] [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/42] 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/42] 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/42] 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/42] 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/42] 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/42] 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/42] 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/42] 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/42] 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/42] 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/42] 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/42] 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/42] 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/42] 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/42] 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/42] 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/42] =?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/42] 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/42] 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/42] 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/42] 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/42] 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": { From fa48aba3e8e4429c91999b58a4ae7b301bd4b272 Mon Sep 17 00:00:00 2001 From: Dominik Ferber Date: Tue, 26 May 2026 07:53:30 +0300 Subject: [PATCH 27/42] keep covariant --- packages/flags/src/next/index.test.ts | 2 +- packages/flags/src/next/precompute.test.ts | 2 +- packages/flags/src/types.ts | 9 +++++++-- 3 files changed, 9 insertions(+), 4 deletions(-) diff --git a/packages/flags/src/next/index.test.ts b/packages/flags/src/next/index.test.ts index b6c7be34..ab1bae58 100644 --- a/packages/flags/src/next/index.test.ts +++ b/packages/flags/src/next/index.test.ts @@ -745,7 +745,7 @@ describe('adapters', () => { key: 'example-flag', defaultValue: outerValue, adapter: { - decide: ({ defaultValue }) => defaultValue || -1, + decide: ({ defaultValue }) => (defaultValue as number) || -1, origin: (key) => `fake-origin#${key}`, }, }); diff --git a/packages/flags/src/next/precompute.test.ts b/packages/flags/src/next/precompute.test.ts index c6251ef1..502cd976 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, ) { diff --git a/packages/flags/src/types.ts b/packages/flags/src/types.ts index 440c2d88..bd809fc9 100644 --- a/packages/flags/src/types.ts +++ b/packages/flags/src/types.ts @@ -163,7 +163,10 @@ export interface Adapter { entities?: EntitiesType; headers: ReadonlyHeaders; cookies: ReadonlyRequestCookies; - defaultValue?: ValueType; + // Typed as `unknown` rather than `ValueType` so `ValueType` stays in + // output positions only. Keeping it covariant lets `Adapter` and + // `Flag` remain assignable to `Adapter` / `Flag`. + defaultValue?: unknown; }) => Promise | ValueType; /** * Optional batch hook used by `bulk()` to evaluate many flags that share @@ -177,7 +180,9 @@ export interface Adapter { * flags without a `defaultValue`). */ bulkDecide?: (params: { - flags: { key: string; defaultValue?: ValueType }[]; + // `defaultValue` is `unknown` for the same reason as in `decide` above: + // it keeps `ValueType` covariant on `Adapter`. + flags: { key: string; defaultValue?: unknown }[]; entities?: EntitiesType; headers: ReadonlyHeaders; cookies: ReadonlyRequestCookies; From dde2adb66e96084cddff5c00aba23001fdf4957a Mon Sep 17 00:00:00 2001 From: Dominik Ferber Date: Tue, 26 May 2026 08:02:56 +0300 Subject: [PATCH 28/42] adjust adapter types --- packages/adapter-posthog/src/index.ts | 13 ++++++++----- packages/adapter-vercel/src/index.ts | 6 +++++- 2 files changed, 13 insertions(+), 6 deletions(-) diff --git a/packages/adapter-posthog/src/index.ts b/packages/adapter-posthog/src/index.ts index d2b408f5..4cd28242 100644 --- a/packages/adapter-posthog/src/index.ts +++ b/packages/adapter-posthog/src/index.ts @@ -23,7 +23,7 @@ export function createPostHogAdapter({ trimKey(key), parsedEntities.distinctId, options, - )) ?? defaultValue; + )) ?? (defaultValue as boolean | undefined); if (result === undefined) { throw new Error( `PostHog Adapter isFeatureEnabled returned undefined for ${trimKey(key)} and no default value was provided.`, @@ -44,7 +44,7 @@ export function createPostHogAdapter({ ); if (flagValue === undefined) { if (typeof defaultValue !== 'undefined') { - return defaultValue; + return defaultValue as string | boolean; } throw new Error( `PostHog Adapter featureFlagValue found undefined for ${trimKey(key)} and no default value was provided.`, @@ -54,7 +54,10 @@ export function createPostHogAdapter({ }, }; }, - featureFlagPayload: (getValue, options) => { + featureFlagPayload: ( + getValue: (payload: JsonType) => T, + options?: { sendFeatureFlagEvents?: boolean }, + ) => { return { async decide({ key, entities, defaultValue }) { const parsedEntities = parseEntities(entities); @@ -66,13 +69,13 @@ export function createPostHogAdapter({ ); if (!payload) { if (typeof defaultValue !== 'undefined') { - return defaultValue; + return defaultValue as T; } throw new Error( `PostHog Adapter featureFlagPayload found undefined for ${trimKey(key)} and no default value was provided.`, ); } - return getValue(payload); + return getValue(payload as JsonType); }, }; }, diff --git a/packages/adapter-vercel/src/index.ts b/packages/adapter-vercel/src/index.ts index 9a1d9363..1708a260 100644 --- a/packages/adapter-vercel/src/index.ts +++ b/packages/adapter-vercel/src/index.ts @@ -65,8 +65,12 @@ export function createVercelAdapter( return evaluationResult.value; }, async bulkDecide({ flags, entities }) { + // `flags` is typed `{ key: string; defaultValue?: unknown }[]` on + // `Adapter.bulkDecide` (to keep `ValueType` covariant). The client + // here narrows it back to `ValueType`; `defaultValue` is shuttled + // through opaquely so the cast is safe. const results = await flagsClient.bulkEvaluate( - flags, + flags as { key: string; defaultValue?: ValueType }[], entities, ); const out: Record = {}; From 29c4f6be9fd601bef597052ffc0ad0e6c9a9bf75 Mon Sep 17 00:00:00 2001 From: Dominik Ferber Date: Fri, 29 May 2026 14:45:57 +0300 Subject: [PATCH 29/42] Merge bulk into evaluate (#392) * merge bulk into evaluate * add pages router compatibility * better types * add array test --- .changeset/adapter-bulk-evaluation.md | 6 +- .changeset/flags-bulk-evaluation.md | 18 - .changeset/flags-evaluate.md | 18 + packages/adapter-vercel/src/index.ts | 4 +- packages/flags/src/next/evaluate.ts | 628 ++++++++++++++++++++++++++ packages/flags/src/next/index.test.ts | 111 ++++- packages/flags/src/next/index.ts | 580 +----------------------- packages/flags/src/next/precompute.ts | 14 +- packages/flags/src/next/types.ts | 19 +- packages/flags/src/types.ts | 10 +- 10 files changed, 771 insertions(+), 637 deletions(-) delete mode 100644 .changeset/flags-bulk-evaluation.md create mode 100644 .changeset/flags-evaluate.md create mode 100644 packages/flags/src/next/evaluate.ts diff --git a/.changeset/adapter-bulk-evaluation.md b/.changeset/adapter-bulk-evaluation.md index 14db73d0..a6ea333d 100644 --- a/.changeset/adapter-bulk-evaluation.md +++ b/.changeset/adapter-bulk-evaluation.md @@ -2,8 +2,8 @@ '@flags-sdk/vercel': minor --- -Faster evaluation of flags when using the Vercel adapter via `bulk()`. +Faster evaluation of flags when using the Vercel adapter via `evaluate()`. -This version of `flags-sdk/vercel` implements `bulkDecide` on the Vercel Flags adapter so flags can be evaluated together via `bulk()` from `flags/next`. +This version of `flags-sdk/vercel` implements `bulkDecide` on the Vercel Flags adapter so flags can be evaluated together via `evaluate()` from `flags/next`. -This improves performance by avoiding the per-flag overhead of separate `evaluate()` calls. We've seen a 10x improvement in evaluation time for large batches of flags. +This improves performance by avoiding the per-flag overhead of separate `decide()` calls. We've seen a 10x improvement in evaluation time for large batches of flags. diff --git a/.changeset/flags-bulk-evaluation.md b/.changeset/flags-bulk-evaluation.md deleted file mode 100644 index 5a414ebd..00000000 --- a/.changeset/flags-bulk-evaluation.md +++ /dev/null @@ -1,18 +0,0 @@ ---- -'flags': minor ---- - -Add `bulk()` function for evaluating multiple flags in a single call from the `flags/next` entry point. - -```tsx -import { bulk } from 'flags/next'; -import { flagA, flagB } from '../flags'; - -// pass a list of flags -const [valueA, valueB] = await bulk([flagA, flagB]); - -// pass an object -const { a, b } = await bulk({ a: flagA, b: flagB }); -``` - -Adapters can now opt into batched evaluation by implementing an optional `bulkDecide` method and setting a stable `adapterId`. When both are present, `bulk()` groups flags that share the same `adapterId` and `identify` source and invokes `bulkDecide` once per group instead of calling `decide` per flag. Flags without a bulk-capable adapter (or with an inline `decide`) still resolve through the normal per-flag path inside `bulk()` and benefit from the shared per-request headers, cookies, and overrides reads. diff --git a/.changeset/flags-evaluate.md b/.changeset/flags-evaluate.md new file mode 100644 index 00000000..c01ff36e --- /dev/null +++ b/.changeset/flags-evaluate.md @@ -0,0 +1,18 @@ +--- +'flags': minor +--- + +Extend `evaluate()` from the `flags/next` entry point to resolve multiple flags in a single call. + +```tsx +import { evaluate } from 'flags/next'; +import { flagA, flagB } from '../flags'; + +// pass a list of flags +const [valueA, valueB] = await evaluate([flagA, flagB]); + +// pass an object +const { a, b } = await evaluate({ a: flagA, b: flagB }); +``` + +Adapters can now opt into batched evaluation by implementing an optional `bulkDecide` method and setting a stable `adapterId`. When both are present, `evaluate()` groups flags that share the same `adapterId` and `identify` source and invokes `bulkDecide` once per group instead of calling `decide` per flag. Flags without a bulk-capable adapter (or with an inline `decide`) still resolve through the normal per-flag path inside `evaluate()` and benefit from the shared per-request headers, cookies, and overrides reads. diff --git a/packages/adapter-vercel/src/index.ts b/packages/adapter-vercel/src/index.ts index 1708a260..1a094b86 100644 --- a/packages/adapter-vercel/src/index.ts +++ b/packages/adapter-vercel/src/index.ts @@ -32,8 +32,8 @@ export function createVercelAdapter( // Stable identity for this adapter's underlying flagsClient. Captured in // the closure so every adapter object the factory below returns shares it, - // letting `bulk()` group flags from multiple `vercelAdapter()` calls into - // a single `bulkDecide` invocation. + // letting `evaluate()` group flags from multiple `vercelAdapter()` calls + // into a single `bulkDecide` invocation. const adapterId = Symbol('vercelAdapter'); return function vercelAdapter(): Adapter< diff --git a/packages/flags/src/next/evaluate.ts b/packages/flags/src/next/evaluate.ts new file mode 100644 index 00000000..44c683fe --- /dev/null +++ b/packages/flags/src/next/evaluate.ts @@ -0,0 +1,628 @@ +import { AsyncLocalStorage } from 'node:async_hooks'; +import type { IncomingHttpHeaders } from 'node:http'; +import { RequestCookies } from '@edge-runtime/cookies'; +import { reportValue } from '..'; +import { internalReportValue } from '../lib/report-value'; +import { setSpanAttribute } from '../lib/tracing'; +import { + HeadersAdapter, + type ReadonlyHeaders, +} from '../spec-extension/adapters/headers'; +import { + type ReadonlyRequestCookies, + RequestCookiesAdapter, +} from '../spec-extension/adapters/request-cookies'; +import type { + Adapter, + Decide, + FlagDeclaration, + FlagParamsType, +} from '../types'; +import { isInternalNextError } from './is-internal-next-error'; +import { getOverrides } from './overrides'; +import type { Flag, PagesRouterRequest } from './types'; + +// Internal markers stamped on the flag api by `flag()`. Read by `evaluate()` +// to partition flags into adapter groups. +// +// - BULK_IDENTIFY_REF: the raw identify source for reference-equality +// comparison across flags. The wrapped `api.identify` is created per +// `flag()` call, so it can't be used for grouping. +// - BULKABLE: whether the flag can participate in adapter-level bulk +// evaluation. An inline `definition.decide` disqualifies the flag +// because `getDecide` prefers it over the adapter's decide. +export const BULK_IDENTIFY_REF = Symbol('flags.bulkIdentifyRef'); +export const BULKABLE = Symbol('flags.bulkable'); + +// a map of (headers, flagKey, entitiesKey) => value +const evaluationCache = new WeakMap< + Headers | IncomingHttpHeaders, + Map> +>(); + +function getCachedValuePromise( + /** + * supports Headers for App Router and IncomingHttpHeaders for Pages Router + */ + headers: Headers | IncomingHttpHeaders, + flagKey: string, + entitiesKey: string, +): any { + return evaluationCache.get(headers)?.get(flagKey)?.get(entitiesKey); +} + +function setCachedValuePromise( + /** + * supports Headers for App Router and IncomingHttpHeaders for Pages Router + */ + headers: Headers | IncomingHttpHeaders, + flagKey: string, + entitiesKey: string, + flagValue: any, +): any { + const byHeaders = evaluationCache.get(headers); + + if (!byHeaders) { + evaluationCache.set( + headers, + new Map([[flagKey, new Map([[entitiesKey, flagValue]])]]), + ); + return; + } + + const byFlagKey = byHeaders.get(flagKey); + if (!byFlagKey) { + byHeaders.set(flagKey, new Map([[entitiesKey, flagValue]])); + return; + } + + byFlagKey.set(entitiesKey, flagValue); +} + +type IdentifyArgs = Parameters< + Exclude['identify'], undefined> +>; +const transformMap = new WeakMap(); +const headersMap = new WeakMap(); +const cookiesMap = new WeakMap(); +const identifyArgsMap = new WeakMap< + Headers | IncomingHttpHeaders, + IdentifyArgs +>(); + +/** + * Transforms IncomingHttpHeaders to Headers + */ +function transformToHeaders(incomingHeaders: IncomingHttpHeaders): Headers { + const cached = transformMap.get(incomingHeaders); + if (cached !== undefined) return cached; + + const headers = new Headers(); + for (const [key, value] of Object.entries(incomingHeaders)) { + if (Array.isArray(value)) { + // If the value is an array, add each item separately + value.forEach((item) => { + headers.append(key, item); + }); + } else if (value !== undefined) { + // If it's a single value, add it directly + headers.append(key, value); + } + } + + transformMap.set(incomingHeaders, headers); + return headers; +} + +function sealHeaders(headers: Headers): ReadonlyHeaders { + const cached = headersMap.get(headers); + if (cached !== undefined) return cached; + + const sealed = HeadersAdapter.seal(headers); + headersMap.set(headers, sealed); + return sealed; +} + +function sealCookies(headers: Headers): ReadonlyRequestCookies { + const cached = cookiesMap.get(headers); + if (cached !== undefined) return cached; + + const sealed = RequestCookiesAdapter.seal(new RequestCookies(headers)); + cookiesMap.set(headers, sealed); + return sealed; +} + +function isIdentifyFunction( + identify: FlagDeclaration['identify'] | EntitiesType, +): identify is FlagDeclaration['identify'] { + return typeof identify === 'function'; +} + +async function getEntities( + identify: FlagDeclaration['identify'] | EntitiesType, + dedupeCacheKey: Headers | IncomingHttpHeaders, + readonlyHeaders: ReadonlyHeaders, + readonlyCookies: ReadonlyRequestCookies, +): Promise { + if (!identify) return undefined; + if (!isIdentifyFunction(identify)) return identify; + + const args = identifyArgsMap.get(dedupeCacheKey); + if (args) return identify(...(args as [FlagParamsType])); + + const nextArgs: IdentifyArgs = [ + { headers: readonlyHeaders, cookies: readonlyCookies }, + ]; + identifyArgsMap.set(dedupeCacheKey, nextArgs); + return identify(...(nextArgs as [FlagParamsType])); +} + +interface BulkStoreData { + headers: ReadonlyHeaders; + cookies: ReadonlyRequestCookies; + dedupeCacheKey: Headers | IncomingHttpHeaders; + overrides: Record | null; +} + +const bulkStore = new AsyncLocalStorage(); + +let headersModulePromise: Promise | undefined; +let headersModule: typeof import('next/headers') | undefined; + +/** + * Subset of a flag declaration / flag function that `applyResult` reads. + * `FlagDeclaration` (passed from `getRun`) and the `api` (passed from + * `evaluate()`) both satisfy this shape after `flag()` stamps `config` onto + * the api. + */ +type FlagInfo = { + key: string; + defaultValue?: ValueType; + config?: { reportValue?: boolean }; +}; + +/** + * Finalize a flag evaluation given an already-computed `entitiesKey`. + * + * Shared by `getRun` (single-flag path) and `evaluate()` (group path). Handles, + * in order: cache hit → override → produce → defaultValue/error normalization + * → cache write → reportValue. Override and cache writes write to the same + * `evaluationCache` either path uses, so a subsequent `flagFn()` in the same + * request hits cache regardless of which path populated it. + */ +async function applyResult(args: { + definition: FlagInfo; + readonlyHeaders: ReadonlyHeaders; + entitiesKey: string; + overrides: Record | null; + produce: () => ValueType | PromiseLike; +}): Promise { + const { definition, readonlyHeaders, entitiesKey, overrides, produce } = args; + + const cachedValue = getCachedValuePromise( + readonlyHeaders, + definition.key, + entitiesKey, + ); + if (cachedValue !== undefined) { + setSpanAttribute('method', 'cached'); + return await cachedValue; + } + + if (overrides && overrides[definition.key] !== undefined) { + setSpanAttribute('method', 'override'); + const decision = overrides[definition.key] as ValueType; + setCachedValuePromise( + readonlyHeaders, + definition.key, + entitiesKey, + Promise.resolve(decision), + ); + internalReportValue(definition.key, decision, { + reason: 'override', + }); + return decision; + } + + // Normalize the result of produce() into a promise. produce() may return + // synchronously or asynchronously, and may also throw synchronously. + // Fall back to defaultValue when produce returns undefined or throws. + let decisionResult: ValueType | PromiseLike; + try { + decisionResult = produce(); + } catch (error) { + decisionResult = Promise.reject(error); + } + + const decisionPromise = Promise.resolve(decisionResult).then< + ValueType, + ValueType + >( + (value) => { + if (value !== undefined) return value; + if (definition.defaultValue !== undefined) return definition.defaultValue; + throw new Error( + `flags: Flag "${definition.key}" must have a defaultValue or a decide function that returns a value`, + ); + }, + (error: Error) => { + if (isInternalNextError(error)) throw error; + + // try to recover if defaultValue is set + if (definition.defaultValue !== undefined) { + if (process.env.NODE_ENV === 'development') { + console.info( + `flags: Flag "${definition.key}" is falling back to its defaultValue`, + ); + } else { + console.warn( + `flags: Flag "${definition.key}" is falling back to its defaultValue after catching the following error`, + error, + ); + } + return definition.defaultValue; + } + console.warn(`flags: Flag "${definition.key}" could not be evaluated`); + throw error; + }, + ); + + setCachedValuePromise( + readonlyHeaders, + definition.key, + entitiesKey, + decisionPromise, + ); + + const decision = await decisionPromise; + + if (definition.config?.reportValue !== false) { + // Only check `config.reportValue` for the result of `decide`. + // No need to check it for `override` since the client will have + // be short circuited in that case. + reportValue(definition.key, decision); + } + + return decision; +} + +type Run = (options: { + entities?: EntitiesType; + identify?: + | FlagDeclaration['identify'] + | EntitiesType; + /** + * For Pages Router only + */ + request?: PagesRouterRequest; +}) => Promise; + +/** + * Builds the runtime function used by a single flag. Handles Pages Router, + * App Router, and reuse of pre-read data when called from inside `evaluate()`. + */ +export function getRun( + definition: FlagDeclaration, + decide: Decide, +): Run { + // use cache to guarantee flags only decide once per request + return async function run(options): Promise { + let readonlyHeaders: ReadonlyHeaders; + let readonlyCookies: ReadonlyRequestCookies; + let dedupeCacheKey: Headers | IncomingHttpHeaders; + + // Check if running inside evaluate() — reuse pre-read headers/cookies/overrides + const bulkData = bulkStore.getStore(); + + let overrides: Record | null; + + if (options.request) { + // pages router + const headers = transformToHeaders(options.request.headers); + readonlyHeaders = sealHeaders(headers); + readonlyCookies = sealCookies(headers); + dedupeCacheKey = options.request.headers; + + // skip microtask if cookie does not exist or is empty + const override = readonlyCookies.get('vercel-flag-overrides')?.value; + overrides = + typeof override === 'string' && override !== '' + ? await getOverrides(override) + : null; + } else if (bulkData) { + // app router — evaluate() mode, everything pre-read + readonlyHeaders = bulkData.headers; + readonlyCookies = bulkData.cookies; + dedupeCacheKey = bulkData.dedupeCacheKey; + overrides = bulkData.overrides; + } else { + // app router + + // async import required as turbopack errors in Pages Router + // when next/headers is imported at the top-level. + // + // cache import so we don't await on every call since this adds + // additional microtask queue overhead + if (!headersModulePromise) headersModulePromise = import('next/headers'); + if (!headersModule) headersModule = await headersModulePromise; + const { headers, cookies } = headersModule; + + const [headersStore, cookiesStore] = await Promise.all([ + headers(), + cookies(), + ]); + readonlyHeaders = headersStore as ReadonlyHeaders; + readonlyCookies = cookiesStore as ReadonlyRequestCookies; + dedupeCacheKey = headersStore; + + // skip microtask if cookie does not exist or is empty + const override = readonlyCookies.get('vercel-flag-overrides')?.value; + overrides = + typeof override === 'string' && override !== '' + ? await getOverrides(override) + : null; + } + + // the flag is being used in app router + // skip microtask if identify does not exist + const entities = options.identify + ? ((await getEntities( + options.identify, + dedupeCacheKey, + readonlyHeaders, + readonlyCookies, + )) as EntitiesType | undefined) + : undefined; + + const entitiesKey = JSON.stringify(entities) ?? ''; + + return applyResult({ + definition, + readonlyHeaders, + entitiesKey, + overrides, + produce: () => + decide({ + // @ts-expect-error TypeScript will not be able to process `getPrecomputed` when added to `Decide`. It is, however, part of the `Adapter` type + defaultValue: definition.defaultValue, + headers: readonlyHeaders, + cookies: readonlyCookies, + entities, + }), + }); + }; +} + +// Distributive value extraction. `Flag` is itself a union +// (AppRouterFlag | PagesRouterFlag | PrecomputedFlag), so inferring V against +// a union element type only works when the conditional's check type is a +// naked type parameter — hence the helper. +type BulkValue = F extends Flag ? V : never; + +type EvaluateRequest = PagesRouterRequest | Request; + +/** + * Resolves a set of flags in a single call. + * + * Pre-reads headers, cookies, and the override cookie once for the whole + * batch, then partitions flags by `(adapterId, identify)` so adapters that + * implement `bulkDecide` can evaluate an entire group through a single call. + * Flags whose adapters don't opt into bulk evaluation (no `adapterId` or no + * `bulkDecide`) and flags with an inline `decide` fall back to the per-flag + * path — they still benefit from the shared pre-read of headers, cookies, and + * overrides. + * + * Accepts either an array of flags (positional results) or an object whose + * values are flags (keyed results). + * + * Pass a `request` as the second argument when calling outside App Router — + * an `IncomingMessage` from Pages Router (`getServerSideProps`, API routes) + * or a `NextRequest` / Web `Request` from routing middleware. Without it, + * `evaluate()` reads from `next/headers`, which is only available in App + * Router and routing middleware. + */ +export async function evaluate[]>( + flags: T, + request?: EvaluateRequest, +): Promise<{ [K in keyof T]: BulkValue }>; +export async function evaluate>>( + flags: T, + request?: EvaluateRequest, +): Promise<{ [K in keyof T]: BulkValue }>; +export async function evaluate( + flags: Record> | readonly Flag[], + request?: EvaluateRequest, +): Promise { + // Skip the `next/headers` read when there's nothing to evaluate. This also + // lets `precompute([])` return `__no_flags__` outside a request scope (e.g. + // during static generation), which is the documented behavior of an empty + // precompute group. + if ( + Array.isArray(flags) ? flags.length === 0 : Object.keys(flags).length === 0 + ) { + return Array.isArray(flags) ? [] : {}; + } + + let readonlyHeaders: ReadonlyHeaders; + let readonlyCookies: ReadonlyRequestCookies; + let dedupeCacheKey: Headers | IncomingHttpHeaders; + + if (request) { + // Derive headers/cookies from the request, skipping the `next/headers` + // import. Discriminate by whether `.headers` is already a `Headers` + // instance (NextRequest / Web Request) or an `IncomingHttpHeaders` plain + // object (Pages Router `IncomingMessage`). + const headers = + request.headers instanceof Headers + ? request.headers + : transformToHeaders(request.headers); + readonlyHeaders = sealHeaders(headers); + readonlyCookies = sealCookies(headers); + dedupeCacheKey = request.headers; + } else { + // app router — read headers & cookies via `next/headers`. + if (!headersModulePromise) headersModulePromise = import('next/headers'); + if (!headersModule) headersModule = await headersModulePromise; + const { headers, cookies } = headersModule; + + const [headersStore, cookiesStore] = await Promise.all([ + headers(), + cookies(), + ]); + + readonlyHeaders = headersStore as ReadonlyHeaders; + readonlyCookies = cookiesStore as ReadonlyRequestCookies; + dedupeCacheKey = headersStore; + } + + // Read overrides once + const override = readonlyCookies.get('vercel-flag-overrides')?.value; + const overrides = + typeof override === 'string' && override !== '' + ? await getOverrides(override) + : null; + + const storeData: BulkStoreData = { + headers: readonlyHeaders, + cookies: readonlyCookies, + dedupeCacheKey, + overrides, + }; + + return bulkStore.run(storeData, async () => { + const entries = Object.entries(flags); + + const standalone: { name: string; flagFn: Flag }[] = []; + // adapterId -> identifyRef -> { adapter, entries } + const groups = new Map< + string | symbol, + Map< + unknown, + { + adapter: Adapter; + entries: { name: string; flagFn: Flag }[]; + } + > + >(); + + for (const [name, flagFn] of entries) { + const entry = { name, flagFn }; + if (!(flagFn as any)[BULKABLE]) { + standalone.push(entry); + continue; + } + const adapter = flagFn.adapter as Adapter; + const groupId = adapter.adapterId as string | symbol; + const identifyRef = (flagFn as any)[BULK_IDENTIFY_REF] ?? null; + let byIdentify = groups.get(groupId); + if (!byIdentify) { + byIdentify = new Map(); + groups.set(groupId, byIdentify); + } + let bucket = byIdentify.get(identifyRef); + if (!bucket) { + // Capture the first adapter for this group — any adapter with the + // same adapterId must wrap the same underlying resource. + bucket = { adapter, entries: [] }; + byIdentify.set(identifyRef, bucket); + } + bucket.entries.push(entry); + } + + const valuesByName: Record = {}; + const groupPromises: Promise[] = []; + + for (const byIdentify of groups.values()) { + for (const [identifyRef, { adapter, entries: list }] of byIdentify) { + groupPromises.push( + (async () => { + // Resolve entities once for the entire group. The dedupe key is + // the same one `getRun` uses (`request.headers` for Pages Router, + // the `headers()` store for App Router), so any flag called + // individually before/after `evaluate()` reuses the cached + // identify args from `identifyArgsMap`. + const entities = identifyRef + ? await getEntities( + identifyRef as any, + dedupeCacheKey, + readonlyHeaders, + readonlyCookies, + ) + : undefined; + const entitiesKey = JSON.stringify(entities) ?? ''; + + // Skip flags already resolved this request — `applyResult` would + // discard the bulk result for them anyway. If every flag in the + // group is cached, the adapter call is avoided entirely. + const uncached = list.filter( + ({ flagFn }) => + getCachedValuePromise( + readonlyHeaders, + flagFn.key, + entitiesKey, + ) === undefined, + ); + + // Call bulkDecide. If it throws, every uncached flag still goes + // through `applyResult` — its producer just rethrows, so the + // catch arm handles the per-flag defaultValue fallback (or + // rejects for flags without a defaultValue). + let bulkResult: Record | null = null; + let bulkError: unknown = null; + if (uncached.length > 0) { + try { + bulkResult = await adapter.bulkDecide!({ + flags: uncached.map(({ flagFn }) => ({ + key: flagFn.key, + defaultValue: flagFn.defaultValue, + })), + entities, + headers: readonlyHeaders, + cookies: readonlyCookies, + }); + } catch (err) { + bulkError = err; + } + } + + await Promise.all( + list.map(async ({ name, flagFn }) => { + valuesByName[name] = await applyResult({ + definition: flagFn, + readonlyHeaders, + entitiesKey, + overrides, + produce: () => { + if (bulkError) throw bulkError; + return bulkResult![flagFn.key]; + }, + }); + }), + ); + })(), + ); + } + } + + if (standalone.length > 0) { + groupPromises.push( + (async () => { + const values = await Promise.all( + standalone.map(({ flagFn }) => flagFn()), + ); + standalone.forEach(({ name }, i) => { + valuesByName[name] = values[i]; + }); + })(), + ); + } + + await Promise.all(groupPromises); + + const result: any = Array.isArray(flags) ? new Array(entries.length) : {}; + for (const [name] of entries) { + result[name] = valuesByName[name]; + } + return result; + }); +} diff --git a/packages/flags/src/next/index.test.ts b/packages/flags/src/next/index.test.ts index ab1bae58..0d84e1e7 100644 --- a/packages/flags/src/next/index.test.ts +++ b/packages/flags/src/next/index.test.ts @@ -5,9 +5,9 @@ import type { NextApiRequestCookies } from 'next/dist/server/api-utils'; import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest'; import { type Adapter, encryptOverrides } from '..'; import { - bulk, clearDedupeCacheForCurrentRequest, dedupe, + evaluate, flag, precompute, } from '.'; @@ -754,7 +754,7 @@ describe('adapters', () => { }); }); -describe('bulk', () => { +describe('evaluate', () => { beforeAll(() => { process.env.FLAGS_SECRET = 'yuhyxaVI0Zue85SguKlMIUQojvJyBPzm95fFYvOa4Rc'; }); @@ -803,7 +803,7 @@ describe('bulk', () => { const b = flag({ key: 'b', adapter: adapter() }); mocks.headers.mockReturnValueOnce(new Headers()); - await expect(bulk({ a, b })).resolves.toEqual({ a: 'A', b: 'B' }); + await expect(evaluate({ a, b })).resolves.toEqual({ a: 'A', b: 'B' }); expect(bulkDecideMock).toHaveBeenCalledTimes(1); expect(bulkDecideMock).toHaveBeenCalledWith( @@ -840,7 +840,7 @@ describe('bulk', () => { }); mocks.headers.mockReturnValueOnce(new Headers()); - await expect(bulk({ a, b })).resolves.toEqual({ a: 'v-a', b: 'v-b' }); + await expect(evaluate({ a, b })).resolves.toEqual({ a: 'v-a', b: 'v-b' }); expect(bulkDecideMock).toHaveBeenCalledTimes(2); }); @@ -854,7 +854,7 @@ describe('bulk', () => { const b = flag({ key: 'b', adapter: adapterB() }); mocks.headers.mockReturnValueOnce(new Headers()); - await expect(bulk({ a, b })).resolves.toEqual({ a: 'A', b: 'B' }); + await expect(evaluate({ a, b })).resolves.toEqual({ a: 'A', b: 'B' }); expect(bulkA).toHaveBeenCalledTimes(1); expect(bulkB).toHaveBeenCalledTimes(1); }); @@ -871,7 +871,7 @@ describe('bulk', () => { const a = flag({ key: 'a', adapter: adapter() }); mocks.headers.mockReturnValueOnce(new Headers()); - await expect(bulk({ a })).resolves.toEqual({ a: 'from-decide' }); + await expect(evaluate({ a })).resolves.toEqual({ a: 'from-decide' }); expect(bulkDecideMock).not.toHaveBeenCalled(); expect(decideMock).toHaveBeenCalledTimes(1); }); @@ -886,7 +886,7 @@ describe('bulk', () => { const a = flag({ key: 'a', adapter: adapter() }); mocks.headers.mockReturnValueOnce(new Headers()); - await expect(bulk({ a })).resolves.toEqual({ a: 'single' }); + await expect(evaluate({ a })).resolves.toEqual({ a: 'single' }); expect(decideMock).toHaveBeenCalledTimes(1); }); @@ -899,7 +899,7 @@ describe('bulk', () => { const b = flag({ key: 'b', adapter: adapter() }); mocks.headers.mockReturnValueOnce(new Headers()); - await expect(bulk({ a, b })).resolves.toEqual({ + await expect(evaluate({ a, b })).resolves.toEqual({ a: 'inline-result', b: 'bulk-result', }); @@ -924,7 +924,7 @@ describe('bulk', () => { }); mocks.headers.mockReturnValueOnce(new Headers()); - await expect(bulk({ a, b })).resolves.toEqual({ a: 'fa', b: 'fb' }); + await expect(evaluate({ a, b })).resolves.toEqual({ a: 'fa', b: 'fb' }); expect(bulkDecideMock).toHaveBeenCalledTimes(1); expect(warnSpy).toHaveBeenCalled(); warnSpy.mockRestore(); @@ -943,7 +943,7 @@ describe('bulk', () => { const b = flag({ key: 'b', adapter: adapter() }); mocks.headers.mockReturnValueOnce(new Headers()); - await expect(bulk({ a, b })).rejects.toThrow('bulk failed'); + await expect(evaluate({ a, b })).rejects.toThrow('bulk failed'); warnSpy.mockRestore(); }); @@ -959,7 +959,7 @@ describe('bulk', () => { }); mocks.headers.mockReturnValueOnce(new Headers()); - await expect(bulk({ a, b })).resolves.toEqual({ a: 'A', b: 'fb' }); + await expect(evaluate({ a, b })).resolves.toEqual({ a: 'A', b: 'fb' }); }); it('lets overrides win over bulkDecide results', async () => { @@ -977,7 +977,7 @@ describe('bulk', () => { mocks.headers.mockReturnValueOnce(new Headers()); mocks.cookies.mockReturnValueOnce({ get: cookieMock }); - await expect(bulk({ a })).resolves.toEqual({ a: true }); + await expect(evaluate({ a })).resolves.toEqual({ a: true }); }); it('populates the evaluation cache so a subsequent flagFn() hits cache', async () => { @@ -988,7 +988,7 @@ describe('bulk', () => { const headers = new Headers(); mocks.headers.mockReturnValue(headers); - await expect(bulk({ a })).resolves.toEqual({ a: 'A' }); + await expect(evaluate({ a })).resolves.toEqual({ a: 'A' }); expect(bulkDecideMock).toHaveBeenCalledTimes(1); // Subsequent direct call in the same "request" (same headers object) @@ -1007,7 +1007,90 @@ describe('bulk', () => { const apple = flag({ key: 'apple', adapter: adapter() }); mocks.headers.mockReturnValueOnce(new Headers()); - const result = await bulk({ zebra, apple }); + const result = await evaluate({ zebra, apple }); expect(Object.keys(result)).toEqual(['zebra', 'apple']); }); + + describe('with request argument', () => { + it('resolves flags using a Pages Router IncomingMessage without touching next/headers', async () => { + const bulkDecideMock = vi.fn().mockResolvedValue({ a: 'A', b: 'B' }); + const adapter = makeBulkAdapter({ bulkDecide: bulkDecideMock }); + + const a = flag({ key: 'a', adapter: adapter() }); + const b = flag({ key: 'b', adapter: adapter() }); + + mocks.headers.mockClear(); + const [request, socket] = createRequest(); + await expect(evaluate({ a, b }, request)).resolves.toEqual({ + a: 'A', + b: 'B', + }); + expect(mocks.headers).not.toHaveBeenCalled(); + expect(bulkDecideMock).toHaveBeenCalledTimes(1); + socket.destroy(); + }); + + it('accepts a web Request (NextRequest) and skips next/headers', async () => { + const bulkDecideMock = vi.fn().mockResolvedValue({ a: 'A', b: 'B' }); + const adapter = makeBulkAdapter({ bulkDecide: bulkDecideMock }); + + const a = flag({ key: 'a', adapter: adapter() }); + const b = flag({ key: 'b', adapter: adapter() }); + + mocks.headers.mockClear(); + const webRequest = new Request('http://example.com/', { + headers: { cookie: 'foo=bar' }, + }); + await expect(evaluate({ a, b }, webRequest)).resolves.toEqual({ + a: 'A', + b: 'B', + }); + expect(mocks.headers).not.toHaveBeenCalled(); + expect(bulkDecideMock).toHaveBeenCalledTimes(1); + + // bulkDecide receives the request's own headers (not a copy via + // transformToHeaders) — verify by checking a header round-trips. + const [callArgs] = bulkDecideMock.mock.calls; + expect(callArgs[0].headers.get('cookie')).toBe('foo=bar'); + }); + + it('array overload preserves order and skips next/headers', async () => { + const bulkDecideMock = vi.fn().mockResolvedValue({ z: 'Z', a: 'A' }); + const adapter = makeBulkAdapter({ bulkDecide: bulkDecideMock }); + + const z = flag({ key: 'z', adapter: adapter() }); + const a = flag({ key: 'a', adapter: adapter() }); + + mocks.headers.mockClear(); + const [request, socket] = createRequest(); + const result = await evaluate([z, a], request); + // positional: index 0 → z, index 1 → a + expect(result).toEqual(['Z', 'A']); + expect(mocks.headers).not.toHaveBeenCalled(); + socket.destroy(); + }); + + it('shares the per-request cache with direct flag(req) calls', async () => { + const bulkDecideMock = vi.fn().mockResolvedValue({ a: 'A' }); + const decideMock = vi.fn(() => 'inline'); + const adapter = makeBulkAdapter({ bulkDecide: bulkDecideMock }); + + const a = flag({ key: 'a', adapter: adapter() }); + const b = flag({ key: 'b', decide: decideMock }); + + const [request, socket] = createRequest(); + await expect(evaluate({ a, b }, request)).resolves.toEqual({ + a: 'A', + b: 'inline', + }); + + // Subsequent direct call in the same request should hit cache, + // not re-invoke bulkDecide/decide. + await expect(a(request)).resolves.toEqual('A'); + await expect(b(request)).resolves.toEqual('inline'); + expect(bulkDecideMock).toHaveBeenCalledTimes(1); + expect(decideMock).toHaveBeenCalledTimes(1); + socket.destroy(); + }); + }); }); diff --git a/packages/flags/src/next/index.ts b/packages/flags/src/next/index.ts index 6208ad2b..52e0ddde 100644 --- a/packages/flags/src/next/index.ts +++ b/packages/flags/src/next/index.ts @@ -1,41 +1,21 @@ -import { AsyncLocalStorage } from 'node:async_hooks'; -import type { IncomingHttpHeaders } from 'node:http'; -import { RequestCookies } from '@edge-runtime/cookies'; -import { - type FlagDefinitionsType, - type FlagDefinitionType, - type ProviderData, - reportValue, -} from '..'; +import type { FlagDefinitionsType, FlagDefinitionType, ProviderData } from '..'; import { normalizeOptions } from '../lib/normalize-options'; -import { internalReportValue } from '../lib/report-value'; import { setSpanAttribute, trace } from '../lib/tracing'; -import { - HeadersAdapter, - type ReadonlyHeaders, -} from '../spec-extension/adapters/headers'; -import { - type ReadonlyRequestCookies, - RequestCookiesAdapter, -} from '../spec-extension/adapters/request-cookies'; import type { - Adapter, Decide, FlagDeclaration, - FlagParamsType, Identify, JsonValue, Origin, } from '../types'; -import { isInternalNextError } from './is-internal-next-error'; -import { getOverrides } from './overrides'; +import { BULK_IDENTIFY_REF, BULKABLE, getRun } from './evaluate'; import { getPrecomputed } from './precompute'; import type { Flag, PagesRouterFlag, PrecomputedFlag } from './types'; +export { evaluate } from './evaluate'; export { combine, deserialize, - evaluate, generatePermutations, getPrecomputed, precompute, @@ -43,135 +23,6 @@ export { } from './precompute'; export type { Flag } from './types'; -// Internal markers stamped on the flag api by `flag()`. Read by `bulk()`. -// Kept off the public FlagMeta type — they're an implementation detail of -// how we partition flags for bulk evaluation. -const BULK_IDENTIFY_REF = Symbol('flags.bulkIdentifyRef'); -const BULKABLE = Symbol('flags.bulkable'); - -// a map of (headers, flagKey, entitiesKey) => value -const evaluationCache = new WeakMap< - Headers | IncomingHttpHeaders, - Map> ->(); - -function getCachedValuePromise( - /** - * supports Headers for App Router and IncomingHttpHeaders for Pages Router - */ - headers: Headers | IncomingHttpHeaders, - flagKey: string, - entitiesKey: string, -): any { - return evaluationCache.get(headers)?.get(flagKey)?.get(entitiesKey); -} - -function setCachedValuePromise( - /** - * supports Headers for App Router and IncomingHttpHeaders for Pages Router - */ - headers: Headers | IncomingHttpHeaders, - flagKey: string, - entitiesKey: string, - flagValue: any, -): any { - const byHeaders = evaluationCache.get(headers); - - if (!byHeaders) { - evaluationCache.set( - headers, - new Map([[flagKey, new Map([[entitiesKey, flagValue]])]]), - ); - return; - } - - const byFlagKey = byHeaders.get(flagKey); - if (!byFlagKey) { - byHeaders.set(flagKey, new Map([[entitiesKey, flagValue]])); - return; - } - - byFlagKey.set(entitiesKey, flagValue); -} - -type IdentifyArgs = Parameters< - Exclude['identify'], undefined> ->; -const transformMap = new WeakMap(); -const headersMap = new WeakMap(); -const cookiesMap = new WeakMap(); -const identifyArgsMap = new WeakMap< - Headers | IncomingHttpHeaders, - IdentifyArgs ->(); - -/** - * Transforms IncomingHttpHeaders to Headers - */ -function transformToHeaders(incomingHeaders: IncomingHttpHeaders): Headers { - const cached = transformMap.get(incomingHeaders); - if (cached !== undefined) return cached; - - const headers = new Headers(); - for (const [key, value] of Object.entries(incomingHeaders)) { - if (Array.isArray(value)) { - // If the value is an array, add each item separately - value.forEach((item) => { - headers.append(key, item); - }); - } else if (value !== undefined) { - // If it's a single value, add it directly - headers.append(key, value); - } - } - - transformMap.set(incomingHeaders, headers); - return headers; -} - -function sealHeaders(headers: Headers): ReadonlyHeaders { - const cached = headersMap.get(headers); - if (cached !== undefined) return cached; - - const sealed = HeadersAdapter.seal(headers); - headersMap.set(headers, sealed); - return sealed; -} - -function sealCookies(headers: Headers): ReadonlyRequestCookies { - const cached = cookiesMap.get(headers); - if (cached !== undefined) return cached; - - const sealed = RequestCookiesAdapter.seal(new RequestCookies(headers)); - cookiesMap.set(headers, sealed); - return sealed; -} - -function isIdentifyFunction( - identify: FlagDeclaration['identify'] | EntitiesType, -): identify is FlagDeclaration['identify'] { - return typeof identify === 'function'; -} - -async function getEntities( - identify: FlagDeclaration['identify'] | EntitiesType, - dedupeCacheKey: Headers | IncomingHttpHeaders, - readonlyHeaders: ReadonlyHeaders, - readonlyCookies: ReadonlyRequestCookies, -): Promise { - if (!identify) return undefined; - if (!isIdentifyFunction(identify)) return identify; - - const args = identifyArgsMap.get(dedupeCacheKey); - if (args) return identify(...(args as [FlagParamsType])); - - const nextArgs: IdentifyArgs = [ - { headers: readonlyHeaders, cookies: readonlyCookies }, - ]; - identifyArgsMap.set(dedupeCacheKey, nextArgs); - return identify(...(nextArgs as [FlagParamsType])); -} - function getDecide( definition: FlagDeclaration, ): Decide { @@ -201,200 +52,6 @@ function getDecide( }; } -interface BulkStoreData { - headers: ReadonlyHeaders; - cookies: ReadonlyRequestCookies; - dedupeCacheKey: Headers; - overrides: Record | null; -} - -const bulkStore = new AsyncLocalStorage(); - -// Distributive value extraction. `Flag` is itself a union -// (AppRouterFlag | PagesRouterFlag | PrecomputedFlag), so inferring V against -// a union element type only works when the conditional's check type is a -// naked type parameter — hence the helper. -type BulkValue = F extends Flag ? V : never; - -export async function bulk>>( - flags: T, -): Promise<{ [K in keyof T]: BulkValue }>; -export async function bulk[]>( - flags: T, -): Promise<{ [K in keyof T]: BulkValue }>; -export async function bulk( - flags: Record> | readonly Flag[], -): Promise { - // Read headers & cookies once - if (!headersModulePromise) headersModulePromise = import('next/headers'); - if (!headersModule) headersModule = await headersModulePromise; - const { headers, cookies } = headersModule; - - const [headersStore, cookiesStore] = await Promise.all([ - headers(), - cookies(), - ]); - - const readonlyHeaders = headersStore as ReadonlyHeaders; - const readonlyCookies = cookiesStore as ReadonlyRequestCookies; - - // Read overrides once - const override = readonlyCookies.get('vercel-flag-overrides')?.value; - const overrides = - typeof override === 'string' && override !== '' - ? await getOverrides(override) - : null; - - const storeData: BulkStoreData = { - headers: readonlyHeaders, - cookies: readonlyCookies, - dedupeCacheKey: headersStore, - overrides, - }; - - // Run all flags within the bulk store context. We partition flags by - // (adapterId, identifyRef) so adapters that implement `bulkDecide` can - // evaluate an entire group in a single call. Flags whose adapters don't - // opt into bulk (no `adapterId` or no `bulkDecide`) and flags with an - // inline `decide` fall back to the per-flag `flagFn()` path — which still - // benefits from the pre-read headers/cookies/overrides via `bulkStore`. - return bulkStore.run(storeData, async () => { - const entries = Object.entries(flags); - - const standalone: { name: string; flagFn: Flag }[] = []; - // adapterId -> identifyRef -> { adapter, entries } - const groups = new Map< - string | symbol, - Map< - unknown, - { - adapter: Adapter; - entries: { name: string; flagFn: Flag }[]; - } - > - >(); - - for (const [name, flagFn] of entries) { - const entry = { name, flagFn }; - if (!(flagFn as any)[BULKABLE]) { - standalone.push(entry); - continue; - } - const adapter = flagFn.adapter as Adapter; - const groupId = adapter.adapterId as string | symbol; - const identifyRef = (flagFn as any)[BULK_IDENTIFY_REF] ?? null; - let byIdentify = groups.get(groupId); - if (!byIdentify) { - byIdentify = new Map(); - groups.set(groupId, byIdentify); - } - let bucket = byIdentify.get(identifyRef); - if (!bucket) { - // Capture the first adapter for this group — any adapter with the - // same adapterId must wrap the same underlying resource. - bucket = { adapter, entries: [] }; - byIdentify.set(identifyRef, bucket); - } - bucket.entries.push(entry); - } - - const valuesByName: Record = {}; - const groupPromises: Promise[] = []; - - for (const byIdentify of groups.values()) { - for (const [identifyRef, { adapter, entries: list }] of byIdentify) { - groupPromises.push( - (async () => { - // Resolve entities once for the entire group. The dedupe key is - // the raw `headersStore` (same key getRun uses), so any flag - // called individually after `bulk()` reuses the cached identify - // args from `identifyArgsMap`. - const entities = identifyRef - ? await getEntities( - identifyRef as any, - headersStore, - readonlyHeaders, - readonlyCookies, - ) - : undefined; - const entitiesKey = JSON.stringify(entities) ?? ''; - - // Skip flags already resolved this request — `applyResult` would - // discard the bulk result for them anyway. If every flag in the - // group is cached, the adapter call is avoided entirely. - const uncached = list.filter( - ({ flagFn }) => - getCachedValuePromise( - readonlyHeaders, - flagFn.key, - entitiesKey, - ) === undefined, - ); - - // Call bulkDecide. If it throws, every uncached flag still goes - // through `applyResult` — its producer just rethrows, so the - // catch arm handles the per-flag defaultValue fallback (or - // rejects for flags without a defaultValue). - let bulkResult: Record | null = null; - let bulkError: unknown = null; - if (uncached.length > 0) { - try { - bulkResult = await adapter.bulkDecide!({ - flags: uncached.map(({ flagFn }) => ({ - key: flagFn.key, - defaultValue: flagFn.defaultValue, - })), - entities, - headers: readonlyHeaders, - cookies: readonlyCookies, - }); - } catch (err) { - bulkError = err; - } - } - - await Promise.all( - list.map(async ({ name, flagFn }) => { - valuesByName[name] = await applyResult({ - definition: flagFn, - readonlyHeaders, - entitiesKey, - overrides, - produce: () => { - if (bulkError) throw bulkError; - return bulkResult![flagFn.key]; - }, - }); - }), - ); - })(), - ); - } - } - - if (standalone.length > 0) { - groupPromises.push( - (async () => { - const values = await Promise.all( - standalone.map(({ flagFn }) => flagFn()), - ); - standalone.forEach(({ name }, i) => { - valuesByName[name] = values[i]; - }); - })(), - ); - } - - await Promise.all(groupPromises); - - const result: any = Array.isArray(flags) ? new Array(entries.length) : {}; - for (const [name] of entries) { - result[name] = valuesByName[name]; - } - return result; - }); -} - function getIdentify( definition: FlagDeclaration, ): Identify { @@ -409,228 +66,6 @@ function getIdentify( }; } -type Run = (options: { - entities?: EntitiesType; - identify?: - | FlagDeclaration['identify'] - | EntitiesType; - /** - * For Pages Router only - */ - request?: Parameters>[0]; -}) => Promise; - -let headersModulePromise: Promise | undefined; -let headersModule: typeof import('next/headers') | undefined; - -/** - * Subset of a flag declaration / flag function that `applyResult` reads. - * `FlagDeclaration` (passed from `getRun`) and the `api` (passed from `bulk()`) - * both satisfy this shape after `flag()` stamps `config` onto the api. - */ -type FlagInfo = { - key: string; - defaultValue?: ValueType; - config?: { reportValue?: boolean }; -}; - -/** - * Finalize a flag evaluation given an already-computed `entitiesKey`. - * - * Shared by `getRun` (single-flag path) and `bulk()` (group path). Handles, in - * order: cache hit → override → produce → defaultValue/error normalization → - * cache write → reportValue. Override and cache writes write to the same - * `evaluationCache` either path uses, so a subsequent `flagFn()` in the same - * request hits cache regardless of which path populated it. - */ -async function applyResult(args: { - definition: FlagInfo; - readonlyHeaders: ReadonlyHeaders; - entitiesKey: string; - overrides: Record | null; - produce: () => ValueType | PromiseLike; -}): Promise { - const { definition, readonlyHeaders, entitiesKey, overrides, produce } = args; - - const cachedValue = getCachedValuePromise( - readonlyHeaders, - definition.key, - entitiesKey, - ); - if (cachedValue !== undefined) { - setSpanAttribute('method', 'cached'); - return await cachedValue; - } - - if (overrides && overrides[definition.key] !== undefined) { - setSpanAttribute('method', 'override'); - const decision = overrides[definition.key] as ValueType; - setCachedValuePromise( - readonlyHeaders, - definition.key, - entitiesKey, - Promise.resolve(decision), - ); - internalReportValue(definition.key, decision, { - reason: 'override', - }); - return decision; - } - - // Normalize the result of produce() into a promise. produce() may return - // synchronously or asynchronously, and may also throw synchronously. - // Fall back to defaultValue when produce returns undefined or throws. - let decisionResult: ValueType | PromiseLike; - try { - decisionResult = produce(); - } catch (error) { - decisionResult = Promise.reject(error); - } - - const decisionPromise = Promise.resolve(decisionResult).then< - ValueType, - ValueType - >( - (value) => { - if (value !== undefined) return value; - if (definition.defaultValue !== undefined) return definition.defaultValue; - throw new Error( - `flags: Flag "${definition.key}" must have a defaultValue or a decide function that returns a value`, - ); - }, - (error: Error) => { - if (isInternalNextError(error)) throw error; - - // try to recover if defaultValue is set - if (definition.defaultValue !== undefined) { - if (process.env.NODE_ENV === 'development') { - console.info( - `flags: Flag "${definition.key}" is falling back to its defaultValue`, - ); - } else { - console.warn( - `flags: Flag "${definition.key}" is falling back to its defaultValue after catching the following error`, - error, - ); - } - return definition.defaultValue; - } - console.warn(`flags: Flag "${definition.key}" could not be evaluated`); - throw error; - }, - ); - - setCachedValuePromise( - readonlyHeaders, - definition.key, - entitiesKey, - decisionPromise, - ); - - const decision = await decisionPromise; - - if (definition.config?.reportValue !== false) { - // Only check `config.reportValue` for the result of `decide`. - // No need to check it for `override` since the client will have - // be short circuited in that case. - reportValue(definition.key, decision); - } - - return decision; -} - -function getRun( - definition: FlagDeclaration, - decide: Decide, -): Run { - // use cache to guarantee flags only decide once per request - return async function run(options): Promise { - let readonlyHeaders: ReadonlyHeaders; - let readonlyCookies: ReadonlyRequestCookies; - let dedupeCacheKey: Headers | IncomingHttpHeaders; - - // Check if running inside bulk() — reuse pre-read headers/cookies/overrides - const bulkData = bulkStore.getStore(); - - let overrides: Record | null; - - if (options.request) { - // pages router - const headers = transformToHeaders(options.request.headers); - readonlyHeaders = sealHeaders(headers); - readonlyCookies = sealCookies(headers); - dedupeCacheKey = options.request.headers; - - // skip microtask if cookie does not exist or is empty - const override = readonlyCookies.get('vercel-flag-overrides')?.value; - overrides = - typeof override === 'string' && override !== '' - ? await getOverrides(override) - : null; - } else if (bulkData) { - // app router — bulk mode, everything pre-read - readonlyHeaders = bulkData.headers; - readonlyCookies = bulkData.cookies; - dedupeCacheKey = bulkData.dedupeCacheKey; - overrides = bulkData.overrides; - } else { - // app router - - // async import required as turbopack errors in Pages Router - // when next/headers is imported at the top-level. - // - // cache import so we don't await on every call since this adds - // additional microtask queue overhead - if (!headersModulePromise) headersModulePromise = import('next/headers'); - if (!headersModule) headersModule = await headersModulePromise; - const { headers, cookies } = headersModule; - - const [headersStore, cookiesStore] = await Promise.all([ - headers(), - cookies(), - ]); - readonlyHeaders = headersStore as ReadonlyHeaders; - readonlyCookies = cookiesStore as ReadonlyRequestCookies; - dedupeCacheKey = headersStore; - - // skip microtask if cookie does not exist or is empty - const override = readonlyCookies.get('vercel-flag-overrides')?.value; - overrides = - typeof override === 'string' && override !== '' - ? await getOverrides(override) - : null; - } - - // the flag is being used in app router - // skip microtask if identify does not exist - const entities = options.identify - ? ((await getEntities( - options.identify, - dedupeCacheKey, - readonlyHeaders, - readonlyCookies, - )) as EntitiesType | undefined) - : undefined; - - const entitiesKey = JSON.stringify(entities) ?? ''; - - return applyResult({ - definition, - readonlyHeaders, - entitiesKey, - overrides, - produce: () => - decide({ - // @ts-expect-error TypeScript will not be able to process `getPrecomputed` when added to `Decide`. It is, however, part of the `Adapter` type - defaultValue: definition.defaultValue, - headers: readonlyHeaders, - cookies: readonlyCookies, - entities, - }), - }); - }; -} - function getOrigin( definition: FlagDeclaration, ): string | Origin | undefined { @@ -737,13 +172,8 @@ export function flag< api.adapter = definition.adapter; api.config = definition.config; - // Internal markers used by `bulk()` to partition flags into adapter groups. - // - BULK_IDENTIFY_REF: the raw identify source for reference-equality - // comparison across flags. `api.identify` is a wrapper created per - // `flag()` call, so it can't be used for grouping. - // - BULKABLE: whether the flag can participate in adapter-level bulk - // evaluation. An inline `definition.decide` disqualifies the flag - // because `getDecide` prefers it over the adapter's decide. + // Internal markers used by `evaluate()` to partition flags into adapter + // groups. See `./evaluate.ts` for the symbol definitions. (api as any)[BULK_IDENTIFY_REF] = definition.identify ?? definition.adapter?.identify ?? null; (api as any)[BULKABLE] = diff --git a/packages/flags/src/next/precompute.ts b/packages/flags/src/next/precompute.ts index e4571b96..af219c4a 100644 --- a/packages/flags/src/next/precompute.ts +++ b/packages/flags/src/next/precompute.ts @@ -1,23 +1,11 @@ import type { JsonValue } from '..'; import * as s from '../lib/serialization'; +import { evaluate } from './evaluate'; import type { Flag } from './types'; type FlagsArray = readonly Flag[]; type ValuesArray = readonly any[]; -/** - * Resolves a list of flags - * @param flags - list of flags - * @returns - an array of evaluated flag values with one entry per flag - */ -export async function evaluate( - flags: T, -): Promise<{ [K in keyof T]: Awaited> }> { - return Promise.all(flags.map((flag) => flag())) as Promise<{ - [K in keyof T]: Awaited>; - }>; -} - /** * Evaluate a list of feature flags and generate a signed string representing their values. * diff --git a/packages/flags/src/next/types.ts b/packages/flags/src/next/types.ts index dc975059..88d69684 100644 --- a/packages/flags/src/next/types.ts +++ b/packages/flags/src/next/types.ts @@ -6,6 +6,13 @@ type NextApiRequestCookies = Partial<{ [key: string]: string; }>; +/** + * The Pages Router request shape accepted by `flag(req)` and `evaluate(flags, req)`. + */ +export type PagesRouterRequest = IncomingMessage & { + cookies: NextApiRequestCookies; +}; + /** * Metadata on a feature flag function */ @@ -47,9 +54,9 @@ type FlagMeta = { */ identify?: FlagDeclaration['identify']; /** - * The adapter used to evaluate this flag, if any. Exposed so `bulk()` can - * group flags that share an `adapterId` and call `adapter.bulkDecide` once - * per group. + * The adapter used to evaluate this flag, if any. Exposed so `evaluate()` + * can group flags that share an `adapterId` and call `adapter.bulkDecide` + * once per group. */ adapter?: FlagDeclaration['adapter']; /** @@ -65,7 +72,7 @@ type FlagMeta = { identify: | FlagDeclaration['identify'] | EntitiesType; - request?: Parameters>[0]; + request?: PagesRouterRequest; }) => Promise; }; @@ -74,9 +81,7 @@ export type AppRouterFlag = export type PagesRouterFlag = { (): never; - ( - request: IncomingMessage & { cookies: NextApiRequestCookies }, - ): Promise; + (request: PagesRouterRequest): Promise; } & FlagMeta; export type PrecomputedFlag = { diff --git a/packages/flags/src/types.ts b/packages/flags/src/types.ts index bd809fc9..cdd1cbc4 100644 --- a/packages/flags/src/types.ts +++ b/packages/flags/src/types.ts @@ -153,7 +153,7 @@ export interface Adapter { * function so every adapter object the factory returns shares the same id. * * The Flags SDK uses this for cross-instance grouping (most notably, - * `bulk()` batches flags whose adapters share an `adapterId` and an + * `evaluate()` batches flags whose adapters share an `adapterId` and an * `identify` source through a single `bulkDecide` call). Adapters without * an `adapterId` are never batched. */ @@ -169,10 +169,10 @@ export interface Adapter { defaultValue?: unknown; }) => Promise | ValueType; /** - * Optional batch hook used by `bulk()` to evaluate many flags that share - * this adapter's `adapterId` and the same `identify` source in a single - * call. When implemented (and `adapterId` is set), `bulk()` calls this - * once per group instead of invoking `decide` per flag. + * Optional batch hook used by `evaluate()` to resolve many flags that + * share this adapter's `adapterId` and the same `identify` source in a + * single call. When implemented (and `adapterId` is set), `evaluate()` + * calls this once per group instead of invoking `decide` per flag. * * - Return `Record`. Missing keys or `value: undefined` * trigger the per-flag `defaultValue` fallback in the SDK. From 0b5417292031df5fe82118b31210007812652abd Mon Sep 17 00:00:00 2001 From: Dominik Ferber Date: Fri, 29 May 2026 15:08:45 +0300 Subject: [PATCH 30/42] add test --- .../vercel-flags-core/src/black-box.test.ts | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/packages/vercel-flags-core/src/black-box.test.ts b/packages/vercel-flags-core/src/black-box.test.ts index 6ed5a187..e4e61858 100644 --- a/packages/vercel-flags-core/src/black-box.test.ts +++ b/packages/vercel-flags-core/src/black-box.test.ts @@ -2788,6 +2788,25 @@ describe('Controller (black-box)', () => { ); }); + it('should return FLAG_NOT_FOUND with undefined value when no defaultValue is provided for missing flag', async () => { + const client = createClient(sdkKey, { + fetch: fetchMock, + stream: false, + polling: false, + datafile: makeBundled(), + buildStep: true, + }); + + const result = await client.evaluate('nonexistent-flag'); + + expect(result.value).toBeUndefined(); + expect(result.reason).toBe('error'); + expect(result.errorCode).toBe('FLAG_NOT_FOUND'); + expect(result.errorMessage).toContain( + '@vercel/flags-core: Definition not found for flag "nonexistent-flag"', + ); + }); + it('should evaluate existing paused flag', async () => { const client = createClient(sdkKey, { fetch: fetchMock, From cafd95129c7d8b97f87f07d3e309c9f0a983ef20 Mon Sep 17 00:00:00 2001 From: Dominik Ferber Date: Fri, 29 May 2026 15:58:25 +0300 Subject: [PATCH 31/42] changesets --- .changeset/adapter-bulk-evaluation.md | 6 +----- .changeset/flags-evaluate.md | 8 ++++++-- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/.changeset/adapter-bulk-evaluation.md b/.changeset/adapter-bulk-evaluation.md index a6ea333d..135ffc64 100644 --- a/.changeset/adapter-bulk-evaluation.md +++ b/.changeset/adapter-bulk-evaluation.md @@ -2,8 +2,4 @@ '@flags-sdk/vercel': minor --- -Faster evaluation of flags when using the Vercel adapter via `evaluate()`. - -This version of `flags-sdk/vercel` implements `bulkDecide` on the Vercel Flags adapter so flags can be evaluated together via `evaluate()` from `flags/next`. - -This improves performance by avoiding the per-flag overhead of separate `decide()` calls. We've seen a 10x improvement in evaluation time for large batches of flags. +Reduces overhead when evaluating multiple flags via `evaluate()` or `precompute()` by using new bulk evaluation capabilities of `@vercel/flags-core`. diff --git a/.changeset/flags-evaluate.md b/.changeset/flags-evaluate.md index c01ff36e..5e845d8c 100644 --- a/.changeset/flags-evaluate.md +++ b/.changeset/flags-evaluate.md @@ -2,7 +2,11 @@ 'flags': minor --- -Extend `evaluate()` from the `flags/next` entry point to resolve multiple flags in a single call. +Introduces a new bulk evaluation method for adapters, which is used when multiple flags are evaluated together to avoid making individual calls to each adapter. + +When applications call `evaluate()` or `precompute()` function from `flags/next` it now defers bulk evaluation to the underlying adapters in case those support it, or otherwise falls back to evaluating each flag individually. + +This speeds up evaluation for applications that need to evaluate multiple flags at once, as the runtime needs to handle fewer promises and more work is reused. In testing we have seen a 20x improvement when called with 100 flags. ```tsx import { evaluate } from 'flags/next'; @@ -15,4 +19,4 @@ const [valueA, valueB] = await evaluate([flagA, flagB]); const { a, b } = await evaluate({ a: flagA, b: flagB }); ``` -Adapters can now opt into batched evaluation by implementing an optional `bulkDecide` method and setting a stable `adapterId`. When both are present, `evaluate()` groups flags that share the same `adapterId` and `identify` source and invokes `bulkDecide` once per group instead of calling `decide` per flag. Flags without a bulk-capable adapter (or with an inline `decide`) still resolve through the normal per-flag path inside `evaluate()` and benefit from the shared per-request headers, cookies, and overrides reads. +Adapters can opt into bulk evaluation by implementing a `bulkDecide` method and setting a stable `adapterId`. When both are present, flag evaluation 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 still resolve through the normal per-flag path inside `evaluate()` and still benefit from now reusing the shared per-request headers, cookies, and overrides reads. From e6a6157efc5b1baaa85cd20799074725aa49d9fb Mon Sep 17 00:00:00 2001 From: Dominik Ferber Date: Fri, 29 May 2026 16:05:41 +0300 Subject: [PATCH 32/42] use type import --- packages/flags/src/next/index.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/flags/src/next/index.ts b/packages/flags/src/next/index.ts index 52e0ddde..21b27b2c 100644 --- a/packages/flags/src/next/index.ts +++ b/packages/flags/src/next/index.ts @@ -1,12 +1,14 @@ -import type { FlagDefinitionsType, FlagDefinitionType, ProviderData } from '..'; import { normalizeOptions } from '../lib/normalize-options'; import { setSpanAttribute, trace } from '../lib/tracing'; import type { Decide, FlagDeclaration, + FlagDefinitionsType, + FlagDefinitionType, Identify, JsonValue, Origin, + ProviderData, } from '../types'; import { BULK_IDENTIFY_REF, BULKABLE, getRun } from './evaluate'; import { getPrecomputed } from './precompute'; From 85598774d9b651ba0ca1f58cc89efa7fcaa8800a Mon Sep 17 00:00:00 2001 From: Dominik Ferber Date: Fri, 29 May 2026 17:19:33 +0300 Subject: [PATCH 33/42] fix import --- packages/flags/src/next/evaluate.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/flags/src/next/evaluate.ts b/packages/flags/src/next/evaluate.ts index 44c683fe..d0cab0a5 100644 --- a/packages/flags/src/next/evaluate.ts +++ b/packages/flags/src/next/evaluate.ts @@ -1,7 +1,7 @@ import { AsyncLocalStorage } from 'node:async_hooks'; import type { IncomingHttpHeaders } from 'node:http'; import { RequestCookies } from '@edge-runtime/cookies'; -import { reportValue } from '..'; +import { reportValue } from '../lib/report-value'; import { internalReportValue } from '../lib/report-value'; import { setSpanAttribute } from '../lib/tracing'; import { From 794460874fdd5167a909369618c641671c9338ff Mon Sep 17 00:00:00 2001 From: Dominik Ferber Date: Fri, 29 May 2026 17:20:52 +0300 Subject: [PATCH 34/42] extract readOverrides --- packages/flags/src/next/evaluate.ts | 34 ++++++++++++++--------------- 1 file changed, 17 insertions(+), 17 deletions(-) diff --git a/packages/flags/src/next/evaluate.ts b/packages/flags/src/next/evaluate.ts index d0cab0a5..e6a6c67f 100644 --- a/packages/flags/src/next/evaluate.ts +++ b/packages/flags/src/next/evaluate.ts @@ -157,6 +157,20 @@ async function getEntities( return identify(...(nextArgs as [FlagParamsType])); } +/** + * Reads and decrypts the `vercel-flag-overrides` cookie. Returns `null` when + * the cookie is absent or empty (skipping the decrypt microtask). + */ +function readOverrides( + cookies: ReadonlyRequestCookies, +): Promise | null> { + // skip microtask if cookie does not exist or is empty + const override = cookies.get('vercel-flag-overrides')?.value; + return typeof override === 'string' && override !== '' + ? getOverrides(override) + : Promise.resolve(null); +} + interface BulkStoreData { headers: ReadonlyHeaders; cookies: ReadonlyRequestCookies; @@ -323,12 +337,7 @@ export function getRun( 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; + overrides = await readOverrides(readonlyCookies); } else if (bulkData) { // app router — evaluate() mode, everything pre-read readonlyHeaders = bulkData.headers; @@ -355,12 +364,7 @@ export function getRun( readonlyCookies = cookiesStore as ReadonlyRequestCookies; dedupeCacheKey = headersStore; - // skip microtask if cookie does not exist or is empty - const override = readonlyCookies.get('vercel-flag-overrides')?.value; - overrides = - typeof override === 'string' && override !== '' - ? await getOverrides(override) - : null; + overrides = await readOverrides(readonlyCookies); } // the flag is being used in app router @@ -476,11 +480,7 @@ export async function evaluate( } // Read overrides once - const override = readonlyCookies.get('vercel-flag-overrides')?.value; - const overrides = - typeof override === 'string' && override !== '' - ? await getOverrides(override) - : null; + const overrides = await readOverrides(readonlyCookies); const storeData: BulkStoreData = { headers: readonlyHeaders, From 7da325cd472f7eecec884730ec7d5ab00bfb5b8e Mon Sep 17 00:00:00 2001 From: Dominik Ferber Date: Fri, 29 May 2026 17:21:26 +0300 Subject: [PATCH 35/42] fix import of reportValue --- packages/flags/src/next/evaluate.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/flags/src/next/evaluate.ts b/packages/flags/src/next/evaluate.ts index e6a6c67f..b1fe4760 100644 --- a/packages/flags/src/next/evaluate.ts +++ b/packages/flags/src/next/evaluate.ts @@ -1,8 +1,7 @@ import { AsyncLocalStorage } from 'node:async_hooks'; import type { IncomingHttpHeaders } from 'node:http'; import { RequestCookies } from '@edge-runtime/cookies'; -import { reportValue } from '../lib/report-value'; -import { internalReportValue } from '../lib/report-value'; +import { internalReportValue, reportValue } from '../lib/report-value'; import { setSpanAttribute } from '../lib/tracing'; import { HeadersAdapter, From d39f8f32b8d97dfc56017c712714219a47f8be1f Mon Sep 17 00:00:00 2001 From: Dominik Ferber Date: Fri, 29 May 2026 18:53:28 +0300 Subject: [PATCH 36/42] tracing --- packages/flags/src/next/evaluate.ts | 200 ++++++++++++++-------- packages/flags/src/next/index.test.ts | 237 +++++++++++++++++++++++++- 2 files changed, 367 insertions(+), 70 deletions(-) diff --git a/packages/flags/src/next/evaluate.ts b/packages/flags/src/next/evaluate.ts index b1fe4760..47417031 100644 --- a/packages/flags/src/next/evaluate.ts +++ b/packages/flags/src/next/evaluate.ts @@ -2,7 +2,7 @@ import { AsyncLocalStorage } from 'node:async_hooks'; import type { IncomingHttpHeaders } from 'node:http'; import { RequestCookies } from '@edge-runtime/cookies'; import { internalReportValue, reportValue } from '../lib/report-value'; -import { setSpanAttribute } from '../lib/tracing'; +import { setSpanAttribute, trace } from '../lib/tracing'; import { HeadersAdapter, type ReadonlyHeaders, @@ -424,15 +424,35 @@ type EvaluateRequest = PagesRouterRequest | Request; * `evaluate()` reads from `next/headers`, which is only available in App * Router and routing middleware. */ -export async function evaluate[]>( +export function evaluate[]>( flags: T, request?: EvaluateRequest, ): Promise<{ [K in keyof T]: BulkValue }>; -export async function evaluate>>( +export function evaluate>>( flags: T, request?: EvaluateRequest, ): Promise<{ [K in keyof T]: BulkValue }>; -export async function evaluate( +export function evaluate( + flags: Record> | readonly Flag[], + request?: EvaluateRequest, +): Promise { + // Non-async wrapper so the returned promise is the traced one verbatim — no + // extra microtask. `trace` short-circuits to `evaluateImpl` when no tracer + // is registered. + return tracedEvaluate(flags, request); +} + +const tracedEvaluate = trace(evaluateImpl, { + name: 'evaluate', + isVerboseTrace: false, + attributesSuccess: (result) => ({ + flagCount: Array.isArray(result) + ? result.length + : Object.keys(result).length, + }), +}); + +async function evaluateImpl( flags: Record> | readonly Flag[], request?: EvaluateRequest, ): Promise { @@ -529,76 +549,118 @@ export async function evaluate( } const valuesByName: Record = {}; - const groupPromises: Promise[] = []; + const groupPromises: Promise[] = []; for (const byIdentify of groups.values()) { for (const [identifyRef, { adapter, entries: list }] of byIdentify) { groupPromises.push( - (async () => { - // Resolve entities once for the entire group. The dedupe key is - // the same one `getRun` uses (`request.headers` for Pages Router, - // the `headers()` store for App Router), so any flag called - // individually before/after `evaluate()` reuses the cached - // identify args from `identifyArgsMap`. - const entities = identifyRef - ? await getEntities( - identifyRef as any, - dedupeCacheKey, - readonlyHeaders, - readonlyCookies, - ) - : undefined; - const entitiesKey = JSON.stringify(entities) ?? ''; - - // Skip flags already resolved this request — `applyResult` would - // discard the bulk result for them anyway. If every flag in the - // group is cached, the adapter call is avoided entirely. - const uncached = list.filter( - ({ flagFn }) => - getCachedValuePromise( - readonlyHeaders, - flagFn.key, - entitiesKey, - ) === undefined, - ); - - // Call bulkDecide. If it throws, every uncached flag still goes - // through `applyResult` — its producer just rethrows, so the - // catch arm handles the per-flag defaultValue fallback (or - // rejects for flags without a defaultValue). - let bulkResult: Record | null = null; - let bulkError: unknown = null; - if (uncached.length > 0) { - try { - bulkResult = await adapter.bulkDecide!({ - flags: uncached.map(({ flagFn }) => ({ - key: flagFn.key, - defaultValue: flagFn.defaultValue, - })), - entities, - headers: readonlyHeaders, - cookies: readonlyCookies, - }); - } catch (err) { - bulkError = err; + // One `batch` span per bulk-evaluated group (a batch being a single + // group within the overall `evaluate()` bulk), replacing the + // per-flag `run` span that bulkable flags would otherwise get via + // `flagFn()`. A per-flag span here would reintroduce the per-flag + // instrumentation overhead (closure + span + microtask) that bulk + // evaluation exists to avoid, so the batch reports an aggregate + // `method`/count summary instead. Standalone flags still emit their + // own `flag` span. + trace( + async () => { + // Resolve entities once for the entire group. The dedupe key is + // the same one `getRun` uses (`request.headers` for Pages Router, + // the `headers()` store for App Router), so any flag called + // individually before/after `evaluate()` reuses the cached + // identify args from `identifyArgsMap`. + const entities = identifyRef + ? await getEntities( + identifyRef as any, + dedupeCacheKey, + readonlyHeaders, + readonlyCookies, + ) + : undefined; + const entitiesKey = JSON.stringify(entities) ?? ''; + + // Skip flags already resolved this request — `applyResult` would + // discard the bulk result for them anyway. If every flag in the + // group is cached, the adapter call is avoided entirely. + const uncached = list.filter( + ({ flagFn }) => + getCachedValuePromise( + readonlyHeaders, + flagFn.key, + entitiesKey, + ) === undefined, + ); + + // Call bulkDecide. If it throws, every uncached flag still goes + // through `applyResult` — its producer just rethrows, so the + // catch arm handles the per-flag defaultValue fallback (or + // rejects for flags without a defaultValue). + let bulkResult: Record | null = null; + let bulkError: unknown = null; + if (uncached.length > 0) { + try { + bulkResult = await adapter.bulkDecide!({ + flags: uncached.map(({ flagFn }) => ({ + key: flagFn.key, + defaultValue: flagFn.defaultValue, + })), + entities, + headers: readonlyHeaders, + cookies: readonlyCookies, + }); + } catch (err) { + bulkError = err; + } } - } - - await Promise.all( - list.map(async ({ name, flagFn }) => { - valuesByName[name] = await applyResult({ - definition: flagFn, - readonlyHeaders, - entitiesKey, - overrides, - produce: () => { - if (bulkError) throw bulkError; - return bulkResult![flagFn.key]; - }, - }); - }), - ); - })(), + + 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]; + }, + }); + }), + ); + + // `applyResult` stamps a per-flag `method` onto the active span; + // here that span is shared by the whole group, so overwrite it + // with `bulk`. `trace` flushes the span-context store last, so + // this final write wins over the per-flag ones. The per-flag + // breakdown is reported as counts via `attributesSuccess`. + setSpanAttribute('method', 'bulk'); + + // Returned so the span can derive aggregate counts lazily — + // `attributesSuccess` only runs when a tracer is registered, so + // nothing here costs anything on the untraced hot path. + return uncached; + }, + { + name: 'batch', + isVerboseTrace: false, + attributes: { adapterId: String(adapter.adapterId) }, + attributesSuccess: (uncached) => { + const cachedCount = list.length - uncached.length; + let overrideCount = 0; + if (overrides) { + for (const { flagFn } of uncached) { + if (overrides[flagFn.key] !== undefined) overrideCount++; + } + } + return { + keys: list.map(({ flagFn }) => flagFn.key), + cachedCount, + overrideCount, + decidedCount: uncached.length - overrideCount, + }; + }, + }, + )(), ); } } diff --git a/packages/flags/src/next/index.test.ts b/packages/flags/src/next/index.test.ts index 0d84e1e7..61831f06 100644 --- a/packages/flags/src/next/index.test.ts +++ b/packages/flags/src/next/index.test.ts @@ -3,7 +3,7 @@ import type { Socket } from 'node:net'; import { Readable } from 'node:stream'; import type { NextApiRequestCookies } from 'next/dist/server/api-utils'; import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest'; -import { type Adapter, encryptOverrides } from '..'; +import { type Adapter, encryptOverrides, setTracerProvider } from '..'; import { clearDedupeCacheForCurrentRequest, dedupe, @@ -1094,3 +1094,238 @@ describe('evaluate', () => { }); }); }); + +describe('tracing', () => { + beforeAll(() => { + process.env.FLAGS_SECRET = 'yuhyxaVI0Zue85SguKlMIUQojvJyBPzm95fFYvOa4Rc'; + }); + + // `setTracerProvider` writes to a global symbol; capture/restore it so a + // registered tracer doesn't leak into other test files. + const traceSymbol = Symbol.for('flags:global-trace'); + const previousTraceProvider = Reflect.get(globalThis, traceSymbol); + + afterEach(() => { + if (previousTraceProvider === undefined) { + Reflect.deleteProperty(globalThis, traceSymbol); + } else { + Reflect.set(globalThis, traceSymbol, previousTraceProvider); + } + if (previousRequestContext === undefined) { + Reflect.deleteProperty(globalThis, requestContextSymbol); + } else { + Reflect.set(globalThis, requestContextSymbol, previousRequestContext); + } + }); + + interface RecordedSpan { + name: string; + attributes: Record; + status?: { code: number; message?: string }; + ended: boolean; + } + + // Minimal recording TracerProvider. `trace()` only needs + // `getTracer().startActiveSpan(name, fn)` plus + // `setAttribute(s)`/`setStatus`/`end` on the span, so we record just those. + function recordSpans(): RecordedSpan[] { + const spans: RecordedSpan[] = []; + const tracer = { + startActiveSpan(name: string, fn: (span: any) => any) { + const record: RecordedSpan = { name, attributes: {}, ended: false }; + spans.push(record); + return fn({ + setAttributes(attrs: Record) { + Object.assign(record.attributes, attrs); + }, + setAttribute(key: string, value: unknown) { + record.attributes[key] = value; + }, + setStatus(status: { code: number; message?: string }) { + record.status = status; + }, + end() { + record.ended = true; + }, + }); + }, + }; + setTracerProvider({ getTracer: () => tracer } as any); + return spans; + } + + function makeBulkAdapter(opts: { + bulkDecide: Adapter['bulkDecide']; + }) { + const id = Symbol('trace-adapter'); + return (): Adapter => ({ + adapterId: id, + origin: 'test://origin', + decide: () => { + throw new Error('decide should not be called in bulk path'); + }, + bulkDecide: opts.bulkDecide, + }); + } + + it('emits an evaluate span and a batch span with aggregate attributes', async () => { + const adapter = makeBulkAdapter({ + bulkDecide: vi.fn().mockResolvedValue({ a: 'A', b: 'B' }), + }); + const a = flag({ key: 'a', adapter: adapter() }); + const b = flag({ key: 'b', adapter: adapter() }); + + const spans = recordSpans(); + mocks.headers.mockReturnValueOnce(new Headers()); + await expect(evaluate({ a, b })).resolves.toEqual({ a: 'A', b: 'B' }); + + const evaluateSpan = spans.find((s) => s.name === 'evaluate'); + expect(evaluateSpan).toBeDefined(); + expect(evaluateSpan!.attributes.flagCount).toBe(2); + expect(evaluateSpan!.ended).toBe(true); + + const batchSpans = spans.filter((s) => s.name === 'batch'); + expect(batchSpans).toHaveLength(1); + const [batchSpan] = batchSpans; + expect(batchSpan!.attributes).toMatchObject({ + method: 'bulk', + keys: ['a', 'b'], + cachedCount: 0, + overrideCount: 0, + decidedCount: 2, + }); + expect(typeof batchSpan!.attributes.adapterId).toBe('string'); + expect(batchSpan!.ended).toBe(true); + + // Bulkable flags must not also emit their own per-flag `run`/`flag` span — + // that's the per-flag overhead the batch span exists to replace. + expect(spans.some((s) => s.name === 'run')).toBe(false); + expect(spans.some((s) => s.name === 'flag')).toBe(false); + }); + + it('keeps the per-flag `flag` span for standalone (non-bulkable) flags', async () => { + const a = flag({ key: 'a', decide: () => 'inline' }); + + const spans = recordSpans(); + mocks.headers.mockReturnValueOnce(new Headers()); + await expect(evaluate({ a })).resolves.toEqual({ a: 'inline' }); + + expect(spans.some((s) => s.name === 'evaluate')).toBe(true); + expect(spans.some((s) => s.name === 'batch')).toBe(false); + // standalone flags resolve via `flagFn()`, which still emits a `flag` span + expect(spans.some((s) => s.name === 'flag')).toBe(true); + }); + + it('counts overrides separately from decided flags on the batch span', async () => { + const adapter = makeBulkAdapter({ + bulkDecide: vi.fn().mockResolvedValue({ a: 'A', b: 'B' }), + }); + const a = flag({ key: 'a', adapter: adapter() }); + const b = flag({ key: 'b', adapter: adapter() }); + + const override = await encryptOverrides({ a: 'overridden' }); + const cookieMock = vi.fn((name: string) => + name === 'vercel-flag-overrides' + ? { name: 'vercel-flag-overrides', value: override } + : undefined, + ); + + const spans = recordSpans(); + mocks.headers.mockReturnValueOnce(new Headers()); + mocks.cookies.mockReturnValueOnce({ get: cookieMock }); + await expect(evaluate({ a, b })).resolves.toEqual({ + a: 'overridden', + b: 'B', + }); + + const batch = spans.find((s) => s.name === 'batch'); + expect(batch).toBeDefined(); + expect(batch!.attributes).toMatchObject({ + cachedCount: 0, + overrideCount: 1, + decidedCount: 1, + }); + }); + + it('emits a flag span with the key and method for a direct app-router call', async () => { + const a = flag({ key: 'my-flag', decide: () => 'value' }); + + const spans = recordSpans(); + mocks.headers.mockReturnValueOnce(new Headers()); + await expect(a()).resolves.toBe('value'); + + const flagSpan = spans.find((s) => s.name === 'flag'); + expect(flagSpan).toBeDefined(); + expect(flagSpan!.attributes).toMatchObject({ + key: 'my-flag', + method: 'decided', + }); + expect(flagSpan!.ended).toBe(true); + }); + + it('records method "override" on the flag span when an override is set', async () => { + const a = flag({ key: 'my-flag', decide: () => 'value' }); + + const override = await encryptOverrides({ 'my-flag': 'forced' }); + const cookieMock = vi.fn((name: string) => + name === 'vercel-flag-overrides' + ? { name: 'vercel-flag-overrides', value: override } + : undefined, + ); + + const spans = recordSpans(); + mocks.headers.mockReturnValueOnce(new Headers()); + mocks.cookies.mockReturnValueOnce({ get: cookieMock }); + await expect(a()).resolves.toBe('forced'); + + const flagSpan = spans.find((s) => s.name === 'flag'); + expect(flagSpan!.attributes).toMatchObject({ + key: 'my-flag', + method: 'override', + }); + }); + + it('records method "cached" on a repeated call within the same request', async () => { + const decide = vi.fn(() => 'value'); + const a = flag({ key: 'my-flag', decide }); + + // Same headers object both calls → same per-request cache key. + const headers = new Headers(); + mocks.headers.mockReturnValueOnce(headers).mockReturnValueOnce(headers); + + const spans = recordSpans(); + await expect(a()).resolves.toBe('value'); + await expect(a()).resolves.toBe('value'); + expect(decide).toHaveBeenCalledTimes(1); + + const methods = spans + .filter((s) => s.name === 'flag') + .map((s) => s.attributes.method); + expect(methods).toEqual(['decided', 'cached']); + }); + + it('precompute emits the same evaluate and batch spans as evaluate', async () => { + const adapter = makeBulkAdapter({ + bulkDecide: vi.fn().mockResolvedValue({ a: 'A', b: 'B' }), + }); + const a = flag({ key: 'a', adapter: adapter() }); + const b = flag({ key: 'b', adapter: adapter() }); + + const spans = recordSpans(); + mocks.headers.mockReturnValueOnce(new Headers()); + await precompute([a, b]); + + const evaluateSpan = spans.find((s) => s.name === 'evaluate'); + expect(evaluateSpan).toBeDefined(); + // array overload → `flagCount` reflects the array length + expect(evaluateSpan!.attributes.flagCount).toBe(2); + + const batch = spans.find((s) => s.name === 'batch'); + expect(batch).toBeDefined(); + expect(batch!.attributes).toMatchObject({ + method: 'bulk', + keys: ['a', 'b'], + decidedCount: 2, + }); + }); +}); From 8e451a906a33eaab25cdc443ade91c70e6465ef9 Mon Sep 17 00:00:00 2001 From: Dominik Ferber Date: Wed, 3 Jun 2026 16:12:14 +0300 Subject: [PATCH 37/42] skip bulkDecide for overwritten flags --- .changeset/flags-evaluate.md | 2 + packages/flags/src/next/evaluate.ts | 51 ++++++++++------- packages/flags/src/next/index.test.ts | 82 +++++++++++++++++++++++++++ 3 files changed, 115 insertions(+), 20 deletions(-) diff --git a/.changeset/flags-evaluate.md b/.changeset/flags-evaluate.md index 5e845d8c..4fc90c3e 100644 --- a/.changeset/flags-evaluate.md +++ b/.changeset/flags-evaluate.md @@ -20,3 +20,5 @@ const { a, b } = await evaluate({ a: flagA, b: flagB }); ``` Adapters can opt into bulk evaluation by implementing a `bulkDecide` method and setting a stable `adapterId`. When both are present, flag evaluation 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 still resolve through the normal per-flag path inside `evaluate()` and still benefit from now reusing the shared per-request headers, cookies, and overrides reads. + +Tracing reflects this grouping. `evaluate()` (and therefore `precompute()`) now emits an `evaluate` span carrying a `flagCount` attribute. Within it, bulk-evaluated flags no longer emit an individual per-flag `run` span; instead each adapter group emits a single `batch` span (carrying the `adapterId`, the `keys` evaluated in the batch, and `cachedCount`/`overrideCount`/`decidedCount` attributes summarizing how the batch resolved) so per-flag instrumentation overhead is not reintroduced. Flags that fall back to the per-flag path continue to emit their own `flag` span as before. diff --git a/packages/flags/src/next/evaluate.ts b/packages/flags/src/next/evaluate.ts index 47417031..0269525a 100644 --- a/packages/flags/src/next/evaluate.ts +++ b/packages/flags/src/next/evaluate.ts @@ -192,8 +192,23 @@ type FlagInfo = { key: string; defaultValue?: ValueType; config?: { reportValue?: boolean }; + adapter?: { config?: { reportValue?: boolean } }; }; +function hasOverride( + overrides: Record | null, + key: string, +): boolean { + return overrides !== null && overrides[key] !== undefined; +} + +function shouldReportValue(definition: FlagInfo): boolean { + return ( + (definition.config?.reportValue ?? + definition.adapter?.config?.reportValue) !== false + ); +} + /** * Finalize a flag evaluation given an already-computed `entitiesKey`. * @@ -222,9 +237,9 @@ async function applyResult(args: { return await cachedValue; } - if (overrides && overrides[definition.key] !== undefined) { + if (hasOverride(overrides, definition.key)) { setSpanAttribute('method', 'override'); - const decision = overrides[definition.key] as ValueType; + const decision = overrides![definition.key] as ValueType; setCachedValuePromise( readonlyHeaders, definition.key, @@ -289,10 +304,8 @@ async function applyResult(args: { 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. + if (shouldReportValue(definition)) { + // Overrides return before this point and report with `reason: "override"`. reportValue(definition.key, decision); } @@ -580,8 +593,7 @@ async function evaluateImpl( const entitiesKey = JSON.stringify(entities) ?? ''; // Skip flags already resolved this request — `applyResult` would - // discard the bulk result for them anyway. If every flag in the - // group is cached, the adapter call is avoided entirely. + // discard the bulk result for them anyway. const uncached = list.filter( ({ flagFn }) => getCachedValuePromise( @@ -590,17 +602,21 @@ async function evaluateImpl( entitiesKey, ) === undefined, ); + const undecided = uncached.filter( + ({ flagFn }) => !hasOverride(overrides, flagFn.key), + ); - // Call bulkDecide. If it throws, every uncached flag still goes + // Call bulkDecide only for flags that are neither cached nor + // overridden. If it throws, every undecided flag still goes // through `applyResult` — its producer just rethrows, so the // catch arm handles the per-flag defaultValue fallback (or // rejects for flags without a defaultValue). let bulkResult: Record | null = null; let bulkError: unknown = null; - if (uncached.length > 0) { + if (undecided.length > 0) { try { bulkResult = await adapter.bulkDecide!({ - flags: uncached.map(({ flagFn }) => ({ + flags: undecided.map(({ flagFn }) => ({ key: flagFn.key, defaultValue: flagFn.defaultValue, })), @@ -638,25 +654,20 @@ async function evaluateImpl( // Returned so the span can derive aggregate counts lazily — // `attributesSuccess` only runs when a tracer is registered, so // nothing here costs anything on the untraced hot path. - return uncached; + return { uncached, undecided }; }, { name: 'batch', isVerboseTrace: false, attributes: { adapterId: String(adapter.adapterId) }, - attributesSuccess: (uncached) => { + attributesSuccess: ({ uncached, undecided }) => { const cachedCount = list.length - uncached.length; - let overrideCount = 0; - if (overrides) { - for (const { flagFn } of uncached) { - if (overrides[flagFn.key] !== undefined) overrideCount++; - } - } + const overrideCount = uncached.length - undecided.length; return { keys: list.map(({ flagFn }) => flagFn.key), cachedCount, overrideCount, - decidedCount: uncached.length - overrideCount, + decidedCount: undecided.length, }; }, }, diff --git a/packages/flags/src/next/index.test.ts b/packages/flags/src/next/index.test.ts index 61831f06..0462b836 100644 --- a/packages/flags/src/next/index.test.ts +++ b/packages/flags/src/next/index.test.ts @@ -264,6 +264,57 @@ describe('flag on app router', () => { ]); }); + it('honors adapter-level reportValue false', async () => { + const requestContext = createRequestContext(); + Reflect.set(globalThis, requestContextSymbol, { + get() { + return requestContext; + }, + }); + + const f = flag({ + key: 'first-flag', + adapter: { + config: { reportValue: false }, + decide: () => true, + }, + }); + + mocks.headers.mockReturnValueOnce(new Headers()); + await expect(f()).resolves.toEqual(true); + expect(requestContext.flags.calls).toEqual([]); + }); + + it('lets flag-level reportValue override adapter config', async () => { + const requestContext = createRequestContext(); + Reflect.set(globalThis, requestContextSymbol, { + get() { + return requestContext; + }, + }); + + const f = flag({ + key: 'first-flag', + config: { reportValue: true }, + adapter: { + config: { reportValue: false }, + decide: () => true, + }, + }); + + mocks.headers.mockReturnValueOnce(new Headers()); + await expect(f()).resolves.toEqual(true); + expect(requestContext.flags.calls).toEqual([ + { + key: 'first-flag', + value: true, + data: expect.objectContaining({ + sdkVersion: expect.any(String), + }), + }, + ]); + }); + it('preserves method binding for override reporting hooks', async () => { const requestContext = createRequestContext(); Reflect.set(globalThis, requestContextSymbol, { @@ -978,6 +1029,37 @@ describe('evaluate', () => { mocks.cookies.mockReturnValueOnce({ get: cookieMock }); await expect(evaluate({ a })).resolves.toEqual({ a: true }); + expect(bulkDecideMock).not.toHaveBeenCalled(); + }); + + it('omits overridden flags from bulkDecide input', async () => { + const bulkDecideMock = vi.fn(({ flags }: { flags: { key: string }[] }) => + Object.fromEntries(flags.map((f) => [f.key, `bulk-${f.key}`])), + ); + const adapter = makeBulkAdapter({ bulkDecide: bulkDecideMock }); + + const a = flag({ key: 'a', adapter: adapter() }); + const b = flag({ key: 'b', adapter: adapter() }); + + const override = await encryptOverrides({ a: 'overridden' }); + 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(evaluate({ a, b })).resolves.toEqual({ + a: 'overridden', + b: 'bulk-b', + }); + expect(bulkDecideMock).toHaveBeenCalledTimes(1); + expect(bulkDecideMock).toHaveBeenCalledWith( + expect.objectContaining({ + flags: [{ key: 'b', defaultValue: undefined }], + }), + ); }); it('populates the evaluation cache so a subsequent flagFn() hits cache', async () => { From 574ea99ccb549fb99a1f4a678ffce1c4f1994422 Mon Sep 17 00:00:00 2001 From: Dominik Ferber Date: Wed, 3 Jun 2026 16:18:37 +0300 Subject: [PATCH 38/42] update changesets --- package.json | 4 +- pnpm-lock.yaml | 155 +++++++++++++++++++++++++++++++++++-------------- 2 files changed, 114 insertions(+), 45 deletions(-) diff --git a/package.json b/package.json index 04ec9a6e..aa2c5687 100644 --- a/package.json +++ b/package.json @@ -44,8 +44,8 @@ }, "devDependencies": { "@biomejs/biome": "^2.4.6", - "@changesets/changelog-github": "^0.6.0", - "@changesets/cli": "2.29.8", + "@changesets/changelog-github": "^0.7.0", + "@changesets/cli": "2.31.0", "@types/node": "22.9.0", "gray-matter": "4.0.3", "husky": "9.0.10", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e9caede8..9129597d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -21,11 +21,11 @@ importers: specifier: ^2.4.6 version: 2.4.6 '@changesets/changelog-github': - specifier: ^0.6.0 - version: 0.6.0 + specifier: ^0.7.0 + version: 0.7.0 '@changesets/cli': - specifier: 2.29.8 - version: 2.29.8(@types/node@22.9.0) + specifier: 2.31.0 + version: 2.31.0(@types/node@22.9.0) '@types/node': specifier: 22.9.0 version: 22.9.0 @@ -1238,24 +1238,28 @@ packages: engines: {node: '>=14.21.3'} cpu: [arm64] os: [linux] + libc: [musl] '@biomejs/cli-linux-arm64@2.4.6': resolution: {integrity: sha512-kMLaI7OF5GN1Q8Doymjro1P8rVEoy7BKQALNz6fiR8IC1WKduoNyteBtJlHT7ASIL0Cx2jR6VUOBIbcB1B8pew==} engines: {node: '>=14.21.3'} cpu: [arm64] os: [linux] + libc: [glibc] '@biomejs/cli-linux-x64-musl@2.4.6': resolution: {integrity: sha512-C9s98IPDu7DYarjlZNuzJKTjVHN03RUnmHV5htvqsx6vEUXCDSJ59DNwjKVD5XYoSS4N+BYhq3RTBAL8X6svEg==} engines: {node: '>=14.21.3'} cpu: [x64] os: [linux] + libc: [musl] '@biomejs/cli-linux-x64@2.4.6': resolution: {integrity: sha512-oHXmUFEoH8Lql1xfc3QkFLiC1hGR7qedv5eKNlC185or+o4/4HiaU7vYODAH3peRCfsuLr1g6v2fK9dFFOYdyw==} engines: {node: '>=14.21.3'} cpu: [x64] os: [linux] + libc: [glibc] '@biomejs/cli-win32-arm64@2.4.6': resolution: {integrity: sha512-xzThn87Pf3YrOGTEODFGONmqXpTwUNxovQb72iaUOdcw8sBSY3+3WD8Hm9IhMYLnPi0n32s3L3NWU6+eSjfqFg==} @@ -1284,36 +1288,36 @@ packages: '@bundled-es-modules/tough-cookie@0.1.6': resolution: {integrity: sha512-dvMHbL464C0zI+Yqxbz6kZ5TOEp7GLW+pry/RWndAR8MJQAXZ2rPmIs8tziTZjeIyhSNZgZbCePtfSbdWqStJw==} - '@changesets/apply-release-plan@7.1.0': - resolution: {integrity: sha512-yq8ML3YS7koKQ/9bk1PqO0HMzApIFNwjlwCnwFEXMzNe8NpzeeYYKCmnhWJGkN8g7E51MnWaSbqRcTcdIxUgnQ==} + '@changesets/apply-release-plan@7.1.1': + resolution: {integrity: sha512-9qPCm/rLx/xoOFXIHGB229+4GOL76S4MC+7tyOuTsR6+1jYlfFDQORdvwR5hDA6y4FL2BPt3qpbcQIS+dW85LA==} - '@changesets/assemble-release-plan@6.0.9': - resolution: {integrity: sha512-tPgeeqCHIwNo8sypKlS3gOPmsS3wP0zHt67JDuL20P4QcXiw/O4Hl7oXiuLnP9yg+rXLQ2sScdV1Kkzde61iSQ==} + '@changesets/assemble-release-plan@6.0.10': + resolution: {integrity: sha512-rSDcqdJ9KbVyjpBIuCidhvZNIiVt1XaIYp73ycVQRIA5n/j6wQaEk0ChRLMUQ1vkxZe51PTQ9OIhbg6HQMW45A==} '@changesets/changelog-git@0.2.1': resolution: {integrity: sha512-x/xEleCFLH28c3bQeQIyeZf8lFXyDFVn1SgcBiR2Tw/r4IAWlk1fzxCEZ6NxQAjF2Nwtczoen3OA2qR+UawQ8Q==} - '@changesets/changelog-github@0.6.0': - resolution: {integrity: sha512-wA2/y4hR/A1K411cCT75rz0d46Iezxp1WYRFoFJDIUpkQ6oDBAIUiU7BZkDCmYgz0NBl94X1lgcZO+mHoiHnFg==} + '@changesets/changelog-github@0.7.0': + resolution: {integrity: sha512-rBsbRvc4TVn+FvFnOVM3LxlFJfTXXCp8gfVJ+0BubxWNSVnLuAzowi5j+IEraLLP52w8AAs9QfKbPS3MMiXQJA==} - '@changesets/cli@2.29.8': - resolution: {integrity: sha512-1weuGZpP63YWUYjay/E84qqwcnt5yJMM0tep10Up7Q5cS/DGe2IZ0Uj3HNMxGhCINZuR7aO9WBMdKnPit5ZDPA==} + '@changesets/cli@2.31.0': + resolution: {integrity: sha512-AhI4enNTgHu2IZr6K4WZyf0EPch4XVMn1yOMFmCD9gsfBGqMYaHXls5HyDv6/CL5axVQABz68eG30eCtbr2wFg==} hasBin: true - '@changesets/config@3.1.3': - resolution: {integrity: sha512-vnXjcey8YgBn2L1OPWd3ORs0bGC4LoYcK/ubpgvzNVr53JXV5GiTVj7fWdMRsoKUH7hhhMAQnsJUqLr21EncNw==} + '@changesets/config@3.1.4': + resolution: {integrity: sha512-pf0bvD/v6WI2cRlZ6hzpjtZdSlXDXMAJ+Iz7xfFzV4ZxJ8OGGAON+1qYc99ZPrijnt4xp3VGG7eNvAOGS24V1Q==} '@changesets/errors@0.2.0': resolution: {integrity: sha512-6BLOQUscTpZeGljvyQXlWOItQyU71kCdGz7Pi8H8zdw6BI0g3m43iL4xKUVPWtG+qrrL9DTjpdn8eYuCQSRpow==} - '@changesets/get-dependents-graph@2.1.3': - resolution: {integrity: sha512-gphr+v0mv2I3Oxt19VdWRRUxq3sseyUpX9DaHpTUmLj92Y10AGy+XOtV+kbM6L/fDcpx7/ISDFK6T8A/P3lOdQ==} + '@changesets/get-dependents-graph@2.1.4': + resolution: {integrity: sha512-ZsS00x6WvmHq3sQv8oCMwL0f/z3wbXCVuSVTJwCnnmbC/iBdNJGFx1EcbMG4PC6sXRyH69liM4A2WKXzn/kRPg==} '@changesets/get-github-info@0.8.0': resolution: {integrity: sha512-cRnC+xdF0JIik7coko3iUP9qbnfi1iJQ3sAa6dE+Tx3+ET8bjFEm63PA4WEohgjYcmsOikPHWzPsMWWiZmntOQ==} - '@changesets/get-release-plan@4.0.15': - resolution: {integrity: sha512-Q04ZaRPuEVZtA+auOYgFaVQQSA98dXiVe/yFaZfY7hoSmQICHGvP0TF4u3EDNHWmmCS4ekA/XSpKlSM2PyTS2g==} + '@changesets/get-release-plan@4.0.16': + resolution: {integrity: sha512-2K5Om6CrMPm45rtvckfzWo7e9jOVCKLCnXia5eUPaURH7/LWzri7pK1TycdzAuAtehLkW7VPbWLCSExTHmiI6g==} '@changesets/get-version-range-type@0.4.0': resolution: {integrity: sha512-hwawtob9DryoGTpixy1D3ZXbGgJu1Rhr+ySH2PvTLHvkZuQ7sRT4oQwMh0hbqZH1weAooedEjRsbrWcGLCeyVQ==} @@ -2279,89 +2283,105 @@ packages: resolution: {integrity: sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==} cpu: [arm64] os: [linux] + libc: [glibc] '@img/sharp-libvips-linux-arm@1.2.4': resolution: {integrity: sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==} cpu: [arm] os: [linux] + libc: [glibc] '@img/sharp-libvips-linux-ppc64@1.2.4': resolution: {integrity: sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==} cpu: [ppc64] os: [linux] + libc: [glibc] '@img/sharp-libvips-linux-riscv64@1.2.4': resolution: {integrity: sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==} cpu: [riscv64] os: [linux] + libc: [glibc] '@img/sharp-libvips-linux-s390x@1.2.4': resolution: {integrity: sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==} cpu: [s390x] os: [linux] + libc: [glibc] '@img/sharp-libvips-linux-x64@1.2.4': resolution: {integrity: sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==} cpu: [x64] os: [linux] + libc: [glibc] '@img/sharp-libvips-linuxmusl-arm64@1.2.4': resolution: {integrity: sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==} cpu: [arm64] os: [linux] + libc: [musl] '@img/sharp-libvips-linuxmusl-x64@1.2.4': resolution: {integrity: sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==} cpu: [x64] os: [linux] + libc: [musl] '@img/sharp-linux-arm64@0.34.5': resolution: {integrity: sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [arm64] os: [linux] + libc: [glibc] '@img/sharp-linux-arm@0.34.5': resolution: {integrity: sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [arm] os: [linux] + libc: [glibc] '@img/sharp-linux-ppc64@0.34.5': resolution: {integrity: sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [ppc64] os: [linux] + libc: [glibc] '@img/sharp-linux-riscv64@0.34.5': resolution: {integrity: sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [riscv64] os: [linux] + libc: [glibc] '@img/sharp-linux-s390x@0.34.5': resolution: {integrity: sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [s390x] os: [linux] + libc: [glibc] '@img/sharp-linux-x64@0.34.5': resolution: {integrity: sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [x64] os: [linux] + libc: [glibc] '@img/sharp-linuxmusl-arm64@0.34.5': resolution: {integrity: sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [arm64] os: [linux] + libc: [musl] '@img/sharp-linuxmusl-x64@0.34.5': resolution: {integrity: sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [x64] os: [linux] + libc: [musl] '@img/sharp-wasm32@0.34.5': resolution: {integrity: sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==} @@ -2590,96 +2610,112 @@ packages: engines: {node: '>= 10'} cpu: [arm64] os: [linux] + libc: [glibc] '@next/swc-linux-arm64-gnu@16.1.5': resolution: {integrity: sha512-qNIb42o3C02ccIeSeKjacF3HXotGsxh/FMk/rSRmCzOVMtoWH88odn2uZqF8RLsSUWHcAqTgYmPD3pZ03L9ZAA==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] + libc: [glibc] '@next/swc-linux-arm64-gnu@16.1.6': resolution: {integrity: sha512-OJYkCd5pj/QloBvoEcJ2XiMnlJkRv9idWA/j0ugSuA34gMT6f5b7vOiCQHVRpvStoZUknhl6/UxOXL4OwtdaBw==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] + libc: [glibc] '@next/swc-linux-arm64-gnu@16.2.0': resolution: {integrity: sha512-GkjL/Q7MWOwqWR9zoxu1TIHzkOI2l2BHCf7FzeQG87zPgs+6WDh+oC9Sw9ARuuL/FUk6JNCgKRkA6rEQYadUaw==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] + libc: [glibc] '@next/swc-linux-arm64-musl@15.5.7': resolution: {integrity: sha512-nfymt+SE5cvtTrG9u1wdoxBr9bVB7mtKTcj0ltRn6gkP/2Nu1zM5ei8rwP9qKQP0Y//umK+TtkKgNtfboBxRrw==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] + libc: [musl] '@next/swc-linux-arm64-musl@16.1.5': resolution: {integrity: sha512-U+kBxGUY1xMAzDTXmuVMfhaWUZQAwzRaHJ/I6ihtR5SbTVUEaDRiEU9YMjy1obBWpdOBuk1bcm+tsmifYSygfw==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] + libc: [musl] '@next/swc-linux-arm64-musl@16.1.6': resolution: {integrity: sha512-S4J2v+8tT3NIO9u2q+S0G5KdvNDjXfAv06OhfOzNDaBn5rw84DGXWndOEB7d5/x852A20sW1M56vhC/tRVbccQ==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] + libc: [musl] '@next/swc-linux-arm64-musl@16.2.0': resolution: {integrity: sha512-1ffhC6KY5qWLg5miMlKJp3dZbXelEfjuXt1qcp5WzSCQy36CV3y+JT7OC1WSFKizGQCDOcQbfkH/IjZP3cdRNA==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] + libc: [musl] '@next/swc-linux-x64-gnu@15.5.7': resolution: {integrity: sha512-hvXcZvCaaEbCZcVzcY7E1uXN9xWZfFvkNHwbe/n4OkRhFWrs1J1QV+4U1BN06tXLdaS4DazEGXwgqnu/VMcmqw==} engines: {node: '>= 10'} cpu: [x64] os: [linux] + libc: [glibc] '@next/swc-linux-x64-gnu@16.1.5': resolution: {integrity: sha512-gq2UtoCpN7Ke/7tKaU7i/1L7eFLfhMbXjNghSv0MVGF1dmuoaPeEVDvkDuO/9LVa44h5gqpWeJ4mRRznjDv7LA==} engines: {node: '>= 10'} cpu: [x64] os: [linux] + libc: [glibc] '@next/swc-linux-x64-gnu@16.1.6': resolution: {integrity: sha512-2eEBDkFlMMNQnkTyPBhQOAyn2qMxyG2eE7GPH2WIDGEpEILcBPI/jdSv4t6xupSP+ot/jkfrCShLAa7+ZUPcJQ==} engines: {node: '>= 10'} cpu: [x64] os: [linux] + libc: [glibc] '@next/swc-linux-x64-gnu@16.2.0': resolution: {integrity: sha512-FmbDcZQ8yJRq93EJSL6xaE0KK/Rslraf8fj1uViGxg7K4CKBCRYSubILJPEhjSgZurpcPQq12QNOJQ0DRJl6Hg==} engines: {node: '>= 10'} cpu: [x64] os: [linux] + libc: [glibc] '@next/swc-linux-x64-musl@15.5.7': resolution: {integrity: sha512-4IUO539b8FmF0odY6/SqANJdgwn1xs1GkPO5doZugwZ3ETF6JUdckk7RGmsfSf7ws8Qb2YB5It33mvNL/0acqA==} engines: {node: '>= 10'} cpu: [x64] os: [linux] + libc: [musl] '@next/swc-linux-x64-musl@16.1.5': resolution: {integrity: sha512-bQWSE729PbXT6mMklWLf8dotislPle2L70E9q6iwETYEOt092GDn0c+TTNj26AjmeceSsC4ndyGsK5nKqHYXjQ==} engines: {node: '>= 10'} cpu: [x64] os: [linux] + libc: [musl] '@next/swc-linux-x64-musl@16.1.6': resolution: {integrity: sha512-oicJwRlyOoZXVlxmIMaTq7f8pN9QNbdes0q2FXfRsPhfCi8n8JmOZJm5oo1pwDaFbnnD421rVU409M3evFbIqg==} engines: {node: '>= 10'} cpu: [x64] os: [linux] + libc: [musl] '@next/swc-linux-x64-musl@16.2.0': resolution: {integrity: sha512-HzjIHVkmGAwRbh/vzvoBWWEbb8BBZPxBvVbDQDvzHSf3D8RP/4vjw7MNLDXFF9Q1WEzeQyEj2zdxBtVAHu5Oyw==} engines: {node: '>= 10'} cpu: [x64] os: [linux] + libc: [musl] '@next/swc-win32-arm64-msvc@15.5.7': resolution: {integrity: sha512-CpJVTkYI3ZajQkC5vajM7/ApKJUOlm6uP4BknM3XKvJ7VXAvCqSjSLmM0LKdYzn6nBJVSjdclx8nYJSa3xlTgQ==} @@ -3852,66 +3888,79 @@ packages: resolution: {integrity: sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw==} cpu: [arm] os: [linux] + libc: [glibc] '@rollup/rollup-linux-arm-musleabihf@4.59.0': resolution: {integrity: sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA==} cpu: [arm] os: [linux] + libc: [musl] '@rollup/rollup-linux-arm64-gnu@4.59.0': resolution: {integrity: sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA==} cpu: [arm64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-arm64-musl@4.59.0': resolution: {integrity: sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA==} cpu: [arm64] os: [linux] + libc: [musl] '@rollup/rollup-linux-loong64-gnu@4.59.0': resolution: {integrity: sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg==} cpu: [loong64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-loong64-musl@4.59.0': resolution: {integrity: sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q==} cpu: [loong64] os: [linux] + libc: [musl] '@rollup/rollup-linux-ppc64-gnu@4.59.0': resolution: {integrity: sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA==} cpu: [ppc64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-ppc64-musl@4.59.0': resolution: {integrity: sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA==} cpu: [ppc64] os: [linux] + libc: [musl] '@rollup/rollup-linux-riscv64-gnu@4.59.0': resolution: {integrity: sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg==} cpu: [riscv64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-riscv64-musl@4.59.0': resolution: {integrity: sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg==} cpu: [riscv64] os: [linux] + libc: [musl] '@rollup/rollup-linux-s390x-gnu@4.59.0': resolution: {integrity: sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w==} cpu: [s390x] os: [linux] + libc: [glibc] '@rollup/rollup-linux-x64-gnu@4.59.0': resolution: {integrity: sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg==} cpu: [x64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-x64-musl@4.59.0': resolution: {integrity: sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg==} cpu: [x64] os: [linux] + libc: [musl] '@rollup/rollup-openbsd-x64@4.59.0': resolution: {integrity: sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ==} @@ -4255,72 +4304,84 @@ packages: engines: {node: '>= 10'} cpu: [arm64] os: [linux] + libc: [glibc] '@tailwindcss/oxide-linux-arm64-gnu@4.1.16': resolution: {integrity: sha512-DoixyMmTNO19rwRPdqviTrG1rYzpxgyYJl8RgQvdAQUzxC1ToLRqtNJpU/ATURSKgIg6uerPw2feW0aS8SNr/w==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] + libc: [glibc] '@tailwindcss/oxide-linux-arm64-gnu@4.1.18': resolution: {integrity: sha512-S0n3jboLysNbh55Vrt7pk9wgpyTTPD0fdQeh7wQfMqLPM/Hrxi+dVsLsPrycQjGKEQk85Kgbx+6+QnYNiHalnw==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] + libc: [glibc] '@tailwindcss/oxide-linux-arm64-musl@4.0.15': resolution: {integrity: sha512-342GVnhH/6PkVgKtEzvNVuQ4D+Q7B7qplvuH20Cfz9qEtydG6IQczTZ5IT4JPlh931MG1NUCVxg+CIorr1WJyw==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] + libc: [musl] '@tailwindcss/oxide-linux-arm64-musl@4.1.16': resolution: {integrity: sha512-H81UXMa9hJhWhaAUca6bU2wm5RRFpuHImrwXBUvPbYb+3jo32I9VIwpOX6hms0fPmA6f2pGVlybO6qU8pF4fzQ==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] + libc: [musl] '@tailwindcss/oxide-linux-arm64-musl@4.1.18': resolution: {integrity: sha512-1px92582HkPQlaaCkdRcio71p8bc8i/ap5807tPRDK/uw953cauQBT8c5tVGkOwrHMfc2Yh6UuxaH4vtTjGvHg==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] + libc: [musl] '@tailwindcss/oxide-linux-x64-gnu@4.0.15': resolution: {integrity: sha512-g76GxlKH124RuGqacCEFc2nbzRl7bBrlC8qDQMiUABkiifDRHOIUjgKbLNG4RuR9hQAD/MKsqZ7A8L08zsoBrw==} engines: {node: '>= 10'} cpu: [x64] os: [linux] + libc: [glibc] '@tailwindcss/oxide-linux-x64-gnu@4.1.16': resolution: {integrity: sha512-ZGHQxDtFC2/ruo7t99Qo2TTIvOERULPl5l0K1g0oK6b5PGqjYMga+FcY1wIUnrUxY56h28FxybtDEla+ICOyew==} engines: {node: '>= 10'} cpu: [x64] os: [linux] + libc: [glibc] '@tailwindcss/oxide-linux-x64-gnu@4.1.18': resolution: {integrity: sha512-v3gyT0ivkfBLoZGF9LyHmts0Isc8jHZyVcbzio6Wpzifg/+5ZJpDiRiUhDLkcr7f/r38SWNe7ucxmGW3j3Kb/g==} engines: {node: '>= 10'} cpu: [x64] os: [linux] + libc: [glibc] '@tailwindcss/oxide-linux-x64-musl@4.0.15': resolution: {integrity: sha512-Gg/Y1XrKEvKpq6WeNt2h8rMIKOBj/W3mNa5NMvkQgMC7iO0+UNLrYmt6zgZufht66HozNpn+tJMbbkZ5a3LczA==} engines: {node: '>= 10'} cpu: [x64] os: [linux] + libc: [musl] '@tailwindcss/oxide-linux-x64-musl@4.1.16': resolution: {integrity: sha512-Oi1tAaa0rcKf1Og9MzKeINZzMLPbhxvm7rno5/zuP1WYmpiG0bEHq4AcRUiG2165/WUzvxkW4XDYCscZWbTLZw==} engines: {node: '>= 10'} cpu: [x64] os: [linux] + libc: [musl] '@tailwindcss/oxide-linux-x64-musl@4.1.18': resolution: {integrity: sha512-bhJ2y2OQNlcRwwgOAGMY0xTFStt4/wyU6pvI6LSuZpRgKQwxTec0/3Scu91O8ir7qCR3AuepQKLU/kX99FouqQ==} engines: {node: '>= 10'} cpu: [x64] os: [linux] + libc: [musl] '@tailwindcss/oxide-wasm32-wasi@4.1.16': resolution: {integrity: sha512-B01u/b8LteGRwucIBmCQ07FVXLzImWESAIMcUU6nvFt/tYsQ6IHz8DmZ5KtvmwxD+iTYBtM1xwoGXswnlu9v0Q==} @@ -4778,41 +4839,49 @@ packages: resolution: {integrity: sha512-34gw7PjDGB9JgePJEmhEqBhWvCiiWCuXsL9hYphDF7crW7UgI05gyBAi6MF58uGcMOiOqSJ2ybEeCvHcq0BCmQ==} cpu: [arm64] os: [linux] + libc: [glibc] '@unrs/resolver-binding-linux-arm64-musl@1.11.1': resolution: {integrity: sha512-RyMIx6Uf53hhOtJDIamSbTskA99sPHS96wxVE/bJtePJJtpdKGXO1wY90oRdXuYOGOTuqjT8ACccMc4K6QmT3w==} cpu: [arm64] os: [linux] + libc: [musl] '@unrs/resolver-binding-linux-ppc64-gnu@1.11.1': resolution: {integrity: sha512-D8Vae74A4/a+mZH0FbOkFJL9DSK2R6TFPC9M+jCWYia/q2einCubX10pecpDiTmkJVUH+y8K3BZClycD8nCShA==} cpu: [ppc64] os: [linux] + libc: [glibc] '@unrs/resolver-binding-linux-riscv64-gnu@1.11.1': resolution: {integrity: sha512-frxL4OrzOWVVsOc96+V3aqTIQl1O2TjgExV4EKgRY09AJ9leZpEg8Ak9phadbuX0BA4k8U5qtvMSQQGGmaJqcQ==} cpu: [riscv64] os: [linux] + libc: [glibc] '@unrs/resolver-binding-linux-riscv64-musl@1.11.1': resolution: {integrity: sha512-mJ5vuDaIZ+l/acv01sHoXfpnyrNKOk/3aDoEdLO/Xtn9HuZlDD6jKxHlkN8ZhWyLJsRBxfv9GYM2utQ1SChKew==} cpu: [riscv64] os: [linux] + libc: [musl] '@unrs/resolver-binding-linux-s390x-gnu@1.11.1': resolution: {integrity: sha512-kELo8ebBVtb9sA7rMe1Cph4QHreByhaZ2QEADd9NzIQsYNQpt9UkM9iqr2lhGr5afh885d/cB5QeTXSbZHTYPg==} cpu: [s390x] os: [linux] + libc: [glibc] '@unrs/resolver-binding-linux-x64-gnu@1.11.1': resolution: {integrity: sha512-C3ZAHugKgovV5YvAMsxhq0gtXuwESUKc5MhEtjBpLoHPLYM+iuwSj3lflFwK3DPm68660rZ7G8BMcwSro7hD5w==} cpu: [x64] os: [linux] + libc: [glibc] '@unrs/resolver-binding-linux-x64-musl@1.11.1': resolution: {integrity: sha512-rV0YSoyhK2nZ4vEswT/QwqzqQXw5I6CjoaYMOX0TqBlWhojUf8P94mvI7nuJTeaCkkds3QE4+zS8Ko+GdXuZtA==} cpu: [x64] os: [linux] + libc: [musl] '@unrs/resolver-binding-wasm32-wasi@1.11.1': resolution: {integrity: sha512-5u4RkfxJm+Ng7IWgkzi3qrFOvLvQYnPBmjmZQ8+szTK/b31fQCnleNl1GgEt7nIsZRIf5PLhPwT0WM+q45x/UQ==} @@ -5398,10 +5467,6 @@ packages: resolution: {integrity: sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==} engines: {node: '>=18'} - ci-info@3.9.0: - resolution: {integrity: sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ==} - engines: {node: '>=8'} - cjs-module-lexer@1.4.3: resolution: {integrity: sha512-9z8TZaGM1pfswYeXrUpzPrkx8UnWYdhJclsiYMm6x/w5+nN+8Tf/LnAgfLGQCm59qAOxU8WwHEq2vNwF6i4j+Q==} @@ -7072,48 +7137,56 @@ packages: engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] + libc: [glibc] lightningcss-linux-arm64-gnu@1.30.2: resolution: {integrity: sha512-UK65WJAbwIJbiBFXpxrbTNArtfuznvxAJw4Q2ZGlU8kPeDIWEX1dg3rn2veBVUylA2Ezg89ktszWbaQnxD/e3A==} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] + libc: [glibc] lightningcss-linux-arm64-musl@1.29.2: resolution: {integrity: sha512-Q64eM1bPlOOUgxFmoPUefqzY1yV3ctFPE6d/Vt7WzLW4rKTv7MyYNky+FWxRpLkNASTnKQUaiMJ87zNODIrrKQ==} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] + libc: [musl] lightningcss-linux-arm64-musl@1.30.2: resolution: {integrity: sha512-5Vh9dGeblpTxWHpOx8iauV02popZDsCYMPIgiuw97OJ5uaDsL86cnqSFs5LZkG3ghHoX5isLgWzMs+eD1YzrnA==} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] + libc: [musl] lightningcss-linux-x64-gnu@1.29.2: resolution: {integrity: sha512-0v6idDCPG6epLXtBH/RPkHvYx74CVziHo6TMYga8O2EiQApnUPZsbR9nFNrg2cgBzk1AYqEd95TlrsL7nYABQg==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] + libc: [glibc] lightningcss-linux-x64-gnu@1.30.2: resolution: {integrity: sha512-Cfd46gdmj1vQ+lR6VRTTadNHu6ALuw2pKR9lYq4FnhvgBc4zWY1EtZcAc6EffShbb1MFrIPfLDXD6Xprbnni4w==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] + libc: [glibc] lightningcss-linux-x64-musl@1.29.2: resolution: {integrity: sha512-rMpz2yawkgGT8RULc5S4WiZopVMOFWjiItBT7aSfDX4NQav6M44rhn5hjtkKzB+wMTRlLLqxkeYEtQ3dd9696w==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] + libc: [musl] lightningcss-linux-x64-musl@1.30.2: resolution: {integrity: sha512-XJaLUUFXb6/QG2lGIW6aIk6jKdtjtcffUT0NKvIqhSBY3hh9Ch+1LCeH80dR9q9LBjG3ewbDjnumefsLsP6aiA==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] + libc: [musl] lightningcss-win32-arm64-msvc@1.29.2: resolution: {integrity: sha512-nL7zRW6evGQqYVu/bKGK+zShyz8OVzsCotFgc7judbt6wnB2KbiKKJwBE4SGoDBQ1O94RjW4asrCjQL4i8Fhbw==} @@ -9715,9 +9788,9 @@ snapshots: '@types/tough-cookie': 4.0.5 tough-cookie: 4.1.4 - '@changesets/apply-release-plan@7.1.0': + '@changesets/apply-release-plan@7.1.1': dependencies: - '@changesets/config': 3.1.3 + '@changesets/config': 3.1.4 '@changesets/get-version-range-type': 0.4.0 '@changesets/git': 3.0.4 '@changesets/should-skip-package': 0.1.2 @@ -9731,10 +9804,10 @@ snapshots: resolve-from: 5.0.0 semver: 7.7.4 - '@changesets/assemble-release-plan@6.0.9': + '@changesets/assemble-release-plan@6.0.10': dependencies: '@changesets/errors': 0.2.0 - '@changesets/get-dependents-graph': 2.1.3 + '@changesets/get-dependents-graph': 2.1.4 '@changesets/should-skip-package': 0.1.2 '@changesets/types': 6.1.0 '@manypkg/get-packages': 1.1.3 @@ -9744,7 +9817,7 @@ snapshots: dependencies: '@changesets/types': 6.1.0 - '@changesets/changelog-github@0.6.0': + '@changesets/changelog-github@0.7.0': dependencies: '@changesets/get-github-info': 0.8.0 '@changesets/types': 6.1.0 @@ -9752,15 +9825,15 @@ snapshots: transitivePeerDependencies: - encoding - '@changesets/cli@2.29.8(@types/node@22.9.0)': + '@changesets/cli@2.31.0(@types/node@22.9.0)': dependencies: - '@changesets/apply-release-plan': 7.1.0 - '@changesets/assemble-release-plan': 6.0.9 + '@changesets/apply-release-plan': 7.1.1 + '@changesets/assemble-release-plan': 6.0.10 '@changesets/changelog-git': 0.2.1 - '@changesets/config': 3.1.3 + '@changesets/config': 3.1.4 '@changesets/errors': 0.2.0 - '@changesets/get-dependents-graph': 2.1.3 - '@changesets/get-release-plan': 4.0.15 + '@changesets/get-dependents-graph': 2.1.4 + '@changesets/get-release-plan': 4.0.16 '@changesets/git': 3.0.4 '@changesets/logger': 0.1.1 '@changesets/pre': 2.0.2 @@ -9771,11 +9844,9 @@ snapshots: '@inquirer/external-editor': 1.0.3(@types/node@22.9.0) '@manypkg/get-packages': 1.1.3 ansi-colors: 4.1.3 - ci-info: 3.9.0 enquirer: 2.4.1 fs-extra: 7.0.1 mri: 1.2.0 - p-limit: 2.3.0 package-manager-detector: 0.2.11 picocolors: 1.1.1 resolve-from: 5.0.0 @@ -9785,10 +9856,10 @@ snapshots: transitivePeerDependencies: - '@types/node' - '@changesets/config@3.1.3': + '@changesets/config@3.1.4': dependencies: '@changesets/errors': 0.2.0 - '@changesets/get-dependents-graph': 2.1.3 + '@changesets/get-dependents-graph': 2.1.4 '@changesets/logger': 0.1.1 '@changesets/should-skip-package': 0.1.2 '@changesets/types': 6.1.0 @@ -9800,7 +9871,7 @@ snapshots: dependencies: extendable-error: 0.1.7 - '@changesets/get-dependents-graph@2.1.3': + '@changesets/get-dependents-graph@2.1.4': dependencies: '@changesets/types': 6.1.0 '@manypkg/get-packages': 1.1.3 @@ -9814,10 +9885,10 @@ snapshots: transitivePeerDependencies: - encoding - '@changesets/get-release-plan@4.0.15': + '@changesets/get-release-plan@4.0.16': dependencies: - '@changesets/assemble-release-plan': 6.0.9 - '@changesets/config': 3.1.3 + '@changesets/assemble-release-plan': 6.0.10 + '@changesets/config': 3.1.4 '@changesets/pre': 2.0.2 '@changesets/read': 0.6.7 '@changesets/types': 6.1.0 @@ -13982,8 +14053,6 @@ snapshots: chownr@3.0.0: {} - ci-info@3.9.0: {} - cjs-module-lexer@1.4.3: {} class-variance-authority@0.7.1: From 25f6fd7170e5d3a47a382741a79d92f4fc95fc39 Mon Sep 17 00:00:00 2001 From: Dominik Ferber Date: Wed, 3 Jun 2026 16:22:52 +0300 Subject: [PATCH 39/42] [changesets] onlyUpdatePeerDependentsWhenOutOfRange --- .changeset/config.json | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.changeset/config.json b/.changeset/config.json index 9cb00b96..7f30a780 100644 --- a/.changeset/config.json +++ b/.changeset/config.json @@ -7,6 +7,9 @@ "access": "public", "baseBranch": "main", "updateInternalDependencies": "patch", + "___experimentalUnsafeOptions_WILL_CHANGE_IN_PATCH": { + "onlyUpdatePeerDependentsWhenOutOfRange": true + }, "ignore": [ "playground", "shirt-shop", From 14fcc201dde21927ee4afc93847520e291f8e286 Mon Sep 17 00:00:00 2001 From: Dominik Ferber Date: Wed, 3 Jun 2026 16:34:01 +0300 Subject: [PATCH 40/42] add type guard --- packages/flags/src/next/evaluate.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/flags/src/next/evaluate.ts b/packages/flags/src/next/evaluate.ts index 0269525a..297bebe6 100644 --- a/packages/flags/src/next/evaluate.ts +++ b/packages/flags/src/next/evaluate.ts @@ -198,7 +198,7 @@ type FlagInfo = { function hasOverride( overrides: Record | null, key: string, -): boolean { +): overrides is Record { return overrides !== null && overrides[key] !== undefined; } @@ -239,7 +239,7 @@ async function applyResult(args: { if (hasOverride(overrides, definition.key)) { setSpanAttribute('method', 'override'); - const decision = overrides![definition.key] as ValueType; + const decision = overrides[definition.key] as ValueType; setCachedValuePromise( readonlyHeaders, definition.key, From 7da30f5ae2983500465a9c01b20c9c985db611e3 Mon Sep 17 00:00:00 2001 From: Luis Meyer Date: Thu, 28 May 2026 09:20:15 +0200 Subject: [PATCH 41/42] Reapply "Add oidc support (#374)" (#389) This reverts commit 357ca676c6c9103fbbc2ee82e2ac0458fbdbcc21. --- .changeset/twelve-shoes-behave.md | 13 + packages/adapter-vercel/src/index.ts | 22 +- packages/prepare-flags-definitions/README.md | 2 +- .../src/index.test.ts | 277 +++++++++++++-- .../prepare-flags-definitions/src/index.ts | 336 ++++++++++++------ packages/vercel-flags-core/CLAUDE.md | 2 +- packages/vercel-flags-core/package.json | 1 + .../vercel-flags-core/src/controller/auth.ts | 90 +++++ .../src/controller/bundled-source.ts | 19 +- .../src/controller/fetch-datafile.ts | 7 +- .../vercel-flags-core/src/controller/index.ts | 20 +- .../src/controller/normalized-options.ts | 9 +- .../src/controller/polling-source.ts | 3 +- .../src/controller/stream-connection.ts | 16 +- .../src/controller/stream-source.ts | 40 ++- .../src/create-raw-client.ts | 2 +- .../vercel-flags-core/src/index.make.test.ts | 29 +- packages/vercel-flags-core/src/index.make.ts | 40 +-- packages/vercel-flags-core/src/types.ts | 5 +- .../src/types/flags-definitions.d.ts | 2 +- .../utils/read-bundled-definitions.test.ts | 44 ++- .../src/utils/read-bundled-definitions.ts | 23 +- .../src/utils/usage-tracker.test.ts | 22 +- .../src/utils/usage-tracker.ts | 7 +- pnpm-lock.yaml | 84 +++-- 25 files changed, 799 insertions(+), 316 deletions(-) create mode 100644 .changeset/twelve-shoes-behave.md create mode 100644 packages/vercel-flags-core/src/controller/auth.ts diff --git a/.changeset/twelve-shoes-behave.md b/.changeset/twelve-shoes-behave.md new file mode 100644 index 00000000..4dbe53f8 --- /dev/null +++ b/.changeset/twelve-shoes-behave.md @@ -0,0 +1,13 @@ +--- +"@flags-sdk/vercel": minor +"@vercel/prepare-flags-definitions": minor +"@vercel/flags-core": minor +--- + +Add OIDC authentication support for Vercel Flags clients and generated flag definitions. + +`@vercel/flags-core` can now create clients without an SDK key and authenticate with a Vercel OIDC token, while still supporting SDK keys and connection strings. Bundled definitions can be looked up by SDK key hash or OIDC project ID. + +`@vercel/prepare-flags-definitions` now collects both SDK keys and `VERCEL_OIDC_TOKEN`, fetches definitions for each auth entry, deduplicates identical definitions across SDK keys and OIDC project IDs, and writes generated maps keyed by SDK key hash or project ID. + +`@flags-sdk/vercel` now supports provider data lookup for Vercel flag origins that do not include an SDK key, allowing OIDC-backed clients to resolve project metadata. diff --git a/packages/adapter-vercel/src/index.ts b/packages/adapter-vercel/src/index.ts index 1a094b86..5f327c88 100644 --- a/packages/adapter-vercel/src/index.ts +++ b/packages/adapter-vercel/src/index.ts @@ -23,10 +23,10 @@ export type VercelAdapterDeclaration = Omit< */ export function createVercelAdapter( // usually a connection string, but can also be a pre-configured FlagsClient - sdkKeyOrFlagsClient: string | FlagsClient, + sdkKeyOrFlagsClient?: string | FlagsClient, ) { const flagsClient = - typeof sdkKeyOrFlagsClient === 'string' + typeof sdkKeyOrFlagsClient === 'string' || sdkKeyOrFlagsClient === undefined ? createClient(sdkKeyOrFlagsClient) : sdkKeyOrFlagsClient; @@ -111,9 +111,13 @@ export function vercelAdapter(): Adapter< return defaultVercelAdapter(); } -const flagsClients = new Map(); +const flagsClients = new Map(); -function getOrCreateClient(sdkKey: string): FlagsClient { +/** + * Ensures we only ever create a single client per SDK Key + * When undefined is passed, due to OIDC being used, then we return a single client too. + **/ +function getOrCreateClient(sdkKey?: string): FlagsClient { let client = flagsClients.get(sdkKey); if (!client) { client = createClient(sdkKey); @@ -124,14 +128,12 @@ function getOrCreateClient(sdkKey: string): FlagsClient { function isVercelOrigin( origin: unknown, -): origin is { provider: 'vercel'; sdkKey: string } { +): origin is { provider: 'vercel'; sdkKey?: string } { return ( typeof origin === 'object' && origin !== null && 'provider' in origin && - (origin as Record).provider === 'vercel' && - 'sdkKey' in origin && - typeof (origin as Record).sdkKey === 'string' + (origin as Record).provider === 'vercel' ); } @@ -147,14 +149,14 @@ export async function getProviderData( .filter((i): i is KeyedFlagDefinitionType => !Array.isArray(i)); // Collect unique sdkKeys and resolve their projectIds - const sdkKeys = new Set(); + const sdkKeys = new Set(); for (const d of flagDefs) { if (isVercelOrigin(d.origin)) { sdkKeys.add(d.origin.sdkKey); } } - const projectIdBySdkKey = new Map(); + const projectIdBySdkKey = new Map(); await Promise.all( Array.from(sdkKeys).map(async (sdkKey) => { const client = getOrCreateClient(sdkKey); diff --git a/packages/prepare-flags-definitions/README.md b/packages/prepare-flags-definitions/README.md index 3a84c9cc..98c62ae8 100644 --- a/packages/prepare-flags-definitions/README.md +++ b/packages/prepare-flags-definitions/README.md @@ -22,7 +22,7 @@ const result = await prepareFlagsDefinitions({ }); if (result.created) { - console.log(`Bundled definitions for ${result.sdkKeysCount} SDK keys`); + console.log(`Bundled definitions for ${result.entryCount} SDK keys`); } else { console.log(`No definitions created: ${result.reason}`); } diff --git a/packages/prepare-flags-definitions/src/index.test.ts b/packages/prepare-flags-definitions/src/index.test.ts index 2dc557a2..d61445d8 100644 --- a/packages/prepare-flags-definitions/src/index.test.ts +++ b/packages/prepare-flags-definitions/src/index.test.ts @@ -1,11 +1,23 @@ +import { readFile } from 'node:fs/promises'; import { describe, expect, it, vi } from 'vitest'; import { version as pkgVersion } from '../package.json'; import { generateDefinitionsModule, + getProjectIdFromOidcToken, hashSdkKey, prepareFlagsDefinitions, } from './index'; +function createOidcToken(projectId: string): string { + const header = Buffer.from(JSON.stringify({ alg: 'none' })).toString( + 'base64url', + ); + const payload = Buffer.from( + JSON.stringify({ project_id: projectId, exp: 4_102_444_800 }), + ).toString('base64url'); + return `${header}.${payload}.signature`; +} + describe('hashSdkKey', () => { it('returns a SHA-256 hex digest', () => { const hash = hashSdkKey('vf_server_test_key'); @@ -21,80 +33,133 @@ describe('hashSdkKey', () => { }); }); +describe('getProjectIdFromOidcToken', () => { + it('reads the project_id claim', () => { + expect(getProjectIdFromOidcToken(createOidcToken('prj_test'))).toBe( + 'prj_test', + ); + }); +}); + describe('generateDefinitionsModule', () => { it('generates a valid JS module', () => { - const sdkKeys = ['vf_server_key1']; - const values = [{ flag_a: { value: true } }]; - const result = generateDefinitionsModule(sdkKeys, values); + const result = generateDefinitionsModule( + [{ key: 'vf_server_key1', definitions: { flag_a: { value: true } } }], + undefined, + ); expect(result).toContain('const memo'); - expect(result).toContain('export function get(hashedSdkKey)'); + expect(result).toContain('export function get(key)'); expect(result).toContain('export const version'); - expect(result).toContain(hashSdkKey('vf_server_key1')); + expect(result).toContain('vf_server_key1'); }); it('deduplicates identical definitions', () => { - const sdkKeys = ['vf_server_key1', 'vf_client_key2']; const sharedDef = { flag_a: { value: true } }; - const values = [sharedDef, sharedDef]; - const result = generateDefinitionsModule(sdkKeys, values); + const result = generateDefinitionsModule( + [ + { key: 'vf_server_key1', definitions: sharedDef }, + { key: 'vf_client_key2', definitions: sharedDef }, + ], + undefined, + ); const memoMatches = result.match(/const _d\d+ = memo/g); expect(memoMatches).toHaveLength(1); }); it('keeps separate definitions when values differ', () => { - const sdkKeys = ['vf_server_key1', 'vf_client_key2']; - const values = [{ flag_a: { value: true } }, { flag_b: { value: false } }]; - const result = generateDefinitionsModule(sdkKeys, values); + const result = generateDefinitionsModule( + [ + { key: 'vf_server_key1', definitions: { flag_a: { value: true } } }, + { key: 'vf_client_key2', definitions: { flag_b: { value: false } } }, + ], + undefined, + ); const memoMatches = result.match(/const _d\d+ = memo/g); expect(memoMatches).toHaveLength(2); }); it('maps each SDK key hash to the correct definition index', () => { - const sdkKeys = ['vf_server_key1', 'vf_client_key2']; - const values = [{ flag_a: true }, { flag_b: false }]; - const result = generateDefinitionsModule(sdkKeys, values); - - expect(result).toContain( - `${JSON.stringify(hashSdkKey('vf_server_key1'))}: _d0`, - ); - expect(result).toContain( - `${JSON.stringify(hashSdkKey('vf_client_key2'))}: _d1`, + const result = generateDefinitionsModule( + [ + { key: 'vf_server_key1', definitions: { flag_a: true } }, + { key: 'vf_client_key2', definitions: { flag_b: false } }, + ], + undefined, ); + + expect(result).toContain(`${JSON.stringify('vf_server_key1')}: _d0`); + expect(result).toContain(`${JSON.stringify('vf_client_key2')}: _d1`); }); it('handles empty input', () => { - const result = generateDefinitionsModule([], []); + const result = generateDefinitionsModule([], undefined); expect(result).toContain('const map = {'); - expect(result).toContain('export function get(hashedSdkKey)'); + expect(result).toContain('export function get(key)'); + }); + + it('deduplicates definitions across SDK keys and OIDC project IDs', () => { + const sharedDef = { flag_a: { value: true } }; + const result = generateDefinitionsModule( + [ + { key: 'vf_server_key1', definitions: sharedDef }, + { key: 'prj_test', definitions: sharedDef }, + ], + undefined, + ); + + const memoMatches = result.match(/const _d\d+ = memo/g); + expect(memoMatches).toHaveLength(1); + expect(result).toContain(`${JSON.stringify('vf_server_key1')}: _d0`); + expect(result).toContain(`${JSON.stringify('prj_test')}: _d0`); }); }); describe('prepareFlagsDefinitions', () => { - it('returns { created: false, reason: "no-sdk-keys" } when no SDK keys in env', async () => { + it('returns { created: false, reason: "no-flags-entries" } when no flags auth is in env', async () => { const result = await prepareFlagsDefinitions({ cwd: '/tmp/test', env: { SOME_VAR: 'hello' }, }); - expect(result).toEqual({ created: false, reason: 'no-sdk-keys' }); + expect(result).toEqual({ created: false, reason: 'no-flags-entries' }); }); - it('returns { created: true, sdkKeysCount: N } when definitions are created', async () => { + it('returns { created: true, entryCount: N } when definitions are created', async () => { const mockFetch = async () => new Response(JSON.stringify({ flag_a: { value: true } }), { status: 200, }); + const cwd = '/tmp/test-definitions'; const result = await prepareFlagsDefinitions({ - cwd: '/tmp/test-definitions', + cwd, env: { FLAGS_SECRET: 'vf_server_test_key_123' }, fetch: mockFetch, }); - expect(result).toEqual({ created: true, sdkKeysCount: 1 }); + expect(result).toEqual({ created: true, entryCount: 1 }); + const definitionsJs = await readFile( + `${cwd}/node_modules/@vercel/flags-definitions/index.js`, + 'utf8', + ); + expect(definitionsJs).toMatchInlineSnapshot(` + "const memo = (fn) => { let cached; return () => (cached ??= fn()); }; + + const _d0 = memo(() => JSON.parse("{\\"flag_a\\":{\\"value\\":true}}")); + + const map = { + "faab116281fa4201059a73f3ca8b7cad7fce9e1132988008784883fa2c78d64a": _d0, + }; + + export function get(key) { + return map[key]?.() ?? null; + } + + export const version = "1.0.1";" + `); }); it('sends default user-agent with package version', async () => { @@ -147,7 +212,7 @@ describe('prepareFlagsDefinitions', () => { fetch: mockFetch, }); - expect(result).toEqual({ created: false, reason: 'no-sdk-keys' }); + expect(result).toEqual({ created: false, reason: 'no-flags-entries' }); expect(mockFetch).not.toHaveBeenCalled(); }); @@ -157,18 +222,38 @@ describe('prepareFlagsDefinitions', () => { json: () => Promise.resolve({ flag_a: { value: true } }), }); + const cwd = '/tmp/test-flags-format'; const result = await prepareFlagsDefinitions({ - cwd: '/tmp/test-flags-format', + cwd, env: { FLAGS_CONNECTION: 'flags:sdkKey=vf_server_my_key&other=value', }, fetch: mockFetch, }); - expect(result).toEqual({ created: true, sdkKeysCount: 1 }); + expect(result).toEqual({ created: true, entryCount: 1 }); expect(mockFetch).toHaveBeenCalledTimes(1); const headers = mockFetch.mock.calls[0]?.[1]?.headers; expect(headers.authorization).toBe('Bearer vf_server_my_key'); + const definitionsJs = await readFile( + `${cwd}/node_modules/@vercel/flags-definitions/index.js`, + 'utf8', + ); + expect(definitionsJs).toMatchInlineSnapshot(` + "const memo = (fn) => { let cached; return () => (cached ??= fn()); }; + + const _d0 = memo(() => JSON.parse("{\\"flag_a\\":{\\"value\\":true}}")); + + const map = { + "3790790d2dc9b23c4539a9f3c49eb5820e4216daebdd7eeee9136f3ceccc31a3": _d0, + }; + + export function get(key) { + return map[key]?.() ?? null; + } + + export const version = "1.0.1";" + `); }); it('ignores invalid SDK keys in flags: connection string', async () => { @@ -182,7 +267,137 @@ describe('prepareFlagsDefinitions', () => { fetch: mockFetch, }); - expect(result).toEqual({ created: false, reason: 'no-sdk-keys' }); + expect(result).toEqual({ created: false, reason: 'no-flags-entries' }); expect(mockFetch).not.toHaveBeenCalled(); }); + + it('stores OIDC definitions under the token project_id', async () => { + const mockFetch = vi.fn().mockResolvedValue({ + ok: true, + json: () => Promise.resolve({ flag_a: { value: true } }), + }); + + const cwd = '/tmp/test-oidc-definitions'; + const result = await prepareFlagsDefinitions({ + cwd, + env: { VERCEL_OIDC_TOKEN: createOidcToken('prj_oidc_test') }, + fetch: mockFetch, + }); + + expect(result).toEqual({ created: true, entryCount: 1 }); + expect(mockFetch).toHaveBeenCalledTimes(1); + const headers = mockFetch.mock.calls[0]?.[1]?.headers; + expect(headers.authorization).toBe( + `Bearer ${createOidcToken('prj_oidc_test')}`, + ); + + const definitionsJs = await readFile( + `${cwd}/node_modules/@vercel/flags-definitions/index.js`, + 'utf8', + ); + expect(definitionsJs).toMatchInlineSnapshot(` + "const memo = (fn) => { let cached; return () => (cached ??= fn()); }; + + const _d0 = memo(() => JSON.parse("{\\"flag_a\\":{\\"value\\":true}}")); + + const map = { + "prj_oidc_test": _d0, + }; + + export function get(key) { + return map[key]?.() ?? null; + } + + export const version = "1.0.1";" + `); + }); + + it('stores OIDC definitions alongside SDK Keys', async () => { + const mockFetch = vi.fn().mockResolvedValue({ + ok: true, + json: () => Promise.resolve({ flag_a: { value: true } }), + }); + + const cwd = '/tmp/test-oidc-sdk-key-mix'; + const result = await prepareFlagsDefinitions({ + cwd, + env: { + VERCEL_OIDC_TOKEN: createOidcToken('prj_oidc_test'), + FLAGS: 'vf_server_test_key_123', + }, + fetch: mockFetch, + }); + + expect(result).toEqual({ created: true, entryCount: 2 }); + expect(mockFetch).toHaveBeenCalledTimes(2); + + const definitionsJs = await readFile( + `${cwd}/node_modules/@vercel/flags-definitions/index.js`, + 'utf8', + ); + expect(definitionsJs).toMatchInlineSnapshot(` + "const memo = (fn) => { let cached; return () => (cached ??= fn()); }; + + const _d0 = memo(() => JSON.parse("{\\"flag_a\\":{\\"value\\":true}}")); + + const map = { + "faab116281fa4201059a73f3ca8b7cad7fce9e1132988008784883fa2c78d64a": _d0, + "prj_oidc_test": _d0, + }; + + export function get(key) { + return map[key]?.() ?? null; + } + + export const version = "1.0.1";" + `); + }); + + it('stores OIDC definitions alongside SDK Keys with different datafiles', async () => { + const mockFetch = vi + .fn() + .mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve({ flag_a: { value: true } }), + }) + .mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve({ flag_b: { value: true } }), + }); + + const cwd = '/tmp/test-oidc-sdk-key-mix'; + const result = await prepareFlagsDefinitions({ + cwd, + env: { + VERCEL_OIDC_TOKEN: createOidcToken('prj_oidc_test'), + FLAGS: 'vf_server_test_key_123', + }, + fetch: mockFetch, + }); + + expect(result).toEqual({ created: true, entryCount: 2 }); + expect(mockFetch).toHaveBeenCalledTimes(2); + + const definitionsJs = await readFile( + `${cwd}/node_modules/@vercel/flags-definitions/index.js`, + 'utf8', + ); + expect(definitionsJs).toMatchInlineSnapshot(` + "const memo = (fn) => { let cached; return () => (cached ??= fn()); }; + + const _d0 = memo(() => JSON.parse("{\\"flag_a\\":{\\"value\\":true}}")); + const _d1 = memo(() => JSON.parse("{\\"flag_b\\":{\\"value\\":true}}")); + + const map = { + "faab116281fa4201059a73f3ca8b7cad7fce9e1132988008784883fa2c78d64a": _d0, + "prj_oidc_test": _d1, + }; + + export function get(key) { + return map[key]?.() ?? null; + } + + export const version = "1.0.1";" + `); + }); }); diff --git a/packages/prepare-flags-definitions/src/index.ts b/packages/prepare-flags-definitions/src/index.ts index 47798a43..bc5d18e9 100644 --- a/packages/prepare-flags-definitions/src/index.ts +++ b/packages/prepare-flags-definitions/src/index.ts @@ -14,8 +14,8 @@ export interface Output { } export type PrepareFlagsDefinitionsResult = - | { created: false; reason: 'no-sdk-keys' } - | { created: true; sdkKeysCount: number }; + | { created: false; reason: 'no-flags-entries' } + | { created: true; entryCount: number }; /** * Obfuscates SDK key for logging (shows first 18 chars) @@ -34,13 +34,128 @@ export function hashSdkKey(sdkKey: string): string { return createHash('sha256').update(sdkKey).digest('hex'); } +export function getProjectIdFromOidcToken(oidcToken: string) { + const tokenParts = oidcToken.split('.'); + if (tokenParts.length !== 3 || !tokenParts[1]) { + return; + } + + const payload = JSON.parse( + Buffer.from(tokenParts[1], 'base64url').toString('utf8'), + ) as { project_id?: unknown }; + + if (typeof payload.project_id !== 'string' || !payload.project_id) { + return; + } + + return payload.project_id; +} + +type DefinitionsEntry = { + key: string; + definitions: BundledDefinitions; +}; + +type MapEntry = { + key: string; + value: string; +}; + /** - * Regex to match valid Vercel Flags SDK keys. - * SDK keys must follow the format: vf_server_* or vf_client_* - * This avoids false positives with third-party identifiers that happen - * to start with 'vf_' (e.g., Stripe identity flow IDs like 'vf_1PyH...'). + * Creates js constants pointing to memoized deduplicated flag definitions. + * Output format: + * ```js + * const _d0 = memo(() => JSON.parse('...')); + * const _d1 = memo(() => JSON.parse('...')); + * ```` */ -const SDK_KEY_REGEX = /^vf_(?:server|client)_/; +function generateDefinitionConstants( + lines: string[], + entries: DefinitionsEntry[], +): MapEntry[] { + const stringToConst = new Map(); + + return entries.map((entry) => { + const stringified = JSON.stringify(entry.definitions); + let definitionConst = stringToConst.get(stringified); + + if (!definitionConst) { + definitionConst = `_d${stringToConst.size}`; + stringToConst.set(stringified, definitionConst); + lines.push( + `const ${definitionConst} = memo(() => JSON.parse(${JSON.stringify(stringified)}));`, + ); + } + + return { key: entry.key, value: definitionConst }; + }); +} + +/** + * Creates a js map and getter function for exposing key value pairs. + * Output format: + * ```js + * const map = { "": _d0 }; + * export function get(key) { return map[key]?.() ?? null; } + * ```` + */ +function generateMap(lines: string[], entries: MapEntry[]): void { + lines.push(''); + lines.push('const map = {'); + for (const entry of entries) { + lines.push(` ${JSON.stringify(entry.key)}: ${entry.value},`); + } + lines.push('};'); + lines.push(''); + lines.push('export function get(key) {'); + lines.push(' return map[key]?.() ?? null;'); + lines.push('}'); +} + +async function fetchDatafile( + token: string, + env: Record, + fetchFn: typeof globalThis.fetch, + userAgentSuffix?: string, +): Promise { + const headers: Record = { + authorization: `Bearer ${token}`, + 'user-agent': [ + `@vercel/prepare-flags-definitions/${PACKAGE_VERSION}`, + userAgentSuffix, + ] + .filter(Boolean) + .join(' '), + }; + + // Add Vercel metadata headers if available + if (env.VERCEL_PROJECT_ID) { + headers['x-vercel-project-id'] = env.VERCEL_PROJECT_ID; + } + if (env.VERCEL_ENV) { + headers['x-vercel-env'] = env.VERCEL_ENV; + } + if (env.VERCEL_DEPLOYMENT_ID) { + headers['x-vercel-deployment-id'] = env.VERCEL_DEPLOYMENT_ID; + } + if (env.VERCEL_REGION) { + headers['x-vercel-region'] = env.VERCEL_REGION; + } + + const res = await fetchFn(`${FLAGS_HOST}/v1/datafile`, { headers }); + + if (!res.ok) { + if (res.status === 404) { + return undefined; + } + + throw new Error( + `Failed to fetch flag definitions for ${obfuscate(token)}: ${res.status} ${res.statusText}`, + ); + } + + return res.json() as Promise; +} /** * Generates a JS module with deduplicated, lazily-parsed definitions. @@ -57,61 +172,92 @@ const SDK_KEY_REGEX = /^vf_(?:server|client)_/; * ``` */ export function generateDefinitionsModule( - sdkKeys: string[], - values: BundledDefinitions[], + entries: DefinitionsEntry[], + output: Output | undefined, ): string { - // Stringify each definition - const stringified = sdkKeys.map((_, i) => JSON.stringify(values[i])); - - // Deduplicate: map unique strings to indices - const uniqueStrings: string[] = []; - const stringToIndex = new Map(); - for (const s of stringified) { - if (!stringToIndex.has(s)) { - stringToIndex.set(s, uniqueStrings.length); - uniqueStrings.push(s); - } - } - - // Map SDK keys to their definition index - const keyToIndex = sdkKeys.map( - (_, i) => stringToIndex.get(stringified[i]!) ?? 0, + output?.debug( + `vercel-flags: writing flag definitions for "${entries.map(({ key }) => obfuscate(key)).join(', ')}"`, ); - // Hash each SDK key - const hashedKeys = sdkKeys.map(hashSdkKey); - - // Generate JS + // generate shared js const lines: string[] = [ 'const memo = (fn) => { let cached; return () => (cached ??= fn()); };', '', ]; - // Add definition constants - for (let i = 0; i < uniqueStrings.length; i++) { - lines.push( - `const _d${i} = memo(() => JSON.parse(${JSON.stringify(uniqueStrings[i])}));`, - ); - } + // generate js const and capture the const names + const generatedDefinitions = generateDefinitionConstants(lines, entries); + + // generate a map wiring keys to const names + generateMap(lines, generatedDefinitions); - lines.push(''); - lines.push('const map = {'); - for (let i = 0; i < sdkKeys.length; i++) { - lines.push(` ${JSON.stringify(hashedKeys[i])}: _d${keyToIndex[i]},`); - } - lines.push('};'); - lines.push(''); - lines.push('export function get(hashedSdkKey) {'); - lines.push(' return map[hashedSdkKey]?.() ?? null;'); - lines.push('}'); - lines.push(''); lines.push( + '', `export const version = ${JSON.stringify(FLAGS_DEFINITIONS_VERSION)};`, ); return lines.join('\n'); } +type FlagEntry = { + type: 'oidcToken' | 'sdkKey'; + key: string; +}; + +/** + * Regex to match valid Vercel Flags SDK keys. + * SDK keys must follow the format: vf_server_* or vf_client_* + * This avoids false positives with third-party identifiers that happen + * to start with 'vf_' (e.g., Stripe identity flow IDs like 'vf_1PyH...'). + */ +const SDK_KEY_REGEX = /^vf_(?:server|client)_/; + +/** + * Collect all possible flag entries the need embedding from the environment + */ +function collectFlagEntries( + env: Record, + output: Output | undefined, +): FlagEntry[] { + const entries: FlagEntry[] = []; + + // Collect unique SDK keys from environment variables + // Supports both direct SDK keys (vf_server_*/vf_client_*) and flags: format + const sdkKeys = Array.from( + Object.values(env).reduce>((acc, value) => { + if (typeof value === 'string') { + if (SDK_KEY_REGEX.test(value)) { + acc.add(value); + } else if (value.startsWith('flags:')) { + const params = new URLSearchParams(value.slice('flags:'.length)); + const sdkKey = params.get('sdkKey'); + if (sdkKey && SDK_KEY_REGEX.test(sdkKey)) { + acc.add(sdkKey); + } + } + } + return acc; + }, new Set()), + ); + + if (sdkKeys.length > 0) { + output?.debug(`vercel-flags: found ${sdkKeys.length} SDK keys`); + + for (const key of sdkKeys) { + entries.push({ type: 'sdkKey', key }); + } + } + + const oidcToken = env.VERCEL_OIDC_TOKEN; + if (oidcToken && oidcToken?.length > 0) { + output?.debug(`vercel-flags: found OIDC token`); + + entries.push({ type: 'oidcToken', key: oidcToken }); + } + + return entries; +} + /** * Prepares flag definitions by reading SDK keys from environment variables, * fetching definitions from flags.vercel.com, and writing them into a @@ -136,78 +282,49 @@ export async function prepareFlagsDefinitions(options: { output, } = options; - output?.debug('vercel-flags: checking env vars for SDK Keys'); - - // Collect unique SDK keys from environment variables - // Supports both direct SDK keys (vf_server_*/vf_client_*) and flags: format - const sdkKeys = Array.from( - Object.values(env).reduce>((acc, value) => { - if (typeof value === 'string') { - if (SDK_KEY_REGEX.test(value)) { - acc.add(value); - } else if (value.startsWith('flags:')) { - const params = new URLSearchParams(value.slice('flags:'.length)); - const sdkKey = params.get('sdkKey'); - if (sdkKey && SDK_KEY_REGEX.test(sdkKey)) { - acc.add(sdkKey); - } - } - } - return acc; - }, new Set()), - ); - - output?.debug(`vercel-flags: found ${sdkKeys.length} SDK keys`); + output?.debug('vercel-flags: checking env vars for SDK Keys and OIDC Token'); - if (sdkKeys.length === 0) { - return { created: false, reason: 'no-sdk-keys' }; + const entries = collectFlagEntries(env, output); + if (entries.length === 0) { + return { created: false, reason: 'no-flags-entries' }; } - // Fetch definitions for each SDK key - const fetchPromise = Promise.all( - sdkKeys.map(async (sdkKey) => { - const headers: Record = { - authorization: `Bearer ${sdkKey}`, - 'user-agent': [ - `@vercel/prepare-flags-definitions/${PACKAGE_VERSION}`, - userAgentSuffix, - ] - .filter(Boolean) - .join(' '), - }; - - // Add Vercel metadata headers if available - if (env.VERCEL_PROJECT_ID) { - headers['x-vercel-project-id'] = env.VERCEL_PROJECT_ID; - } - if (env.VERCEL_ENV) { - headers['x-vercel-env'] = env.VERCEL_ENV; - } - if (env.VERCEL_DEPLOYMENT_ID) { - headers['x-vercel-deployment-id'] = env.VERCEL_DEPLOYMENT_ID; - } - if (env.VERCEL_REGION) { - headers['x-vercel-region'] = env.VERCEL_REGION; + // fetch all datafiles for sdk keys and oidc tokens + const resolvedEntries = await Promise.all( + entries.map(async ({ key, type }) => { + const definitions = await fetchDatafile( + key, + env, + fetchFn, + userAgentSuffix, + ); + if (!definitions) { + return; } - const res = await fetchFn(`${FLAGS_HOST}/v1/datafile`, { headers }); + if (type === 'oidcToken') { + const projectId = getProjectIdFromOidcToken(key); - if (!res.ok) { - throw new Error( - `Failed to fetch flag definitions for ${obfuscate(sdkKey)}: ${res.status} ${res.statusText}`, - ); + if (projectId) { + return { + key: projectId, + definitions, + }; + } } - return res.json() as Promise; + if (type === 'sdkKey') { + return { + key: hashSdkKey(key), + definitions, + }; + } }), ); - const values = output - ? await output.time('vercel-flags: load datafiles', fetchPromise) - : await fetchPromise; + const validEntries = resolvedEntries.filter((entry) => !!entry); - // Generate the JS module - const definitionsJs = generateDefinitionsModule(sdkKeys, values); + const definitionsJs = generateDefinitionsModule(validEntries, output); // Write to node_modules/@vercel/flags-definitions/ const storageDir = join(cwd, 'node_modules', '@vercel', 'flags-definitions'); @@ -216,7 +333,7 @@ export async function prepareFlagsDefinitions(options: { const packageJsonPath = join(storageDir, 'package.json'); const dts = [ - 'export function get(hashedSdkKey: string): Record | null;', + 'export function get(key: string): Record | null;', 'export const version: string;', '', ].join('\n'); @@ -246,9 +363,6 @@ export async function prepareFlagsDefinitions(options: { output?.debug(` → ${indexPath}`); output?.debug(` → ${dtsPath}`); output?.debug(` → ${packageJsonPath}`); - output?.debug( - ` → included definitions for keys "${sdkKeys.map((key) => obfuscate(key)).join(', ')}"`, - ); - return { created: true, sdkKeysCount: sdkKeys.length }; + return { created: true, entryCount: entries.length }; } diff --git a/packages/vercel-flags-core/CLAUDE.md b/packages/vercel-flags-core/CLAUDE.md index 69fb0314..a2409216 100644 --- a/packages/vercel-flags-core/CLAUDE.md +++ b/packages/vercel-flags-core/CLAUDE.md @@ -84,7 +84,7 @@ type FlagsClient = { ```typescript type ControllerOptions = { - sdkKey: string; + sdkKey?: string; datafile?: Datafile; // Initial datafile for immediate reads stream?: boolean | { initTimeoutMs: number }; // default: true (3000ms) polling?: boolean | { intervalMs: number; initTimeoutMs: number }; // default: true (30s interval, 3s timeout) diff --git a/packages/vercel-flags-core/package.json b/packages/vercel-flags-core/package.json index cc1a4c3f..8a708654 100644 --- a/packages/vercel-flags-core/package.json +++ b/packages/vercel-flags-core/package.json @@ -74,6 +74,7 @@ }, "dependencies": { "@vercel/functions": "^3.4.3", + "@vercel/oidc": "3.4.0", "jose": "5.2.1", "js-xxhash": "4.0.0" }, diff --git a/packages/vercel-flags-core/src/controller/auth.ts b/packages/vercel-flags-core/src/controller/auth.ts new file mode 100644 index 00000000..d2f9b986 --- /dev/null +++ b/packages/vercel-flags-core/src/controller/auth.ts @@ -0,0 +1,90 @@ +import { getVercelOidcToken } from '@vercel/oidc'; +import { parseSdkKeyFromFlagsConnectionString } from '../utils/sdk-keys'; + +export type BundledDefinitionsLookup = + | { type: 'sdk-key'; sdkKey: string } + | { type: 'project-id'; projectId: string }; + +export interface Auth { + sdkKey?: string; + resolveToken(): Promise; + resolveBundledDefinitionsLookup(): Promise; +} + +async function getOidcToken(): Promise { + try { + return await getVercelOidcToken(); + } catch { + throw new Error( + [ + '@vercel/flags-core: Failed to get OIDC token.', + 'Are you running in a Vercel Environment where OIDC tokens are available?', + 'Did you mean to use an SDK Key instead? Use the environment variable FLAGS or pass it directly to the client.', + ].join(' '), + ); + } +} + +function getProjectIdFromOidcToken(oidcToken: string): string { + const tokenParts = oidcToken.split('.'); + if (tokenParts.length !== 3 || !tokenParts[1]) { + throw new Error('@vercel/flags-core: Invalid OIDC token'); + } + + const payload = JSON.parse( + Buffer.from(tokenParts[1], 'base64url').toString('utf8'), + ) as { project_id?: unknown }; + + if (typeof payload.project_id !== 'string' || !payload.project_id) { + throw new Error( + '@vercel/flags-core: Missing project_id claim in OIDC token', + ); + } + + return payload.project_id; +} + +export class Authentication implements Auth { + public readonly sdkKey?: string; + + constructor(sdkKeyOrConnectionString: string | undefined) { + // validate sdk key format + if (sdkKeyOrConnectionString !== undefined) { + if (typeof sdkKeyOrConnectionString !== 'string') { + throw new Error( + `@vercel/flags-core: Invalid sdkKey. Expected string, got ${typeof sdkKeyOrConnectionString}`, + ); + } + + // Parse connection string if needed (e.g., "flags:edgeConfigId=...&sdkKey=vf_xxx") + const parsed = parseSdkKeyFromFlagsConnectionString( + sdkKeyOrConnectionString, + ); + if (!parsed) { + throw new Error('@vercel/flags-core: Missing sdkKey'); + } + + this.sdkKey = parsed; + } + } + + public async resolveToken() { + if (this.sdkKey) { + return this.sdkKey; + } + + return await getOidcToken(); + } + + public async resolveBundledDefinitionsLookup(): Promise { + if (this.sdkKey) { + return { type: 'sdk-key', sdkKey: this.sdkKey }; + } + + const oidcToken = await this.resolveToken(); + return { + type: 'project-id', + projectId: getProjectIdFromOidcToken(oidcToken), + }; + } +} diff --git a/packages/vercel-flags-core/src/controller/bundled-source.ts b/packages/vercel-flags-core/src/controller/bundled-source.ts index 8a7a47de..139dbc79 100644 --- a/packages/vercel-flags-core/src/controller/bundled-source.ts +++ b/packages/vercel-flags-core/src/controller/bundled-source.ts @@ -5,6 +5,7 @@ import type { DatafileInput, } from '../types'; import type { readBundledDefinitions } from '../utils/read-bundled-definitions'; +import type { Auth } from './auth'; /** * Manages loading of bundled flag definitions. @@ -12,17 +13,13 @@ import type { readBundledDefinitions } from '../utils/read-bundled-definitions'; */ export class BundledSource { private promise: Promise | undefined; - private options: { - sdkKey: string; - readBundledDefinitions: typeof readBundledDefinitions; - }; - constructor(options: { - sdkKey: string; - readBundledDefinitions: typeof readBundledDefinitions; - }) { - this.options = options; - } + constructor( + private options: { + auth: Auth; + readBundledDefinitions: typeof readBundledDefinitions; + }, + ) {} /** * Load bundled definitions. @@ -76,7 +73,7 @@ export class BundledSource { private getResult(): Promise { if (!this.promise) { - this.promise = this.options.readBundledDefinitions(this.options.sdkKey); + this.promise = this.options.readBundledDefinitions(this.options.auth); } return this.promise; } diff --git a/packages/vercel-flags-core/src/controller/fetch-datafile.ts b/packages/vercel-flags-core/src/controller/fetch-datafile.ts index b63b01b6..6427e7dd 100644 --- a/packages/vercel-flags-core/src/controller/fetch-datafile.ts +++ b/packages/vercel-flags-core/src/controller/fetch-datafile.ts @@ -1,5 +1,6 @@ import { version } from '../../package.json'; import type { BundledDefinitions } from '../types'; +import type { Auth } from './auth'; const DEFAULT_FETCH_TIMEOUT_MS = 10_000; @@ -8,10 +9,12 @@ const DEFAULT_FETCH_TIMEOUT_MS = 10_000; */ export async function fetchDatafile(options: { host: string; - sdkKey: string; + auth: Auth; fetch: typeof globalThis.fetch; signal?: AbortSignal; }): Promise { + const token = await options.auth.resolveToken(); + const controller = new AbortController(); const timeoutId = setTimeout( () => controller.abort(), @@ -31,7 +34,7 @@ export async function fetchDatafile(options: { try { const res = await options.fetch(`${options.host}/v1/datafile`, { headers: { - Authorization: `Bearer ${options.sdkKey}`, + Authorization: `Bearer ${token}`, 'User-Agent': `VercelFlagsCore/${version}`, ...(process.env.VERCEL_ENV ? { 'X-Vercel-Env': process.env.VERCEL_ENV } diff --git a/packages/vercel-flags-core/src/controller/index.ts b/packages/vercel-flags-core/src/controller/index.ts index c173e69b..5fd6db29 100644 --- a/packages/vercel-flags-core/src/controller/index.ts +++ b/packages/vercel-flags-core/src/controller/index.ts @@ -119,16 +119,6 @@ export class Controller implements ControllerInterface { private unauthorized = false; constructor(options: ControllerOptions) { - if ( - !options.sdkKey || - typeof options.sdkKey !== 'string' || - !options.sdkKey.startsWith('vf_') - ) { - throw new Error( - '@vercel/flags-core: SDK key must be a string starting with "vf_"', - ); - } - this.options = normalizeOptions(options); // Create source modules @@ -140,7 +130,7 @@ export class Controller implements ControllerInterface { this.pollingSource = new PollingSource(this.options); this.bundledSource = new BundledSource({ - sdkKey: this.options.sdkKey, + auth: this.options.auth, readBundledDefinitions, }); @@ -388,7 +378,7 @@ export class Controller implements ControllerInterface { try { const fetched = await fetchDatafile({ host: this.options.host, - sdkKey: this.options.sdkKey, + auth: this.options.auth, fetch: this.options.fetch, }); this.data = tagData(fetched, 'fetched'); @@ -624,7 +614,7 @@ export class Controller implements ControllerInterface { try { const fetched = await fetchDatafile({ host: this.options.host, - sdkKey: this.options.sdkKey, + auth: this.options.auth, fetch: this.options.fetch, }); return tagData(fetched, 'fetched'); @@ -665,7 +655,7 @@ export class Controller implements ControllerInterface { try { const fetched = await fetchDatafile({ host: this.options.host, - sdkKey: this.options.sdkKey, + auth: this.options.auth, fetch: this.options.fetch, }); this.data = tagData(fetched, 'fetched'); @@ -730,7 +720,7 @@ export class Controller implements ControllerInterface { try { const fetched = await fetchDatafile({ host: this.options.host, - sdkKey: this.options.sdkKey, + auth: this.options.auth, fetch: this.options.fetch, }); this.data = tagData(fetched, 'fetched'); diff --git a/packages/vercel-flags-core/src/controller/normalized-options.ts b/packages/vercel-flags-core/src/controller/normalized-options.ts index 5474e4a3..f17b15a4 100644 --- a/packages/vercel-flags-core/src/controller/normalized-options.ts +++ b/packages/vercel-flags-core/src/controller/normalized-options.ts @@ -1,4 +1,5 @@ import type { DatafileInput, PollingOptions, StreamOptions } from '../types'; +import type { Auth } from './auth'; const DEFAULT_STREAM_INIT_TIMEOUT_MS = 3000; const DEFAULT_POLLING_INTERVAL_MS = 30_000; @@ -9,8 +10,8 @@ const DEFAULT_POLLING_INIT_TIMEOUT_MS = 3_000; * Configuration options for Controller */ export type ControllerOptions = { - /** SDK key for authentication (must start with "vf_") */ - sdkKey: string; + /** Authentication which resolves the token for requests */ + auth: Auth; /** * Initial datafile to use immediately @@ -54,7 +55,7 @@ export type ControllerOptions = { }; export type NormalizedOptions = { - sdkKey: string; + auth: Auth; datafile: DatafileInput | undefined; stream: { enabled: boolean; initTimeoutMs: number }; polling: { enabled: boolean; intervalMs: number; initTimeoutMs: number }; @@ -103,7 +104,7 @@ export function normalizeOptions( } return { - sdkKey: options.sdkKey, + auth: options.auth, datafile: options.datafile, stream, polling, diff --git a/packages/vercel-flags-core/src/controller/polling-source.ts b/packages/vercel-flags-core/src/controller/polling-source.ts index 59c808cb..e39cd191 100644 --- a/packages/vercel-flags-core/src/controller/polling-source.ts +++ b/packages/vercel-flags-core/src/controller/polling-source.ts @@ -1,10 +1,11 @@ import type { DatafileInput } from '../types'; +import type { Auth } from './auth'; import { fetchDatafile } from './fetch-datafile'; import { TypedEmitter } from './typed-emitter'; export type PollingSourceConfig = { host: string; - sdkKey: string; + auth: Auth; polling: { intervalMs: number; }; diff --git a/packages/vercel-flags-core/src/controller/stream-connection.ts b/packages/vercel-flags-core/src/controller/stream-connection.ts index fcf4c156..4019ee21 100644 --- a/packages/vercel-flags-core/src/controller/stream-connection.ts +++ b/packages/vercel-flags-core/src/controller/stream-connection.ts @@ -43,7 +43,8 @@ export type StreamCallbacks = { export type StreamConfig = { host: string; - sdkKey: string; + token?: string; + sdkKey?: string; abortController: AbortController; fetch?: typeof globalThis.fetch; /** Returns the current revision number to send as X-Revision header */ @@ -59,12 +60,11 @@ export async function connectStream( config: StreamConfig, callbacks: StreamCallbacks, ): Promise { - const { - host, - sdkKey, - abortController, - fetch: fetchFn = globalThis.fetch, - } = config; + const { host, abortController, fetch: fetchFn = globalThis.fetch } = config; + const token = config.token ?? config.sdkKey; + if (!token) { + throw new Error('stream: missing auth token'); + } const { onDatafile, onPrimed, onDisconnect } = callbacks; let retryCount = 0; let lastAttemptTime = 0; @@ -115,7 +115,7 @@ export async function connectStream( try { lastAttemptTime = Date.now(); const headers: Record = { - Authorization: `Bearer ${sdkKey}`, + Authorization: `Bearer ${token}`, 'User-Agent': `VercelFlagsCore/${version}`, 'X-Retry-Attempt': String(retryCount), }; diff --git a/packages/vercel-flags-core/src/controller/stream-source.ts b/packages/vercel-flags-core/src/controller/stream-source.ts index 93fba85c..0940d752 100644 --- a/packages/vercel-flags-core/src/controller/stream-source.ts +++ b/packages/vercel-flags-core/src/controller/stream-source.ts @@ -52,27 +52,29 @@ export class StreamSource extends TypedEmitter { ); try { - const promise = connectStream( - { - host: this.options.host, - sdkKey: this.options.sdkKey, - abortController, - fetch: this.options.fetch, - revision: this.revision, - }, - { - onDatafile: (newData) => { - this.emit('data', newData); - this.emit('connected'); + const promise = this.options.auth.resolveToken().then((token) => + connectStream( + { + host: this.options.host, + token, + abortController, + fetch: this.options.fetch, + revision: this.revision, }, - onPrimed: (message) => { - this.emit('primed', message); - this.emit('connected'); + { + onDatafile: (newData) => { + this.emit('data', newData); + this.emit('connected'); + }, + onPrimed: (message) => { + this.emit('primed', message); + this.emit('connected'); + }, + onDisconnect: () => { + this.emit('disconnected'); + }, }, - onDisconnect: () => { - this.emit('disconnected'); - }, - }, + ), ); this.promise = promise; diff --git a/packages/vercel-flags-core/src/create-raw-client.ts b/packages/vercel-flags-core/src/create-raw-client.ts index 49890862..bf4acb06 100644 --- a/packages/vercel-flags-core/src/create-raw-client.ts +++ b/packages/vercel-flags-core/src/create-raw-client.ts @@ -48,7 +48,7 @@ export function createCreateRawClient(fns: { origin, }: { controller: ControllerInterface; - origin?: { provider: string; sdkKey: string }; + origin?: { provider: string; sdkKey?: string }; }): FlagsClient { const id = idCount++; controllerInstanceMap.set(id, { diff --git a/packages/vercel-flags-core/src/index.make.test.ts b/packages/vercel-flags-core/src/index.make.test.ts index 3651aa54..89e70e9c 100644 --- a/packages/vercel-flags-core/src/index.make.test.ts +++ b/packages/vercel-flags-core/src/index.make.test.ts @@ -4,8 +4,8 @@ import { make } from './index.make'; // Mock the Controller to avoid real network calls vi.mock('./controller', () => ({ - Controller: vi.fn().mockImplementation(({ sdkKey }) => ({ - sdkKey, + Controller: vi.fn().mockImplementation(({ auth }) => ({ + auth, read: vi.fn().mockResolvedValue({ projectId: 'test', definitions: {}, @@ -20,7 +20,7 @@ vi.mock('./controller', () => ({ import { Controller } from './controller'; function createMockCreateRawClient(): ReturnType { - return vi.fn().mockImplementation(({ dataSource }) => ({ + return vi.fn().mockImplementation(({ controller }) => ({ initialize: vi.fn().mockResolvedValue(undefined), shutdown: vi.fn().mockResolvedValue(undefined), getDatafile: vi.fn().mockResolvedValue({ @@ -38,7 +38,7 @@ function createMockCreateRawClient(): ReturnType { revision: 1, }), evaluate: vi.fn().mockResolvedValue({ value: true, reason: 'static' }), - _dataSource: dataSource, // For testing inspection + _dataSource: controller, // For testing inspection })); } @@ -63,7 +63,7 @@ describe('make', () => { const client = createClient('vf_server_test_key'); expect(Controller).toHaveBeenCalledWith({ - sdkKey: 'vf_server_test_key', + auth: expect.objectContaining({ sdkKey: 'vf_server_test_key' }), }); expect(createRawClient).toHaveBeenCalled(); expect(client).toBeDefined(); @@ -78,7 +78,7 @@ describe('make', () => { const client = createClient(connectionString); expect(Controller).toHaveBeenCalledWith({ - sdkKey: 'vf_client_conn_key', + auth: expect.objectContaining({ sdkKey: 'vf_client_conn_key' }), }); expect(client).toBeDefined(); }); @@ -127,15 +127,16 @@ describe('make', () => { expect(createRawClient).toHaveBeenCalledTimes(1); }); - it('should throw if FLAGS env var is missing when accessed', () => { + it('should create an OIDC-authenticated default client if FLAGS env var is missing', () => { const createRawClient = createMockCreateRawClient(); delete process.env.FLAGS; const { flagsClient } = make(createRawClient); + const _ = flagsClient.evaluate; - expect(() => flagsClient.evaluate).toThrow( - 'flags: Missing environment variable FLAGS', - ); + expect(Controller).toHaveBeenCalledWith({ + auth: expect.objectContaining({ sdkKey: undefined }), + }); }); it('should throw if FLAGS env var has invalid value', () => { @@ -172,7 +173,7 @@ describe('make', () => { const _ = flagsClient.evaluate; expect(Controller).toHaveBeenCalledWith({ - sdkKey: 'vf_server_env_key', + auth: expect.objectContaining({ sdkKey: 'vf_server_env_key' }), }); }); @@ -185,7 +186,7 @@ describe('make', () => { const _ = flagsClient.evaluate; expect(Controller).toHaveBeenCalledWith({ - sdkKey: 'vf_client_flags_key', + auth: expect.objectContaining({ sdkKey: 'vf_client_flags_key' }), }); }); }); @@ -218,7 +219,7 @@ describe('make', () => { // Access with first key const _ = flagsClient.evaluate; expect(Controller).toHaveBeenCalledWith({ - sdkKey: 'vf_server_first_key', + auth: expect.objectContaining({ sdkKey: 'vf_server_first_key' }), }); // Reset and change env @@ -228,7 +229,7 @@ describe('make', () => { // Access again with new key const __ = flagsClient.initialize; expect(Controller).toHaveBeenCalledWith({ - sdkKey: 'vf_client_second_key', + auth: expect.objectContaining({ sdkKey: 'vf_client_second_key' }), }); }); }); diff --git a/packages/vercel-flags-core/src/index.make.ts b/packages/vercel-flags-core/src/index.make.ts index bdb0aef2..1c5aaa5a 100644 --- a/packages/vercel-flags-core/src/index.make.ts +++ b/packages/vercel-flags-core/src/index.make.ts @@ -3,14 +3,14 @@ */ import { Controller, type ControllerOptions } from './controller'; +import { Authentication } from './controller/auth'; import type { createCreateRawClient } from './create-raw-client'; import type { FlagsClient } from './types'; -import { parseSdkKeyFromFlagsConnectionString } from './utils/sdk-keys'; /** * Options for createClient */ -export type CreateClientOptions = Omit; +export type CreateClientOptions = Omit; export function make( createRawClient: ReturnType, @@ -18,7 +18,7 @@ export function make( flagsClient: FlagsClient; resetDefaultFlagsClient: () => void; createClient: >( - sdkKeyOrConnectionString: string, + sdkKeyOrConnectionString?: string, options?: CreateClientOptions, ) => FlagsClient; } { @@ -28,32 +28,16 @@ export function make( // - data source must specify the environment & projectId as sdkKey has that info // - "reuse" functionality relies on the data source having the data for all envs function createClient>( - sdkKeyOrConnectionString: string, + sdkKeyOrConnectionString?: string, options?: CreateClientOptions, ): FlagsClient { - if (!sdkKeyOrConnectionString) - throw new Error('@vercel/flags-core: Missing sdkKey'); - - if (typeof sdkKeyOrConnectionString !== 'string') - throw new Error( - `@vercel/flags-core: Invalid sdkKey. Expected string, got ${typeof sdkKeyOrConnectionString}`, - ); - - // Parse connection string if needed (e.g., "flags:edgeConfigId=...&sdkKey=vf_xxx") - const sdkKey = parseSdkKeyFromFlagsConnectionString( - sdkKeyOrConnectionString, - ); - if (!sdkKey) { - throw new Error( - '@vercel/flags-core: Missing sdkKey in connection string', - ); - } + const auth = new Authentication(sdkKeyOrConnectionString); // sdk key contains the environment - const controller = new Controller({ sdkKey, ...options }); + const controller = new Controller({ auth, ...options }); return createRawClient({ controller, - origin: { provider: 'vercel', sdkKey }, + origin: { provider: 'vercel', sdkKey: auth.sdkKey }, }); } @@ -64,15 +48,7 @@ export function make( const flagsClient: FlagsClient = new Proxy({} as FlagsClient, { get(_, prop) { if (!_defaultFlagsClient) { - if (!process.env.FLAGS) { - throw new Error('flags: Missing environment variable FLAGS'); - } - - const sdkKey = parseSdkKeyFromFlagsConnectionString(process.env.FLAGS); - if (!sdkKey) { - throw new Error('@vercel/flags-core: Missing sdkKey'); - } - _defaultFlagsClient = createClient(sdkKey); + _defaultFlagsClient = createClient(process.env.FLAGS); } return _defaultFlagsClient[prop as keyof FlagsClient]; }, diff --git a/packages/vercel-flags-core/src/types.ts b/packages/vercel-flags-core/src/types.ts index c4f7f3f5..77f0adf8 100644 --- a/packages/vercel-flags-core/src/types.ts +++ b/packages/vercel-flags-core/src/types.ts @@ -128,11 +128,12 @@ export type BulkEvaluateInput = { */ export type FlagsClient> = { /** - * Origin information for this client (provider and sdkKey) + * Origin information for this client. + * sdkKey is only present when the client was explicitly created with one. */ origin?: { provider: string; - sdkKey: string; + sdkKey?: string; }; /** * Evaluate a feature flag diff --git a/packages/vercel-flags-core/src/types/flags-definitions.d.ts b/packages/vercel-flags-core/src/types/flags-definitions.d.ts index fb289556..88e1b022 100644 --- a/packages/vercel-flags-core/src/types/flags-definitions.d.ts +++ b/packages/vercel-flags-core/src/types/flags-definitions.d.ts @@ -1,4 +1,4 @@ declare module '@vercel/flags-definitions' { - export function get(hashedSdkKey: string): Record | null; + export function get(key: string): Record | null; export const version: string; } diff --git a/packages/vercel-flags-core/src/utils/read-bundled-definitions.test.ts b/packages/vercel-flags-core/src/utils/read-bundled-definitions.test.ts index 336b28e1..6ec08fa6 100644 --- a/packages/vercel-flags-core/src/utils/read-bundled-definitions.test.ts +++ b/packages/vercel-flags-core/src/utils/read-bundled-definitions.test.ts @@ -1,4 +1,5 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import type { Auth } from '../controller/auth'; // The readBundledDefinitions function uses dynamic import which is hard to mock. // Instead, we test the behavior indirectly through the Controller @@ -7,6 +8,12 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; describe('readBundledDefinitions', () => { const originalEnv = process.env; + const auth: Auth = { + sdkKey: 'test-id', + resolveToken: () => Promise.resolve('test-id'), + resolveBundledDefinitionsLookup: () => + Promise.resolve({ type: 'sdk-key', sdkKey: 'test-id' }), + }; beforeEach(() => { vi.resetModules(); @@ -26,7 +33,7 @@ describe('readBundledDefinitions', () => { expect(typeof readBundledDefinitions).toBe('function'); // Calling it should return a promise (will likely fail since the module doesn't exist) - const result = readBundledDefinitions('test-id'); + const result = readBundledDefinitions(auth); expect(result).toBeInstanceOf(Promise); // The result should have the expected shape @@ -40,7 +47,7 @@ describe('readBundledDefinitions', () => { './read-bundled-definitions' ); - const result = await readBundledDefinitions('nonexistent-id'); + const result = await readBundledDefinitions(auth); // Since @vercel/flags-definitions/definitions.json doesn't exist in test env, // it should return either missing-file or unexpected-error @@ -55,7 +62,7 @@ describe('readBundledDefinitions', () => { './read-bundled-definitions' ); - const result = await readBundledDefinitions('nonexistent-id'); + const result = await readBundledDefinitions(auth); expect(result).toEqual({ definitions: null, @@ -63,6 +70,37 @@ describe('readBundledDefinitions', () => { }); }); + it('should read OIDC bundled definitions by project id', async () => { + const definitions = { + projectId: 'prj_test', + environment: 'production', + definitions: {}, + configUpdatedAt: 1, + digest: 'digest', + revision: 1, + }; + const get = vi.fn((key: string) => + key === 'prj_test' ? definitions : null, + ); + vi.doMock('@vercel/flags-definitions', () => ({ get })); + + const oidcAuth: Auth = { + resolveToken: () => Promise.resolve('token'), + resolveBundledDefinitionsLookup: () => + Promise.resolve({ type: 'project-id', projectId: 'prj_test' }), + }; + + const { readBundledDefinitions } = await import( + './read-bundled-definitions' + ); + + await expect(readBundledDefinitions(oidcAuth)).resolves.toEqual({ + definitions, + state: 'ok', + }); + expect(get).toHaveBeenCalledWith('prj_test'); + }); + // The detailed behavior of readBundledDefinitions is tested indirectly // through Controller tests which mock readBundledDefinitions. // Those tests cover: diff --git a/packages/vercel-flags-core/src/utils/read-bundled-definitions.ts b/packages/vercel-flags-core/src/utils/read-bundled-definitions.ts index aace6ce9..87034733 100644 --- a/packages/vercel-flags-core/src/utils/read-bundled-definitions.ts +++ b/packages/vercel-flags-core/src/utils/read-bundled-definitions.ts @@ -4,6 +4,7 @@ // is degraded or unavailable. // +import type { Auth, BundledDefinitionsLookup } from '../controller/auth'; import type { BundledDefinitions, BundledDefinitionsResult } from '../types'; /** In-memory cache of SDK key to its hashed value, so we don't re-hash repeatedly. */ @@ -35,7 +36,7 @@ function hashSdkKey(sdkKey: string): Promise { * Reads the local definitions that get bundled at build time. */ export async function readBundledDefinitions( - sdkKey: string, + auth: Auth, ): Promise { let get: (sdkKey: string) => BundledDefinitions | null; try { @@ -62,13 +63,27 @@ export async function readBundledDefinitions( return { definitions: null, state: 'missing-file' }; } - // try plain sdk key first - const entry = get(sdkKey); + let lookup: BundledDefinitionsLookup; + try { + lookup = await auth.resolveBundledDefinitionsLookup(); + } catch (error) { + return { definitions: null, state: 'unexpected-error', error }; + } + + if (lookup.type === 'project-id') { + const entry = get(lookup.projectId); + return entry + ? { definitions: entry, state: 'ok' } + : { definitions: null, state: 'missing-entry' }; + } + + // Try plain sdk key first for bundles created by older CLI versions. + const entry = get(lookup.sdkKey); if (entry) return { definitions: entry, state: 'ok' }; // try hashed key but catch any errors try { - const hashedKey = await hashSdkKey(sdkKey); + const hashedKey = await hashSdkKey(lookup.sdkKey); // try original key (older cli versions) and hashed key (newer cli versions) const hashedEntry = get(hashedKey); if (hashedEntry) return { definitions: hashedEntry, state: 'ok' }; diff --git a/packages/vercel-flags-core/src/utils/usage-tracker.test.ts b/packages/vercel-flags-core/src/utils/usage-tracker.test.ts index c2e5ace0..86895f60 100644 --- a/packages/vercel-flags-core/src/utils/usage-tracker.test.ts +++ b/packages/vercel-flags-core/src/utils/usage-tracker.test.ts @@ -1,4 +1,5 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import type { Auth } from '../controller/auth'; import { setRequestContext } from '../test-utils'; import { type FlagsConfigReadEvent, UsageTracker } from './usage-tracker'; @@ -39,9 +40,18 @@ afterEach(() => { vi.unstubAllEnvs(); }); +function createAuth(sdkKey = 'test-key'): Auth { + return { + sdkKey, + resolveToken: () => Promise.resolve(sdkKey), + resolveBundledDefinitionsLookup: () => + Promise.resolve({ type: 'sdk-key', sdkKey }), + }; +} + function createTracker(sdkKey = 'test-key') { return new UsageTracker({ - sdkKey, + auth: createAuth(sdkKey), host: 'https://example.com', fetch: fetchMock, }); @@ -137,7 +147,7 @@ describe('UsageTracker', () => { fetchMock.mockImplementation(() => jsonResponse({ ok: true })); const tracker = new UsageTracker({ - sdkKey: 'my-secret-key', + auth: createAuth('my-secret-key'), host: 'https://example.com', fetch: fetchMock, }); @@ -229,7 +239,7 @@ describe('UsageTracker', () => { ); const tracker = new FreshUsageTracker({ - sdkKey: 'test-key', + auth: createAuth('test-key'), host: 'https://example.com', fetch: fetchMock, }); @@ -267,7 +277,7 @@ describe('UsageTracker', () => { fetchMock.mockImplementation(() => jsonResponse({ ok: true })); const tracker = new FreshUsageTracker({ - sdkKey: 'test-key', + auth: createAuth('test-key'), host: 'https://example.com', fetch: fetchMock, }); @@ -367,13 +377,13 @@ describe('UsageTracker', () => { }); const tracker1 = new UsageTracker({ - sdkKey: 'key-1', + auth: createAuth('key-1'), host: 'https://example.com', fetch: fetchMock, }); const tracker2 = new UsageTracker({ - sdkKey: 'key-2', + auth: createAuth('key-2'), host: 'https://example.com', fetch: fetchMock, }); diff --git a/packages/vercel-flags-core/src/utils/usage-tracker.ts b/packages/vercel-flags-core/src/utils/usage-tracker.ts index 033b06b2..e03f53bf 100644 --- a/packages/vercel-flags-core/src/utils/usage-tracker.ts +++ b/packages/vercel-flags-core/src/utils/usage-tracker.ts @@ -1,5 +1,6 @@ import { waitUntil } from '@vercel/functions'; import { version } from '../../package.json'; +import type { Auth } from '../controller/auth'; import { getJitteredWaitMs, getRetryDelayMs } from './backoff'; const RESOLVED_VOID: Promise = Promise.resolve(); @@ -81,7 +82,7 @@ function getRequestContext(): RequestContext { } export interface UsageTrackerOptions { - sdkKey: string; + auth: Auth; host: string; fetch: typeof fetch; } @@ -259,6 +260,8 @@ export class UsageTracker { const flushId = ++this.flushCounter; + const token = await this.options.auth.resolveToken(); + for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) { try { const response = await this.options.fetch( @@ -267,7 +270,7 @@ export class UsageTracker { method: 'POST', headers: { 'Content-Type': 'application/json', - Authorization: `Bearer ${this.options.sdkKey}`, + Authorization: `Bearer ${token}`, 'User-Agent': `VercelFlagsCore/${version}`, ...(process.env.VERCEL_ENV ? { 'X-Vercel-Env': process.env.VERCEL_ENV } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9129597d..53f9234f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -40,7 +40,7 @@ importers: version: 15.2.2 next: specifier: ^16.1.6 - version: 16.1.6(@opentelemetry/api@1.9.0)(@playwright/test@1.58.1)(react-dom@19.2.4(react@19.3.0-canary-561ed529-20260423))(react@19.3.0-canary-561ed529-20260423) + version: 16.1.6(@opentelemetry/api@1.9.0)(@playwright/test@1.58.1)(react-dom@19.2.4(react@19.3.0-canary-f4e0d4ed-20260429))(react@19.3.0-canary-f4e0d4ed-20260429) playwright: specifier: 1.56.1 version: 1.56.1 @@ -49,7 +49,7 @@ importers: version: 0.3.14 react: specifier: canary - version: 19.3.0-canary-561ed529-20260423 + version: 19.3.0-canary-f4e0d4ed-20260429 turbo: specifier: 2.8.15 version: 2.8.15 @@ -484,7 +484,7 @@ importers: dependencies: '@vercel/edge-config': specifier: ^1.4.3 - version: 1.4.3(@opentelemetry/api@1.9.0)(next@16.2.0(@opentelemetry/api@1.9.0)(@playwright/test@1.58.1)(react-dom@19.2.4(react@19.3.0-canary-561ed529-20260423))(react@19.3.0-canary-561ed529-20260423)) + version: 1.4.3(@opentelemetry/api@1.9.0)(next@16.2.0(@opentelemetry/api@1.9.0)(@playwright/test@1.58.1)(react-dom@19.2.4(react@19.3.0-canary-f4e0d4ed-20260429))(react@19.3.0-canary-f4e0d4ed-20260429)) devDependencies: '@types/node': specifier: 20.11.17 @@ -546,7 +546,7 @@ importers: version: 1.6.1 '@vercel/edge-config': specifier: ^1.4.3 - version: 1.4.3(@opentelemetry/api@1.9.0)(next@16.2.0(@opentelemetry/api@1.9.0)(@playwright/test@1.58.1)(react-dom@19.2.4(react@19.3.0-canary-561ed529-20260423))(react@19.3.0-canary-561ed529-20260423)) + version: 1.4.3(@opentelemetry/api@1.9.0)(next@16.2.0(@opentelemetry/api@1.9.0)(@playwright/test@1.58.1)(react-dom@19.2.4(react@19.3.0-canary-f4e0d4ed-20260429))(react@19.3.0-canary-f4e0d4ed-20260429)) '@vercel/functions': specifier: ^1.5.2 version: 1.6.0 @@ -580,7 +580,7 @@ importers: dependencies: '@vercel/edge-config': specifier: ^1.4.3 - version: 1.4.3(@opentelemetry/api@1.9.0)(next@16.2.0(@opentelemetry/api@1.9.0)(@playwright/test@1.58.1)(react-dom@19.2.4(react@19.3.0-canary-561ed529-20260423))(react@19.3.0-canary-561ed529-20260423)) + version: 1.4.3(@opentelemetry/api@1.9.0)(next@16.2.0(@opentelemetry/api@1.9.0)(@playwright/test@1.58.1)(react-dom@19.2.4(react@19.3.0-canary-f4e0d4ed-20260429))(react@19.3.0-canary-f4e0d4ed-20260429)) hypertune: specifier: 2.8.3 version: 2.8.3 @@ -614,10 +614,10 @@ importers: dependencies: '@launchdarkly/vercel-server-sdk': specifier: ^1.3.34 - version: 1.3.34(@opentelemetry/api@1.9.0)(next@16.2.0(@opentelemetry/api@1.9.0)(@playwright/test@1.58.1)(react-dom@19.2.4(react@19.3.0-canary-561ed529-20260423))(react@19.3.0-canary-561ed529-20260423)) + version: 1.3.34(@opentelemetry/api@1.9.0)(next@16.2.0(@opentelemetry/api@1.9.0)(@playwright/test@1.58.1)(react-dom@19.2.4(react@19.3.0-canary-f4e0d4ed-20260429))(react@19.3.0-canary-f4e0d4ed-20260429)) '@vercel/edge-config': specifier: ^1.4.3 - version: 1.4.3(@opentelemetry/api@1.9.0)(next@16.2.0(@opentelemetry/api@1.9.0)(@playwright/test@1.58.1)(react-dom@19.2.4(react@19.3.0-canary-561ed529-20260423))(react@19.3.0-canary-561ed529-20260423)) + version: 1.4.3(@opentelemetry/api@1.9.0)(next@16.2.0(@opentelemetry/api@1.9.0)(@playwright/test@1.58.1)(react-dom@19.2.4(react@19.3.0-canary-f4e0d4ed-20260429))(react@19.3.0-canary-f4e0d4ed-20260429)) devDependencies: '@types/node': specifier: 20.11.17 @@ -705,7 +705,7 @@ importers: dependencies: '@vercel/edge-config': specifier: ^1.4.3 - version: 1.4.3(@opentelemetry/api@1.9.0)(next@16.2.0(@opentelemetry/api@1.9.0)(@playwright/test@1.58.1)(react-dom@19.2.4(react@19.3.0-canary-561ed529-20260423))(react@19.3.0-canary-561ed529-20260423)) + version: 1.4.3(@opentelemetry/api@1.9.0)(next@16.2.0(@opentelemetry/api@1.9.0)(@playwright/test@1.58.1)(react-dom@19.2.4(react@19.3.0-canary-f4e0d4ed-20260429))(react@19.3.0-canary-f4e0d4ed-20260429)) posthog-node: specifier: 4.11.1 version: 4.11.1 @@ -797,7 +797,7 @@ importers: dependencies: '@vercel/edge-config': specifier: ^1.4.3 - version: 1.4.3(@opentelemetry/api@1.9.0)(next@16.2.0(@opentelemetry/api@1.9.0)(@playwright/test@1.58.1)(react-dom@19.2.4(react@19.3.0-canary-561ed529-20260423))(react@19.3.0-canary-561ed529-20260423)) + version: 1.4.3(@opentelemetry/api@1.9.0)(next@16.2.0(@opentelemetry/api@1.9.0)(@playwright/test@1.58.1)(react-dom@19.2.4(react@19.3.0-canary-f4e0d4ed-20260429))(react@19.3.0-canary-f4e0d4ed-20260429)) '@vercel/functions': specifier: ^1.5.2 version: 1.6.0 @@ -806,7 +806,7 @@ importers: version: 0.5.2 statsig-node-vercel: specifier: ^0.7.0 - version: 0.7.0(@vercel/edge-config@1.4.3(@opentelemetry/api@1.9.0)(next@16.2.0(@opentelemetry/api@1.9.0)(@playwright/test@1.58.1)(react-dom@19.2.4(react@19.3.0-canary-561ed529-20260423))(react@19.3.0-canary-561ed529-20260423))) + version: 0.7.0(@vercel/edge-config@1.4.3(@opentelemetry/api@1.9.0)(next@16.2.0(@opentelemetry/api@1.9.0)(@playwright/test@1.58.1)(react-dom@19.2.4(react@19.3.0-canary-f4e0d4ed-20260429))(react@19.3.0-canary-f4e0d4ed-20260429))) devDependencies: '@types/node': specifier: 20.11.17 @@ -850,7 +850,7 @@ importers: version: 2.6.4(@types/node@20.11.17)(typescript@5.6.3) next: specifier: 16.1.5 - version: 16.1.5(@opentelemetry/api@1.9.0)(@playwright/test@1.58.1)(react-dom@19.2.4(react@19.3.0-canary-561ed529-20260423))(react@19.3.0-canary-561ed529-20260423) + version: 16.1.5(@opentelemetry/api@1.9.0)(@playwright/test@1.58.1)(react-dom@19.2.4(react@19.3.0-canary-f4e0d4ed-20260429))(react@19.3.0-canary-f4e0d4ed-20260429) tsup: specifier: 8.5.1 version: 8.5.1(jiti@2.6.1)(postcss@8.5.8)(typescript@5.6.3)(yaml@2.8.1) @@ -945,6 +945,9 @@ importers: '@vercel/functions': specifier: ^3.4.3 version: 3.4.3 + '@vercel/oidc': + specifier: 3.4.0 + version: 3.4.0 jose: specifier: 5.2.1 version: 5.2.1 @@ -960,7 +963,7 @@ importers: version: 20.11.17 next: specifier: ^16.1.6 - version: 16.1.6(@opentelemetry/api@1.9.0)(@playwright/test@1.58.1)(react-dom@19.2.4(react@19.3.0-canary-561ed529-20260423))(react@19.3.0-canary-561ed529-20260423) + version: 16.1.6(@opentelemetry/api@1.9.0)(@playwright/test@1.58.1)(react-dom@19.2.4(react@19.3.0-canary-f4e0d4ed-20260429))(react@19.3.0-canary-f4e0d4ed-20260429) tsup: specifier: 8.5.1 version: 8.5.1(jiti@2.6.1)(postcss@8.5.8)(typescript@5.6.3)(yaml@2.8.1) @@ -5035,6 +5038,10 @@ packages: resolution: {integrity: sha512-UycprH3T6n3jH0k44NHMa7pnFHGu/N05MjojYr+Mc6I7obkoLIJujSWwin1pCvdy/eOxrI/l3uDLQsmcrOb4ug==} engines: {node: '>= 20'} + '@vercel/oidc@3.4.0': + resolution: {integrity: sha512-p0sKfHkfRmMaqqDwNL4tjnX9TgRrLMlEtUjIxfrEns8pOxz1R9ztqOVI+ehqiq93/2/HnfPe/UBZkfAZwnx0UA==} + engines: {node: '>= 20'} + '@vercel/speed-insights@1.3.1': resolution: {integrity: sha512-PbEr7FrMkUrGYvlcLHGkXdCkxnylCWePx7lPxxq36DNdfo9mcUjLOmqOyPDHAOgnfqgGGdmE3XI9L/4+5fr+vQ==} peerDependencies: @@ -8349,8 +8356,8 @@ packages: resolution: {integrity: sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==} engines: {node: '>=0.10.0'} - react@19.3.0-canary-561ed529-20260423: - resolution: {integrity: sha512-tN5JiqCwYgG5kSzVIcM5Sx3NPT6+rfOCVKolHycLBZivIhfcPVQqAn5/UgSxy8QeVNrUlyniVIztNpAeKlUz5w==} + react@19.3.0-canary-f4e0d4ed-20260429: + resolution: {integrity: sha512-FNfU7Fsr/U/6t76mMAOucXovXS7536HmVK4nVWKxLAOY+P7dpZ/rJfR2If4uHjAwQoMsLxZ41gG2VOWJDkprOA==} engines: {node: '>=0.10.0'} read-cache@1.0.0: @@ -9244,6 +9251,7 @@ packages: uuid@8.3.2: resolution: {integrity: sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==} + deprecated: uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028). hasBin: true validate-npm-package-name@5.0.1: @@ -10701,10 +10709,10 @@ snapshots: '@launchdarkly/js-sdk-common': 2.19.0 semver: 7.5.4 - '@launchdarkly/vercel-server-sdk@1.3.34(@opentelemetry/api@1.9.0)(next@16.2.0(@opentelemetry/api@1.9.0)(@playwright/test@1.58.1)(react-dom@19.2.4(react@19.3.0-canary-561ed529-20260423))(react@19.3.0-canary-561ed529-20260423))': + '@launchdarkly/vercel-server-sdk@1.3.34(@opentelemetry/api@1.9.0)(next@16.2.0(@opentelemetry/api@1.9.0)(@playwright/test@1.58.1)(react-dom@19.2.4(react@19.3.0-canary-f4e0d4ed-20260429))(react@19.3.0-canary-f4e0d4ed-20260429))': dependencies: '@launchdarkly/js-server-sdk-common-edge': 2.6.9 - '@vercel/edge-config': 1.4.3(@opentelemetry/api@1.9.0)(next@16.2.0(@opentelemetry/api@1.9.0)(@playwright/test@1.58.1)(react-dom@19.2.4(react@19.3.0-canary-561ed529-20260423))(react@19.3.0-canary-561ed529-20260423)) + '@vercel/edge-config': 1.4.3(@opentelemetry/api@1.9.0)(next@16.2.0(@opentelemetry/api@1.9.0)(@playwright/test@1.58.1)(react-dom@19.2.4(react@19.3.0-canary-f4e0d4ed-20260429))(react@19.3.0-canary-f4e0d4ed-20260429)) crypto-js: 4.2.0 transitivePeerDependencies: - '@opentelemetry/api' @@ -13367,12 +13375,12 @@ snapshots: '@opentelemetry/api': 1.9.0 next: 16.1.5(@opentelemetry/api@1.9.0)(@playwright/test@1.58.1)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) - '@vercel/edge-config@1.4.3(@opentelemetry/api@1.9.0)(next@16.2.0(@opentelemetry/api@1.9.0)(@playwright/test@1.58.1)(react-dom@19.2.4(react@19.3.0-canary-561ed529-20260423))(react@19.3.0-canary-561ed529-20260423))': + '@vercel/edge-config@1.4.3(@opentelemetry/api@1.9.0)(next@16.2.0(@opentelemetry/api@1.9.0)(@playwright/test@1.58.1)(react-dom@19.2.4(react@19.3.0-canary-f4e0d4ed-20260429))(react@19.3.0-canary-f4e0d4ed-20260429))': dependencies: '@vercel/edge-config-fs': 0.1.0 optionalDependencies: '@opentelemetry/api': 1.9.0 - next: 16.2.0(@opentelemetry/api@1.9.0)(@playwright/test@1.58.1)(react-dom@19.2.4(react@19.3.0-canary-561ed529-20260423))(react@19.3.0-canary-561ed529-20260423) + next: 16.2.0(@opentelemetry/api@1.9.0)(@playwright/test@1.58.1)(react-dom@19.2.4(react@19.3.0-canary-f4e0d4ed-20260429))(react@19.3.0-canary-f4e0d4ed-20260429) '@vercel/edge@1.2.1': {} @@ -13489,6 +13497,8 @@ snapshots: '@vercel/oidc@3.2.0': {} + '@vercel/oidc@3.4.0': {} + '@vercel/speed-insights@1.3.1(@sveltejs/kit@2.50.2(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@4.0.4(svelte@5.41.3)(vite@5.4.21(@types/node@24.10.13)(lightningcss@1.30.2)))(svelte@5.41.3)(typescript@5.8.2)(vite@5.4.21(@types/node@24.10.13)(lightningcss@1.30.2)))(next@16.2.0(@opentelemetry/api@1.9.0)(@playwright/test@1.58.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react@19.2.4)(svelte@5.41.3)': optionalDependencies: '@sveltejs/kit': 2.50.2(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@4.0.4(svelte@5.41.3)(vite@5.4.21(@types/node@24.10.13)(lightningcss@1.30.2)))(svelte@5.41.3)(typescript@5.8.2)(vite@5.4.21(@types/node@24.10.13)(lightningcss@1.30.2)) @@ -16985,16 +16995,16 @@ snapshots: - '@babel/core' - babel-plugin-macros - next@16.1.5(@opentelemetry/api@1.9.0)(@playwright/test@1.58.1)(react-dom@19.2.4(react@19.3.0-canary-561ed529-20260423))(react@19.3.0-canary-561ed529-20260423): + next@16.1.5(@opentelemetry/api@1.9.0)(@playwright/test@1.58.1)(react-dom@19.2.4(react@19.3.0-canary-f4e0d4ed-20260429))(react@19.3.0-canary-f4e0d4ed-20260429): dependencies: '@next/env': 16.1.5 '@swc/helpers': 0.5.15 baseline-browser-mapping: 2.10.0 caniuse-lite: 1.0.30001777 postcss: 8.4.31 - react: 19.3.0-canary-561ed529-20260423 - react-dom: 19.2.4(react@19.3.0-canary-561ed529-20260423) - styled-jsx: 5.1.6(react@19.3.0-canary-561ed529-20260423) + react: 19.3.0-canary-f4e0d4ed-20260429 + react-dom: 19.2.4(react@19.3.0-canary-f4e0d4ed-20260429) + styled-jsx: 5.1.6(react@19.3.0-canary-f4e0d4ed-20260429) optionalDependencies: '@next/swc-darwin-arm64': 16.1.5 '@next/swc-darwin-x64': 16.1.5 @@ -17037,16 +17047,16 @@ snapshots: - '@babel/core' - babel-plugin-macros - next@16.1.6(@opentelemetry/api@1.9.0)(@playwright/test@1.58.1)(react-dom@19.2.4(react@19.3.0-canary-561ed529-20260423))(react@19.3.0-canary-561ed529-20260423): + next@16.1.6(@opentelemetry/api@1.9.0)(@playwright/test@1.58.1)(react-dom@19.2.4(react@19.3.0-canary-f4e0d4ed-20260429))(react@19.3.0-canary-f4e0d4ed-20260429): dependencies: '@next/env': 16.1.6 '@swc/helpers': 0.5.15 baseline-browser-mapping: 2.10.0 caniuse-lite: 1.0.30001777 postcss: 8.4.31 - react: 19.3.0-canary-561ed529-20260423 - react-dom: 19.2.4(react@19.3.0-canary-561ed529-20260423) - styled-jsx: 5.1.6(react@19.3.0-canary-561ed529-20260423) + react: 19.3.0-canary-f4e0d4ed-20260429 + react-dom: 19.2.4(react@19.3.0-canary-f4e0d4ed-20260429) + styled-jsx: 5.1.6(react@19.3.0-canary-f4e0d4ed-20260429) optionalDependencies: '@next/swc-darwin-arm64': 16.1.6 '@next/swc-darwin-x64': 16.1.6 @@ -17089,16 +17099,16 @@ snapshots: - '@babel/core' - babel-plugin-macros - next@16.2.0(@opentelemetry/api@1.9.0)(@playwright/test@1.58.1)(react-dom@19.2.4(react@19.3.0-canary-561ed529-20260423))(react@19.3.0-canary-561ed529-20260423): + next@16.2.0(@opentelemetry/api@1.9.0)(@playwright/test@1.58.1)(react-dom@19.2.4(react@19.3.0-canary-f4e0d4ed-20260429))(react@19.3.0-canary-f4e0d4ed-20260429): dependencies: '@next/env': 16.2.0 '@swc/helpers': 0.5.15 baseline-browser-mapping: 2.10.0 caniuse-lite: 1.0.30001777 postcss: 8.4.31 - react: 19.3.0-canary-561ed529-20260423 - react-dom: 19.2.4(react@19.3.0-canary-561ed529-20260423) - styled-jsx: 5.1.6(react@19.3.0-canary-561ed529-20260423) + react: 19.3.0-canary-f4e0d4ed-20260429 + react-dom: 19.2.4(react@19.3.0-canary-f4e0d4ed-20260429) + styled-jsx: 5.1.6(react@19.3.0-canary-f4e0d4ed-20260429) optionalDependencies: '@next/swc-darwin-arm64': 16.2.0 '@next/swc-darwin-x64': 16.2.0 @@ -17597,9 +17607,9 @@ snapshots: react: 19.2.4 scheduler: 0.27.0 - react-dom@19.2.4(react@19.3.0-canary-561ed529-20260423): + react-dom@19.2.4(react@19.3.0-canary-f4e0d4ed-20260429): dependencies: - react: 19.3.0-canary-561ed529-20260423 + react: 19.3.0-canary-f4e0d4ed-20260429 scheduler: 0.27.0 react-is@16.13.1: {} @@ -17707,7 +17717,7 @@ snapshots: react@19.2.4: {} - react@19.3.0-canary-561ed529-20260423: {} + react@19.3.0-canary-f4e0d4ed-20260429: {} read-cache@1.0.0: dependencies: @@ -18218,9 +18228,9 @@ snapshots: transitivePeerDependencies: - encoding - statsig-node-vercel@0.7.0(@vercel/edge-config@1.4.3(@opentelemetry/api@1.9.0)(next@16.2.0(@opentelemetry/api@1.9.0)(@playwright/test@1.58.1)(react-dom@19.2.4(react@19.3.0-canary-561ed529-20260423))(react@19.3.0-canary-561ed529-20260423))): + statsig-node-vercel@0.7.0(@vercel/edge-config@1.4.3(@opentelemetry/api@1.9.0)(next@16.2.0(@opentelemetry/api@1.9.0)(@playwright/test@1.58.1)(react-dom@19.2.4(react@19.3.0-canary-f4e0d4ed-20260429))(react@19.3.0-canary-f4e0d4ed-20260429))): dependencies: - '@vercel/edge-config': 1.4.3(@opentelemetry/api@1.9.0)(next@16.2.0(@opentelemetry/api@1.9.0)(@playwright/test@1.58.1)(react-dom@19.2.4(react@19.3.0-canary-561ed529-20260423))(react@19.3.0-canary-561ed529-20260423)) + '@vercel/edge-config': 1.4.3(@opentelemetry/api@1.9.0)(next@16.2.0(@opentelemetry/api@1.9.0)(@playwright/test@1.58.1)(react-dom@19.2.4(react@19.3.0-canary-f4e0d4ed-20260429))(react@19.3.0-canary-f4e0d4ed-20260429)) statsig-node-lite: 0.4.4 transitivePeerDependencies: - encoding @@ -18384,10 +18394,10 @@ snapshots: client-only: 0.0.1 react: 19.2.0 - styled-jsx@5.1.6(react@19.3.0-canary-561ed529-20260423): + styled-jsx@5.1.6(react@19.3.0-canary-f4e0d4ed-20260429): dependencies: client-only: 0.0.1 - react: 19.3.0-canary-561ed529-20260423 + react: 19.3.0-canary-f4e0d4ed-20260429 stylis@4.3.6: {} From 6180dbf00165a28302f667b9550633fb02e1ceb9 Mon Sep 17 00:00:00 2001 From: Luis Meyer Date: Thu, 28 May 2026 09:26:05 +0200 Subject: [PATCH 42/42] bump oidc package --- packages/vercel-flags-core/package.json | 2 +- pnpm-lock.yaml | 13 ++++++------- 2 files changed, 7 insertions(+), 8 deletions(-) diff --git a/packages/vercel-flags-core/package.json b/packages/vercel-flags-core/package.json index 8a708654..b026ac9a 100644 --- a/packages/vercel-flags-core/package.json +++ b/packages/vercel-flags-core/package.json @@ -74,7 +74,7 @@ }, "dependencies": { "@vercel/functions": "^3.4.3", - "@vercel/oidc": "3.4.0", + "@vercel/oidc": "3.5.0", "jose": "5.2.1", "js-xxhash": "4.0.0" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 53f9234f..a19d26e8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -946,8 +946,8 @@ importers: specifier: ^3.4.3 version: 3.4.3 '@vercel/oidc': - specifier: 3.4.0 - version: 3.4.0 + specifier: 3.5.0 + version: 3.5.0 jose: specifier: 5.2.1 version: 5.2.1 @@ -963,7 +963,7 @@ importers: version: 20.11.17 next: specifier: ^16.1.6 - version: 16.1.6(@opentelemetry/api@1.9.0)(@playwright/test@1.58.1)(react-dom@19.2.4(react@19.3.0-canary-f4e0d4ed-20260429))(react@19.3.0-canary-f4e0d4ed-20260429) + version: 16.2.0(@opentelemetry/api@1.9.0)(@playwright/test@1.58.1)(react-dom@19.2.4(react@19.3.0-canary-f4e0d4ed-20260429))(react@19.3.0-canary-f4e0d4ed-20260429) tsup: specifier: 8.5.1 version: 8.5.1(jiti@2.6.1)(postcss@8.5.8)(typescript@5.6.3)(yaml@2.8.1) @@ -5038,8 +5038,8 @@ packages: resolution: {integrity: sha512-UycprH3T6n3jH0k44NHMa7pnFHGu/N05MjojYr+Mc6I7obkoLIJujSWwin1pCvdy/eOxrI/l3uDLQsmcrOb4ug==} engines: {node: '>= 20'} - '@vercel/oidc@3.4.0': - resolution: {integrity: sha512-p0sKfHkfRmMaqqDwNL4tjnX9TgRrLMlEtUjIxfrEns8pOxz1R9ztqOVI+ehqiq93/2/HnfPe/UBZkfAZwnx0UA==} + '@vercel/oidc@3.5.0': + resolution: {integrity: sha512-jo7GgeJx2YMkjg9A28pFM5p88n5SnSHvDeNlf9898bRWiG9jPxwedj/gn/2XTw4UOTyQ50uHlrTGSlf/XU5tgw==} engines: {node: '>= 20'} '@vercel/speed-insights@1.3.1': @@ -13497,7 +13497,7 @@ snapshots: '@vercel/oidc@3.2.0': {} - '@vercel/oidc@3.4.0': {} + '@vercel/oidc@3.5.0': {} '@vercel/speed-insights@1.3.1(@sveltejs/kit@2.50.2(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@4.0.4(svelte@5.41.3)(vite@5.4.21(@types/node@24.10.13)(lightningcss@1.30.2)))(svelte@5.41.3)(typescript@5.8.2)(vite@5.4.21(@types/node@24.10.13)(lightningcss@1.30.2)))(next@16.2.0(@opentelemetry/api@1.9.0)(@playwright/test@1.58.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react@19.2.4)(svelte@5.41.3)': optionalDependencies: @@ -17124,7 +17124,6 @@ snapshots: transitivePeerDependencies: - '@babel/core' - babel-plugin-macros - optional: true node-emoji@2.2.0: dependencies: