@shelbyb/daywalker-sdk 0.3.0 → 0.4.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.
Files changed (54) hide show
  1. package/README.md +64 -23
  2. package/dist/analytics/index.d.ts +16 -2
  3. package/dist/analytics/index.js +79 -14
  4. package/dist/cli.js +133 -31
  5. package/dist/client.d.ts +29 -2
  6. package/dist/client.js +314 -62
  7. package/dist/config.d.ts +8 -2
  8. package/dist/config.js +14 -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 +42 -12
  16. package/dist/crypto/signatures.js +236 -79
  17. package/dist/express.d.ts +1 -0
  18. package/dist/express.js +8 -6
  19. package/dist/flags/client.d.ts +32 -4
  20. package/dist/flags/client.js +74 -12
  21. package/dist/flags/common/events.d.ts +9 -0
  22. package/dist/flags/common/events.js +51 -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/index.d.ts +1 -1
  30. package/dist/flags/engine/index.js +1 -1
  31. package/dist/flags/engine/operators.d.ts +20 -3
  32. package/dist/flags/engine/operators.js +38 -32
  33. package/dist/flags/node.d.ts +26 -14
  34. package/dist/flags/node.js +51 -7
  35. package/dist/flags/react.d.ts +6 -4
  36. package/dist/flags/react.js +47 -17
  37. package/dist/index.d.ts +7 -6
  38. package/dist/index.js +4 -4
  39. package/dist/middleware.d.ts +18 -0
  40. package/dist/middleware.js +28 -7
  41. package/dist/next.d.ts +52 -8
  42. package/dist/next.js +151 -37
  43. package/dist/openfeature/server.d.ts +3 -0
  44. package/dist/openfeature/server.js +14 -2
  45. package/dist/openfeature/shared.d.ts +1 -1
  46. package/dist/openfeature/shared.js +22 -2
  47. package/dist/openfeature/web.js +9 -3
  48. package/dist/types.d.ts +56 -4
  49. package/dist/types.js +6 -0
  50. package/docs/analytics.md +1 -1
  51. package/docs/flags-node.md +1 -1
  52. package/docs/flags.md +2 -1
  53. package/package.json +32 -14
  54. 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";
@@ -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,10 +1,27 @@
1
1
  import type { FlagValue, Operator } from "./types.js";
2
- /** `matches` limits: longer inputs or patterns never match. */
2
+ /** `matches` limits: longer inputs or patterns never match (same limits as the DevPulse server). */
3
3
  export declare const MAX_MATCH_INPUT_LENGTH = 1024;
4
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
+ };
5
12
  /**
6
- * Conservative ReDoS heuristic: a group that contains a quantifier or alternation and is itself
7
- * repeated (`(a+)+`, `(a*)*`, `(a|aa)+`, `(?:x+y)*`, `(a+){2,}`). Character classes and escapes are skipped.
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,}`).
8
25
  */
9
26
  export declare function hasNestedQuantifier(pattern: string): boolean;
10
27
  /** RFC 3339 timestamp or Unix milliseconds → epoch ms. */
@@ -1,12 +1,43 @@
1
+ import { RE2JS } from "re2js";
1
2
  import { compareSemVer, parseSemVer } from "./semver.js";
2
- const regexCache = new Map();
3
- /** `matches` limits: longer inputs or patterns never match. */
3
+ /** `matches` limits: longer inputs or patterns never match (same limits as the DevPulse server). */
4
4
  export const MAX_MATCH_INPUT_LENGTH = 1024;
5
5
  export const MAX_MATCH_PATTERN_LENGTH = 500;
6
- let warnedUnsafePattern = false;
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;
9
+ const regexCache = new Map();
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) {
17
+ if (!regexCache.has(pattern)) {
18
+ let re = null;
19
+ if (pattern.length <= MAX_MATCH_PATTERN_LENGTH) {
20
+ try {
21
+ re = RE2JS.compile(pattern);
22
+ }
23
+ catch {
24
+ re = null;
25
+ }
26
+ }
27
+ if (regexCache.size > 1000)
28
+ regexCache.clear();
29
+ regexCache.set(pattern, re);
30
+ }
31
+ return regexCache.get(pattern);
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
+ }
7
37
  /**
8
- * Conservative ReDoS heuristic: a group that contains a quantifier or alternation and is itself
9
- * repeated (`(a+)+`, `(a*)*`, `(a|aa)+`, `(?:x+y)*`, `(a+){2,}`). Character classes and escapes are skipped.
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,}`).
10
41
  */
11
42
  export function hasNestedQuantifier(pattern) {
12
43
  const stack = []; // per open group: contains a quantifier / alternation
@@ -40,29 +71,6 @@ export function hasNestedQuantifier(pattern) {
40
71
  }
41
72
  return false;
42
73
  }
43
- function regex(pattern) {
44
- if (!regexCache.has(pattern)) {
45
- let re = null;
46
- if (pattern.length > MAX_MATCH_PATTERN_LENGTH || hasNestedQuantifier(pattern)) {
47
- if (!warnedUnsafePattern) {
48
- warnedUnsafePattern = true;
49
- console.warn("DevPulse: a `matches` pattern was too long or had nested quantifiers and is treated as no match");
50
- }
51
- }
52
- else {
53
- try {
54
- re = new RegExp(pattern);
55
- }
56
- catch {
57
- re = null;
58
- }
59
- }
60
- if (regexCache.size > 1000)
61
- regexCache.clear();
62
- regexCache.set(pattern, re);
63
- }
64
- return regexCache.get(pattern);
65
- }
66
74
  /** RFC 3339 timestamp or Unix milliseconds → epoch ms. */
67
75
  export function parseTime(v) {
68
76
  if (typeof v === "number" && Number.isFinite(v))
@@ -97,11 +105,9 @@ export function applyOperator(op, actual, expected) {
97
105
  case "contains":
98
106
  return typeof actual === "string" && typeof expected === "string" && actual.includes(expected);
99
107
  case "matches": {
100
- if (typeof actual !== "string" || typeof expected !== "string")
101
- return false;
102
- if (actual.length > MAX_MATCH_INPUT_LENGTH)
108
+ if (typeof actual !== "string" || typeof expected !== "string" || actual.length > MAX_MATCH_INPUT_LENGTH)
103
109
  return false;
104
- return regex(expected)?.test(actual) ?? false;
110
+ return compilePattern(expected)?.test(actual) ?? false;
105
111
  }
106
112
  case "lessThan":
107
113
  case "lessThanOrEqual":
@@ -1,7 +1,9 @@
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
8
  /** Base URL of your DevPulse deployment (https required except on localhost). */
7
9
  baseUrl: string;
@@ -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,11 +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
5
  import { assertSecureUrl } from "../config.js";
6
+ export { SDK_VERSION };
6
7
  /**
7
8
  * Server-side client. One instance per process and SDK key.
8
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
+ *
9
13
  * const client = init(process.env.DEVPULSE_SDK_KEY!, { baseUrl });
10
14
  * await client.waitForInitialization({ timeoutMs: 5000 });
11
15
  * const enabled = await client.variation("new-checkout", { key: user.id, email: user.email }, false);
@@ -23,6 +27,7 @@ export class DevPulseNodeClient extends EventEmitter {
23
27
  resolveReady;
24
28
  rejectReady;
25
29
  streamFailures = 0;
30
+ stale = false;
26
31
  logger;
27
32
  base;
28
33
  constructor(sdkKey, options) {
@@ -61,16 +66,20 @@ export class DevPulseNodeClient extends EventEmitter {
61
66
  this.startStreaming();
62
67
  }
63
68
  headers() {
64
- return { authorization: this.sdkKey, "user-agent": "DevPulseNode/0.1.0" };
69
+ return { authorization: this.sdkKey, "user-agent": `DevPulseNode/${SDK_VERSION}` };
65
70
  }
66
71
  startStreaming() {
67
72
  this.stream = new SseClient({
68
73
  url: `${this.base}/sdk/v1/server/stream`,
69
74
  headers: () => this.headers(),
70
75
  fetch: this.options.fetch,
76
+ browser: false,
77
+ // Full rulesets (segments with big target lists) can be large.
78
+ maxEventSize: 64 * 1024 * 1024,
71
79
  onOpen: () => {
72
80
  this.streamFailures = 0;
73
81
  this.stopPolling();
82
+ this.setStale(false);
74
83
  },
75
84
  onEvent: (ev) => this.onStreamEvent(ev.type, ev.data),
76
85
  onError: (err) => {
@@ -79,6 +88,9 @@ export class DevPulseNodeClient extends EventEmitter {
79
88
  this.fail(new Error("Invalid SDK key"));
80
89
  return false;
81
90
  }
91
+ // While polling, the poll result decides whether data is stale.
92
+ if (!this.poller)
93
+ this.setStale(true, err);
82
94
  // After repeated failures, poll so data stays fresh while we keep retrying the stream.
83
95
  if (++this.streamFailures >= 3)
84
96
  this.startPolling();
@@ -94,17 +106,21 @@ export class DevPulseNodeClient extends EventEmitter {
94
106
  const poll = async () => {
95
107
  try {
96
108
  const res = await (this.options.fetch ?? fetch)(`${this.base}/sdk/v1/server/flags`, { headers: { ...this.headers(), ...(this.etag ? { "if-none-match": this.etag } : {}) } });
97
- if (res.status === 304)
109
+ if (res.status === 304) {
110
+ this.setStale(false);
98
111
  return this.markReady();
112
+ }
99
113
  if (res.status === 401 || res.status === 403)
100
114
  return this.fail(new Error("Invalid SDK key"));
101
115
  if (!res.ok)
102
116
  throw new Error(`Poll failed with ${res.status}`);
103
117
  this.etag = res.headers.get("etag");
104
118
  this.applyPut((await res.json()));
119
+ this.setStale(false);
105
120
  }
106
121
  catch (err) {
107
122
  this.logger.warn("DevPulse poll failed", err);
123
+ this.setStale(true, err);
108
124
  }
109
125
  };
110
126
  this.poller = setInterval(poll, interval);
@@ -163,6 +179,16 @@ export class DevPulseNodeClient extends EventEmitter {
163
179
  this.resolveReady();
164
180
  this.emit("ready");
165
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
+ }
166
192
  fail(err) {
167
193
  if (!this.initialized)
168
194
  this.rejectReady(err);
@@ -173,7 +199,12 @@ export class DevPulseNodeClient extends EventEmitter {
173
199
  const ready = this.ready.then(() => this);
174
200
  if (!opts.timeoutMs)
175
201
  return ready;
176
- 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));
177
208
  }
178
209
  get isInitialized() {
179
210
  return this.initialized;
@@ -217,10 +248,14 @@ export class DevPulseNodeClient extends EventEmitter {
217
248
  return this.variation(key, context, defaultValue);
218
249
  }
219
250
  /**
220
- * 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
221
252
  * during server rendering. Doesn't record analytics.
253
+ *
254
+ * const state = await client.allFlagsState(context);
255
+ * createClient(id, context, { baseUrl, bootstrap: state.toJSON() });
222
256
  */
223
257
  async allFlagsState(context, opts = {}) {
258
+ const clientSideOnly = opts.clientSideOnly ?? true;
224
259
  let ctx;
225
260
  try {
226
261
  ctx = parseContext(context);
@@ -231,10 +266,19 @@ export class DevPulseNodeClient extends EventEmitter {
231
266
  const evaluator = new Evaluator(this.store);
232
267
  const state = {};
233
268
  for (const f of this.store.flags.values()) {
234
- if (opts.clientSideOnly && !f.clientSide)
269
+ if (f.deleted || (clientSideOnly && !f.clientSide))
235
270
  continue;
236
271
  const { detail } = evaluator.evaluate(f, ctx);
237
- 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
+ };
238
282
  }
239
283
  return { valid: this.initialized, values: Object.fromEntries(Object.entries(state).map(([k, v]) => [k, v.value])), toJSON: () => state };
240
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;