From 1cc9ea56613ea5dc87defe6f7601d051f5ad78ea Mon Sep 17 00:00:00 2001 From: Ron Cohen Date: Sun, 23 Mar 2025 11:18:11 +0100 Subject: [PATCH 1/5] feat: use webcrypto API instead of node Crypto to support edge runtimes --- packages/flag-evaluation/src/index.ts | 69 +++++++++++++-------- packages/flag-evaluation/test/index.test.ts | 16 ++--- 2 files changed, 52 insertions(+), 33 deletions(-) diff --git a/packages/flag-evaluation/src/index.ts b/packages/flag-evaluation/src/index.ts index 56b8f282..11e64b92 100644 --- a/packages/flag-evaluation/src/index.ts +++ b/packages/flag-evaluation/src/index.ts @@ -1,4 +1,10 @@ -import { createHash } from "node:crypto"; +try { + // crypto not available on globalThis in Node.js v18 + // eslint-disable-next-line @typescript-eslint/no-require-imports + globalThis.crypto ??= require("node:crypto").webcrypto; +} catch { + // ignore +} /** * Represents a filter class with a specific type property. @@ -259,16 +265,20 @@ export function unflattenJSON(data: Record): Record { * @param {string} hashInput - The input string used to generate the hash. * @return {number} A number between 0 and 100000 derived from the hash of the input string. */ -export function hashInt(hashInput: string): number { +export async function hashInt(hashInput: string): Promise { // 1. hash the key and the partial rollout attribute // 2. take 20 bits from the hash and divide by 2^20 - 1 to get a number between 0 and 1 // 3. multiply by 100000 to get a number between 0 and 100000 and compare it to the threshold // // we only need 20 bits to get to 100000 because 2^20 is 1048576 - const hash = - createHash("sha256").update(hashInput, "utf-8").digest().readUInt32LE(0) & - 0xfffff; - return Math.floor((hash / 0xfffff) * 100000); + const msgUint8 = new TextEncoder().encode(hashInput); + + // Hash the message + const hashBuffer = await crypto.subtle.digest("SHA-256", msgUint8); + + const view = new DataView(hashBuffer); + const value = view.getUint32(0, true) & 0xfffff; + return Math.floor((value / 0xfffff) * 100000); } /** @@ -341,11 +351,11 @@ export function evaluate( } } -function evaluateRecursively( +async function evaluateRecursively( filter: RuleFilter, context: Record, missingContextFieldsSet: Set, -): boolean { +): Promise { switch (filter.type) { case "constant": return filter.value; @@ -366,30 +376,37 @@ function evaluateRecursively( return false; } - const hashVal = hashInt( + const hashVal = await hashInt( `${filter.key}.${context[filter.partialRolloutAttribute]}`, ); return hashVal < filter.partialRolloutThreshold; } - case "group": - return filter.filters.reduce((acc, current) => { - if (filter.operator === "and") { - return ( - acc && - evaluateRecursively(current, context, missingContextFieldsSet) - ); + case "group": { + const isAnd = filter.operator === "and"; + let result = isAnd; + for (const current of filter.filters) { + // short-circuit if we know the result already + if ((isAnd && !result) || (!isAnd && result)) { + return result; } - return ( - acc || evaluateRecursively(current, context, missingContextFieldsSet) + + const newRes = await evaluateRecursively( + current, + context, + missingContextFieldsSet, ); - }, filter.operator === "and"); + + result = isAnd ? result && newRes : result || newRes; + } + return result; + } case "negation": - return !evaluateRecursively( + return !(await evaluateRecursively( filter.filter, context, missingContextFieldsSet, - ); + )); default: return false; } @@ -431,16 +448,18 @@ export interface EvaluationResult { missingContextFields?: string[]; } -export function evaluateFeatureRules({ +export async function evaluateFeatureRules({ context, featureKey, rules, -}: EvaluationParams): EvaluationResult { +}: EvaluationParams): Promise> { const flatContext = flattenJSON(context); const missingContextFieldsSet = new Set(); - const ruleEvaluationResults = rules.map((rule) => - evaluateRecursively(rule.filter, flatContext, missingContextFieldsSet), + const ruleEvaluationResults = await Promise.all( + rules.map((rule) => + evaluateRecursively(rule.filter, flatContext, missingContextFieldsSet), + ), ); const missingContextFields = Array.from(missingContextFieldsSet); diff --git a/packages/flag-evaluation/test/index.test.ts b/packages/flag-evaluation/test/index.test.ts index 7739f009..665575bc 100644 --- a/packages/flag-evaluation/test/index.test.ts +++ b/packages/flag-evaluation/test/index.test.ts @@ -38,7 +38,7 @@ const feature = { describe("evaluate feature targeting integration ", () => { it("evaluates all kinds of filters", async () => { - const res = evaluateFeatureRules({ + const res = await evaluateFeatureRules({ featureKey: "feature", rules: [ { @@ -109,7 +109,7 @@ describe("evaluate feature targeting integration ", () => { }); it("evaluates flag when there's no matching rule", async () => { - const res = evaluateFeatureRules({ + const res = await evaluateFeatureRules({ ...feature, context: { company: { @@ -137,7 +137,7 @@ describe("evaluate feature targeting integration ", () => { }, }; - const res = evaluateFeatureRules({ + const res = await evaluateFeatureRules({ ...feature, context, }); @@ -155,7 +155,7 @@ describe("evaluate feature targeting integration ", () => { }); it("evaluates flag with missing values", async () => { - const res = evaluateFeatureRules({ + const res = await evaluateFeatureRules({ featureKey: "feature", rules: [ { @@ -198,7 +198,7 @@ describe("evaluate feature targeting integration ", () => { }); it("returns list of missing context keys ", async () => { - const res = evaluateFeatureRules({ + const res = await evaluateFeatureRules({ ...feature, context: {}, }); @@ -214,7 +214,7 @@ describe("evaluate feature targeting integration ", () => { }); it("fails evaluation and includes key in missing keys when rollout attribute is missing from context", async () => { - const res = evaluateFeatureRules({ + const res = await evaluateFeatureRules({ featureKey: "myfeature", rules: [ { @@ -352,8 +352,8 @@ describe("rollout hash", () => { ] as const; for (const [input, expected] of tests) { - it(`evaluates '${input}' = ${expected}`, () => { - const res = hashInt(input); + it(`evaluates '${input}' = ${expected}`, async () => { + const res = await hashInt(input); expect(res).toEqual(expected); }); } From bc1ec183eab72d55e8c4ced67d2dd54909877de8 Mon Sep 17 00:00:00 2001 From: Ron Cohen Date: Sun, 23 Mar 2025 11:23:39 +0100 Subject: [PATCH 2/5] add comment --- packages/flag-evaluation/src/index.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/flag-evaluation/src/index.ts b/packages/flag-evaluation/src/index.ts index 11e64b92..85e32aa0 100644 --- a/packages/flag-evaluation/src/index.ts +++ b/packages/flag-evaluation/src/index.ts @@ -387,6 +387,7 @@ async function evaluateRecursively( let result = isAnd; for (const current of filter.filters) { // short-circuit if we know the result already + // could be simplified to isAnd !== result, but this is more readable if ((isAnd && !result) || (!isAnd && result)) { return result; } From fd145651816816d35d55a51d24365e43f5824bdd Mon Sep 17 00:00:00 2001 From: Ron Cohen Date: Sun, 23 Mar 2025 11:26:13 +0100 Subject: [PATCH 3/5] bump version --- packages/flag-evaluation/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/flag-evaluation/package.json b/packages/flag-evaluation/package.json index 64d4017d..1ab54c98 100644 --- a/packages/flag-evaluation/package.json +++ b/packages/flag-evaluation/package.json @@ -1,6 +1,6 @@ { "name": "@bucketco/flag-evaluation", - "version": "0.1.0", + "version": "0.2.0", "license": "MIT", "repository": { "type": "git", From c639369d6b04f4fee3c3d1764ba3ee9ff301e9eb Mon Sep 17 00:00:00 2001 From: Ron Cohen Date: Mon, 24 Mar 2025 11:38:21 +0100 Subject: [PATCH 4/5] update yarn.lock --- yarn.lock | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/yarn.lock b/yarn.lock index ba547b9d..09d1a100 100644 --- a/yarn.lock +++ b/yarn.lock @@ -497,7 +497,14 @@ __metadata: languageName: unknown linkType: soft -"@bucketco/flag-evaluation@npm:~0.1.0, @bucketco/flag-evaluation@workspace:packages/flag-evaluation": +"@bucketco/flag-evaluation@npm:~0.1.0": + version: 0.1.0 + resolution: "@bucketco/flag-evaluation@npm:0.1.0" + checksum: 10c0/a5d747962d43ce12b194735e92524a576edac9e9ad53c425b4a517123ca9918d3001891cd212f178b7cf6b235c79aa5cfa3942e162187da056c2ae1d5230a984 + languageName: node + linkType: hard + +"@bucketco/flag-evaluation@workspace:packages/flag-evaluation": version: 0.0.0-use.local resolution: "@bucketco/flag-evaluation@workspace:packages/flag-evaluation" dependencies: From c74f41bf002712d04bcc51550ad9fb19c204441a Mon Sep 17 00:00:00 2001 From: Ron Cohen Date: Mon, 24 Mar 2025 11:50:31 +0100 Subject: [PATCH 5/5] expose FeatureConfigVariant as well --- packages/node-sdk/src/index.ts | 1 + packages/node-sdk/src/types.ts | 4 +--- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/packages/node-sdk/src/index.ts b/packages/node-sdk/src/index.ts index 8f1de37b..5cd5ec16 100644 --- a/packages/node-sdk/src/index.ts +++ b/packages/node-sdk/src/index.ts @@ -7,6 +7,7 @@ export type { ContextWithTracking, EmptyFeatureRemoteConfig, Feature, + FeatureConfigVariant, FeatureOverride, FeatureOverrides, FeatureOverridesFn, diff --git a/packages/node-sdk/src/types.ts b/packages/node-sdk/src/types.ts index fb771b50..28b9fe16 100644 --- a/packages/node-sdk/src/types.ts +++ b/packages/node-sdk/src/types.ts @@ -232,9 +232,7 @@ export type FeatureOverrides = Partial< export type FeatureOverridesFn = (context: Context) => FeatureOverrides; /** - * (Internal) Describes a remote feature config variant. - * - * @internal + * Describes a remote feature config variant. */ export type FeatureConfigVariant = { /**