@zenxdigitalholdings/zenflags 0.2.0 → 0.2.1
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/dist/evaluator.js +6 -3
- package/dist/provider.d.ts +2 -0
- package/dist/zen.js +26 -9
- package/package.json +1 -1
package/dist/evaluator.js
CHANGED
|
@@ -18,14 +18,17 @@ class Evaluator {
|
|
|
18
18
|
const key = (0, spec_1.specKey)(spec);
|
|
19
19
|
try {
|
|
20
20
|
const res = await this.provider.evaluate(spec, rc);
|
|
21
|
+
// The flag is protected if the caller declared it OR the server did — so
|
|
22
|
+
// a central PROTECTED marking is honoured even if a call site forgot it.
|
|
23
|
+
const isProtected = !!spec.protected || !!res.protected;
|
|
21
24
|
if (!(0, state_1.isValidState)(res.state)) {
|
|
22
|
-
return new decision_1.Decision(key, 'DISABLED_HARD',
|
|
25
|
+
return new decision_1.Decision(key, 'DISABLED_HARD', isProtected, '', false, `invalid state "${res.state}" -> DISABLED_HARD`, new Error('invalid state'));
|
|
23
26
|
}
|
|
24
27
|
if (res.state === 'DRAIN') {
|
|
25
28
|
const ramped = (0, ramp_1.inRamp)(key, rc.entityId ?? '', res.rampPercent ?? 0);
|
|
26
|
-
return new decision_1.Decision(key, 'DRAIN',
|
|
29
|
+
return new decision_1.Decision(key, 'DRAIN', isProtected, res.variant ?? '', ramped, `DRAIN ${res.rampPercent ?? 0}%: inRamp=${ramped}`);
|
|
27
30
|
}
|
|
28
|
-
return new decision_1.Decision(key, res.state,
|
|
31
|
+
return new decision_1.Decision(key, res.state, isProtected, res.variant ?? '', false, res.reason ?? '');
|
|
29
32
|
}
|
|
30
33
|
catch (err) {
|
|
31
34
|
if (spec.protected) {
|
package/dist/provider.d.ts
CHANGED
|
@@ -7,6 +7,8 @@ export interface EvalResult {
|
|
|
7
7
|
rampPercent?: number;
|
|
8
8
|
variant?: string;
|
|
9
9
|
reason?: string;
|
|
10
|
+
/** Server-declared PROTECTED class — the SDK OR's this with Spec.protected. */
|
|
11
|
+
protected?: boolean;
|
|
10
12
|
}
|
|
11
13
|
/** Pluggable flag backend. FliptProvider is production; MemoryProvider backs tests. */
|
|
12
14
|
export interface Provider {
|
package/dist/zen.js
CHANGED
|
@@ -41,7 +41,7 @@ class ZenProvider {
|
|
|
41
41
|
if (!res.ok)
|
|
42
42
|
throw new Error(`zen flags: status ${res.status}`);
|
|
43
43
|
const out = (await res.json());
|
|
44
|
-
return { state: out.state, rampPercent: out.rampPercent, reason: out.reason };
|
|
44
|
+
return { state: out.state, rampPercent: out.rampPercent, reason: out.reason, protected: out.protected };
|
|
45
45
|
}
|
|
46
46
|
finally {
|
|
47
47
|
clearTimeout(timer);
|
|
@@ -66,7 +66,8 @@ class CachedZenProvider {
|
|
|
66
66
|
this.etag = '';
|
|
67
67
|
this.timer = null;
|
|
68
68
|
this.stopped = false;
|
|
69
|
-
|
|
69
|
+
// Guard against a non-positive interval, which would busy-poll the server.
|
|
70
|
+
this.intervalMs = opts.refreshMs && opts.refreshMs > 0 ? opts.refreshMs : 5000;
|
|
70
71
|
this.maxStaleMs = opts.maxStaleMs ?? 5 * 60_000;
|
|
71
72
|
this.zen = new ZenProvider(opts);
|
|
72
73
|
this.readyPromise = new Promise((resolve) => {
|
|
@@ -82,8 +83,12 @@ class CachedZenProvider {
|
|
|
82
83
|
* fall back until it recovers.
|
|
83
84
|
*/
|
|
84
85
|
ready(timeoutMs) {
|
|
85
|
-
|
|
86
|
+
// Only an unspecified timeout waits indefinitely. An explicit 0 (or
|
|
87
|
+
// negative) means "don't block boot" and resolves false immediately.
|
|
88
|
+
if (timeoutMs === undefined)
|
|
86
89
|
return this.readyPromise.then(() => true);
|
|
90
|
+
if (timeoutMs <= 0)
|
|
91
|
+
return Promise.resolve(false);
|
|
87
92
|
return Promise.race([
|
|
88
93
|
this.readyPromise.then(() => true),
|
|
89
94
|
new Promise((r) => setTimeout(() => r(false), timeoutMs)),
|
|
@@ -101,11 +106,16 @@ class CachedZenProvider {
|
|
|
101
106
|
this.timer.unref();
|
|
102
107
|
}
|
|
103
108
|
async refresh() {
|
|
109
|
+
// Bound the request: without this, a hung connection stalls the whole
|
|
110
|
+
// refresh loop (the next poll is chained off this one) until the snapshot
|
|
111
|
+
// crosses maxStale and every flag fails closed.
|
|
112
|
+
const controller = new AbortController();
|
|
113
|
+
const timer = setTimeout(() => controller.abort(), 4000);
|
|
104
114
|
try {
|
|
105
115
|
const headers = { authorization: `Bearer ${this.zen.apiKey}` };
|
|
106
116
|
if (this.etag)
|
|
107
117
|
headers['if-none-match'] = this.etag;
|
|
108
|
-
const res = await fetch(`${this.zen.baseURL}/v1/snapshot`, { headers });
|
|
118
|
+
const res = await fetch(`${this.zen.baseURL}/v1/snapshot`, { headers, signal: controller.signal });
|
|
109
119
|
if (res.status === 304) {
|
|
110
120
|
this.fetchedAt = Date.now(); // unchanged — refresh the staleness clock
|
|
111
121
|
this.markReady();
|
|
@@ -116,7 +126,7 @@ class CachedZenProvider {
|
|
|
116
126
|
const out = (await res.json());
|
|
117
127
|
const next = new Map();
|
|
118
128
|
for (const f of out.flags ?? []) {
|
|
119
|
-
next.set(f.key, { state: f.state, rampPercent: f.rampPercent, reason: 'snapshot' });
|
|
129
|
+
next.set(f.key, { state: f.state, rampPercent: f.rampPercent, protected: f.protected, reason: 'snapshot' });
|
|
120
130
|
}
|
|
121
131
|
this.snapshot = next;
|
|
122
132
|
this.fetchedAt = Date.now();
|
|
@@ -126,15 +136,22 @@ class CachedZenProvider {
|
|
|
126
136
|
catch {
|
|
127
137
|
/* keep last snapshot; staleness guard below handles prolonged outages */
|
|
128
138
|
}
|
|
139
|
+
finally {
|
|
140
|
+
clearTimeout(timer);
|
|
141
|
+
}
|
|
129
142
|
}
|
|
130
143
|
async evaluate(spec, _rc) {
|
|
144
|
+
const entry = this.snapshot?.get((0, spec_1.specKey)(spec));
|
|
131
145
|
if (!this.snapshot || Date.now() - this.fetchedAt > this.maxStaleMs) {
|
|
146
|
+
// Beyond maxStale the snapshot's values are no longer trusted. But if the
|
|
147
|
+
// last good snapshot marked this flag PROTECTED, fail it closed here
|
|
148
|
+
// rather than leaving it to a caller-supplied (possibly open) fallback.
|
|
149
|
+
if (entry?.protected || spec.protected) {
|
|
150
|
+
return { state: 'DISABLED_HARD', protected: true, reason: 'stale snapshot, protected -> fail closed' };
|
|
151
|
+
}
|
|
132
152
|
throw new Error('zen flags: snapshot unavailable or stale');
|
|
133
153
|
}
|
|
134
|
-
return
|
|
135
|
-
state: 'DISABLED_HARD',
|
|
136
|
-
reason: 'unknown flag -> baseline',
|
|
137
|
-
});
|
|
154
|
+
return entry ?? { state: 'DISABLED_HARD', reason: 'unknown flag -> baseline' };
|
|
138
155
|
}
|
|
139
156
|
async close() {
|
|
140
157
|
this.stopped = true;
|
package/package.json
CHANGED