From 0fafb10226f1f6991322e9d7bf2e35b37220f1fb Mon Sep 17 00:00:00 2001 From: Ben Ebsworth Date: Sat, 10 Oct 2026 08:28:08 +1100 Subject: [PATCH] Sync @flaggr/sdk 0.5.0 and @flaggr/evaluator 0.3.0 packages/sdk and packages/evaluator as skunkworq/flaggr 37eed64 has them (the commit v0.5.0 will tag): scripts/check-js-mirror.mjs passes against this tree. The SDK's React tests render with react-dom now, so the root gains the react-dom and @types/react-dom dev dependencies the monorepo gives them. The seven tests that read packages/analytics skip here, as they're meant to. Co-Authored-By: Claude Opus 5.5 --- package-lock.json | 36 + package.json | 2 + packages/evaluator/LICENSE | 21 + packages/evaluator/README.md | 4 +- packages/evaluator/package.json | 14 +- packages/evaluator/src/evaluator.ts | 74 +- packages/evaluator/src/types.ts | 35 +- .../test/rule-matching-cases.go-wire.json | 1345 +++++++++++++++++ .../evaluator/test/rule-matching-cases.json | 641 ++++++++ .../evaluator/test/rule-matching-cases.ts | 57 + packages/evaluator/test/rule-matching.test.ts | 32 + packages/sdk/LICENSE | 21 + packages/sdk/README.md | 9 +- packages/sdk/package.json | 44 +- packages/sdk/src/app-url.test.ts | 57 + packages/sdk/src/app-url.ts | 32 + packages/sdk/src/build.test.ts | 104 ++ packages/sdk/src/client.test.ts | 1184 ++++++++++++++- packages/sdk/src/client.ts | 604 +++++--- packages/sdk/src/env.test.ts | 81 + packages/sdk/src/env.ts | 68 +- packages/sdk/src/expose-flags.test.ts | 49 +- packages/sdk/src/index.ts | 6 +- packages/sdk/src/keepalive.test.ts | 84 + packages/sdk/src/keepalive.ts | 86 ++ packages/sdk/src/otel.ts | 2 +- packages/sdk/src/react.server.test.tsx | 50 + packages/sdk/src/react.test.tsx | 465 ++++++ packages/sdk/src/react.tsx | 266 ++-- packages/sdk/src/remote-telemetry.test.ts | 52 + packages/sdk/src/telemetry.test.ts | 168 ++ packages/sdk/src/telemetry.ts | 64 +- packages/sdk/src/typecheck.test.ts | 30 + packages/sdk/src/types.ts | 87 +- packages/sdk/tsup.config.ts | 42 +- 35 files changed, 5482 insertions(+), 434 deletions(-) create mode 100644 packages/evaluator/LICENSE create mode 100644 packages/evaluator/test/rule-matching-cases.go-wire.json create mode 100644 packages/evaluator/test/rule-matching-cases.json create mode 100644 packages/evaluator/test/rule-matching-cases.ts create mode 100644 packages/evaluator/test/rule-matching.test.ts create mode 100644 packages/sdk/LICENSE create mode 100644 packages/sdk/src/app-url.test.ts create mode 100644 packages/sdk/src/app-url.ts create mode 100644 packages/sdk/src/build.test.ts create mode 100644 packages/sdk/src/env.test.ts create mode 100644 packages/sdk/src/keepalive.test.ts create mode 100644 packages/sdk/src/keepalive.ts create mode 100644 packages/sdk/src/react.server.test.tsx create mode 100644 packages/sdk/src/react.test.tsx create mode 100644 packages/sdk/src/remote-telemetry.test.ts create mode 100644 packages/sdk/src/typecheck.test.ts diff --git a/package-lock.json b/package-lock.json index fc35277..258b74d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,7 +13,9 @@ "@types/jsdom": "^20.0.1", "@types/node": "^22.0.0", "@types/react": "^18.3.0", + "@types/react-dom": "^18.3.7", "jsdom": "^20.0.3", + "react-dom": "^18.3.1", "vitest": "^4.1.11" }, "engines": { @@ -1323,6 +1325,16 @@ "csstype": "^3.2.2" } }, + "node_modules/@types/react-dom": { + "version": "18.3.7", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz", + "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^18.0.0" + } + }, "node_modules/@types/tough-cookie": { "version": "4.0.5", "resolved": "https://registry.npmjs.org/@types/tough-cookie/-/tough-cookie-4.0.5.tgz", @@ -2912,6 +2924,20 @@ "node": ">=0.10.0" } }, + "node_modules/react-dom": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", + "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", + "dev": true, + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0", + "scheduler": "^0.23.2" + }, + "peerDependencies": { + "react": "^18.3.1" + } + }, "node_modules/readdirp": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", @@ -3043,6 +3069,16 @@ "node": ">=v12.22.7" } }, + "node_modules/scheduler": { + "version": "0.23.2", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", + "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + } + }, "node_modules/siginfo": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", diff --git a/package.json b/package.json index 0a5c2c7..4a5cadb 100644 --- a/package.json +++ b/package.json @@ -20,7 +20,9 @@ "@types/jsdom": "^20.0.1", "@types/node": "^22.0.0", "@types/react": "^18.3.0", + "@types/react-dom": "^18.3.7", "jsdom": "^20.0.3", + "react-dom": "^18.3.1", "vitest": "^4.1.11" }, "engines": { diff --git a/packages/evaluator/LICENSE b/packages/evaluator/LICENSE new file mode 100644 index 0000000..389eed7 --- /dev/null +++ b/packages/evaluator/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2024-present Flaggr Contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/evaluator/README.md b/packages/evaluator/README.md index 16bcdad..429947a 100644 --- a/packages/evaluator/README.md +++ b/packages/evaluator/README.md @@ -46,7 +46,9 @@ console.log(result.reason) // "TARGETING_MATCH" - Zero dependencies - Gives the same results as Flaggr's data plane for the operators it implements: equals, not_equals, in, not_in, contains, starts_with, ends_with, greater_than, less_than, gte, lte, regex, before and after. `isLocallyEvaluable(flag)` tells you whether a flag uses only these. It also evaluates exists and not_exists. Cohort operators need the server. - Percentage rollouts with deterministic hashing -- Variant assignment for experiments +- Targeting rules match as on the Flaggr data plane. A rule's condition groups, when it has them, decide (a group without conditions matches nobody); otherwise its conditions do, and a rule with no conditions matches everyone, so `conditions: []` with `rolloutPercentage: 10` serves the rule's value to 10% of all users. A context without a `targetingKey` is never in a rollout below 100%. Flaggr stores percentages and weights as whole numbers from 0 to 100. (Deployments of `api.flaggr.dev` from before Flaggr 0.5.0 match a rule with no conditions to nobody.) +- Reads flags as the data plane encodes them (`/api/flags/export`, its SSE configuration events): a field encoded as `null` is unset, as is `""` in `conditionOperator`, `groupOperator` and `variant`. So a rule whose `rolloutPercentage` is `null` serves everyone it matches, and one whose `value` is `null` serves the flag's default. An entry of `targeting` or `overrides` that isn't an object is skipped. +- Variant splits by targeting key (a running Flaggr experiment assigns its variants only on the control plane, not here) - Type-safe evaluation (boolean, string, number, object) ## Documentation diff --git a/packages/evaluator/package.json b/packages/evaluator/package.json index 0c3d3b4..557233a 100644 --- a/packages/evaluator/package.json +++ b/packages/evaluator/package.json @@ -7,9 +7,14 @@ "types": "dist/index.d.ts", "exports": { ".": { - "import": "./dist/index.mjs", - "require": "./dist/index.js", - "types": "./dist/index.d.ts" + "import": { + "types": "./dist/index.d.mts", + "default": "./dist/index.mjs" + }, + "require": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + } } }, "files": [ @@ -20,7 +25,8 @@ "build": "tsup src/index.ts --format cjs,esm --dts", "dev": "tsup src/index.ts --format cjs,esm --dts --watch", "clean": "rm -rf dist", - "typecheck": "tsc --noEmit" + "typecheck": "tsc --noEmit", + "test": "vitest run" }, "devDependencies": { "tsup": "^8.0.0", diff --git a/packages/evaluator/src/evaluator.ts b/packages/evaluator/src/evaluator.ts index d3c3ca0..d0b2744 100644 --- a/packages/evaluator/src/evaluator.ts +++ b/packages/evaluator/src/evaluator.ts @@ -50,12 +50,13 @@ export const GO_SUPPORTED_OPERATORS: ReadonlySet = new Set([ * remotely. */ export function isLocallyEvaluable(flag: FlagConfig): boolean { - for (const rule of flag.targeting ?? []) { + for (const rule of Array.isArray(flag.targeting) ? flag.targeting : []) { + if (!isObject(rule)) continue; for (const cond of rule.conditions ?? []) { if (!GO_SUPPORTED_OPERATORS.has(cond.operator)) return false; } for (const group of rule.groups ?? []) { - for (const cond of group.conditions ?? []) { + for (const cond of group?.conditions ?? []) { if (!GO_SUPPORTED_OPERATORS.has(cond.operator)) return false; } } @@ -71,6 +72,10 @@ export class Evaluator { * Evaluates a flag configuration against an evaluation context. * Order (Go parity): disabled → overrides → targeting rules (schedule → * conditions/groups → rollout %) → variants → default. + * + * Reads the data plane's JSON (/api/flags/export, its SSE configuration + * events) as the data plane does: a field it encodes as null or "" is unset, + * and an entry of targeting or overrides that isn't an object is skipped. */ static evaluate( flag: FlagConfig, @@ -84,10 +89,10 @@ export class Evaluator { } // Overrides — identity match, priority-sorted (highest first) - if (flag.overrides && flag.overrides.length > 0 && context.targetingKey) { - const sorted = [...flag.overrides].sort( - (a, b) => (b.priority ?? 0) - (a.priority ?? 0) - ); + if (Array.isArray(flag.overrides) && flag.overrides.length > 0 && context.targetingKey) { + const sorted = flag.overrides + .filter(isObject) + .sort((a, b) => (b.priority ?? 0) - (a.priority ?? 0)); for (const override of sorted) { if (override.identifiers?.includes(context.targetingKey)) { return { value: override.value, reason: "OVERRIDE" }; @@ -96,21 +101,23 @@ export class Evaluator { } // Targeting rules - if (flag.targeting && flag.targeting.length > 0) { + if (Array.isArray(flag.targeting) && flag.targeting.length > 0) { for (const rule of flag.targeting) { + // Not an object (null, or junk an import stored): no rule. Read as + // one, it would have no conditions and match everyone. + if (!isObject(rule)) continue; if (!isWithinSchedule(rule.schedule)) continue; if (this.evaluateRule(rule, context)) { if ( - rule.rolloutPercentage !== undefined && + rule.rolloutPercentage != null && rule.rolloutPercentage < 100 && !isInRollout(flag.key, context.targetingKey ?? "", rule.rolloutPercentage) ) { continue; } - const value = rule.value !== undefined ? rule.value : flag.defaultValue; return { - value, - variant: rule.variant, + value: rule.value != null ? rule.value : flag.defaultValue, + variant: rule.variant || undefined, reason: "TARGETING_MATCH", }; } @@ -169,47 +176,56 @@ export class Evaluator { } /** - * Evaluates a targeting rule against the context. - * Groups take precedence over flat conditions (Go parity). + * Evaluates a targeting rule against the context (Go parity). A rule with + * condition groups is decided by its groups; otherwise its flat conditions + * combine with conditionOperator ("and" by default). A rule with neither + * conditions nor groups matches everyone, so `conditions: []` with a + * rolloutPercentage rolls the rule's value out to that share of all users. + * The cases in test/rule-matching-cases.json pin these semantics for this + * evaluator, the control plane's and the Go data plane's. */ private static evaluateRule( rule: TargetingRule, context: EvaluationContext ): boolean { - if (rule.groups && rule.groups.length > 0) { + if (Array.isArray(rule.groups) && rule.groups.length > 0) { return this.evaluateGroups(rule.groups, rule.groupOperator || "or", context); } - if (!rule.conditions || rule.conditions.length === 0) { - return false; + // No groups and no conditions: the rule matches everyone. + const conditions = rule.conditions; + if (!Array.isArray(conditions) || conditions.length === 0) { + return true; } const operator = rule.conditionOperator || "and"; if (operator === "or") { - return rule.conditions.some((condition: Condition) => + return conditions.some((condition: Condition) => this.evaluateCondition(condition, context) ); } // Default: AND logic - return rule.conditions.every((condition: Condition) => + return conditions.every((condition: Condition) => this.evaluateCondition(condition, context) ); } /** * Evaluates condition groups: each group ANDs its conditions, groups combine - * with the group operator (default "or") — Go parity. + * with the group operator (default "or") — Go parity. A group without + * conditions matches nobody. */ private static evaluateGroups( - groups: ConditionGroup[], + groups: Array, groupOp: string, context: EvaluationContext ): boolean { - const evalGroup = (g: ConditionGroup): boolean => { - if (!g.conditions || g.conditions.length === 0) return false; - return g.conditions.every((cond) => this.evaluateCondition(cond, context)); + const evalGroup = (g: ConditionGroup | null): boolean => { + const conditions = g?.conditions; + if (!Array.isArray(conditions) || conditions.length === 0) return false; + return conditions.every((cond) => this.evaluateCondition(cond, context)); }; if (groupOp === "or") { @@ -388,7 +404,9 @@ export function hashString(str: string): number { /** * Gradual rollout bucket check — Go parity: - * hash(flagKey + ":rollout:" + targetingKey) % 100 < percentage + * hash(flagKey + ":rollout:" + targetingKey) % 100 < percentage. Without a + * targeting key there is no bucket, so a partial rollout leaves the context + * out. */ export function isInRollout( flagKey: string, @@ -397,9 +415,15 @@ export function isInRollout( ): boolean { if (percentage === 0) return false; if (percentage >= 100) return true; + if (!targetingKey) return false; return hashString(`${flagKey}:rollout:${targetingKey}`) % 100 < percentage; } +/** A JSON object (not null, not an array): what a rule or an override must be. */ +function isObject(value: T): value is T & object { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + /** * Equality with Go evaluator coercion: strings compare as strings, numbers as * numbers, booleans as booleans; mixed types fall back to string form @@ -432,7 +456,7 @@ function parseDate(value: unknown): Date | null { * Schedule gating — Go parity (IsWithinSchedule). All times are UTC. */ export function isWithinSchedule( - schedule: RuleSchedule | undefined, + schedule: RuleSchedule | null | undefined, now: Date = new Date() ): boolean { if (!schedule) return true; diff --git a/packages/evaluator/src/types.ts b/packages/evaluator/src/types.ts index 19d1014..06655c3 100644 --- a/packages/evaluator/src/types.ts +++ b/packages/evaluator/src/types.ts @@ -70,10 +70,11 @@ export interface Condition { /** * Condition group — conditions AND together, groups combine via the rule's - * groupOperator (Go evaluator parity). + * groupOperator (Go evaluator parity). A group without conditions matches + * nobody. */ export interface ConditionGroup { - conditions: Condition[]; + conditions: Condition[] | null; } /** @@ -81,7 +82,7 @@ export interface ConditionGroup { */ export interface ScheduleRecurrence { type?: string; // "daily" | "weekly" - daysOfWeek?: number[]; // 0=Sunday + daysOfWeek?: number[] | null; // 0=Sunday startTime?: string; // "HH:MM" endTime?: string; // "HH:MM" } @@ -92,22 +93,30 @@ export interface ScheduleRecurrence { export interface RuleSchedule { startDate?: string; // RFC3339 endDate?: string; // RFC3339 - recurrence?: ScheduleRecurrence; + recurrence?: ScheduleRecurrence | null; } /** - * Targeting rule with conditions and resulting value/variant + * Targeting rule with conditions and resulting value/variant. Groups, when + * present, decide whether it matches; otherwise its conditions do, and a rule + * with neither (`conditions: []`) matches every context. + * + * The data plane's JSON (/api/flags/export, its SSE configuration events) + * writes every field: an unset one as null, or as "" for conditionOperator, + * groupOperator and variant. Both read as unset. */ export interface TargetingRule { id: string; - conditions: Condition[]; + conditions?: Condition[] | null; conditionOperator?: "and" | "or"; // Default: "and" - groups?: ConditionGroup[]; + groups?: ConditionGroup[] | null; groupOperator?: "and" | "or"; // Default: "or" variant?: string; - value?: FlagValue; - rolloutPercentage?: number; - schedule?: RuleSchedule; + /** Unset: the rule serves the flag's default value. */ + value?: FlagValue | null; + /** Share of targeting keys (0-100, whole numbers) the rule serves; unset serves all. */ + rolloutPercentage?: number | null; + schedule?: RuleSchedule | null; } /** @@ -129,9 +138,9 @@ export interface FlagConfig { type: FlagType; enabled: boolean; defaultValue: FlagValue; - variants?: FlagVariant[]; - targeting?: TargetingRule[]; - overrides?: FlagOverride[]; + variants?: FlagVariant[] | null; + targeting?: TargetingRule[] | null; + overrides?: FlagOverride[] | null; } /** diff --git a/packages/evaluator/test/rule-matching-cases.go-wire.json b/packages/evaluator/test/rule-matching-cases.go-wire.json new file mode 100644 index 0000000..710d0d4 --- /dev/null +++ b/packages/evaluator/test/rule-matching-cases.go-wire.json @@ -0,0 +1,1345 @@ +{ + "description": "Generated by services/flaggr-api/evaluator/rule_matching_parity_test.go (UPDATE_RULE_MATCHING_GO_WIRE=1 go test ./evaluator -run TestRuleMatchingGoWireForm); don't edit. Each case of rule-matching-cases.json, by name, with its flag as the Go data plane encodes it (/api/flags/export, the SSE configuration events): unset fields null or \"\", null targeting entries dropped. The TypeScript evaluators' tests evaluate these flags against the cases' contexts too.", + "flags": { + "a disabled flag serves its default, whatever its rules": { + "key": "disabled", + "name": "", + "description": "", + "type": "string", + "enabled": false, + "defaultValue": "off", + "projectId": "", + "serviceId": "", + "environment": "", + "tags": null, + "targeting": [ + { + "id": "everyone", + "conditions": [], + "conditionOperator": "", + "groups": null, + "groupOperator": "", + "variant": "", + "value": "on", + "rolloutPercentage": null, + "schedule": null + } + ], + "variants": null, + "overrides": null, + "prerequisites": null, + "createdAt": "", + "updatedAt": "", + "version": 0 + }, + "a null entry in targeting is no rule": { + "key": "null-entry", + "name": "", + "description": "", + "type": "string", + "enabled": true, + "defaultValue": "off", + "projectId": "", + "serviceId": "", + "environment": "", + "tags": null, + "targeting": [ + { + "id": "pro", + "conditions": [ + { + "property": "plan", + "operator": "equals", + "value": "pro", + "cohortId": "" + } + ], + "conditionOperator": "", + "groups": null, + "groupOperator": "", + "variant": "", + "value": "pro-value", + "rolloutPercentage": null, + "schedule": null + } + ], + "variants": [ + { + "name": "control", + "value": "control-value", + "weight": 50 + }, + { + "name": "treatment", + "value": "treatment-value", + "weight": 50 + } + ], + "overrides": null, + "prerequisites": null, + "createdAt": "", + "updatedAt": "", + "version": 0 + }, + "a rule whose conditions are null matches everyone": { + "key": "everyone-on", + "name": "", + "description": "", + "type": "string", + "enabled": true, + "defaultValue": "off", + "projectId": "", + "serviceId": "", + "environment": "", + "tags": null, + "targeting": [ + { + "id": "everyone", + "conditions": null, + "conditionOperator": "", + "groups": null, + "groupOperator": "", + "variant": "", + "value": "on", + "rolloutPercentage": null, + "schedule": null + } + ], + "variants": null, + "overrides": null, + "prerequisites": null, + "createdAt": "", + "updatedAt": "", + "version": 0 + }, + "a rule whose only group is empty matches nobody": { + "key": "empty-group", + "name": "", + "description": "", + "type": "string", + "enabled": true, + "defaultValue": "off", + "projectId": "", + "serviceId": "", + "environment": "", + "tags": null, + "targeting": [ + { + "id": "groups", + "conditions": [], + "conditionOperator": "", + "groups": [ + { + "conditions": [] + } + ], + "groupOperator": "", + "variant": "", + "value": "on", + "rolloutPercentage": null, + "schedule": null + } + ], + "variants": null, + "overrides": null, + "prerequisites": null, + "createdAt": "", + "updatedAt": "", + "version": 0 + }, + "a rule with no conditions after a targeted rule: the first rule that matches serves": { + "key": "first-match", + "name": "", + "description": "", + "type": "string", + "enabled": true, + "defaultValue": "off", + "projectId": "", + "serviceId": "", + "environment": "", + "tags": null, + "targeting": [ + { + "id": "pro", + "conditions": [ + { + "property": "plan", + "operator": "equals", + "value": "pro", + "cohortId": "" + } + ], + "conditionOperator": "", + "groups": null, + "groupOperator": "", + "variant": "", + "value": "pro-value", + "rolloutPercentage": null, + "schedule": null + }, + { + "id": "everyone", + "conditions": [], + "conditionOperator": "", + "groups": null, + "groupOperator": "", + "variant": "", + "value": "everyone-value", + "rolloutPercentage": null, + "schedule": null + } + ], + "variants": null, + "overrides": null, + "prerequisites": null, + "createdAt": "", + "updatedAt": "", + "version": 0 + }, + "a rule with no conditions and an empty groups list matches everyone": { + "key": "everyone-on", + "name": "", + "description": "", + "type": "string", + "enabled": true, + "defaultValue": "off", + "projectId": "", + "serviceId": "", + "environment": "", + "tags": null, + "targeting": [ + { + "id": "everyone", + "conditions": [], + "conditionOperator": "", + "groups": [], + "groupOperator": "", + "variant": "", + "value": "on", + "rolloutPercentage": null, + "schedule": null + } + ], + "variants": null, + "overrides": null, + "prerequisites": null, + "createdAt": "", + "updatedAt": "", + "version": 0 + }, + "a rule with no conditions matches everyone": { + "key": "everyone-on", + "name": "", + "description": "", + "type": "string", + "enabled": true, + "defaultValue": "off", + "projectId": "", + "serviceId": "", + "environment": "", + "tags": null, + "targeting": [ + { + "id": "everyone", + "conditions": [], + "conditionOperator": "", + "groups": null, + "groupOperator": "", + "variant": "", + "value": "on", + "rolloutPercentage": null, + "schedule": null + } + ], + "variants": null, + "overrides": null, + "prerequisites": null, + "createdAt": "", + "updatedAt": "", + "version": 0 + }, + "a rule without a conditions field matches everyone": { + "key": "everyone-on", + "name": "", + "description": "", + "type": "string", + "enabled": true, + "defaultValue": "off", + "projectId": "", + "serviceId": "", + "environment": "", + "tags": null, + "targeting": [ + { + "id": "everyone", + "conditions": null, + "conditionOperator": "", + "groups": null, + "groupOperator": "", + "variant": "", + "value": "on", + "rolloutPercentage": null, + "schedule": null + } + ], + "variants": null, + "overrides": null, + "prerequisites": null, + "createdAt": "", + "updatedAt": "", + "version": 0 + }, + "an empty group fails an and of groups": { + "key": "empty-group", + "name": "", + "description": "", + "type": "string", + "enabled": true, + "defaultValue": "off", + "projectId": "", + "serviceId": "", + "environment": "", + "tags": null, + "targeting": [ + { + "id": "groups", + "conditions": [], + "conditionOperator": "", + "groups": [ + { + "conditions": [] + }, + { + "conditions": [ + { + "property": "role", + "operator": "equals", + "value": "admin", + "cohortId": "" + } + ] + } + ], + "groupOperator": "and", + "variant": "", + "value": "on", + "rolloutPercentage": null, + "schedule": null + } + ], + "variants": null, + "overrides": null, + "prerequisites": null, + "createdAt": "", + "updatedAt": "", + "version": 0 + }, + "an empty group matches nobody, but the other groups still can": { + "key": "empty-group", + "name": "", + "description": "", + "type": "string", + "enabled": true, + "defaultValue": "off", + "projectId": "", + "serviceId": "", + "environment": "", + "tags": null, + "targeting": [ + { + "id": "groups", + "conditions": [], + "conditionOperator": "", + "groups": [ + { + "conditions": [] + }, + { + "conditions": [ + { + "property": "role", + "operator": "equals", + "value": "admin", + "cohortId": "" + } + ] + } + ], + "groupOperator": "", + "variant": "", + "value": "on", + "rolloutPercentage": null, + "schedule": null + } + ], + "variants": null, + "overrides": null, + "prerequisites": null, + "createdAt": "", + "updatedAt": "", + "version": 0 + }, + "an empty groupOperator combines groups with or, an empty conditionOperator conditions with and": { + "key": "empty-operators", + "name": "", + "description": "", + "type": "string", + "enabled": true, + "defaultValue": "off", + "projectId": "", + "serviceId": "", + "environment": "", + "tags": null, + "targeting": [ + { + "id": "groups", + "conditions": null, + "conditionOperator": "", + "groups": [ + { + "conditions": [ + { + "property": "plan", + "operator": "equals", + "value": "pro", + "cohortId": "" + } + ] + }, + { + "conditions": [ + { + "property": "role", + "operator": "equals", + "value": "admin", + "cohortId": "" + } + ] + } + ], + "groupOperator": "", + "variant": "", + "value": "groups-value", + "rolloutPercentage": null, + "schedule": null + }, + { + "id": "both", + "conditions": [ + { + "property": "country", + "operator": "equals", + "value": "AU", + "cohortId": "" + }, + { + "property": "beta", + "operator": "equals", + "value": true, + "cohortId": "" + } + ], + "conditionOperator": "", + "groups": null, + "groupOperator": "", + "variant": "", + "value": "both-value", + "rolloutPercentage": null, + "schedule": null + } + ], + "variants": null, + "overrides": null, + "prerequisites": null, + "createdAt": "", + "updatedAt": "", + "version": 0 + }, + "conditions combine with and by default": { + "key": "conditions-and", + "name": "", + "description": "", + "type": "string", + "enabled": true, + "defaultValue": "off", + "projectId": "", + "serviceId": "", + "environment": "", + "tags": null, + "targeting": [ + { + "id": "au-pro", + "conditions": [ + { + "property": "country", + "operator": "equals", + "value": "AU", + "cohortId": "" + }, + { + "property": "plan", + "operator": "equals", + "value": "pro", + "cohortId": "" + } + ], + "conditionOperator": "", + "groups": null, + "groupOperator": "", + "variant": "", + "value": "on", + "rolloutPercentage": null, + "schedule": null + } + ], + "variants": null, + "overrides": null, + "prerequisites": null, + "createdAt": "", + "updatedAt": "", + "version": 0 + }, + "conditions combine with or": { + "key": "conditions-or", + "name": "", + "description": "", + "type": "string", + "enabled": true, + "defaultValue": "off", + "projectId": "", + "serviceId": "", + "environment": "", + "tags": null, + "targeting": [ + { + "id": "oceania", + "conditions": [ + { + "property": "country", + "operator": "equals", + "value": "AU", + "cohortId": "" + }, + { + "property": "country", + "operator": "equals", + "value": "NZ", + "cohortId": "" + } + ], + "conditionOperator": "or", + "groups": null, + "groupOperator": "", + "variant": "", + "value": "on", + "rolloutPercentage": null, + "schedule": null + } + ], + "variants": null, + "overrides": null, + "prerequisites": null, + "createdAt": "", + "updatedAt": "", + "version": 0 + }, + "groups and no conditions: the groups decide (or by default)": { + "key": "groups-only", + "name": "", + "description": "", + "type": "string", + "enabled": true, + "defaultValue": "off", + "projectId": "", + "serviceId": "", + "environment": "", + "tags": null, + "targeting": [ + { + "id": "groups", + "conditions": [], + "conditionOperator": "", + "groups": [ + { + "conditions": [ + { + "property": "country", + "operator": "equals", + "value": "AU", + "cohortId": "" + }, + { + "property": "plan", + "operator": "equals", + "value": "pro", + "cohortId": "" + } + ] + }, + { + "conditions": [ + { + "property": "role", + "operator": "equals", + "value": "admin", + "cohortId": "" + } + ] + } + ], + "groupOperator": "", + "variant": "", + "value": "on", + "rolloutPercentage": null, + "schedule": null + } + ], + "variants": null, + "overrides": null, + "prerequisites": null, + "createdAt": "", + "updatedAt": "", + "version": 0 + }, + "groups combined with and: every group must match": { + "key": "groups-and", + "name": "", + "description": "", + "type": "string", + "enabled": true, + "defaultValue": "off", + "projectId": "", + "serviceId": "", + "environment": "", + "tags": null, + "targeting": [ + { + "id": "groups", + "conditions": [], + "conditionOperator": "", + "groups": [ + { + "conditions": [ + { + "property": "country", + "operator": "equals", + "value": "AU", + "cohortId": "" + } + ] + }, + { + "conditions": [ + { + "property": "plan", + "operator": "equals", + "value": "pro", + "cohortId": "" + } + ] + } + ], + "groupOperator": "and", + "variant": "", + "value": "on", + "rolloutPercentage": null, + "schedule": null + } + ], + "variants": null, + "overrides": null, + "prerequisites": null, + "createdAt": "", + "updatedAt": "", + "version": 0 + }, + "groups take precedence over flat conditions": { + "key": "groups-first", + "name": "", + "description": "", + "type": "string", + "enabled": true, + "defaultValue": "off", + "projectId": "", + "serviceId": "", + "environment": "", + "tags": null, + "targeting": [ + { + "id": "groups", + "conditions": [ + { + "property": "plan", + "operator": "equals", + "value": "pro", + "cohortId": "" + } + ], + "conditionOperator": "", + "groups": [ + { + "conditions": [ + { + "property": "country", + "operator": "equals", + "value": "AU", + "cohortId": "" + } + ] + } + ], + "groupOperator": "", + "variant": "", + "value": "on", + "rolloutPercentage": null, + "schedule": null + } + ], + "variants": null, + "overrides": null, + "prerequisites": null, + "createdAt": "", + "updatedAt": "", + "version": 0 + }, + "missing attribute: contains and regex don't match": { + "key": "missing-string-ops", + "name": "", + "description": "", + "type": "string", + "enabled": true, + "defaultValue": "off", + "projectId": "", + "serviceId": "", + "environment": "", + "tags": null, + "targeting": [ + { + "id": "contains", + "conditions": [ + { + "property": "email", + "operator": "contains", + "value": "@example.com", + "cohortId": "" + } + ], + "conditionOperator": "", + "groups": null, + "groupOperator": "", + "variant": "", + "value": "contains-value", + "rolloutPercentage": null, + "schedule": null + }, + { + "id": "regex", + "conditions": [ + { + "property": "username", + "operator": "regex", + "value": "^admin-", + "cohortId": "" + } + ], + "conditionOperator": "", + "groups": null, + "groupOperator": "", + "variant": "", + "value": "regex-value", + "rolloutPercentage": null, + "schedule": null + } + ], + "variants": null, + "overrides": null, + "prerequisites": null, + "createdAt": "", + "updatedAt": "", + "version": 0 + }, + "missing attribute: equals doesn't match": { + "key": "missing-equals", + "name": "", + "description": "", + "type": "string", + "enabled": true, + "defaultValue": "off", + "projectId": "", + "serviceId": "", + "environment": "", + "tags": null, + "targeting": [ + { + "id": "pro", + "conditions": [ + { + "property": "plan", + "operator": "equals", + "value": "pro", + "cohortId": "" + } + ], + "conditionOperator": "", + "groups": null, + "groupOperator": "", + "variant": "", + "value": "on", + "rolloutPercentage": null, + "schedule": null + } + ], + "variants": null, + "overrides": null, + "prerequisites": null, + "createdAt": "", + "updatedAt": "", + "version": 0 + }, + "missing attribute: greater_than doesn't match": { + "key": "missing-greater-than", + "name": "", + "description": "", + "type": "string", + "enabled": true, + "defaultValue": "off", + "projectId": "", + "serviceId": "", + "environment": "", + "tags": null, + "targeting": [ + { + "id": "adults", + "conditions": [ + { + "property": "age", + "operator": "greater_than", + "value": 18, + "cohortId": "" + } + ], + "conditionOperator": "", + "groups": null, + "groupOperator": "", + "variant": "", + "value": "on", + "rolloutPercentage": null, + "schedule": null + } + ], + "variants": null, + "overrides": null, + "prerequisites": null, + "createdAt": "", + "updatedAt": "", + "version": 0 + }, + "missing attribute: in doesn't match": { + "key": "missing-in", + "name": "", + "description": "", + "type": "string", + "enabled": true, + "defaultValue": "off", + "projectId": "", + "serviceId": "", + "environment": "", + "tags": null, + "targeting": [ + { + "id": "paid", + "conditions": [ + { + "property": "plan", + "operator": "in", + "value": [ + "pro", + "team" + ], + "cohortId": "" + } + ], + "conditionOperator": "", + "groups": null, + "groupOperator": "", + "variant": "", + "value": "on", + "rolloutPercentage": null, + "schedule": null + } + ], + "variants": null, + "overrides": null, + "prerequisites": null, + "createdAt": "", + "updatedAt": "", + "version": 0 + }, + "missing attribute: not_equals matches": { + "key": "missing-not-equals", + "name": "", + "description": "", + "type": "string", + "enabled": true, + "defaultValue": "off", + "projectId": "", + "serviceId": "", + "environment": "", + "tags": null, + "targeting": [ + { + "id": "not-free", + "conditions": [ + { + "property": "plan", + "operator": "not_equals", + "value": "free", + "cohortId": "" + } + ], + "conditionOperator": "", + "groups": null, + "groupOperator": "", + "variant": "", + "value": "on", + "rolloutPercentage": null, + "schedule": null + } + ], + "variants": null, + "overrides": null, + "prerequisites": null, + "createdAt": "", + "updatedAt": "", + "version": 0 + }, + "missing attribute: not_in doesn't match": { + "key": "missing-not-in", + "name": "", + "description": "", + "type": "string", + "enabled": true, + "defaultValue": "off", + "projectId": "", + "serviceId": "", + "environment": "", + "tags": null, + "targeting": [ + { + "id": "not-free", + "conditions": [ + { + "property": "plan", + "operator": "not_in", + "value": [ + "free" + ], + "cohortId": "" + } + ], + "conditionOperator": "", + "groups": null, + "groupOperator": "", + "variant": "", + "value": "on", + "rolloutPercentage": null, + "schedule": null + } + ], + "variants": null, + "overrides": null, + "prerequisites": null, + "createdAt": "", + "updatedAt": "", + "version": 0 + }, + "no conditions and a 0% rollout: nobody": { + "key": "everyone-rollout", + "name": "", + "description": "", + "type": "string", + "enabled": true, + "defaultValue": "off", + "projectId": "", + "serviceId": "", + "environment": "", + "tags": null, + "targeting": [ + { + "id": "everyone", + "conditions": [], + "conditionOperator": "", + "groups": null, + "groupOperator": "", + "variant": "", + "value": "on", + "rolloutPercentage": 0, + "schedule": null + } + ], + "variants": null, + "overrides": null, + "prerequisites": null, + "createdAt": "", + "updatedAt": "", + "version": 0 + }, + "no conditions and a 100% rollout: everyone, with or without a targeting key": { + "key": "everyone-rollout", + "name": "", + "description": "", + "type": "string", + "enabled": true, + "defaultValue": "off", + "projectId": "", + "serviceId": "", + "environment": "", + "tags": null, + "targeting": [ + { + "id": "everyone", + "conditions": [], + "conditionOperator": "", + "groups": null, + "groupOperator": "", + "variant": "", + "value": "on", + "rolloutPercentage": 100, + "schedule": null + } + ], + "variants": null, + "overrides": null, + "prerequisites": null, + "createdAt": "", + "updatedAt": "", + "version": 0 + }, + "no conditions and a 30% rollout: the targeting keys in rollout buckets 0-29, never a context without one": { + "key": "everyone-rollout", + "name": "", + "description": "", + "type": "string", + "enabled": true, + "defaultValue": "off", + "projectId": "", + "serviceId": "", + "environment": "", + "tags": null, + "targeting": [ + { + "id": "thirty-percent", + "conditions": [], + "conditionOperator": "", + "groups": null, + "groupOperator": "", + "variant": "", + "value": "on", + "rolloutPercentage": 30, + "schedule": null + } + ], + "variants": null, + "overrides": null, + "prerequisites": null, + "createdAt": "", + "updatedAt": "", + "version": 0 + }, + "no conditions and a 50% rollout ahead of a variant split: keys outside the rollout get a variant": { + "key": "everyone-split", + "name": "", + "description": "", + "type": "string", + "enabled": true, + "defaultValue": "off", + "projectId": "", + "serviceId": "", + "environment": "", + "tags": null, + "targeting": [ + { + "id": "half", + "conditions": [], + "conditionOperator": "", + "groups": null, + "groupOperator": "", + "variant": "", + "value": "rule-value", + "rolloutPercentage": 50, + "schedule": null + } + ], + "variants": [ + { + "name": "control", + "value": "control-value", + "weight": 50 + }, + { + "name": "treatment", + "value": "treatment-value", + "weight": 50 + } + ], + "overrides": null, + "prerequisites": null, + "createdAt": "", + "updatedAt": "", + "version": 0 + }, + "no conditions and a variant: everyone gets the rule's value, named by its variant": { + "key": "everyone-variant", + "name": "", + "description": "", + "type": "string", + "enabled": true, + "defaultValue": "off", + "projectId": "", + "serviceId": "", + "environment": "", + "tags": null, + "targeting": [ + { + "id": "treatment-for-all", + "conditions": [], + "conditionOperator": "", + "groups": null, + "groupOperator": "", + "variant": "treatment", + "value": "treatment-value", + "rolloutPercentage": null, + "schedule": null + } + ], + "variants": [ + { + "name": "control", + "value": "control-value", + "weight": 50 + }, + { + "name": "treatment", + "value": "treatment-value", + "weight": 50 + } + ], + "overrides": null, + "prerequisites": null, + "createdAt": "", + "updatedAt": "", + "version": 0 + }, + "overrides come before a rule for everyone": { + "key": "overridden", + "name": "", + "description": "", + "type": "string", + "enabled": true, + "defaultValue": "off", + "projectId": "", + "serviceId": "", + "environment": "", + "tags": null, + "targeting": [ + { + "id": "everyone", + "conditions": [], + "conditionOperator": "", + "groups": null, + "groupOperator": "", + "variant": "", + "value": "on", + "rolloutPercentage": null, + "schedule": null + } + ], + "variants": null, + "overrides": [ + { + "name": "vip", + "identifiers": [ + "vip-user" + ], + "value": "vip-value", + "priority": 1 + } + ], + "prerequisites": null, + "createdAt": "", + "updatedAt": "", + "version": 0 + }, + "rules with no conditions apply only inside their schedule": { + "key": "scheduled", + "name": "", + "description": "", + "type": "string", + "enabled": true, + "defaultValue": "off", + "projectId": "", + "serviceId": "", + "environment": "", + "tags": null, + "targeting": [ + { + "id": "expired", + "conditions": [], + "conditionOperator": "", + "groups": null, + "groupOperator": "", + "variant": "", + "value": "expired-value", + "rolloutPercentage": null, + "schedule": { + "startDate": "2020-01-01T00:00:00Z", + "endDate": "2020-01-02T00:00:00Z", + "recurrence": null + } + }, + { + "id": "open-ended", + "conditions": [], + "conditionOperator": "", + "groups": null, + "groupOperator": "", + "variant": "", + "value": "open-value", + "rolloutPercentage": null, + "schedule": { + "startDate": "2020-01-01T00:00:00Z", + "endDate": "", + "recurrence": null + } + } + ], + "variants": null, + "overrides": null, + "prerequisites": null, + "createdAt": "", + "updatedAt": "", + "version": 0 + }, + "targeting of only null entries leaves the flag to its variants": { + "key": "null-entry", + "name": "", + "description": "", + "type": "string", + "enabled": true, + "defaultValue": "off", + "projectId": "", + "serviceId": "", + "environment": "", + "tags": null, + "targeting": [], + "variants": [ + { + "name": "control", + "value": "control-value", + "weight": 50 + }, + { + "name": "treatment", + "value": "treatment-value", + "weight": 50 + } + ], + "overrides": null, + "prerequisites": null, + "createdAt": "", + "updatedAt": "", + "version": 0 + }, + "the rollout plans' match-all condition matches everyone, with or without a targeting key": { + "key": "match-all", + "name": "", + "description": "", + "type": "string", + "enabled": true, + "defaultValue": "off", + "projectId": "", + "serviceId": "", + "environment": "", + "tags": null, + "targeting": [ + { + "id": "rollout-plan", + "conditions": [ + { + "property": "targetingKey", + "operator": "not_equals", + "value": "__flaggr_rollout_match_all__", + "cohortId": "" + } + ], + "conditionOperator": "", + "groups": null, + "groupOperator": "", + "variant": "", + "value": "on", + "rolloutPercentage": null, + "schedule": null + } + ], + "variants": null, + "overrides": null, + "prerequisites": null, + "createdAt": "", + "updatedAt": "", + "version": 0 + }, + "unset fields as the data plane encodes them (null and \"\") read as unset": { + "key": "data-plane-encoding", + "name": "", + "description": "", + "type": "string", + "enabled": true, + "defaultValue": "off", + "projectId": "", + "serviceId": "", + "environment": "", + "tags": null, + "targeting": [ + { + "id": "pro", + "conditions": [ + { + "property": "plan", + "operator": "equals", + "value": "pro", + "cohortId": "" + } + ], + "conditionOperator": "", + "groups": null, + "groupOperator": "", + "variant": "", + "value": "pro-value", + "rolloutPercentage": null, + "schedule": null + }, + { + "id": "everyone", + "conditions": null, + "conditionOperator": "", + "groups": null, + "groupOperator": "", + "variant": "", + "value": null, + "rolloutPercentage": null, + "schedule": { + "startDate": "", + "endDate": "", + "recurrence": null + } + } + ], + "variants": null, + "overrides": null, + "prerequisites": null, + "createdAt": "", + "updatedAt": "", + "version": 0 + } + } +} diff --git a/packages/evaluator/test/rule-matching-cases.json b/packages/evaluator/test/rule-matching-cases.json new file mode 100644 index 0000000..ca7d523 --- /dev/null +++ b/packages/evaluator/test/rule-matching-cases.json @@ -0,0 +1,641 @@ +{ + "description": "How every Flaggr evaluator matches targeting rules. A rule with condition groups is decided by its groups (each group ANDs its conditions; groups combine with groupOperator, \"or\" by default; a group without conditions matches nobody). Otherwise its conditions decide (conditionOperator, \"and\" by default), and a rule with neither conditions nor groups matches everyone. A matched rule's rolloutPercentage then admits that share of targeting keys (none without a targeting key), and the first rule that matches serves its value. Each case's flag is evaluated with each context; value, reason and variant (absent = none) must come out as given. Unset fields may come as the data plane encodes them, null or \"\" (conditions, groups, rolloutPercentage, value and schedule null; conditionOperator, groupOperator and variant \"\"), and read as unset; an entry of targeting that is null is no rule. Each case also runs as rule-matching-cases.go-wire.json holds it: the flag as the Go data plane encodes it (/api/flags/export, the SSE configuration events), regenerated by its Go test.", + "consumers": [ + "__tests__/unit/evaluator-parity.test.ts (src/lib/evaluator.ts and @flaggr/evaluator, root Jest)", + "__tests__/unit/in-process-provider-rules.test.ts (InProcessProvider, root Jest)", + "packages/evaluator/test/rule-matching.test.ts (@flaggr/evaluator, vitest)", + "services/flaggr-api/evaluator/rule_matching_parity_test.go (Go data plane; writes rule-matching-cases.go-wire.json)" + ], + "cases": [ + { + "name": "a rule with no conditions matches everyone", + "flag": { + "key": "everyone-on", + "type": "string", + "enabled": true, + "defaultValue": "off", + "targeting": [{ "id": "everyone", "conditions": [], "value": "on" }] + }, + "evaluations": [ + { "context": { "targetingKey": "user-1" }, "value": "on", "reason": "TARGETING_MATCH" }, + { "context": { "targetingKey": "user-2", "plan": "free" }, "value": "on", "reason": "TARGETING_MATCH" }, + { "context": {}, "value": "on", "reason": "TARGETING_MATCH" } + ] + }, + { + "name": "a rule without a conditions field matches everyone", + "flag": { + "key": "everyone-on", + "type": "string", + "enabled": true, + "defaultValue": "off", + "targeting": [{ "id": "everyone", "value": "on" }] + }, + "evaluations": [ + { "context": { "targetingKey": "user-1" }, "value": "on", "reason": "TARGETING_MATCH" }, + { "context": {}, "value": "on", "reason": "TARGETING_MATCH" } + ] + }, + { + "name": "a rule whose conditions are null matches everyone", + "flag": { + "key": "everyone-on", + "type": "string", + "enabled": true, + "defaultValue": "off", + "targeting": [{ "id": "everyone", "conditions": null, "value": "on" }] + }, + "evaluations": [ + { "context": { "targetingKey": "user-1" }, "value": "on", "reason": "TARGETING_MATCH" }, + { "context": {}, "value": "on", "reason": "TARGETING_MATCH" } + ] + }, + { + "name": "a rule with no conditions and an empty groups list matches everyone", + "flag": { + "key": "everyone-on", + "type": "string", + "enabled": true, + "defaultValue": "off", + "targeting": [{ "id": "everyone", "conditions": [], "groups": [], "value": "on" }] + }, + "evaluations": [ + { "context": { "targetingKey": "user-1" }, "value": "on", "reason": "TARGETING_MATCH" }, + { "context": {}, "value": "on", "reason": "TARGETING_MATCH" } + ] + }, + { + "name": "no conditions and a 100% rollout: everyone, with or without a targeting key", + "flag": { + "key": "everyone-rollout", + "type": "string", + "enabled": true, + "defaultValue": "off", + "targeting": [{ "id": "everyone", "conditions": [], "rolloutPercentage": 100, "value": "on" }] + }, + "evaluations": [ + { "context": { "targetingKey": "bob" }, "value": "on", "reason": "TARGETING_MATCH" }, + { "context": {}, "value": "on", "reason": "TARGETING_MATCH" } + ] + }, + { + "name": "no conditions and a 0% rollout: nobody", + "flag": { + "key": "everyone-rollout", + "type": "string", + "enabled": true, + "defaultValue": "off", + "targeting": [{ "id": "everyone", "conditions": [], "rolloutPercentage": 0, "value": "on" }] + }, + "evaluations": [ + { "context": { "targetingKey": "alice" }, "value": "off", "reason": "DEFAULT" }, + { "context": {}, "value": "off", "reason": "DEFAULT" } + ] + }, + { + "name": "no conditions and a 30% rollout: the targeting keys in rollout buckets 0-29, never a context without one", + "flag": { + "key": "everyone-rollout", + "type": "string", + "enabled": true, + "defaultValue": "off", + "targeting": [{ "id": "thirty-percent", "conditions": [], "rolloutPercentage": 30, "value": "on" }] + }, + "evaluations": [ + { "context": { "targetingKey": "alice" }, "value": "on", "reason": "TARGETING_MATCH" }, + { "context": { "targetingKey": "dave" }, "value": "on", "reason": "TARGETING_MATCH" }, + { "context": { "targetingKey": "user-1" }, "value": "off", "reason": "DEFAULT" }, + { "context": { "targetingKey": "bob" }, "value": "off", "reason": "DEFAULT" }, + { "context": { "plan": "pro" }, "value": "off", "reason": "DEFAULT" } + ] + }, + { + "name": "no conditions and a variant: everyone gets the rule's value, named by its variant", + "flag": { + "key": "everyone-variant", + "type": "string", + "enabled": true, + "defaultValue": "off", + "variants": [ + { "name": "control", "value": "control-value", "weight": 50 }, + { "name": "treatment", "value": "treatment-value", "weight": 50 } + ], + "targeting": [{ "id": "treatment-for-all", "conditions": [], "variant": "treatment", "value": "treatment-value" }] + }, + "evaluations": [ + { "context": { "targetingKey": "user-1" }, "value": "treatment-value", "variant": "treatment", "reason": "TARGETING_MATCH" }, + { "context": { "targetingKey": "carol" }, "value": "treatment-value", "variant": "treatment", "reason": "TARGETING_MATCH" }, + { "context": {}, "value": "treatment-value", "variant": "treatment", "reason": "TARGETING_MATCH" } + ] + }, + { + "name": "no conditions and a 50% rollout ahead of a variant split: keys outside the rollout get a variant", + "flag": { + "key": "everyone-split", + "type": "string", + "enabled": true, + "defaultValue": "off", + "variants": [ + { "name": "control", "value": "control-value", "weight": 50 }, + { "name": "treatment", "value": "treatment-value", "weight": 50 } + ], + "targeting": [{ "id": "half", "conditions": [], "rolloutPercentage": 50, "value": "rule-value" }] + }, + "evaluations": [ + { "context": { "targetingKey": "alice" }, "value": "rule-value", "reason": "TARGETING_MATCH" }, + { "context": { "targetingKey": "erin" }, "value": "rule-value", "reason": "TARGETING_MATCH" }, + { "context": { "targetingKey": "carol" }, "value": "control-value", "variant": "control", "reason": "VARIANT" }, + { "context": { "targetingKey": "user-1" }, "value": "treatment-value", "variant": "treatment", "reason": "VARIANT" }, + { "context": {}, "value": "off", "reason": "DEFAULT" } + ] + }, + { + "name": "a rule with no conditions after a targeted rule: the first rule that matches serves", + "flag": { + "key": "first-match", + "type": "string", + "enabled": true, + "defaultValue": "off", + "targeting": [ + { "id": "pro", "conditions": [{ "property": "plan", "operator": "equals", "value": "pro" }], "value": "pro-value" }, + { "id": "everyone", "conditions": [], "value": "everyone-value" } + ] + }, + "evaluations": [ + { "context": { "targetingKey": "user-1", "plan": "pro" }, "value": "pro-value", "reason": "TARGETING_MATCH" }, + { "context": { "targetingKey": "user-2", "plan": "free" }, "value": "everyone-value", "reason": "TARGETING_MATCH" }, + { "context": {}, "value": "everyone-value", "reason": "TARGETING_MATCH" } + ] + }, + { + "name": "rules with no conditions apply only inside their schedule", + "flag": { + "key": "scheduled", + "type": "string", + "enabled": true, + "defaultValue": "off", + "targeting": [ + { + "id": "expired", + "conditions": [], + "value": "expired-value", + "schedule": { "startDate": "2020-01-01T00:00:00Z", "endDate": "2020-01-02T00:00:00Z" } + }, + { + "id": "open-ended", + "conditions": [], + "value": "open-value", + "schedule": { "startDate": "2020-01-01T00:00:00Z" } + } + ] + }, + "evaluations": [ + { "context": { "targetingKey": "user-1" }, "value": "open-value", "reason": "TARGETING_MATCH" }, + { "context": {}, "value": "open-value", "reason": "TARGETING_MATCH" } + ] + }, + { + "name": "groups and no conditions: the groups decide (or by default)", + "flag": { + "key": "groups-only", + "type": "string", + "enabled": true, + "defaultValue": "off", + "targeting": [ + { + "id": "groups", + "conditions": [], + "groups": [ + { + "conditions": [ + { "property": "country", "operator": "equals", "value": "AU" }, + { "property": "plan", "operator": "equals", "value": "pro" } + ] + }, + { "conditions": [{ "property": "role", "operator": "equals", "value": "admin" }] } + ], + "value": "on" + } + ] + }, + "evaluations": [ + { "context": { "targetingKey": "user-1", "country": "AU", "plan": "pro" }, "value": "on", "reason": "TARGETING_MATCH" }, + { "context": { "role": "admin" }, "value": "on", "reason": "TARGETING_MATCH" }, + { "context": { "targetingKey": "user-2", "country": "AU", "plan": "free" }, "value": "off", "reason": "DEFAULT" }, + { "context": { "targetingKey": "user-3" }, "value": "off", "reason": "DEFAULT" }, + { "context": {}, "value": "off", "reason": "DEFAULT" } + ] + }, + { + "name": "groups combined with and: every group must match", + "flag": { + "key": "groups-and", + "type": "string", + "enabled": true, + "defaultValue": "off", + "targeting": [ + { + "id": "groups", + "conditions": [], + "groups": [ + { "conditions": [{ "property": "country", "operator": "equals", "value": "AU" }] }, + { "conditions": [{ "property": "plan", "operator": "equals", "value": "pro" }] } + ], + "groupOperator": "and", + "value": "on" + } + ] + }, + "evaluations": [ + { "context": { "country": "AU", "plan": "pro" }, "value": "on", "reason": "TARGETING_MATCH" }, + { "context": { "country": "AU" }, "value": "off", "reason": "DEFAULT" }, + { "context": { "plan": "pro" }, "value": "off", "reason": "DEFAULT" } + ] + }, + { + "name": "groups take precedence over flat conditions", + "flag": { + "key": "groups-first", + "type": "string", + "enabled": true, + "defaultValue": "off", + "targeting": [ + { + "id": "groups", + "conditions": [{ "property": "plan", "operator": "equals", "value": "pro" }], + "groups": [{ "conditions": [{ "property": "country", "operator": "equals", "value": "AU" }] }], + "value": "on" + } + ] + }, + "evaluations": [ + { "context": { "country": "AU", "plan": "free" }, "value": "on", "reason": "TARGETING_MATCH" }, + { "context": { "country": "NZ", "plan": "pro" }, "value": "off", "reason": "DEFAULT" } + ] + }, + { + "name": "an empty group matches nobody, but the other groups still can", + "flag": { + "key": "empty-group", + "type": "string", + "enabled": true, + "defaultValue": "off", + "targeting": [ + { + "id": "groups", + "conditions": [], + "groups": [{ "conditions": [] }, { "conditions": [{ "property": "role", "operator": "equals", "value": "admin" }] }], + "value": "on" + } + ] + }, + "evaluations": [ + { "context": { "targetingKey": "user-1", "role": "admin" }, "value": "on", "reason": "TARGETING_MATCH" }, + { "context": { "targetingKey": "user-2", "role": "viewer" }, "value": "off", "reason": "DEFAULT" }, + { "context": {}, "value": "off", "reason": "DEFAULT" } + ] + }, + { + "name": "a rule whose only group is empty matches nobody", + "flag": { + "key": "empty-group", + "type": "string", + "enabled": true, + "defaultValue": "off", + "targeting": [{ "id": "groups", "conditions": [], "groups": [{ "conditions": [] }], "value": "on" }] + }, + "evaluations": [ + { "context": { "targetingKey": "user-1" }, "value": "off", "reason": "DEFAULT" }, + { "context": {}, "value": "off", "reason": "DEFAULT" } + ] + }, + { + "name": "an empty group fails an and of groups", + "flag": { + "key": "empty-group", + "type": "string", + "enabled": true, + "defaultValue": "off", + "targeting": [ + { + "id": "groups", + "conditions": [], + "groups": [{ "conditions": [] }, { "conditions": [{ "property": "role", "operator": "equals", "value": "admin" }] }], + "groupOperator": "and", + "value": "on" + } + ] + }, + "evaluations": [{ "context": { "targetingKey": "user-1", "role": "admin" }, "value": "off", "reason": "DEFAULT" }] + }, + { + "name": "conditions combine with and by default", + "flag": { + "key": "conditions-and", + "type": "string", + "enabled": true, + "defaultValue": "off", + "targeting": [ + { + "id": "au-pro", + "conditions": [ + { "property": "country", "operator": "equals", "value": "AU" }, + { "property": "plan", "operator": "equals", "value": "pro" } + ], + "value": "on" + } + ] + }, + "evaluations": [ + { "context": { "targetingKey": "user-1", "country": "AU", "plan": "pro" }, "value": "on", "reason": "TARGETING_MATCH" }, + { "context": { "targetingKey": "user-2", "country": "AU", "plan": "free" }, "value": "off", "reason": "DEFAULT" }, + { "context": { "targetingKey": "user-3", "country": "AU" }, "value": "off", "reason": "DEFAULT" } + ] + }, + { + "name": "conditions combine with or", + "flag": { + "key": "conditions-or", + "type": "string", + "enabled": true, + "defaultValue": "off", + "targeting": [ + { + "id": "oceania", + "conditions": [ + { "property": "country", "operator": "equals", "value": "AU" }, + { "property": "country", "operator": "equals", "value": "NZ" } + ], + "conditionOperator": "or", + "value": "on" + } + ] + }, + "evaluations": [ + { "context": { "country": "NZ" }, "value": "on", "reason": "TARGETING_MATCH" }, + { "context": { "country": "US" }, "value": "off", "reason": "DEFAULT" }, + { "context": {}, "value": "off", "reason": "DEFAULT" } + ] + }, + { + "name": "missing attribute: equals doesn't match", + "flag": { + "key": "missing-equals", + "type": "string", + "enabled": true, + "defaultValue": "off", + "targeting": [{ "id": "pro", "conditions": [{ "property": "plan", "operator": "equals", "value": "pro" }], "value": "on" }] + }, + "evaluations": [ + { "context": { "targetingKey": "user-1" }, "value": "off", "reason": "DEFAULT" }, + { "context": { "targetingKey": "user-1", "plan": "pro" }, "value": "on", "reason": "TARGETING_MATCH" } + ] + }, + { + "name": "missing attribute: not_equals matches", + "flag": { + "key": "missing-not-equals", + "type": "string", + "enabled": true, + "defaultValue": "off", + "targeting": [{ "id": "not-free", "conditions": [{ "property": "plan", "operator": "not_equals", "value": "free" }], "value": "on" }] + }, + "evaluations": [ + { "context": { "targetingKey": "user-1" }, "value": "on", "reason": "TARGETING_MATCH" }, + { "context": {}, "value": "on", "reason": "TARGETING_MATCH" }, + { "context": { "plan": "free" }, "value": "off", "reason": "DEFAULT" } + ] + }, + { + "name": "missing attribute: in doesn't match", + "flag": { + "key": "missing-in", + "type": "string", + "enabled": true, + "defaultValue": "off", + "targeting": [{ "id": "paid", "conditions": [{ "property": "plan", "operator": "in", "value": ["pro", "team"] }], "value": "on" }] + }, + "evaluations": [ + { "context": { "targetingKey": "user-1" }, "value": "off", "reason": "DEFAULT" }, + { "context": { "plan": "team" }, "value": "on", "reason": "TARGETING_MATCH" } + ] + }, + { + "name": "missing attribute: not_in doesn't match", + "flag": { + "key": "missing-not-in", + "type": "string", + "enabled": true, + "defaultValue": "off", + "targeting": [{ "id": "not-free", "conditions": [{ "property": "plan", "operator": "not_in", "value": ["free"] }], "value": "on" }] + }, + "evaluations": [ + { "context": { "targetingKey": "user-1" }, "value": "off", "reason": "DEFAULT" }, + { "context": { "plan": "pro" }, "value": "on", "reason": "TARGETING_MATCH" }, + { "context": { "plan": "free" }, "value": "off", "reason": "DEFAULT" } + ] + }, + { + "name": "missing attribute: greater_than doesn't match", + "flag": { + "key": "missing-greater-than", + "type": "string", + "enabled": true, + "defaultValue": "off", + "targeting": [{ "id": "adults", "conditions": [{ "property": "age", "operator": "greater_than", "value": 18 }], "value": "on" }] + }, + "evaluations": [ + { "context": { "targetingKey": "user-1" }, "value": "off", "reason": "DEFAULT" }, + { "context": { "age": 30 }, "value": "on", "reason": "TARGETING_MATCH" } + ] + }, + { + "name": "missing attribute: contains and regex don't match", + "flag": { + "key": "missing-string-ops", + "type": "string", + "enabled": true, + "defaultValue": "off", + "targeting": [ + { "id": "contains", "conditions": [{ "property": "email", "operator": "contains", "value": "@example.com" }], "value": "contains-value" }, + { "id": "regex", "conditions": [{ "property": "username", "operator": "regex", "value": "^admin-" }], "value": "regex-value" } + ] + }, + "evaluations": [ + { "context": { "targetingKey": "user-1" }, "value": "off", "reason": "DEFAULT" }, + { "context": { "email": "a@example.com" }, "value": "contains-value", "reason": "TARGETING_MATCH" }, + { "context": { "username": "admin-7" }, "value": "regex-value", "reason": "TARGETING_MATCH" } + ] + }, + { + "name": "the rollout plans' match-all condition matches everyone, with or without a targeting key", + "flag": { + "key": "match-all", + "type": "string", + "enabled": true, + "defaultValue": "off", + "targeting": [ + { + "id": "rollout-plan", + "conditions": [{ "property": "targetingKey", "operator": "not_equals", "value": "__flaggr_rollout_match_all__" }], + "value": "on" + } + ] + }, + "evaluations": [ + { "context": { "targetingKey": "user-1" }, "value": "on", "reason": "TARGETING_MATCH" }, + { "context": {}, "value": "on", "reason": "TARGETING_MATCH" } + ] + }, + { + "name": "a disabled flag serves its default, whatever its rules", + "flag": { + "key": "disabled", + "type": "string", + "enabled": false, + "defaultValue": "off", + "targeting": [{ "id": "everyone", "conditions": [], "value": "on" }] + }, + "evaluations": [ + { "context": { "targetingKey": "user-1" }, "value": "off", "reason": "DISABLED" }, + { "context": {}, "value": "off", "reason": "DISABLED" } + ] + }, + { + "name": "overrides come before a rule for everyone", + "flag": { + "key": "overridden", + "type": "string", + "enabled": true, + "defaultValue": "off", + "overrides": [{ "name": "vip", "identifiers": ["vip-user"], "value": "vip-value", "priority": 1 }], + "targeting": [{ "id": "everyone", "conditions": [], "value": "on" }] + }, + "evaluations": [ + { "context": { "targetingKey": "vip-user" }, "value": "vip-value", "reason": "OVERRIDE" }, + { "context": { "targetingKey": "user-1" }, "value": "on", "reason": "TARGETING_MATCH" } + ] + }, + { + "name": "unset fields as the data plane encodes them (null and \"\") read as unset", + "flag": { + "key": "data-plane-encoding", + "type": "string", + "enabled": true, + "defaultValue": "off", + "targeting": [ + { + "id": "pro", + "conditions": [{ "property": "plan", "operator": "equals", "value": "pro", "cohortId": "" }], + "conditionOperator": "", + "groups": null, + "groupOperator": "", + "variant": "", + "value": "pro-value", + "rolloutPercentage": null, + "schedule": null + }, + { + "id": "everyone", + "conditions": null, + "conditionOperator": "", + "groups": null, + "groupOperator": "", + "variant": "", + "value": null, + "rolloutPercentage": null, + "schedule": { "startDate": "", "endDate": "", "recurrence": null } + } + ], + "variants": null, + "overrides": null + }, + "evaluations": [ + { "context": { "targetingKey": "user-1", "plan": "pro" }, "value": "pro-value", "reason": "TARGETING_MATCH" }, + { "context": { "targetingKey": "user-2", "plan": "free" }, "value": "off", "reason": "TARGETING_MATCH" }, + { "context": { "plan": "pro" }, "value": "pro-value", "reason": "TARGETING_MATCH" }, + { "context": {}, "value": "off", "reason": "TARGETING_MATCH" } + ] + }, + { + "name": "an empty groupOperator combines groups with or, an empty conditionOperator conditions with and", + "flag": { + "key": "empty-operators", + "type": "string", + "enabled": true, + "defaultValue": "off", + "targeting": [ + { + "id": "groups", + "conditions": null, + "conditionOperator": "", + "groups": [ + { "conditions": [{ "property": "plan", "operator": "equals", "value": "pro" }] }, + { "conditions": [{ "property": "role", "operator": "equals", "value": "admin" }] } + ], + "groupOperator": "", + "value": "groups-value" + }, + { + "id": "both", + "conditions": [ + { "property": "country", "operator": "equals", "value": "AU" }, + { "property": "beta", "operator": "equals", "value": true } + ], + "conditionOperator": "", + "value": "both-value" + } + ] + }, + "evaluations": [ + { "context": { "targetingKey": "user-1", "role": "admin" }, "value": "groups-value", "reason": "TARGETING_MATCH" }, + { "context": { "plan": "pro" }, "value": "groups-value", "reason": "TARGETING_MATCH" }, + { "context": { "country": "AU", "beta": true }, "value": "both-value", "reason": "TARGETING_MATCH" }, + { "context": { "country": "AU" }, "value": "off", "reason": "DEFAULT" }, + { "context": { "beta": true }, "value": "off", "reason": "DEFAULT" } + ] + }, + { + "name": "a null entry in targeting is no rule", + "flag": { + "key": "null-entry", + "type": "string", + "enabled": true, + "defaultValue": "off", + "variants": [ + { "name": "control", "value": "control-value", "weight": 50 }, + { "name": "treatment", "value": "treatment-value", "weight": 50 } + ], + "targeting": [ + null, + { "id": "pro", "conditions": [{ "property": "plan", "operator": "equals", "value": "pro" }], "value": "pro-value" }, + null + ] + }, + "evaluations": [ + { "context": { "targetingKey": "user-1", "plan": "pro" }, "value": "pro-value", "reason": "TARGETING_MATCH" }, + { "context": { "targetingKey": "carol" }, "value": "control-value", "variant": "control", "reason": "VARIANT" }, + { "context": { "targetingKey": "bob" }, "value": "treatment-value", "variant": "treatment", "reason": "VARIANT" }, + { "context": {}, "value": "off", "reason": "DEFAULT" } + ] + }, + { + "name": "targeting of only null entries leaves the flag to its variants", + "flag": { + "key": "null-entry", + "type": "string", + "enabled": true, + "defaultValue": "off", + "variants": [ + { "name": "control", "value": "control-value", "weight": 50 }, + { "name": "treatment", "value": "treatment-value", "weight": 50 } + ], + "targeting": [null] + }, + "evaluations": [ + { "context": { "targetingKey": "carol", "plan": "pro" }, "value": "control-value", "variant": "control", "reason": "VARIANT" }, + { "context": {}, "value": "off", "reason": "DEFAULT" } + ] + } + ] +} diff --git a/packages/evaluator/test/rule-matching-cases.ts b/packages/evaluator/test/rule-matching-cases.ts new file mode 100644 index 0000000..70ed098 --- /dev/null +++ b/packages/evaluator/test/rule-matching-cases.ts @@ -0,0 +1,57 @@ +import type { EvaluationContext, FlagConfig, FlagValue } from "../src"; + +/** + * The shared rule-matching cases (rule-matching-cases.json), each with its flag + * in two forms: as the case writes it, and as the Go data plane encodes it + * (rule-matching-cases.go-wire.json, written by + * services/flaggr-api/evaluator/rule_matching_parity_test.go), where every + * unset field is null or "". Read by this package's tests, the control plane's + * (__tests__/unit/evaluator-parity.test.ts) and the in-process provider's. + */ + +export interface RuleMatchingEvaluation { + context: EvaluationContext; + value: FlagValue; + reason: string; + /** Absent when the result names no variant. */ + variant?: string; +} + +export interface RuleMatchingCase { + name: string; + flag: FlagConfig; + evaluations: RuleMatchingEvaluation[]; +} + +export type RuleMatchingForm = "as written" | "as the data plane encodes it"; + +export interface RuleMatchingSuite extends RuleMatchingCase { + /** The case's flag in each form. */ + forms: Array<[form: RuleMatchingForm, flag: FlagConfig]>; +} + +/** + * Every case with both forms of its flag. `read` returns the text of a file in + * this directory. A case the Go-encoded file lacks is an error: regenerate it + * with `UPDATE_RULE_MATCHING_GO_WIRE=1 go test ./evaluator -run + * TestRuleMatchingGoWireForm` (in services/flaggr-api). + */ +export function loadRuleMatchingSuites(read: (file: string) => string): RuleMatchingSuite[] { + const { cases } = JSON.parse(read("rule-matching-cases.json")) as { cases: RuleMatchingCase[] }; + const { flags } = JSON.parse(read("rule-matching-cases.go-wire.json")) as { flags: Record }; + return cases.map((c) => { + const encoded = flags[c.name]; + if (!encoded) { + throw new Error( + `rule-matching-cases.go-wire.json has no flag for "${c.name}": regenerate it with UPDATE_RULE_MATCHING_GO_WIRE=1 go test ./evaluator -run TestRuleMatchingGoWireForm (in services/flaggr-api)` + ); + } + return { + ...c, + forms: [ + ["as written", c.flag], + ["as the data plane encodes it", encoded], + ], + }; + }); +} diff --git a/packages/evaluator/test/rule-matching.test.ts b/packages/evaluator/test/rule-matching.test.ts new file mode 100644 index 0000000..68399fa --- /dev/null +++ b/packages/evaluator/test/rule-matching.test.ts @@ -0,0 +1,32 @@ +import { readFileSync } from "node:fs"; +import { describe, expect, it } from "vitest"; +import { Evaluator } from "../src"; +import { loadRuleMatchingSuites } from "./rule-matching-cases"; + +/** + * The shared rule-matching cases (rule-matching-cases.json), each flag as + * written and as the Go data plane encodes it: the control plane's evaluator + * (__tests__/unit/evaluator-parity.test.ts) and the Go data plane + * (services/flaggr-api/evaluator/rule_matching_parity_test.go) are tested + * against the same cases. + */ +const suites = loadRuleMatchingSuites((file) => readFileSync(new URL(`./${file}`, import.meta.url), "utf8")); + +describe("rule matching (shared cases)", () => { + it("has cases", () => { + expect(suites.length).toBeGreaterThan(0); + }); + + describe.each(suites.map((s) => [s.name, s] as const))("%s", (_name, { forms, evaluations }) => { + describe.each(forms)("%s", (_form, flag) => { + it.each(evaluations.map((e) => [JSON.stringify(e.context), e] as const))("context %s", (_context, evaluation) => { + const result = Evaluator.evaluate(flag, evaluation.context); + expect({ value: result.value, reason: result.reason, variant: result.variant }).toEqual({ + value: evaluation.value, + reason: evaluation.reason, + variant: evaluation.variant, + }); + }); + }); + }); +}); diff --git a/packages/sdk/LICENSE b/packages/sdk/LICENSE new file mode 100644 index 0000000..389eed7 --- /dev/null +++ b/packages/sdk/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2024-present Flaggr Contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/sdk/README.md b/packages/sdk/README.md index 36cfd1b..f0454f6 100644 --- a/packages/sdk/README.md +++ b/packages/sdk/README.md @@ -43,13 +43,13 @@ The browser bundle (`dist/browser.global.js`) sets up a global `flaggr` from the ``` -`https://cdn.flaggr.dev/sdk.js` serves the npm release tagged `latest`, once Flaggr has added it to the CDN; `https://cdn.jsdelivr.net/npm/@flaggr/sdk@0/dist/browser.global.js` serves the newest 0.x release. Pinned copies with Subresource Integrity hashes are listed in [`https://cdn.flaggr.dev/manifest.json`](https://cdn.flaggr.dev/manifest.json). From 0.5.0, `data-api-url` is optional and defaults to `https://api.flaggr.dev`; 0.4.0 needs it, or it sends evaluations to `/undefined/api/flags/evaluate`. +`https://cdn.flaggr.dev/sdk.js` serves the release Flaggr has pinned for it, whatever npm tags `latest` (a new npm release reaches it only once Flaggr adds it); `https://cdn.jsdelivr.net/npm/@flaggr/sdk@0/dist/browser.global.js` serves the newest 0.x release. [`https://cdn.flaggr.dev/manifest.json`](https://cdn.flaggr.dev/manifest.json) shows the version `/sdk.js` serves (`latest["/sdk.js"]`) and lists pinned copies with Subresource Integrity hashes. From 0.5.0, `data-api-url` is optional and defaults to `https://api.flaggr.dev`; 0.4.0 needs it, or it sends evaluations to `/undefined/api/flags/evaluate`. Other attributes: `data-update-mode`, `data-remote-config`, `data-telemetry` and `data-expose-flags="false"`. See the [script tag docs](https://flaggr.dev/docs/sdk/typescript-sdk#script-tag). ## Streaming -`updateMode: "stream"` (or `enableStreaming: true`) with an `apiKey` streams flag configuration over `fetch` with the key in the `Authorization` header, never in the URL. Evaluation then runs locally from the streamed configuration, with no network round trip (flags using operators the data plane doesn't implement are still evaluated remotely). The SDK reconnects with backoff, and if the stream refuses the key (401/403) it polls watched flags every `batchIntervalMs` instead. Keyless clients use `EventSource`. Call `client.destroy()` when you're done with a streaming client, including on the server (Node 18+). +`updateMode: "stream"` (or `enableStreaming: true`) with an `apiKey` streams flag configuration over `fetch` with the key in the `Authorization` header, never in the URL. Evaluation then runs locally from the streamed configuration, with no network round trip (flags using operators the data plane doesn't implement are still evaluated remotely). Local evaluation uses `@flaggr/evaluator` 0.3.0: a targeting rule with no conditions matches everyone (deployments of `api.flaggr.dev` from before Flaggr 0.5.0 match it to nobody), a context without a `targetingKey` is never in a rollout below 100%, and a field the data plane sends as `null`, such as an unset `rolloutPercentage`, is unset. The SDK reconnects with backoff, and if the stream refuses the key (401/403) it polls watched flags every `batchIntervalMs` instead. Keyless clients use `EventSource`, which works only for public demo services with `apiUrl: "https://flaggr.dev"`: the data plane answers 401 to a stream without a key, and the client then polls watched flags every `batchIntervalMs`, as it does where `EventSource` doesn't exist. Call `client.destroy()` when you're done with a streaming client, including on the server (Node 18+). ## React @@ -72,7 +72,7 @@ function Checkout() { `FlaggrProvider` also takes a `config` object with any `createFlaggr` option; when you pass the `serviceId` prop, the `apiKey`, `serviceId` and `environment` props override the same fields in it. Hooks return the flag's value. -In the Next.js App Router, `@flaggr/sdk/react` must be imported from Client Components: it creates a React context when it loads and has no `'use client'` directive. Render `FlaggrProvider` from a `'use client'` file such as `app/providers.tsx`, wrap your root layout's children with it, and call the hooks in Client Components. Pass `NEXT_PUBLIC_` values as props, since the SDK's own environment lookup can't see them in a browser bundle. See [React hooks](https://flaggr.dev/docs/sdk/react-hooks#nextjs-app-router). +In the Next.js App Router, `@flaggr/sdk/react` starts with a `'use client'` directive, so a Server Component can render `FlaggrProvider` with serializable props. Its props reach the browser, so give it only a key that's safe there. Render it from a `'use client'` file (such as `app/providers.tsx`) when `config` holds functions such as `plugins`, and call the hooks in Client Components. `NEXT_PUBLIC_FLAGGR_*` values set at build time reach browser bundles. See [React hooks](https://flaggr.dev/docs/sdk/react-hooks#nextjs-app-router). ## Browser analytics @@ -92,7 +92,8 @@ const client = createFlaggr({ serviceId: 'web-app', apiKey: 'fgr_xxx', exposeFla ``` ```html - + ``` ## OpenTelemetry diff --git a/packages/sdk/package.json b/packages/sdk/package.json index 7e12c43..9a74ef8 100644 --- a/packages/sdk/package.json +++ b/packages/sdk/package.json @@ -7,26 +7,50 @@ "types": "./dist/index.d.ts", "exports": { ".": { - "types": "./dist/index.d.ts", - "import": "./dist/index.mjs", - "require": "./dist/index.js" + "import": { + "types": "./dist/index.d.mts", + "default": "./dist/index.mjs" + }, + "require": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + } }, "./react": { - "types": "./dist/react.d.ts", - "import": "./dist/react.mjs", - "require": "./dist/react.js" + "import": { + "types": "./dist/react.d.mts", + "default": "./dist/react.mjs" + }, + "require": { + "types": "./dist/react.d.ts", + "default": "./dist/react.js" + } }, "./otel": { - "types": "./dist/otel.d.ts", - "import": "./dist/otel.mjs", - "require": "./dist/otel.js" + "import": { + "types": "./dist/otel.d.mts", + "default": "./dist/otel.mjs" + }, + "require": { + "types": "./dist/otel.d.ts", + "default": "./dist/otel.js" + } }, "./browser": { - "types": "./dist/index.d.ts", "import": "./dist/browser.global.js", "require": "./dist/browser.global.js" } }, + "typesVersions": { + "*": { + "react": [ + "./dist/react.d.ts" + ], + "otel": [ + "./dist/otel.d.ts" + ] + } + }, "files": [ "dist", "README.md" diff --git a/packages/sdk/src/app-url.test.ts b/packages/sdk/src/app-url.test.ts new file mode 100644 index 0000000..0b5b0aa --- /dev/null +++ b/packages/sdk/src/app-url.test.ts @@ -0,0 +1,57 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { appBaseUrl } from "./app-url"; +import { FlaggrClient } from "./client"; + +afterEach(() => { + vi.unstubAllGlobals(); + vi.restoreAllMocks(); +}); + +describe("appBaseUrl", () => { + it.each([ + [undefined, "https://flaggr.dev"], + ["https://api.flaggr.dev", "https://flaggr.dev"], + ["https://api.flaggr.dev/", "https://flaggr.dev"], + ["https://API.flaggr.dev:8443", "https://flaggr.dev"], + ["https://flaggr.dev", "https://flaggr.dev"], + ["http://localhost:3000/", "http://localhost:3000"], + ["https://flags.example.com", "https://flags.example.com"], + ["", ""], + ["/", ""], + ])("apiUrl %s: the app is at %s", (apiUrl, expected) => { + expect(appBaseUrl(apiUrl)).toBe(expected); + }); +}); + +/** + * GET /api/sdk-config is the Flaggr app's route. The hosted data plane + * (https://api.flaggr.dev, the client's default apiUrl) answers it with a + * 404, which loadRemoteConfig ignores, so remote config never applied. + */ +describe("remoteConfig", () => { + const configRequests = (apiUrl?: string) => { + const fetchMock = vi.fn(async (_url: string | URL, _init?: RequestInit) => + new Response(JSON.stringify({}), { status: 200, headers: { "Content-Type": "application/json" } }) + ); + vi.stubGlobal("fetch", fetchMock); + const client = new FlaggrClient({ + ...(apiUrl === undefined ? {} : { apiUrl }), + serviceId: "web-app", + apiKey: "fgr_test", + remoteConfig: true, + updateMode: "poll", + }); + client.destroy(); + return fetchMock.mock.calls.map(([url]) => String(url)).filter((url) => url.includes("/api/sdk-config")); + }; + + it("fetches the hosted data plane's config from the Flaggr app", () => { + const [url] = configRequests(); + expect(url).toMatch(/^https:\/\/flaggr\.dev\/api\/sdk-config\?/); + expect(new URL(url).searchParams.get("serviceId")).toBe("web-app"); + }); + + it("fetches it at any other apiUrl", () => { + expect(configRequests("https://flags.example.com")[0]).toMatch(/^https:\/\/flags\.example\.com\/api\/sdk-config\?/); + }); +}); diff --git a/packages/sdk/src/app-url.ts b/packages/sdk/src/app-url.ts new file mode 100644 index 0000000..0c81b8a --- /dev/null +++ b/packages/sdk/src/app-url.ts @@ -0,0 +1,32 @@ +/** + * Where the Flaggr app's routes are for a client's apiUrl. + * + * The hosted data plane, https://api.flaggr.dev (the client's default + * apiUrl), serves only evaluation. The app's routes, such as + * GET /api/sdk-config (remote config) and POST /api/analytics/sdk-telemetry + * (the telemetry plugin), are at https://flaggr.dev. Any other apiUrl (the app + * itself, a self-hosted deployment or a same-origin proxy) serves them at + * the apiUrl. + */ + +/** The hosted data plane (the client's default apiUrl): evaluation only. */ +export const HOSTED_DATA_PLANE_HOST = "api.flaggr.dev"; +/** The hosted Flaggr app. */ +export const HOSTED_APP_ORIGIN = "https://flaggr.dev"; + +/** + * The base URL of the Flaggr app for `apiUrl`, without a trailing slash. An + * unset apiUrl stands for the client's default, the hosted data plane. An + * empty one (or "/") is the page's own origin: "". + */ +export function appBaseUrl(apiUrl: string | undefined): string { + if (apiUrl === undefined) return HOSTED_APP_ORIGIN; + const base = apiUrl.replace(/\/+$/, ""); + let host = ""; + try { + host = new URL(base).hostname.toLowerCase(); + } catch { + /* not an absolute URL: the app is next to it */ + } + return host === HOSTED_DATA_PLANE_HOST ? HOSTED_APP_ORIGIN : base; +} diff --git a/packages/sdk/src/build.test.ts b/packages/sdk/src/build.test.ts new file mode 100644 index 0000000..12bfea2 --- /dev/null +++ b/packages/sdk/src/build.test.ts @@ -0,0 +1,104 @@ +import { execFileSync } from "node:child_process"; +import { mkdtempSync, readdirSync, readFileSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; + +/** + * The package as tsup.config.ts builds it (into a temporary directory, without + * the type declarations, which the checks here don't need). + */ +const SDK_DIR = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +let outDir = ""; + +beforeAll(() => { + outDir = mkdtempSync(path.join(tmpdir(), "flaggr-sdk-build-")); + execFileSync( + process.execPath, + [ + "-e", + "require('tsup').build({ outDir: process.argv[1], dts: false, silent: true })" + + ".catch((error) => { console.error(error); process.exit(1); })", + outDir, + ], + { cwd: SDK_DIR, stdio: "pipe" } + ); +}, 120_000); + +afterAll(() => { + if (outDir) rmSync(outDir, { recursive: true, force: true }); +}); + +const built = (file: string) => readFileSync(path.join(outDir, file), "utf8"); + +const BASE64 = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; + +/** The original line (0-based) the first segment of generated line `line` maps to, or null. */ +function originalLine(mappings: string, line: number): number | null { + // Decode every segment up to `line`: fields are deltas across the whole map. + let sourceLine = 0; + const lines = mappings.split(";"); + for (let l = 0; l <= line && l < lines.length; l++) { + let first: number | null = null; + for (const segment of lines[l].split(",").filter(Boolean)) { + const fields: number[] = []; + let value = 0; + let shift = 0; + for (const char of segment) { + const digit = BASE64.indexOf(char); + value += (digit & 31) << shift; + if (digit & 32) { + shift += 5; + } else { + fields.push(value & 1 ? -(value >>> 1) : value >>> 1); + value = 0; + shift = 0; + } + } + if (fields.length >= 4) { + sourceLine += fields[2]; + first ??= sourceLine; + } + } + if (l === line) return first; + } + return null; +} + +describe("the built package", () => { + it.each(["react.mjs", "react.js"])("%s starts with the \"use client\" directive", (file) => { + // A Server Component can import FlaggrProvider and the hooks: the entry is a client boundary. + expect(built(file).startsWith('"use client";\n')).toBe(true); + }); + + it("marks only the React entry: the core entries and shared chunks stay server-usable", () => { + const others = readdirSync(outDir).filter( + (file) => /\.(m?js)$/.test(file) && file !== "react.mjs" && file !== "react.js" + ); + expect(others).toEqual(expect.arrayContaining(["index.mjs", "index.js", "otel.mjs", "otel.js", "browser.global.js"])); + for (const file of others) expect([file, built(file).includes("use client")]).toEqual([file, false]); + }); + + it.each(["react.mjs", "react.js"])("%s's source map still lines up after the directive", (file) => { + const code = built(file).split("\n"); + const map = JSON.parse(built(`${file}.map`)) as { sources: string[]; mappings: string }; + expect(originalLine(map.mappings, 0)).toBeNull(); // the directive: no source + const source = readFileSync(path.join(SDK_DIR, "src/react.tsx"), "utf8").split("\n"); + // Every top-level function, and the context, maps to its declaration in react.tsx. + const probes = code.flatMap((line, index) => { + const declared = /^function (\w+)\(/.exec(line) ?? /^var (FlaggrContext) = /.exec(line); + return declared ? [{ index, name: declared[1] }] : []; + }); + expect(probes.map(({ name }) => name)).toEqual( + expect.arrayContaining(["FlaggrContext", "FlaggrProvider", "useFlaggr", "useFlag", "useBooleanFlag", "useRefreshFlags"]) + ); + for (const { index, name } of probes) { + const line = originalLine(map.mappings, index); + expect([name, line === null ? null : source[line]]).toEqual([ + name, + expect.stringMatching(new RegExp(`^(export )?(function|const) ${name}\\b`)), + ]); + } + }); +}); diff --git a/packages/sdk/src/client.test.ts b/packages/sdk/src/client.test.ts index 397c0ed..ebd7444 100644 --- a/packages/sdk/src/client.test.ts +++ b/packages/sdk/src/client.test.ts @@ -1,6 +1,6 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { FlaggrClient } from "./client"; -import type { FlaggrPlugin, RequestInfo } from "./types"; +import type { EvaluationResult, FlaggrClientInstance, FlaggrPlugin, RequestInfo } from "./types"; afterEach(() => { vi.useRealTimers(); @@ -9,27 +9,83 @@ afterEach(() => { }); describe("FlaggrClient", () => { - it("returns configured defaults without making a remote request", async () => { - const fetchMock = vi.fn(); - vi.stubGlobal("fetch", fetchMock); + describe("defaults: fallback values, not answers", () => { + it("evaluates a flag listed in defaults remotely in poll mode", async () => { + const fetchMock = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ value: false, reason: "TARGETING_MATCH", variant: "off" }), + }); + vi.stubGlobal("fetch", fetchMock); + const plugin: FlaggrPlugin = { name: "observer", onEvaluateComplete: vi.fn() }; + const client = new FlaggrClient({ + apiUrl: "https://flaggr.test", + serviceId: "web", + defaults: { "new-nav": true }, + plugins: [plugin], + }); - const plugin: FlaggrPlugin = { - name: "observer", - onEvaluateComplete: vi.fn(), - }; - const client = new FlaggrClient({ - serviceId: "web", - defaults: { "new-nav": true }, - plugins: [plugin], + await expect(client.getBooleanValue("new-nav", false)).resolves.toBe(false); + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(fetchMock.mock.calls[0][0]).toBe("https://flaggr.test/api/flags/evaluate"); + // The entry is the request's fallback (what the control plane answers + // NOT_FOUND with), not the caller's default. + expect(JSON.parse(fetchMock.mock.calls[0][1].body)).toMatchObject({ flagKey: "new-nav", defaultValue: true }); + expect(plugin.onEvaluateComplete).toHaveBeenCalledWith( + "new-nav", + { value: false, reason: "TARGETING_MATCH", variant: "off" }, + expect.any(Number), + ); + client.destroy(); }); - await expect(client.getBooleanValue("new-nav", false)).resolves.toBe(true); - expect(fetchMock).not.toHaveBeenCalled(); - expect(plugin.onEvaluateComplete).toHaveBeenCalledWith( - "new-nav", - { value: true, reason: "DEFAULT" }, - expect.any(Number), - ); + it("stands in for the caller's default when the evaluation fails or the flag isn't found", async () => { + const fetchMock = vi + .fn() + .mockRejectedValueOnce(new TypeError("Failed to fetch")) + // services/flaggr-api: 200, FLAG_NOT_FOUND and no value. + .mockResolvedValueOnce({ ok: true, json: async () => ({ reason: "FLAG_NOT_FOUND", errorCode: "FLAG_NOT_FOUND" }) }); + vi.stubGlobal("fetch", fetchMock); + const plugin: FlaggrPlugin = { name: "errors", onEvaluateError: vi.fn() }; + const client = new FlaggrClient({ + apiUrl: "https://flaggr.test", + serviceId: "web", + defaults: { "new-nav": true, "rate-limit": 100 }, + plugins: [plugin], + }); + + await expect(client.evaluate("new-nav", false)).resolves.toMatchObject({ + value: true, + reason: "ERROR", + errorMessage: "Failed to fetch", + }); + expect(plugin.onEvaluateError).toHaveBeenCalledTimes(1); + await expect(client.evaluate("rate-limit", 5)).resolves.toMatchObject({ value: 100, reason: "FLAG_NOT_FOUND" }); + expect(fetchMock).toHaveBeenCalledTimes(2); + client.destroy(); + }); + + it("is what evaluateSync returns before anything has loaded", () => { + vi.stubGlobal("fetch", vi.fn()); + const client = new FlaggrClient({ serviceId: "web", defaults: { "new-nav": true } }); + + expect(client.evaluateSync("new-nav", false)).toEqual({ value: true, reason: "DEFAULT" }); + expect(client.getBooleanValueSync("new-nav", false)).toBe(true); + expect(client.evaluateSync("other", false)).toEqual({ value: false, reason: "DEFAULT" }); + client.destroy(); + }); + + it("stands in for the caller's default in a failed batch", async () => { + vi.stubGlobal("fetch", vi.fn().mockRejectedValue(new TypeError("Failed to fetch"))); + const client = new FlaggrClient({ apiUrl: "https://flaggr.test", serviceId: "web", defaults: { "new-nav": true } }); + + const results = await client.evaluateBatch([ + { flagKey: "new-nav", defaultValue: false }, + { flagKey: "other", defaultValue: "x" }, + ]); + expect(results.get("new-nav")).toMatchObject({ value: true, reason: "ERROR" }); + expect(results.get("other")).toMatchObject({ value: "x", reason: "ERROR" }); + client.destroy(); + }); }); it("posts authenticated evaluation requests and caches successful results", async () => { @@ -819,3 +875,1093 @@ describe("batch evaluation against the data plane", () => { client.destroy(); }); }); + +/** A 200 JSON response. */ +const jsonResponse = (body: unknown) => + new Response(JSON.stringify(body), { status: 200, headers: { "Content-Type": "application/json" } }); + +const isOutcome = (url: unknown) => String(url).endsWith("/api/events/outcomes"); + +describe("trackOutcome", () => { + /** The bodies of the outcome requests, parsed. */ + const outcomeBodies = (fetchMock: { mock: { calls: unknown[][] } }) => + fetchMock.mock.calls + .filter(([url]) => isOutcome(url)) + .map(([, init]) => JSON.parse(String((init as RequestInit).body)) as Record); + + it("posts with the key as a bearer on a keepalive fetch, never navigator.sendBeacon", async () => { + // /api/events/outcomes refuses a request without Authorization (401), and + // Chromium refuses an application/json beacon: a beacon never arrived. + const fetchMock = vi.fn(async (_url: string | URL, _init?: RequestInit) => jsonResponse({ accepted: 1 })); + vi.stubGlobal("fetch", fetchMock); + const sendBeacon = vi.fn(() => true); + vi.stubGlobal("navigator", { sendBeacon }); + const client = new FlaggrClient({ + apiUrl: "https://flaggr.test", + apiKey: "fgr_key", + serviceId: "web", + environment: "staging", + }); + + await client.trackOutcome({ flagKey: "checkout", eventName: "purchase", eventValue: 49.99, userId: "u1" }); + + expect(sendBeacon).not.toHaveBeenCalled(); + expect(fetchMock).toHaveBeenCalledTimes(1); + const [url, init] = fetchMock.mock.calls[0]; + expect(url).toBe("https://flaggr.test/api/events/outcomes"); + expect(init).toMatchObject({ + method: "POST", + keepalive: true, + headers: { "Content-Type": "application/json", Authorization: "Bearer fgr_key" }, + }); + expect(JSON.parse(String(init?.body))).toEqual({ + flagKey: "checkout", + variant: "unknown", + eventName: "purchase", + eventValue: 49.99, + userId: "u1", + serviceId: "web", + environment: "staging", + projectId: "", + }); + client.destroy(); + }); + + it("sends the hosted data plane's outcomes to the Flaggr app, which stores them", async () => { + const fetchMock = vi.fn(async (_url: string | URL, _init?: RequestInit) => jsonResponse({ accepted: 1 })); + vi.stubGlobal("fetch", fetchMock); + const client = new FlaggrClient({ apiKey: "fgr_key", serviceId: "web" }); // apiUrl: https://api.flaggr.dev + + await client.trackOutcome({ flagKey: "checkout", eventName: "purchase" }); + + expect(fetchMock.mock.calls.map(([url]) => url)).toEqual(["https://flaggr.dev/api/events/outcomes"]); + client.destroy(); + }); + + it("sends a body over the 64 KiB keepalive budget as a plain fetch", async () => { + const fetchMock = vi.fn(async (_url: string | URL, _init?: RequestInit) => jsonResponse({ accepted: 1 })); + vi.stubGlobal("fetch", fetchMock); + const client = new FlaggrClient({ apiUrl: "https://flaggr.test", apiKey: "fgr_key", serviceId: "web" }); + + await client.trackOutcome({ flagKey: "checkout", eventName: "purchase", userId: "u".repeat(70 * 1024) }); + + expect(fetchMock.mock.calls[0][1]).toMatchObject({ keepalive: false, headers: { Authorization: "Bearer fgr_key" } }); + client.destroy(); + }); + + it("never throws", async () => { + vi.stubGlobal("fetch", vi.fn(async () => Promise.reject(new TypeError("Failed to fetch")))); + const client = new FlaggrClient({ apiUrl: "https://flaggr.test", apiKey: "fgr_key", serviceId: "web" }); + await expect(client.trackOutcome({ flagKey: "checkout", eventName: "purchase" })).resolves.toBeUndefined(); + client.destroy(); + }); + + it("attributes each outcome to the variant evaluated for its own user, on a server evaluating for many", async () => { + const fetchMock = vi.fn(async (url: string | URL, init?: RequestInit) => { + if (isOutcome(url)) return jsonResponse({ accepted: 1 }); + const { context } = JSON.parse(String(init?.body)) as { context: { targetingKey?: string } }; + const variant = context.targetingKey === "u1" ? "a" : "b"; + return jsonResponse({ value: variant, variant, reason: "TARGETING_MATCH" }); + }); + vi.stubGlobal("fetch", fetchMock); + // One client, a per-call context per request. + const client = new FlaggrClient({ apiUrl: "https://flaggr.test", apiKey: "fgr_key", serviceId: "api" }); + await client.getStringValue("checkout", "control", { targetingKey: "u1" }); + await client.getStringValue("checkout", "control", { targetingKey: "u2" }); + + await client.trackOutcome({ flagKey: "checkout", eventName: "purchase", userId: "u1" }); + await client.trackOutcome({ flagKey: "checkout", eventName: "purchase", targetingKey: "u2", userId: "customer-2" }); + // Never evaluated for u3: not u2's variant. + await client.trackOutcome({ flagKey: "checkout", eventName: "purchase", userId: "u3" }); + // A caller that knows the variant says so. + await client.trackOutcome({ flagKey: "checkout", eventName: "purchase", userId: "u3", variant: "c" }); + + expect(outcomeBodies(fetchMock).map((body) => [body.variant, body.userId])).toEqual([ + ["a", "u1"], + ["b", "customer-2"], + ["unknown", "u3"], + ["c", "u3"], + ]); + // The targeting key only picks the variant: it isn't sent. + expect(outcomeBodies(fetchMock).every((body) => !("targetingKey" in body))).toBe(true); + client.destroy(); + }); + + it("falls back to the client's own targetingKey in a browser", async () => { + const fetchMock = vi.fn(async (url: string | URL, _init?: RequestInit) => + isOutcome(url) ? jsonResponse({ accepted: 1 }) : jsonResponse({ value: true, variant: "on", reason: "TARGETING_MATCH" }) + ); + vi.stubGlobal("fetch", fetchMock); + const client = new FlaggrClient({ + apiUrl: "https://flaggr.test", + apiKey: "fgr_key", + serviceId: "web", + context: { targetingKey: "visitor-1" }, + }); + await client.getBooleanValue("checkout", false); + + await client.trackOutcome({ flagKey: "checkout", eventName: "purchase" }); + await client.trackOutcome({ flagKey: "checkout", eventName: "purchase", userId: "customer-42" }); + await client.trackOutcome({ flagKey: "checkout", eventName: "purchase", targetingKey: "someone-else" }); + + expect(outcomeBodies(fetchMock).map((body) => body.variant)).toEqual(["on", "on", "unknown"]); + client.destroy(); + }); + + it("remembers at most 10,000 flag and targeting key pairs, dropping the least recently evaluated", async () => { + const fetchMock = vi.fn(async (_url: string | URL, _init?: RequestInit) => jsonResponse({ accepted: 1 })); + vi.stubGlobal("fetch", fetchMock); + const client = new FlaggrClient({ + apiUrl: "https://flaggr.test", + apiKey: "fgr_key", + serviceId: "api", + // Every read evaluates (locally) again, so each one counts as recent. + cacheTtl: -1, + bootstrap: { flags: [{ key: "checkout", type: "string", enabled: true, defaultValue: "control" }] }, + }); + const evaluateFor = (targetingKey: string) => client.getStringValue("checkout", "x", { targetingKey }); + for (let i = 0; i < 10_000; i++) await evaluateFor(`u${i}`); + await evaluateFor("u0"); // the most recent again + await evaluateFor("u10000"); // one too many: u1 is dropped + + for (const targetingKey of ["u0", "u1", "u2", "u10000"]) { + await client.trackOutcome({ flagKey: "checkout", eventName: "purchase", targetingKey }); + } + expect(outcomeBodies(fetchMock).map((body) => body.variant)).toEqual(["control", "unknown", "control", "control"]); + client.destroy(); + }); +}); + +describe("keyless stream", () => { + interface FakeSource { + url: string; + readyState: number; + onopen: null | (() => void); + onmessage: null | ((e: MessageEvent) => void); + onerror: null | (() => void); + addEventListener: (...args: unknown[]) => void; + close: () => void; + } + /** EventSource, as browsers have it: readyState CLOSED (2) once refused or closed. */ + const eventSources = () => { + const sources: FakeSource[] = []; + vi.stubGlobal( + "EventSource", + vi.fn(function (url: string) { + const source: FakeSource = { + url, + readyState: 0, + onopen: null, + onmessage: null, + onerror: null, + addEventListener: vi.fn(), + close: vi.fn(() => { + source.readyState = 2; + }), + }; + sources.push(source); + return source; + }) + ); + return sources; + }; + /** A control-plane batch answer: hero is on. */ + const batchFetch = () => + vi.fn(async (_url: string | URL, _init?: RequestInit) => + jsonResponse({ flags: [{ key: "hero", value: true, reason: "STATIC" }], total: 1 }) + ); + const batchCalls = (fetchMock: ReturnType) => + fetchMock.mock.calls.filter(([url]) => String(url).endsWith("/api/flags/evaluate/batch")).length; + + it("polls once EventSource is refused for good (the data plane's 401 to a request without a key)", async () => { + vi.useFakeTimers(); + const fetchMock = batchFetch(); + vi.stubGlobal("fetch", fetchMock); + const sources = eventSources(); + const client = new FlaggrClient({ + apiUrl: "https://flaggr.test", + serviceId: "demo", + updateMode: "stream", + batchIntervalMs: 1000, + }); + const states: string[] = []; + client.onConnectionStateChange((state) => states.push(state)); + const changes: unknown[] = []; + client.onFlagChange("hero", (event) => changes.push(event.newValue)); + + // A refusal: EventSource fires error with readyState CLOSED and won't reconnect. + sources[0].readyState = 2; + sources[0].onerror?.(); + expect(states).toEqual(["error"]); + await vi.advanceTimersByTimeAsync(0); // the fallback's first refresh + expect(batchCalls(fetchMock)).toBe(1); + expect(changes).toEqual([true]); + await vi.advanceTimersByTimeAsync(1000); + expect(batchCalls(fetchMock)).toBe(2); + + client.destroy(); + await vi.advanceTimersByTimeAsync(5000); + expect(batchCalls(fetchMock)).toBe(2); + expect(sources).toHaveLength(1); + }); + + it("leaves a dropped connection to EventSource's own reconnect", async () => { + vi.useFakeTimers(); + const fetchMock = batchFetch(); + vi.stubGlobal("fetch", fetchMock); + const sources = eventSources(); + const client = new FlaggrClient({ apiUrl: "https://flaggr.test", serviceId: "demo", updateMode: "stream" }); + client.onFlagChange("hero", () => {}); + + sources[0].readyState = 0; // CONNECTING: it retries by itself + sources[0].onerror?.(); + await vi.advanceTimersByTimeAsync(10_000); + + expect(client.getConnectionState()).toBe("error"); + expect(batchCalls(fetchMock)).toBe(0); + expect(sources[0].close).not.toHaveBeenCalled(); + client.destroy(); + }); + + it("polls in a runtime without EventSource", async () => { + vi.useFakeTimers(); + const fetchMock = batchFetch(); + vi.stubGlobal("fetch", fetchMock); + vi.stubGlobal("EventSource", undefined); + const client = new FlaggrClient({ + apiUrl: "https://flaggr.test", + serviceId: "demo", + updateMode: "stream", + batchIntervalMs: 1000, + }); + const changes: unknown[] = []; + client.onFlagChange("hero", (event) => changes.push(event.newValue)); + + await vi.advanceTimersByTimeAsync(1000); + expect(batchCalls(fetchMock)).toBe(1); + expect(changes).toEqual([true]); + client.destroy(); + }); +}); + +describe("refresh()", () => { + /** Control plane: a value for each flag, from `values`; a batch answer lists them. */ + const controlPlane = (values: Record) => + vi.fn(async (url: string | URL, init?: RequestInit) => { + const body = JSON.parse(String(init?.body)) as { flagKey?: string; flags?: Array<{ key: string }> }; + if (String(url).endsWith("/batch")) { + return jsonResponse({ flags: body.flags!.map(({ key }) => ({ key, value: values[key], reason: "STATIC" })) }); + } + return jsonResponse({ value: values[body.flagKey!], reason: "STATIC" }); + }); + + it("re-evaluates the flags the client evaluated, in one batch, and notifies listeners of changes", async () => { + const values: Record = { promo: "spring", hero: true }; + const fetchMock = controlPlane(values); + vi.stubGlobal("fetch", fetchMock); + const client = new FlaggrClient({ apiUrl: "https://flaggr.test", serviceId: "web" }); + await expect(client.getStringValue("promo", "none")).resolves.toBe("spring"); + await expect(client.getBooleanValue("hero", false)).resolves.toBe(true); + const changes: unknown[] = []; + client.onFlagChange("promo", (event) => changes.push([event.oldValue, event.newValue])); + client.onFlagChange("hero", (event) => changes.push([event.oldValue, event.newValue])); + + values.promo = "summer"; + await client.refresh(); + + expect(changes).toEqual([["spring", "summer"]]); + expect(fetchMock).toHaveBeenCalledTimes(3); // two evaluations, one batch + // The refreshed values are cached. + await expect(client.getStringValue("promo", "none")).resolves.toBe("summer"); + expect(fetchMock).toHaveBeenCalledTimes(3); + await client.refresh(); // nothing changed: nobody notified + expect(changes).toHaveLength(1); + client.destroy(); + }); + + it("batch mode: a refresh that fails or can't find the flag neither caches nor announces its fallback", async () => { + vi.useFakeTimers(); + let answer: "spring" | "offline" | "gone" = "spring"; + vi.stubGlobal( + "fetch", + vi.fn(async (url: string | URL, init?: RequestInit) => { + if (answer === "offline") throw new TypeError("Failed to fetch"); + const body = JSON.parse(String(init?.body)) as { flagKey?: string; flagKeys?: string[] }; + // services/flaggr-api: 200, FLAG_NOT_FOUND and a null value for a flag it doesn't have. + const result = (flagKey: string) => + answer === "gone" + ? { flagKey, value: null, reason: "FLAG_NOT_FOUND", errorCode: "FLAG_NOT_FOUND" } + : { flagKey, value: "spring", reason: "STATIC" }; + if (String(url).endsWith("/batch")) { + return jsonResponse({ flags: Object.fromEntries(body.flagKeys!.map((key) => [key, result(key)])) }); + } + return jsonResponse(result(body.flagKey!)); + }) + ); + const client = new FlaggrClient({ + apiUrl: "https://flaggr.test", + serviceId: "web", + updateMode: "batch", + batchIntervalMs: 1000, + }); + await expect(client.getStringValue("promo", "none")).resolves.toBe("spring"); + const changes: unknown[] = []; + client.onFlagChange("promo", (event) => changes.push(event.newValue)); + + answer = "offline"; + await vi.advanceTimersByTimeAsync(1000); + answer = "gone"; + await vi.advanceTimersByTimeAsync(1000); + + // The refreshes only had their own fallback (`false`) for promo: not a value. + expect(changes).toEqual([]); + // Nothing of it was cached: the read asks again, with its own default. + await expect(client.getStringValue("promo", "none")).resolves.toBe("none"); + client.destroy(); + }); +}); + +describe("start: false and the client lifecycle", () => { + it("creates a client with no side effects until start()", async () => { + vi.useFakeTimers(); + const fetchMock = vi.fn(async (_url: string | URL, _init?: RequestInit) => jsonResponse({})); + vi.stubGlobal("fetch", fetchMock); + const plugin: FlaggrPlugin = { name: "probe", onInit: vi.fn(), onDestroy: vi.fn() }; + const client = new FlaggrClient( + { + apiUrl: "https://flaggr.test", + serviceId: "web", + updateMode: "batch", + batchIntervalMs: 1000, + remoteConfig: true, + plugins: [plugin], + }, + { start: false } + ); + + await vi.advanceTimersByTimeAsync(5000); + expect(plugin.onInit).not.toHaveBeenCalled(); + expect(fetchMock).not.toHaveBeenCalled(); + expect(vi.getTimerCount()).toBe(0); + expect(client.getConnectionState()).toBe("disconnected"); + + client.start(); + client.start(); // once only + expect(plugin.onInit).toHaveBeenCalledTimes(1); + expect(plugin.onInit).toHaveBeenCalledWith(client); + expect(fetchMock.mock.calls.map(([url]) => String(url).split("?")[0])).toEqual([ + "https://flaggr.test/api/sdk-config", + ]); + expect(vi.getTimerCount()).toBe(1); // the batch timer + expect(client.getConnectionState()).toBe("connected"); + + client.destroy(); + expect(plugin.onDestroy).toHaveBeenCalledTimes(1); + expect(vi.getTimerCount()).toBe(0); + }); + + it("a client destroyed before start() never calls its plugins, and its waiting evaluations go ahead", async () => { + const fetchMock = vi.fn(async (_url: string | URL, _init?: RequestInit) => jsonResponse({ value: true, reason: "STATIC" })); + vi.stubGlobal("fetch", fetchMock); + const plugin: FlaggrPlugin = { name: "probe", onInit: vi.fn(), onDestroy: vi.fn() }; + const client = new FlaggrClient( + { apiUrl: "https://flaggr.test", serviceId: "web", remoteConfig: true, plugins: [plugin] }, + { start: false } + ); + + const pending = client.getBooleanValue("hero", false); // waits for the remote config + client.destroy(); + await expect(pending).resolves.toBe(true); + client.start(); // after destroy(): nothing + expect(plugin.onInit).not.toHaveBeenCalled(); + expect(plugin.onDestroy).not.toHaveBeenCalled(); + expect(fetchMock.mock.calls.map(([url]) => String(url))).toEqual(["https://flaggr.test/api/flags/evaluate"]); + }); + + it("starts nothing when the remote config arrives after destroy()", async () => { + vi.useFakeTimers(); + let answer!: (response: Response) => void; + vi.stubGlobal( + "fetch", + vi.fn((url: string | URL) => + String(url).includes("/api/sdk-config") + ? new Promise((resolve) => (answer = resolve)) + : Promise.resolve(jsonResponse({ flags: [] })) + ) + ); + const client = new FlaggrClient({ apiUrl: "https://flaggr.test", apiKey: "fgr_key", serviceId: "web", remoteConfig: true }); + await vi.advanceTimersByTimeAsync(0); + + client.destroy(); + answer(jsonResponse({ sdk: { updateMode: "batch", telemetry: true } })); + await vi.advanceTimersByTimeAsync(10_000); + + // No batch timer, no telemetry plugin (its flush timer). + expect(vi.getTimerCount()).toBe(0); + expect(client.getUpdateMode()).toBe("poll"); + }); + + it("destroy() runs once", () => { + const plugin: FlaggrPlugin = { name: "probe", onDestroy: vi.fn() }; + const client = new FlaggrClient({ serviceId: "web", plugins: [plugin] }); + client.destroy(); + client.destroy(); + expect(plugin.onDestroy).toHaveBeenCalledTimes(1); + }); + + it("persists a bootstrap snapshot under the client's auth scope", () => { + const store = new Map(); + vi.stubGlobal("localStorage", { + getItem: (key: string) => store.get(key) ?? null, + setItem: (key: string, value: string) => void store.set(key, String(value)), + removeItem: (key: string) => void store.delete(key), + key: (index: number) => [...store.keys()][index] ?? null, + get length() { + return store.size; + }, + }); + vi.stubGlobal("fetch", vi.fn(() => new Promise(() => {}))); // the remote config never answers + const client = new FlaggrClient({ + serviceId: "web", + apiKey: "fgr_key", + remoteConfig: true, + bootstrap: { flags: [{ key: "hero", type: "boolean", enabled: true, defaultValue: true }] }, + }); + + // The key's scope, which a later load (readFlagSnapshot) looks for. + expect([...store.keys()].filter((key) => key.startsWith("flaggr:flags:"))).toEqual([ + expect.stringMatching(/^flaggr:flags:web:production:k[0-9a-f]+$/), + ]); + client.destroy(); + }); +}); + +describe("public types", () => { + it("getObjectValue takes an interface", async () => { + interface Banner { + text: string; + dismissible: boolean; + } + vi.stubGlobal("fetch", vi.fn(async () => jsonResponse({ value: { text: "Sale", dismissible: false }, reason: "STATIC" }))); + const client = new FlaggrClient({ apiUrl: "https://flaggr.test", serviceId: "web" }); + const fallback: Banner = { text: "Welcome", dismissible: true }; + + const banner: Banner = await client.getObjectValue("banner", fallback); + const instance: FlaggrClientInstance = client; + const again: Banner = await instance.getObjectValue("banner", fallback); + + expect([banner, again]).toEqual([ + { text: "Sale", dismissible: false }, + { text: "Sale", dismissible: false }, + ]); + client.destroy(); + }); + + it("EvaluationResult.reason has the reasons both planes answer with", () => { + // src/lib/evaluator.ts (control plane) and services/flaggr-api (data plane). + const reasons: Array = [ + "FLAG_NOT_FOUND", + "EXPERIMENT", + "PREREQUISITE_FAILED", + "MUTUAL_EXCLUSION", + ]; + expect(reasons).toHaveLength(4); + }); +}); + +/** A 200 text/event-stream response whose body the test writes (the keyed stream). */ +function eventStream() { + const encoder = new TextEncoder(); + let controller!: ReadableStreamDefaultController; + const response = new Response( + new ReadableStream({ + start(c) { + controller = c; + }, + }), + { status: 200, headers: { "Content-Type": "text/event-stream" } } + ); + return { + response, + sync: (flags: unknown[]) => + controller.enqueue( + encoder.encode(`event: configuration_sync\ndata: ${JSON.stringify({ type: "configuration_sync", flags })}\n\n`) + ), + message: (data: unknown) => controller.enqueue(encoder.encode(`data: ${JSON.stringify(data)}\n\n`)), + }; +} + +/** The variants of the outcome requests a fetch mock saw, in order. */ +const outcomeVariants = (fetchMock: { mock: { calls: unknown[][] } }) => + fetchMock.mock.calls + .filter(([url]) => isOutcome(url)) + .map(([, init]) => (JSON.parse(String((init as RequestInit).body)) as { variant: string }).variant); + +describe("trackOutcome attribution", () => { + it("never gives an outcome the variant of an anonymous per-call evaluation (a server's anonymous request)", async () => { + const fetchMock = vi.fn(async (url: string | URL, init?: RequestInit) => { + if (isOutcome(url)) return jsonResponse({ accepted: 1 }); + const { context } = JSON.parse(String(init?.body)) as { context: { targetingKey?: string } }; + return context.targetingKey === "bob" + ? jsonResponse({ value: "B", variant: "B", reason: "TARGETING_MATCH" }) + : jsonResponse({ value: "anon-variant", variant: "anon-variant", reason: "TARGETING_MATCH" }); + }); + vi.stubGlobal("fetch", fetchMock); + // A server's client: no context of its own, a per-call one per request. + const client = new FlaggrClient({ apiUrl: "https://flaggr.test", apiKey: "fgr_key", serviceId: "api" }); + await expect(client.getStringValue("checkout", "control", { country: "AU" })).resolves.toBe("anon-variant"); + await expect(client.getStringValue("checkout", "control", { targetingKey: "bob" })).resolves.toBe("B"); + + await client.trackOutcome({ flagKey: "checkout", eventName: "buy", userId: "carol" }); // never evaluated + await client.trackOutcome({ flagKey: "checkout", eventName: "buy" }); + await client.trackOutcome({ flagKey: "checkout", eventName: "buy", userId: "bob" }); + + expect(outcomeVariants(fetchMock)).toEqual(["unknown", "unknown", "B"]); + client.destroy(); + }); + + it("still attributes a keyless client's own-context evaluations, with or without a userId", async () => { + const fetchMock = vi.fn(async (url: string | URL) => + isOutcome(url) ? jsonResponse({ accepted: 1 }) : jsonResponse({ value: true, variant: "on", reason: "STATIC" }) + ); + vi.stubGlobal("fetch", fetchMock); + // A browser page's client without a targetingKey: the same context for all it evaluates. + const client = new FlaggrClient({ apiUrl: "https://flaggr.test", apiKey: "fgr_key", serviceId: "web" }); + await client.getBooleanValue("checkout", false); + await client.getBooleanValue("checkout", false, {}); // an empty per-call context is the client's own + + await client.trackOutcome({ flagKey: "checkout", eventName: "buy" }); + await client.trackOutcome({ flagKey: "checkout", eventName: "buy", userId: "customer-42" }); + + expect(outcomeVariants(fetchMock)).toEqual(["on", "on"]); + client.destroy(); + }); + + it("attributes the outcomes of users evaluated through evaluateBatch to their own variants", async () => { + const fetchMock = vi.fn(async (url: string | URL, init?: RequestInit) => { + if (isOutcome(url)) return jsonResponse({ accepted: 1 }); + const body = JSON.parse(String(init?.body)) as { context: { targetingKey?: string }; flagKeys: string[] }; + const variant = body.context.targetingKey === "alice" ? "A" : "B"; + return jsonResponse({ + flags: Object.fromEntries(body.flagKeys.map((flagKey) => [flagKey, { flagKey, value: variant, variant, reason: "SPLIT" }])), + }); + }); + vi.stubGlobal("fetch", fetchMock); + const client = new FlaggrClient({ apiUrl: "https://flaggr.test", apiKey: "fgr_key", serviceId: "api" }); + await client.evaluateBatch([{ flagKey: "checkout", defaultValue: "control" }], { targetingKey: "alice" }); + await client.evaluateBatch([{ flagKey: "checkout", defaultValue: "control" }], { targetingKey: "bob" }); + + await client.trackOutcome({ flagKey: "checkout", eventName: "buy", userId: "alice" }); + await client.trackOutcome({ flagKey: "checkout", eventName: "buy", userId: "bob" }); + + expect(outcomeVariants(fetchMock)).toEqual(["A", "B"]); + client.destroy(); + }); + + it("follows a live update: the variant a stream sync gives the client's own context", async () => { + const stream = eventStream(); + const fetchMock = vi.fn(async (url: string | URL) => + isOutcome(url) ? jsonResponse({ accepted: 1 }) : stream.response + ); + vi.stubGlobal("fetch", fetchMock); + const client = new FlaggrClient({ + apiUrl: "https://flaggr.test", + apiKey: "fgr_key", + serviceId: "web", + updateMode: "stream", + context: { targetingKey: "v1" }, + }); + const copy = (value: string) => ({ key: "copy", type: "string", enabled: true, defaultValue: value }); + const seen: unknown[] = []; + client.onFlagChange("copy", (event) => seen.push(event.newValue)); + stream.sync([copy("old")]); + await vi.waitFor(() => expect(client.localFlagCount).toBe(1)); + await expect(client.getStringValue("copy", "none")).resolves.toBe("old"); + + stream.sync([copy("new")]); + await vi.waitFor(() => expect(seen).toContain("new")); + await client.trackOutcome({ flagKey: "copy", eventName: "buy" }); + + expect(outcomeVariants(fetchMock)).toEqual(["new"]); + client.destroy(); + }); + + it("follows a value pushed by the control plane's legacy stream message", async () => { + const stream = eventStream(); + const fetchMock = vi.fn(async (url: string | URL) => + isOutcome(url) ? jsonResponse({ accepted: 1 }) : stream.response + ); + vi.stubGlobal("fetch", fetchMock); + const client = new FlaggrClient({ + apiUrl: "https://flaggr.test", + apiKey: "fgr_key", + serviceId: "web", + updateMode: "stream", + bootstrap: { flags: [{ key: "copy", type: "string", enabled: true, defaultValue: "old" }] }, + }); + await expect(client.getStringValue("copy", "none")).resolves.toBe("old"); + + const seen: unknown[] = []; + client.onFlagChange("copy", (event) => seen.push(event.newValue)); + stream.message({ flagKey: "copy", value: "pushed", variant: "treatment", reason: "TARGETING_MATCH" }); + await vi.waitFor(() => expect(seen).toEqual(["pushed"])); + await client.trackOutcome({ flagKey: "copy", eventName: "buy" }); + + expect(outcomeVariants(fetchMock)).toEqual(["treatment"]); + client.destroy(); + }); + + it("follows setContext: the variant a watched flag now has for the new user", async () => { + const fetchMock = vi.fn(async () => jsonResponse({ accepted: 1 })); + vi.stubGlobal("fetch", fetchMock); + const client = new FlaggrClient({ + apiUrl: "https://flaggr.test", + apiKey: "fgr_key", + serviceId: "web", + context: { targetingKey: "u1" }, + bootstrap: { + flags: [ + { + key: "plan", + type: "string", + enabled: true, + defaultValue: "basic", + overrides: [{ identifiers: ["u2"], value: "pro" }], + }, + ], + }, + }); + const seen: unknown[] = []; + client.onFlagChange("plan", (event) => seen.push(event.newValue)); // a hook watching it + await expect(client.getStringValue("plan", "none")).resolves.toBe("basic"); + + client.setContext({ targetingKey: "u2" }); + expect(seen).toEqual(["pro"]); + await client.trackOutcome({ flagKey: "plan", eventName: "upgrade" }); + + expect(outcomeVariants(fetchMock)).toEqual(["pro"]); + client.destroy(); + }); +}); + +describe("trackOutcome and live updates of flags the app only watches", () => { + it("doesn't give an outcome the variant a stream sync gives the client's own context, for a flag the app evaluated only per call", async () => { + const stream = eventStream(); + const fetchMock = vi.fn(async (url: string | URL) => + isOutcome(url) ? jsonResponse({ accepted: 1 }) : stream.response + ); + vi.stubGlobal("fetch", fetchMock); + const client = new FlaggrClient({ apiUrl: "https://flaggr.test", apiKey: "fgr_key", serviceId: "web", updateMode: "stream" }); + const copy = (fallback: string) => ({ + key: "copy", + type: "string", + enabled: true, + defaultValue: fallback, + targeting: [{ id: "r1", conditions: [{ property: "plan", operator: "equals", value: "pro" }], value: "pro-copy" }], + }); + stream.sync([copy("standard")]); + await vi.waitFor(() => expect(client.localFlagCount).toBe(1)); + // A hook with a per-call context: it watches the flag and evaluates it for that context. + const heard: unknown[] = []; + client.onFlagChange("copy", (event) => heard.push(event.newValue)); + await expect(client.getStringValue("copy", "none", { plan: "pro" })).resolves.toBe("pro-copy"); + + stream.sync([copy("standard-2")]); // the client's own context now gets standard-2 + await vi.waitFor(() => expect(heard).toEqual(["standard-2"])); + await client.trackOutcome({ flagKey: "copy", eventName: "buy" }); + + // Not standard-2: nobody was shown that. The per-call evaluation had no targeting key. + expect(outcomeVariants(fetchMock)).toEqual(["unknown"]); + client.destroy(); + }); + + it("doesn't give an outcome the variant a refresh gives the client's own context, for a flag the app evaluated only per call", async () => { + const fetchMock = vi.fn(async (url: string | URL, init?: RequestInit) => { + if (isOutcome(url)) return jsonResponse({ accepted: 1 }); + const body = JSON.parse(String(init?.body)) as { context?: { targetingKey?: string }; flags?: Array<{ key: string }> }; + const value = body.context?.targetingKey === "admin-preview" ? "beta" : "stable"; + if (String(url).endsWith("/batch")) return jsonResponse({ flags: body.flags!.map(({ key }) => ({ key, value, reason: "STATIC" })) }); + return jsonResponse({ value, reason: "STATIC" }); + }); + vi.stubGlobal("fetch", fetchMock); + const client = new FlaggrClient({ apiUrl: "https://flaggr.test", apiKey: "fgr_key", serviceId: "web", context: { targetingKey: "me" } }); + client.onFlagChange("layout", () => {}); + await client.getStringValue("layout", "none", { targetingKey: "admin-preview" }); + + await client.refresh(); // evaluates layout for "me" (stable): the listener may be the client's own + await client.trackOutcome({ flagKey: "layout", eventName: "buy" }); + await client.trackOutcome({ flagKey: "layout", eventName: "buy", targetingKey: "admin-preview" }); + + expect(outcomeVariants(fetchMock)).toEqual(["unknown", "beta"]); + client.destroy(); + }); +}); + +describe("live updates and what the app evaluated per call", () => { + it("doesn't give an outcome the client's own variant after setContext or a pushed value, for a flag evaluated only per call", async () => { + const stream = eventStream(); + const fetchMock = vi.fn(async (url: string | URL) => + isOutcome(url) ? jsonResponse({ accepted: 1 }) : stream.response + ); + vi.stubGlobal("fetch", fetchMock); + const client = new FlaggrClient({ + apiUrl: "https://flaggr.test", + apiKey: "fgr_key", + serviceId: "web", + updateMode: "stream", + context: { targetingKey: "me" }, + bootstrap: { flags: [{ key: "plan", type: "string", enabled: true, defaultValue: "basic", overrides: [{ identifiers: ["other"], value: "pro" }] }] }, + }); + const heard: unknown[] = []; + client.onFlagChange("plan", (event) => heard.push(event.newValue)); + // Only evaluated for someone else. + await expect(client.getStringValue("plan", "none", { targetingKey: "other" })).resolves.toBe("pro"); + + client.setContext({ targetingKey: "me-2" }); + stream.message({ flagKey: "plan", value: "pushed", variant: "pushed-variant" }); + await vi.waitFor(() => expect(heard).toEqual(["basic", "pushed"])); + await client.trackOutcome({ flagKey: "plan", eventName: "upgrade" }); + + expect(outcomeVariants(fetchMock)).toEqual(["unknown"]); + client.destroy(); + }); + + it("drops what's cached for per-call contexts when the stream pushes a flag's new value", async () => { + const stream = eventStream(); + let copy = "old"; + const fetchMock = vi.fn(async (url: string | URL) => + String(url).includes("/api/flags/stream") ? stream.response : jsonResponse({ value: copy, reason: "STATIC" }) + ); + vi.stubGlobal("fetch", fetchMock); + const client = new FlaggrClient({ apiUrl: "https://flaggr.test", apiKey: "fgr_key", serviceId: "web", updateMode: "stream" }); + await expect(client.getStringValue("copy", "none", { targetingKey: "u1" })).resolves.toBe("old"); + const heard: unknown[] = []; + client.onFlagChange("copy", (event) => heard.push(event.newValue)); + + copy = "new"; + stream.message({ flagKey: "copy", value: "new", reason: "STATIC" }); + await vi.waitFor(() => expect(heard).toEqual(["new"])); + + // Not the stale per-call entry: evaluated again. + await expect(client.getStringValue("copy", "none", { targetingKey: "u1" })).resolves.toBe("new"); + client.destroy(); + }); +}); + +describe("refresh() and per-call contexts", () => { + /** Answers `beta` to the targeting key admin-preview, else `stable`; a batch answer lists them. */ + const preview = () => + vi.fn(async (url: string | URL, init?: RequestInit) => { + const body = JSON.parse(String(init?.body)) as { + flagKey?: string; + flags?: Array<{ key: string }>; + context?: { targetingKey?: string }; + }; + const value = body.context?.targetingKey === "admin-preview" ? "beta" : "stable"; + if (String(url).endsWith("/batch")) { + return jsonResponse({ flags: body.flags!.map(({ key }) => ({ key, value, reason: "TARGETING_MATCH" })) }); + } + return jsonResponse({ value, reason: "TARGETING_MATCH" }); + }); + const batches = (fetchMock: ReturnType) => + fetchMock.mock.calls.filter(([url]) => String(url).endsWith("/batch")).length; + + it("doesn't evaluate a flag cached only for a per-call context with the client's own: it drops the entry", async () => { + const fetchMock = preview(); + vi.stubGlobal("fetch", fetchMock); + const client = new FlaggrClient({ apiUrl: "https://flaggr.test", serviceId: "web", context: { targetingKey: "me" } }); + await expect(client.getStringValue("layout", "none", { targetingKey: "admin-preview" })).resolves.toBe("beta"); + + await client.refresh(); + expect(batches(fetchMock)).toBe(0); + // The per-call entry is gone: the next read asks again. + await expect(client.getStringValue("layout", "none", { targetingKey: "admin-preview" })).resolves.toBe("beta"); + expect(fetchMock).toHaveBeenCalledTimes(2); + client.destroy(); + }); + + it("compares with the client's own context's entry only: a per-call value is never a change event's oldValue", async () => { + const fetchMock = preview(); + vi.stubGlobal("fetch", fetchMock); + const client = new FlaggrClient({ apiUrl: "https://flaggr.test", serviceId: "web", context: { targetingKey: "me" } }); + await client.getStringValue("layout", "none", { targetingKey: "admin-preview" }); // beta, per call + const events: Array<[unknown, unknown]> = []; + client.onFlagChange("layout", (event) => events.push([event.oldValue, event.newValue])); + + await client.refresh(); + // Nothing was cached for the client's own context: nothing to compare with. + expect(events).toEqual([[undefined, "stable"]]); + + // Now both are cached, the per-call one first: still compared with the own one. + await client.getStringValue("layout", "none", { targetingKey: "admin-preview" }); + await client.refresh(); + expect(events).toHaveLength(1); + client.destroy(); + }); +}); + +describe("plugins and start()", () => { + /** A plugin that records the hooks it hears, in order. */ + const recorder = () => { + const calls: string[] = []; + const plugin: FlaggrPlugin = { + name: "recorder", + onInit: () => void calls.push("onInit"), + onEvaluate: (flagKey) => void calls.push(`onEvaluate:${flagKey}`), + onEvaluateComplete: (flagKey, result) => void calls.push(`onEvaluateComplete:${flagKey}:${String(result.value)}`), + onRequest: (info) => void calls.push(`onRequest:${info.url}`), + onDestroy: () => void calls.push("onDestroy"), + }; + return { calls, plugin }; + }; + + it("delivers what a client did before start() after its plugins' onInit, in order", async () => { + vi.stubGlobal("fetch", vi.fn(async () => jsonResponse({ value: true, reason: "STATIC" }))); + const { calls, plugin } = recorder(); + const client = new FlaggrClient( + { + apiUrl: "https://flaggr.test", + serviceId: "web", + plugins: [plugin], + bootstrap: { flags: [{ key: "hero", type: "boolean", enabled: true, defaultValue: true }] }, + }, + { start: false } + ); + // FlaggrProvider: a hook evaluates while rendering, then in its effect, both before start(). + client.evaluateSync("hero", false); + await client.evaluate("promo", false); + expect(calls).toEqual([]); + + client.start(); + expect(calls).toEqual([ + "onInit", + "onEvaluateComplete:hero:true", + "onEvaluate:promo", + "onRequest:https://flaggr.test/api/flags/evaluate", + "onEvaluateComplete:promo:true", + ]); + client.evaluateSync("hero", false); + expect(calls.at(-1)).toBe("onEvaluateComplete:hero:true"); + client.destroy(); + expect(calls.at(-1)).toBe("onDestroy"); + }); + + it("calls no hook of a plugin whose client is destroyed before start()", () => { + vi.stubGlobal("fetch", vi.fn(async () => jsonResponse({}))); + const { calls, plugin } = recorder(); + const client = new FlaggrClient({ serviceId: "web", plugins: [plugin] }, { start: false }); + client.evaluateSync("hero", false); + client.destroy(); + client.start(); + expect(calls).toEqual([]); + }); + + it("opens no transport for setUpdateMode before start(): start() opens the mode's", async () => { + vi.useFakeTimers(); + const fetchMock = vi.fn(async () => jsonResponse({ flags: [] })); + vi.stubGlobal("fetch", fetchMock); + const client = new FlaggrClient({ apiUrl: "https://flaggr.test", serviceId: "web" }, { start: false }); + client.onFlagChange("hero", () => {}); + + client.setUpdateMode("batch"); + await vi.advanceTimersByTimeAsync(10_000); + expect(vi.getTimerCount()).toBe(0); + expect(fetchMock).not.toHaveBeenCalled(); + + client.start(); + expect(vi.getTimerCount()).toBe(1); // the batch timer + await vi.advanceTimersByTimeAsync(0); + expect(fetchMock).toHaveBeenCalledTimes(1); // its first refresh + client.destroy(); + }); +}); + +describe("remote config intervals", () => { + const batchCalls = (fetchMock: { mock: { calls: unknown[][] } }) => + fetchMock.mock.calls.filter(([url]) => String(url).endsWith("/api/flags/evaluate/batch")).length; + + it("runs the first batch timer at the remote batchIntervalMs that comes with the remote updateMode", async () => { + vi.useFakeTimers(); + const fetchMock = vi.fn(async (url: string | URL, _init?: RequestInit) => + String(url).includes("/api/sdk-config") + ? jsonResponse({ sdk: { updateMode: "batch", batchIntervalMs: 50 } }) + : jsonResponse({ flags: [{ key: "hero", value: true, reason: "STATIC" }] }) + ); + vi.stubGlobal("fetch", fetchMock); + const client = new FlaggrClient({ apiUrl: "https://flaggr.test", serviceId: "web", remoteConfig: true }); + client.onFlagChange("hero", () => {}); + await vi.advanceTimersByTimeAsync(0); + expect(client.getUpdateMode()).toBe("batch"); + const first = batchCalls(fetchMock); // the switch's catch-up refresh + + await vi.advanceTimersByTimeAsync(200); + expect(batchCalls(fetchMock) - first).toBe(4); + client.destroy(); + }); + + it("moves a running batch timer to a remote batchIntervalMs that comes alone", async () => { + vi.useFakeTimers(); + const fetchMock = vi.fn(async (url: string | URL, _init?: RequestInit) => + String(url).includes("/api/sdk-config") + ? jsonResponse({ sdk: { batchIntervalMs: 50 } }) + : jsonResponse({ flags: [{ key: "hero", value: true, reason: "STATIC" }] }) + ); + vi.stubGlobal("fetch", fetchMock); + const client = new FlaggrClient({ apiUrl: "https://flaggr.test", serviceId: "web", updateMode: "batch", remoteConfig: true }); + client.onFlagChange("hero", () => {}); + await vi.advanceTimersByTimeAsync(0); + const first = batchCalls(fetchMock); + + await vi.advanceTimersByTimeAsync(200); + expect(batchCalls(fetchMock) - first).toBe(4); + client.destroy(); + }); +}); + +describe("the stream's polling fallback, refused", () => { + const isBatch = (url: unknown) => String(url).endsWith("/api/flags/evaluate/batch"); + + it("stops once a batch is refused: the keyless page against a data plane that wants a key", async () => { + vi.useFakeTimers(); + const fetchMock = vi.fn(async (_url: string | URL, _init?: RequestInit) => + new Response(JSON.stringify({ error: "unauthorized" }), { status: 401 }) + ); + vi.stubGlobal("fetch", fetchMock); + const sources: Array<{ readyState: number; onerror: null | (() => void); close: () => void }> = []; + vi.stubGlobal( + "EventSource", + vi.fn(function () { + const source = { readyState: 0, onopen: null, onmessage: null, onerror: null, addEventListener: vi.fn(), close: vi.fn() }; + sources.push(source); + return source; + }) + ); + const client = new FlaggrClient({ apiUrl: "https://flaggr.test", serviceId: "demo", updateMode: "stream", batchIntervalMs: 500 }); + client.onFlagChange("hero", () => {}); + + sources[0].readyState = 2; // refused + sources[0].onerror?.(); + await vi.advanceTimersByTimeAsync(10_000); + + expect(fetchMock.mock.calls.filter(([url]) => isBatch(url))).toHaveLength(1); + expect(vi.getTimerCount()).toBe(0); + expect(client.getConnectionState()).toBe("error"); + client.destroy(); + }); + + it.each([401, 403])("stops when the keyed stream's fallback batch answers %i", async (status) => { + vi.useFakeTimers(); + const fetchMock = vi.fn(async (_url: string | URL, _init?: RequestInit) => + new Response(JSON.stringify({ error: "refused" }), { status }) + ); + vi.stubGlobal("fetch", fetchMock); + const client = new FlaggrClient({ + apiUrl: "https://flaggr.test", + apiKey: "fgr_revoked", + serviceId: "web", + updateMode: "stream", + batchIntervalMs: 500, + }); + client.onFlagChange("hero", () => {}); + await vi.advanceTimersByTimeAsync(10_000); + + expect(fetchMock.mock.calls.filter(([url]) => isBatch(url))).toHaveLength(1); + expect(vi.getTimerCount()).toBe(0); + client.destroy(); + }); + + it("keeps polling through a server error", async () => { + vi.useFakeTimers(); + const fetchMock = vi.fn(async (url: string | URL, _init?: RequestInit) => + String(url).includes("/api/flags/stream") + ? new Response("", { status: 401 }) + : new Response(JSON.stringify({ error: "boom" }), { status: 503 }) + ); + vi.stubGlobal("fetch", fetchMock); + const client = new FlaggrClient({ + apiUrl: "https://flaggr.test", + apiKey: "fgr_key", + serviceId: "web", + updateMode: "stream", + batchIntervalMs: 500, + }); + client.onFlagChange("hero", () => {}); + await vi.advanceTimersByTimeAsync(2_000); + + expect(fetchMock.mock.calls.filter(([url]) => isBatch(url)).length).toBeGreaterThanOrEqual(4); + client.destroy(); + }); + + it("leaves batch mode, which the app chose, polling through a refused batch (as 0.4.0 did)", async () => { + vi.useFakeTimers(); + const fetchMock = vi.fn(async (_url: string | URL, _init?: RequestInit) => + new Response(JSON.stringify({ error: "unauthorized" }), { status: 401 }) + ); + vi.stubGlobal("fetch", fetchMock); + const client = new FlaggrClient({ apiUrl: "https://flaggr.test", serviceId: "web", updateMode: "batch", batchIntervalMs: 500 }); + client.onFlagChange("hero", () => {}); + await vi.advanceTimersByTimeAsync(2_000); + + expect(fetchMock.mock.calls.filter(([url]) => isBatch(url)).length).toBeGreaterThanOrEqual(4); + client.destroy(); + }); + + it("ignores an error from an EventSource it has already replaced", async () => { + vi.useFakeTimers(); + const fetchMock = vi.fn(async () => jsonResponse({ flags: [] })); + vi.stubGlobal("fetch", fetchMock); + const sources: Array<{ readyState: number; onerror: null | (() => void); close: () => void }> = []; + vi.stubGlobal( + "EventSource", + vi.fn(function () { + const source = { readyState: 0, onopen: null, onmessage: null, onerror: null, addEventListener: vi.fn(), close: vi.fn() }; + sources.push(source); + return source; + }) + ); + const client = new FlaggrClient({ apiUrl: "https://flaggr.test", serviceId: "demo", updateMode: "stream", batchIntervalMs: 500 }); + client.onFlagChange("hero", () => {}); + client.setUpdateMode("poll"); + + // The closed source's late error: not the client's stream any more. + sources[0].readyState = 2; + sources[0].onerror?.(); + await vi.advanceTimersByTimeAsync(5_000); + + expect(fetchMock).not.toHaveBeenCalled(); + expect(client.getConnectionState()).not.toBe("error"); + client.destroy(); + }); +}); + +describe("FlaggrClientInstance compatibility", () => { + it("takes a hand-written implementation of 0.4.0's members (clearPersistedConfig is optional)", async () => { + // Type-checked by typecheck.test.ts: 0.4.0's interface had no clearPersistedConfig. + const result = { value: false, reason: "STATIC" as const }; + const instance: FlaggrClientInstance = { + getBooleanValue: async () => false, + getStringValue: async () => "", + getNumberValue: async () => 0, + getObjectValue: async (_flagKey: string, defaultValue: T) => defaultValue, + evaluate: async () => result as never, + evaluateSync: () => result as never, + getBooleanValueSync: () => false, + setContext: () => {}, + setUpdateMode: () => {}, + getUpdateMode: () => "poll", + onFlagChange: () => () => {}, + onConnectionStateChange: () => () => {}, + getConnectionState: () => "disconnected" as never, + getConfig: () => ({ serviceId: "web" }), + refresh: async () => {}, + trackOutcome: async () => {}, + destroy: () => {}, + }; + expect(instance.clearPersistedConfig).toBeUndefined(); + // The class has it. + const client: FlaggrClientInstance = new FlaggrClient({ serviceId: "web" }); + expect(typeof client.clearPersistedConfig).toBe("function"); + client.clearPersistedConfig?.(); + client.destroy(); + }); +}); diff --git a/packages/sdk/src/client.ts b/packages/sdk/src/client.ts index f6b208b..9d5fc1d 100644 --- a/packages/sdk/src/client.ts +++ b/packages/sdk/src/client.ts @@ -16,8 +16,46 @@ import { isLocallyEvaluable, } from "@flaggr/evaluator"; import type { FlagConfig } from "@flaggr/evaluator"; +import { appBaseUrl } from "./app-url"; import { openEventStream } from "./event-stream"; import { exposeFlag, forgetFlags, shouldExpose, withdrawFlag } from "./expose-flags"; +import { postWithKeepalive } from "./keepalive"; + +/** + * The most (flag, targetingKey) variants a client keeps for trackOutcome. A + * server evaluating flags for many users would otherwise grow the map without + * bound; past this, the least recently evaluated pair is dropped. + */ +const MAX_TRACKED_VARIANTS = 10_000; + +/** lastEvaluatedVariants key: a flag and the targeting key it was evaluated for ("" for none). */ +function variantKey(flagKey: string, targetingKey: unknown): string { + return `${flagKey}\u0000${targetingKey === undefined || targetingKey === null ? "" : String(targetingKey)}`; +} + +/** Whether a per-call context sets anything: `{}` evaluates as the client's own context. */ +function hasKeys(context: EvaluationContext | undefined): context is EvaluationContext { + if (!context) return false; + for (const key in context) { + if (Object.prototype.hasOwnProperty.call(context, key)) return true; + } + return false; +} + +/** The plugin hooks the client calls as it evaluates, requests and updates (not onInit, onDestroy). */ +type PluginEvent = + | "onEvaluate" + | "onEvaluateComplete" + | "onEvaluateError" + | "onFlagChange" + | "onConnectionStateChange" + | "onRequest"; + +/** + * The most plugin hook calls a client created with `{ start: false }` holds + * for start() (see callPlugins); past this, later ones are dropped. + */ +const MAX_PENDING_PLUGIN_CALLS = 1_000; interface CacheEntry { value: FlagValue; @@ -91,14 +129,39 @@ export class FlaggrClient implements FlaggrClientInstance { */ private stream: { close(): void } | null = null; private destroyed = false; + /** start() has run: plugins initialised, remote config and update delivery begun. */ + private started = false; + /** The config as the caller gave it: its fields win over remote config. */ + private explicitConfig: FlaggrConfig; /** Settles when remote SDK config has been applied (or skipped/failed) */ private configReady: Promise; + /** Settles configReady of a client created with `{ start: false }` once start() (or destroy()) runs. */ + private releaseConfigReady?: () => void; private updateMode: UpdateMode; /** Auth scope for the persisted flag snapshot — "anon" or key hash. */ private persistScope: string; + /** Refreshes watched flags every batchIntervalMs: batch mode's, or the stream's polling fallback. */ private batchTimer: ReturnType | null = null; - /** Tracks the last evaluated variant per flag key for outcome event correlation */ + /** batchTimer is the stream's polling fallback (startPollingFallback), not batch mode's. */ + private pollingFallback = false; + /** + * Plugin hook calls made before start(), delivered once start() has called + * the plugins' onInit (see callPlugins). + */ + private pendingPluginCalls: Array<[PluginEvent, unknown[]]> = []; + /** + * The last evaluated variant per flag and targeting key (variantKey), for + * outcome event correlation; at most MAX_TRACKED_VARIANTS, oldest first. + */ private lastEvaluatedVariants: Map = new Map(); + /** + * Flags the app evaluated with the client's own context (no per-call + * context). A live update of such a flag (stream sync, batch refresh, + * setContext, a pushed value) changes what the app shows, so its variant is + * tracked for trackOutcome. Not a flag only watched, say by a hook with a + * per-call context: its value for the client's own context is nobody's. + */ + private ownEvaluated = new Set(); /** Publish resolved values to the page for browser analytics (`exposeFlags`). */ private exposeFlags: boolean; /** @@ -111,17 +174,30 @@ export class FlaggrClient implements FlaggrClientInstance { /** Bumped by setContext: results that arrive later belong to the old context. */ private contextVersion = 0; - constructor(config: FlaggrConfig) { + /** + * @param options.start `false` creates the client without side effects: + * no plugin `onInit`, remote config fetch, stream or timer until + * `start()`. FlaggrProvider does this so a render React discards (Strict + * Mode renders twice; a server render never commits) leaves nothing + * running. Default: start now. + */ + constructor(config: FlaggrConfig, options: { start?: boolean } = {}) { this.config = { apiUrl: "https://api.flaggr.dev", cacheTtl: 10_000, enableStreaming: false, ...config, }; + this.explicitConfig = config; this.exposeFlags = shouldExpose(config.exposeFlags); this.plugins = config.plugins ?? []; this.context = config.context ?? {}; this.hasBaseContext = Object.keys(this.context).length > 0; + // Auth scope of the persisted flag snapshot: set before the bootstrap + // below, which persists one when remoteConfig is on. + this.persistScope = this.config.apiKey + ? `k${fnv32(this.config.apiKey)}` + : "anon"; // Seed from a bootstrap configuration so first paint never hits network if (config.bootstrap) { @@ -131,20 +207,49 @@ export class FlaggrClient implements FlaggrClientInstance { this.updateMode = config.updateMode ?? (config.enableStreaming ? "stream" : "poll"); + if (options.start === false) { + // A remote evaluation waits for start(), which fetches the remote config. + this.configReady = this.config.remoteConfig + ? new Promise((resolve) => (this.releaseConfigReady = resolve)) + : Promise.resolve(); + return; + } + this.configReady = Promise.resolve(); + this.start(); + } + + /** + * Start a client created with `{ start: false }`: initialise its plugins, + * fetch the remote config (`remoteConfig`) and open its update delivery + * (the stream, or the batch timer). Runs once; does nothing after + * destroy(). A client created without that option starts in its + * constructor. + */ + start(): void { + if (this.started || this.destroyed) return; + this.started = true; + // Notify plugins of initialization for (const plugin of this.plugins) { plugin.onInit?.(this); } + // Then what the client did before start(): FlaggrProvider's children + // evaluate while rendering and in their effects, which run before the + // provider's effect starts its client. A plugin hears nothing before its + // onInit, as with a client that starts in its constructor. + const pending = this.pendingPluginCalls; + this.pendingPluginCalls = []; + for (const [hook, args] of pending) this.deliverPluginCall(hook, args); // Remote config fetch — fills unset fields from GET /api/sdk-config // before the first remote eval; localStorage SWR keeps repeat loads free. - this.persistScope = this.config.apiKey - ? `k${fnv32(this.config.apiKey)}` - : "anon"; - - this.configReady = this.config.remoteConfig - ? this.loadRemoteConfig(config) - : Promise.resolve(); + if (this.config.remoteConfig) { + const loaded = this.loadRemoteConfig(this.explicitConfig); + this.configReady = loaded; + const release = this.releaseConfigReady; + this.releaseConfigReady = undefined; + if (release) loaded.then(release, release); + } this.applyUpdateMode(this.updateMode); @@ -168,6 +273,10 @@ export class FlaggrClient implements FlaggrClientInstance { bootstrapVersion?: string; sdk?: { updateMode?: UpdateMode; telemetry?: boolean; cacheTtlMs?: number; batchIntervalMs?: number }; }) => { + // Destroyed while the config was on its way (FlaggrProvider under + // React Strict Mode destroys its first client at once): starting a + // transport or a plugin now would leave it running for good. + if (this.destroyed) return; // Bootstrap flag configs → local eval with zero extra round trips. // Skip the re-apply entirely when the persisted snapshot already // carries this exact content version. @@ -185,21 +294,33 @@ export class FlaggrClient implements FlaggrClientInstance { } const sdk = cfg.sdk; if (!sdk) return; - if (explicit.updateMode === undefined && sdk.updateMode) { - this.updateMode = sdk.updateMode; - this.applyUpdateMode(sdk.updateMode); - } if (explicit.cacheTtl === undefined && sdk.cacheTtlMs) { this.config.cacheTtl = sdk.cacheTtlMs; } - if (explicit.batchIntervalMs === undefined && sdk.batchIntervalMs) { + // Before the update mode below: its batch timer runs at this interval. + let newInterval = false; + if ( + explicit.batchIntervalMs === undefined && + sdk.batchIntervalMs && + sdk.batchIntervalMs !== this.config.batchIntervalMs + ) { this.config.batchIntervalMs = sdk.batchIntervalMs; + newInterval = true; + } + if (explicit.updateMode === undefined && sdk.updateMode) { + this.updateMode = sdk.updateMode; + this.applyUpdateMode(sdk.updateMode); + } else if (newInterval && this.batchTimer) { + // Only the interval changed: the running timer (batch mode's, or + // the stream's polling fallback) takes it up. + this.scheduleBatchTimer(); } // Remote telemetry opt-in — attach the plugin lazily if the service // config asks for it and the caller didn't already provide one. if (sdk.telemetry && !this.plugins.some((p) => p.name === "flaggr-telemetry")) { void import("./telemetry") .then(({ flaggrTelemetry }) => { + if (this.destroyed) return; const plugin = flaggrTelemetry(); this.plugins.push(plugin); plugin.onInit?.(this); @@ -241,7 +362,7 @@ export class FlaggrClient implements FlaggrClientInstance { const headers: Record = {}; if (this.config.apiKey) headers.Authorization = `Bearer ${this.config.apiKey}`; const res = await fetch( - `${this.config.apiUrl}/api/sdk-config?${params}`, + `${appBaseUrl(this.config.apiUrl)}/api/sdk-config?${params}`, { headers } ); if (!res.ok) return; @@ -392,7 +513,8 @@ export class FlaggrClient implements FlaggrClientInstance { setUpdateMode(mode: UpdateMode): void { if (this.destroyed || mode === this.updateMode) return; this.updateMode = mode; - this.applyUpdateMode(mode); + // Not started yet: start() opens this mode's transport. + if (this.started) this.applyUpdateMode(mode); } getUpdateMode(): UpdateMode { @@ -407,53 +529,77 @@ export class FlaggrClient implements FlaggrClientInstance { // Tear down whichever transport was active. this.stream?.close(); this.stream = null; - if (this.batchTimer) { - clearInterval(this.batchTimer); - this.batchTimer = null; - } + this.stopBatchTimer(); + // A destroyed client opens nothing (remote config can name a mode after destroy()). + if (this.destroyed) return; if (mode === "stream") { this.connectSSE(); } else if (mode === "batch") { - const interval = this.config.batchIntervalMs ?? 2000; - this.batchTimer = setInterval(() => { - void this.refreshWatchedFlags(); - }, interval); + this.scheduleBatchTimer(); // Catch up immediately on switch instead of waiting a full tick. void this.refreshWatchedFlags(); } } + /** (Re)start batchTimer at the current batchIntervalMs (default 2000). */ + private scheduleBatchTimer(): void { + if (this.batchTimer) clearInterval(this.batchTimer); + this.batchTimer = setInterval(() => { + void this.refreshWatchedFlags(); + }, this.config.batchIntervalMs ?? 2000); + } + + private stopBatchTimer(): void { + if (this.batchTimer) clearInterval(this.batchTimer); + this.batchTimer = null; + this.pollingFallback = false; + } + /** - * Batch-mode refresh: one POST covers every flag the app actually uses — - * watched via listeners or already cached — instead of a request per flag. - * Watched cache entries are dropped first so the batch revalidates rather - * than short-circuiting on the TTL; listeners fire on observed changes. + * Batch-mode refresh (and refresh()): one POST covers every flag the app + * actually uses — watched via listeners or cached for the client's own + * context — instead of a request per flag, evaluated with the client's own + * context. Their cache entries are dropped first so the batch revalidates + * rather than short-circuiting on the TTL; listeners fire on observed + * changes. A result without a value of its own (ERROR: the request failed + * or the answer left the flag out; NOT_FOUND, FLAG_NOT_FOUND) is neither + * cached nor announced. + * + * Results cached for per-call contexts are dropped, and go to the network + * again at their next call. A flag cached only for per-call contexts, and + * not watched, isn't evaluated: its value for the client's own context is + * nobody's to announce or publish (as with setContext). */ private async refreshWatchedFlags(): Promise { - const keys = new Set(); - for (const flagKey of this.flagChangeListeners.keys()) keys.add(flagKey); + const keys = new Set(this.flagChangeListeners.keys()); + // What the listeners last heard: the entry for the client's own context, + // which the refresh evaluates with. Never a per-call context's value. const oldValues = new Map(); for (const [cacheKey, entry] of this.cache) { - const pipe = cacheKey.indexOf("|"); - const base = pipe === -1 ? cacheKey : cacheKey.slice(0, pipe); - keys.add(base); - if (!oldValues.has(base)) oldValues.set(base, entry.value); + if (cacheKey.includes("|")) continue; // a per-call context's + keys.add(cacheKey); + oldValues.set(cacheKey, entry.value); } - if (keys.size === 0) return; for (const cacheKey of [...this.cache.keys()]) { - const pipe = cacheKey.indexOf("|"); - const base = pipe === -1 ? cacheKey : cacheKey.slice(0, pipe); - if (keys.has(base)) this.cache.delete(cacheKey); + if (cacheKey.includes("|") || keys.has(cacheKey)) this.cache.delete(cacheKey); } + if (keys.size === 0) return; const requests = [...keys].map((flagKey) => ({ flagKey, defaultValue: (this.flagConfigs.get(flagKey)?.defaultValue as FlagValue | undefined) ?? false, })); - const results = await this.evaluateBatch(requests); + const results = await this.evaluateMany(requests, undefined, true); for (const [flagKey, result] of results) { + if (result.reason === "ERROR" || /NOT_FOUND/.test(result.reason)) { + // No value of its own: only the default this refresh asked with (it + // doesn't know the callers'). Not served from the cache — the next + // evaluation asks again, with its own default — nor announced. + this.cache.delete(flagKey); + continue; + } const oldValue = oldValues.get(flagKey); if (oldValue !== undefined && JSON.stringify(oldValue) === JSON.stringify(result.value)) { @@ -469,9 +615,31 @@ export class FlaggrClient implements FlaggrClientInstance { for (const listener of listeners) { listener(changeEvent); } - for (const plugin of this.plugins) { - plugin.onFlagChange?.(changeEvent); - } + this.callPlugins("onFlagChange", changeEvent); + } + } + + /** + * Call a plugin hook on every plugin. Before start() (a client created + * with `{ start: false }`), the call waits for start() to call the + * plugins' onInit, then comes in order with the others; a client + * destroyed before start() never calls its plugins. + */ + private callPlugins( + hook: K, + ...args: Parameters> + ): void { + if (this.plugins.length === 0) return; + if (this.started) { + this.deliverPluginCall(hook, args); + } else if (!this.destroyed && this.pendingPluginCalls.length < MAX_PENDING_PLUGIN_CALLS) { + this.pendingPluginCalls.push([hook, args]); + } + } + + private deliverPluginCall(hook: PluginEvent, args: unknown[]): void { + for (const plugin of this.plugins) { + (plugin[hook] as ((...hookArgs: unknown[]) => void) | undefined)?.apply(plugin, args); } } @@ -502,12 +670,12 @@ export class FlaggrClient implements FlaggrClientInstance { return result.value; } - async getObjectValue>( + async getObjectValue( flagKey: string, defaultValue: T, context?: EvaluationContext ): Promise { - const result = await this.evaluate( + const result = await this.evaluate( flagKey, defaultValue as T & FlagValue, context @@ -529,16 +697,27 @@ export class FlaggrClient implements FlaggrClientInstance { return result.value; } + /** + * The value an evaluation falls back to: the flag's `defaults` entry when + * the config has one, else the caller's `defaultValue`. + */ + private fallbackFor(flagKey: string, defaultValue: T): T { + const entry = this.config.defaults?.[flagKey]; + return entry !== undefined ? (entry as T) : defaultValue; + } + /** * Synchronous evaluation — local-only fast path. Returns the streamed or - * cached value; falls back to `defaultValue` when neither is available - * (caller can then use the async API to fetch remotely). + * cached value; falls back to the flag's `defaults` entry, else + * `defaultValue`, when neither is available (caller can then use the + * async API to fetch remotely). */ evaluateSync( flagKey: string, defaultValue: T, context?: EvaluationContext ): EvaluationResult { + if (!hasKeys(context)) this.ownEvaluated.add(flagKey); const mergedContext = !context ? this.context : this.hasBaseContext @@ -557,12 +736,11 @@ export class FlaggrClient implements FlaggrClientInstance { reason: cached.reason as EvaluationResult["reason"], variant: cached.variant, } - : { value: defaultValue, reason: "DEFAULT" }; - // The defaultValue fallback (nothing resolved yet) is never published. + : { value: this.fallbackFor(flagKey, defaultValue), reason: "DEFAULT" }; + // The fallback (nothing resolved yet) is never published. if (local || cached) this.expose(flagKey, result, context); if (timed) { - const d = performance.now() - start; - for (const p of plugins) p.onEvaluateComplete?.(flagKey, result, d); + this.callPlugins("onEvaluateComplete", flagKey, result, performance.now() - start); } return result; } @@ -583,13 +761,11 @@ export class FlaggrClient implements FlaggrClientInstance { ? { ...this.context, ...context } : context); const start = performance.now(); + if (!hasKeys(context)) this.ownEvaluated.add(flagKey); // Notify plugins: before evaluation if (this.plugins.length) { - const merged = mergeContext(); - for (const plugin of this.plugins) { - plugin.onEvaluate?.(flagKey, merged); - } + this.callPlugins("onEvaluate", flagKey, mergeContext()); } try { @@ -603,9 +779,7 @@ export class FlaggrClient implements FlaggrClientInstance { variant: cached.variant, }; this.expose(flagKey, result, context); - for (const plugin of this.plugins) { - plugin.onEvaluateComplete?.(flagKey, result, duration); - } + this.callPlugins("onEvaluateComplete", flagKey, result, duration); return result; } @@ -614,36 +788,23 @@ export class FlaggrClient implements FlaggrClientInstance { const local = this.tryLocalEval(flagKey, mergeContext()); if (local) { this.setCache(flagKey, local, context); - this.trackVariant(flagKey, local); + this.trackVariant(flagKey, local, context); this.expose(flagKey, local, context); const duration = performance.now() - start; - for (const plugin of this.plugins) { - plugin.onEvaluateComplete?.(flagKey, local, duration); - } + this.callPlugins("onEvaluateComplete", flagKey, local, duration); return local; } - // Check defaults - if (this.config.defaults?.[flagKey] !== undefined) { - const result: EvaluationResult = { - value: this.config.defaults[flagKey] as T, - reason: "DEFAULT", - }; - const duration = performance.now() - start; - for (const plugin of this.plugins) { - plugin.onEvaluateComplete?.(flagKey, result, duration); - } - return result; - } - // Remote evaluation — deduplicated so concurrent calls share one fetch. // Wait for remote config if it's mid-flight (bounded: fetch resolves or - // fails quickly; the local/cached paths above never touch this). + // fails quickly; the local/cached paths above never touch this). A + // `defaults` entry is only the fallback: the flag is evaluated all the + // same, and the entry stands in for defaultValue (not found, failure). const version = this.contextVersion; await this.configReady; const result = await this.remoteEvaluateDeduped( flagKey, - defaultValue, + this.fallbackFor(flagKey, defaultValue), mergeContext() ); const duration = performance.now() - start; @@ -653,30 +814,27 @@ export class FlaggrClient implements FlaggrClientInstance { if (version === this.contextVersion) { this.setCache(flagKey, result, context); // Track variant for outcome correlation - this.trackVariant(flagKey, result); + this.trackVariant(flagKey, result, context); this.expose(flagKey, result, context); } // Notify plugins: after evaluation - for (const plugin of this.plugins) { - plugin.onEvaluateComplete?.(flagKey, result, duration); - } + this.callPlugins("onEvaluateComplete", flagKey, result, duration); return result; } catch (error) { const duration = performance.now() - start; // Notify plugins: error - for (const plugin of this.plugins) { - plugin.onEvaluateError?.( - flagKey, - error instanceof Error ? error : new Error(String(error)), - duration - ); - } + this.callPlugins( + "onEvaluateError", + flagKey, + error instanceof Error ? error : new Error(String(error)), + duration + ); return { - value: defaultValue, + value: this.fallbackFor(flagKey, defaultValue), reason: "ERROR", errorMessage: error instanceof Error ? error.message : String(error), }; @@ -692,6 +850,22 @@ export class FlaggrClient implements FlaggrClientInstance { async evaluateBatch( requests: Array<{ flagKey: string; defaultValue: FlagValue }>, context?: EvaluationContext + ): Promise> { + if (!hasKeys(context)) { + for (const { flagKey } of requests) this.ownEvaluated.add(flagKey); + } + return this.evaluateMany(requests, context, false); + } + + /** + * evaluateBatch, and refreshWatchedFlags' batch (`refresh`): a refresh + * tracks the variants (trackVariant) only of the flags the app evaluated + * with the client's own context (ownEvaluated). + */ + private async evaluateMany( + requests: Array<{ flagKey: string; defaultValue: FlagValue }>, + context: EvaluationContext | undefined, + refresh: boolean ): Promise> { // Lazy merge — skipped entirely when every flag hits cache. let mergedContext: EvaluationContext | undefined; @@ -719,12 +893,13 @@ export class FlaggrClient implements FlaggrClientInstance { const local = this.tryLocalEval(flagKey, mergeContext()); if (local) { this.setCache(flagKey, local, context); - this.trackVariant(flagKey, local); + if (!refresh || this.ownEvaluated.has(flagKey)) this.trackVariant(flagKey, local, context); this.expose(flagKey, local, context); results.set(flagKey, local); continue; } - remoteNeeded.push({ flagKey, defaultValue }); + // A `defaults` entry stands in for defaultValue, as in evaluate(). + remoteNeeded.push({ flagKey, defaultValue: this.fallbackFor(flagKey, defaultValue) }); } await this.configReady; @@ -737,7 +912,7 @@ export class FlaggrClient implements FlaggrClientInstance { chunks.push(remoteNeeded.slice(i, i + 100)); } for (let i = 0; i < chunks.length; i += CHUNK_CONCURRENCY) { - await Promise.all(chunks.slice(i, i + CHUNK_CONCURRENCY).map((chunk) => this.fetchBatchChunk(chunk, mergeContext(), context, results, version))); + await Promise.all(chunks.slice(i, i + CHUNK_CONCURRENCY).map((chunk) => this.fetchBatchChunk(chunk, mergeContext(), context, results, version, refresh))); } return results; @@ -749,7 +924,9 @@ export class FlaggrClient implements FlaggrClientInstance { context: EvaluationContext | undefined, results: Map, /** contextVersion when the batch started (see evaluate's remote path). */ - version: number + version: number, + /** refreshWatchedFlags' batch (see evaluateMany). */ + refresh: boolean ): Promise { try { const headers: Record = { "Content-Type": "application/json" }; @@ -772,6 +949,7 @@ export class FlaggrClient implements FlaggrClientInstance { }), }); if (!response.ok) { + if (response.status === 401 || response.status === 403) this.batchRefused(); throw new Error(`Batch evaluation failed: HTTP ${response.status}`); } // The data plane answers with a map of flag key → result, the @@ -791,7 +969,7 @@ export class FlaggrClient implements FlaggrClientInstance { : { value: defaultValue, reason: "ERROR", errorMessage: "Flag missing from batch response" }; if (version === this.contextVersion) { this.setCache(flagKey, result, context); - this.trackVariant(flagKey, result); + if (!refresh || this.ownEvaluated.has(flagKey)) this.trackVariant(flagKey, result, context); this.expose(flagKey, result, context); } results.set(flagKey, result); @@ -829,6 +1007,8 @@ export class FlaggrClient implements FlaggrClientInstance { !isLocallyEvaluable(flag) ) continue; const next = Evaluator.evaluate(flag, context); + // The value the app now shows for this flag: what an outcome goes to. + if (this.ownEvaluated.has(flagKey)) this.trackVariant(flagKey, next as EvaluationResult); this.expose(flagKey, next); // in use, so no `pushed` check needed // No previous value is tracked post-clear; notify with the new value. const changeEvent: FlagChangeEvent = { flagKey, newValue: next.value }; @@ -865,8 +1045,13 @@ export class FlaggrClient implements FlaggrClientInstance { return this.connectionState; } + /** + * Re-evaluate everything cached or watched, in one batch, and notify the + * listeners of what changed (see FlaggrClientInstance.refresh). + */ async refresh(): Promise { - this.cache.clear(); + if (this.destroyed) return; + await this.refreshWatchedFlags(); } /** Current configuration version reported by the stream, if connected. */ @@ -879,63 +1064,81 @@ export class FlaggrClient implements FlaggrClientInstance { return this.flagConfigs.size; } + /** + * POST the outcome to the Flaggr app's /api/events/outcomes, which stores + * outcomes (appBaseUrl: https://flaggr.dev when apiUrl is the hosted data + * plane), with the apiKey as a bearer: the route refuses a request without + * one (401). Never navigator.sendBeacon, which can't carry that header: a + * keepalive fetch, which outlives the page as a beacon does (a pagehide + * handler can track an outcome), when the body fits the page's 64 KiB + * keepalive budget (one event takes a few hundred bytes), else a plain + * fetch. + */ async trackOutcome(event: OutcomeEvent): Promise { - const variant = - this.lastEvaluatedVariants.get(event.flagKey) || "unknown"; - const body = JSON.stringify({ - flagKey: event.flagKey, - variant, - eventName: event.eventName, - eventValue: event.eventValue, - userId: event.userId, - serviceId: this.config.serviceId, - environment: this.config.environment || "production", - projectId: "", - }); - try { - // sendBeacon never blocks the page and survives unload - if ( - typeof navigator !== "undefined" && - typeof navigator.sendBeacon === "function" - ) { - const sent = navigator.sendBeacon( - `${this.config.apiUrl}/api/events/outcomes`, - new Blob([body], { type: "application/json" }) - ); - if (sent) return; - } - + const body = JSON.stringify({ + flagKey: event.flagKey, + variant: this.outcomeVariant(event), + eventName: event.eventName, + eventValue: event.eventValue, + userId: event.userId, + serviceId: this.config.serviceId, + environment: this.config.environment || "production", + projectId: "", + }); const headers: Record = { "Content-Type": "application/json", }; if (this.config.apiKey) { headers["Authorization"] = `Bearer ${this.config.apiKey}`; } - await fetch(`${this.config.apiUrl}/api/events/outcomes`, { - method: "POST", - headers, - body, - keepalive: true, - }); + await postWithKeepalive(`${appBaseUrl(this.config.apiUrl)}/api/events/outcomes`, headers, body); } catch { // Outcome tracking is best-effort; never throw } } + /** + * The variant an outcome is attributed to: the event's own, else the one + * last evaluated for the flag and the event's targetingKey — by default its + * userId, then the client's own targetingKey (or, for a client without + * one, its own context: see trackVariant) — else "unknown". Never the + * variant evaluated for someone else. + */ + private outcomeVariant(event: OutcomeEvent): string { + if (event.variant) return event.variant; + const keys: unknown[] = + event.targetingKey !== undefined + ? [event.targetingKey] + : event.userId !== undefined + ? [event.userId, this.context.targetingKey] + : [this.context.targetingKey]; + for (const targetingKey of keys) { + const variant = this.lastEvaluatedVariants.get(variantKey(event.flagKey, targetingKey)); + if (variant !== undefined) return variant; + } + return "unknown"; + } + destroy(): void { + if (this.destroyed) return; this.destroyed = true; + // Evaluations waiting for a start() that never came go ahead. + this.releaseConfigReady?.(); + this.releaseConfigReady = undefined; this.stream?.close(); this.stream = null; - if (this.batchTimer) { - clearInterval(this.batchTimer); - this.batchTimer = null; - } + this.stopBatchTimer(); this.cache.clear(); this.flagConfigs.clear(); this.flagChangeListeners.clear(); this.connectionListeners.clear(); + this.ownEvaluated.clear(); + // A client that never started never called its plugins' onInit, nor + // any other hook of theirs. + this.pendingPluginCalls = []; + if (!this.started) return; for (const plugin of this.plugins) { plugin.onDestroy?.(); } @@ -981,34 +1184,55 @@ export class FlaggrClient implements FlaggrClientInstance { const responseBytes = response.headers?.get ? Number(response.headers.get("content-length") ?? 0) : 0; - for (const plugin of this.plugins) { - plugin.onRequest?.({ - url, - durationMs: Math.round(performance.now() - start), - ok: response.ok, - requestBytes, - responseBytes: Number.isFinite(responseBytes) ? responseBytes : 0, - }); - } + this.callPlugins("onRequest", { + url, + durationMs: Math.round(performance.now() - start), + ok: response.ok, + requestBytes, + responseBytes: Number.isFinite(responseBytes) ? responseBytes : 0, + }); return response; } catch (error) { - for (const plugin of this.plugins) { - plugin.onRequest?.({ - url, - durationMs: Math.round(performance.now() - start), - ok: false, - requestBytes, - responseBytes: 0, - }); - } + this.callPlugins("onRequest", { + url, + durationMs: Math.round(performance.now() - start), + ok: false, + requestBytes, + responseBytes: 0, + }); throw error; } } - private trackVariant(flagKey: string, result: EvaluationResult): void { + /** + * Remember the variant an evaluation gave, for trackOutcome: per flag and + * targeting key, so a server evaluating for many users attributes each + * outcome to its own user's variant. `context` is the evaluation's per-call + * context, if it had one; the targeting key is the one it was evaluated + * with (the per-call context's, else the client's own). + * + * An evaluation without a targeting key is kept under "" only when it used + * the client's own context, which is the same for everything the client + * evaluates. One whose per-call context leaves it without a targeting key + * (say a server evaluating an anonymous request) isn't kept: it's no one's + * in particular, so an outcome falling back to the client's own context + * mustn't get it. The map keeps the MAX_TRACKED_VARIANTS most recently + * evaluated pairs. + */ + private trackVariant(flagKey: string, result: EvaluationResult, context?: EvaluationContext): void { + const perCall = hasKeys(context); + const targetingKey = + perCall && "targetingKey" in context ? context.targetingKey : this.context.targetingKey; + if (perCall && (targetingKey === undefined || targetingKey === null || targetingKey === "")) return; const v = result.variant ?? String(result.value); - if (this.lastEvaluatedVariants.get(flagKey) !== v) { - this.lastEvaluatedVariants.set(flagKey, v); + const key = variantKey(flagKey, targetingKey); + const variants = this.lastEvaluatedVariants; + // Re-inserted, so the most recently evaluated pairs are the last dropped. + variants.delete(key); + variants.set(key, v); + if (variants.size > MAX_TRACKED_VARIANTS) { + const oldest = variants.keys().next().value; + if (oldest !== undefined) variants.delete(oldest); } } @@ -1159,9 +1383,7 @@ export class FlaggrClient implements FlaggrClientInstance { for (const listener of this.connectionListeners) { listener(state); } - for (const plugin of this.plugins) { - plugin.onConnectionStateChange?.(state); - } + this.callPlugins("onConnectionStateChange", state); } /** @@ -1246,21 +1468,21 @@ export class FlaggrClient implements FlaggrClientInstance { const hasListeners = this.flagChangeListeners.has(flagKey); const cached = this.cache.get(flagKey); if (!hasListeners && !cached) return; - if (!isLocallyEvaluable(flag)) { - // Remote-only flag: drop all cached values so next eval refetches. - this.deleteFlagCacheEntries(flagKey); - return; - } + // Config changed: what's cached for any context, per-call ones included, + // is stale. A remote-only flag is refetched by its next evaluation. + this.deleteFlagCacheEntries(flagKey); + if (!isLocallyEvaluable(flag)) return; const evaluated = Evaluator.evaluate(flag, this.context); const changed = !cached || JSON.stringify(cached.value) !== JSON.stringify(evaluated.value); - // Config changed — context-variant entries are stale. - this.deleteFlagCacheEntries(flagKey); - this.setCache(flagKey, { + const result: EvaluationResult = { value: evaluated.value, - reason: evaluated.reason, + reason: evaluated.reason as EvaluationResult["reason"], variant: evaluated.variant, - }); + }; + this.setCache(flagKey, result); + // What the app now shows for this flag: what an outcome goes to. + if (this.ownEvaluated.has(flagKey)) this.trackVariant(flagKey, result); this.expose(flagKey, evaluated, null, true); if (changed && hasListeners) { const changeEvent: FlagChangeEvent = { @@ -1271,9 +1493,7 @@ export class FlaggrClient implements FlaggrClientInstance { for (const listener of this.flagChangeListeners.get(flagKey)!) { listener(changeEvent); } - for (const plugin of this.plugins) { - plugin.onFlagChange?.(changeEvent); - } + this.callPlugins("onFlagChange", changeEvent); } } @@ -1311,8 +1531,12 @@ export class FlaggrClient implements FlaggrClientInstance { return; } - // Keyless streams (e.g. public demo services) keep EventSource. - if (typeof EventSource === "undefined") return; + // Keyless streams (e.g. public demo services) keep EventSource. A runtime + // without it can't stream: poll, as the keyed path does. + if (typeof EventSource === "undefined") { + this.startPollingFallback(); + return; + } const source = new EventSource(url); this.stream = source; @@ -1335,24 +1559,42 @@ export class FlaggrClient implements FlaggrClientInstance { }; source.onerror = () => { + if (this.stream !== source) return; this.setConnectionState(CS.ERROR); - // EventSource auto-reconnects + // EventSource reconnects by itself after a network error (readyState + // CONNECTING). A refusal — any answer but a 200 text/event-stream, such + // as the data plane's 401 to a request without a key — closes it for + // good (readyState CLOSED): poll instead, as the keyed path does. + if (source.readyState !== 2 /* EventSource.CLOSED */) return; + source.close(); + this.stream = null; + this.startPollingFallback(); }; } /** * Stream fallback: the stream was refused or can't run, so refresh watched - * flags on the batch cadence. A mode switch or destroy() stops it. + * flags on the batch cadence. A mode switch or destroy() stops it, as does + * a refused batch (batchRefused). */ private startPollingFallback(): void { if (this.destroyed || this.batchTimer) return; - const interval = this.config.batchIntervalMs ?? 2000; - this.batchTimer = setInterval(() => { - void this.refreshWatchedFlags(); - }, interval); + this.scheduleBatchTimer(); + this.pollingFallback = true; void this.refreshWatchedFlags(); } + /** + * A batch request was refused (401/403). A refused key (or a request + * without one, which the data plane refuses) won't be accepted on a retry: + * the stream's polling fallback stops, rather than sending a request that + * fails every batchIntervalMs for as long as the page is open. The client + * stays in the ERROR state its stream left it in. + */ + private batchRefused(): void { + if (this.pollingFallback) this.stopBatchTimer(); + } + /** * One stream event, from either transport: configuration_sync and * configuration_delta (data plane), unnamed messages (control-plane @@ -1408,9 +1650,7 @@ export class FlaggrClient implements FlaggrClientInstance { listener(changeEvent); } } - for (const plugin of this.plugins) { - plugin.onFlagChange?.({ flagKey: data.flagKey, newValue: data.value as FlagValue }); - } + this.callPlugins("onFlagChange", { flagKey: data.flagKey, newValue: data.value as FlagValue }); } } else if (data.flagKey) { const changeEvent: FlagChangeEvent = { @@ -1425,7 +1665,13 @@ export class FlaggrClient implements FlaggrClientInstance { reason: (data.reason as EvaluationResult["reason"]) ?? "STATIC", variant: data.variant, }; + // The flag changed: what's cached for per-call contexts is stale too. + this.deleteFlagCacheEntries(data.flagKey); this.setCache(data.flagKey, result); + // The value pushed for the client's own context: what an outcome goes to. + if (data.value !== undefined && this.ownEvaluated.has(data.flagKey)) { + this.trackVariant(data.flagKey, result); + } this.expose(data.flagKey, result, null, true); // Notify listeners @@ -1437,9 +1683,7 @@ export class FlaggrClient implements FlaggrClientInstance { } // Notify plugins - for (const plugin of this.plugins) { - plugin.onFlagChange?.(changeEvent); - } + this.callPlugins("onFlagChange", changeEvent); } } } diff --git a/packages/sdk/src/env.test.ts b/packages/sdk/src/env.test.ts new file mode 100644 index 0000000..80b993e --- /dev/null +++ b/packages/sdk/src/env.test.ts @@ -0,0 +1,81 @@ +import { fileURLToPath } from "node:url"; +import vm from "node:vm"; +import { build } from "esbuild"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { readEnvConfig } from "./env"; + +afterEach(() => { + vi.unstubAllEnvs(); +}); + +/** + * env.ts bundled for a browser the way Next.js builds a client bundle: the + * NEXT_PUBLIC_* values set at build time replace their literal + * `process.env.NEXT_PUBLIC_…` expressions (DefinePlugin; esbuild's `define` + * here), and anything else is left as written. Then run where a browser + * runs it, with no `process` global. + */ +async function readEnvConfigInBrowser(inlined: Record): Promise> { + const define = Object.fromEntries( + Object.entries(inlined).map(([name, value]) => [`process.env.${name}`, JSON.stringify(value)]) + ); + const result = await build({ + entryPoints: [fileURLToPath(new URL("./env.ts", import.meta.url))], + bundle: true, + write: false, + format: "iife", + globalName: "flaggrEnv", + platform: "browser", + define, + logLevel: "silent", + }); + const sandbox: { flaggrEnv?: { readEnvConfig: typeof readEnvConfig } } = {}; + vm.runInNewContext(`${result.outputFiles[0].text}\nthis.flaggrEnv = flaggrEnv;`, sandbox); + expect("process" in sandbox).toBe(false); + return sandbox.flaggrEnv!.readEnvConfig(); +} + +describe("readEnvConfig in a browser bundle", () => { + it("reads the NEXT_PUBLIC_* values the bundler inlined", async () => { + await expect( + readEnvConfigInBrowser({ + NEXT_PUBLIC_FLAGGR_SERVICE_ID: " web-app\n", + NEXT_PUBLIC_FLAGGR_API_KEY: "fgr_public", + NEXT_PUBLIC_FLAGGR_ENVIRONMENT: "staging", + NEXT_PUBLIC_FLAGGR_API_URL: "https://flaggr.dev", + }) + ).resolves.toEqual({ + serviceId: "web-app", + apiKey: "fgr_public", + environment: "staging", + apiUrl: "https://flaggr.dev", + }); + }); + + it("reads each one on its own: an unset one doesn't hide the others", async () => { + // Only the service id was set at build time: the other three stay + // `process.env.…` expressions, which throw without a `process`. + await expect(readEnvConfigInBrowser({ NEXT_PUBLIC_FLAGGR_SERVICE_ID: "web-app" })).resolves.toEqual({ + serviceId: "web-app", + }); + }); + + it("is empty when nothing was inlined", async () => { + await expect(readEnvConfigInBrowser({})).resolves.toEqual({}); + }); +}); + +describe("readEnvConfig on a server", () => { + it("prefers the server-side names, falls back to NEXT_PUBLIC_*, and trims", () => { + vi.stubEnv("FLAGGR_SERVICE_ID", " api \n"); + vi.stubEnv("NEXT_PUBLIC_FLAGGR_SERVICE_ID", "web-app"); + vi.stubEnv("FLAGGR_API_KEY", ""); + vi.stubEnv("NEXT_PUBLIC_FLAGGR_API_KEY", "fgr_public "); + vi.stubEnv("FLAGGR_ENVIRONMENT", " "); + vi.stubEnv("NEXT_PUBLIC_FLAGGR_ENVIRONMENT", ""); + vi.stubEnv("FLAGGR_API_URL", "https://flaggr.dev"); + vi.stubEnv("NEXT_PUBLIC_FLAGGR_API_URL", "https://example.test"); + + expect(readEnvConfig()).toEqual({ serviceId: "api", apiKey: "fgr_public", apiUrl: "https://flaggr.dev" }); + }); +}); diff --git a/packages/sdk/src/env.ts b/packages/sdk/src/env.ts index eff757c..69ff043 100644 --- a/packages/sdk/src/env.ts +++ b/packages/sdk/src/env.ts @@ -6,17 +6,57 @@ * - NEXT_PUBLIC_* (Next.js browser bundles) * * Always trims values to avoid whitespace issues. + * + * Every variable is read with a literal `process.env.NAME` expression, never + * `process.env[name]`: bundlers (Next.js, webpack's DefinePlugin, esbuild's + * and Vite's `define`) replace only the literal expressions with their + * values, so in a browser bundle a dynamic lookup finds nothing — Next.js + * leaves the browser no process.env of NEXT_PUBLIC_* values to read. */ import type { FlaggrConfig } from "./types"; -function readEnv(key: string): string | undefined { - // Node.js / Edge / Next.js server - if (typeof process !== "undefined" && process.env) { - const val = process.env[key]; - if (val) return val.trim(); +type EnvValues = Partial>; + +/** The server-side names: never inlined into browser bundles. */ +function serverEnv(): EnvValues { + if (typeof process === "undefined" || !process.env) return {}; + return { + apiKey: process.env.FLAGGR_API_KEY, + serviceId: process.env.FLAGGR_SERVICE_ID, + environment: process.env.FLAGGR_ENVIRONMENT, + apiUrl: process.env.FLAGGR_API_URL, + }; +} + +/** + * One NEXT_PUBLIC_* value. A bundler that inlined it left no `process` + * reference in `read`, so this works in a browser, which has no `process`. + * One it didn't inline (unset at build time) throws there, and is unset: + * each is read on its own, so that doesn't lose the others. + */ +function publicValue(read: () => string | undefined): string | undefined { + try { + return read(); + } catch { + return undefined; } - return undefined; +} + +/** The NEXT_PUBLIC_* names: inlined into browser bundles by Next.js. */ +function publicEnv(): EnvValues { + return { + apiKey: publicValue(() => process.env.NEXT_PUBLIC_FLAGGR_API_KEY), + serviceId: publicValue(() => process.env.NEXT_PUBLIC_FLAGGR_SERVICE_ID), + environment: publicValue(() => process.env.NEXT_PUBLIC_FLAGGR_ENVIRONMENT), + apiUrl: publicValue(() => process.env.NEXT_PUBLIC_FLAGGR_API_URL), + }; +} + +/** A set, non-blank value, trimmed. */ +function clean(value: string | undefined): string | undefined { + const trimmed = typeof value === "string" ? value.trim() : undefined; + return trimmed ? trimmed : undefined; } /** @@ -28,16 +68,16 @@ function readEnv(key: string): string | undefined { * Client-side (Next.js): * NEXT_PUBLIC_FLAGGR_API_KEY, NEXT_PUBLIC_FLAGGR_SERVICE_ID, * NEXT_PUBLIC_FLAGGR_ENVIRONMENT, NEXT_PUBLIC_FLAGGR_API_URL + * + * Where both are set, the server-side name wins. */ export function readEnvConfig(): Partial { - const apiKey = - readEnv("FLAGGR_API_KEY") ?? readEnv("NEXT_PUBLIC_FLAGGR_API_KEY"); - const serviceId = - readEnv("FLAGGR_SERVICE_ID") ?? readEnv("NEXT_PUBLIC_FLAGGR_SERVICE_ID"); - const environment = - readEnv("FLAGGR_ENVIRONMENT") ?? readEnv("NEXT_PUBLIC_FLAGGR_ENVIRONMENT"); - const apiUrl = - readEnv("FLAGGR_API_URL") ?? readEnv("NEXT_PUBLIC_FLAGGR_API_URL"); + const server = serverEnv(); + const pub = publicEnv(); + const apiKey = clean(server.apiKey) ?? clean(pub.apiKey); + const serviceId = clean(server.serviceId) ?? clean(pub.serviceId); + const environment = clean(server.environment) ?? clean(pub.environment); + const apiUrl = clean(server.apiUrl) ?? clean(pub.apiUrl); const config: Partial = {}; if (apiKey) config.apiKey = apiKey; diff --git a/packages/sdk/src/expose-flags.test.ts b/packages/sdk/src/expose-flags.test.ts index 2b41f48..70825eb 100644 --- a/packages/sdk/src/expose-flags.test.ts +++ b/packages/sdk/src/expose-flags.test.ts @@ -309,21 +309,26 @@ describe("exposeFlags: publishing resolved values for browser analytics", () => }); it("keeps the last resolved value when a later evaluation fails", async () => { - const fetchMock = vi - .fn() - .mockResolvedValueOnce(json({ value: "treatment", reason: "TARGETING_MATCH" })) - .mockRejectedValueOnce(new TypeError("Failed to fetch")) + let answer: "treatment" | "offline" | "left out" | "error" = "treatment"; + const fetchMock = vi.fn(async (url: string | URL) => { + if (answer === "offline") throw new TypeError("Failed to fetch"); + if (answer === "treatment") return json({ value: "treatment", reason: "TARGETING_MATCH" }); + if (!String(url).endsWith("/batch")) throw new Error(`unexpected request to ${String(url)}`); // Batch answers that don't resolve the flag: one leaves it out (the // SDK's "Flag missing from batch response"), one reports an error. - .mockResolvedValueOnce(json({ flags: {}, total: 0 })) - .mockResolvedValueOnce(json({ flags: [{ key: "copy", reason: "ERROR" }] })); + return json(answer === "left out" ? { flags: {}, total: 0 } : { flags: [{ key: "copy", reason: "ERROR" }] }); + }); vi.stubGlobal("fetch", fetchMock); const client = new FlaggrClient({ apiUrl: API, serviceId: "web" }); await expect(client.getStringValue("copy", "control")).resolves.toBe("treatment"); - await client.refresh(); // drop the cache: the next read goes to the network + answer = "offline"; + // The refresh fails: the cached value is dropped, so the next read goes + // to the network, and fails too. + await client.refresh(); await expect(client.getStringValue("copy", "control")).resolves.toBe("control"); - for (let i = 0; i < 2; i++) { + for (const next of ["left out", "error"] as const) { + answer = next; await client.refresh(); const bulk = await client.evaluateBatch([{ flagKey: "copy", defaultValue: "control" }]); expect(bulk.get("copy")).toMatchObject({ value: "control", reason: "ERROR" }); @@ -332,7 +337,10 @@ describe("exposeFlags: publishing resolved values for browser analytics", () => } await settle(); - expect(fetchMock).toHaveBeenCalledTimes(4); + // The evaluation, the failed refresh and read, then a batch each for the + // two answers (nothing was cached for the first refresh to ask about), + // and the second refresh's batch. + expect(fetchMock).toHaveBeenCalledTimes(6); expect(page.__FLAGGR_FLAGS__).toEqual({ copy: "treatment" }); expect(events).toHaveLength(1); client.destroy(); @@ -1789,3 +1797,26 @@ describe.skipIf(!existsSync(analyticsScriptSources))("with the analytics script' config.destroy(); }); }); + +describe("exposeFlags and refresh()", () => { + it("doesn't publish a flag the page evaluated only for someone else: a refresh doesn't evaluate it for the page's user", async () => { + const fetchMock = vi.fn(async (url: string | URL, init?: RequestInit) => { + const body = JSON.parse(String(init?.body)) as { context: { targetingKey?: string }; flags?: Array<{ key: string }> }; + const value = body.context.targetingKey === "admin" ? "admin-copy" : "customer-copy"; + if (String(url).endsWith("/batch")) return json({ flags: body.flags!.map(({ key }) => ({ key, value, reason: "STATIC" })) }); + return json({ value, reason: "STATIC" }); + }); + vi.stubGlobal("fetch", fetchMock); + // An admin's page previewing a flag for a customer. + const client = new FlaggrClient({ apiUrl: API, serviceId: "web", context: { targetingKey: "admin" } }); + await expect(client.getStringValue("copy", "none", { targetingKey: "customer-42" })).resolves.toBe("customer-copy"); + + await client.refresh(); + await settle(); + + expect(page.__FLAGGR_FLAGS__).toBeUndefined(); + expect(events).toHaveLength(0); + expect(fetchMock).toHaveBeenCalledTimes(1); + client.destroy(); + }); +}); diff --git a/packages/sdk/src/index.ts b/packages/sdk/src/index.ts index b99ec2b..8f8678f 100644 --- a/packages/sdk/src/index.ts +++ b/packages/sdk/src/index.ts @@ -16,7 +16,7 @@ * * const client = createFlaggr({ * serviceId: 'web-app', - * apiKey: 'flg_xxx', + * apiKey: 'fgr_xxx', * }) * * const isEnabled = await client.getBooleanValue('my-feature', false) @@ -29,7 +29,7 @@ * * const client = createFlaggr({ * serviceId: 'web-app', - * apiKey: 'flg_xxx', + * apiKey: 'fgr_xxx', * plugins: [otelPlugin({ serviceName: 'checkout' })], * }) * ``` @@ -40,7 +40,7 @@ * * function App() { * return ( - * + * * * * ) diff --git a/packages/sdk/src/keepalive.test.ts b/packages/sdk/src/keepalive.test.ts new file mode 100644 index 0000000..cd64958 --- /dev/null +++ b/packages/sdk/src/keepalive.test.ts @@ -0,0 +1,84 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { bodyBytes, fetchWithKeepalive, fitsKeepaliveBudget, KEEPALIVE_BUDGET_BYTES } from "./keepalive"; + +afterEach(() => { + vi.unstubAllGlobals(); + vi.restoreAllMocks(); +}); + +/** A fetch whose requests the test answers (resolve) or fails (reject), in order. */ +function heldFetch() { + const pending: Array<{ resolve(): void; reject(): void }> = []; + const fetchMock = vi.fn( + (_url: string | URL, _init?: RequestInit) => + new Promise((resolve, reject) => + pending.push({ + resolve: () => resolve(new Response(null, { status: 202 })), + reject: () => reject(new TypeError("Failed to fetch")), + }) + ) + ); + vi.stubGlobal("fetch", fetchMock); + return { fetchMock, pending, keepalive: () => fetchMock.mock.calls.map(([, init]) => init?.keepalive) }; +} + +const KIB = 1024; + +describe("fetchWithKeepalive", () => { + it("keeps this SDK's keepalive bodies in flight within 64 KiB, freeing a request's share once it's answered", async () => { + const { pending, keepalive } = heldFetch(); + const body = "x".repeat(40 * KIB); + + const first = fetchWithKeepalive("https://flaggr.test/a", { method: "POST", body }); + const second = fetchWithKeepalive("https://flaggr.test/b", { method: "POST", body }); // 80 KiB in flight: too much + expect(keepalive()).toEqual([true, false]); + expect(fitsKeepaliveBudget("x".repeat(24 * KIB))).toBe(true); + expect(fitsKeepaliveBudget("x".repeat(25 * KIB))).toBe(false); + + pending[0].resolve(); + await first; + const third = fetchWithKeepalive("https://flaggr.test/c", { method: "POST", body }); + expect(keepalive()).toEqual([true, false, true]); + + // A failed request frees its share too. + pending[2].reject(); + await expect(third).rejects.toThrow("Failed to fetch"); + pending[1].resolve(); + await second; + expect(fitsKeepaliveBudget("x".repeat(KEEPALIVE_BUDGET_BYTES))).toBe(true); + }); + + it("sends a body over 64 KiB on its own as a plain request", async () => { + const { pending, keepalive } = heldFetch(); + const response = fetchWithKeepalive("https://flaggr.test/a", { method: "POST", body: "x".repeat(KEEPALIVE_BUDGET_BYTES + 1) }); + expect(keepalive()).toEqual([false]); + pending[0].resolve(); + await response; + }); + + it("frees the share of a request fetch threw for", () => { + vi.stubGlobal( + "fetch", + vi.fn(() => { + throw new TypeError("fetch threw"); + }) + ); + const body = "x".repeat(40 * KIB); + expect(() => fetchWithKeepalive("https://flaggr.test/a", { method: "POST", body })).toThrow("fetch threw"); + expect(fitsKeepaliveBudget("x".repeat(KEEPALIVE_BUDGET_BYTES))).toBe(true); + }); +}); + +describe("bodyBytes", () => { + it("counts UTF-8 bytes, as the budget does", () => { + expect(bodyBytes("abc")).toBe(3); + expect(bodyBytes("é")).toBe(2); + expect(bodyBytes("€")).toBe(3); + expect(bodyBytes("😀")).toBe(4); + }); + + it("counts the same without TextEncoder", () => { + vi.stubGlobal("TextEncoder", undefined); + expect([bodyBytes("abc"), bodyBytes("é"), bodyBytes("€"), bodyBytes("😀"), bodyBytes("a😀b")]).toEqual([3, 2, 3, 4, 6]); + }); +}); diff --git a/packages/sdk/src/keepalive.ts b/packages/sdk/src/keepalive.ts new file mode 100644 index 0000000..19ba975 --- /dev/null +++ b/packages/sdk/src/keepalive.ts @@ -0,0 +1,86 @@ +/** + * Requests that outlive the page. + * + * A fetch with `keepalive: true` keeps going after the page is hidden or + * unloaded, as navigator.sendBeacon does, but can carry headers (an + * Authorization bearer: the browser sends the CORS preflight first). The + * Fetch standard caps the bodies of a page's keepalive requests in flight at + * 64 KiB in total, and a request over that fails as a network error + * (Chromium, Firefox and WebKit all enforce it). This module keeps the SDK's + * own keepalive requests within that budget: a body that doesn't fit what's + * left goes on a plain request, which arrives as long as the page stays open. + */ + +/** The bytes a page's keepalive request bodies in flight may add up to. */ +export const KEEPALIVE_BUDGET_BYTES = 64 * 1024; + +/** Body bytes of this SDK's keepalive requests still in flight (one budget per page). */ +let inflightBytes = 0; + +/** A request body's size in bytes (UTF-8), as the keepalive budget counts it. */ +export function bodyBytes(body: string): number { + if (typeof TextEncoder !== "undefined") return new TextEncoder().encode(body).length; + // No TextEncoder: count UTF-8 bytes by hand (a surrogate pair is 4). + let bytes = 0; + for (let i = 0; i < body.length; i++) { + const code = body.charCodeAt(i); + if (code < 0x80) bytes += 1; + else if (code < 0x800) bytes += 2; + else if (code >= 0xd800 && code <= 0xdbff && i + 1 < body.length) { + bytes += 4; + i++; + } else bytes += 3; + } + return bytes; +} + +/** + * Whether a keepalive request with this body fits what's left of the page's + * budget, as far as this SDK knows (other scripts' keepalive requests count + * too, but can't be seen). + */ +export function fitsKeepaliveBudget(body: string): boolean { + return bodyBytes(body) <= KEEPALIVE_BUDGET_BYTES - inflightBytes; +} + +/** + * Send `init` with fetch as a keepalive request when its body fits the + * budget left, else as a plain request. Returns, resolves, rejects or throws + * as fetch does. + */ +export function fetchWithKeepalive(url: string, init: RequestInit & { body: string }): Promise { + const bytes = bodyBytes(init.body); + if (bytes > KEEPALIVE_BUDGET_BYTES - inflightBytes) { + return fetch(url, { ...init, keepalive: false }); + } + inflightBytes += bytes; + const release = () => { + inflightBytes -= bytes; + }; + let response: Promise; + try { + response = fetch(url, { ...init, keepalive: true }); + } catch (error) { + release(); + throw error; + } + return response.then( + (res) => { + release(); + return res; + }, + (error: unknown) => { + release(); + throw error; + } + ); +} + +/** POST a JSON body (see fetchWithKeepalive). */ +export function postWithKeepalive( + url: string, + headers: Record, + body: string +): Promise { + return fetchWithKeepalive(url, { method: "POST", headers, body }); +} diff --git a/packages/sdk/src/otel.ts b/packages/sdk/src/otel.ts index c061472..1303e44 100644 --- a/packages/sdk/src/otel.ts +++ b/packages/sdk/src/otel.ts @@ -8,7 +8,7 @@ * * const client = createFlaggr({ * serviceId: 'web-app', - * apiKey: 'flg_xxx', + * apiKey: 'fgr_xxx', * plugins: [ * otelPlugin() // Uses global OTEL providers * otelPlugin({ serviceName: 'my-app' }) // Custom service name diff --git a/packages/sdk/src/react.server.test.tsx b/packages/sdk/src/react.server.test.tsx new file mode 100644 index 0000000..2870e73 --- /dev/null +++ b/packages/sdk/src/react.server.test.tsx @@ -0,0 +1,50 @@ +import React from "react"; +import { renderToString } from "react-dom/server"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { FlaggrProvider, useBooleanFlag } from "./react"; +import type { FlaggrConfig, FlaggrPlugin } from "./types"; + +afterEach(() => { + vi.useRealTimers(); + vi.unstubAllGlobals(); + vi.restoreAllMocks(); +}); + +function Hero() { + return

{useBooleanFlag("hero", false) ? "on" : "off"}

; +} + +describe("FlaggrProvider in a server render", () => { + it.each(["stream", "batch"] as const)( + "renders %s mode from the bootstrap without starting the client: no request, timer or plugin", + async (updateMode) => { + vi.useFakeTimers(); + const fetchMock = vi.fn(() => new Promise(() => {})); + vi.stubGlobal("fetch", fetchMock); + const plugin: FlaggrPlugin = { name: "probe", onInit: vi.fn() }; + const config: FlaggrConfig = { + apiUrl: "https://flaggr.test", + serviceId: "web", + apiKey: "fgr_key", + updateMode, + remoteConfig: true, + plugins: [plugin], + bootstrap: { flags: [{ key: "hero", type: "boolean", enabled: true, defaultValue: true }] }, + }; + + // A server render never commits, so no effect runs: nothing would + // ever stop a client started here. + const html = renderToString( + + + + ); + await vi.advanceTimersByTimeAsync(0); + + expect(html).toBe("

on

"); + expect(fetchMock).not.toHaveBeenCalled(); + expect(vi.getTimerCount()).toBe(0); + expect(plugin.onInit).not.toHaveBeenCalled(); + } + ); +}); diff --git a/packages/sdk/src/react.test.tsx b/packages/sdk/src/react.test.tsx new file mode 100644 index 0000000..1c21f51 --- /dev/null +++ b/packages/sdk/src/react.test.tsx @@ -0,0 +1,465 @@ +// @vitest-environment jsdom + +import React, { act, StrictMode, type ReactNode } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { + FlaggrProvider, + useBooleanFlag, + useFlag, + useFlaggr, + useObjectFlag, + useRefreshFlags, + useStringFlag, +} from "./react"; +import type { EvaluationContext, FlaggrClientInstance, FlaggrConfig, FlaggrPlugin } from "./types"; + +// React's act() warns unless the environment says it's a test. +(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + +let container: HTMLElement | null = null; +let root: Root | null = null; + +/** Render into a fresh container, effects included. */ +function mount(node: ReactNode): void { + container = document.body.appendChild(document.createElement("div")); + root = createRoot(container); + act(() => root!.render(node)); +} + +function unmount(): void { + if (root) act(() => root!.unmount()); + root = null; + container?.remove(); + container = null; +} + +const text = (testId: string) => container?.querySelector(`[data-testid="${testId}"]`)?.textContent; + +afterEach(() => { + unmount(); + vi.useRealTimers(); + vi.unstubAllGlobals(); + vi.restoreAllMocks(); +}); + +const jsonResponse = (body: unknown) => + new Response(JSON.stringify(body), { status: 200, headers: { "Content-Type": "application/json" } }); + +/** A 200 text/event-stream response whose body the test writes. */ +function sseResponse() { + const encoder = new TextEncoder(); + let controller!: ReadableStreamDefaultController; + const response = new Response( + new ReadableStream({ + start(c) { + controller = c; + }, + }), + { status: 200, headers: { "Content-Type": "text/event-stream" } } + ); + return { response, send: (chunk: string) => controller.enqueue(encoder.encode(chunk)) }; +} + +/** + * The data plane: every stream request (with its abort signal), batch + * refreshes, and evaluations, which answer `hero` off. + */ +function dataPlane() { + const streams: Array<{ signal: AbortSignal; send(chunk: string): void }> = []; + const fetchMock = vi.fn(async (url: string | URL, init?: RequestInit) => { + if (String(url).includes("/api/flags/stream")) { + const stream = sseResponse(); + streams.push({ signal: init!.signal!, send: stream.send }); + return stream.response; + } + if (String(url).endsWith("/api/flags/evaluate/batch")) { + const { flagKeys = [] } = JSON.parse(String(init?.body)) as { flagKeys?: string[] }; + return jsonResponse({ + flags: Object.fromEntries(flagKeys.map((flagKey) => [flagKey, { flagKey, value: false, reason: "DEFAULT" }])), + }); + } + return jsonResponse({ value: false, reason: "DEFAULT" }); + }); + vi.stubGlobal("fetch", fetchMock); + return { + fetchMock, + open: () => streams.filter((stream) => !stream.signal.aborted), + batchBodies: () => + fetchMock.mock.calls + .filter(([url]) => String(url).endsWith("/api/flags/evaluate/batch")) + .map(([, init]) => JSON.parse(String(init?.body)) as { flagKeys: string[] }), + }; +} + +const HERO_ON = + 'event: configuration_sync\ndata: {"type":"configuration_sync","version":"v1",' + + '"flags":[{"key":"hero","type":"boolean","enabled":true,"defaultValue":true}]}\n\n'; + +const STREAM: FlaggrConfig = { + apiUrl: "https://flaggr.test", + serviceId: "web", + apiKey: "fgr_key", + updateMode: "stream", +}; + +/** Let pending fetch answers, and the renders they cause, happen (inside act). */ +const settle = (ms = 20) => act(() => new Promise((resolve) => setTimeout(resolve, ms))); + +/** Settle until `check` passes (or give up after about a second). */ +async function until(check: () => void): Promise { + for (let attempt = 0; ; attempt++) { + await settle(10); + try { + check(); + return; + } catch (error) { + if (attempt >= 100) throw error; + } + } +} + +let seen: FlaggrClientInstance | undefined; +function Hero() { + seen = useFlaggr(); + const on = useBooleanFlag("hero", false); + return

{on ? "on" : "off"}

; +} + +describe("FlaggrProvider", () => { + it("ends React StrictMode's mount, unmount and mount with a started client: its stream is open and reaches the hooks", async () => { + const plane = dataPlane(); + mount( + + + + + + ); + await until(() => expect(plane.open()).toHaveLength(1)); + await settle(); // the hooks' own evaluations are answered (off) + expect(text("hero")).toBe("off"); + + plane.open()[0].send(HERO_ON); + + await until(() => expect(text("hero")).toBe("on")); + expect(seen!.getConnectionState()).toBe("connected"); + expect(seen!.getUpdateMode()).toBe("stream"); + // StrictMode's unmount destroyed the first started client (before its + // stream went out, or closing it); a render React threw away started none. + expect(plane.open()).toHaveLength(1); + }); + + it("keeps the batch timer of the client the tree uses running under StrictMode", async () => { + const plane = dataPlane(); + mount( + + + + + + ); + + // The watched flag is refreshed every 25 ms. + await until(() => expect(plane.batchBodies().length).toBeGreaterThanOrEqual(2)); + expect(plane.batchBodies()[0].flagKeys).toEqual(["hero"]); + }); + + it("starts one client without StrictMode, and unmounting destroys it", async () => { + const plane = dataPlane(); + mount( + + + + ); + await until(() => expect(plane.open()).toHaveLength(1)); + await settle(); + expect(plane.fetchMock.mock.calls.filter(([url]) => String(url).includes("/api/flags/stream"))).toHaveLength(1); + + unmount(); + expect(plane.open()).toHaveLength(0); + // A destroyed client ignores a mode switch. + seen!.setUpdateMode("poll"); + expect(seen!.getUpdateMode()).toBe("stream"); + }); + + it("re-renders the hooks with what useRefreshFlags found changed", async () => { + const values: Record = { promo: "spring" }; + vi.stubGlobal( + "fetch", + vi.fn(async (url: string | URL, init?: RequestInit) => { + const body = JSON.parse(String(init?.body)) as { flagKey?: string; flags?: Array<{ key: string }> }; + if (String(url).endsWith("/batch")) { + return jsonResponse({ flags: body.flags!.map(({ key }) => ({ key, value: values[key], reason: "STATIC" })) }); + } + return jsonResponse({ value: values[body.flagKey!], reason: "STATIC" }); + }) + ); + let refresh!: () => Promise; + function Promo() { + refresh = useRefreshFlags(); + return

{useStringFlag("promo", "none")}

; + } + mount( + + + + ); + await until(() => expect(text("promo")).toBe("spring")); + + values.promo = "summer"; + await act(() => refresh()); + + expect(text("promo")).toBe("summer"); + }); + + it("useObjectFlag takes an interface", async () => { + interface Banner { + text: string; + } + vi.stubGlobal("fetch", vi.fn(async () => jsonResponse({ value: { text: "Sale" }, reason: "STATIC" }))); + function BannerView() { + const banner: Banner = useObjectFlag("banner", { text: "Welcome" }); + return

{banner.text}

; + } + mount( + + + + ); + expect(text("banner")).toBe("Welcome"); + await until(() => expect(text("banner")).toBe("Sale")); + }); +}); + +describe("hooks with a per-call context", () => { + /** + * The data plane answers `beta` to the targeting key admin-preview, else + * `stable`, for evaluations and batches; `failing` batches answer 503. + */ + function previewPlane() { + const state = { failing: false, failed: 0, batches: 0 }; + const valueFor = (context?: EvaluationContext) => (context?.targetingKey === "admin-preview" ? "beta" : "stable"); + const fetchMock = vi.fn(async (url: string | URL, init?: RequestInit) => { + const body = JSON.parse(String(init?.body)) as { context?: EvaluationContext; flagKeys?: string[] }; + if (String(url).endsWith("/api/flags/evaluate/batch")) { + if (state.failing) { + state.failed++; + return new Response(JSON.stringify({ error: "unavailable" }), { status: 503 }); + } + state.batches++; + const value = valueFor(body.context); + return jsonResponse({ + flags: Object.fromEntries(body.flagKeys!.map((flagKey) => [flagKey, { flagKey, value, reason: "TARGETING_MATCH" }])), + }); + } + return jsonResponse({ value: valueFor(body.context), reason: "TARGETING_MATCH" }); + }); + vi.stubGlobal("fetch", fetchMock); + return state; + } + + let refresh!: () => Promise; + /** An admin's page previewing a flag for someone else. */ + function Preview() { + seen = useFlaggr(); + refresh = useRefreshFlags(); + return

{useFlag("layout", "none", { targetingKey: "admin-preview" })}

; + } + + it("keeps its own value through useRefreshFlags: never the client's own context's", async () => { + previewPlane(); + mount( + + + + ); + await until(() => expect(text("layout")).toBe("beta")); + + await act(() => refresh()); + await settle(); + + expect(text("layout")).toBe("beta"); + }); + + it("keeps its own value across batch ticks, after a failed one too", async () => { + const plane = previewPlane(); + mount( + + + + ); + await until(() => expect(text("layout")).toBe("beta")); + + plane.failing = true; + await until(() => expect(plane.failed).toBeGreaterThanOrEqual(1)); + plane.failing = false; + const before = plane.batches; + await until(() => expect(plane.batches - before).toBeGreaterThanOrEqual(3)); + await settle(); + + expect(text("layout")).toBe("beta"); + }); + + it("keeps a hook on the client's own context and one with a per-call context apart", async () => { + previewPlane(); + function Both() { + refresh = useRefreshFlags(); + const own = useStringFlag("layout", "none"); + const preview = useStringFlag("layout", "none", { targetingKey: "admin-preview" }); + return

{`${own}/${preview}`}

; + } + mount( + + + + ); + await until(() => expect(text("both")).toBe("stable/beta")); + + await act(() => refresh()); + await settle(); + + expect(text("both")).toBe("stable/beta"); + }); + + it("takes a stream's configuration_sync for its own context", async () => { + const plane = dataPlane(); + function Beta() { + return

{String(useFlag("beta", false, { targetingKey: "user-1" }))}

; + } + mount( + + + + ); + await until(() => expect(plane.open()).toHaveLength(1)); + await settle(); + expect(text("beta")).toBe("false"); + + // A rule for user-1 only: the client's own context still gets false. + plane.open()[0].send( + 'event: configuration_sync\ndata: {"type":"configuration_sync","version":"v2","flags":[{"key":"beta","type":"boolean",' + + '"enabled":true,"defaultValue":false,"overrides":[{"identifiers":["user-1"],"value":true}]}]}\n\n' + ); + + await until(() => expect(text("beta")).toBe("true")); + }); +}); + +describe("hooks and value-less change events", () => { + it("useFlag evaluates again on a change without a value (a control-plane flag-update): never undefined", async () => { + let promo = "spring"; + let streamSend!: (chunk: string) => void; + vi.stubGlobal( + "fetch", + vi.fn(async (url: string | URL) => { + if (String(url).includes("/api/flags/stream")) { + const stream = sseResponse(); + streamSend = stream.send; + return stream.response; + } + return jsonResponse({ value: promo, reason: "STATIC" }); + }) + ); + function Promo() { + return

{String(useFlag("promo", "none"))}

; + } + mount( + + + + ); + await until(() => expect(text("promo")).toBe("spring")); + + promo = "summer"; + streamSend('data: {"type":"flag-update","flagKey":"promo","eventType":"UPDATED"}\n\n'); + + await until(() => expect(text("promo")).toBe("summer")); + }); +}); + +describe("hooks and answers arriving out of order", () => { + it("keeps a newer value over an evaluation that answers after it", async () => { + // The hook's own evaluation is slow; a refresh's batch answers first, with a newer value. + let answerEvaluation!: (response: Response) => void; + vi.stubGlobal( + "fetch", + vi.fn((url: string | URL, init?: RequestInit) => { + if (String(url).endsWith("/batch")) { + const { flags } = JSON.parse(String(init?.body)) as { flags: Array<{ key: string }> }; + return Promise.resolve(jsonResponse({ flags: flags.map(({ key }) => ({ key, value: "summer", reason: "STATIC" })) })); + } + return new Promise((resolve) => (answerEvaluation = resolve)); + }) + ); + let refresh!: () => Promise; + function Promo() { + refresh = useRefreshFlags(); + return

{useStringFlag("promo", "none")}

; + } + mount( + + + + ); + await until(() => expect(answerEvaluation).toBeDefined()); + + await act(() => refresh()); + expect(text("promo")).toBe("summer"); + // The evaluation the hook started first answers last, with what was true then. + answerEvaluation(jsonResponse({ value: "spring", reason: "STATIC" })); + await settle(); + + expect(text("promo")).toBe("summer"); + }); +}); + +describe("FlaggrProvider and plugins", () => { + it("calls a plugin's onInit before any other hook, though children evaluate before the client starts", async () => { + dataPlane(); + const calls: string[] = []; + const plugin: FlaggrPlugin = { + name: "recorder", + onInit: () => void calls.push("onInit"), + onEvaluate: (flagKey) => void calls.push(`onEvaluate:${flagKey}`), + onEvaluateComplete: (flagKey) => void calls.push(`onEvaluateComplete:${flagKey}`), + }; + mount( + + + + ); + await until(() => expect(calls.filter((call) => call === "onEvaluateComplete:hero")).toHaveLength(2)); + + expect(calls[0]).toBe("onInit"); + expect(calls.filter((call) => call === "onInit")).toHaveLength(1); + }); + + it("holds the children's first evaluations for the remote config: they ask with its default environment", async () => { + const fetchMock = vi.fn(async (url: string | URL, _init?: RequestInit) => + String(url).includes("/api/sdk-config") + ? jsonResponse({ defaultEnvironment: "staging" }) + : jsonResponse({ value: true, reason: "STATIC" }) + ); + vi.stubGlobal("fetch", fetchMock); + mount( + + + + ); + await until(() => expect(text("hero")).toBe("on")); + + const evaluations = fetchMock.mock.calls.filter(([url]) => String(url).endsWith("/api/flags/evaluate")); + expect(evaluations.length).toBeGreaterThanOrEqual(1); + expect(JSON.parse(String(evaluations[0][1]?.body))).toMatchObject({ flagKey: "hero", environment: "staging" }); + expect(String(fetchMock.mock.calls[0][0])).toContain("/api/sdk-config"); + }); +}); diff --git a/packages/sdk/src/react.tsx b/packages/sdk/src/react.tsx index 1dc4185..cb239b3 100644 --- a/packages/sdk/src/react.tsx +++ b/packages/sdk/src/react.tsx @@ -7,7 +7,7 @@ * * function App() { * return ( - * + * * * * ) @@ -51,14 +51,14 @@ const FlaggrContext = createContext(null); * * @example Flat props (recommended) * ```tsx - * + * * * * ``` * * @example Config object * ```tsx - * + * * * * ``` @@ -76,21 +76,43 @@ export function FlaggrProvider({ config?: FlaggrConfig; children: ReactNode; }) { - const clientRef = useRef(null); - - if (!clientRef.current) { - const resolved = resolveProviderConfig({ apiKey, serviceId, environment, config }); - clientRef.current = new FlaggrClient(resolved); - } + // The props a replacement client is created from (see the second effect): + // the latest committed ones. This effect runs before that one. + const props = { apiKey, serviceId, environment, config }; + const propsRef = useRef(props); + useEffect(() => { + propsRef.current = props; + }); + + // Created while rendering but not started: no plugin onInit, remote config + // fetch, stream or timer. A render React throws away (Strict Mode renders + // twice in development; a server render never commits) leaves nothing + // running, and the server never opens a stream. Children can evaluate + // synchronously (bootstrap, cache) from the first render. + const [client, setClient] = useState( + () => new FlaggrClient(resolveProviderConfig(props), { start: false }) + ); + /** The client this effect's cleanup destroyed last. */ + const destroyedRef = useRef(null); useEffect(() => { + if (destroyedRef.current === client) { + // Mounted again after the cleanup destroyed the client: Strict Mode + // does that once after the first mount in development, as does an + // shown again. Replace it; this effect runs again for the + // new client and starts it. + setClient(new FlaggrClient(resolveProviderConfig(propsRef.current), { start: false })); + return; + } + client.start(); return () => { - clientRef.current?.destroy(); + client.destroy(); + destroyedRef.current = client; }; - }, []); + }, [client]); return ( - + {children} ); @@ -143,6 +165,56 @@ export function useFlaggr(): FlaggrClientInstance { return client; } +/** Whether a hook's per-call context sets anything (`{}` is the client's own context). */ +function hasOwnContext(context: EvaluationContext | undefined): boolean { + return context !== undefined && Object.keys(context).length > 0; +} + +/** + * A hook's effect: evaluate its flag, then follow the flag's changes. A + * change event carries the flag's value for the client's own context, so a + * hook with a per-call context (or an event without a value, such as a + * deletion) evaluates again, with its own context, instead of taking it. + * Only the newest answer is kept: an evaluation still on its way when a + * newer one starts, or a newer value arrives, is dropped. + */ +function followFlag( + client: FlaggrClientInstance, + flagKey: string, + contextRef: { readonly current: EvaluationContext | undefined }, + evaluate: (context: EvaluationContext | undefined) => Promise, + accepts: (value: FlagValue) => boolean, + setValue: (value: T) => void +): () => void { + let mounted = true; + let latest = 0; + const reevaluate = () => { + const call = ++latest; + evaluate(contextRef.current).then( + (value) => { + if (mounted && call === latest) setValue(value); + }, + () => { + /* evaluation errors resolve to the fallback; a throwing plugin keeps the value */ + } + ); + }; + reevaluate(); + const unsubscribe = client.onFlagChange(flagKey, (event) => { + if (!mounted) return; + if (hasOwnContext(contextRef.current) || event.newValue === undefined) { + reevaluate(); + } else if (accepts(event.newValue)) { + latest++; + setValue(event.newValue as T); + } + }); + return () => { + mounted = false; + unsubscribe(); + }; +} + // --- Generic hook --- /** @@ -188,22 +260,18 @@ export function useFlag( const contextRef = useRef(context); contextRef.current = context; - useEffect(() => { - let mounted = true; - - client.evaluate(flagKey, def, contextRef.current).then((result) => { - if (mounted) setValue(result.value); - }); - - const unsub = client.onFlagChange(flagKey, (event) => { - if (mounted) setValue(event.newValue as T); - }); - - return () => { - mounted = false; - unsub(); - }; - }, [client, flagKey, def]); + useEffect( + () => + followFlag( + client, + flagKey, + contextRef, + (ctx) => client.evaluate(flagKey, def, ctx).then((result) => result.value), + () => true, + setValue + ), + [client, flagKey, def] + ); return value; } @@ -226,26 +294,18 @@ export function useBooleanFlag( const contextRef = useRef(context); contextRef.current = context; - useEffect(() => { - let mounted = true; - - client - .getBooleanValue(flagKey, defaultValue, contextRef.current) - .then((v) => { - if (mounted) setValue(v); - }); - - const unsub = client.onFlagChange(flagKey, (event) => { - if (mounted && typeof event.newValue === "boolean") { - setValue(event.newValue); - } - }); - - return () => { - mounted = false; - unsub(); - }; - }, [client, flagKey, defaultValue]); + useEffect( + () => + followFlag( + client, + flagKey, + contextRef, + (ctx) => client.getBooleanValue(flagKey, defaultValue, ctx), + (value) => typeof value === "boolean", + setValue + ), + [client, flagKey, defaultValue] + ); return value; } @@ -266,26 +326,18 @@ export function useStringFlag( const contextRef = useRef(context); contextRef.current = context; - useEffect(() => { - let mounted = true; - - client - .getStringValue(flagKey, defaultValue, contextRef.current) - .then((v) => { - if (mounted) setValue(v); - }); - - const unsub = client.onFlagChange(flagKey, (event) => { - if (mounted && typeof event.newValue === "string") { - setValue(event.newValue); - } - }); - - return () => { - mounted = false; - unsub(); - }; - }, [client, flagKey, defaultValue]); + useEffect( + () => + followFlag( + client, + flagKey, + contextRef, + (ctx) => client.getStringValue(flagKey, defaultValue, ctx), + (value) => typeof value === "string", + setValue + ), + [client, flagKey, defaultValue] + ); return value; } @@ -306,66 +358,50 @@ export function useNumberFlag( const contextRef = useRef(context); contextRef.current = context; - useEffect(() => { - let mounted = true; - - client - .getNumberValue(flagKey, defaultValue, contextRef.current) - .then((v) => { - if (mounted) setValue(v); - }); - - const unsub = client.onFlagChange(flagKey, (event) => { - if (mounted && typeof event.newValue === "number") { - setValue(event.newValue); - } - }); - - return () => { - mounted = false; - unsub(); - }; - }, [client, flagKey, defaultValue]); + useEffect( + () => + followFlag( + client, + flagKey, + contextRef, + (ctx) => client.getNumberValue(flagKey, defaultValue, ctx), + (value) => typeof value === "number", + setValue + ), + [client, flagKey, defaultValue] + ); return value; } /** - * Evaluate a typed object feature flag + * Evaluate a typed object feature flag (T may be an interface: any object type) */ -export function useObjectFlag>( +export function useObjectFlag( flagKey: string, defaultValue: T, context?: EvaluationContext ): T { const client = useFlaggr(); const [value, setValue] = useState( - () => client.evaluateSync(flagKey, defaultValue, context).value + () => client.evaluateSync(flagKey, defaultValue as T & FlagValue, context).value ); const contextRef = useRef(context); contextRef.current = context; - useEffect(() => { - let mounted = true; - - client - .getObjectValue(flagKey, defaultValue, contextRef.current) - .then((v) => { - if (mounted) setValue(v); - }); - - const unsub = client.onFlagChange(flagKey, (event) => { - if (mounted && typeof event.newValue === "object" && event.newValue !== null) { - setValue(event.newValue as T); - } - }); - - return () => { - mounted = false; - unsub(); - }; - }, [client, flagKey, defaultValue]); + useEffect( + () => + followFlag( + client, + flagKey, + contextRef, + (ctx) => client.getObjectValue(flagKey, defaultValue, ctx), + (value) => typeof value === "object" && value !== null, + setValue + ), + [client, flagKey, defaultValue] + ); return value; } @@ -387,7 +423,13 @@ export function useConnectionState(): ConnectionState { } /** - * Get a refresh function to force-refresh all flags + * Get a refresh function to force-refresh all flags: it re-evaluates the + * flags the client has cached or watches (the hooks watch theirs), and the + * hooks of flags whose value changed re-render (see `refresh()`). A hook with + * a per-call context evaluates again, for that context, when its flag's + * value changes for the client's own context (or the client had none + * cached for it): give the provider the user's context (`config.context`) + * rather than each hook, and every hook follows every change. */ export function useRefreshFlags(): () => Promise { const client = useFlaggr(); diff --git a/packages/sdk/src/remote-telemetry.test.ts b/packages/sdk/src/remote-telemetry.test.ts new file mode 100644 index 0000000..1e80be7 --- /dev/null +++ b/packages/sdk/src/remote-telemetry.test.ts @@ -0,0 +1,52 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import type { FlaggrPlugin } from "./types"; + +afterEach(() => { + vi.doUnmock("./telemetry"); + vi.resetModules(); + vi.unstubAllGlobals(); + vi.restoreAllMocks(); +}); + +const jsonResponse = (body: unknown) => + new Response(JSON.stringify(body), { status: 200, headers: { "Content-Type": "application/json" } }); + +describe("the telemetry plugin a remote config turns on", () => { + it("isn't added when its module arrives after destroy()", async () => { + // The plugin's module, held until the test lets it load. + let load!: () => void; + const loaded = new Promise((resolve) => (load = resolve)); + const plugin: FlaggrPlugin = { name: "flaggr-telemetry", onInit: vi.fn() }; + const factory = vi.fn(async () => { + await loaded; + return { flaggrTelemetry: () => plugin }; + }); + vi.doMock("./telemetry", factory); + const { FlaggrClient } = await import("./client"); + vi.stubGlobal( + "fetch", + vi.fn(async () => jsonResponse({ sdk: { telemetry: true } })) + ); + + const client = new FlaggrClient({ apiUrl: "https://flaggr.test", apiKey: "fgr_key", serviceId: "web", remoteConfig: true }); + // The remote config is applied: the plugin's module is on its way. + await vi.waitFor(() => expect(factory).toHaveBeenCalled()); + client.destroy(); + load(); + await new Promise((resolve) => setTimeout(resolve, 10)); + + expect(plugin.onInit).not.toHaveBeenCalled(); + }); + + it("is added and initialised when its module arrives first", async () => { + const plugin: FlaggrPlugin = { name: "flaggr-telemetry", onInit: vi.fn(), onDestroy: vi.fn() }; + vi.doMock("./telemetry", async () => ({ flaggrTelemetry: () => plugin })); + const { FlaggrClient } = await import("./client"); + vi.stubGlobal("fetch", vi.fn(async () => jsonResponse({ sdk: { telemetry: true } }))); + + const client = new FlaggrClient({ apiUrl: "https://flaggr.test", apiKey: "fgr_key", serviceId: "web", remoteConfig: true }); + await vi.waitFor(() => expect(plugin.onInit).toHaveBeenCalledWith(client)); + client.destroy(); + expect(plugin.onDestroy).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/sdk/src/telemetry.test.ts b/packages/sdk/src/telemetry.test.ts index 74f2fb9..93833c7 100644 --- a/packages/sdk/src/telemetry.test.ts +++ b/packages/sdk/src/telemetry.test.ts @@ -314,3 +314,171 @@ describe("flaggrTelemetry endpoint", () => { expect(telemetryEndpoint(undefined)).toBe("https://flaggr.dev/api/analytics/sdk-telemetry"); }); }); + +describe("flaggrTelemetry Web Vitals", () => { + /** + * PerformanceObserver as browsers have it: observe() ignores an entry type + * it doesn't support (there is no "first-contentful-paint" type: FCP is a + * "paint" entry with that name), and an observer gets only the entries of + * the types it observes. + */ + class FakeObserver { + static instances: FakeObserver[] = []; + static readonly supported = ["largest-contentful-paint", "layout-shift", "paint", "event", "navigation"]; + readonly types = new Set(); + constructor(private readonly callback: (list: { getEntries(): unknown[] }) => void) { + FakeObserver.instances.push(this); + } + /** Entries recorded before any observer was created (FCP usually is, before the SDK loads). */ + static earlier: Array<{ entryType: string; name: string; startTime: number; duration?: number }> = []; + observe({ type, buffered }: { type: string; buffered?: boolean }) { + if (!FakeObserver.supported.includes(type)) return; + this.types.add(type); + // Only a buffered observer gets the entries from before it observed. + if (buffered) { + const earlier = FakeObserver.earlier.filter((entry) => entry.entryType === type); + if (earlier.length) queueMicrotask(() => this.callback({ getEntries: () => earlier })); + } + } + disconnect() { + this.types.clear(); + } + deliver(entries: Array<{ entryType: string; name: string; startTime: number; duration?: number }>) { + const observed = entries.filter((entry) => this.types.has(entry.entryType)); + if (observed.length) this.callback({ getEntries: () => observed }); + } + } + + it("reports FCP from the first-contentful-paint paint entry", async () => { + vi.useFakeTimers(); + stubPage({}); + FakeObserver.instances = []; + vi.stubGlobal("PerformanceObserver", FakeObserver); + const fetchMock = okFetch(); + const plugin = flaggrTelemetry({ errors: false, flushIntervalMs: 1000 }); + plugin.onInit?.(fakeClient(KEY)); + plugin.onEvaluateComplete?.("new-nav", { value: true, reason: "STATIC", variant: "on" }, 2); + + FakeObserver.instances[0].deliver([ + { entryType: "paint", name: "first-paint", startTime: 90 }, + { entryType: "paint", name: "first-contentful-paint", startTime: 123.5 }, + { entryType: "largest-contentful-paint", name: "", startTime: 400 }, + ]); + await vi.advanceTimersByTimeAsync(1000); + + const body = JSON.parse(fetchMock.mock.calls[0][1]!.body!) as { + vitals: Array<{ metric: string; value: number; flags: Record }>; + }; + expect(body.vitals.map(({ metric, value, flags }) => [metric, value, flags])).toEqual([ + ["fcp", 123.5, { "new-nav": "on" }], + ["lcp", 400, { "new-nav": "on" }], + ]); + plugin.onDestroy?.(); + }); + + it("reports the FCP the page painted before the plugin started: it observes paint entries buffered", async () => { + vi.useFakeTimers(); + stubPage({}); + FakeObserver.instances = []; + FakeObserver.earlier = [ + { entryType: "paint", name: "first-paint", startTime: 80 }, + { entryType: "paint", name: "first-contentful-paint", startTime: 95 }, + ]; + vi.stubGlobal("PerformanceObserver", FakeObserver); + const fetchMock = okFetch(); + const plugin = flaggrTelemetry({ errors: false, flushIntervalMs: 1000 }); + plugin.onEvaluateComplete?.("new-nav", { value: true, reason: "STATIC", variant: "on" }, 2); + plugin.onInit?.(fakeClient(KEY)); + await vi.advanceTimersByTimeAsync(1000); + + const body = JSON.parse(fetchMock.mock.calls[0][1]!.body!) as { vitals: Array<{ metric: string; value: number }> }; + expect(body.vitals.map(({ metric, value }) => [metric, value])).toEqual([["fcp", 95]]); + FakeObserver.earlier = []; + plugin.onDestroy?.(); + }); +}); + +describe("flaggrTelemetry onDestroy", () => { + it("removes the listeners it added to the page", () => { + const { win, doc } = stubPage({ sendBeacon: vi.fn(() => true) }); + okFetch(); + const added = [vi.spyOn(win, "addEventListener"), vi.spyOn(doc, "addEventListener")]; + const removed = [vi.spyOn(win, "removeEventListener"), vi.spyOn(doc, "removeEventListener")]; + const plugin = flaggrTelemetry({ vitals: false }); + plugin.onInit?.(fakeClient(KEY)); + const listeners = added.map((spy) => spy.mock.calls.map(([type, listener]) => [type, listener])); + expect(listeners.map((calls) => calls.map(([type]) => type))).toEqual([["error", "pagehide"], ["visibilitychange"]]); + + plugin.onDestroy?.(); + + // Each one, by the function it was added with. + expect(removed.map((spy) => spy.mock.calls.map(([type, listener]) => [type, listener]))).toEqual(listeners); + }); + + it("removes its visibilitychange listener: a hidden page doesn't flush a destroyed plugin", () => { + const { doc } = stubPage({ sendBeacon: vi.fn(() => true) }); + const fetchMock = okFetch(); + const plugin = installed(); + plugin.onDestroy?.(); // flushes the accumulated evaluation + expect(fetchMock).toHaveBeenCalledTimes(1); + + plugin.onEvaluateComplete?.("new-nav", { value: true, reason: "STATIC", variant: "on" }, 2); + doc.visibilityState = "hidden"; + doc.dispatchEvent(new Event("visibilitychange")); + + expect(fetchMock).toHaveBeenCalledTimes(1); + }); +}); + +describe("flaggrTelemetry and the 64 KiB keepalive budget", () => { + /** Accumulate enough flags for a batch well over 64 KiB. */ + const bigBatch = (plugin: ReturnType) => { + for (let i = 0; i < 800; i++) { + plugin.onEvaluateComplete?.(`flag-${i}-${"x".repeat(60)}`, { value: true, reason: "STATIC", variant: "on" }, 1); + } + }; + + it("sends a periodic batch over the budget as a plain request: a keepalive one would fail", async () => { + vi.useFakeTimers(); + const fetchMock = okFetch(); + const plugin = installed(KEY, { flushIntervalMs: 1000 }); + bigBatch(plugin); + + await vi.advanceTimersByTimeAsync(1000); + + const [, init] = fetchMock.mock.calls[0]; + expect(init!.body!.length).toBeGreaterThan(64 * 1024); + expect(init).toMatchObject({ keepalive: false, headers: { Authorization: `Bearer ${KEY}` } }); + plugin.onDestroy?.(); + }); + + it("keeps a periodic batch within the budget on a keepalive request", async () => { + vi.useFakeTimers(); + const fetchMock = okFetch(); + const plugin = installed(KEY, { flushIntervalMs: 1000 }); + + await vi.advanceTimersByTimeAsync(1000); + + expect(fetchMock.mock.calls[0][1]).toMatchObject({ keepalive: true, headers: { Authorization: `Bearer ${KEY}` } }); + plugin.onDestroy?.(); + }); + + it("sends a page-hide batch over the budget as a plain request with the bearer header, not a beacon", () => { + const sendBeacon = vi.fn(() => true); + const { win } = stubPage({ sendBeacon }); + const fetchMock = okFetch(); + const plugin = installed(); + bigBatch(plugin); + + win.dispatchEvent(new Event("pagehide")); + + expect(sendBeacon).not.toHaveBeenCalled(); + expect(fetchMock).toHaveBeenCalledTimes(1); + const [, init] = fetchMock.mock.calls[0]; + expect(init).toMatchObject({ + keepalive: false, + headers: { "Content-Type": "application/json", Authorization: `Bearer ${KEY}` }, + }); + expect(JSON.parse(init!.body!).apiKey).toBeUndefined(); + }); +}); diff --git a/packages/sdk/src/telemetry.ts b/packages/sdk/src/telemetry.ts index 9189e41..47a9fc4 100644 --- a/packages/sdk/src/telemetry.ts +++ b/packages/sdk/src/telemetry.ts @@ -29,6 +29,8 @@ * data-api-key="fgr_..." data-telemetry defer> */ +import { appBaseUrl } from "./app-url"; +import { fetchWithKeepalive, fitsKeepaliveBudget } from "./keepalive"; import type { EvaluationResult, FlaggrClientInstance, @@ -39,7 +41,7 @@ import type { export interface TelemetryOptions { /** ms between periodic flushes (default 30000) */ flushIntervalMs?: number; - /** capture LCP/CLS/FCP/INP/TTFB via PerformanceObserver (default true) */ + /** capture LCP, CLS, FCP, INP and TTFB via PerformanceObserver (default true) */ vitals?: boolean; /** capture window errors correlated with active flag variants (default true) */ errors?: boolean; @@ -63,29 +65,18 @@ export interface TelemetryOptions { /** Why telemetry stopped, already reported by console.warn. */ const warned = new Set(); -/** The hosted data plane (the client's default apiUrl): evaluation only, no telemetry endpoint. */ -const HOSTED_DATA_PLANE_HOST = "api.flaggr.dev"; -/** The hosted Flaggr app, which serves POST /api/analytics/sdk-telemetry. */ -const HOSTED_APP_ORIGIN = "https://flaggr.dev"; +/** The Flaggr app's route (the hosted data plane has no telemetry endpoint). */ const TELEMETRY_PATH = "/api/analytics/sdk-telemetry"; /** - * Where a client's batches go (TelemetryOptions.endpoint). An unset apiUrl - * stands for the client's default, the hosted data plane: the hosted app's - * endpoint. An empty one (or "/") is the page's own origin, where the client - * evaluates too: the endpoint's path there. + * Where a client's batches go (TelemetryOptions.endpoint): the Flaggr app's + * endpoint for the client's apiUrl (appBaseUrl). An unset apiUrl stands for + * the client's default, the hosted data plane: the hosted app's endpoint. An + * empty one (or "/") is the page's own origin, where the client evaluates + * too: the endpoint's path there. */ export function telemetryEndpoint(apiUrl: string | undefined, endpoint?: string): string { - if (endpoint) return endpoint; - if (apiUrl === undefined) return `${HOSTED_APP_ORIGIN}${TELEMETRY_PATH}`; - const base = apiUrl.replace(/\/+$/, ""); - let host = ""; - try { - host = new URL(base).hostname.toLowerCase(); - } catch { - /* not an absolute URL: post next to it */ - } - return `${host === HOSTED_DATA_PLANE_HOST ? HOSTED_APP_ORIGIN : base}${TELEMETRY_PATH}`; + return endpoint || `${appBaseUrl(apiUrl)}${TELEMETRY_PATH}`; } /** Whether fetch() honours `keepalive` (Request#keepalive: Chrome 66, Safari 13, Firefox 133). */ @@ -103,12 +94,16 @@ function keepaliveFetchSupported(): boolean { * navigator.sendBeacon may send as "null" under a strict referrer policy * (Firefox, WebKit), and the endpoint trusts a batch for rollout RUM checks * only from a domain of the project's Sites. navigator.sendBeacon only where - * keepalive fetch isn't supported. Returns false when neither went out. + * keepalive fetch isn't supported. Returns false when neither went out, and + * for a batch over what's left of the page's 64 KiB keepalive budget, which + * a beacon (held to the same quota) couldn't carry either: the caller sends + * that one as a plain request, which arrives unless the page is unloading. */ function sendOnPageHide(url: string, body: string, onResponse: (response: Response) => void): boolean { + if (!fitsKeepaliveBudget(body)) return false; if (keepaliveFetchSupported()) { try { - fetch(url, { method: "POST", keepalive: true, headers: { "Content-Type": "text/plain" }, body }) + fetchWithKeepalive(url, { method: "POST", headers: { "Content-Type": "text/plain" }, body }) .then(onResponse) .catch(() => {}); return true; @@ -161,6 +156,8 @@ export function flaggrTelemetry(options: TelemetryOptions = {}): FlaggrPlugin { let vitalsObserver: PerformanceObserver | null = null; let onError: ((e: ErrorEvent) => void) | null = null; let onHide: (() => void) | null = null; + /** The document's visibilitychange listener: kept so onDestroy can remove it. */ + let onVisibilityChange: (() => void) | null = null; /** No apiKey, or the endpoint refused it (401/403): no more telemetry from this client. */ let refused = false; const requests: { durationMs: number; ok: boolean }[] = []; @@ -279,11 +276,13 @@ export function flaggrTelemetry(options: TelemetryOptions = {}): FlaggrPlugin { if (pageHide && beacon && inBrowser && sendOnPageHide(url, JSON.stringify({ ...batch, apiKey }), answered)) { return; } - fetch(url, { + // Keepalive when the batch fits the page's 64 KiB keepalive budget: a + // bigger one (many flags, each vital sample tagged with all of them) + // would fail as a network error. It goes as a plain request instead. + fetchWithKeepalive(url, { method: "POST", headers: { "Content-Type": "application/json", Authorization: `Bearer ${apiKey}` }, body: JSON.stringify(batch), - keepalive: true, }) .then(answered) .catch(() => {}); @@ -316,13 +315,17 @@ export function flaggrTelemetry(options: TelemetryOptions = {}): FlaggrPlugin { try { vitalsObserver = new PerformanceObserver((list) => { for (const entry of list.getEntries()) { + // FCP is a "paint" entry named first-contentful-paint (the + // other paint entry, first-paint, isn't a Web Vital). const metric = entry.entryType === "largest-contentful-paint" ? "lcp" : entry.entryType === "layout-shift" ? "cls" - : entry.entryType === "first-contentful-paint" - ? "fcp" + : entry.entryType === "paint" + ? entry.name === "first-contentful-paint" + ? "fcp" + : null : entry.entryType === "event" ? "inp" : entry.entryType === "navigation" @@ -342,7 +345,7 @@ export function flaggrTelemetry(options: TelemetryOptions = {}): FlaggrPlugin { } } }); - for (const type of ["largest-contentful-paint", "layout-shift", "first-contentful-paint", "event", "navigation"]) { + for (const type of ["largest-contentful-paint", "layout-shift", "paint", "event", "navigation"]) { try { vitalsObserver.observe({ type, buffered: true } as PerformanceObserverInit); } catch { @@ -363,9 +366,10 @@ export function flaggrTelemetry(options: TelemetryOptions = {}): FlaggrPlugin { } onHide = () => flush(true); - document.addEventListener("visibilitychange", () => { + onVisibilityChange = () => { if (document.visibilityState === "hidden") onHide?.(); - }); + }; + document.addEventListener("visibilitychange", onVisibilityChange); window.addEventListener("pagehide", onHide); }, @@ -389,9 +393,13 @@ export function flaggrTelemetry(options: TelemetryOptions = {}): FlaggrPlugin { onDestroy() { if (timer) clearInterval(timer); + timer = null; vitalsObserver?.disconnect(); + vitalsObserver = null; if (onError) window.removeEventListener("error", onError); if (onHide) window.removeEventListener("pagehide", onHide); + if (onVisibilityChange) document.removeEventListener("visibilitychange", onVisibilityChange); + onError = onHide = onVisibilityChange = null; flush(true); }, }; diff --git a/packages/sdk/src/typecheck.test.ts b/packages/sdk/src/typecheck.test.ts new file mode 100644 index 0000000..e1f9893 --- /dev/null +++ b/packages/sdk/src/typecheck.test.ts @@ -0,0 +1,30 @@ +import { execFileSync } from "node:child_process"; +import { createRequire } from "node:module"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { describe, expect, it } from "vitest"; + +/** + * Vitest runs the tests without checking their types, so the type-level + * tests (a public type taking what it should: getObjectValue's interfaces, + * EvaluationResult's reasons, a 0.4.0-style FlaggrClientInstance) would pass + * on broken types. This runs the package's type check, tests included. + */ +const SDK_DIR = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); + +describe("the package's types", () => { + it("type-check: tsc --noEmit over the sources and tests finds no error", () => { + const tsc = createRequire(path.join(SDK_DIR, "package.json")).resolve("typescript/bin/tsc"); + let output = ""; + try { + execFileSync(process.execPath, [tsc, "--noEmit", "-p", path.join(SDK_DIR, "tsconfig.json")], { + cwd: SDK_DIR, + stdio: "pipe", + }); + } catch (error) { + const { stdout, stderr, message } = error as { stdout?: Buffer; stderr?: Buffer; message: string }; + output = `${stdout ?? ""}${stderr ?? ""}` || message; + } + expect(output).toBe(""); + }, 120_000); +}); diff --git a/packages/sdk/src/types.ts b/packages/sdk/src/types.ts index d79ae9d..bd3aeff 100644 --- a/packages/sdk/src/types.ts +++ b/packages/sdk/src/types.ts @@ -19,7 +19,12 @@ export interface EvaluationContext { export interface EvaluationResult { /** The evaluated flag value */ value: T; - /** Reason for the evaluation result */ + /** + * Reason for the evaluation result. The data plane (https://api.flaggr.dev) + * answers a flag it doesn't have with FLAG_NOT_FOUND, the control plane + * (https://flaggr.dev) with NOT_FOUND; EXPERIMENT, PREREQUISITE_FAILED and + * MUTUAL_EXCLUSION come from the control plane's evaluator. + */ reason: | "STATIC" | "DEFAULT" @@ -29,6 +34,10 @@ export interface EvaluationResult { | "SPLIT" | "DISABLED" | "NOT_FOUND" + | "FLAG_NOT_FOUND" + | "EXPERIMENT" + | "PREREQUISITE_FAILED" + | "MUTUAL_EXCLUSION" | "ERROR"; /** Variant key if applicable */ variant?: string; @@ -85,7 +94,12 @@ export interface RequestInfo { export interface FlaggrPlugin { /** Plugin name for identification */ name: string; - /** Called when the SDK is initialized */ + /** + * Called when the SDK is initialized, before any other hook. A client + * created with `{ start: false }` (FlaggrProvider's) initializes in + * start(): what it evaluated or requested before then reaches the other + * hooks right after onInit, in order. + */ onInit?(client: FlaggrClientInstance): void; /** Called before each flag evaluation */ onEvaluate?(flagKey: string, context?: EvaluationContext): void; @@ -111,7 +125,12 @@ export interface FlaggrPlugin { * SDK configuration */ export interface FlaggrConfig { - /** Flaggr API URL (defaults to https://flaggr.dev) */ + /** + * Where the client evaluates flags (default: https://api.flaggr.dev, the + * hosted data plane). The Flaggr app's routes it also calls + * (/api/sdk-config, /api/events/outcomes, /api/analytics/sdk-telemetry) go + * to https://flaggr.dev when apiUrl is the hosted data plane, else to apiUrl. + */ apiUrl?: string; /** Service identifier */ serviceId: string; @@ -140,10 +159,18 @@ export interface FlaggrConfig { * When provided, flags evaluate locally with zero network on first paint. */ bootstrap?: Record; - /** Default values for flags (used when offline or on error) */ + /** + * Fallback values by flag key, returned instead of the method-level + * default wherever the client would return that default: the evaluation + * fails (offline, an HTTP error, a refused key), the flag isn't found, or, + * for `evaluateSync`, nothing has loaded yet. A flag listed here is still + * evaluated as usual: its entry never replaces a result. + */ defaults?: Record; /** - * Fetch service-level SDK config from GET /api/sdk-config on init — + * Fetch service-level SDK config from the Flaggr app's GET /api/sdk-config on init + * (https://flaggr.dev when apiUrl is the hosted data plane, https://api.flaggr.dev, + * which serves only evaluation; otherwise the apiUrl) — * environment, updateMode, telemetry, cacheTtl come from Flaggr instead * of being repeated in client code. Explicit fields in this config win; * remote values fill gaps. Cached in localStorage with stale-while- @@ -232,6 +259,19 @@ export interface OutcomeEvent { eventValue?: number; /** Optional user identifier (will be SHA-256 hashed server-side) */ userId?: string; + /** + * The targeting key of the evaluation the outcome follows: the event + * carries the variant this client last evaluated for the flag with this + * targetingKey. Default: `userId`, then the client's own targetingKey (its + * `context`). Not sent. + */ + targetingKey?: string; + /** + * The variant the user saw, when the caller knows it (say a server that + * evaluated the flag elsewhere). Default: the one this client last + * evaluated for the flag and targetingKey, else "unknown". + */ + variant?: string; } /** @@ -256,8 +296,8 @@ export interface FlaggrClientInstance { defaultValue: number, context?: EvaluationContext ): Promise; - /** Evaluate an object flag */ - getObjectValue>( + /** Evaluate an object flag (T may be an interface: any object type) */ + getObjectValue( flagKey: string, defaultValue: T, context?: EvaluationContext @@ -306,11 +346,38 @@ export interface FlaggrClientInstance { * Clear all persisted SDK state for this service (remote settings + * flag snapshots across all environments/scopes). Next load re-fetches * everything — use for key rotation or recovering from bad cache state. + * Optional, so an implementation written for 0.4.0, which didn't have it, + * still is one: FlaggrClient always has it (`client.clearPersistedConfig?.()` + * on this type). + */ + clearPersistedConfig?(): void; + /** + * Force refresh all flags: re-evaluate, with the client's own context and + * in one batched request (flags it holds the configuration of evaluate + * locally), every flag this client has cached for that context or watches + * with `onFlagChange`, and notify the listeners of each flag whose value + * changed. Resolves once that's done. Results cached for per-call + * contexts are dropped: those go to the network again at their next call, + * as does a flag the refresh can't resolve (the request fails, or the + * flag isn't found), which notifies nobody. A flag only cached for + * per-call contexts, and not watched, isn't evaluated for the client's own + * context. (A React hook with a per-call context evaluates again, for its + * context, when its flag's listeners are notified.) */ - clearPersistedConfig(): void; - /** Force refresh all flags */ refresh(): Promise; - /** Track an outcome event for passive experiment analysis */ + /** + * Track an outcome event for passive experiment analysis: POSTed to the + * Flaggr app's /api/events/outcomes with the client's apiKey as a bearer, + * on a keepalive request (it outlives the page) when the body fits the + * browser's 64 KiB keepalive budget. Never throws. + * + * In a browser the request is sent at once, and a CORS preflight goes + * first: the returned promise settles after both round trips, so don't + * await it before navigating away (the keepalive request arrives anyway). + * When the page is closing, call it from a `visibilitychange` handler + * (document hidden) rather than `pagehide`: some browsers drop requests + * started in `pagehide` when a tab is closed. + */ trackOutcome(event: OutcomeEvent): Promise; /** Destroy the client and clean up resources */ destroy(): void; diff --git a/packages/sdk/tsup.config.ts b/packages/sdk/tsup.config.ts index 5b29e76..f15b172 100644 --- a/packages/sdk/tsup.config.ts +++ b/packages/sdk/tsup.config.ts @@ -1,4 +1,43 @@ -import { defineConfig } from "tsup"; +import { readFile, writeFile } from "node:fs/promises"; +import path from "node:path"; +import { defineConfig, type Options } from "tsup"; + +type TsupPlugin = NonNullable[number]; + +/** + * Put `"use client";` at the top of the built React entry (react.mjs and + * react.js), so a Server Component can import FlaggrProvider and the hooks: + * the directive makes the module a Client Component boundary. Only that + * entry: `@flaggr/sdk` itself (flag(), createFlaggr) stays usable on the + * server, and the chunks the entries share stay plain modules. + * + * Done once the files are written: tsup's banner applies to every file of a + * build, and its treeshake pass (Rollup) drops module-level directives from + * the source. The line it adds maps to nothing in the source map: a ";" + * opens the map's mappings, so every other line keeps its mapping. + */ +function clientDirectivePlugin(entry: string): TsupPlugin { + const files = new Set([`${entry}.mjs`, `${entry}.js`]); + return { + name: "use-client-directive", + async buildEnd({ writtenFiles }) { + for (const { name } of writtenFiles) { + if (!files.has(path.basename(name))) continue; + const file = path.resolve(name); + const code = await readFile(file, "utf8"); + if (code.startsWith('"use client";')) continue; + await writeFile(file, `"use client";\n${code}`); + try { + const map = JSON.parse(await readFile(`${file}.map`, "utf8")) as { mappings: string }; + map.mappings = `;${map.mappings}`; + await writeFile(`${file}.map`, JSON.stringify(map)); + } catch { + /* no source map for this file */ + } + } + }, + }; +} export default defineConfig([ { @@ -14,6 +53,7 @@ export default defineConfig([ clean: true, treeshake: true, external: ["react", "@opentelemetry/api"], + plugins: [clientDirectivePlugin("react")], }, { // Browser bundle — served via CDN (jsDelivr/unpkg) as a plain