@zudojs/feature-flags 0.1.0 → 1.1.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.
Files changed (86) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +171 -13
  3. package/dist/evaluator/evaluator.core.d.ts +15 -1
  4. package/dist/evaluator/evaluator.core.js +48 -10
  5. package/dist/evaluator/evaluatorAttribute.d.ts +5 -0
  6. package/dist/evaluator/evaluatorAttribute.js +62 -4
  7. package/dist/evaluator/evaluatorRule.core.js +32 -7
  8. package/dist/featureFlagTypes/featureFlagProvider.d.ts +10 -0
  9. package/dist/featureFlagTypes/index.d.ts +1 -1
  10. package/dist/featureFlags/featureFlags.core.d.ts +28 -8
  11. package/dist/featureFlags/featureFlags.core.js +101 -34
  12. package/dist/featureFlags/featureFlags.resolve.d.ts +4 -3
  13. package/dist/featureFlags/featureFlags.resolve.js +29 -12
  14. package/dist/index.d.ts +4 -1
  15. package/dist/index.js +1 -0
  16. package/dist/provider/providerCached.core.d.ts +3 -3
  17. package/dist/provider/providerCached.core.js +22 -3
  18. package/dist/provider/providerComposite.core.d.ts +2 -2
  19. package/dist/provider/providerComposite.core.js +62 -14
  20. package/dist/provider/providerEnvironment.core.d.ts +3 -3
  21. package/dist/provider/providerEnvironment.core.js +9 -4
  22. package/dist/provider/providerMemory.core.d.ts +18 -2
  23. package/dist/provider/providerMemory.core.js +38 -4
  24. package/package.json +26 -14
  25. package/dist/evaluator/evaluator.core.d.ts.map +0 -1
  26. package/dist/evaluator/evaluator.core.js.map +0 -1
  27. package/dist/evaluator/evaluatorAttribute.d.ts.map +0 -1
  28. package/dist/evaluator/evaluatorAttribute.js.map +0 -1
  29. package/dist/evaluator/evaluatorRule.core.d.ts.map +0 -1
  30. package/dist/evaluator/evaluatorRule.core.js.map +0 -1
  31. package/dist/evaluator/index.d.ts.map +0 -1
  32. package/dist/evaluator/index.js.map +0 -1
  33. package/dist/featureFlagErrors/featureFlagError.base.d.ts.map +0 -1
  34. package/dist/featureFlagErrors/featureFlagError.base.js.map +0 -1
  35. package/dist/featureFlagErrors/featureFlagError.types.d.ts.map +0 -1
  36. package/dist/featureFlagErrors/featureFlagError.types.js.map +0 -1
  37. package/dist/featureFlagErrors/index.d.ts.map +0 -1
  38. package/dist/featureFlagErrors/index.js.map +0 -1
  39. package/dist/featureFlagTypes/featureFlag.interface.d.ts.map +0 -1
  40. package/dist/featureFlagTypes/featureFlag.interface.js.map +0 -1
  41. package/dist/featureFlagTypes/featureFlagContext.d.ts.map +0 -1
  42. package/dist/featureFlagTypes/featureFlagContext.js.map +0 -1
  43. package/dist/featureFlagTypes/featureFlagEvaluation.d.ts.map +0 -1
  44. package/dist/featureFlagTypes/featureFlagEvaluation.js.map +0 -1
  45. package/dist/featureFlagTypes/featureFlagProvider.d.ts.map +0 -1
  46. package/dist/featureFlagTypes/featureFlagProvider.js.map +0 -1
  47. package/dist/featureFlagTypes/featureFlagRule/featureFlagRule.type.d.ts.map +0 -1
  48. package/dist/featureFlagTypes/featureFlagRule/featureFlagRule.type.js.map +0 -1
  49. package/dist/featureFlagTypes/featureFlagRule/featureFlagValue.type.d.ts.map +0 -1
  50. package/dist/featureFlagTypes/featureFlagRule/featureFlagValue.type.js.map +0 -1
  51. package/dist/featureFlagTypes/featureFlagRule/index.d.ts.map +0 -1
  52. package/dist/featureFlagTypes/featureFlagRule/index.js.map +0 -1
  53. package/dist/featureFlagTypes/index.d.ts.map +0 -1
  54. package/dist/featureFlagTypes/index.js.map +0 -1
  55. package/dist/featureFlags/featureFlags.core.d.ts.map +0 -1
  56. package/dist/featureFlags/featureFlags.core.js.map +0 -1
  57. package/dist/featureFlags/featureFlags.resolve.d.ts.map +0 -1
  58. package/dist/featureFlags/featureFlags.resolve.js.map +0 -1
  59. package/dist/featureFlags/index.d.ts.map +0 -1
  60. package/dist/featureFlags/index.js.map +0 -1
  61. package/dist/index.d.ts.map +0 -1
  62. package/dist/index.js.map +0 -1
  63. package/dist/provider/index.d.ts.map +0 -1
  64. package/dist/provider/index.js.map +0 -1
  65. package/dist/provider/providerCached.core.d.ts.map +0 -1
  66. package/dist/provider/providerCached.core.js.map +0 -1
  67. package/dist/provider/providerComposite.core.d.ts.map +0 -1
  68. package/dist/provider/providerComposite.core.js.map +0 -1
  69. package/dist/provider/providerEnvironment.core.d.ts.map +0 -1
  70. package/dist/provider/providerEnvironment.core.js.map +0 -1
  71. package/dist/provider/providerMemory.core.d.ts.map +0 -1
  72. package/dist/provider/providerMemory.core.js.map +0 -1
  73. package/dist/registry/index.d.ts.map +0 -1
  74. package/dist/registry/index.js.map +0 -1
  75. package/dist/registry/registry.core.d.ts.map +0 -1
  76. package/dist/registry/registry.core.js.map +0 -1
  77. package/dist/rollout/index.d.ts.map +0 -1
  78. package/dist/rollout/index.js.map +0 -1
  79. package/dist/rollout/rolloutBucketing.d.ts.map +0 -1
  80. package/dist/rollout/rolloutBucketing.js.map +0 -1
  81. package/dist/rollout/rolloutHashing.d.ts.map +0 -1
  82. package/dist/rollout/rolloutHashing.js.map +0 -1
  83. package/dist/utils/index.d.ts.map +0 -1
  84. package/dist/utils/index.js.map +0 -1
  85. package/dist/utils/utils.helper.d.ts.map +0 -1
  86. package/dist/utils/utils.helper.js.map +0 -1
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Zudojs Contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -10,27 +10,185 @@ npm install @zudojs/feature-flags
10
10
 
11
11
  ## Quick Start
12
12
 
13
+ Flags come from a **provider**; `createFeatureFlags` evaluates them against a
14
+ context.
15
+
13
16
  ```typescript
14
- import { createFeatureFlags } from "@zudojs/feature-flags";
17
+ import {
18
+ createFeatureFlags,
19
+ createMemoryProvider,
20
+ } from "@zudojs/feature-flags";
15
21
 
16
22
  const flags = createFeatureFlags({
17
- rules: [
18
- { key: "new-ui", rollout: 0.1 },
19
- { key: "beta-feature", users: ["user-123"] },
20
- ],
23
+ provider: createMemoryProvider([
24
+ {
25
+ key: "new-ui",
26
+ enabled: true,
27
+ defaultValue: false,
28
+ rules: [{ type: "percentage", percentage: 10, value: true }],
29
+ },
30
+ {
31
+ key: "beta-feature",
32
+ enabled: true,
33
+ defaultValue: false,
34
+ rules: [{ type: "user", users: ["user-123"], value: true }],
35
+ },
36
+ ]),
21
37
  });
22
38
 
23
- const enabled = await flags.isEnabled("new-ui", { userId: "user-456" });
39
+ await flags.isEnabled("beta-feature", { userId: "user-123" }); // true
40
+ await flags.isEnabled("new-ui", { userId: "user-456" }); // stable per user
41
+ ```
42
+
43
+ `isEnabled` is strictly boolean: a flag whose value is a string, a number or
44
+ an object reports `false`. Use `get()` for a typed value, or `getBoolean(key,
45
+ fallback)` when a missing or unreachable flag should fall back to a value you
46
+ choose.
47
+
48
+ ## Flag definition
49
+
50
+ ```typescript
51
+ interface FeatureFlag {
52
+ key: string;
53
+ enabled: boolean; // the global kill switch
54
+ defaultValue: FeatureFlagValue; // used whenever no rule decides
55
+ state?: "active" | "archived" | "draft";
56
+ visibility?: "client" | "server";
57
+ rules?: FeatureFlagRule[]; // evaluated in order, first match wins
58
+ dependencies?: string[]; // other flags that must be enabled
59
+ metadata?: { expiresAt?: Date /* … */ };
60
+ }
61
+ ```
62
+
63
+ `expiresAt` may also arrive as an ISO string or a timestamp — a flag loaded
64
+ from JSON does — and expires the flag just the same.
65
+
66
+ ## Rules
67
+
68
+ | Type | Matches when |
69
+ | ------------ | ----------------------------------------------- |
70
+ | `static` | always |
71
+ | `user` | `context.userId` is in `users` |
72
+ | `tenant` | `context.tenantId` is in `tenants` |
73
+ | `attribute` | `attribute` compared to `value` with `operator` |
74
+ | `percentage` | the subject's bucket falls inside `percentage` |
75
+ | `schedule` | now is between `startAt` and `endAt` |
76
+ | `variant` | always, assigning a variant by weight |
77
+
78
+ Rules are evaluated in declaration order and the first match wins. The
79
+ subject for `percentage` and `variant` is `userId`, then `tenantId`, then
80
+ `sessionId`, then `"anonymous"`.
81
+
82
+ Operators: `equals`, `not_equals`, `contains`, `starts_with`, `ends_with`,
83
+ `in`, `not_in`, `greater_than`, `greater_than_or_equal`, `less_than`,
84
+ `less_than_or_equal`, `exists`, `matches`.
85
+
86
+ Attribute paths use dot notation and are resolved against the context first,
87
+ then `context.attributes`. Only **own** properties are traversed:
88
+ `__proto__`, `constructor` and `prototype` never resolve, so a rule cannot
89
+ accidentally (or deliberately) target everyone through the prototype chain. A
90
+ `matches` pattern that does not compile, or is longer than 512 characters,
91
+ matches nothing instead of throwing.
92
+
93
+ ## Rollouts and variants
94
+
95
+ ```typescript
96
+ import { getBucket, isInRollout, hashString } from "@zudojs/feature-flags";
24
97
  ```
25
98
 
26
- ## Features
99
+ Bucketing is deterministic: `hash(flagKey + ":" + subject)` over 10,000
100
+ buckets, so the same subject always lands in the same bucket for the same
101
+ flag, and raising a percentage never removes anyone already inside it.
102
+ Variant weights are applied over those same buckets, so a 90/10 split really
103
+ is 90/10.
104
+
105
+ ## Dependencies
106
+
107
+ A flag may declare `dependencies`. It evaluates normally only when every
108
+ dependency — transitively — exists and is enabled; otherwise the result is
109
+ `dependency_disabled` with the declared default. Cycles resolve to disabled;
110
+ a shared dependency reached down two branches is not a cycle.
111
+
112
+ `evaluateFlag()` on its own has no registry and cannot resolve dependencies,
113
+ so it reports `dependency_disabled` for any flag that declares them unless
114
+ the caller passes `{ dependenciesSatisfied: true }`.
115
+
116
+ ## Providers
117
+
118
+ ```typescript
119
+ import {
120
+ createMemoryProvider,
121
+ createEnvironmentProvider,
122
+ createCompositeProvider,
123
+ createCachedProvider,
124
+ } from "@zudojs/feature-flags";
125
+
126
+ const provider = createCachedProvider(
127
+ createCompositeProvider([
128
+ createEnvironmentProvider({ prefix: "FEATURE_" }),
129
+ remoteProvider,
130
+ ]),
131
+ { ttl: 30_000 },
132
+ );
133
+ ```
134
+
135
+ `createMemoryProvider` returns a typed provider with `set`, `delete` and
136
+ `setAll`, and it announces every change to subscribers. `createCachedProvider`
137
+ and `createCompositeProvider` forward those announcements — the cache is
138
+ dropped first, and the composite announces its merged view — so the stack
139
+ above still propagates a change made to `remoteProvider`.
140
+
141
+ `createEnvironmentProvider` parses `true`/`false` and numbers; anything else,
142
+ including an empty `FEATURE_X=`, stays a string.
143
+
144
+ ## Change propagation
145
+
146
+ `createFeatureFlags` subscribes to the provider when it offers `subscribe()`,
147
+ so a flag flipped at the source reaches an already-loaded instance without a
148
+ manual `refresh()`. Call `flags.close()` to unsubscribe, and
149
+ `flags.refresh()` to reload explicitly from a provider that cannot announce
150
+ changes.
151
+
152
+ ## Failure behaviour
153
+
154
+ | Situation | Result |
155
+ | -------------------------------- | ------------------------------------------------------ |
156
+ | Flag not found | `not_found`, value `undefined`; `isEnabled` is `false` |
157
+ | Flag not found, `throwOnMissing` | throws `FeatureFlagNotFoundError` |
158
+ | Flag disabled or draft | `disabled`, the declared default |
159
+ | Flag archived or expired | `expired`, the declared default |
160
+ | Dependency not satisfied | `dependency_disabled`, the declared default |
161
+ | Provider unreachable | `error`, reported to `onError`; never enabled |
162
+
163
+ "Unreachable" covers both `getAll()` and a `get()` for a flag not yet loaded:
164
+ a lookup the store could not answer is `error`, never `not_found`, and does
165
+ not throw `FeatureFlagNotFoundError` under `throwOnMissing`.
166
+
167
+ An unreachable store never enables a flag. Set `throwOnProviderError: true`
168
+ to own the failure yourself instead.
169
+
170
+ ```typescript
171
+ const flags = createFeatureFlags({
172
+ provider,
173
+ onError: (error, source) => logger.error({ error, source }, "flag store"),
174
+ });
175
+ ```
176
+
177
+ ## Snapshots
178
+
179
+ ```typescript
180
+ const snapshot = await flags.snapshot({ userId });
181
+ ```
182
+
183
+ Only flags explicitly marked `visibility: "client"` are included — a snapshot
184
+ is shipped to a browser, so a flag that declares no visibility is withheld.
185
+
186
+ ## Errors
27
187
 
28
- - Percentage rollouts
29
- - User and segment targeting
30
- - A/B testing variants
31
- - Rule engine with AND/OR logic
32
- - Snapshot export for client-side flags
33
- - Evaluation context
188
+ `FeatureFlagError` is the base: `FeatureFlagNotFoundError` ·
189
+ `FeatureFlagProviderError` · `FeatureFlagEvaluationError` ·
190
+ `FeatureFlagRuleError` · `FeatureFlagDependencyError` ·
191
+ `FeatureFlagConfigurationError` · `FeatureFlagTypeError`.
34
192
 
35
193
  ## Use Cases
36
194
 
@@ -9,12 +9,26 @@ import type { FeatureFlag } from "../featureFlagTypes/featureFlag.interface.js";
9
9
  import type { FeatureFlagContext } from "../featureFlagTypes/featureFlagContext.js";
10
10
  import type { FeatureFlagValue } from "../featureFlagTypes/featureFlagRule/featureFlagValue.type.js";
11
11
  import type { FeatureFlagEvaluation } from "../featureFlagTypes/featureFlagEvaluation.js";
12
+ /** Options for {@link evaluateFlag}. */
13
+ export interface EvaluateFlagOptions {
14
+ /**
15
+ * Whether the flag's declared dependencies have been resolved and are all
16
+ * enabled.
17
+ *
18
+ * `evaluateFlag` has no registry and cannot resolve a dependency itself, so
19
+ * it defaults to `false` and returns `dependency_disabled` — the safe
20
+ * answer for a flag whose preconditions are unknown. The caller that *can*
21
+ * resolve them (`createFeatureFlags`) passes `true` once it has.
22
+ */
23
+ readonly dependenciesSatisfied?: boolean;
24
+ }
12
25
  /**
13
26
  * Evaluate a feature flag against a context.
14
27
  *
15
28
  * @param flag - The feature flag definition.
16
29
  * @param context - The evaluation context.
30
+ * @param options - Facts the caller resolved that this function cannot.
17
31
  * @returns A structured evaluation result.
18
32
  */
19
- export declare function evaluateFlag<TValue extends FeatureFlagValue = FeatureFlagValue>(flag: FeatureFlag, context?: FeatureFlagContext): FeatureFlagEvaluation<TValue>;
33
+ export declare function evaluateFlag<TValue extends FeatureFlagValue = FeatureFlagValue>(flag: FeatureFlag, context?: FeatureFlagContext, options?: EvaluateFlagOptions): FeatureFlagEvaluation<TValue>;
20
34
  //# sourceMappingURL=evaluator.core.d.ts.map
@@ -6,14 +6,52 @@
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
+ }
28
+ /** The evaluation reason a matching rule of each type produces. */
29
+ function reasonFor(type) {
30
+ switch (type) {
31
+ case "percentage":
32
+ return "percentage_rollout";
33
+ case "variant":
34
+ return "variant_assignment";
35
+ case "static":
36
+ return "static";
37
+ case "user":
38
+ case "tenant":
39
+ // Explicit targeting, which is what `target_match` is for — it was a
40
+ // declared reason nothing ever produced.
41
+ return "target_match";
42
+ default:
43
+ return "rule_match";
44
+ }
45
+ }
9
46
  /**
10
47
  * Evaluate a feature flag against a context.
11
48
  *
12
49
  * @param flag - The feature flag definition.
13
50
  * @param context - The evaluation context.
51
+ * @param options - Facts the caller resolved that this function cannot.
14
52
  * @returns A structured evaluation result.
15
53
  */
16
- export function evaluateFlag(flag, context = {}) {
54
+ export function evaluateFlag(flag, context = {}, options = {}) {
17
55
  if (!flag.enabled) {
18
56
  return {
19
57
  key: flag.key,
@@ -30,7 +68,7 @@ export function evaluateFlag(flag, context = {}) {
30
68
  defaulted: true,
31
69
  };
32
70
  }
33
- if (flag.metadata?.expiresAt && flag.metadata.expiresAt < new Date()) {
71
+ if (isExpired(flag.metadata?.expiresAt)) {
34
72
  return {
35
73
  key: flag.key,
36
74
  value: flag.defaultValue,
@@ -38,7 +76,13 @@ export function evaluateFlag(flag, context = {}) {
38
76
  defaulted: true,
39
77
  };
40
78
  }
41
- if (flag.dependencies && flag.dependencies.length > 0) {
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.
42
86
  return {
43
87
  key: flag.key,
44
88
  value: flag.defaultValue,
@@ -59,13 +103,7 @@ export function evaluateFlag(flag, context = {}) {
59
103
  const rule = flag.rules[i];
60
104
  const result = evaluateRule(rule, context, flag.key);
61
105
  if (result.matched) {
62
- const reason = rule.type === "percentage"
63
- ? "percentage_rollout"
64
- : rule.type === "variant"
65
- ? "variant_assignment"
66
- : rule.type === "static"
67
- ? "static"
68
- : "rule_match";
106
+ const reason = reasonFor(rule.type);
69
107
  return {
70
108
  key: flag.key,
71
109
  value: (result.value ?? flag.defaultValue),
@@ -9,6 +9,11 @@ import type { FeatureFlagOperator } from "../featureFlagTypes/featureFlagRule/fe
9
9
  /**
10
10
  * Safely resolve a dot-notation path from an object.
11
11
  *
12
+ * Only own, enumerable-or-not *own* properties are traversed: `toString`,
13
+ * `constructor` and friends live on the prototype, and resolving them made
14
+ * `{ attribute: "toString", operator: "exists" }` match every context there
15
+ * has ever been — a targeting rule that silently targets everyone.
16
+ *
12
17
  * @param obj - The object to traverse.
13
18
  * @param path - Dot-separated path (e.g. "user.country").
14
19
  * @returns The value at the path, or undefined.
@@ -5,26 +5,79 @@
5
5
  *
6
6
  * @module evaluator/evaluatorAttribute
7
7
  */
8
+ /**
9
+ * Path segments that must never be traversed.
10
+ *
11
+ * Evaluation context attributes come from request data, and a rule's
12
+ * attribute path comes from flag configuration. Either side reaching the
13
+ * prototype chain turns a targeting rule into a universal one.
14
+ */
15
+ const FORBIDDEN_SEGMENTS = new Set(["__proto__", "prototype", "constructor"]);
8
16
  /**
9
17
  * Safely resolve a dot-notation path from an object.
10
18
  *
19
+ * Only own, enumerable-or-not *own* properties are traversed: `toString`,
20
+ * `constructor` and friends live on the prototype, and resolving them made
21
+ * `{ attribute: "toString", operator: "exists" }` match every context there
22
+ * has ever been — a targeting rule that silently targets everyone.
23
+ *
11
24
  * @param obj - The object to traverse.
12
25
  * @param path - Dot-separated path (e.g. "user.country").
13
26
  * @returns The value at the path, or undefined.
14
27
  */
15
28
  export function resolvePath(obj, path) {
29
+ if (path === "")
30
+ return undefined;
16
31
  const parts = path.split(".");
17
32
  let current = obj;
18
33
  for (const part of parts) {
34
+ if (FORBIDDEN_SEGMENTS.has(part))
35
+ return undefined;
19
36
  if (current === null ||
20
37
  current === undefined ||
21
38
  typeof current !== "object") {
22
39
  return undefined;
23
40
  }
41
+ if (!Object.hasOwn(current, part))
42
+ return undefined;
24
43
  current = current[part];
25
44
  }
26
45
  return current;
27
46
  }
47
+ /** Longest pattern accepted by the `matches` operator. */
48
+ const MAX_PATTERN_LENGTH = 512;
49
+ /**
50
+ * Compiled patterns, so a rule evaluated on every request does not recompile
51
+ * its regular expression every time.
52
+ */
53
+ const patternCache = new Map();
54
+ /** Cap on distinct cached patterns, since flag config can change at runtime. */
55
+ const MAX_CACHED_PATTERNS = 256;
56
+ /**
57
+ * Compile a rule pattern, or return `null` when it cannot be used.
58
+ *
59
+ * A pattern that does not compile used to throw out of rule evaluation and
60
+ * out of `isEnabled()` with it. A flag whose configuration is broken must
61
+ * fall back to its default, not take the caller down.
62
+ */
63
+ function compilePattern(pattern) {
64
+ if (pattern.length > MAX_PATTERN_LENGTH)
65
+ return null;
66
+ const cached = patternCache.get(pattern);
67
+ if (cached !== undefined)
68
+ return cached;
69
+ let compiled;
70
+ try {
71
+ compiled = new RegExp(pattern);
72
+ }
73
+ catch {
74
+ compiled = null;
75
+ }
76
+ if (patternCache.size >= MAX_CACHED_PATTERNS)
77
+ patternCache.clear();
78
+ patternCache.set(pattern, compiled);
79
+ return compiled;
80
+ }
28
81
  /**
29
82
  * Evaluate an attribute rule against a context value.
30
83
  *
@@ -54,6 +107,8 @@ export function matchAttribute(actual, operator, expected) {
54
107
  case "in":
55
108
  return Array.isArray(expected) && expected.includes(actual);
56
109
  case "not_in":
110
+ // A malformed rule must not match everyone: `not_in` with a non-array
111
+ // expectation is unusable, so it matches nobody.
57
112
  return Array.isArray(expected) && !expected.includes(actual);
58
113
  case "greater_than":
59
114
  return (typeof actual === "number" &&
@@ -73,10 +128,13 @@ export function matchAttribute(actual, operator, expected) {
73
128
  actual <= expected);
74
129
  case "exists":
75
130
  return actual !== undefined && actual !== null;
76
- case "matches":
77
- return (typeof actual === "string" &&
78
- typeof expected === "string" &&
79
- new RegExp(expected).test(actual));
131
+ case "matches": {
132
+ if (typeof actual !== "string" || typeof expected !== "string") {
133
+ return false;
134
+ }
135
+ const pattern = compilePattern(expected);
136
+ return pattern !== null && pattern.test(actual);
137
+ }
80
138
  default:
81
139
  return false;
82
140
  }
@@ -5,8 +5,10 @@
5
5
  *
6
6
  * @module evaluator/evaluatorRule
7
7
  */
8
- import { isInRollout } from "../rollout/rolloutBucketing.js";
8
+ import { getBucket, isInRollout } from "../rollout/rolloutBucketing.js";
9
9
  import { matchAttribute, resolvePath } from "./evaluatorAttribute.js";
10
+ /** Bucket resolution for variant assignment — 0.01% precision. */
11
+ const VARIANT_BUCKETS = 10_000;
10
12
  /**
11
13
  * Evaluate a single feature flag rule against a context.
12
14
  *
@@ -45,25 +47,48 @@ export function evaluateRule(rule, context, flagKey) {
45
47
  return { matched, value: matched ? rule.value : undefined };
46
48
  }
47
49
  case "schedule": {
48
- const now = Date.now();
49
50
  const start = new Date(rule.startAt).getTime();
50
51
  const end = new Date(rule.endAt).getTime();
52
+ // An unparseable date yields NaN, and every comparison against NaN is
53
+ // false — which happens to fail closed, but only by accident. Say so.
54
+ if (Number.isNaN(start) || Number.isNaN(end))
55
+ return { matched: false };
56
+ const now = Date.now();
51
57
  const matched = now >= start && now <= end;
52
58
  return { matched, value: matched ? rule.value : undefined };
53
59
  }
54
60
  case "variant": {
55
61
  const subject = context.userId ?? context.tenantId ?? context.sessionId ?? "anonymous";
56
- const totalWeight = rule.variants.reduce((sum, v) => sum + v.weight, 0);
62
+ // Negative or non-finite weights would make the cumulative walk
63
+ // non-monotonic, shifting every downstream variant's band, so they are
64
+ // treated as zero.
65
+ const weighted = rule.variants.map((variant) => ({
66
+ key: variant.key,
67
+ weight: Number.isFinite(variant.weight) && variant.weight > 0
68
+ ? variant.weight
69
+ : 0,
70
+ }));
71
+ const totalWeight = weighted.reduce((sum, entry) => sum + entry.weight, 0);
57
72
  if (totalWeight <= 0)
58
73
  return { matched: false };
59
- const bucket = isInRollout(flagKey, subject, 100) ? 99 : 0;
74
+ // The real bucket for this (flag, subject) pair. This used to be
75
+ // `isInRollout(flagKey, subject, 100) ? 99 : 0`, which is always 99 —
76
+ // so every subject landed in the last variant and the weights did
77
+ // nothing at all.
78
+ const bucket = getBucket(flagKey, subject, VARIANT_BUCKETS);
60
79
  let cumulative = 0;
61
- for (const variant of rule.variants) {
62
- cumulative += (variant.weight / totalWeight) * 100;
80
+ for (const entry of weighted) {
81
+ cumulative += (entry.weight / totalWeight) * VARIANT_BUCKETS;
63
82
  if (bucket < cumulative) {
64
- return { matched: true, value: variant.key, variant: variant.key };
83
+ return { matched: true, value: entry.key, variant: entry.key };
65
84
  }
66
85
  }
86
+ // Floating-point rounding can leave the last bucket just past the
87
+ // cumulative total; the final weighted variant owns it.
88
+ const positive = weighted.filter((entry) => entry.weight > 0);
89
+ const last = positive[positive.length - 1];
90
+ if (last)
91
+ return { matched: true, value: last.key, variant: last.key };
67
92
  return { matched: false };
68
93
  }
69
94
  default:
@@ -21,4 +21,14 @@ export interface FeatureFlagProvider {
21
21
  /** Optional: subscribe to flag changes. */
22
22
  subscribe?(listener: FeatureFlagChangeListener): Unsubscribe;
23
23
  }
24
+ /**
25
+ * A provider that always implements {@link FeatureFlagProvider.refresh}.
26
+ *
27
+ * The built-in providers all do, but declaring them as `FeatureFlagProvider`
28
+ * hid it behind an optional method, so a caller could not invoke `refresh()`
29
+ * without a non-null assertion.
30
+ */
31
+ export interface RefreshableFeatureFlagProvider extends FeatureFlagProvider {
32
+ refresh(): Promise<void>;
33
+ }
24
34
  //# sourceMappingURL=featureFlagProvider.d.ts.map
@@ -5,5 +5,5 @@ export type { FeatureFlagValue, FeatureFlagState, FeatureFlagVisibility, Feature
5
5
  export type { FeatureFlagContext } from "./featureFlagContext.js";
6
6
  export type { FeatureFlagMetadata, FeatureFlag, } from "./featureFlag.interface.js";
7
7
  export type { FeatureFlagEvaluationReason, FeatureFlagEvaluation, } from "./featureFlagEvaluation.js";
8
- export type { FeatureFlagProvider, FeatureFlagChangeListener, Unsubscribe, } from "./featureFlagProvider.js";
8
+ export type { FeatureFlagProvider, RefreshableFeatureFlagProvider, FeatureFlagChangeListener, Unsubscribe, } from "./featureFlagProvider.js";
9
9
  //# sourceMappingURL=index.d.ts.map
@@ -18,14 +18,25 @@ export interface FeatureFlagsOptions {
18
18
  readonly defaultContext?: FeatureFlagContext;
19
19
  /** Whether to throw on missing flags (default: false, returns default value). */
20
20
  readonly throwOnMissing?: boolean;
21
+ /**
22
+ * Reports a provider failure that evaluation contained.
23
+ *
24
+ * A store that cannot be reached must not decide a flag, and it must not
25
+ * take the caller down either: the evaluation falls back to the flag's
26
+ * declared default (or `not_found` when nothing is known about it) and the
27
+ * failure surfaces here.
28
+ */
29
+ readonly onError?: (error: unknown, source: string) => void;
30
+ /**
31
+ * Rethrow provider failures instead of containing them. Default: `false`.
32
+ *
33
+ * Set it when an unreachable store should be a hard failure the caller
34
+ * handles itself.
35
+ */
36
+ readonly throwOnProviderError?: boolean;
21
37
  }
22
- /**
23
- * Create a FeatureFlags instance.
24
- *
25
- * @param options - Configuration options.
26
- * @returns A FeatureFlags API object.
27
- */
28
- export declare function createFeatureFlags(options: FeatureFlagsOptions): {
38
+ /** The public FeatureFlags API. */
39
+ export interface FeatureFlags {
29
40
  isEnabled(key: string, context?: FeatureFlagContext): Promise<boolean>;
30
41
  get<T extends FeatureFlagValue = FeatureFlagValue>(key: string, context?: FeatureFlagContext): Promise<T | undefined>;
31
42
  getBoolean(key: string, defaultValue: boolean, context?: FeatureFlagContext): Promise<boolean>;
@@ -33,5 +44,14 @@ export declare function createFeatureFlags(options: FeatureFlagsOptions): {
33
44
  snapshot(context?: FeatureFlagContext): Promise<ReadonlyMap<string, FeatureFlagEvaluation>>;
34
45
  refresh(): Promise<void>;
35
46
  getAll(): Promise<readonly FeatureFlag[]>;
36
- };
47
+ /** Stop listening for provider changes. */
48
+ close(): void;
49
+ }
50
+ /**
51
+ * Create a FeatureFlags instance.
52
+ *
53
+ * @param options - Configuration options.
54
+ * @returns A FeatureFlags API object.
55
+ */
56
+ export declare function createFeatureFlags(options: FeatureFlagsOptions): FeatureFlags;
37
57
  //# sourceMappingURL=featureFlags.core.d.ts.map