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
24 changes: 10 additions & 14 deletions packages/flags/src/next/create-flags-discovery-endpoint.ts
Original file line number Diff line number Diff line change
@@ -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';

/**
Expand All @@ -20,18 +19,15 @@ export function createFlagsDiscoveryEndpoint(
},
) {
return async (request: NextRequest): Promise<Response> => {
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' },
}),
});
};
}
73 changes: 4 additions & 69 deletions packages/flags/src/next/evaluate.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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()`
Expand Down Expand Up @@ -82,56 +76,11 @@ function setCachedValuePromise(
type IdentifyArgs = Parameters<
Exclude<FlagDeclaration<any, any>['identify'], undefined>
>;
const transformMap = new WeakMap<IncomingHttpHeaders, Headers>();
const headersMap = new WeakMap<Headers, ReadonlyHeaders>();
const cookiesMap = new WeakMap<Headers, ReadonlyRequestCookies>();
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<ValueType, EntitiesType>(
identify: FlagDeclaration<ValueType, EntitiesType>['identify'] | EntitiesType,
): identify is FlagDeclaration<ValueType, EntitiesType>['identify'] {
Expand All @@ -157,20 +106,6 @@ async function getEntities<ValueType, EntitiesType>(
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<Record<string, any> | 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;
Expand Down
17 changes: 0 additions & 17 deletions packages/flags/src/next/overrides.ts

This file was deleted.

62 changes: 8 additions & 54 deletions packages/flags/src/next/precompute.ts
Original file line number Diff line number Diff line change
@@ -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';

Expand Down Expand Up @@ -28,7 +28,7 @@ export async function precompute<T extends FlagsArray>(
* @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);
}

/**
Expand All @@ -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);
}

/**
Expand All @@ -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);
}

/**
Expand Down Expand Up @@ -133,41 +131,20 @@ export async function getPrecomputed<T extends JsonValue>(
const keys = Array.isArray(flagOrFlags)
? flagOrFlags.map((f) => f.key).join(', ')
: (flagOrFlags as Flag<T, any>).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<T, any>).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<T, any>).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
Expand All @@ -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<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)));
return shared.generatePermutations(flags, filter, secret);
}
42 changes: 42 additions & 0 deletions packages/flags/src/shared/discovery.ts
Original file line number Diff line number Diff line change
@@ -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<TResponse>({
authHeader,
secret,
getApiData,
unauthorized,
respond,
}: {
authHeader: string | null;
secret: string | undefined;
getApiData: () => Promise<ApiData> | ApiData;
unauthorized: () => TResponse;
respond: (apiData: ApiData, headers: Record<string, string>) => TResponse;
}): Promise<TResponse> {
const access = await verifyAccess(authHeader, secret);
if (!access) return unauthorized();

const apiData = await getApiData();
return respond(apiData, { [FLAGS_VERSION_HEADER]: version });
}
43 changes: 43 additions & 0 deletions packages/flags/src/shared/overrides.ts
Original file line number Diff line number Diff line change
@@ -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<Record<string, any> | 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<Record<string, any> | null> {
return getOverrides(cookies.get('vercel-flag-overrides')?.value, secret);
}
Loading
Loading