@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 +50 -8
- package/dist/evaluator/evaluator.core.d.ts +4 -0
- package/dist/evaluator/evaluator.core.js +9 -53
- package/dist/evaluator/evaluatorGate.core.d.ts +35 -0
- package/dist/evaluator/evaluatorGate.core.js +68 -0
- package/dist/featureFlagTypes/featureFlag.interface.d.ts +10 -1
- package/dist/featureFlags/featureFlags.cooloff.d.ts +27 -0
- package/dist/featureFlags/featureFlags.cooloff.js +34 -0
- package/dist/featureFlags/featureFlags.core.d.ts +6 -0
- package/dist/featureFlags/featureFlags.core.js +30 -3
- package/dist/provider/providerEnvironment.core.d.ts +23 -1
- package/dist/provider/providerEnvironment.core.js +17 -3
- package/dist/utils/utils.helper.d.ts +9 -0
- package/dist/utils/utils.helper.js +57 -5
- package/package.json +5 -5
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
|
|
61
|
-
|
|
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
|
|
135
|
-
|
|
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
|
|
192
|
-
| Flag archived or expired | `expired`, the
|
|
193
|
-
| Dependency not satisfied | `dependency_disabled`, the
|
|
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
|
-
|
|
56
|
-
|
|
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
|
|
89
|
-
reason:
|
|
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
|
-
/**
|
|
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 "
|
|
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 "
|
|
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
|
-
|
|
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 ===
|
|
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 (
|
|
36
|
+
if (a === null || b === null)
|
|
22
37
|
return false;
|
|
23
|
-
|
|
24
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
15
|
-
"@zudojs/types": "1.
|
|
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": "^
|
|
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://
|
|
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
|
},
|