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..07304c42 --- /dev/null +++ b/.changeset/flags-evaluate.md @@ -0,0 +1,22 @@ +--- +'flags': minor +--- + +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/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 3ecb8919..5f327c88 100644 --- a/packages/adapter-vercel/src/index.ts +++ b/packages/adapter-vercel/src/index.ts @@ -30,11 +30,18 @@ export function createVercelAdapter( ? createClient(sdkKeyOrFlagsClient) : sdkKeyOrFlagsClient; + // Stable identity for this adapter's underlying flagsClient. Captured in + // the closure so every adapter object the factory below returns shares it, + // letting `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; + }, }; }; } 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/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/create-raw-client.ts b/packages/vercel-flags-core/src/create-raw-client.ts index e9a773b8..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>({ @@ -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.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 1fe8010f..77f0adf8 100644 --- a/packages/vercel-flags-core/src/types.ts +++ b/packages/vercel-flags-core/src/types.ts @@ -115,6 +115,14 @@ export type Source = { projectSlug: string; }; +/** + * Input for a single flag in a bulk evaluation call. + */ +export type BulkEvaluateInput = { + key: string; + defaultValue?: T; +}; + /** * A client for Vercel Flags */ @@ -142,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/pnpm-lock.yaml b/pnpm-lock.yaml index 1bd41202..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 @@ -1241,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==} @@ -1287,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==} @@ -2282,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==} @@ -2593,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==} @@ -3855,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==} @@ -4258,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==} @@ -4781,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==} @@ -5405,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==} @@ -7079,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==} @@ -9723,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 @@ -9739,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 @@ -9752,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 @@ -9760,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 @@ -9779,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 @@ -9793,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 @@ -9808,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 @@ -9822,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 @@ -13992,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: