diff --git a/.changeset/metal-donkeys-eat.md b/.changeset/metal-donkeys-eat.md new file mode 100644 index 00000000..5b5e7a7e --- /dev/null +++ b/.changeset/metal-donkeys-eat.md @@ -0,0 +1,5 @@ +--- +'@web-widget/flags-kit': minor +--- + +提供 koa 预计算 diff --git a/packages/flags/package.json b/packages/flags/package.json index ee40afc0..2fe15e3f 100644 --- a/packages/flags/package.json +++ b/packages/flags/package.json @@ -48,6 +48,10 @@ "./web-router": { "import": "./dist/web-router.js", "require": "./dist/web-router.cjs" + }, + "./koa-precompute": { + "import": "./dist/koa-precompute.js", + "require": "./dist/koa-precompute.cjs" } }, "typesVersions": { diff --git a/packages/flags/src/koa/README.md b/packages/flags/src/koa/README.md new file mode 100644 index 00000000..4570d641 --- /dev/null +++ b/packages/flags/src/koa/README.md @@ -0,0 +1,185 @@ +# Koa Precompute + +## Background + +In the Next.js ecosystem, the Flag system provides powerful precompute functionality that allows pre-generating flag values on the server side and serializing them into strings, then quickly deserializing them on the client side to obtain results. This mechanism can significantly improve performance and reduce repeated server-side calculations. + +However, when using Flag's precompute functionality in the Koa framework, we encountered a critical limitation: + +### Problem Analysis + +1. **Flag Precompute Limitations**: Flag's `precompute` and `evaluate` functions do not support dynamically passing `request` objects +2. **Flag Function Flexibility**: The flag functions provided by `flag/next` themselves support passing `request` parameters [Reference Pages Router](https://flags-sdk.dev/frameworks/next#pages-router) +3. **Koa Environment Requirements**: In Koa applications, Flag cannot automatically obtain `request` internally and requires manual passing + +### Core Contradiction + +```typescript +// flag's precompute function doesn't support request parameter +export async function precompute( + flags: T, + // ❌ No request parameter +): Promise; + +// But flag functions themselves support request parameters +const flag = flag({ + key: 'user-feature', + decide: (request) => { + // ✅ Can access cookies, headers, etc. from request + return request?.cookies?.['user-type'] === 'premium'; + }, +}); +``` + +## Solution + +To solve this problem, we provide enhanced precompute functions for the Koa environment that support dynamically passing `request` objects: + +### New Features + +1. **evaluate function supporting request parameters** +2. **precompute function supporting request parameters** +3. **Complete type safety support** +4. **Backward compatibility** + +### Implementation Principle + +```typescript +// Koa's enhanced precompute functions +export async function evaluate( + flags: T, + request?: KoaRequest, // ✅ Supports optional request parameter +): Promise<{ [K in keyof T]: Awaited> }>; + +export async function precompute( + flags: T, + request?: KoaRequest, // ✅ Supports optional request parameter +): Promise; +``` + +## Usage Examples + +### Basic Usage + +```typescript +import { flag } from '@web-widget/flags/next'; +import { precompute, evaluate } from '@web-widget/flags/koa'; + +// Define flags that support request +const userFeatureFlag = flag({ + key: 'user-feature', + decide: (request) => { + // Decide flag value based on request context + const userType = request?.cookies?.['user-type']; + return userType === 'premium'; + }, +}); + +const themeFlag = flag({ + key: 'theme', + decide: (request) => { + // Decide theme based on cookies + return request?.cookies?.['theme'] || 'light'; + }, +}); + +const flags = [userFeatureFlag, themeFlag]; +``` + +### Precompute Usage + +```typescript +// Use in Koa middleware +app.use(async (ctx, next) => { + // Create Koa-compatible request object + const koaRequest = { + cookies: ctx.cookies, + headers: ctx.headers, + // Other necessary properties... + }; + + try { + // Precompute all flags + const precomputedCode = await precompute(flags, koaRequest); + + // Pass precomputed results to client + ctx.state.precomputedFlags = precomputedCode; + + await next(); + } catch (error) { + console.error('Flag precompute failed:', error); + await next(); + } +}); +``` + +### Dynamic Evaluation + +```typescript +// Directly evaluate flags (without precompute) +app.use(async (ctx, next) => { + const koaRequest = { + cookies: ctx.cookies, + headers: ctx.headers, + }; + + try { + // Directly evaluate flags + const flagValues = await evaluate(flags, koaRequest); + + // Use evaluation results + ctx.state.userFeature = flagValues[0]; // userFeatureFlag value + ctx.state.theme = flagValues[1]; // themeFlag value + + await next(); + } catch (error) { + console.error('Flag evaluation failed:', error); + await next(); + } +}); +``` + +## Type Definitions + +### KoaRequest Type + +```typescript +type KoaRequestCookies = Partial<{ + [key: string]: string; +}>; + +type KoaRequest = IncomingMessage & { + cookies: KoaRequestCookies; +}; +``` + +### Function Signatures + +```typescript +// Evaluate flags +export async function evaluate( + flags: T, + request?: KoaRequest, +): Promise<{ [K in keyof T]: Awaited> }>; + +// Precompute flags +export async function precompute( + flags: T, + request?: KoaRequest, +): Promise; + +// Deserialize +export async function deserialize( + flags: FlagsArray, + code: string, + secret?: string, +): Promise>; + +// Get precomputed value +export async function getPrecomputed( + flag: Flag, + precomputeFlags: FlagsArray, + code: string, + secret?: string, +): Promise; +``` diff --git a/packages/flags/src/koa/precompute.test.ts b/packages/flags/src/koa/precompute.test.ts new file mode 100644 index 00000000..6749d6c5 --- /dev/null +++ b/packages/flags/src/koa/precompute.test.ts @@ -0,0 +1,306 @@ +import { describe, expect, it } from 'vitest'; +import { + deserialize, + evaluate, + precompute, + generatePermutations, + getPrecomputed, + serialize, +} from './precompute'; +import type { Flag } from '../next/types'; +import crypto from 'node:crypto'; +import type { JsonValue } from '../types'; + +// Create simple test flag objects for testing +function createTestFlag( + key: string, + decide: (request?: any) => T | Promise, + options?: T[], +): Flag { + const flagFn = (request?: any) => decide(request); + flagFn.key = key; + flagFn.options = options?.map((opt) => ({ value: opt })); + flagFn.decide = decide; + return flagFn as Flag; +} + +/** + * Helper function to assert the generated permutations. + * + * @param group the group of flags to generate permutations for + * @param expected the expected permutations + */ +async function expectPermutations( + group: Flag[], + expected: any[], + filter?: ((permutation: Record) => boolean) | null, +) { + const permutationsPromise = generatePermutations(group, filter); + await expect(permutationsPromise).resolves.toHaveLength(expected.length); + + const permutations = await permutationsPromise; + await expect( + Promise.all(permutations.map((p) => deserialize(group, p))), + ).resolves.toEqual(expected); +} + +describe('generatePermutations', () => { + describe('when flag declares no options', () => { + it('should infer boolean options', async () => { + process.env.FLAGS_SECRET = crypto.randomBytes(32).toString('base64url'); + + const flagA = createTestFlag('a', () => false); + await expectPermutations([flagA], [{ a: false }, { a: true }]); + }); + }); + + describe('when flag declares empty options', () => { + it('should not infer any options', async () => { + process.env.FLAGS_SECRET = crypto.randomBytes(32).toString('base64url'); + + const flagA = createTestFlag('a', () => false, []); + await expectPermutations([flagA], []); + }); + }); + + describe('when flag declares options', () => { + it('should generate permutations', async () => { + process.env.FLAGS_SECRET = crypto.randomBytes(32).toString('base64url'); + + const flagA = createTestFlag('a', () => 'two', ['one', 'two', 'three']); + + await expectPermutations( + [flagA], + [{ a: 'one' }, { a: 'two' }, { a: 'three' }], + ); + }); + }); + + describe('when flag declares options with a filter', () => { + it('should generate permutations', async () => { + process.env.FLAGS_SECRET = crypto.randomBytes(32).toString('base64url'); + + const flagA = createTestFlag('a', () => 'two', ['one', 'two', 'three']); + + await expectPermutations( + [flagA], + [{ a: 'two' }], + // the filter passed to generatePermutations() + (permutation) => permutation.a === 'two', + ); + }); + }); + + describe('multiple flags with inferred options', () => { + it('should generate permutations', async () => { + process.env.FLAGS_SECRET = crypto.randomBytes(32).toString('base64url'); + + const flagA = createTestFlag('a', () => false); + + const flagB = createTestFlag('b', () => false); + + await expectPermutations( + [flagA, flagB], + [ + { a: false, b: false }, + { a: true, b: false }, + { a: false, b: true }, + { a: true, b: true }, + ], + ); + }); + }); + + describe('multiple flags with a mix of inferred and declared options', () => { + it('should generate permutations', async () => { + process.env.FLAGS_SECRET = crypto.randomBytes(32).toString('base64url'); + + const flagA = createTestFlag('a', () => false); + + const flagB = createTestFlag('b', () => false); + + const flagC = createTestFlag('c', () => 'two', ['one', 'two', 'three']); + + await expectPermutations( + [flagA, flagB, flagC], + [ + { a: false, b: false, c: 'one' }, + { a: true, b: false, c: 'one' }, + { a: false, b: true, c: 'one' }, + { a: true, b: true, c: 'one' }, + + { a: false, b: false, c: 'two' }, + { a: true, b: false, c: 'two' }, + { a: false, b: true, c: 'two' }, + { a: true, b: true, c: 'two' }, + + { a: false, b: false, c: 'three' }, + { a: true, b: false, c: 'three' }, + { a: false, b: true, c: 'three' }, + { a: true, b: true, c: 'three' }, + ], + ); + }); + }); + + describe('multiple flags with a mix of inferred and declared options, filtered', () => { + it('should generate permutations', async () => { + process.env.FLAGS_SECRET = crypto.randomBytes(32).toString('base64url'); + + const flagA = createTestFlag('a', () => false); + + const flagB = createTestFlag('b', () => false); + + const flagC = createTestFlag('c', () => 'two', ['one', 'two', 'three']); + + await expectPermutations( + [flagA, flagB, flagC], + [ + { a: false, b: true, c: 'two' }, + { a: true, b: true, c: 'two' }, + ], + (permutation) => permutation.c === 'two' && permutation.b, + ); + }); + }); +}); + +describe('getPrecomputed', () => { + it('should return the precomputed value', async () => { + process.env.FLAGS_SECRET = crypto.randomBytes(32).toString('base64url'); + + const flagA = createTestFlag('a', () => true); + const flagB = createTestFlag('b', () => false); + + const group = [flagA, flagB]; + const code = await serialize(group, [true, false]); + await expect(getPrecomputed(flagA, group, code)).resolves.toBe(true); + }); +}); + +describe('evaluate', () => { + it('should evaluate flags without request', async () => { + process.env.FLAGS_SECRET = crypto.randomBytes(32).toString('base64url'); + + const flagA = createTestFlag('a', () => true); + const flagB = createTestFlag('b', () => 'test'); + const flags = [flagA, flagB]; + + const result = await evaluate(flags); + expect(result).toEqual([true, 'test']); + }); + + it('should evaluate flags with request object', async () => { + process.env.FLAGS_SECRET = crypto.randomBytes(32).toString('base64url'); + + const mockRequest = { + cookies: { 'user-id': '123' }, + } as any; + + const flagA = createTestFlag( + 'a', + (request) => request?.cookies?.['user-id'] === '123', + ); + const flagB = createTestFlag('b', (request) => + request?.cookies?.['user-id'] ? 'authenticated' : 'anonymous', + ); + const flags = [flagA, flagB]; + + const result = await evaluate(flags, mockRequest); + expect(result).toEqual([true, 'authenticated']); + }); + + it('should handle flags that use request context', async () => { + process.env.FLAGS_SECRET = crypto.randomBytes(32).toString('base64url'); + + const mockRequest = { + cookies: { theme: 'dark' }, + } as any; + + const flagA = createTestFlag( + 'theme', + (request) => request?.cookies?.['theme'] || 'light', + ); + const flags = [flagA]; + + const result = await evaluate(flags, mockRequest); + expect(result).toEqual(['dark']); + }); +}); + +describe('precompute', () => { + it('should precompute flags without request', async () => { + process.env.FLAGS_SECRET = crypto.randomBytes(32).toString('base64url'); + + const flagA = createTestFlag('a', () => true); + const flagB = createTestFlag('b', () => 'test'); + const flags = [flagA, flagB]; + + const result = await precompute(flags); + expect(typeof result).toBe('string'); + + // Verify the result can be deserialized + const deserialized = await deserialize(flags, result); + expect(deserialized).toEqual({ a: true, b: 'test' }); + }); + + it('should precompute flags with request object', async () => { + process.env.FLAGS_SECRET = crypto.randomBytes(32).toString('base64url'); + + const mockRequest = { + cookies: { 'user-id': '456' }, + } as any; + + const flagA = createTestFlag( + 'a', + (request) => request?.cookies?.['user-id'] === '456', + ); + const flagB = createTestFlag('b', (request) => + request?.cookies?.['user-id'] ? 'user-456' : 'unknown', + ); + const flags = [flagA, flagB]; + + const result = await precompute(flags, mockRequest); + expect(typeof result).toBe('string'); + + // Verify the result can be deserialized + const deserialized = await deserialize(flags, result); + expect(deserialized).toEqual({ a: true, b: 'user-456' }); + }); + + it('should handle complex request scenarios', async () => { + process.env.FLAGS_SECRET = crypto.randomBytes(32).toString('base64url'); + + const mockRequest = { + cookies: { + 'user-type': 'premium', + region: 'us-east', + }, + } as any; + + const flagA = createTestFlag( + 'premium-features', + (request) => request?.cookies?.['user-type'] === 'premium', + ); + const flagB = createTestFlag( + 'region', + (request) => request?.cookies?.['region'] || 'default', + ); + const flagC = createTestFlag('feature-limit', (request) => { + const userType = request?.cookies?.['user-type']; + return userType === 'premium' ? 100 : 10; + }); + const flags = [flagA, flagB, flagC]; + + const result = await precompute(flags, mockRequest); + expect(typeof result).toBe('string'); + + // Verify the result can be deserialized + const deserialized = await deserialize(flags, result); + expect(deserialized).toEqual({ + 'premium-features': true, + region: 'us-east', + 'feature-limit': 100, + }); + }); +}); diff --git a/packages/flags/src/koa/precompute.ts b/packages/flags/src/koa/precompute.ts new file mode 100644 index 00000000..ffb3d9cc --- /dev/null +++ b/packages/flags/src/koa/precompute.ts @@ -0,0 +1,208 @@ +import { Flag } from '../next/types'; +import type { JsonValue } from '..'; +import * as s from '../lib/serialization'; +import type { IncomingMessage } from 'node:http'; + +type FlagsArray = readonly Flag[]; +type ValuesArray = readonly any[]; +type KoaRequestCookies = Partial<{ + [key: string]: string; +}>; +type KoaRequest = IncomingMessage & { cookies: KoaRequestCookies }; + +/** + * Resolves a list of flags + * @param flags - list of flags + * @returns - an array of evaluated flag values with one entry per flag + */ +export async function evaluate( + flags: T, + request?: KoaRequest, +): Promise<{ [K in keyof T]: Awaited> }> { + return Promise.all( + flags.map((flag) => (request ? flag(request) : flag())), + ) as Promise<{ + [K in keyof T]: Awaited>; + }>; +} + +/** + * Evaluate a list of feature flags and generate a signed string representing their values. + * + * This convenience function call combines `evaluate` and `serialize`. + * + * @param flags - list of flags + * @returns - a string representing evaluated flags + */ +export async function precompute( + flags: T, + request?: KoaRequest, +): Promise { + const values = await evaluate(flags, request); + return serialize(flags, values); +} + +/** + * 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]])); +} + +/** + * 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 it's 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: FlagsArray, + values: ValuesArray, + secret: string | undefined = process.env.FLAGS_SECRET, +) { + if (!secret) { + throw new Error('flags: Can not serialize due to missing secret'); + } + + 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 signing the result + * @returns - An object consisting of each flag's key and its resolved value. + */ +export async function deserialize( + flags: FlagsArray, + code: string, + secret: string | undefined = process.env.FLAGS_SECRET, +) { + if (!secret) { + throw new Error('flags: Can not serialize due to missing secret'); + } + + 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 flag - 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( + flag: Flag, + precomputeFlags: FlagsArray, + code: string, + secret?: string, +): Promise; + +/** + * Decodes the value of one or multiple flags given the list of flags used to encode and the code. + * + * @param flag - 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, + K extends readonly Flag[], +>( + flags: readonly [...K], + precomputeFlags: FlagsArray, + code: string, + secret?: string, +): Promise<{ [P in keyof K]: K[P] extends Flag ? U : never }>; + +/** + * Decodes the value of one or multiple flags given the list of flags used to encode and the code. + * + * @param flag - 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( + flagOrFlags: Flag | readonly Flag[], + precomputeFlags: FlagsArray, + code: string, + secret: string | undefined = process.env.FLAGS_SECRET, +): Promise { + if (!secret) { + throw new Error( + 'flags: getPrecomputed was called without a secret. Please set FLAGS_SECRET environment variable.', + ); + } + + const flagSet = await deserialize(precomputeFlags, code, secret); + + if (Array.isArray(flagOrFlags)) { + // Handle case when an array of flags is passed + return flagOrFlags.map((flag) => flagSet[flag.key]); + } else { + // Handle case when a single flag is passed + return 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 (let r of remainder) for (let 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 sign the generated permutation with + * @returns An array of strings representing each permutation + */ +export async function generatePermutations( + flags: FlagsArray, + filter: ((permutation: Record) => boolean) | null = null, + secret: string = process.env.FLAGS_SECRET!, +): Promise { + if (!secret) { + throw new Error( + 'flags: generatePermutations was called without a secret. Please set FLAGS_SECRET environment variable.', + ); + } + + 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/tsup.config.js b/packages/flags/tsup.config.js index 87606c0d..a6b4a23d 100644 --- a/packages/flags/tsup.config.js +++ b/packages/flags/tsup.config.js @@ -20,6 +20,7 @@ export default defineConfig({ react: 'src/react/index.tsx', analytics: 'src/analytics.ts', 'web-router': 'src/web-router/index.ts', + 'koa-precompute': 'src/koa/precompute.ts', }, ...defaultConfig, });