@zudojs/feature-flags 1.2.0 → 1.3.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 +11 -0
- 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/utils/utils.helper.d.ts +9 -0
- package/dist/utils/utils.helper.js +57 -5
- package/package.json +3 -3
package/README.md
CHANGED
|
@@ -171,6 +171,11 @@ remembers a key the provider does not know for `missingFlagTtlMs` (default
|
|
|
171
171
|
turn every evaluation into a remote round trip. The memory is dropped on
|
|
172
172
|
every reload.
|
|
173
173
|
|
|
174
|
+
A provider that fails is left alone for `providerCooloffMs` (default 5 s;
|
|
175
|
+
`0` disables it) before it is probed again, so an outage costs one pair of
|
|
176
|
+
calls per window instead of two per evaluation. The first successful call
|
|
177
|
+
closes the window, and `refresh()` always probes.
|
|
178
|
+
|
|
174
179
|
`createEnvironmentProvider` parses `true`/`false` and numbers; anything else,
|
|
175
180
|
including an empty `FEATURE_X=`, stays a string.
|
|
176
181
|
|
|
@@ -200,6 +205,12 @@ not throw `FeatureFlagNotFoundError` under `throwOnMissing`.
|
|
|
200
205
|
An unreachable store never enables a flag. Set `throwOnProviderError: true`
|
|
201
206
|
to own the failure yourself instead.
|
|
202
207
|
|
|
208
|
+
`snapshot()` and `getAll()` reject with `FeatureFlagProviderError` when the
|
|
209
|
+
flags were never loaded: an empty `Map` cannot be told apart from "no flags
|
|
210
|
+
are configured", and shipping one to a browser turns an outage into every
|
|
211
|
+
flag being off. Once a load has succeeded they keep serving that data, even
|
|
212
|
+
if a later reload fails.
|
|
213
|
+
|
|
203
214
|
```typescript
|
|
204
215
|
const flags = createFeatureFlags({
|
|
205
216
|
provider,
|
|
@@ -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() {
|
|
@@ -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.3.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,8 +11,8 @@
|
|
|
11
11
|
}
|
|
12
12
|
},
|
|
13
13
|
"dependencies": {
|
|
14
|
-
"@zudojs/errors": "1.
|
|
15
|
-
"@zudojs/types": "1.1.
|
|
14
|
+
"@zudojs/errors": "1.2.0",
|
|
15
|
+
"@zudojs/types": "1.1.1"
|
|
16
16
|
},
|
|
17
17
|
"devDependencies": {
|
|
18
18
|
"typescript": "7.0.2",
|