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", diff --git a/packages/flag-evaluation/src/index.ts b/packages/flag-evaluation/src/index.ts index 56b8f282..85e32aa0 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,38 @@ 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 + // could be simplified to isAnd !== result, but this is more readable + 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 +449,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); }); } 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 = { /** 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: