From c6d1f6d7047022721d9e2332b5ebe068192c4f01 Mon Sep 17 00:00:00 2001 From: Dominik Ferber Date: Mon, 6 Apr 2026 19:07:12 +0300 Subject: [PATCH] extract reusable engine --- .../flags/src/engine/adapter-resolution.ts | 62 +++ packages/flags/src/engine/evaluation-cache.ts | 46 ++ packages/flags/src/engine/flag-metadata.ts | 28 ++ packages/flags/src/engine/index.ts | 17 + packages/flags/src/engine/precompute.ts | 141 ++++++ packages/flags/src/engine/provider-data.ts | 33 ++ packages/flags/src/engine/resolve-flag.ts | 159 +++++++ packages/flags/src/engine/seal.ts | 30 ++ packages/flags/src/engine/types.ts | 61 +++ packages/flags/src/next/index.ts | 435 ++++-------------- packages/flags/src/next/precompute.ts | 101 +--- packages/flags/src/sveltekit/index.ts | 154 ++----- packages/flags/src/sveltekit/precompute.ts | 116 +---- 13 files changed, 733 insertions(+), 650 deletions(-) create mode 100644 packages/flags/src/engine/adapter-resolution.ts create mode 100644 packages/flags/src/engine/evaluation-cache.ts create mode 100644 packages/flags/src/engine/flag-metadata.ts create mode 100644 packages/flags/src/engine/index.ts create mode 100644 packages/flags/src/engine/precompute.ts create mode 100644 packages/flags/src/engine/provider-data.ts create mode 100644 packages/flags/src/engine/resolve-flag.ts create mode 100644 packages/flags/src/engine/seal.ts create mode 100644 packages/flags/src/engine/types.ts diff --git a/packages/flags/src/engine/adapter-resolution.ts b/packages/flags/src/engine/adapter-resolution.ts new file mode 100644 index 00000000..6c335fe6 --- /dev/null +++ b/packages/flags/src/engine/adapter-resolution.ts @@ -0,0 +1,62 @@ +import type { Decide, FlagDeclaration, Identify, Origin } from '../types'; + +/** + * Resolves the `decide` function from a flag declaration, checking + * the explicit declaration first, then the adapter. + */ +export function getDecide( + definition: FlagDeclaration, +): Decide { + if (definition.adapter && typeof definition.adapter.decide !== 'function') { + throw new Error( + `flags: You passed an adapter that does not have a "decide" method for flag "${definition.key}". Did you pass "adapter: exampleAdapter" instead of "adapter: exampleAdapter()"?`, + ); + } + + if ( + typeof definition.decide !== 'function' && + typeof definition.adapter?.decide !== 'function' + ) { + throw new Error( + `flags: You passed a flag declaration that does not have a "decide" method for flag "${definition.key}"`, + ); + } + + return function decide(params) { + if (typeof definition.decide === 'function') { + return definition.decide(params); + } + if (typeof definition.adapter?.decide === 'function') { + return definition.adapter.decide({ key: definition.key, ...params }); + } + throw new Error(`flags: No decide function provided for ${definition.key}`); + }; +} + +/** + * Resolves the `identify` function from a flag declaration, checking + * the explicit declaration first, then the adapter. + */ +export function getIdentify( + definition: FlagDeclaration, +): Identify | undefined { + if (typeof definition.identify === 'function') { + return definition.identify; + } + if (typeof definition.adapter?.identify === 'function') { + return definition.adapter.identify; + } +} + +/** + * Resolves the `origin` from a flag declaration, checking + * the explicit declaration first, then the adapter. + */ +export function getOrigin( + definition: FlagDeclaration, +): string | Origin | undefined { + if (definition.origin) return definition.origin; + if (typeof definition.adapter?.origin === 'function') + return definition.adapter.origin(definition.key); + return definition.adapter?.origin; +} diff --git a/packages/flags/src/engine/evaluation-cache.ts b/packages/flags/src/engine/evaluation-cache.ts new file mode 100644 index 00000000..15b2f5c8 --- /dev/null +++ b/packages/flags/src/engine/evaluation-cache.ts @@ -0,0 +1,46 @@ +/** + * A three-level WeakMap for per-request flag evaluation deduplication. + * + * Structure: cacheKey -> flagKey -> entitiesKey -> valuePromise + * + * The `cacheKey` is an object reference (typically Headers or a request object) + * that is the same for the duration of a single request, ensuring values are + * cached per-request via WeakMap. + */ +const evaluationCache = new WeakMap< + object, + Map> +>(); + +export function getCachedValuePromise( + cacheKey: object, + flagKey: string, + entitiesKey: string, +): any { + return evaluationCache.get(cacheKey)?.get(flagKey)?.get(entitiesKey); +} + +export function setCachedValuePromise( + cacheKey: object, + flagKey: string, + entitiesKey: string, + flagValue: any, +): void { + const byKey = evaluationCache.get(cacheKey); + + if (!byKey) { + evaluationCache.set( + cacheKey, + new Map([[flagKey, new Map([[entitiesKey, flagValue]])]]), + ); + return; + } + + const byFlagKey = byKey.get(flagKey); + if (!byFlagKey) { + byKey.set(flagKey, new Map([[entitiesKey, flagValue]])); + return; + } + + byFlagKey.set(entitiesKey, flagValue); +} diff --git a/packages/flags/src/engine/flag-metadata.ts b/packages/flags/src/engine/flag-metadata.ts new file mode 100644 index 00000000..1a915e8a --- /dev/null +++ b/packages/flags/src/engine/flag-metadata.ts @@ -0,0 +1,28 @@ +import { normalizeOptions } from '../lib/normalize-options'; +import type { Decide, FlagDeclaration, Identify } from '../types'; + +/** + * Attaches flag metadata properties to a flag function. + * Both Next.js and SvelteKit attach the same set of properties. + */ +export function attachFlagMetadata( + fn: Record, + definition: FlagDeclaration, + { + decide, + identify, + origin, + }: { + decide: Decide; + identify?: Identify; + origin?: FlagDeclaration['origin']; + }, +): void { + fn.key = definition.key; + fn.defaultValue = definition.defaultValue; + fn.origin = origin; + fn.description = definition.description; + fn.options = normalizeOptions(definition.options); + fn.decide = decide; + fn.identify = identify; +} diff --git a/packages/flags/src/engine/index.ts b/packages/flags/src/engine/index.ts new file mode 100644 index 00000000..03da846a --- /dev/null +++ b/packages/flags/src/engine/index.ts @@ -0,0 +1,17 @@ +export { getDecide, getIdentify, getOrigin } from './adapter-resolution'; +export { + getCachedValuePromise, + setCachedValuePromise, +} from './evaluation-cache'; +export { attachFlagMetadata } from './flag-metadata'; +export { + combine, + deserialize, + generatePermutations, + getPrecomputed, + serialize, +} from './precompute'; +export { getProviderData } from './provider-data'; +export { resolveFlag } from './resolve-flag'; +export { sealCookies, sealHeaders } from './seal'; +export type { FlagLike, RequestContext, ResolveFlagOptions } from './types'; diff --git a/packages/flags/src/engine/precompute.ts b/packages/flags/src/engine/precompute.ts new file mode 100644 index 00000000..a3ef1f2f --- /dev/null +++ b/packages/flags/src/engine/precompute.ts @@ -0,0 +1,141 @@ +import * as s from '../lib/serialization'; +import type { JsonValue } from '../types'; +import type { FlagLike } from './types'; + +type ValuesArray = readonly any[]; + +/** + * Combines flag declarations with values into a record. + * @param flags - flag declarations + * @param values - flag values + * @returns A record where the keys are flag keys and the values are flag values. + */ +export function combine(flags: readonly FlagLike[], values: ValuesArray) { + return Object.fromEntries(flags.map((flag, i) => [flag.key, values[i]])); +} + +/** + * Takes a list of feature flag declarations and their values and turns them into a short, signed string. + * + * The returned string is signed to avoid enumeration attacks. + * + * When a feature flag's `options` contains the value the flag resolved to, then the encoding will store its index only, leading to better compression. Boolean values and null are compressed even when the options are not declared on the flag. + * + * @param flags - A list of feature flags + * @param values - A list of the values of the flags declared in `flags` + * @param secret - The secret to use for signing the result + * @returns A short string representing the values. + */ +export async function serialize( + flags: readonly FlagLike[], + values: ValuesArray, + secret: string, +) { + if (flags.length === 0) return '__no_flags__'; + return s.serialize(combine(flags, values), flags, secret); +} + +/** + * Decodes all flags given the list of flags used to encode. Returns an object consisting of each flag's key and its resolved value. + * @param flags - Flags used when `code` was generated by `precompute` or `serialize`. + * @param code - The code returned from `serialize` + * @param secret - The secret to use for verifying the signature + * @returns An object consisting of each flag's key and its resolved value. + */ +export async function deserialize( + flags: readonly FlagLike[], + code: string, + secret: string, +) { + if (code === '__no_flags__') return {}; + return s.deserialize(code, flags, secret); +} + +/** + * Decodes the value of one or multiple flags given the list of flags used to encode and the code. + * + * @param flagOrFlags - Flag or list of flags to decode + * @param precomputeFlags - Flags used when `code` was generated by `serialize` + * @param code - The code returned from `serialize` + * @param secret - The secret to use for verifying the signature + */ +export async function getPrecomputed( + flagOrFlags: FlagLike | readonly FlagLike[], + precomputeFlags: readonly FlagLike[], + code: string, + secret: string, +): Promise { + if (code === '__no_flags__') { + const keys = Array.isArray(flagOrFlags) + ? flagOrFlags.map((f) => f.key).join(', ') + : (flagOrFlags as FlagLike).key; + console.warn( + `flags: getPrecomputed was called with a code generated from an empty flags array. The flag(s) "${keys}" can not be resolved. Make sure to include them in the array passed to serialize/precompute.`, + ); + } + + const flagSet = await deserialize(precomputeFlags, code, secret); + + if (Array.isArray(flagOrFlags)) { + return flagOrFlags.map((flag) => { + if (!Object.hasOwn(flagSet, flag.key)) { + console.warn( + `flags: Tried to read precomputed value for flag "${flag.key}" which is not part of the precomputed flags. Make sure to include it in the array passed to serialize/precompute.`, + ); + } + return flagSet[flag.key]; + }); + } + + const key = (flagOrFlags as FlagLike).key; + if (!Object.hasOwn(flagSet, key)) { + console.warn( + `flags: Tried to read precomputed value for flag "${key}" which is not part of the precomputed flags. Make sure to include it in the array passed to serialize/precompute.`, + ); + } + return flagSet[key]; +} + +// see https://stackoverflow.com/a/44344803 +function* cartesianIterator(items: T[][]): Generator { + const remainder = items.length > 1 ? cartesianIterator(items.slice(1)) : [[]]; + for (const r of remainder) for (const h of items.at(0)!) yield [h, ...r]; +} + +/** + * Generates all permutations given a list of feature flags based on the options declared on each flag. + * @param flags - The list of feature flags + * @param filter - An optional filter function which gets called with each permutation. + * @param secret - The secret to sign the generated permutation with + * @returns An array of strings representing each permutation + */ +export async function generatePermutations( + flags: readonly FlagLike[], + filter: ((permutation: Record) => boolean) | null = null, + secret: string, +): Promise { + if (flags.length === 0) return ['__no_flags__']; + + const options = flags.map((flag) => { + // infer boolean permutations if you don't declare any options. + // + // to explicitly opt out you need to use "filter" + if (!flag.options) return [false, true]; + return flag.options.map((option) => option.value); + }); + + const list: Record[] = []; + + for (const permutation of cartesianIterator(options)) { + const permObject = permutation.reduce>( + (acc, value, index) => { + acc[flags[index]!.key] = value; + return acc; + }, + {}, + ); + if (!filter || filter(permObject)) list.push(permObject); + } + + return Promise.all(list.map((values) => s.serialize(values, flags, secret))); +} diff --git a/packages/flags/src/engine/provider-data.ts b/packages/flags/src/engine/provider-data.ts new file mode 100644 index 00000000..155f6412 --- /dev/null +++ b/packages/flags/src/engine/provider-data.ts @@ -0,0 +1,33 @@ +import { normalizeOptions } from '../lib/normalize-options'; +import type { + FlagDefinitionsType, + FlagDefinitionType, + ProviderData, +} from '../types'; +import type { FlagLike } from './types'; + +/** + * Takes an object whose values are feature flag declarations and + * turns them into ProviderData to be returned through `/.well-known/vercel/flags`. + * + * Works with any framework's flag shape that satisfies `FlagLike`. + */ +export function getProviderData( + flags: Record, +): ProviderData { + const definitions = Object.values(flags) + // filter out precomputed arrays + .filter((i): i is FlagLike => !Array.isArray(i)) + .reduce((acc, d) => { + acc[d.key] = { + options: normalizeOptions(d.options), + origin: d.origin, + description: d.description, + defaultValue: d.defaultValue, + declaredInCode: true, + } satisfies FlagDefinitionType; + return acc; + }, {}); + + return { definitions, hints: [] }; +} diff --git a/packages/flags/src/engine/resolve-flag.ts b/packages/flags/src/engine/resolve-flag.ts new file mode 100644 index 00000000..87e23c61 --- /dev/null +++ b/packages/flags/src/engine/resolve-flag.ts @@ -0,0 +1,159 @@ +import { internalReportValue, reportValue } from '../lib/report-value'; +import { setSpanAttribute } from '../lib/tracing'; +import type { Identify, JsonValue } from '../types'; +import { + getCachedValuePromise, + setCachedValuePromise, +} from './evaluation-cache'; +import type { RequestContext, ResolveFlagOptions } from './types'; + +/** + * Per-request deduplication of identify calls. + * Maps cacheKey -> (identify function -> entities promise) + */ +const identifyCache = new WeakMap< + object, + Map, ReturnType>> +>(); + +async function getEntities( + identify: Identify, + context: RequestContext, +): Promise { + let byRequest = identifyCache.get(context.cacheKey); + if (!byRequest) { + byRequest = new Map(); + identifyCache.set(context.cacheKey, byRequest); + } + + if (!byRequest.has(identify as Identify)) { + const entities = identify({ + headers: context.headers, + cookies: context.cookies, + }); + byRequest.set(identify as Identify, entities); + } + + return (await byRequest.get(identify as Identify)) as EntitiesType; +} + +/** + * Resolves a flag value using the shared pipeline. + * + * Frameworks call this after building a `RequestContext` from their + * native request type. The pipeline: + * + * 1. Check evaluation cache + * 2. Check overrides (from `vercel-flag-overrides` cookie) + * 3. Identify entities (deduplicated per request) + * 4. Call decide() + * 5. Handle errors (fallback to defaultValue, re-throw framework errors) + * 6. Cache and report the result + */ +export async function resolveFlag( + context: RequestContext, + options: ResolveFlagOptions, +): Promise { + // Read override cookie — skip microtask if cookie does not exist or is empty + const overrideCookie = context.cookies.get('vercel-flag-overrides')?.value; + const overrides = + typeof overrideCookie === 'string' && overrideCookie !== '' + ? await options.decryptOverrides(overrideCookie) + : null; + + // Identify entities — skip microtask if identify does not exist + const entities = options.identify + ? await getEntities(options.identify, context) + : undefined; + + // Check cache + const entitiesKey = JSON.stringify(entities) ?? ''; + + const cachedValue = getCachedValuePromise( + context.cacheKey, + options.key, + entitiesKey, + ); + if (cachedValue !== undefined) { + setSpanAttribute('method', 'cached'); + return await cachedValue; + } + + // Check overrides + if (overrides && overrides[options.key] !== undefined) { + setSpanAttribute('method', 'override'); + const decision = overrides[options.key] as ValueType; + setCachedValuePromise( + context.cacheKey, + options.key, + entitiesKey, + Promise.resolve(decision), + ); + internalReportValue(options.key, decision, { + reason: 'override', + }); + return decision; + } + + // Call decide — normalize sync/async results and handle sync throws + let decisionResult: ValueType | PromiseLike; + try { + decisionResult = options.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: options.defaultValue, + headers: context.headers, + cookies: context.cookies, + entities, + }); + } catch (error) { + decisionResult = Promise.reject(error); + } + + const decisionPromise = Promise.resolve(decisionResult).then< + ValueType, + ValueType + >( + (value) => { + if (value !== undefined) return value; + if (options.defaultValue !== undefined) return options.defaultValue; + throw new Error( + `flags: Flag "${options.key}" must have a defaultValue or a decide function that returns a value`, + ); + }, + (error: Error) => { + if (options.shouldRethrowError?.(error)) throw error; + + // try to recover if defaultValue is set + if (options.defaultValue !== undefined) { + if (process.env.NODE_ENV === 'development') { + console.info( + `flags: Flag "${options.key}" is falling back to its defaultValue`, + ); + } else { + console.warn( + `flags: Flag "${options.key}" is falling back to its defaultValue after catching the following error`, + error, + ); + } + return options.defaultValue; + } + console.warn(`flags: Flag "${options.key}" could not be evaluated`); + throw error; + }, + ); + + setCachedValuePromise( + context.cacheKey, + options.key, + entitiesKey, + decisionPromise, + ); + + const decision = await decisionPromise; + + if (options.config?.reportValue !== false) { + reportValue(options.key, decision); + } + + return decision; +} diff --git a/packages/flags/src/engine/seal.ts b/packages/flags/src/engine/seal.ts new file mode 100644 index 00000000..8eb78ed9 --- /dev/null +++ b/packages/flags/src/engine/seal.ts @@ -0,0 +1,30 @@ +import { RequestCookies } from '@edge-runtime/cookies'; +import { + HeadersAdapter, + type ReadonlyHeaders, +} from '../spec-extension/adapters/headers'; +import { + type ReadonlyRequestCookies, + RequestCookiesAdapter, +} from '../spec-extension/adapters/request-cookies'; + +const headersMap = new WeakMap(); +const cookiesMap = new WeakMap(); + +export 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; +} + +export 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; +} diff --git a/packages/flags/src/engine/types.ts b/packages/flags/src/engine/types.ts new file mode 100644 index 00000000..506f2463 --- /dev/null +++ b/packages/flags/src/engine/types.ts @@ -0,0 +1,61 @@ +import type { ReadonlyHeaders } from '../spec-extension/adapters/headers'; +import type { ReadonlyRequestCookies } from '../spec-extension/adapters/request-cookies'; +import type { + Decide, + FlagDeclaration, + Identify, + JsonValue, + Origin, +} from '../types'; + +/** + * Framework-agnostic request context. + * Frameworks build this from their native request types before calling engine primitives. + */ +export interface RequestContext { + headers: ReadonlyHeaders; + cookies: ReadonlyRequestCookies; + /** + * Object used as WeakMap key for per-request deduplication. + * Must be the same object reference for the same request. + * Typically the raw Headers object or the framework's request object. + */ + cacheKey: object; +} + +/** + * Options passed to `resolveFlag()` by framework adapters. + */ +export interface ResolveFlagOptions { + key: string; + defaultValue?: ValueType; + decide: Decide; + identify?: Identify; + config?: { reportValue?: boolean }; + /** + * Framework-provided override decryption. + * Called with the raw `vercel-flag-overrides` cookie value. + * Should return the decrypted overrides record, or null if decryption fails. + */ + decryptOverrides: ( + cookieValue: string, + ) => Promise | null | undefined>; + /** + * Whether a caught error is a framework-internal signal that must be re-thrown + * rather than triggering fallback to defaultValue. + * For example, Next.js re-throws redirect and dynamic usage errors. + */ + shouldRethrowError?: (error: unknown) => boolean; +} + +/** + * Minimal flag shape used by engine primitives. + * Any framework's Flag type should satisfy this interface. + */ +export interface FlagLike { + key: string; + defaultValue?: ValueType; + origin?: string | Origin; + description?: string; + options?: { value: ValueType; label?: string }[]; +} diff --git a/packages/flags/src/next/index.ts b/packages/flags/src/next/index.ts index 57912a93..0bedb0b3 100644 --- a/packages/flags/src/next/index.ts +++ b/packages/flags/src/next/index.ts @@ -1,30 +1,20 @@ import type { IncomingHttpHeaders } from 'node:http'; -import { RequestCookies } from '@edge-runtime/cookies'; +import type { FlagDefinitionType, ProviderData } from '..'; import { - type FlagDefinitionsType, - type FlagDefinitionType, - type ProviderData, - reportValue, -} from '..'; -import { normalizeOptions } from '../lib/normalize-options'; -import { internalReportValue } from '../lib/report-value'; + attachFlagMetadata, + getProviderData as engineGetProviderData, + getDecide, + getIdentify, + getOrigin, + resolveFlag, + sealCookies, + sealHeaders, +} from '../engine'; +import type { RequestContext } from '../engine/types'; 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, - Identify, - JsonValue, - Origin, -} from '../types'; +import type { ReadonlyHeaders } from '../spec-extension/adapters/headers'; +import type { ReadonlyRequestCookies } from '../spec-extension/adapters/request-cookies'; +import type { Decide, FlagDeclaration, Identify, JsonValue } from '../types'; import { isInternalNextError } from './is-internal-next-error'; import { getOverrides } from './overrides'; import { getPrecomputed } from './precompute'; @@ -41,65 +31,9 @@ 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> ->; +// Pages Router: transform IncomingHttpHeaders to Headers 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; @@ -107,12 +41,10 @@ function transformToHeaders(incomingHeaders: IncomingHttpHeaders): Headers { 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); } } @@ -121,89 +53,39 @@ function transformToHeaders(incomingHeaders: IncomingHttpHeaders): 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 { - if (definition.adapter && typeof definition.adapter.decide !== 'function') { - throw new Error( - `flags: You passed an adapter that does not have a "decide" method for flag "${definition.key}". Did you pass "adapter: exampleAdapter" instead of "adapter: exampleAdapter()"?`, - ); - } - - if ( - typeof definition.decide !== 'function' && - typeof definition.adapter?.decide !== 'function' - ) { - throw new Error( - `flags: You passed a flag declaration that does not have a "decide" method for flag "${definition.key}"`, - ); - } +// Lazy import of next/headers for App Router +let headersModulePromise: Promise | undefined; +let headersModule: typeof import('next/headers') | undefined; - return function decide(params) { - if (typeof definition.decide === 'function') { - return definition.decide(params); - } - if (typeof definition.adapter?.decide === 'function') { - return definition.adapter.decide({ key: definition.key, ...params }); - } - throw new Error(`flags: No decide function provided for ${definition.key}`); +async function getAppRouterContext(): Promise { + // 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(), + ]); + return { + headers: headersStore as ReadonlyHeaders, + cookies: cookiesStore as ReadonlyRequestCookies, + cacheKey: headersStore, }; } -function getIdentify( - definition: FlagDeclaration, -): Identify { - return function identify(params) { - if (typeof definition.identify === 'function') { - return definition.identify(params); - } - if (typeof definition.adapter?.identify === 'function') { - return definition.adapter.identify(params); - } - return definition.identify; +function getPagesRouterContext( + request: Parameters>[0], +): RequestContext { + const headers = transformToHeaders(request.headers); + return { + headers: sealHeaders(headers), + cookies: sealCookies(headers), + cacheKey: request.headers, }; } @@ -218,172 +100,6 @@ type Run = (options: { 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 { - if (definition.origin) return definition.origin; - if (typeof definition.adapter?.origin === 'function') - return definition.adapter.origin(definition.key); - return definition.adapter?.origin; -} - /** * Declares a feature flag. * @@ -405,12 +121,47 @@ export function flag< ): Flag { const decide = getDecide(definition); const identify = getIdentify(definition); - const run = getRun(definition, decide); const origin = getOrigin(definition); + function resolveFlagOptions(identifyOverride?: Identify) { + return { + key: definition.key, + defaultValue: definition.defaultValue, + config: definition.config, + decide, + identify: identifyOverride ?? identify, + decryptOverrides: (cookie: string) => getOverrides(cookie), + shouldRethrowError: isInternalNextError, + }; + } + + const run: Run = async function run( + options, + ): Promise { + const context = options.request + ? getPagesRouterContext(options.request) + : await getAppRouterContext(); + + // .run() allows passing either a function or static entities + let identifyFn: Identify | undefined; + if (options.identify) { + if (typeof options.identify === 'function') { + identifyFn = options.identify as Identify; + } else { + // Wrap static entities as an identify function + identifyFn = () => options.identify as EntitiesType; + } + } + + return resolveFlag( + context, + resolveFlagOptions(identifyFn), + ); + }; + const api = trace( async (...args: any[]) => { - // Default method, may be overwritten by `getPrecomputed` or `run` + // Default method, may be overwritten by `getPrecomputed` or `resolveFlag` // which is why we must not trace them directly in here, // as the attribute should be part of the `flag` function. setSpanAttribute('method', 'decided'); @@ -456,11 +207,7 @@ export function flag< }, ) as Flag; - api.key = definition.key; - api.defaultValue = definition.defaultValue; - api.origin = origin; - api.options = normalizeOptions(definition.options); - api.description = definition.description; + attachFlagMetadata(api, definition, { decide, identify, origin }); api.identify = identify ? trace(identify, { isVerboseTrace: false, @@ -497,23 +244,7 @@ export function getProviderData( KeyedFlagDefinitionType | readonly unknown[] >, ): ProviderData { - const definitions = Object.values(flags) - // filter out precomputed arrays - .filter((i): i is KeyedFlagDefinitionType => !Array.isArray(i)) - .reduce((acc, d) => { - // maps the existing type from the facet definitions to the type - // the toolbar expects - acc[d.key] = { - options: d.options, - origin: d.origin, - description: d.description, - defaultValue: d.defaultValue, - declaredInCode: true, - } satisfies FlagDefinitionType; - return acc; - }, {}); - - return { definitions, hints: [] }; + return engineGetProviderData(flags); } export { createFlagsDiscoveryEndpoint } from './create-flags-discovery-endpoint'; diff --git a/packages/flags/src/next/precompute.ts b/packages/flags/src/next/precompute.ts index e4571b96..43161b57 100644 --- a/packages/flags/src/next/precompute.ts +++ b/packages/flags/src/next/precompute.ts @@ -1,14 +1,20 @@ import type { JsonValue } from '..'; -import * as s from '../lib/serialization'; +import { + combine as _combine, + deserialize as _deserialize, + generatePermutations as _generatePermutations, + getPrecomputed as _getPrecomputed, + serialize as _serialize, +} from '../engine/precompute'; import type { Flag } from './types'; type FlagsArray = readonly Flag[]; -type ValuesArray = readonly any[]; /** - * Resolves a list of flags + * Resolves a list of flags. + * This is Next.js-specific because it calls flags with no arguments (App Router). * @param flags - list of flags - * @returns - an array of evaluated flag values with one entry per flag + * @returns an array of evaluated flag values with one entry per flag */ export async function evaluate( flags: T, @@ -24,7 +30,7 @@ export async function evaluate( * This convenience function call combines `evaluate` and `serialize`. * * @param flags - list of flags - * @returns - a string representing evaluated flags + * @returns a string representing evaluated flags */ export async function precompute( flags: T, @@ -37,10 +43,10 @@ export async function precompute( * Combines flag declarations with values. * @param flags - flag declarations * @param values - flag values - * @returns - A record where the keys are flag keys and the values are flag values. + * @returns A record where the keys are flag keys and the values are flag values. */ -export function combine(flags: FlagsArray, values: ValuesArray) { - return Object.fromEntries(flags.map((flag, i) => [flag.key, values[i]])); +export function combine(flags: FlagsArray, values: readonly any[]) { + return _combine(flags, values); } /** @@ -48,24 +54,21 @@ export function combine(flags: FlagsArray, values: ValuesArray) { * * The returned string is signed to avoid enumeration attacks. * - * When a feature flag's `options` contains the value the flag resolved to, then the encoding will store it's index only, leading to better compression. Boolean values and null are compressed even when the options are not declared on the flag. - * * @param flags - A list of feature flags - * @param values - A list of the values of the flags declared in ´flags` + * @param values - A list of the values of the flags declared in `flags` * @param secret - The secret to use for signing the result - * @returns - A short string representing the values. + * @returns A short string representing the values. */ export async function serialize( flags: FlagsArray, - values: ValuesArray, + values: readonly any[], secret: string | undefined = process.env.FLAGS_SECRET, ) { if (!secret) { throw new Error('flags: Can not serialize due to missing secret'); } - if (flags.length === 0) return '__no_flags__'; - return s.serialize(combine(flags, values), flags, secret); + return _serialize(flags, values, secret); } /** @@ -73,7 +76,7 @@ export async function serialize( * @param flags - Flags used when `code` was generated by `precompute` or `serialize`. * @param code - The code returned from `serialize` * @param secret - The secret to use for signing the result - * @returns - An object consisting of each flag's key and its resolved value. + * @returns An object consisting of each flag's key and its resolved value. */ export async function deserialize( flags: FlagsArray, @@ -84,8 +87,7 @@ export async function deserialize( throw new Error('flags: Can not serialize due to missing secret'); } - if (code === '__no_flags__') return {}; - return s.deserialize(code, flags, secret); + return _deserialize(flags, code, secret); } /** @@ -141,43 +143,7 @@ export async function getPrecomputed( ); } - if (code === '__no_flags__') { - const keys = Array.isArray(flagOrFlags) - ? flagOrFlags.map((f) => f.key).join(', ') - : (flagOrFlags as Flag).key; - console.warn( - `flags: getPrecomputed was called with a code generated from an empty flags array. The flag(s) "${keys}" can not be resolved. Make sure to include them in the array passed to serialize/precompute.`, - ); - } - - const flagSet = await deserialize(precomputeFlags, code, secret); - - if (Array.isArray(flagOrFlags)) { - // Handle case when an array of flags is passed - return flagOrFlags.map((flag) => { - if (!Object.hasOwn(flagSet, flag.key)) { - console.warn( - `flags: Tried to read precomputed value for flag "${flag.key}" which is not part of the precomputed flags. Make sure to include it in the array passed to serialize/precompute.`, - ); - } - return flagSet[flag.key]; - }); - } else { - // Handle case when a single flag is passed - const key = (flagOrFlags as Flag).key; - if (!Object.hasOwn(flagSet, key)) { - console.warn( - `flags: Tried to read precomputed value for flag "${key}" which is not part of the precomputed flags. Make sure to include it in the array passed to serialize/precompute.`, - ); - } - return flagSet[key]; - } -} - -// see https://stackoverflow.com/a/44344803 -function* cartesianIterator(items: T[][]): Generator { - const remainder = items.length > 1 ? cartesianIterator(items.slice(1)) : [[]]; - for (const r of remainder) for (const h of items.at(0)!) yield [h, ...r]; + return _getPrecomputed(flagOrFlags, precomputeFlags, code, secret); } /** @@ -198,28 +164,5 @@ export async function generatePermutations( ); } - if (flags.length === 0) return ['__no_flags__']; - - const options = flags.map((flag) => { - // infer boolean permutations if you don't declare any options. - // - // to explicitly opt out you need to use "filter" - if (!flag.options) return [false, true]; - return flag.options.map((option) => option.value); - }); - - const list: Record[] = []; - - for (const permutation of cartesianIterator(options)) { - const permObject = permutation.reduce>( - (acc, value, index) => { - acc[flags[index]!.key] = value; - return acc; - }, - {}, - ); - if (!filter || filter(permObject)) list.push(permObject); - } - - return Promise.all(list.map((values) => s.serialize(values, flags, secret))); + return _generatePermutations(flags, filter, secret); } diff --git a/packages/flags/src/sveltekit/index.ts b/packages/flags/src/sveltekit/index.ts index 10fff36d..19297946 100644 --- a/packages/flags/src/sveltekit/index.ts +++ b/packages/flags/src/sveltekit/index.ts @@ -1,5 +1,4 @@ import { AsyncLocalStorage } from 'node:async_hooks'; -import { RequestCookies } from '@edge-runtime/cookies'; import { error, type Handle, @@ -17,22 +16,22 @@ import { type ApiData, type FlagDefinitionsType, type JsonValue, - reportValue, safeJsonStringify, verifyAccess, version, } from '..'; -import { normalizeOptions } from '../lib/normalize-options'; -import { - HeadersAdapter, - type ReadonlyHeaders, -} from '../spec-extension/adapters/headers'; import { - type ReadonlyRequestCookies, - RequestCookiesAdapter, -} from '../spec-extension/adapters/request-cookies'; + attachFlagMetadata, + getDecide, + getIdentify, + getOrigin, + resolveFlag, + sealCookies, + sealHeaders, +} from '../engine'; +import type { RequestContext } from '../engine/types'; +import { normalizeOptions } from '../lib/normalize-options'; import type { - Decide, FlagDeclaration, FlagOverridesType, FlagValuesType, @@ -46,80 +45,21 @@ import { } from './precompute'; import type { Flag, FlagsArray } from './types'; -// biome-ignore lint/suspicious/noShadowRestrictedNames: for type safety -function hasOwnProperty( - obj: X, - prop: Y, -): obj is X & Record { - return Object.hasOwn(obj, prop); -} - -const headersMap = new WeakMap(); -const cookiesMap = new WeakMap(); - -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; -} - type PromisesMap = { [K in keyof T]: Promise; }; async function resolveObjectPromises(obj: PromisesMap): Promise { - // Convert the object into an array of [key, promise] pairs const entries = Object.entries(obj) as [keyof T, Promise][]; - - // Use Promise.all to wait for all the promises to resolve const resolvedEntries = await Promise.all( entries.map(async ([key, promise]) => { const value = await promise; return [key, value] as [keyof T, T[keyof T]]; }), ); - - // Convert the array of resolved [key, value] pairs back into an object return Object.fromEntries(resolvedEntries) as T; } -function getDecide( - definition: FlagDeclaration, -): Decide { - return function decide(params) { - if (typeof definition.decide === 'function') { - return definition.decide(params); - } - if (typeof definition.adapter?.decide === 'function') { - return definition.adapter.decide({ key: definition.key, ...params }); - } - throw new Error(`flags: No decide function provided for ${definition.key}`); - }; -} - -function getIdentify( - definition: FlagDeclaration, -): Identify | undefined { - if (typeof definition.identify === 'function') { - return definition.identify; - } - if (typeof definition.adapter?.identify === 'function') { - return definition.adapter.identify; - } -} - /** * Used when a flag is called outside of a request context, i.e. outside of the lifecycle of the `handle` hook. * This could be the case when the flag is called from routing functions. @@ -135,6 +75,7 @@ export function flag< >(definition: FlagDeclaration): Flag { const decide = getDecide(definition); const identify = getIdentify(definition); + const origin = getOrigin(definition); const flagImpl = async function flagImpl( requestOrCode?: string | Request, @@ -157,6 +98,7 @@ export function flag< } } + // Precomputed path if ( typeof requestOrCode === 'string' && Array.isArray(flagsArrayOrSecret) @@ -169,65 +111,31 @@ export function flag< ); } - if (hasOwnProperty(store.usedFlags, definition.key)) { - const valuePromise = store.usedFlags[definition.key]; - if (typeof valuePromise !== 'undefined') { - return valuePromise as Promise; - } - } - - const headers = sealHeaders(store.request.headers); - const cookies = sealCookies(store.request.headers); - - const overridesCookie = cookies.get('vercel-flag-overrides')?.value; - const overrides = overridesCookie - ? await _decryptOverrides(overridesCookie, store.secret) - : undefined; - - if (overrides && hasOwnProperty(overrides, definition.key)) { - const value = overrides[definition.key]; - if (typeof value !== 'undefined') { - reportValue(definition.key, value); - store.usedFlags[definition.key] = Promise.resolve(value as JsonValue); - return value; - } - } - - let entities: EntitiesType | undefined; - if (identify) { - // Deduplicate calls to identify, key being the function itself - if (!store.identifiers.has(identify)) { - const entities = identify({ - headers, - cookies, - }); - store.identifiers.set(identify, entities); - } - - entities = (await store.identifiers.get(identify)) as EntitiesType; - } - - const valuePromise = decide({ - headers, - cookies, - entities, + const context: RequestContext = { + headers: sealHeaders(store.request.headers), + cookies: sealCookies(store.request.headers), + cacheKey: store.request.headers, + }; + + const value = await resolveFlag(context, { + key: definition.key, + defaultValue: definition.defaultValue, + config: definition.config, + decide, + identify, + decryptOverrides: (cookie: string) => + _decryptOverrides(cookie, store!.secret), }); - store.usedFlags[definition.key] = valuePromise as Promise; - const value = await valuePromise; - reportValue(definition.key, value); + // Track used flags for HTML injection in createHandle + store.usedFlags[definition.key] = Promise.resolve(value as JsonValue); + return value; }; - flagImpl.key = definition.key; - flagImpl.defaultValue = definition.defaultValue; - flagImpl.origin = definition.origin; - flagImpl.description = definition.description; - flagImpl.options = normalizeOptions(definition.options); - flagImpl.decide = decide; - flagImpl.identify = identify; + attachFlagMetadata(flagImpl, definition, { decide, identify, origin }); - return flagImpl; + return flagImpl as Flag; } export function getProviderData(flags: Record>): ApiData { diff --git a/packages/flags/src/sveltekit/precompute.ts b/packages/flags/src/sveltekit/precompute.ts index e5b27aa1..f8d74b3d 100644 --- a/packages/flags/src/sveltekit/precompute.ts +++ b/packages/flags/src/sveltekit/precompute.ts @@ -1,13 +1,21 @@ import type { JsonValue } from '..'; -import * as s from '../lib/serialization'; +import { + combine as _combine, + deserialize as _deserialize, + generatePermutations as _generatePermutations, + getPrecomputed as _getPrecomputed, + serialize as _serialize, +} from '../engine/precompute'; import type { Flag, FlagsArray } from './types'; type ValuesArray = readonly any[]; /** - * Resolves a list of flags + * Resolves a list of flags. + * This is SvelteKit-specific because it calls flags with a Request argument. * @param flags - list of flags - * @returns - an array of evaluated flag values with one entry per flag + * @param request - the SvelteKit request + * @returns an array of evaluated flag values with one entry per flag */ async function evaluate( flags: T, @@ -24,7 +32,7 @@ async function evaluate( * This convenience function call combines `evaluate` and `serialize`. * * @param flags - list of flags - * @returns - a string representing evaluated flags + * @returns a string representing evaluated flags */ export async function precompute( flags: T, @@ -32,56 +40,13 @@ export async function precompute( secret: string, ): Promise { const values = await evaluate(flags, request); - return serialize(flags, values, secret); + return _serialize(flags, values, secret); } /** - * Combines flag declarations with values. - * @param flags - flag declarations - * @param values - flag values - * @returns - A record where the keys are flag keys and the values are flag values. - */ -function combine(flags: FlagsArray, values: ValuesArray) { - return Object.fromEntries(flags.map((flag, i) => [flag.key, values[i]])); -} - -/** - * Takes a list of feature flag declarations and their values and turns them into a short, signed string. + * Decodes the value of a flag given the list of flags used to encode and the code. * - * The returned string is signed to avoid enumeration attacks. - * - * When a feature flag's `options` contains the value the flag resolved to, then the encoding will store it's index only, leading to better compression. Boolean values and null are compressed even when the options are not declared on the flag. - * - * @param flags - A list of feature flags - * @param values - A list of the values of the flags declared in ´flags` - * @param secret - The secret to use for signing the result - * @returns - A short string representing the values. - */ -async function serialize( - flags: FlagsArray, - values: ValuesArray, - secret: string, -) { - if (flags.length === 0) return '__no_flags__'; - return s.serialize(combine(flags, values), flags, secret); -} - -/** - * Decodes all flags given the list of flags used to encode. Returns an object consisting of each flag's key and its resolved value. - * @param flags - Flags used when `code` was generated by `precompute` or `serialize`. - * @param code - The code returned from `serialize` - * @param secret - The secret to use for signing the result - * @returns - An object consisting of each flag's key and its resolved value. - */ -async function deserialize(flags: FlagsArray, code: string, secret: string) { - if (code === '__no_flags__') return {}; - return s.deserialize(code, flags, secret); -} - -/** - * Decodes the value of one or multiple flags given the list of flags used to encode and the code. - * - * @param flagKey - Flag or list of flags to decode + * @param flagKey - Flag key to decode * @param precomputeFlags - Flags used when `code` was generated by `serialize` * @param code - The code returned from `serialize` * @param secret - The secret to use for verifying the signature @@ -92,34 +57,16 @@ export async function getPrecomputed( code: string, secret: string, ): Promise { - if (code === '__no_flags__') { - console.warn( - `flags: getPrecomputed was called with a code generated from an empty flags array. The flag "${flagKey}" can not be resolved. Make sure to include it in the array passed to serialize/precompute.`, - ); - } - - const flagSet = await deserialize(precomputeFlags, code, secret); - - if (!Object.hasOwn(flagSet, flagKey)) { - console.warn( - `flags: Tried to read precomputed value for flag "${flagKey}" which is not part of the precomputed flags. Make sure to include it in the array passed to serialize/precompute.`, - ); - } - - return flagSet[flagKey]; -} - -// see https://stackoverflow.com/a/44344803 -function* cartesianIterator(items: T[][]): Generator { - const remainder = items.length > 1 ? cartesianIterator(items.slice(1)) : [[]]; - for (const r of remainder) for (const h of items.at(0)!) yield [h, ...r]; + // SvelteKit's getPrecomputed takes a key string, so we create a minimal flag-like object + const flagLike = { key: flagKey }; + return _getPrecomputed(flagLike, precomputeFlags, code, secret); } /** * Generates all permutations given a list of feature flags based on the options declared on each flag. * @param flags - The list of feature flags * @param filter - An optional filter function which gets called with each permutation. - * @param secret - The secret sign the generated permutation with + * @param secret - The secret to sign the generated permutation with * @returns An array of strings representing each permutation */ export async function generatePermutations( @@ -127,28 +74,5 @@ export async function generatePermutations( filter: ((permutation: Record) => boolean) | null = null, secret: string, ): Promise { - if (flags.length === 0) return ['__no_flags__']; - - const options = flags.map((flag) => { - // infer boolean permutations if you don't declare any options. - // - // to explicitly opt out you need to use "filter" - if (!flag.options) return [false, true]; - return flag.options.map((option) => option.value); - }); - - const list: Record[] = []; - - for (const permutation of cartesianIterator(options)) { - const permObject = permutation.reduce>( - (acc, value, index) => { - acc[(flags[index] as Flag).key] = value; - return acc; - }, - {}, - ); - if (!filter || filter(permObject)) list.push(permObject); - } - - return Promise.all(list.map((values) => s.serialize(values, flags, secret))); + return _generatePermutations(flags, filter, secret); }