Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
62 changes: 62 additions & 0 deletions packages/flags/src/engine/adapter-resolution.ts
Original file line number Diff line number Diff line change
@@ -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<ValueType, EntitiesType>(
definition: FlagDeclaration<ValueType, EntitiesType>,
): Decide<ValueType, EntitiesType> {
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<ValueType, EntitiesType>(
definition: FlagDeclaration<ValueType, EntitiesType>,
): Identify<EntitiesType> | 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<ValueType, EntitiesType>(
definition: FlagDeclaration<ValueType, EntitiesType>,
): 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;
}
46 changes: 46 additions & 0 deletions packages/flags/src/engine/evaluation-cache.ts
Original file line number Diff line number Diff line change
@@ -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</* flagKey */ string, Map</* entitiesKey */ string, any>>
>();

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);
}
28 changes: 28 additions & 0 deletions packages/flags/src/engine/flag-metadata.ts
Original file line number Diff line number Diff line change
@@ -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<ValueType, EntitiesType>(
fn: Record<string, any>,
definition: FlagDeclaration<ValueType, EntitiesType>,
{
decide,
identify,
origin,
}: {
decide: Decide<ValueType, EntitiesType>;
identify?: Identify<EntitiesType>;
origin?: FlagDeclaration<ValueType, EntitiesType>['origin'];
},
): void {
fn.key = definition.key;
fn.defaultValue = definition.defaultValue;
fn.origin = origin;
fn.description = definition.description;
fn.options = normalizeOptions<ValueType>(definition.options);
fn.decide = decide;
fn.identify = identify;
}
17 changes: 17 additions & 0 deletions packages/flags/src/engine/index.ts
Original file line number Diff line number Diff line change
@@ -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';
141 changes: 141 additions & 0 deletions packages/flags/src/engine/precompute.ts
Original file line number Diff line number Diff line change
@@ -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<T extends JsonValue>(
flagOrFlags: FlagLike<T> | readonly FlagLike<T>[],
precomputeFlags: readonly FlagLike[],
code: string,
secret: string,
): Promise<any> {
if (code === '__no_flags__') {
const keys = Array.isArray(flagOrFlags)
? flagOrFlags.map((f) => f.key).join(', ')
: (flagOrFlags as FlagLike<T>).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<T>).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<T>(items: T[][]): Generator<T[]> {
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<string, JsonValue>) => boolean) | null = null,
secret: string,
): Promise<string[]> {
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<string, JsonValue>[] = [];

for (const permutation of cartesianIterator(options)) {
const permObject = permutation.reduce<Record<string, JsonValue>>(
(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)));
}
33 changes: 33 additions & 0 deletions packages/flags/src/engine/provider-data.ts
Original file line number Diff line number Diff line change
@@ -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<string, FlagLike | readonly unknown[]>,
): ProviderData {
const definitions = Object.values(flags)
// filter out precomputed arrays
.filter((i): i is FlagLike => !Array.isArray(i))
.reduce<FlagDefinitionsType>((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: [] };
}
Loading
Loading