diff --git a/packages/flags/src/next/create-flags-discovery-endpoint.ts b/packages/flags/src/next/create-flags-discovery-endpoint.ts index f8058f11..dad4daf2 100644 --- a/packages/flags/src/next/create-flags-discovery-endpoint.ts +++ b/packages/flags/src/next/create-flags-discovery-endpoint.ts @@ -1,8 +1,7 @@ // Must not import anything other than types from next/server, as importing // the real next/server would prevent flags/next from working in Pages Router. import type { NextRequest } from 'next/server'; -import { version } from '..'; -import { verifyAccess } from '../lib/verify-access'; +import { handleDiscoveryRequest } from '../shared/discovery'; import type { ApiData } from '../types'; /** @@ -20,18 +19,15 @@ export function createFlagsDiscoveryEndpoint( }, ) { return async (request: NextRequest): Promise => { - const access = await verifyAccess( - request.headers.get('Authorization'), - options?.secret, - ); - if (!access) return Response.json(null, { status: 401 }); - - const apiData = await getApiData(request); - return new Response(JSON.stringify(apiData), { - headers: { - 'x-flags-sdk-version': version, - 'content-type': 'application/json', - }, + return handleDiscoveryRequest({ + authHeader: request.headers.get('Authorization'), + secret: options?.secret, + getApiData: () => getApiData(request), + unauthorized: () => Response.json(null, { status: 401 }), + respond: (apiData, headers) => + new Response(JSON.stringify(apiData), { + headers: { ...headers, 'content-type': 'application/json' }, + }), }); }; } diff --git a/packages/flags/src/next/evaluate.ts b/packages/flags/src/next/evaluate.ts index bf3b91b7..29645100 100644 --- a/packages/flags/src/next/evaluate.ts +++ b/packages/flags/src/next/evaluate.ts @@ -1,16 +1,11 @@ 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 { readOverrides } from '../shared/overrides'; +import { sealCookies, sealHeaders, transformToHeaders } from '../shared/seal'; +import type { ReadonlyHeaders } from '../spec-extension/adapters/headers'; +import type { ReadonlyRequestCookies } from '../spec-extension/adapters/request-cookies'; import type { Adapter, Decide, @@ -19,7 +14,6 @@ import type { ResolvedFlagDeclaration, } 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()` @@ -82,56 +76,11 @@ function setCachedValuePromise( 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'] { @@ -157,20 +106,6 @@ async function getEntities( return identify(...(nextArgs as [FlagParamsType])); } -/** - * Reads and decrypts the `vercel-flag-overrides` cookie. Returns `null` when - * the cookie is absent or empty (skipping the decrypt microtask). - */ -function readOverrides( - cookies: ReadonlyRequestCookies, -): Promise | null> { - // skip microtask if cookie does not exist or is empty - const override = cookies.get('vercel-flag-overrides')?.value; - return typeof override === 'string' && override !== '' - ? getOverrides(override) - : Promise.resolve(null); -} - interface BulkStoreData { headers: ReadonlyHeaders; cookies: ReadonlyRequestCookies; diff --git a/packages/flags/src/next/overrides.ts b/packages/flags/src/next/overrides.ts deleted file mode 100644 index fe72ac89..00000000 --- a/packages/flags/src/next/overrides.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { decryptOverrides } from '..'; -import { memoizeOne } from '../lib/async-memoize-one'; - -const memoizedDecrypt = memoizeOne( - (text: string) => decryptOverrides(text), - (a, b) => a[0] === b[0], // only the first argument gets compared - { cachePromiseRejection: true }, -); - -export async function getOverrides(cookie: string | undefined) { - if (typeof cookie === 'string' && cookie !== '') { - const cookieOverrides = await memoizedDecrypt(cookie); - return cookieOverrides ?? null; - } - - return null; -} diff --git a/packages/flags/src/next/precompute.ts b/packages/flags/src/next/precompute.ts index af219c4a..32c5486d 100644 --- a/packages/flags/src/next/precompute.ts +++ b/packages/flags/src/next/precompute.ts @@ -1,5 +1,5 @@ import type { JsonValue } from '..'; -import * as s from '../lib/serialization'; +import * as shared from '../shared/precompute'; import { evaluate } from './evaluate'; import type { Flag } from './types'; @@ -28,7 +28,7 @@ export async function precompute( * @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]])); + return shared.combine(flags, values); } /** @@ -52,8 +52,7 @@ export async function serialize( 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 shared.serialize(flags, values, secret); } /** @@ -72,8 +71,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 shared.deserialize(flags, code, secret); } /** @@ -133,41 +131,20 @@ export async function getPrecomputed( 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.`, - ); + shared.warnEmptyCode(keys); } 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]; - }); + return flagOrFlags.map((flag) => shared.readFlagValue(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]; + return shared.readFlagValue(flagSet, (flagOrFlags as Flag).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 @@ -186,28 +163,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 shared.generatePermutations(flags, filter, secret); } diff --git a/packages/flags/src/shared/discovery.ts b/packages/flags/src/shared/discovery.ts new file mode 100644 index 00000000..91b9b6d6 --- /dev/null +++ b/packages/flags/src/shared/discovery.ts @@ -0,0 +1,42 @@ +import { version } from '../../package.json'; +import { verifyAccess } from '../lib/verify-access'; +import type { ApiData } from '../types'; + +/** Response header that carries the Flags SDK version on discovery responses. */ +export const FLAGS_VERSION_HEADER = 'x-flags-sdk-version'; + +/** + * Shared control flow for a Flags Discovery Endpoint, which is the well-known + * endpoint Flags Explorer uses to discover an application's flags. + * + * Verifies the request's `Authorization` header, then either renders an + * unauthorized response or resolves the API data and renders it with the + * `x-flags-sdk-version` header set. Response construction is injected so each + * framework can use its own primitives. + * + * @param authHeader - The request's `Authorization` header value + * @param secret - The secret used to verify access (defaults to `FLAGS_SECRET`) + * @param getApiData - Resolves the API data once access is granted + * @param unauthorized - Builds the 401 response + * @param respond - Builds the success response from the API data and the + * version headers it must include + */ +export async function handleDiscoveryRequest({ + authHeader, + secret, + getApiData, + unauthorized, + respond, +}: { + authHeader: string | null; + secret: string | undefined; + getApiData: () => Promise | ApiData; + unauthorized: () => TResponse; + respond: (apiData: ApiData, headers: Record) => TResponse; +}): Promise { + const access = await verifyAccess(authHeader, secret); + if (!access) return unauthorized(); + + const apiData = await getApiData(); + return respond(apiData, { [FLAGS_VERSION_HEADER]: version }); +} diff --git a/packages/flags/src/shared/overrides.ts b/packages/flags/src/shared/overrides.ts new file mode 100644 index 00000000..c9aa359b --- /dev/null +++ b/packages/flags/src/shared/overrides.ts @@ -0,0 +1,43 @@ +import { memoizeOne } from '../lib/async-memoize-one'; +import { decryptOverrides } from '../lib/crypto'; +import type { ReadonlyRequestCookies } from '../spec-extension/adapters/request-cookies'; + +const memoizedDecrypt = memoizeOne( + (text: string, secret?: string) => decryptOverrides(text, secret), + // Re-decrypt when either the cookie text or the secret changes. + (a, b) => a[0] === b[0] && a[1] === b[1], + { cachePromiseRejection: true }, +); + +/** + * Decrypts the `vercel-flag-overrides` cookie value. Returns `null` when the + * cookie is absent or empty (skipping the decrypt microtask). + * + * @param cookie - The raw cookie value + * @param secret - The decryption secret (defaults to `FLAGS_SECRET` env var) + */ +export async function getOverrides( + cookie: string | undefined, + secret?: string, +): Promise | null> { + if (typeof cookie === 'string' && cookie !== '') { + const cookieOverrides = await memoizedDecrypt(cookie, secret); + return cookieOverrides ?? null; + } + + return null; +} + +/** + * Reads and decrypts the `vercel-flag-overrides` cookie off a sealed cookie + * store. Returns `null` when the cookie is absent or empty. + * + * @param cookies - The sealed request cookies + * @param secret - The decryption secret (defaults to `FLAGS_SECRET` env var) + */ +export function readOverrides( + cookies: ReadonlyRequestCookies, + secret?: string, +): Promise | null> { + return getOverrides(cookies.get('vercel-flag-overrides')?.value, secret); +} diff --git a/packages/flags/src/shared/precompute.ts b/packages/flags/src/shared/precompute.ts new file mode 100644 index 00000000..0b18498a --- /dev/null +++ b/packages/flags/src/shared/precompute.ts @@ -0,0 +1,125 @@ +import type { JsonValue } from '..'; +import * as s from '../lib/serialization'; +import type { FlagOption } from '../types'; + +/** + * The minimal flag shape the precompute core needs: a `key` and the optional + * `options` used by the serializer for index-based compression. Both the Next + * and SvelteKit `Flag` types satisfy this. + */ +export type KeyedFlag = { key: string; options?: FlagOption[] }; +export type FlagsArray = readonly KeyedFlag[]; +export type ValuesArray = readonly any[]; + +/** + * 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. + */ +export function combine(flags: FlagsArray, values: ValuesArray) { + return Object.fromEntries(flags.map((flag, i) => [flag.key, values[i]])); +} + +/** + * Turns a list of flags and their values into a short, signed string. Returns + * the `__no_flags__` sentinel for an empty list. Expects an already-resolved + * `secret`. + */ +export async function serialize( + flags: FlagsArray, + values: ValuesArray, + secret: string, +): Promise { + if (flags.length === 0) return '__no_flags__'; + return s.serialize(combine(flags, values), flags, secret); +} + +/** + * Decodes a signed code back into a record of flag keys to values. Returns an + * empty object for the `__no_flags__` sentinel. Expects an already-resolved + * `secret`. + */ +export async function deserialize( + flags: FlagsArray, + code: string, + secret: string, +): Promise> { + if (code === '__no_flags__') return {}; + return s.deserialize(code, flags, secret); +} + +/** + * Reads a single flag's value out of a deserialized flag set, warning when the + * flag was not part of the precomputed set. + */ +export function readFlagValue( + flagSet: Record, + key: string, +): JsonValue { + 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]; +} + +/** + * Emits the warning shown when `getPrecomputed` is called with a code generated + * from an empty flags array. + * + * @param keysDescription - A human-readable description of the affected flag + * key(s), e.g. a single key or a comma-joined list. + */ +export function warnEmptyCode(keysDescription: string): void { + console.warn( + `flags: getPrecomputed was called with a code generated from an empty flags array. The flag(s) "${keysDescription}" can not be resolved. Make sure to include them in the array passed to serialize/precompute.`, + ); +} + +// 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. Expects an already-resolved `secret`. + * + * @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 permutations with + * @returns An array of strings representing each permutation + */ +export async function generatePermutations( + flags: FlagsArray, + 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/shared/seal.ts b/packages/flags/src/shared/seal.ts new file mode 100644 index 00000000..50f75445 --- /dev/null +++ b/packages/flags/src/shared/seal.ts @@ -0,0 +1,68 @@ +import type { IncomingHttpHeaders } from 'node:http'; +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 transformMap = new WeakMap(); +const headersMap = new WeakMap(); +const cookiesMap = new WeakMap(); + +/** + * Transforms `IncomingHttpHeaders` (Pages Router `IncomingMessage`) to a + * standard `Headers` instance. Cached by the original object identity so the + * resulting `Headers` is stable across calls within a request. + */ +export 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; +} + +/** + * Wraps a `Headers` instance in a read-only adapter, cached by the original + * `Headers` identity. + */ +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; +} + +/** + * Reads the cookies off a `Headers` instance and wraps them in a read-only + * adapter, cached by the original `Headers` identity. + */ +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/sveltekit/index.ts b/packages/flags/src/sveltekit/index.ts index 11dafc25..2975c3a8 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, @@ -23,14 +22,9 @@ import { 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'; +import { handleDiscoveryRequest } from '../shared/discovery'; +import { readOverrides } from '../shared/overrides'; +import { sealCookies, sealHeaders } from '../shared/seal'; import type { Decide, FlagDeclaration, @@ -55,27 +49,6 @@ function hasOwnProperty( 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; }; @@ -191,10 +164,7 @@ export function flag< 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; + const overrides = await readOverrides(cookies, store.secret); if (overrides && hasOwnProperty(overrides, definition.key)) { const value = overrides[definition.key]; @@ -448,14 +418,13 @@ export function createFlagsDiscoveryEndpoint( }, ) { const requestHandler: RequestHandler = async (event) => { - const access = await verifyAccess( - event.request.headers.get('Authorization'), - options?.secret, - ); - if (!access) error(401); - - const apiData = await getApiData(event); - return json(apiData, { headers: { 'x-flags-sdk-version': version } }); + return handleDiscoveryRequest({ + authHeader: event.request.headers.get('Authorization'), + secret: options?.secret, + getApiData: () => getApiData(event), + unauthorized: () => error(401), + respond: (apiData, headers) => json(apiData, { headers }), + }); }; return requestHandler; diff --git a/packages/flags/src/sveltekit/precompute.ts b/packages/flags/src/sveltekit/precompute.ts index e5b27aa1..13e7f939 100644 --- a/packages/flags/src/sveltekit/precompute.ts +++ b/packages/flags/src/sveltekit/precompute.ts @@ -1,5 +1,5 @@ import type { JsonValue } from '..'; -import * as s from '../lib/serialization'; +import * as shared from '../shared/precompute'; import type { Flag, FlagsArray } from './types'; type ValuesArray = readonly any[]; @@ -35,16 +35,6 @@ export async function precompute( 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. * @@ -62,8 +52,7 @@ async function serialize( values: ValuesArray, secret: string, ) { - if (flags.length === 0) return '__no_flags__'; - return s.serialize(combine(flags, values), flags, secret); + return shared.serialize(flags, values, secret); } /** @@ -74,8 +63,7 @@ async function serialize( * @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); + return shared.deserialize(flags, code, secret); } /** @@ -93,26 +81,12 @@ export async function getPrecomputed( 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.`, - ); + shared.warnEmptyCode(flagKey); } 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]; + return shared.readFlagValue(flagSet, flagKey); } /** @@ -127,28 +101,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 shared.generatePermutations(flags, filter, secret); }