@zudojs/feature-flags 0.1.0 → 1.0.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/LICENSE +21 -0
- package/README.md +158 -13
- package/dist/evaluator/evaluator.core.d.ts +15 -1
- package/dist/evaluator/evaluator.core.js +28 -9
- package/dist/evaluator/evaluatorAttribute.d.ts +5 -0
- package/dist/evaluator/evaluatorAttribute.js +62 -4
- package/dist/evaluator/evaluatorRule.core.js +32 -7
- package/dist/featureFlagTypes/featureFlagProvider.d.ts +10 -0
- package/dist/featureFlagTypes/index.d.ts +1 -1
- package/dist/featureFlags/featureFlags.core.d.ts +28 -8
- package/dist/featureFlags/featureFlags.core.js +96 -33
- package/dist/featureFlags/featureFlags.resolve.d.ts +4 -3
- package/dist/featureFlags/featureFlags.resolve.js +29 -12
- package/dist/index.d.ts +4 -1
- package/dist/index.js +1 -0
- package/dist/provider/providerCached.core.d.ts +3 -3
- package/dist/provider/providerCached.core.js +1 -1
- package/dist/provider/providerComposite.core.d.ts +2 -2
- package/dist/provider/providerEnvironment.core.d.ts +3 -3
- package/dist/provider/providerEnvironment.core.js +1 -1
- package/dist/provider/providerMemory.core.d.ts +18 -2
- package/dist/provider/providerMemory.core.js +38 -4
- package/package.json +22 -14
- package/dist/evaluator/evaluator.core.d.ts.map +0 -1
- package/dist/evaluator/evaluator.core.js.map +0 -1
- package/dist/evaluator/evaluatorAttribute.d.ts.map +0 -1
- package/dist/evaluator/evaluatorAttribute.js.map +0 -1
- package/dist/evaluator/evaluatorRule.core.d.ts.map +0 -1
- package/dist/evaluator/evaluatorRule.core.js.map +0 -1
- package/dist/evaluator/index.d.ts.map +0 -1
- package/dist/evaluator/index.js.map +0 -1
- package/dist/featureFlagErrors/featureFlagError.base.d.ts.map +0 -1
- package/dist/featureFlagErrors/featureFlagError.base.js.map +0 -1
- package/dist/featureFlagErrors/featureFlagError.types.d.ts.map +0 -1
- package/dist/featureFlagErrors/featureFlagError.types.js.map +0 -1
- package/dist/featureFlagErrors/index.d.ts.map +0 -1
- package/dist/featureFlagErrors/index.js.map +0 -1
- package/dist/featureFlagTypes/featureFlag.interface.d.ts.map +0 -1
- package/dist/featureFlagTypes/featureFlag.interface.js.map +0 -1
- package/dist/featureFlagTypes/featureFlagContext.d.ts.map +0 -1
- package/dist/featureFlagTypes/featureFlagContext.js.map +0 -1
- package/dist/featureFlagTypes/featureFlagEvaluation.d.ts.map +0 -1
- package/dist/featureFlagTypes/featureFlagEvaluation.js.map +0 -1
- package/dist/featureFlagTypes/featureFlagProvider.d.ts.map +0 -1
- package/dist/featureFlagTypes/featureFlagProvider.js.map +0 -1
- package/dist/featureFlagTypes/featureFlagRule/featureFlagRule.type.d.ts.map +0 -1
- package/dist/featureFlagTypes/featureFlagRule/featureFlagRule.type.js.map +0 -1
- package/dist/featureFlagTypes/featureFlagRule/featureFlagValue.type.d.ts.map +0 -1
- package/dist/featureFlagTypes/featureFlagRule/featureFlagValue.type.js.map +0 -1
- package/dist/featureFlagTypes/featureFlagRule/index.d.ts.map +0 -1
- package/dist/featureFlagTypes/featureFlagRule/index.js.map +0 -1
- package/dist/featureFlagTypes/index.d.ts.map +0 -1
- package/dist/featureFlagTypes/index.js.map +0 -1
- package/dist/featureFlags/featureFlags.core.d.ts.map +0 -1
- package/dist/featureFlags/featureFlags.core.js.map +0 -1
- package/dist/featureFlags/featureFlags.resolve.d.ts.map +0 -1
- package/dist/featureFlags/featureFlags.resolve.js.map +0 -1
- package/dist/featureFlags/index.d.ts.map +0 -1
- package/dist/featureFlags/index.js.map +0 -1
- package/dist/index.d.ts.map +0 -1
- package/dist/index.js.map +0 -1
- package/dist/provider/index.d.ts.map +0 -1
- package/dist/provider/index.js.map +0 -1
- package/dist/provider/providerCached.core.d.ts.map +0 -1
- package/dist/provider/providerCached.core.js.map +0 -1
- package/dist/provider/providerComposite.core.d.ts.map +0 -1
- package/dist/provider/providerComposite.core.js.map +0 -1
- package/dist/provider/providerEnvironment.core.d.ts.map +0 -1
- package/dist/provider/providerEnvironment.core.js.map +0 -1
- package/dist/provider/providerMemory.core.d.ts.map +0 -1
- package/dist/provider/providerMemory.core.js.map +0 -1
- package/dist/registry/index.d.ts.map +0 -1
- package/dist/registry/index.js.map +0 -1
- package/dist/registry/registry.core.d.ts.map +0 -1
- package/dist/registry/registry.core.js.map +0 -1
- package/dist/rollout/index.d.ts.map +0 -1
- package/dist/rollout/index.js.map +0 -1
- package/dist/rollout/rolloutBucketing.d.ts.map +0 -1
- package/dist/rollout/rolloutBucketing.js.map +0 -1
- package/dist/rollout/rolloutHashing.d.ts.map +0 -1
- package/dist/rollout/rolloutHashing.js.map +0 -1
- package/dist/utils/index.d.ts.map +0 -1
- package/dist/utils/index.js.map +0 -1
- package/dist/utils/utils.helper.d.ts.map +0 -1
- 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,172 @@ 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
|
+
|
|
16
|
+
```typescript
|
|
17
|
+
import {
|
|
18
|
+
createFeatureFlags,
|
|
19
|
+
createMemoryProvider,
|
|
20
|
+
} from "@zudojs/feature-flags";
|
|
21
|
+
|
|
22
|
+
const flags = createFeatureFlags({
|
|
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
|
+
]),
|
|
37
|
+
});
|
|
38
|
+
|
|
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
|
+
## Rules
|
|
64
|
+
|
|
65
|
+
| Type | Matches when |
|
|
66
|
+
| ------------ | ----------------------------------------------- |
|
|
67
|
+
| `static` | always |
|
|
68
|
+
| `user` | `context.userId` is in `users` |
|
|
69
|
+
| `tenant` | `context.tenantId` is in `tenants` |
|
|
70
|
+
| `attribute` | `attribute` compared to `value` with `operator` |
|
|
71
|
+
| `percentage` | the subject's bucket falls inside `percentage` |
|
|
72
|
+
| `schedule` | now is between `startAt` and `endAt` |
|
|
73
|
+
| `variant` | always, assigning a variant by weight |
|
|
74
|
+
|
|
75
|
+
Rules are evaluated in declaration order and the first match wins. The
|
|
76
|
+
subject for `percentage` and `variant` is `userId`, then `tenantId`, then
|
|
77
|
+
`sessionId`, then `"anonymous"`.
|
|
78
|
+
|
|
79
|
+
Operators: `equals`, `not_equals`, `contains`, `starts_with`, `ends_with`,
|
|
80
|
+
`in`, `not_in`, `greater_than`, `greater_than_or_equal`, `less_than`,
|
|
81
|
+
`less_than_or_equal`, `exists`, `matches`.
|
|
82
|
+
|
|
83
|
+
Attribute paths use dot notation and are resolved against the context first,
|
|
84
|
+
then `context.attributes`. Only **own** properties are traversed:
|
|
85
|
+
`__proto__`, `constructor` and `prototype` never resolve, so a rule cannot
|
|
86
|
+
accidentally (or deliberately) target everyone through the prototype chain. A
|
|
87
|
+
`matches` pattern that does not compile, or is longer than 512 characters,
|
|
88
|
+
matches nothing instead of throwing.
|
|
89
|
+
|
|
90
|
+
## Rollouts and variants
|
|
91
|
+
|
|
92
|
+
```typescript
|
|
93
|
+
import { getBucket, isInRollout, hashString } from "@zudojs/feature-flags";
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
Bucketing is deterministic: `hash(flagKey + ":" + subject)` over 10,000
|
|
97
|
+
buckets, so the same subject always lands in the same bucket for the same
|
|
98
|
+
flag, and raising a percentage never removes anyone already inside it.
|
|
99
|
+
Variant weights are applied over those same buckets, so a 90/10 split really
|
|
100
|
+
is 90/10.
|
|
101
|
+
|
|
102
|
+
## Dependencies
|
|
103
|
+
|
|
104
|
+
A flag may declare `dependencies`. It evaluates normally only when every
|
|
105
|
+
dependency — transitively — exists and is enabled; otherwise the result is
|
|
106
|
+
`dependency_disabled` with the declared default. Cycles resolve to disabled;
|
|
107
|
+
a shared dependency reached down two branches is not a cycle.
|
|
108
|
+
|
|
109
|
+
`evaluateFlag()` on its own has no registry and cannot resolve dependencies,
|
|
110
|
+
so it reports `dependency_disabled` for any flag that declares them unless
|
|
111
|
+
the caller passes `{ dependenciesSatisfied: true }`.
|
|
112
|
+
|
|
113
|
+
## Providers
|
|
114
|
+
|
|
13
115
|
```typescript
|
|
14
|
-
import {
|
|
116
|
+
import {
|
|
117
|
+
createMemoryProvider,
|
|
118
|
+
createEnvironmentProvider,
|
|
119
|
+
createCompositeProvider,
|
|
120
|
+
createCachedProvider,
|
|
121
|
+
} from "@zudojs/feature-flags";
|
|
122
|
+
|
|
123
|
+
const provider = createCachedProvider(
|
|
124
|
+
createCompositeProvider([
|
|
125
|
+
createEnvironmentProvider({ prefix: "FEATURE_" }),
|
|
126
|
+
remoteProvider,
|
|
127
|
+
]),
|
|
128
|
+
{ ttl: 30_000 },
|
|
129
|
+
);
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
`createMemoryProvider` returns a typed provider with `set`, `delete` and
|
|
133
|
+
`setAll`, and it announces every change to subscribers.
|
|
15
134
|
|
|
135
|
+
## Change propagation
|
|
136
|
+
|
|
137
|
+
`createFeatureFlags` subscribes to the provider when it offers `subscribe()`,
|
|
138
|
+
so a flag flipped at the source reaches an already-loaded instance without a
|
|
139
|
+
manual `refresh()`. Call `flags.close()` to unsubscribe, and
|
|
140
|
+
`flags.refresh()` to reload explicitly from a provider that cannot announce
|
|
141
|
+
changes.
|
|
142
|
+
|
|
143
|
+
## Failure behaviour
|
|
144
|
+
|
|
145
|
+
| Situation | Result |
|
|
146
|
+
| -------------------------------- | ------------------------------------------------------ |
|
|
147
|
+
| Flag not found | `not_found`, value `undefined`; `isEnabled` is `false` |
|
|
148
|
+
| Flag not found, `throwOnMissing` | throws `FeatureFlagNotFoundError` |
|
|
149
|
+
| Flag disabled or draft | `disabled`, the declared default |
|
|
150
|
+
| Flag archived or expired | `expired`, the declared default |
|
|
151
|
+
| Dependency not satisfied | `dependency_disabled`, the declared default |
|
|
152
|
+
| Provider unreachable | `error`, reported to `onError`; never enabled |
|
|
153
|
+
|
|
154
|
+
An unreachable store never enables a flag. Set `throwOnProviderError: true`
|
|
155
|
+
to own the failure yourself instead.
|
|
156
|
+
|
|
157
|
+
```typescript
|
|
16
158
|
const flags = createFeatureFlags({
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
{ key: "beta-feature", users: ["user-123"] },
|
|
20
|
-
],
|
|
159
|
+
provider,
|
|
160
|
+
onError: (error, source) => logger.error({ error, source }, "flag store"),
|
|
21
161
|
});
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
## Snapshots
|
|
22
165
|
|
|
23
|
-
|
|
166
|
+
```typescript
|
|
167
|
+
const snapshot = await flags.snapshot({ userId });
|
|
24
168
|
```
|
|
25
169
|
|
|
26
|
-
|
|
170
|
+
Only flags explicitly marked `visibility: "client"` are included — a snapshot
|
|
171
|
+
is shipped to a browser, so a flag that declares no visibility is withheld.
|
|
172
|
+
|
|
173
|
+
## Errors
|
|
27
174
|
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
- Snapshot export for client-side flags
|
|
33
|
-
- Evaluation context
|
|
175
|
+
`FeatureFlagError` is the base: `FeatureFlagNotFoundError` ·
|
|
176
|
+
`FeatureFlagProviderError` · `FeatureFlagEvaluationError` ·
|
|
177
|
+
`FeatureFlagRuleError` · `FeatureFlagDependencyError` ·
|
|
178
|
+
`FeatureFlagConfigurationError` · `FeatureFlagTypeError`.
|
|
34
179
|
|
|
35
180
|
## Use Cases
|
|
36
181
|
|
|
@@ -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,33 @@
|
|
|
6
6
|
* @module evaluator/evaluator
|
|
7
7
|
*/
|
|
8
8
|
import { evaluateRule } from "./evaluatorRule.core.js";
|
|
9
|
+
/** The evaluation reason a matching rule of each type produces. */
|
|
10
|
+
function reasonFor(type) {
|
|
11
|
+
switch (type) {
|
|
12
|
+
case "percentage":
|
|
13
|
+
return "percentage_rollout";
|
|
14
|
+
case "variant":
|
|
15
|
+
return "variant_assignment";
|
|
16
|
+
case "static":
|
|
17
|
+
return "static";
|
|
18
|
+
case "user":
|
|
19
|
+
case "tenant":
|
|
20
|
+
// Explicit targeting, which is what `target_match` is for — it was a
|
|
21
|
+
// declared reason nothing ever produced.
|
|
22
|
+
return "target_match";
|
|
23
|
+
default:
|
|
24
|
+
return "rule_match";
|
|
25
|
+
}
|
|
26
|
+
}
|
|
9
27
|
/**
|
|
10
28
|
* Evaluate a feature flag against a context.
|
|
11
29
|
*
|
|
12
30
|
* @param flag - The feature flag definition.
|
|
13
31
|
* @param context - The evaluation context.
|
|
32
|
+
* @param options - Facts the caller resolved that this function cannot.
|
|
14
33
|
* @returns A structured evaluation result.
|
|
15
34
|
*/
|
|
16
|
-
export function evaluateFlag(flag, context = {}) {
|
|
35
|
+
export function evaluateFlag(flag, context = {}, options = {}) {
|
|
17
36
|
if (!flag.enabled) {
|
|
18
37
|
return {
|
|
19
38
|
key: flag.key,
|
|
@@ -38,7 +57,13 @@ export function evaluateFlag(flag, context = {}) {
|
|
|
38
57
|
defaulted: true,
|
|
39
58
|
};
|
|
40
59
|
}
|
|
41
|
-
if (flag.dependencies &&
|
|
60
|
+
if (flag.dependencies &&
|
|
61
|
+
flag.dependencies.length > 0 &&
|
|
62
|
+
options.dependenciesSatisfied !== true) {
|
|
63
|
+
// Previously this returned `dependency_disabled` for *every* flag that
|
|
64
|
+
// declared a dependency, satisfied or not — so a flag with dependencies
|
|
65
|
+
// could never turn on, and the caller's own dependency resolution was
|
|
66
|
+
// computed and then discarded.
|
|
42
67
|
return {
|
|
43
68
|
key: flag.key,
|
|
44
69
|
value: flag.defaultValue,
|
|
@@ -59,13 +84,7 @@ export function evaluateFlag(flag, context = {}) {
|
|
|
59
84
|
const rule = flag.rules[i];
|
|
60
85
|
const result = evaluateRule(rule, context, flag.key);
|
|
61
86
|
if (result.matched) {
|
|
62
|
-
const reason = rule.type
|
|
63
|
-
? "percentage_rollout"
|
|
64
|
-
: rule.type === "variant"
|
|
65
|
-
? "variant_assignment"
|
|
66
|
-
: rule.type === "static"
|
|
67
|
-
? "static"
|
|
68
|
-
: "rule_match";
|
|
87
|
+
const reason = reasonFor(rule.type);
|
|
69
88
|
return {
|
|
70
89
|
key: flag.key,
|
|
71
90
|
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
|
-
|
|
78
|
-
|
|
79
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
62
|
-
cumulative += (
|
|
80
|
+
for (const entry of weighted) {
|
|
81
|
+
cumulative += (entry.weight / totalWeight) * VARIANT_BUCKETS;
|
|
63
82
|
if (bucket < cumulative) {
|
|
64
|
-
return { matched: true, value:
|
|
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
|
-
|
|
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
|