Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

extract reusable core #72

Draft
wants to merge 3 commits into
base: main
Choose a base branch
from
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
3 changes: 2 additions & 1 deletion packages/flags/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,8 @@
},
"dependencies": {
"@edge-runtime/cookies": "^5.0.2",
"jose": "^5.2.1"
"jose": "^5.2.1",
"json-stringify-deterministic": "1.0.12"
},
"devDependencies": {
"@arethetypeswrong/cli": "0.17.3",
Expand Down
8 changes: 8 additions & 0 deletions packages/flags/src/lib/core.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import { describe, it, expect } from 'vitest';
import { core } from './core';

describe('core', () => {
it('should work', () => {
expect(typeof core).toBe('function');
});
});
204 changes: 204 additions & 0 deletions packages/flags/src/lib/core.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,204 @@
/**
* This file contains the core logic of the Flags SDK, which can be reused
* by the implementations for different frameworks.
*/

import stringify from 'json-stringify-deterministic';
import { isInternalNextError } from '../next/is-internal-next-error';
import { getCachedValuePromise, setCachedValuePromise } from './request-cache';
import { setSpanAttribute } from './tracing';
import type { ReadonlyHeaders } from '../spec-extension/adapters/headers';
import type { ReadonlyRequestCookies } from '../spec-extension/adapters/request-cookies';
import { internalReportValue, reportValue } from './report-value';
import { Decide, FlagDeclaration, Identify } from '../types';
import { getOverrides } from './overrides';

// Steps to evaluate a flag
//
// 1. check if precomputed, and use that if it is
// -> we don't need to respect overrides here, they were already applied when precomputing
// -> apply spanAttribute: method = "precomputed"
// 2. call run({ identify, headers, cookies }) <- run never respects percomputed values
// 2.1 use override from cookies if one exists, skip caching
// -> apply spanAttribute: method = "override"
// 2.2 get entities from identify
// -> dedupe and cache based on headers and cookies
// 2.3 create cache key by stringifying entities
// 2.4 use cached value if it exists
// -> apply spanAttribute: method = "cached"
// 2.5 call decide({ headers, cookies, entities })
// -> cache promise
// -> apply spanAttribute: method = "decided"
// -> apply internalReportValue: reason = "override"

const identifyArgsMap = new WeakMap<any, IdentifyArgs>();

type IdentifyArgs = Parameters<Exclude<Identify<any>, undefined>>;
function isIdentifyFunction<EntitiesType>(
identify: Identify<any> | EntitiesType,
): identify is Identify<any> {
return typeof identify === 'function';
}

async function getEntities<EntitiesType>(
identify: Identify<any> | EntitiesType,
dedupeCacheKey: any,
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);

const nextArgs: IdentifyArgs = [
{ headers: readonlyHeaders, cookies: readonlyCookies },
];
identifyArgsMap.set(dedupeCacheKey, nextArgs);
return identify(...nextArgs);
}

export function getDecide<ValueType, EntitiesType>(
definition: FlagDeclaration<ValueType, EntitiesType>,
): Decide<ValueType, EntitiesType> {
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}`);
};
}

export function getIdentify<ValueType, EntitiesType>(
definition: FlagDeclaration<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;
};
}

export async function core<ValueType, EntitiesType>({
flagKey,
getPrecomputed,
identify,
decide,
requestCacheKey,
defaultValue,
readonlyHeaders,
readonlyCookies,
shouldReportValue,
secret,
}: {
flagKey: string;
getPrecomputed?: () => Promise<ValueType>;
identify: EntitiesType | Identify<EntitiesType> | undefined;
decide: Decide<ValueType, EntitiesType>;
requestCacheKey: any;
defaultValue?: ValueType;
readonlyHeaders: ReadonlyHeaders;
readonlyCookies: ReadonlyRequestCookies;
shouldReportValue: boolean;
secret?: string;
}) {
if (typeof getPrecomputed === 'function') {
const precomputed = await getPrecomputed();
if (precomputed !== undefined) {
setSpanAttribute('method', 'precomputed');
return precomputed;
}
}

const overrides = await getOverrides(
readonlyCookies.get('vercel-flag-overrides')?.value,
secret,
);
const override = overrides ? overrides?.[flagKey] : null;
if (overrides) {
setSpanAttribute('method', 'override');
internalReportValue(flagKey, override, { reason: 'override' });
return override;
}

const entities = await getEntities(
identify,
requestCacheKey,
readonlyHeaders,
readonlyCookies,
);

const entitiesKey = stringify(entities) ?? '';

const cachedValue = getCachedValuePromise(
requestCacheKey,
flagKey,
entitiesKey,
);

if (cachedValue !== undefined) {
setSpanAttribute('method', 'cached');
return cachedValue;
}

// We use an async iife to ensure we can catch both sync and async errors of
// the original decide function, as that one is not guaranted to be async.
//
// Also fall back to defaultValue when the decide function returns undefined or throws an error.
const decisionPromise = (async () => {
return decide({
headers: readonlyHeaders,
cookies: readonlyCookies,
entities,
});
})()
// catch errors in async "decide" functions
.then<ValueType, ValueType>(
(value) => {
if (value !== undefined) return value;
if (defaultValue !== undefined) return defaultValue;
throw new Error(
`flags: Flag "${flagKey}" 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 (defaultValue !== undefined) {
if (process.env.NODE_ENV === 'development') {
console.info(
`flags: Flag "${flagKey}" is falling back to its defaultValue`,
);
} else {
console.warn(
`flags: Flag "${flagKey}" is falling back to its defaultValue after catching the following error`,
error,
);
}
return defaultValue;
}
console.warn(`flags: Flag "${flagKey}" could not be evaluated`);
throw error;
},
);

setCachedValuePromise(requestCacheKey, flagKey, entitiesKey, decisionPromise);

const decision = await decisionPromise;

// 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.
if (shouldReportValue) reportValue(flagKey, decision);

return decision;
}
13 changes: 13 additions & 0 deletions packages/flags/src/lib/origin.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import { FlagDeclaration, Origin } from '../types';

export function getOrigin<ValueType, EntitiesType>(
definition: Pick<
FlagDeclaration<ValueType, EntitiesType>,
'origin' | 'adapter' | 'key'
>,
): 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;
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,17 @@ import { type FlagOverridesType, decrypt } from '..';
import { memoizeOne } from './async-memoize-one';

const memoizedDecrypt = memoizeOne(
(text: string) => decrypt<FlagOverridesType>(text),
(a, b) => a[0] === b[0], // only the first argument gets compared
(text: string, secret?: string) => decrypt<FlagOverridesType>(text, secret),
(a, b) => a[0] === b[0] && a[1] === b[1],
{ cachePromiseRejection: true },
);

export async function getOverrides(cookie: string | undefined) {
export async function getOverrides(
cookie: string | undefined,
secret?: string,
) {
if (typeof cookie === 'string' && cookie !== '') {
const cookieOverrides = await memoizedDecrypt(cookie);
const cookieOverrides = await memoizedDecrypt(cookie, secret);
return cookieOverrides ?? null;
}

Expand Down
40 changes: 40 additions & 0 deletions packages/flags/src/lib/request-cache.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
// a map of (headers, flagKey, entitiesKey) => value
const evaluationCache = new WeakMap<
any,
Map</* flagKey */ string, Map</* entitiesKey */ string, any>>
>();

export function getCachedValuePromise(
requestCacheKey: any,
flagKey: string,
entitiesKey: string,
): any {
const map = evaluationCache.get(requestCacheKey)?.get(flagKey);
if (!map) return undefined;
return map.get(entitiesKey);
}

export function setCachedValuePromise(
requestCacheKey: any,
flagKey: string,
entitiesKey: string,
flagValue: any,
): any {
const byHeaders = evaluationCache.get(requestCacheKey);

if (!byHeaders) {
evaluationCache.set(
requestCacheKey,
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);
}
56 changes: 56 additions & 0 deletions packages/flags/src/lib/request-mapping.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import type { IncomingHttpHeaders } from 'node:http';
import { RequestCookies } from '@edge-runtime/cookies';
import {
type ReadonlyHeaders,
HeadersAdapter,
} from '../spec-extension/adapters/headers';
import {
type ReadonlyRequestCookies,
RequestCookiesAdapter,
} from '../spec-extension/adapters/request-cookies';

const transformMap = new WeakMap<IncomingHttpHeaders, Headers>();
const headersMap = new WeakMap<Headers, ReadonlyHeaders>();
const cookiesMap = new WeakMap<Headers, ReadonlyRequestCookies>();

/**
* Transforms IncomingHttpHeaders to Headers
*/
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;
}

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;
}
21 changes: 21 additions & 0 deletions packages/flags/src/lib/resolve-object-promises.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
export type PromisesMap<T> = {
[K in keyof T]: Promise<T[K]>;
};

export async function resolveObjectPromises<T>(
obj: PromisesMap<T>,
): Promise<T> {
// Convert the object into an array of [key, promise] pairs
const entries = Object.entries(obj) as [keyof T, Promise<any>][];

// 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;
}
Loading
Loading