@shelbyb/daywalker-sdk 0.2.0 → 0.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.
Files changed (55) hide show
  1. package/README.md +38 -24
  2. package/dist/analytics/index.d.ts +26 -3
  3. package/dist/analytics/index.js +107 -23
  4. package/dist/cli.js +136 -29
  5. package/dist/client.d.ts +44 -3
  6. package/dist/client.js +372 -73
  7. package/dist/config.d.ts +14 -2
  8. package/dist/config.js +36 -0
  9. package/dist/crypto/encoding.d.ts +5 -0
  10. package/dist/crypto/encoding.js +14 -0
  11. package/dist/crypto/index.d.ts +3 -3
  12. package/dist/crypto/index.js +2 -2
  13. package/dist/crypto/keys.d.ts +3 -3
  14. package/dist/crypto/keys.js +28 -7
  15. package/dist/crypto/signatures.d.ts +54 -11
  16. package/dist/crypto/signatures.js +310 -94
  17. package/dist/express.d.ts +1 -0
  18. package/dist/express.js +8 -6
  19. package/dist/flags/client.d.ts +34 -5
  20. package/dist/flags/client.js +93 -22
  21. package/dist/flags/common/events.d.ts +9 -0
  22. package/dist/flags/common/events.js +77 -6
  23. package/dist/flags/common/index.d.ts +3 -3
  24. package/dist/flags/common/index.js +3 -3
  25. package/dist/flags/common/sse.d.ts +24 -3
  26. package/dist/flags/common/sse.js +85 -15
  27. package/dist/flags/common/version.d.ts +2 -0
  28. package/dist/flags/common/version.js +2 -0
  29. package/dist/flags/engine/context.js +4 -3
  30. package/dist/flags/engine/index.d.ts +1 -1
  31. package/dist/flags/engine/index.js +1 -1
  32. package/dist/flags/engine/operators.d.ts +25 -0
  33. package/dist/flags/engine/operators.js +64 -8
  34. package/dist/flags/node.d.ts +27 -15
  35. package/dist/flags/node.js +54 -7
  36. package/dist/flags/react.d.ts +6 -4
  37. package/dist/flags/react.js +47 -17
  38. package/dist/index.d.ts +7 -6
  39. package/dist/index.js +4 -4
  40. package/dist/middleware.d.ts +18 -0
  41. package/dist/middleware.js +28 -7
  42. package/dist/next.d.ts +63 -6
  43. package/dist/next.js +182 -36
  44. package/dist/openfeature/server.d.ts +3 -0
  45. package/dist/openfeature/server.js +14 -2
  46. package/dist/openfeature/shared.d.ts +1 -1
  47. package/dist/openfeature/shared.js +22 -2
  48. package/dist/openfeature/web.js +9 -3
  49. package/dist/types.d.ts +58 -3
  50. package/dist/types.js +6 -0
  51. package/docs/analytics.md +1 -1
  52. package/docs/flags-node.md +1 -1
  53. package/docs/flags.md +2 -1
  54. package/package.json +34 -16
  55. package/assets/logo-512.png +0 -0
@@ -1,13 +1,24 @@
1
- /** Incremental parser: feed it chunks of text, get dispatched events back. */
1
+ /** Default cap on one event (its pending line plus its `data` lines), in UTF-16 code units. */
2
+ export const DEFAULT_MAX_EVENT_SIZE = 1024 * 1024;
3
+ /**
4
+ * Incremental parser: feed it chunks of text, get dispatched events back. Use one parser per
5
+ * connection: a dropped connection can leave a half-received event behind.
6
+ */
2
7
  export class SseParser {
8
+ maxEventSize;
3
9
  buffer = "";
4
10
  data = [];
11
+ dataSize = 0;
5
12
  eventType = "";
6
13
  sawFirstChunk = false;
7
14
  /** The previous chunk ended in CR, so an LF starting this chunk completes a CRLF. */
8
15
  pendingCR = false;
9
16
  lastEventId = "";
10
17
  retry = null;
18
+ /** @param maxEventSize feed() throws an SseError once an event grows past this (bounded memory). */
19
+ constructor(maxEventSize = DEFAULT_MAX_EVENT_SIZE) {
20
+ this.maxEventSize = maxEventSize;
21
+ }
11
22
  feed(chunk) {
12
23
  if (!this.sawFirstChunk) {
13
24
  this.sawFirstChunk = true;
@@ -39,6 +50,12 @@ export class SseParser {
39
50
  out.push(ev);
40
51
  }
41
52
  this.buffer = this.buffer.slice(start);
53
+ if (this.buffer.length + this.dataSize > this.maxEventSize) {
54
+ this.buffer = "";
55
+ this.data = [];
56
+ this.dataSize = 0;
57
+ throw new SseError(`Stream event exceeded ${this.maxEventSize} characters`);
58
+ }
42
59
  return out;
43
60
  }
44
61
  line(line) {
@@ -49,6 +66,7 @@ export class SseParser {
49
66
  }
50
67
  const ev = { type: this.eventType || "message", data: this.data.join("\n"), lastEventId: this.lastEventId };
51
68
  this.data = [];
69
+ this.dataSize = 0;
52
70
  this.eventType = "";
53
71
  return ev;
54
72
  }
@@ -65,6 +83,12 @@ export class SseParser {
65
83
  break;
66
84
  case "data":
67
85
  this.data.push(value);
86
+ this.dataSize += value.length + 1;
87
+ if (this.dataSize > this.maxEventSize) {
88
+ this.data = [];
89
+ this.dataSize = 0;
90
+ throw new SseError(`Stream event exceeded ${this.maxEventSize} characters`);
91
+ }
68
92
  break;
69
93
  case "id":
70
94
  if (!value.includes("\0"))
@@ -78,6 +102,18 @@ export class SseParser {
78
102
  return null;
79
103
  }
80
104
  }
105
+ /** Lower bound for a server-sent `retry:` hint, so `retry: 0` can't cause a reconnect storm. */
106
+ export const MIN_SERVER_RETRY_MS = 1000;
107
+ /** Browsers and web workers enforce CORS on fetch; Node, Deno (no document), Bun and React Native don't. */
108
+ function isBrowserLike() {
109
+ const g = globalThis;
110
+ return typeof g.document !== "undefined" || typeof g.WorkerGlobalScope !== "undefined";
111
+ }
112
+ /** Let a timer not keep a Node/Deno/Bun process alive (no-op in browsers). */
113
+ function unref(t) {
114
+ t.unref?.();
115
+ return t;
116
+ }
81
117
  export class SseError extends Error {
82
118
  status;
83
119
  constructor(message, status) {
@@ -99,7 +135,9 @@ export class SseClient {
99
135
  controller = null;
100
136
  stopped = false;
101
137
  attempt = 0;
102
- parser = new SseParser();
138
+ /** Carried across connections; the parser itself is fresh per connection. */
139
+ lastEventId = "";
140
+ retry = null;
103
141
  timer = null;
104
142
  watchdog = null;
105
143
  reader = null;
@@ -112,44 +150,66 @@ export class SseClient {
112
150
  }
113
151
  stop() {
114
152
  this.stopped = true;
115
- if (this.timer)
116
- clearTimeout(this.timer);
153
+ this.clearTimer();
117
154
  // Don't leave the read-timeout timer or a pending read keeping the process alive.
118
155
  if (this.watchdog)
119
156
  clearTimeout(this.watchdog);
157
+ this.watchdog = null;
120
158
  this.controller?.abort();
121
159
  void this.reader?.cancel().catch(() => { });
122
160
  }
123
- /** Drop the current connection and reconnect immediately (e.g. after identify). */
161
+ /** Drop the current connection (or pending reconnect) and reconnect immediately (e.g. after identify). */
124
162
  restart() {
163
+ this.clearTimer();
125
164
  this.controller?.abort();
126
165
  this.attempt = 0;
127
- this.parser = new SseParser();
166
+ this.lastEventId = ""; // the old stream's ids mean nothing to a new context
128
167
  if (!this.stopped)
129
168
  void this.connect();
130
169
  }
170
+ clearTimer() {
171
+ if (this.timer)
172
+ clearTimeout(this.timer);
173
+ this.timer = null;
174
+ }
131
175
  async connect() {
132
176
  if (this.stopped)
133
177
  return;
178
+ this.clearTimer();
179
+ this.controller?.abort(); // never two live connections
134
180
  const controller = new AbortController();
135
181
  this.controller = controller;
182
+ const parser = new SseParser(this.opts.maxEventSize);
183
+ parser.lastEventId = this.lastEventId;
184
+ parser.retry = this.retry;
136
185
  const f = this.opts.fetch ?? fetch;
137
186
  const resetWatchdog = () => {
138
187
  if (this.watchdog)
139
188
  clearTimeout(this.watchdog);
140
- this.watchdog = setTimeout(() => controller.abort(), this.opts.readTimeoutMs ?? 5 * 60_000);
189
+ this.watchdog = unref(setTimeout(() => controller.abort(), this.opts.readTimeoutMs ?? 5 * 60_000));
141
190
  };
142
191
  const connectedAt = Date.now();
143
192
  try {
144
193
  const url = typeof this.opts.url === "function" ? this.opts.url() : this.opts.url;
145
194
  const headers = typeof this.opts.headers === "function" ? this.opts.headers() : (this.opts.headers ?? {});
195
+ const browser = this.opts.browser ?? isBrowserLike();
146
196
  resetWatchdog();
147
197
  const res = await f(url, {
148
- headers: { accept: "text/event-stream", "cache-control": "no-cache", ...(this.parser.lastEventId ? { "last-event-id": this.parser.lastEventId } : {}), ...headers },
198
+ headers: {
199
+ accept: "text/event-stream",
200
+ ...(browser ? {} : { "cache-control": "no-cache", ...(this.lastEventId ? { "last-event-id": this.lastEventId } : {}) }),
201
+ ...headers,
202
+ },
203
+ // Browsers: keep the HTTP cache out of it without a non-safelisted request header.
204
+ ...(browser ? { cache: "no-store" } : {}),
149
205
  signal: controller.signal,
150
206
  });
151
207
  if (!res.ok || !res.body)
152
208
  throw new SseError(`Stream request failed with ${res.status}`, res.status);
209
+ if (this.controller !== controller) {
210
+ void res.body.cancel().catch(() => { });
211
+ return;
212
+ }
153
213
  this.opts.onOpen?.();
154
214
  const reader = res.body.getReader();
155
215
  this.reader = reader;
@@ -159,25 +219,35 @@ export class SseClient {
159
219
  if (done)
160
220
  break;
161
221
  resetWatchdog();
162
- for (const ev of this.parser.feed(decoder.decode(value, { stream: true })))
222
+ const events = parser.feed(decoder.decode(value, { stream: true }));
223
+ this.lastEventId = parser.lastEventId;
224
+ this.retry = parser.retry;
225
+ for (const ev of events) {
226
+ if (this.controller !== controller)
227
+ return; // superseded by restart()/stop() from a handler
163
228
  this.opts.onEvent(ev);
229
+ }
164
230
  }
165
231
  throw new SseError("Stream closed by server");
166
232
  }
167
233
  catch (err) {
168
- if (this.watchdog)
234
+ if (this.controller === controller && this.watchdog)
169
235
  clearTimeout(this.watchdog);
170
- if (this.stopped || (controller.signal.aborted && this.controller !== controller))
236
+ if (this.stopped || this.controller !== controller)
171
237
  return;
238
+ controller.abort(); // release the response body
172
239
  const e = err instanceof SseError ? err : new SseError(err?.message ?? "Stream error");
173
240
  const keepGoing = this.opts.onError?.(e);
174
- if (keepGoing === false || !e.recoverable)
241
+ if (keepGoing === false || !e.recoverable || this.stopped || this.controller !== controller)
175
242
  return;
176
243
  if (Date.now() - connectedAt > 60_000)
177
244
  this.attempt = 0; // was healthy for a while
178
- const base = this.parser.retry ?? this.opts.initialRetryMs ?? 1000;
179
- const delay = backoffDelay(this.attempt++, base, this.opts.maxRetryMs ?? 30_000);
180
- this.timer = setTimeout(() => void this.connect(), delay);
245
+ const fromServer = this.retry !== null;
246
+ const base = fromServer ? Math.max(MIN_SERVER_RETRY_MS, this.retry) : (this.opts.initialRetryMs ?? 1000);
247
+ let delay = backoffDelay(this.attempt++, base, this.opts.maxRetryMs ?? 30_000);
248
+ if (fromServer)
249
+ delay = Math.max(MIN_SERVER_RETRY_MS, delay);
250
+ this.timer = unref(setTimeout(() => void this.connect(), delay));
181
251
  }
182
252
  }
183
253
  }
@@ -0,0 +1,2 @@
1
+ /** The package version, sent in user agents and analytics `$lib_version`. Keep in sync with package.json. */
2
+ export declare const SDK_VERSION = "0.4.0";
@@ -0,0 +1,2 @@
1
+ /** The package version, sent in user agents and analytics `$lib_version`. Keep in sync with package.json. */
2
+ export const SDK_VERSION = "0.4.0";
@@ -11,7 +11,8 @@ function parseSingle(kind, raw) {
11
11
  throw new ContextError(`Context "${kind}" needs a non-empty string key`);
12
12
  const meta = (obj._meta ?? {});
13
13
  const privateAttributes = Array.isArray(meta.privateAttributes) ? meta.privateAttributes.filter((a) => typeof a === "string") : [];
14
- const attributes = {};
14
+ // No prototype: `constructor`, `toString`, `__proto__` resolve only if the context sets them.
15
+ const attributes = Object.create(null);
15
16
  for (const [k, v] of Object.entries(obj))
16
17
  if (k !== "_meta" && k !== "kind")
17
18
  attributes[k] = v;
@@ -53,7 +54,7 @@ export function getAttribute(ctx, ref) {
53
54
  if (ref === "key")
54
55
  return ctx.key;
55
56
  if (!ref.startsWith("/"))
56
- return ctx.attributes[ref];
57
+ return Object.hasOwn(ctx.attributes, ref) ? ctx.attributes[ref] : undefined;
57
58
  const tokens = ref
58
59
  .slice(1)
59
60
  .split("/")
@@ -70,7 +71,7 @@ export function getAttribute(ctx, ref) {
70
71
  cur = cur[Number(t)];
71
72
  }
72
73
  else {
73
- cur = cur[t];
74
+ cur = Object.hasOwn(cur, t) ? cur[t] : undefined;
74
75
  }
75
76
  }
76
77
  return cur;
@@ -2,6 +2,6 @@ export * from "./types.js";
2
2
  export { parseContext, getAttribute, ContextError, type Context, type ContextInput, type SingleContext } from "./context.js";
3
3
  export { Evaluator, MemoryStore, evaluateFlag, errorDetail, type EvaluationResult, type PrerequisiteEvaluation } from "./evaluate.js";
4
4
  export { bucketContext, variationForRollout } from "./bucket.js";
5
- export { applyOperator, parseTime } from "./operators.js";
5
+ export { applyOperator, parseTime, isSafePattern, compilePattern, MAX_PATTERN_LENGTH, MAX_MATCH_INPUT, MAX_MATCH_PATTERN_LENGTH, MAX_MATCH_INPUT_LENGTH, type SafeRegex } from "./operators.js";
6
6
  export { parseSemVer, compareSemVer } from "./semver.js";
7
7
  export { sha1Hex } from "./sha1.js";
@@ -2,6 +2,6 @@ export * from "./types.js";
2
2
  export { parseContext, getAttribute, ContextError } from "./context.js";
3
3
  export { Evaluator, MemoryStore, evaluateFlag, errorDetail } from "./evaluate.js";
4
4
  export { bucketContext, variationForRollout } from "./bucket.js";
5
- export { applyOperator, parseTime } from "./operators.js";
5
+ export { applyOperator, parseTime, isSafePattern, compilePattern, MAX_PATTERN_LENGTH, MAX_MATCH_INPUT, MAX_MATCH_PATTERN_LENGTH, MAX_MATCH_INPUT_LENGTH } from "./operators.js";
6
6
  export { parseSemVer, compareSemVer } from "./semver.js";
7
7
  export { sha1Hex } from "./sha1.js";
@@ -1,4 +1,29 @@
1
1
  import type { FlagValue, Operator } from "./types.js";
2
+ /** `matches` limits: longer inputs or patterns never match (same limits as the DevPulse server). */
3
+ export declare const MAX_MATCH_INPUT_LENGTH = 1024;
4
+ export declare const MAX_MATCH_PATTERN_LENGTH = 500;
5
+ /** Server-parity aliases (`@devpulse/flag-engine` names). */
6
+ export declare const MAX_PATTERN_LENGTH = 500;
7
+ export declare const MAX_MATCH_INPUT = 1024;
8
+ /** A compiled user pattern: only `test()` is exposed. */
9
+ export type SafeRegex = {
10
+ test(input: string): boolean;
11
+ };
12
+ /**
13
+ * Compile a user-supplied pattern with RE2 (linear-time matching, no backtracking), so no
14
+ * pattern can stall the process however it's written (ReDoS), and every SDK agrees with the
15
+ * DevPulse server on what matches. RE2 has no backreferences or lookaround; patterns using
16
+ * them don't compile and return null (never match).
17
+ */
18
+ export declare function compilePattern(pattern: string): SafeRegex | null;
19
+ /** Whether a `matches` pattern is accepted: bounded length and valid RE2 syntax. */
20
+ export declare function isSafePattern(pattern: string): boolean;
21
+ /**
22
+ * @deprecated No longer used: `matches` runs on RE2, which is immune to ReDoS. Kept for
23
+ * backwards compatibility. Conservative heuristic for a group that contains a quantifier or
24
+ * alternation and is itself repeated (`(a+)+`, `(a|aa)+`, `(a+){2,}`).
25
+ */
26
+ export declare function hasNestedQuantifier(pattern: string): boolean;
2
27
  /** RFC 3339 timestamp or Unix milliseconds → epoch ms. */
3
28
  export declare function parseTime(v: unknown): number | null;
4
29
  /** Compare one context value with one clause value. Type mismatches never match. */
@@ -1,13 +1,28 @@
1
+ import { RE2JS } from "re2js";
1
2
  import { compareSemVer, parseSemVer } from "./semver.js";
3
+ /** `matches` limits: longer inputs or patterns never match (same limits as the DevPulse server). */
4
+ export const MAX_MATCH_INPUT_LENGTH = 1024;
5
+ export const MAX_MATCH_PATTERN_LENGTH = 500;
6
+ /** Server-parity aliases (`@devpulse/flag-engine` names). */
7
+ export const MAX_PATTERN_LENGTH = MAX_MATCH_PATTERN_LENGTH;
8
+ export const MAX_MATCH_INPUT = MAX_MATCH_INPUT_LENGTH;
2
9
  const regexCache = new Map();
3
- function regex(pattern) {
10
+ /**
11
+ * Compile a user-supplied pattern with RE2 (linear-time matching, no backtracking), so no
12
+ * pattern can stall the process however it's written (ReDoS), and every SDK agrees with the
13
+ * DevPulse server on what matches. RE2 has no backreferences or lookaround; patterns using
14
+ * them don't compile and return null (never match).
15
+ */
16
+ export function compilePattern(pattern) {
4
17
  if (!regexCache.has(pattern)) {
5
18
  let re = null;
6
- try {
7
- re = new RegExp(pattern);
8
- }
9
- catch {
10
- re = null;
19
+ if (pattern.length <= MAX_MATCH_PATTERN_LENGTH) {
20
+ try {
21
+ re = RE2JS.compile(pattern);
22
+ }
23
+ catch {
24
+ re = null;
25
+ }
11
26
  }
12
27
  if (regexCache.size > 1000)
13
28
  regexCache.clear();
@@ -15,6 +30,47 @@ function regex(pattern) {
15
30
  }
16
31
  return regexCache.get(pattern);
17
32
  }
33
+ /** Whether a `matches` pattern is accepted: bounded length and valid RE2 syntax. */
34
+ export function isSafePattern(pattern) {
35
+ return compilePattern(pattern) !== null;
36
+ }
37
+ /**
38
+ * @deprecated No longer used: `matches` runs on RE2, which is immune to ReDoS. Kept for
39
+ * backwards compatibility. Conservative heuristic for a group that contains a quantifier or
40
+ * alternation and is itself repeated (`(a+)+`, `(a|aa)+`, `(a+){2,}`).
41
+ */
42
+ export function hasNestedQuantifier(pattern) {
43
+ const stack = []; // per open group: contains a quantifier / alternation
44
+ for (let i = 0; i < pattern.length; i++) {
45
+ const ch = pattern[i];
46
+ if (ch === "\\") {
47
+ i++;
48
+ }
49
+ else if (ch === "[") {
50
+ for (i++; i < pattern.length && pattern[i] !== "]"; i++)
51
+ if (pattern[i] === "\\")
52
+ i++;
53
+ }
54
+ else if (ch === "(") {
55
+ stack.push(false);
56
+ if (pattern[i + 1] === "?")
57
+ i++; // group modifier, not a quantifier
58
+ }
59
+ else if (ch === ")") {
60
+ const risky = stack.pop() ?? false;
61
+ const next = pattern[i + 1];
62
+ const repeated = next === "*" || next === "+" || next === "{";
63
+ if (risky && repeated)
64
+ return true;
65
+ if ((risky || repeated) && stack.length)
66
+ stack[stack.length - 1] = true;
67
+ }
68
+ else if ((ch === "*" || ch === "+" || ch === "?" || ch === "{" || ch === "|") && stack.length) {
69
+ stack[stack.length - 1] = true;
70
+ }
71
+ }
72
+ return false;
73
+ }
18
74
  /** RFC 3339 timestamp or Unix milliseconds → epoch ms. */
19
75
  export function parseTime(v) {
20
76
  if (typeof v === "number" && Number.isFinite(v))
@@ -49,9 +105,9 @@ export function applyOperator(op, actual, expected) {
49
105
  case "contains":
50
106
  return typeof actual === "string" && typeof expected === "string" && actual.includes(expected);
51
107
  case "matches": {
52
- if (typeof actual !== "string" || typeof expected !== "string")
108
+ if (typeof actual !== "string" || typeof expected !== "string" || actual.length > MAX_MATCH_INPUT_LENGTH)
53
109
  return false;
54
- return regex(expected)?.test(actual) ?? false;
110
+ return compilePattern(expected)?.test(actual) ?? false;
55
111
  }
56
112
  case "lessThan":
57
113
  case "lessThanOrEqual":
@@ -1,9 +1,11 @@
1
1
  import { EventEmitter } from "node:events";
2
2
  import { type ContextInput, type EvaluationDetail, type FlagConfig, type FlagValue, type SegmentConfig } from "./engine/index.js";
3
- import { type Logger } from "./common/index.js";
4
- export type { ContextInput, EvaluationDetail, FlagValue, Logger };
3
+ import { SDK_VERSION, type Logger } from "./common/index.js";
4
+ import type { FlagSet } from "./client.js";
5
+ export type { ContextInput, EvaluationDetail, FlagSet, FlagValue, Logger };
6
+ export { SDK_VERSION };
5
7
  export interface NodeClientOptions {
6
- /** Base URL of your DevPulse deployment. */
8
+ /** Base URL of your DevPulse deployment (https required except on localhost). */
7
9
  baseUrl: string;
8
10
  /** Real-time updates over SSE (default). Set false to poll instead. */
9
11
  stream?: boolean;
@@ -26,17 +28,24 @@ export interface NodeClientOptions {
26
28
  export interface AllFlagsState {
27
29
  valid: boolean;
28
30
  values: Record<string, FlagValue>;
29
- /** Pass to the browser SDK's `bootstrap` option for flicker-free SSR. */
30
- toJSON(): Record<string, {
31
- value: FlagValue;
32
- variation: number | null;
33
- version: number;
34
- reason?: unknown;
35
- }>;
31
+ /** Pass to the browser SDK's `bootstrap` option for flicker-free SSR (same shape as the client endpoint). */
32
+ toJSON(): FlagSet;
33
+ }
34
+ export interface AllFlagsStateOptions {
35
+ /**
36
+ * Only flags marked "available to client-side SDKs" (default true), so the result is safe to
37
+ * embed in HTML. Pass false to include every flag, e.g. for server-side use only.
38
+ */
39
+ clientSideOnly?: boolean;
40
+ /** Include evaluation reasons (always included for experiments). */
41
+ withReasons?: boolean;
36
42
  }
37
43
  /**
38
44
  * Server-side client. One instance per process and SDK key.
39
45
  *
46
+ * Events: "ready", "failed" (fatal, e.g. an invalid SDK key), "update" / "update:<key>",
47
+ * "stale" (connection lost; the last data keeps serving) and "recovered".
48
+ *
40
49
  * const client = init(process.env.DEVPULSE_SDK_KEY!, { baseUrl });
41
50
  * await client.waitForInitialization({ timeoutMs: 5000 });
42
51
  * const enabled = await client.variation("new-checkout", { key: user.id, email: user.email }, false);
@@ -54,6 +63,7 @@ export declare class DevPulseNodeClient extends EventEmitter {
54
63
  private resolveReady;
55
64
  private rejectReady;
56
65
  private streamFailures;
66
+ private stale;
57
67
  private readonly logger;
58
68
  private readonly base;
59
69
  constructor(sdkKey: string, options: NodeClientOptions);
@@ -65,6 +75,8 @@ export declare class DevPulseNodeClient extends EventEmitter {
65
75
  private applyPut;
66
76
  private emitUpdate;
67
77
  private markReady;
78
+ /** "stale" when updates stop arriving (flags keep serving the last data), "recovered" when they resume. */
79
+ private setStale;
68
80
  private fail;
69
81
  waitForInitialization(opts?: {
70
82
  timeoutMs?: number;
@@ -80,13 +92,13 @@ export declare class DevPulseNodeClient extends EventEmitter {
80
92
  numberVariation(key: string, context: ContextInput, defaultValue: number): Promise<number>;
81
93
  jsonVariation<T extends FlagValue>(key: string, context: ContextInput, defaultValue: T): Promise<T>;
82
94
  /**
83
- * Evaluate every flag for a context, e.g. to bootstrap a browser client
95
+ * Evaluate every client-side flag for a context, e.g. to bootstrap a browser client
84
96
  * during server rendering. Doesn't record analytics.
97
+ *
98
+ * const state = await client.allFlagsState(context);
99
+ * createClient(id, context, { baseUrl, bootstrap: state.toJSON() });
85
100
  */
86
- allFlagsState(context: ContextInput, opts?: {
87
- clientSideOnly?: boolean;
88
- withReasons?: boolean;
89
- }): Promise<AllFlagsState>;
101
+ allFlagsState(context: ContextInput, opts?: AllFlagsStateOptions): Promise<AllFlagsState>;
90
102
  /** Secure mode hash for a context, for the browser SDK's `hash` option. */
91
103
  secureModeHash(context: ContextInput): string;
92
104
  track(key: string, context: ContextInput, data?: unknown, metricValue?: number): void;
@@ -1,10 +1,15 @@
1
1
  import { createHmac } from "node:crypto";
2
2
  import { EventEmitter } from "node:events";
3
3
  import { Evaluator, MemoryStore, evaluateFlag, parseContext } from "./engine/index.js";
4
- import { EventProcessor, SseClient, noopLogger } from "./common/index.js";
4
+ import { EventProcessor, SDK_VERSION, SseClient, noopLogger } from "./common/index.js";
5
+ import { assertSecureUrl } from "../config.js";
6
+ export { SDK_VERSION };
5
7
  /**
6
8
  * Server-side client. One instance per process and SDK key.
7
9
  *
10
+ * Events: "ready", "failed" (fatal, e.g. an invalid SDK key), "update" / "update:<key>",
11
+ * "stale" (connection lost; the last data keeps serving) and "recovered".
12
+ *
8
13
  * const client = init(process.env.DEVPULSE_SDK_KEY!, { baseUrl });
9
14
  * await client.waitForInitialization({ timeoutMs: 5000 });
10
15
  * const enabled = await client.variation("new-checkout", { key: user.id, email: user.email }, false);
@@ -22,6 +27,7 @@ export class DevPulseNodeClient extends EventEmitter {
22
27
  resolveReady;
23
28
  rejectReady;
24
29
  streamFailures = 0;
30
+ stale = false;
25
31
  logger;
26
32
  base;
27
33
  constructor(sdkKey, options) {
@@ -41,6 +47,8 @@ export class DevPulseNodeClient extends EventEmitter {
41
47
  this.markReady();
42
48
  return;
43
49
  }
50
+ // Checked once the SDK key will actually be sent (offline / fixture clients make no requests).
51
+ assertSecureUrl("baseUrl", options.baseUrl);
44
52
  if (options.sendEvents !== false) {
45
53
  this.events = new EventProcessor({
46
54
  url: `${this.base}/sdk/v1/events`,
@@ -58,16 +66,20 @@ export class DevPulseNodeClient extends EventEmitter {
58
66
  this.startStreaming();
59
67
  }
60
68
  headers() {
61
- return { authorization: this.sdkKey, "user-agent": "DevPulseNode/0.1.0" };
69
+ return { authorization: this.sdkKey, "user-agent": `DevPulseNode/${SDK_VERSION}` };
62
70
  }
63
71
  startStreaming() {
64
72
  this.stream = new SseClient({
65
73
  url: `${this.base}/sdk/v1/server/stream`,
66
74
  headers: () => this.headers(),
67
75
  fetch: this.options.fetch,
76
+ browser: false,
77
+ // Full rulesets (segments with big target lists) can be large.
78
+ maxEventSize: 64 * 1024 * 1024,
68
79
  onOpen: () => {
69
80
  this.streamFailures = 0;
70
81
  this.stopPolling();
82
+ this.setStale(false);
71
83
  },
72
84
  onEvent: (ev) => this.onStreamEvent(ev.type, ev.data),
73
85
  onError: (err) => {
@@ -76,6 +88,9 @@ export class DevPulseNodeClient extends EventEmitter {
76
88
  this.fail(new Error("Invalid SDK key"));
77
89
  return false;
78
90
  }
91
+ // While polling, the poll result decides whether data is stale.
92
+ if (!this.poller)
93
+ this.setStale(true, err);
79
94
  // After repeated failures, poll so data stays fresh while we keep retrying the stream.
80
95
  if (++this.streamFailures >= 3)
81
96
  this.startPolling();
@@ -91,17 +106,21 @@ export class DevPulseNodeClient extends EventEmitter {
91
106
  const poll = async () => {
92
107
  try {
93
108
  const res = await (this.options.fetch ?? fetch)(`${this.base}/sdk/v1/server/flags`, { headers: { ...this.headers(), ...(this.etag ? { "if-none-match": this.etag } : {}) } });
94
- if (res.status === 304)
109
+ if (res.status === 304) {
110
+ this.setStale(false);
95
111
  return this.markReady();
112
+ }
96
113
  if (res.status === 401 || res.status === 403)
97
114
  return this.fail(new Error("Invalid SDK key"));
98
115
  if (!res.ok)
99
116
  throw new Error(`Poll failed with ${res.status}`);
100
117
  this.etag = res.headers.get("etag");
101
118
  this.applyPut((await res.json()));
119
+ this.setStale(false);
102
120
  }
103
121
  catch (err) {
104
122
  this.logger.warn("DevPulse poll failed", err);
123
+ this.setStale(true, err);
105
124
  }
106
125
  };
107
126
  this.poller = setInterval(poll, interval);
@@ -160,6 +179,16 @@ export class DevPulseNodeClient extends EventEmitter {
160
179
  this.resolveReady();
161
180
  this.emit("ready");
162
181
  }
182
+ /** "stale" when updates stop arriving (flags keep serving the last data), "recovered" when they resume. */
183
+ setStale(stale, err) {
184
+ if (!this.initialized || this.stale === stale)
185
+ return;
186
+ this.stale = stale;
187
+ if (stale)
188
+ this.emit("stale", err);
189
+ else
190
+ this.emit("recovered");
191
+ }
163
192
  fail(err) {
164
193
  if (!this.initialized)
165
194
  this.rejectReady(err);
@@ -170,7 +199,12 @@ export class DevPulseNodeClient extends EventEmitter {
170
199
  const ready = this.ready.then(() => this);
171
200
  if (!opts.timeoutMs)
172
201
  return ready;
173
- return Promise.race([ready, new Promise((_, rej) => setTimeout(() => rej(new Error("DevPulse initialization timed out")), opts.timeoutMs).unref?.())]);
202
+ let timer;
203
+ const timeout = new Promise((_, rej) => {
204
+ // Not unref'd: someone is awaiting it. Cleared as soon as either side settles.
205
+ timer = setTimeout(() => rej(new Error("DevPulse initialization timed out")), opts.timeoutMs);
206
+ });
207
+ return Promise.race([ready, timeout]).finally(() => clearTimeout(timer));
174
208
  }
175
209
  get isInitialized() {
176
210
  return this.initialized;
@@ -214,10 +248,14 @@ export class DevPulseNodeClient extends EventEmitter {
214
248
  return this.variation(key, context, defaultValue);
215
249
  }
216
250
  /**
217
- * Evaluate every flag for a context, e.g. to bootstrap a browser client
251
+ * Evaluate every client-side flag for a context, e.g. to bootstrap a browser client
218
252
  * during server rendering. Doesn't record analytics.
253
+ *
254
+ * const state = await client.allFlagsState(context);
255
+ * createClient(id, context, { baseUrl, bootstrap: state.toJSON() });
219
256
  */
220
257
  async allFlagsState(context, opts = {}) {
258
+ const clientSideOnly = opts.clientSideOnly ?? true;
221
259
  let ctx;
222
260
  try {
223
261
  ctx = parseContext(context);
@@ -228,10 +266,19 @@ export class DevPulseNodeClient extends EventEmitter {
228
266
  const evaluator = new Evaluator(this.store);
229
267
  const state = {};
230
268
  for (const f of this.store.flags.values()) {
231
- if (opts.clientSideOnly && !f.clientSide)
269
+ if (f.deleted || (clientSideOnly && !f.clientSide))
232
270
  continue;
233
271
  const { detail } = evaluator.evaluate(f, ctx);
234
- state[f.key] = { value: detail.value, variation: detail.variationIndex, version: f.version, ...(opts.withReasons ? { reason: detail.reason } : {}) };
272
+ // Same shape as the DevPulse client endpoint, so client-side analytics and experiments keep working.
273
+ const inExperiment = "inExperiment" in detail.reason && !!detail.reason.inExperiment;
274
+ state[f.key] = {
275
+ value: detail.value,
276
+ variation: detail.variationIndex,
277
+ version: f.version,
278
+ ...(opts.withReasons || inExperiment ? { reason: detail.reason } : {}),
279
+ ...(f.trackEvents || inExperiment ? { trackEvents: true } : {}),
280
+ ...(inExperiment ? { trackReason: true } : {}),
281
+ };
235
282
  }
236
283
  return { valid: this.initialized, values: Object.fromEntries(Object.entries(state).map(([k, v]) => [k, v.value])), toJSON: () => state };
237
284
  }
@@ -10,9 +10,11 @@ export interface DevPulseProviderProps {
10
10
  children: ReactNode;
11
11
  }
12
12
  /**
13
- * Provides a DevPulse client to the tree. On the server (SSR/RSC streaming)
14
- * no network connection is opened; flags come from `options.bootstrap`, so the
15
- * first client render matches the server HTML. Changing `context` calls identify().
13
+ * Provides a DevPulse client to the tree. On the server (SSR/RSC streaming) and during the
14
+ * first client render, an offline client serves `options.bootstrap` and makes no requests, so
15
+ * hydration matches the server HTML. The live client is created (and closed) in an effect,
16
+ * which keeps StrictMode's simulated unmount from leaving a closed client behind. Changing
17
+ * `context` calls identify().
16
18
  */
17
19
  export declare function DevPulseProvider({ clientSideId, context, options, client: external, children }: DevPulseProviderProps): import("react").JSX.Element;
18
20
  export declare function useDevPulseClient(): DevPulseClient;
@@ -23,5 +25,5 @@ export declare function useFlagDetail<T extends FlagValue>(key: string, defaultV
23
25
  export declare function useFlags(): Record<string, FlagValue>;
24
26
  /** True once flags have loaded (always true when bootstrapped). */
25
27
  export declare function useFlagsReady(): boolean;
26
- /** Track a custom event for metrics and experiments. */
28
+ /** Track a custom event for metrics and experiments. Stable across renders (safe in effect deps). */
27
29
  export declare function useTrack(): (key: string, data?: unknown, metricValue?: number) => void;