diff --git a/.changeset/adapter-bulk-evaluation.md b/.changeset/adapter-bulk-evaluation.md new file mode 100644 index 00000000..135ffc64 --- /dev/null +++ b/.changeset/adapter-bulk-evaluation.md @@ -0,0 +1,5 @@ +--- +'@flags-sdk/vercel': minor +--- + +Reduces overhead when evaluating multiple flags via `evaluate()` or `precompute()` by using new bulk evaluation capabilities of `@vercel/flags-core`. 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", diff --git a/.changeset/core-bulk-evaluation.md b/.changeset/core-bulk-evaluation.md new file mode 100644 index 00000000..00674913 --- /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( + [ + { key: 'a', defaultValue: false }, + { key: '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-evaluate.md b/.changeset/flags-evaluate.md new file mode 100644 index 00000000..4fc90c3e --- /dev/null +++ b/.changeset/flags-evaluate.md @@ -0,0 +1,24 @@ +--- +'flags': minor +--- + +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'; +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 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/.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/package.json b/package.json index ef461f0d..aa2c5687 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", @@ -43,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/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.test.ts b/packages/adapter-vercel/src/index.test.ts index 0275455a..e0017dc6 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( + [ + { key: 'a', defaultValue: 'da' }, + { key: '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..5f327c88 100644 --- a/packages/adapter-vercel/src/index.ts +++ b/packages/adapter-vercel/src/index.ts @@ -23,18 +23,25 @@ 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; + // Stable identity for this adapter's underlying flagsClient. Captured in + // the closure so every adapter object the factory below returns shares it, + // letting `evaluate()` 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,24 @@ export function createVercelAdapter( // when there was an error but the defaultValue was set 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 as { key: string; defaultValue?: ValueType }[], + 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; + }, }; }; } @@ -86,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); @@ -99,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' ); } @@ -122,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/flags/src/next/evaluate.ts b/packages/flags/src/next/evaluate.ts new file mode 100644 index 00000000..297bebe6 --- /dev/null +++ b/packages/flags/src/next/evaluate.ts @@ -0,0 +1,700 @@ +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, 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, +} 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])); +} + +/** + * 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; + 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 }; + adapter?: { config?: { reportValue?: boolean } }; +}; + +function hasOverride( + overrides: Record | null, + key: string, +): overrides is Record { + 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`. + * + * 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 (hasOverride(overrides, definition.key)) { + 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 (shouldReportValue(definition)) { + // Overrides return before this point and report with `reason: "override"`. + 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; + + overrides = await readOverrides(readonlyCookies); + } 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; + + overrides = await readOverrides(readonlyCookies); + } + + // 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 function evaluate[]>( + flags: T, + request?: EvaluateRequest, +): Promise<{ [K in keyof T]: BulkValue }>; +export function evaluate>>( + flags: T, + request?: EvaluateRequest, +): Promise<{ [K in keyof T]: BulkValue }>; +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 { + // 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 overrides = await readOverrides(readonlyCookies); + + 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( + // 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. + const uncached = list.filter( + ({ flagFn }) => + getCachedValuePromise( + readonlyHeaders, + flagFn.key, + entitiesKey, + ) === undefined, + ); + const undecided = uncached.filter( + ({ flagFn }) => !hasOverride(overrides, flagFn.key), + ); + + // 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 (undecided.length > 0) { + try { + bulkResult = await adapter.bulkDecide!({ + flags: undecided.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]; + }, + }); + }), + ); + + // `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, undecided }; + }, + { + name: 'batch', + isVerboseTrace: false, + attributes: { adapterId: String(adapter.adapterId) }, + attributesSuccess: ({ uncached, undecided }) => { + const cachedCount = list.length - uncached.length; + const overrideCount = uncached.length - undecided.length; + return { + keys: list.map(({ flagFn }) => flagFn.key), + cachedCount, + overrideCount, + decidedCount: undecided.length, + }; + }, + }, + )(), + ); + } + } + + 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 7d3701db..0462b836 100644 --- a/packages/flags/src/next/index.test.ts +++ b/packages/flags/src/next/index.test.ts @@ -3,8 +3,14 @@ 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 { clearDedupeCacheForCurrentRequest, dedupe, flag, precompute } from '.'; +import { type Adapter, encryptOverrides, setTracerProvider } from '..'; +import { + clearDedupeCacheForCurrentRequest, + dedupe, + evaluate, + flag, + precompute, +} from '.'; const mocks = vi.hoisted(() => { return { @@ -258,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, { @@ -739,7 +796,7 @@ describe('adapters', () => { key: 'example-flag', defaultValue: outerValue, adapter: { - decide: ({ defaultValue }) => defaultValue || -1, + decide: ({ defaultValue }) => (defaultValue as number) || -1, origin: (key) => `fake-origin#${key}`, }, }); @@ -747,3 +804,610 @@ describe('adapters', () => { expect(await exampleFlag()).toBe(outerValue); }); }); + +describe('evaluate', () => { + 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 + // pattern where every flag does `adapter: adapter()`). + 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(evaluate({ 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(evaluate({ 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(evaluate({ 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(evaluate({ 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(evaluate({ 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(evaluate({ 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(evaluate({ 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(evaluate({ 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(evaluate({ 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(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 () => { + 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(evaluate({ 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 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(); + }); + }); +}); + +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, + }); + }); +}); diff --git a/packages/flags/src/next/index.ts b/packages/flags/src/next/index.ts index 57912a93..21b27b2c 100644 --- a/packages/flags/src/next/index.ts +++ b/packages/flags/src/next/index.ts @@ -1,39 +1,23 @@ -import type { IncomingHttpHeaders } from 'node:http'; -import { RequestCookies } from '@edge-runtime/cookies'; -import { - type FlagDefinitionsType, - type FlagDefinitionType, - type ProviderData, - reportValue, -} 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 { Decide, FlagDeclaration, - FlagParamsType, + FlagDefinitionsType, + FlagDefinitionType, Identify, JsonValue, Origin, + ProviderData, } 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, @@ -41,129 +25,6 @@ export { } from './precompute'; export type { Flag } from './types'; -// 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 { @@ -207,174 +68,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; - -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; - - if (options.request) { - // pages router - const headers = transformToHeaders(options.request.headers); - readonlyHeaders = sealHeaders(headers); - readonlyCookies = sealCookies(headers); - dedupeCacheKey = options.request.headers; - } else { - // app router - - // async import required as turbopack errors in Pages Router - // when next/headers is imported at the top-level. - // - // cache import so we don't await on every call since this adds - // additional microtask queue overhead - if (!headersModulePromise) headersModulePromise = import('next/headers'); - if (!headersModule) headersModule = await headersModulePromise; - const { headers, cookies } = headersModule; - - const [headersStore, cookiesStore] = await Promise.all([ - headers(), - cookies(), - ]); - readonlyHeaders = headersStore as ReadonlyHeaders; - readonlyCookies = cookiesStore as ReadonlyRequestCookies; - dedupeCacheKey = headersStore; - } - - // 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 - // 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) ?? ''; - - 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( - 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 getOrigin( definition: FlagDeclaration, ): string | Origin | undefined { @@ -478,6 +171,17 @@ export function flag< name: 'run', attributes: { key: definition.key }, }); + api.adapter = definition.adapter; + api.config = definition.config; + + // 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] = + !definition.decide && + !!definition.adapter?.bulkDecide && + definition.adapter.adapterId !== undefined; return api; } 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 1c90bbeb..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 */ @@ -46,6 +53,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 `evaluate()` + * 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. * @@ -55,7 +72,7 @@ type FlagMeta = { identify: | FlagDeclaration['identify'] | EntitiesType; - request?: Parameters>[0]; + request?: PagesRouterRequest; }) => Promise; }; @@ -64,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 911e24f5..cdd1cbc4 100644 --- a/packages/flags/src/types.ts +++ b/packages/flags/src/types.ts @@ -146,13 +146,47 @@ 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, + * `evaluate()` 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; 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 `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. + * - Throwing causes per-flag `defaultValue` fallback (and rejection for + * flags without a `defaultValue`). + */ + bulkDecide?: (params: { + // `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; + }) => Promise> | Record; } /** 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..b026ac9a 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.5.0", "jose": "5.2.1", "js-xxhash": "4.0.0" }, 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, diff --git a/packages/vercel-flags-core/src/controller-fns.ts b/packages/vercel-flags-core/src/controller-fns.ts index 6a3cb8db..f6b779b9 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,94 @@ 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.key] = { + 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 { key, defaultValue } = flag; + const flagDefinition = datafile.definitions[key] as Packed.FlagDefinition; + + if (flagDefinition === undefined) { + if (projectId) { + internalReportValue(key, defaultValue, { + originProjectId: projectId, + originProvider: 'vercel', + reason: ResolutionReason.ERROR, + }); + } + results[key] = { + value: defaultValue, + reason: ResolutionReason.ERROR, + errorCode: ErrorCode.FLAG_NOT_FOUND, + errorMessage: `@vercel/flags-core: Definition not found for flag "${key}"`, + metrics: { evaluationMs: 0, ...baseMetrics }, + }; + continue; + } + + toEvaluate[key] = { 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 key in toEvaluate) { + const result = evaluated[key]!; + if (projectId) { + internalReportValue(key, result.value, { + originProjectId: projectId, + originProvider: 'vercel', + reason: result.reason, + outcomeType: + result.reason !== ResolutionReason.ERROR + ? result.outcomeType + : undefined, + }); + } + results[key] = Object.assign(result, { + metrics: { evaluationMs: evaluationDurationMs, ...baseMetrics }, + }); + } + + return results; +} 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 a9d2494d..bf4acb06 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>({ @@ -45,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, { @@ -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 a1e3b6f6..4fdcafee 100644 --- a/packages/vercel-flags-core/src/evaluate.test.ts +++ b/packages/vercel-flags-core/src/evaluate.test.ts @@ -1,5 +1,5 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { evaluate } from './evaluate'; +import { bulkEvaluate, evaluate } from './evaluate'; import { Comparator, type EvaluationResult, @@ -2443,3 +2443,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 85a7710b..3ce46f6f 100644 --- a/packages/vercel-flags-core/src/evaluate.ts +++ b/packages/vercel-flags-core/src/evaluate.ts @@ -585,6 +585,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.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/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 fb684939..77f0adf8 100644 --- a/packages/vercel-flags-core/src/types.ts +++ b/packages/vercel-flags-core/src/types.ts @@ -115,16 +115,25 @@ export type Source = { projectSlug: string; }; +/** + * Input for a single flag in a bulk evaluation call. + */ +export type BulkEvaluateInput = { + key: string; + defaultValue?: T; +}; + /** * A client for Vercel Flags */ 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 @@ -141,6 +150,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 `{ key, defaultValue? }` entries to evaluate. + * @param entities Shared entities used for every flag in the bulk call. + * @returns Object mapping each key to its EvaluationResult. + */ + bulkEvaluate: ( + flags: BulkEvaluateInput[], + entities?: E, + ) => Promise>>; /** * Retrieve the latest datafile during startup, and set up subscriptions if needed. */ 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 e9caede8..a19d26e8 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 @@ -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.5.0 + version: 3.5.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.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) @@ -1238,24 +1241,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 +1291,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 +2286,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 +2613,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 +3891,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 +4307,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 +4842,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==} @@ -4966,6 +5038,10 @@ packages: resolution: {integrity: sha512-UycprH3T6n3jH0k44NHMa7pnFHGu/N05MjojYr+Mc6I7obkoLIJujSWwin1pCvdy/eOxrI/l3uDLQsmcrOb4ug==} engines: {node: '>= 20'} + '@vercel/oidc@3.5.0': + resolution: {integrity: sha512-jo7GgeJx2YMkjg9A28pFM5p88n5SnSHvDeNlf9898bRWiG9jPxwedj/gn/2XTw4UOTyQ50uHlrTGSlf/XU5tgw==} + engines: {node: '>= 20'} + '@vercel/speed-insights@1.3.1': resolution: {integrity: sha512-PbEr7FrMkUrGYvlcLHGkXdCkxnylCWePx7lPxxq36DNdfo9mcUjLOmqOyPDHAOgnfqgGGdmE3XI9L/4+5fr+vQ==} peerDependencies: @@ -5398,10 +5474,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 +7144,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==} @@ -8276,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: @@ -9171,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: @@ -9715,9 +9796,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 +9812,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 +9825,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 +9833,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 +9852,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 +9864,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 +9879,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 +9893,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 @@ -10630,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' @@ -13296,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': {} @@ -13418,6 +13497,8 @@ snapshots: '@vercel/oidc@3.2.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: '@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)) @@ -13982,8 +14063,6 @@ snapshots: chownr@3.0.0: {} - ci-info@3.9.0: {} - cjs-module-lexer@1.4.3: {} class-variance-authority@0.7.1: @@ -16916,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 @@ -16968,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 @@ -17020,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 @@ -17045,7 +17124,6 @@ snapshots: transitivePeerDependencies: - '@babel/core' - babel-plugin-macros - optional: true node-emoji@2.2.0: dependencies: @@ -17528,9 +17606,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: {} @@ -17638,7 +17716,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: @@ -18149,9 +18227,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 @@ -18315,10 +18393,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: {}