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
218 changes: 8 additions & 210 deletions packages/flags/src/next/evaluate.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,12 @@
import { AsyncLocalStorage } from 'node:async_hooks';
import type { IncomingHttpHeaders } from 'node:http';
import { internalReportValue, reportValue } from '../lib/report-value';
import { setSpanAttribute, trace } from '../lib/tracing';
import {
applyResult,
getCachedValuePromise,
getEntities,
hasOverride,
} from '../shared/evaluation';
import { readOverrides } from '../shared/overrides';
import { sealCookies, sealHeaders, transformToHeaders } from '../shared/seal';
import type { ReadonlyHeaders } from '../spec-extension/adapters/headers';
Expand All @@ -10,7 +15,6 @@ import type {
Adapter,
Decide,
FlagDeclaration,
FlagParamsType,
ResolvedFlagDeclaration,
} from '../types';
import { isInternalNextError } from './is-internal-next-error';
Expand All @@ -28,84 +32,6 @@ import type { Flag, PagesRouterRequest } from './types';
export const BULK_IDENTIFY_REF = Symbol('flags.bulkIdentifyRef');
export const BULKABLE = Symbol('flags.bulkable');

// a map of (headers, flagKey, entitiesKey) => value
const evaluationCache = new WeakMap<
Headers | IncomingHttpHeaders,
Map</* flagKey */ string, Map</* entitiesKey */ string, any>>
>();

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<FlagDeclaration<any, any>['identify'], undefined>
>;
const identifyArgsMap = new WeakMap<
Headers | IncomingHttpHeaders,
IdentifyArgs
>();

function isIdentifyFunction<ValueType, EntitiesType>(
identify: FlagDeclaration<ValueType, EntitiesType>['identify'] | EntitiesType,
): identify is FlagDeclaration<ValueType, EntitiesType>['identify'] {
return typeof identify === 'function';
}

async function getEntities<ValueType, EntitiesType>(
identify: FlagDeclaration<ValueType, EntitiesType>['identify'] | EntitiesType,
dedupeCacheKey: Headers | IncomingHttpHeaders,
readonlyHeaders: ReadonlyHeaders,
readonlyCookies: ReadonlyRequestCookies,
): Promise<EntitiesType | undefined> {
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]));
}

interface BulkStoreData {
headers: ReadonlyHeaders;
cookies: ReadonlyRequestCookies;
Expand All @@ -118,136 +44,6 @@ const bulkStore = new AsyncLocalStorage<BulkStoreData>();
let headersModulePromise: Promise<typeof import('next/headers')> | undefined;
let headersModule: typeof import('next/headers') | undefined;

/**
* Subset of a flag declaration / flag function that `applyResult` reads.
* `FlagDeclaration` (passed from `getRun`) and the `api` (passed from
* `evaluate()`) both satisfy this shape after `flag()` stamps `config` onto
* the api.
*/
type FlagInfo<ValueType> = {
key: string;
defaultValue?: ValueType;
config?: { reportValue?: boolean };
adapter?: { config?: { reportValue?: boolean } };
};

function hasOverride(
overrides: Record<string, any> | null,
key: string,
): overrides is Record<string, any> {
return overrides !== null && overrides[key] !== undefined;
}

function shouldReportValue(definition: FlagInfo<any>): boolean {
return (
(definition.config?.reportValue ??
definition.adapter?.config?.reportValue) !== false
);
}

/**
* Finalize a flag evaluation given an already-computed `entitiesKey`.
*
* Shared by `getRun` (single-flag path) and `evaluate()` (group path). Handles,
* in order: cache hit → override → produce → defaultValue/error normalization
* → cache write → reportValue. Override and cache writes write to the same
* `evaluationCache` either path uses, so a subsequent `flagFn()` in the same
* request hits cache regardless of which path populated it.
*/
async function applyResult<ValueType>(args: {
definition: FlagInfo<ValueType>;
readonlyHeaders: ReadonlyHeaders;
entitiesKey: string;
overrides: Record<string, any> | null;
produce: () => ValueType | PromiseLike<ValueType>;
}): Promise<ValueType> {
const { definition, readonlyHeaders, entitiesKey, overrides, produce } = args;

const cachedValue = getCachedValuePromise(
readonlyHeaders,
definition.key,
entitiesKey,
);
if (cachedValue !== undefined) {
setSpanAttribute('method', 'cached');
return await cachedValue;
}

if (hasOverride(overrides, definition.key)) {
setSpanAttribute('method', 'override');
const decision = overrides[definition.key] as ValueType;
setCachedValuePromise(
readonlyHeaders,
definition.key,
entitiesKey,
Promise.resolve(decision),
);
internalReportValue(definition.key, decision, {
reason: 'override',
});
return decision;
}

// Normalize the result of produce() into a promise. produce() may return
// synchronously or asynchronously, and may also throw synchronously.
// Fall back to defaultValue when produce returns undefined or throws.
let decisionResult: ValueType | PromiseLike<ValueType>;
try {
decisionResult = produce();
} catch (error) {
decisionResult = Promise.reject(error);
}

const decisionPromise = Promise.resolve(decisionResult).then<
ValueType,
ValueType
>(
(value) => {
if (value !== undefined) return value;
if (definition.defaultValue !== undefined) return definition.defaultValue;
throw new Error(
`flags: Flag "${definition.key}" must have a defaultValue or a decide function that returns a value`,
);
},
(error: Error) => {
if (isInternalNextError(error)) throw error;

// try to recover if defaultValue is set
if (definition.defaultValue !== undefined) {
if (process.env.NODE_ENV === 'development') {
console.info(
`flags: Flag "${definition.key}" is falling back to its defaultValue`,
);
} else {
console.warn(
`flags: Flag "${definition.key}" is falling back to its defaultValue after catching the following error`,
error,
);
}
return definition.defaultValue;
}
console.warn(`flags: Flag "${definition.key}" could not be evaluated`);
throw error;
},
);

setCachedValuePromise(
readonlyHeaders,
definition.key,
entitiesKey,
decisionPromise,
);

const decision = await decisionPromise;

if (shouldReportValue(definition)) {
// Overrides return before this point and report with `reason: "override"`.
reportValue(definition.key, decision);
}

return decision;
}

type Run<ValueType, EntitiesType> = (options: {
entities?: EntitiesType;
identify?:
Expand Down Expand Up @@ -333,6 +129,7 @@ export function getRun<ValueType, EntitiesType>(
readonlyHeaders,
entitiesKey,
overrides,
isFrameworkError: isInternalNextError,
produce: () =>
decide({
// @ts-expect-error TypeScript will not be able to process `getPrecomputed` when added to `Decide`. It is, however, part of the `Adapter` type
Expand Down Expand Up @@ -572,6 +369,7 @@ async function evaluateImpl(
readonlyHeaders,
entitiesKey,
overrides,
isFrameworkError: isInternalNextError,
produce: () => {
if (bulkError) throw bulkError;
return bulkResult![flagFn.key];
Expand Down
66 changes: 7 additions & 59 deletions packages/flags/src/next/index.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,16 @@
import { normalizeOptions } from '../lib/normalize-options';
import { setSpanAttribute, trace } from '../lib/tracing';
import {
getDecide,
getIdentify,
getOrigin,
resolveAdapter,
} from '../shared/flag-meta';
import type {
Decide,
FlagDeclaration,
FlagDefinitionsType,
FlagDefinitionType,
Identify,
JsonValue,
Origin,
ProviderData,
ResolvedFlagDeclaration,
} from '../types';
Expand All @@ -26,58 +29,6 @@ export {
} from './precompute';
export type { Flag } from './types';

function getDecide<ValueType, EntitiesType>(
definition: ResolvedFlagDeclaration<ValueType, EntitiesType>,
): Decide<ValueType, EntitiesType> {
if (definition.adapter && typeof definition.adapter.decide !== 'function') {
throw new Error(
`flags: The adapter passed to flag "${definition.key}" does not have a "decide" method.`,
);
}

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}`);
};
}

function getIdentify<ValueType, EntitiesType>(
definition: ResolvedFlagDeclaration<ValueType, EntitiesType>,
): Identify<EntitiesType> {
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 getOrigin<ValueType, EntitiesType>(
definition: ResolvedFlagDeclaration<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;
}

/**
* Declares a feature flag.
*
Expand All @@ -100,10 +51,7 @@ export function flag<
// Allow passing the adapter factory directly (`adapter: vercelAdapter`) as a
// shorthand for calling it (`adapter: vercelAdapter()`). Resolve it once here
// so every consumer below works with a concrete Adapter instance.
const adapter =
typeof definition.adapter === 'function'
? definition.adapter()
: definition.adapter;
const adapter = resolveAdapter(definition);
// Cast: spreading the discriminated union widens `decide`/`adapter` so TS no
// longer sees the "decide or adapter is present" guarantee, but the original
// `definition` upheld it and we only narrowed `adapter` to an instance.
Expand Down
Loading