@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 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', !!spec.protected, '', false, `invalid state "${res.state}" -> DISABLED_HARD`, new Error('invalid state'));
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', !!spec.protected, res.variant ?? '', ramped, `DRAIN ${res.rampPercent ?? 0}%: inRamp=${ramped}`);
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, !!spec.protected, res.variant ?? '', false, res.reason ?? '');
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) {
@@ -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
- this.intervalMs = opts.refreshMs ?? 5000;
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
- if (!timeoutMs)
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 (this.snapshot.get((0, spec_1.specKey)(spec)) ?? {
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zenxdigitalholdings/zenflags",
3
- "version": "0.2.0",
3
+ "version": "0.2.1",
4
4
  "description": "Zen Flags SDK \u2014 feature flags for Zen products, evaluated in-process from the Zen flags server.",
5
5
  "license": "UNLICENSED",
6
6
  "type": "commonjs",