@zudojs/feature-flags 1.2.0 → 1.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -57,8 +57,9 @@ choose.
57
57
  interface FeatureFlag {
58
58
  key: string;
59
59
  enabled: boolean; // the global kill switch
60
- defaultValue: FeatureFlagValue; // used whenever no rule decides
61
- state?: "active" | "archived" | "draft";
60
+ defaultValue: FeatureFlagValue; // used when the flag is on and no rule decides
61
+ offValue?: FeatureFlagValue; // served while the flag is off (see below)
62
+ state?: "active" | "disabled" | "archived" | "draft";
62
63
  visibility?: "client" | "server";
63
64
  rules?: FeatureFlagRule[]; // evaluated in order, first match wins
64
65
  dependencies?: string[]; // other flags that must be on for the same context
@@ -69,6 +70,27 @@ interface FeatureFlag {
69
70
  `expiresAt` may also arrive as an ISO string or a timestamp — a flag loaded
70
71
  from JSON does — and expires the flag just the same.
71
72
 
73
+ ### Off means off
74
+
75
+ A flag is **off** when it is killed (`enabled: false` or `state:
76
+ "disabled"`), a draft, archived, expired, or blocked by a dependency. An off
77
+ flag runs no rules and serves its **off value**:
78
+
79
+ 1. `offValue`, when the flag declares one;
80
+ 2. otherwise `false` for a boolean flag;
81
+ 3. otherwise `defaultValue` (a string, number or object flag has no natural
82
+ "off", so the baseline value is served).
83
+
84
+ So `{ key: "x", enabled: false, defaultValue: true }` evaluates to `false`
85
+ and `isEnabled("x")` is `false`. Before 1.4 an off flag served
86
+ `defaultValue`, which made the kill switch fail open for any flag whose
87
+ default was `true`. For a variant flag, declare the variant to fall back to:
88
+
89
+ ```typescript
90
+ { key: "checkout", enabled: false, defaultValue: "new", offValue: "control",
91
+ rules: [{ type: "variant", variants: [{ key: "new", weight: 50 }, { key: "control", weight: 50 }] }] }
92
+ ```
93
+
72
94
  ## Rules
73
95
 
74
96
  | Type | Matches when |
@@ -131,8 +153,8 @@ dependency is **on for the same context**: the prerequisite is evaluated —
131
153
  state, expiry, rules, rollout, and its own dependencies — and must not be
132
154
  disabled, draft, archived or expired, nor evaluate to `false`, `null` or
133
155
  `undefined`. A prerequisite rolled out to 10% keeps its dependents off for the
134
- other 90%. Otherwise the result is `dependency_disabled` with the declared
135
- default. Cycles resolve to disabled; a shared dependency reached down two
156
+ other 90%. Otherwise the result is `dependency_disabled` with the flag's
157
+ off value. Cycles resolve to disabled; a shared dependency reached down two
136
158
  branches is not a cycle.
137
159
 
138
160
  `evaluateFlag()` on its own has no registry and cannot resolve dependencies,
@@ -171,8 +193,22 @@ remembers a key the provider does not know for `missingFlagTtlMs` (default
171
193
  turn every evaluation into a remote round trip. The memory is dropped on
172
194
  every reload.
173
195
 
196
+ A provider that fails is left alone for `providerCooloffMs` (default 5 s;
197
+ `0` disables it) before it is probed again, so an outage costs one pair of
198
+ calls per window instead of two per evaluation. The first successful call
199
+ closes the window, and `refresh()` always probes.
200
+
174
201
  `createEnvironmentProvider` parses `true`/`false` and numbers; anything else,
175
- including an empty `FEATURE_X=`, stays a string.
202
+ including an empty `FEATURE_X=`, stays a string. Every flag it reads is
203
+ enabled.
204
+
205
+ Keys are the variable name without the prefix, **lower-cased with `_`
206
+ turned into `-`**: `FEATURE_NEW_CHECKOUT=true` is the flag `new-checkout`,
207
+ the same key a memory or remote provider uses, so an environment variable
208
+ overrides that flag inside a composite. `get()` normalises the key it is
209
+ asked for the same way, so `NEW_CHECKOUT` and `new_checkout` also find it.
210
+ Pass `keyFormat: "preserve"` to keep the variable's spelling (`NEW_CHECKOUT`),
211
+ which was the behaviour before 1.4.
176
212
 
177
213
  ## Change propagation
178
214
 
@@ -188,9 +224,9 @@ changes.
188
224
  | -------------------------------- | ------------------------------------------------------ |
189
225
  | Flag not found | `not_found`, value `undefined`; `isEnabled` is `false` |
190
226
  | Flag not found, `throwOnMissing` | throws `FeatureFlagNotFoundError` |
191
- | Flag disabled or draft | `disabled`, the declared default |
192
- | Flag archived or expired | `expired`, the declared default |
193
- | Dependency not satisfied | `dependency_disabled`, the declared default |
227
+ | Flag disabled or draft | `disabled`, the off value; `isEnabled` is `false` |
228
+ | Flag archived or expired | `expired`, the off value; `isEnabled` is `false` |
229
+ | Dependency not satisfied | `dependency_disabled`, the off value |
194
230
  | Provider unreachable | `error`, reported to `onError`; never enabled |
195
231
 
196
232
  "Unreachable" covers both `getAll()` and a `get()` for a flag not yet loaded:
@@ -200,6 +236,12 @@ not throw `FeatureFlagNotFoundError` under `throwOnMissing`.
200
236
  An unreachable store never enables a flag. Set `throwOnProviderError: true`
201
237
  to own the failure yourself instead.
202
238
 
239
+ `snapshot()` and `getAll()` reject with `FeatureFlagProviderError` when the
240
+ flags were never loaded: an empty `Map` cannot be told apart from "no flags
241
+ are configured", and shipping one to a browser turns an outage into every
242
+ flag being off. Once a load has succeeded they keep serving that data, even
243
+ if a later reload fails.
244
+
203
245
  ```typescript
204
246
  const flags = createFeatureFlags({
205
247
  provider,
@@ -25,6 +25,10 @@ export interface EvaluateFlagOptions {
25
25
  /**
26
26
  * Evaluate a feature flag against a context.
27
27
  *
28
+ * A flag that is off — killed, draft, archived, expired, or blocked by a
29
+ * dependency — serves its off value (see {@link offValueOf}): `offValue`
30
+ * when declared, else `false` for a boolean flag, else `defaultValue`.
31
+ *
28
32
  * @param flag - The feature flag definition.
29
33
  * @param context - The evaluation context.
30
34
  * @param options - Facts the caller resolved that this function cannot.
@@ -6,25 +6,7 @@
6
6
  * @module evaluator/evaluator
7
7
  */
8
8
  import { evaluateRule } from "./evaluatorRule.core.js";
9
- /**
10
- * Whether an `expiresAt` lies in the past.
11
- *
12
- * The type says `Date`, but a flag loaded from JSON — which is what every
13
- * remote provider hands over — carries an ISO string, and `"2020-01-01" <
14
- * new Date()` is always `false`. A flag that had expired at the source
15
- * therefore never expired here. Anything `Date` can parse is honoured; a
16
- * value it cannot parse is treated as no expiry.
17
- */
18
- function isExpired(expiresAt) {
19
- if (expiresAt === undefined || expiresAt === null)
20
- return false;
21
- const time = expiresAt instanceof Date
22
- ? expiresAt.getTime()
23
- : typeof expiresAt === "string" || typeof expiresAt === "number"
24
- ? new Date(expiresAt).getTime()
25
- : Number.NaN;
26
- return !Number.isNaN(time) && time < Date.now();
27
- }
9
+ import { gateReason, offValueOf } from "./evaluatorGate.core.js";
28
10
  /** The evaluation reason a matching rule of each type produces. */
29
11
  function reasonFor(type) {
30
12
  switch (type) {
@@ -46,48 +28,22 @@ function reasonFor(type) {
46
28
  /**
47
29
  * Evaluate a feature flag against a context.
48
30
  *
31
+ * A flag that is off — killed, draft, archived, expired, or blocked by a
32
+ * dependency — serves its off value (see {@link offValueOf}): `offValue`
33
+ * when declared, else `false` for a boolean flag, else `defaultValue`.
34
+ *
49
35
  * @param flag - The feature flag definition.
50
36
  * @param context - The evaluation context.
51
37
  * @param options - Facts the caller resolved that this function cannot.
52
38
  * @returns A structured evaluation result.
53
39
  */
54
40
  export function evaluateFlag(flag, context = {}, options = {}) {
55
- if (!flag.enabled) {
56
- return {
57
- key: flag.key,
58
- value: flag.defaultValue,
59
- reason: "disabled",
60
- defaulted: true,
61
- };
62
- }
63
- if (flag.state === "archived" || flag.state === "draft") {
64
- return {
65
- key: flag.key,
66
- value: flag.defaultValue,
67
- reason: flag.state === "archived" ? "expired" : "disabled",
68
- defaulted: true,
69
- };
70
- }
71
- if (isExpired(flag.metadata?.expiresAt)) {
72
- return {
73
- key: flag.key,
74
- value: flag.defaultValue,
75
- reason: "expired",
76
- defaulted: true,
77
- };
78
- }
79
- if (flag.dependencies &&
80
- flag.dependencies.length > 0 &&
81
- options.dependenciesSatisfied !== true) {
82
- // Previously this returned `dependency_disabled` for *every* flag that
83
- // declared a dependency, satisfied or not — so a flag with dependencies
84
- // could never turn on, and the caller's own dependency resolution was
85
- // computed and then discarded.
41
+ const gate = gateReason(flag, options);
42
+ if (gate) {
86
43
  return {
87
44
  key: flag.key,
88
- value: flag.defaultValue,
89
- reason: "dependency_disabled",
90
- matchedRule: undefined,
45
+ value: offValueOf(flag),
46
+ reason: gate,
91
47
  defaulted: true,
92
48
  };
93
49
  }
@@ -0,0 +1,35 @@
1
+ /**
2
+ * Gates that turn a flag off before any rule runs, and the value it serves
3
+ * while it is off.
4
+ *
5
+ * @module evaluator/evaluatorGate.core
6
+ */
7
+ import type { FeatureFlag } from "../featureFlagTypes/featureFlag.interface.js";
8
+ import type { FeatureFlagValue } from "../featureFlagTypes/featureFlagRule/featureFlagValue.type.js";
9
+ import type { FeatureFlagEvaluationReason } from "../featureFlagTypes/featureFlagEvaluation.js";
10
+ import type { EvaluateFlagOptions } from "./evaluator.core.js";
11
+ /**
12
+ * Why a flag is off before any rule runs, or `undefined` when it is live.
13
+ *
14
+ * Checked in order: the kill switch (`enabled: false`, `state: "disabled"`),
15
+ * a draft, an archived or expired flag, then unsatisfied dependencies.
16
+ */
17
+ export declare function gateReason(flag: FeatureFlag, options: EvaluateFlagOptions): FeatureFlagEvaluationReason | undefined;
18
+ /**
19
+ * Resolve what a flag serves when it is off — killed (`enabled: false` or
20
+ * `state: "disabled"`), a draft, archived, expired, or blocked by a
21
+ * dependency.
22
+ *
23
+ * 1. `flag.offValue`, when the flag declares one.
24
+ * 2. `false`, for a boolean flag. The kill switch must turn a feature off,
25
+ * and serving `defaultValue: true` from a killed flag left it on.
26
+ * 3. `flag.defaultValue` otherwise. A string, number or object flag has no
27
+ * natural "off", so the declared default — the baseline experience — is
28
+ * the fallback. `isEnabled()` is `false` for these regardless, because it
29
+ * is `true` only for the boolean `true`.
30
+ *
31
+ * @param flag - The flag that is off.
32
+ * @returns The value to serve.
33
+ */
34
+ export declare function offValueOf(flag: FeatureFlag): FeatureFlagValue;
35
+ //# sourceMappingURL=evaluatorGate.core.d.ts.map
@@ -0,0 +1,68 @@
1
+ /**
2
+ * Gates that turn a flag off before any rule runs, and the value it serves
3
+ * while it is off.
4
+ *
5
+ * @module evaluator/evaluatorGate.core
6
+ */
7
+ /**
8
+ * Whether an `expiresAt` lies in the past.
9
+ *
10
+ * The type says `Date`, but a flag loaded from JSON — which is what every
11
+ * remote provider hands over — carries an ISO string, and `"2020-01-01" <
12
+ * new Date()` is always `false`. A flag that had expired at the source
13
+ * therefore never expired here. Anything `Date` can parse is honoured; a
14
+ * value it cannot parse is treated as no expiry.
15
+ */
16
+ function isExpired(expiresAt) {
17
+ if (expiresAt === undefined || expiresAt === null)
18
+ return false;
19
+ const time = expiresAt instanceof Date
20
+ ? expiresAt.getTime()
21
+ : typeof expiresAt === "string" || typeof expiresAt === "number"
22
+ ? new Date(expiresAt).getTime()
23
+ : Number.NaN;
24
+ return !Number.isNaN(time) && time < Date.now();
25
+ }
26
+ /**
27
+ * Why a flag is off before any rule runs, or `undefined` when it is live.
28
+ *
29
+ * Checked in order: the kill switch (`enabled: false`, `state: "disabled"`),
30
+ * a draft, an archived or expired flag, then unsatisfied dependencies.
31
+ */
32
+ export function gateReason(flag, options) {
33
+ if (!flag.enabled || flag.state === "disabled" || flag.state === "draft") {
34
+ return "disabled";
35
+ }
36
+ if (flag.state === "archived" || isExpired(flag.metadata?.expiresAt)) {
37
+ return "expired";
38
+ }
39
+ const hasDependencies = !!flag.dependencies && flag.dependencies.length > 0;
40
+ if (hasDependencies && options.dependenciesSatisfied !== true) {
41
+ return "dependency_disabled";
42
+ }
43
+ return undefined;
44
+ }
45
+ /**
46
+ * Resolve what a flag serves when it is off — killed (`enabled: false` or
47
+ * `state: "disabled"`), a draft, archived, expired, or blocked by a
48
+ * dependency.
49
+ *
50
+ * 1. `flag.offValue`, when the flag declares one.
51
+ * 2. `false`, for a boolean flag. The kill switch must turn a feature off,
52
+ * and serving `defaultValue: true` from a killed flag left it on.
53
+ * 3. `flag.defaultValue` otherwise. A string, number or object flag has no
54
+ * natural "off", so the declared default — the baseline experience — is
55
+ * the fallback. `isEnabled()` is `false` for these regardless, because it
56
+ * is `true` only for the boolean `true`.
57
+ *
58
+ * @param flag - The flag that is off.
59
+ * @returns The value to serve.
60
+ */
61
+ export function offValueOf(flag) {
62
+ if (flag.offValue !== undefined)
63
+ return flag.offValue;
64
+ if (typeof flag.defaultValue === "boolean")
65
+ return false;
66
+ return flag.defaultValue;
67
+ }
68
+ //# sourceMappingURL=evaluatorGate.core.js.map
@@ -30,8 +30,17 @@ export interface FeatureFlag {
30
30
  readonly key: string;
31
31
  /** Default value when no rule matches. */
32
32
  readonly defaultValue: FeatureFlagValue;
33
- /** Whether the flag is globally enabled. */
33
+ /**
34
+ * Whether the flag is globally enabled — the kill switch. A flag with
35
+ * `enabled: false` serves its off value, never `defaultValue: true`.
36
+ */
34
37
  readonly enabled: boolean;
38
+ /**
39
+ * What the flag serves while it is off: killed, draft, archived, expired,
40
+ * or blocked by a dependency. Default: `false` for a boolean flag, the
41
+ * `defaultValue` for any other.
42
+ */
43
+ readonly offValue?: FeatureFlagValue;
35
44
  /** Human-readable description. */
36
45
  readonly description?: string;
37
46
  /** Lifecycle state. */
@@ -0,0 +1,27 @@
1
+ /**
2
+ * Cool-off window for a provider that is failing.
3
+ *
4
+ * @module featureFlags/featureFlags.cooloff
5
+ */
6
+ /** Default cool-off after a provider failure, in ms. */
7
+ export declare const DEFAULT_PROVIDER_COOLOFF_MS = 5000;
8
+ /** Tracks whether a failing provider should be probed again yet. */
9
+ export interface ProviderCooloff {
10
+ /** Whether the provider is inside its cool-off window and must not be called. */
11
+ active(): boolean;
12
+ /** Records a failed provider call, opening a new window. */
13
+ recordFailure(): void;
14
+ /** Records a successful provider call, closing any open window. */
15
+ recordSuccess(): void;
16
+ }
17
+ /**
18
+ * Create a cool-off gate.
19
+ *
20
+ * Without one, every evaluation against an unreachable store re-ran
21
+ * `getAll()` and `get(key)` — two remote round trips per call, each one
22
+ * waiting out its own timeout, for as long as the outage lasted. After a
23
+ * failure the provider is probed at most once per `cooloffMs`; a success
24
+ * clears the window immediately. `cooloffMs <= 0` disables the gate.
25
+ */
26
+ export declare function createProviderCooloff(cooloffMs: number): ProviderCooloff;
27
+ //# sourceMappingURL=featureFlags.cooloff.d.ts.map
@@ -0,0 +1,34 @@
1
+ /**
2
+ * Cool-off window for a provider that is failing.
3
+ *
4
+ * @module featureFlags/featureFlags.cooloff
5
+ */
6
+ /** Default cool-off after a provider failure, in ms. */
7
+ export const DEFAULT_PROVIDER_COOLOFF_MS = 5_000;
8
+ /**
9
+ * Create a cool-off gate.
10
+ *
11
+ * Without one, every evaluation against an unreachable store re-ran
12
+ * `getAll()` and `get(key)` — two remote round trips per call, each one
13
+ * waiting out its own timeout, for as long as the outage lasted. After a
14
+ * failure the provider is probed at most once per `cooloffMs`; a success
15
+ * clears the window immediately. `cooloffMs <= 0` disables the gate.
16
+ */
17
+ export function createProviderCooloff(cooloffMs) {
18
+ const window = Number.isFinite(cooloffMs) ? Math.max(0, cooloffMs) : 0;
19
+ let until = 0;
20
+ return {
21
+ active() {
22
+ if (window === 0)
23
+ return false;
24
+ return Date.now() < until;
25
+ },
26
+ recordFailure() {
27
+ until = Date.now() + window;
28
+ },
29
+ recordSuccess() {
30
+ until = 0;
31
+ },
32
+ };
33
+ }
34
+ //# sourceMappingURL=featureFlags.cooloff.js.map
@@ -41,6 +41,12 @@ export interface FeatureFlagsOptions {
41
41
  * reload (`refresh()`, a provider change notification).
42
42
  */
43
43
  readonly missingFlagTtlMs?: number;
44
+ /**
45
+ * How long a failing provider is left alone before it is probed again, in
46
+ * ms. Default: 5,000. `0` calls the provider on every evaluation, as
47
+ * before. A successful call clears the window at once.
48
+ */
49
+ readonly providerCooloffMs?: number;
44
50
  }
45
51
  /** The public FeatureFlags API. */
46
52
  export interface FeatureFlags {
@@ -10,6 +10,7 @@ import { evaluateFlag } from "../evaluator/evaluator.core.js";
10
10
  import { FeatureFlagNotFoundError, FeatureFlagProviderError, } from "../featureFlagErrors/featureFlagError.types.js";
11
11
  import { resolveDependencies, mergeContext } from "./featureFlags.resolve.js";
12
12
  import { createMissCache, DEFAULT_MISSING_FLAG_TTL_MS, } from "./featureFlags.missCache.js";
13
+ import { createProviderCooloff, DEFAULT_PROVIDER_COOLOFF_MS, } from "./featureFlags.cooloff.js";
13
14
  /**
14
15
  * Create a FeatureFlags instance.
15
16
  *
@@ -17,8 +18,9 @@ import { createMissCache, DEFAULT_MISSING_FLAG_TTL_MS, } from "./featureFlags.mi
17
18
  * @returns A FeatureFlags API object.
18
19
  */
19
20
  export function createFeatureFlags(options) {
20
- const { provider, defaultContext = {}, throwOnMissing = false, onError, throwOnProviderError = false, missingFlagTtlMs = DEFAULT_MISSING_FLAG_TTL_MS, } = options;
21
+ const { provider, defaultContext = {}, throwOnMissing = false, onError, throwOnProviderError = false, missingFlagTtlMs = DEFAULT_MISSING_FLAG_TTL_MS, providerCooloffMs = DEFAULT_PROVIDER_COOLOFF_MS, } = options;
21
22
  const misses = createMissCache(missingFlagTtlMs);
23
+ const cooloff = createProviderCooloff(providerCooloffMs);
22
24
  let registry = createFeatureFlagRegistry();
23
25
  let loaded = false;
24
26
  /**
@@ -27,6 +29,7 @@ export function createFeatureFlags(options) {
27
29
  * @returns `false` when the failure was contained.
28
30
  */
29
31
  function handleProviderError(error, source) {
32
+ cooloff.recordFailure();
30
33
  if (throwOnProviderError)
31
34
  throw error;
32
35
  onError?.(error instanceof Error
@@ -42,6 +45,7 @@ export function createFeatureFlags(options) {
42
45
  registry = createFeatureFlagRegistry(flags);
43
46
  loaded = true;
44
47
  misses.clear();
48
+ cooloff.recordSuccess();
45
49
  return true;
46
50
  }
47
51
  catch (error) {
@@ -54,20 +58,42 @@ export function createFeatureFlags(options) {
54
58
  // every single evaluation.
55
59
  if (loaded)
56
60
  return true;
61
+ // A provider that just failed is left alone until its window closes,
62
+ // rather than being re-queried (and waited on) by every evaluation.
63
+ if (cooloff.active())
64
+ return false;
57
65
  return load();
58
66
  }
67
+ /**
68
+ * Fails a bulk read that has no flags because the store could not be read.
69
+ *
70
+ * `evaluate()` can say `reason: "error"` per flag; a `Map` or an array has
71
+ * nowhere to put that, and an empty one is indistinguishable from "no flags
72
+ * are configured" — a total outage would otherwise ship to a browser as
73
+ * every flag being off. `onError` still sees the underlying failure first.
74
+ *
75
+ * @throws {FeatureFlagProviderError} When the flags were never loaded.
76
+ */
77
+ function requireAvailable(available, operation) {
78
+ if (available)
79
+ return;
80
+ throw new FeatureFlagProviderError(`Feature flags are unavailable: ${operation}() cannot report flags because the provider could not be loaded.`, { provider: "FeatureFlagProvider.getAll" });
81
+ }
59
82
  async function resolveFlag(key) {
60
83
  const known = registry.get(key);
61
84
  if (known)
62
85
  return { flag: known, reachable: true };
63
86
  if (misses.has(key))
64
87
  return { flag: undefined, reachable: true };
88
+ if (cooloff.active())
89
+ return { flag: undefined, reachable: false };
65
90
  try {
66
91
  const flag = await provider.get(key);
67
92
  if (flag)
68
93
  registry.set(flag);
69
94
  else if (loaded)
70
95
  misses.add(key);
96
+ cooloff.recordSuccess();
71
97
  return { flag, reachable: true };
72
98
  }
73
99
  catch (error) {
@@ -86,6 +112,7 @@ export function createFeatureFlags(options) {
86
112
  registry = createFeatureFlagRegistry(flags);
87
113
  loaded = true;
88
114
  misses.clear();
115
+ cooloff.recordSuccess();
89
116
  });
90
117
  const api = {
91
118
  async isEnabled(key, context) {
@@ -136,7 +163,7 @@ export function createFeatureFlags(options) {
136
163
  return evaluateFlag(flag, mergedCtx, { dependenciesSatisfied });
137
164
  },
138
165
  async snapshot(context) {
139
- await ensureLoaded();
166
+ requireAvailable(await ensureLoaded(), "snapshot");
140
167
  const mergedCtx = mergeContext(defaultContext, context);
141
168
  const flags = registry.getAll();
142
169
  const results = new Map();
@@ -163,7 +190,7 @@ export function createFeatureFlags(options) {
163
190
  await load();
164
191
  },
165
192
  async getAll() {
166
- await ensureLoaded();
193
+ requireAvailable(await ensureLoaded(), "getAll");
167
194
  return registry.getAll();
168
195
  },
169
196
  close() {
@@ -3,7 +3,7 @@
3
3
  *
4
4
  * Reads feature flags from environment variables with a configurable prefix.
5
5
  *
6
- * Example: FEATURE_NEW_UI=true → key "NEW_UI" with value true.
6
+ * Example: FEATURE_NEW_UI=true → key "new-ui" with value true.
7
7
  *
8
8
  * @module provider/providerEnvironment
9
9
  */
@@ -14,7 +14,29 @@ export interface EnvironmentProviderOptions {
14
14
  readonly prefix?: string;
15
15
  /** Process env object to read from (default: process.env). */
16
16
  readonly env?: Readonly<Record<string, string | undefined>>;
17
+ /**
18
+ * How a variable name becomes a flag key, once the prefix is removed.
19
+ *
20
+ * - `"kebab"` (default): lower-cased, `_` becomes `-`, so
21
+ * `FEATURE_NEW_CHECKOUT` is the flag `new-checkout` — the same key a
22
+ * memory or remote provider would use.
23
+ * - `"preserve"`: the name as written (`NEW_CHECKOUT`), the behaviour
24
+ * before 1.4.
25
+ *
26
+ * With `"kebab"`, `get()` normalises the key it is asked for the same way,
27
+ * so `get("NEW_CHECKOUT")`, `get("new_checkout")` and
28
+ * `get("new-checkout")` all find the flag.
29
+ */
30
+ readonly keyFormat?: "kebab" | "preserve";
17
31
  }
32
+ /**
33
+ * Normalise an environment-derived key to kebab case: `NEW_CHECKOUT` and
34
+ * `new_checkout` both become `new-checkout`.
35
+ *
36
+ * @param key - The key, with the prefix already removed.
37
+ * @returns The lower-cased key with underscores replaced by hyphens.
38
+ */
39
+ export declare function toEnvironmentFlagKey(key: string): string;
18
40
  /**
19
41
  * Create an environment-variable feature flag provider.
20
42
  *
@@ -3,10 +3,20 @@
3
3
  *
4
4
  * Reads feature flags from environment variables with a configurable prefix.
5
5
  *
6
- * Example: FEATURE_NEW_UI=true → key "NEW_UI" with value true.
6
+ * Example: FEATURE_NEW_UI=true → key "new-ui" with value true.
7
7
  *
8
8
  * @module provider/providerEnvironment
9
9
  */
10
+ /**
11
+ * Normalise an environment-derived key to kebab case: `NEW_CHECKOUT` and
12
+ * `new_checkout` both become `new-checkout`.
13
+ *
14
+ * @param key - The key, with the prefix already removed.
15
+ * @returns The lower-cased key with underscores replaced by hyphens.
16
+ */
17
+ export function toEnvironmentFlagKey(key) {
18
+ return key.toLowerCase().replace(/_/g, "-");
19
+ }
10
20
  /**
11
21
  * Parse a string value into a feature flag value.
12
22
  */
@@ -33,12 +43,15 @@ function parseEnvValue(raw) {
33
43
  */
34
44
  export function createEnvironmentProvider(options = {}) {
35
45
  const prefix = options.prefix ?? "FEATURE_";
46
+ const normalise = options.keyFormat === "preserve"
47
+ ? (key) => key
48
+ : toEnvironmentFlagKey;
36
49
  const env = options.env ?? (typeof process !== "undefined" ? process.env : {});
37
50
  function readFlags() {
38
51
  const flags = [];
39
52
  for (const [key, value] of Object.entries(env)) {
40
53
  if (key.startsWith(prefix) && value !== undefined) {
41
- const flagKey = key.slice(prefix.length);
54
+ const flagKey = normalise(key.slice(prefix.length));
42
55
  flags.push({
43
56
  key: flagKey,
44
57
  enabled: true,
@@ -53,7 +66,8 @@ export function createEnvironmentProvider(options = {}) {
53
66
  async get(key) {
54
67
  if (!cached)
55
68
  cached = readFlags();
56
- return cached.find((f) => f.key === key);
69
+ const wanted = normalise(key);
70
+ return cached.find((f) => f.key === wanted);
57
71
  },
58
72
  async getAll() {
59
73
  if (!cached)
@@ -13,6 +13,15 @@ import type { FeatureFlagValue } from "../featureFlagTypes/featureFlagRule/featu
13
13
  export { isPlainObject } from "@zudojs/types";
14
14
  /**
15
15
  * Check if two feature flag values are equal.
16
+ *
17
+ * The comparison is structural, not `JSON.stringify`: key order does not
18
+ * matter, a key whose value is `undefined` is not the same as an absent key,
19
+ * and a self-referencing value is compared instead of throwing. Arrays match
20
+ * element-wise, `Date`s by instant, and `NaN` equals `NaN`. Any other class
21
+ * instance (Map, Set, RegExp, …) matches only by reference.
22
+ *
23
+ * `@zudojs/types` owns the shared type guards but has no deep-equality helper,
24
+ * so the walk lives here rather than pulling in a new dependency.
16
25
  */
17
26
  export declare function valuesEqual(a: FeatureFlagValue, b: FeatureFlagValue): boolean;
18
27
  //# sourceMappingURL=utils.helper.d.ts.map
@@ -12,17 +12,69 @@
12
12
  export { isPlainObject } from "@zudojs/types";
13
13
  /**
14
14
  * Check if two feature flag values are equal.
15
+ *
16
+ * The comparison is structural, not `JSON.stringify`: key order does not
17
+ * matter, a key whose value is `undefined` is not the same as an absent key,
18
+ * and a self-referencing value is compared instead of throwing. Arrays match
19
+ * element-wise, `Date`s by instant, and `NaN` equals `NaN`. Any other class
20
+ * instance (Map, Set, RegExp, …) matches only by reference.
21
+ *
22
+ * `@zudojs/types` owns the shared type guards but has no deep-equality helper,
23
+ * so the walk lives here rather than pulling in a new dependency.
15
24
  */
16
25
  export function valuesEqual(a, b) {
26
+ return deepEqual(a, b, new Map());
27
+ }
28
+ function deepEqual(a, b, seen) {
17
29
  if (a === b)
18
30
  return true;
19
- if (a === null || b === null)
31
+ if (typeof a === "number" && typeof b === "number") {
32
+ return Number.isNaN(a) && Number.isNaN(b);
33
+ }
34
+ if (typeof a !== "object" || typeof b !== "object")
20
35
  return false;
21
- if (typeof a !== typeof b)
36
+ if (a === null || b === null)
22
37
  return false;
23
- if (typeof a === "object" && typeof b === "object") {
24
- return JSON.stringify(a) === JSON.stringify(b);
38
+ // A pair already on the comparison stack is assumed equal: the cycle it
39
+ // closes is only reached through positions that matched.
40
+ const pending = seen.get(a);
41
+ if (pending?.has(b) === true)
42
+ return true;
43
+ if (pending)
44
+ pending.add(b);
45
+ else
46
+ seen.set(a, new Set([b]));
47
+ try {
48
+ return deepEqualObjects(a, b, seen);
25
49
  }
26
- return false;
50
+ finally {
51
+ seen.get(a)?.delete(b);
52
+ }
53
+ }
54
+ function deepEqualObjects(a, b, seen) {
55
+ if (a instanceof Date || b instanceof Date) {
56
+ return (a instanceof Date && b instanceof Date && a.getTime() === b.getTime());
57
+ }
58
+ if (Array.isArray(a) || Array.isArray(b)) {
59
+ if (!Array.isArray(a) || !Array.isArray(b))
60
+ return false;
61
+ if (a.length !== b.length)
62
+ return false;
63
+ return a.every((item, index) => deepEqual(item, b[index], seen));
64
+ }
65
+ if (!isComparableObject(a) || !isComparableObject(b))
66
+ return false;
67
+ const aKeys = Object.keys(a);
68
+ const bKeys = Object.keys(b);
69
+ if (aKeys.length !== bKeys.length)
70
+ return false;
71
+ const other = b;
72
+ return aKeys.every((key) => Object.hasOwn(other, key) &&
73
+ deepEqual(a[key], other[key], seen));
74
+ }
75
+ /** Plain objects and null-prototype records; anything else compares by reference. */
76
+ function isComparableObject(value) {
77
+ const proto = Object.getPrototypeOf(value);
78
+ return proto === Object.prototype || proto === null;
27
79
  }
28
80
  //# sourceMappingURL=utils.helper.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zudojs/feature-flags",
3
- "version": "1.2.0",
3
+ "version": "1.4.0",
4
4
  "description": "Feature flag system with deterministic rollouts, rule engine, providers, variants, snapshots, and evaluation context.",
5
5
  "main": "./dist/index.js",
6
6
  "types": "./dist/index.d.ts",
@@ -11,12 +11,12 @@
11
11
  }
12
12
  },
13
13
  "dependencies": {
14
- "@zudojs/errors": "1.1.0",
15
- "@zudojs/types": "1.1.0"
14
+ "@zudojs/errors": "1.3.0",
15
+ "@zudojs/types": "1.2.0"
16
16
  },
17
17
  "devDependencies": {
18
18
  "typescript": "7.0.2",
19
- "vitest": "^4.1.11"
19
+ "vitest": "^5.0.1"
20
20
  },
21
21
  "license": "MIT",
22
22
  "author": {
@@ -43,7 +43,7 @@
43
43
  "rollout",
44
44
  "a-b-testing"
45
45
  ],
46
- "homepage": "https://github.com/oyinlola-tech/zudo#readme",
46
+ "homepage": "https://zudojs.oyinlola.site/docs/packages-feature-flags",
47
47
  "bugs": {
48
48
  "url": "https://github.com/oyinlola-tech/zudo/issues"
49
49
  },