@flagbearer/node 0.1.4 → 0.1.5

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 CHANGED
@@ -10,83 +10,10 @@ Requires Node.js 22 or later.
10
10
  npm install @flagbearer/node
11
11
  ```
12
12
 
13
- ## Keys
13
+ ## Docs
14
14
 
15
- Use a server key, which starts with `fbs_`. Server keys are secret, so load them
16
- from your environment and never include them in browser code. Browsers use client
17
- keys, which start with `fbc_`, through `@flagbearer/web`.
18
-
19
- ## Usage
20
-
21
- ```ts
22
- import { init } from "@flagbearer/node";
23
-
24
- const flags = init({
25
- key: process.env.FLAGBEARER_SERVER_KEY!,
26
- });
27
-
28
- try {
29
- await flags.sync(); // Optional: wait for fresh rules before accepting requests.
30
- } catch (error) {
31
- console.error("Initial Flagbearer sync failed", error);
32
- // Continue using defaults; the SDK retries transient failures in the background.
33
- }
34
-
35
- const enabled = flags.isEnabled(
36
- "advanced-search",
37
- { key: "123", email: "jane@example.com" },
38
- false, // Application default when the rule is unknown or can't be evaluated.
39
- );
40
-
41
- // Keep one client per process, pass each request's user explicitly, and close it
42
- // during application shutdown rather than after each request.
43
- flags.close();
44
- ```
45
-
46
- `url` is optional and defaults to `https://sdk.flagbearer.dev`, the SDK API
47
- origin. Override it with the SDK URL printed by `bun run dev` (by default
48
- `http://127.0.0.1:8081`) for local development, or with a self-hosted
49
- deployment's SDK origin; use HTTPS for remote production servers. Secret server keys are sent in
50
- `Authorization: Bearer ...` headers for both HTTP requests and streaming, never
51
- in URLs. Do not include server keys in browser code. Requests reject redirects
52
- rather than following a changed API location.
53
-
54
- ## Refreshing and outages
55
-
56
- Pass `debug: true` to `init` to log stream connections, received versions, refresh
57
- attempts, retries, and applied snapshot versions using `console.debug`. Debugging
58
- is off by default; existing error logs are independent of this option. Logs never
59
- include SDK keys, user identities, flag names, or ruleset contents.
60
-
61
- For a change delivered over SSE, look for both `Stream version received` and
62
- `Snapshot applied` with the same version. A notification alone doesn't mean the
63
- SDK has downloaded the new rules yet. `Snapshot applied` also appears for initial,
64
- manual, and polling refreshes; it doesn't imply flag values actually changed.
65
-
66
- - Initialization opens an authenticated SSE stream and starts fetching rules.
67
- - The internal `@flagbearer/sdk-core` coordinator handles version tracking,
68
- request coalescing, five-second request timeouts, retries, and fallback polling.
69
- - Valid rules are indexed by flag key and replaced together. `isEnabled` is
70
- synchronous and uses only the current in-memory rules.
71
- - A newer streamed version triggers a ruleset refresh within two seconds. Older responses cannot
72
- replace a newer ruleset. Reconnecting also triggers a refresh.
73
- - The Node stream uses `fetch`, supports chunked SSE data and heartbeats, and
74
- reconnects with jittered backoff capped at 30 seconds. Connection setup times
75
- out after five seconds; 45 seconds without stream data triggers reconnection.
76
- - While streaming is disconnected, rules are polled about every 30 seconds. HTTP 408,
77
- 429, and server errors are retried, honoring longer `Retry-After` delays.
78
- - Invalid rules or permanent ruleset errors stop automatic refreshes and close
79
- the stream. Existing rules remain usable. An explicit `sync()` can retry;
80
- successful recovery resumes polling, not the closed stream.
81
- - A permanent stream error stops stream reconnects but leaves polling available.
82
- - Explicit `sync()` failures reject; background refresh failures are logged.
83
- - `close()` aborts requests and streaming and clears timers. It is idempotent;
84
- later `sync()` calls reject. Existing in-memory rules can still be evaluated.
85
-
86
- Until the first valid ruleset arrives, checks use the supplied default. During an
87
- outage, the last valid ruleset stays in memory. There is **no disk cache yet**:
88
- restarting the application during an outage means using defaults until a fetch
89
- succeeds. This SDK requires a Node runtime with built-in `fetch` and web streams.
15
+ Keys, syncing at startup, per-request checks, shutdown and exposures are covered
16
+ at [docs.flagbearer.dev/sdks/node](https://docs.flagbearer.dev/sdks/node).
90
17
 
91
18
  ## Development
92
19
 
@@ -107,42 +34,5 @@ Bun is used as the build tool; the output targets Node.js.
107
34
 
108
35
  ## Integration tests
109
36
 
110
- The integration suite runs this SDK in a separate Node HTTP server alongside
111
- the browser SDK and an isolated backend:
112
-
113
- ```sh
114
- bun run test:integration
115
- ```
116
-
117
- See [the integration README](../../tests/integration/README.md) for setup and coverage.
118
-
119
- ## Exposure reporting
120
-
121
- Flag checks automatically report the boolean actually returned, its evaluation
122
- reason (`off`, `tester`, `rollout`, or `fallback`), the configuration version, and
123
- optional user key/email. Reporting runs in every environment and never waits for
124
- network activity in `isEnabled`. Pass `appVersion` to `init` to identify your
125
- application deployment in the stored records.
126
-
127
- Repeated checks are counted in fixed 30-second buckets by user, flag, result,
128
- reason, and configuration version. Uploads run every 10 seconds in batches of up
129
- to 100 aggregates. The memory buffer holds up to 1,000 aggregates. Overflow and
130
- rejected checks are reported as environment-level reporting loss when delivery
131
- recovers. Records keep their IDs and contents across retries; HTTP retries do not
132
- increase stored counts. The server currently accepts up to 6,000 batches per
133
- minute per environment and returns `429` beyond that limit.
134
-
135
- For shutdown or serverless invocations, explicitly await delivery before closing:
136
-
137
- ```ts
138
- try {
139
- await flags.flush();
140
- } finally {
141
- flags.close();
142
- }
143
- ```
144
-
145
- `flush()` rejects on upload failure so callers can choose their shutdown policy.
146
- `close()` also starts a best-effort flush and stops recurring reporting. Delivery
147
- is best effort: abrupt process exits can lose in-memory checks. Old SDK versions
148
- do not report exposure; no observed activity does not prove a path is unused.
37
+ Flagbearer's integration tests run this SDK against the real backend through
38
+ [`testing/servers/node-server.ts`](../../testing/servers/node-server.ts).
package/dist/index.js CHANGED
@@ -32,7 +32,7 @@ function evaluateFlag(flag, user, version = null, defaultValue = false) {
32
32
  configurationVersion: version
33
33
  };
34
34
  }
35
- // ../sdk-core/src/clock.ts
35
+ // ../core/src/clock.ts
36
36
  var realClock = {
37
37
  now: () => Date.now(),
38
38
  setTimeout(callback, delayMs) {
@@ -41,7 +41,7 @@ var realClock = {
41
41
  }
42
42
  };
43
43
 
44
- // ../sdk-core/src/debug.ts
44
+ // ../core/src/debug.ts
45
45
  function createDebugLogger(enabled = false) {
46
46
  return (message, details) => {
47
47
  if (!enabled)
@@ -55,7 +55,7 @@ function createDebugLogger(enabled = false) {
55
55
  };
56
56
  }
57
57
 
58
- // ../sdk-core/src/validation.ts
58
+ // ../core/src/validation.ts
59
59
  function isRecord(value) {
60
60
  return typeof value === "object" && value !== null && !Array.isArray(value);
61
61
  }
@@ -84,7 +84,7 @@ function isVersionEvent(value) {
84
84
  return isRecord(value) && isVersion(value.version);
85
85
  }
86
86
 
87
- // ../sdk-core/src/refresh.ts
87
+ // ../core/src/refresh.ts
88
88
  var REQUEST_TIMEOUT_MS = 5000;
89
89
  var POLL_INTERVAL_MS = 30000;
90
90
  var MAX_RETRY_MS = 30000;
@@ -156,6 +156,8 @@ function createRefreshCoordinator(options) {
156
156
  let cancelVersionRefresh;
157
157
  let retryAttempt = 0;
158
158
  let notBefore = 0;
159
+ let serverNotBefore = 0;
160
+ let lastCheckedAt = Number.NEGATIVE_INFINITY;
159
161
  function clearRetry() {
160
162
  cancelRetry?.();
161
163
  cancelRetry = undefined;
@@ -196,6 +198,9 @@ function createRefreshCoordinator(options) {
196
198
  const jittered = Math.min(MAX_RETRY_MS, backoff * (0.8 + Math.random() * 0.4));
197
199
  const delay = Math.max(jittered, error instanceof RefreshError ? error.retryAfterMs : 0);
198
200
  notBefore = clock.now() + delay;
201
+ if (error instanceof RefreshError && error.retryAfterMs > 0) {
202
+ serverNotBefore = Math.max(serverNotBefore, clock.now() + error.retryAfterMs);
203
+ }
199
204
  clearRetry();
200
205
  debug("Refresh retry scheduled", {
201
206
  delayMs: Math.ceil(delay),
@@ -229,6 +234,7 @@ function createRefreshCoordinator(options) {
229
234
  cancelRequestTimeout = undefined;
230
235
  if (stopped)
231
236
  return;
237
+ lastCheckedAt = clock.now();
232
238
  if (result === null) {
233
239
  debug("Snapshot unchanged", { version });
234
240
  if (isBehindWantedVersion()) {
@@ -266,6 +272,7 @@ function createRefreshCoordinator(options) {
266
272
  } catch (error) {
267
273
  if (stopped)
268
274
  return;
275
+ options.onRefreshFailed?.(error);
269
276
  if (error instanceof RefreshError && !error.retryable) {
270
277
  debug("Automatic refresh stopped after permanent failure");
271
278
  automaticBlocked = true;
@@ -335,6 +342,28 @@ function createRefreshCoordinator(options) {
335
342
  ensurePolling();
336
343
  }
337
344
  }
345
+ function resume() {
346
+ if (stopped || automaticBlocked)
347
+ return;
348
+ if (clock.now() - lastCheckedAt < (options.resumeStaleMs ?? 15000)) {
349
+ ensurePolling();
350
+ return;
351
+ }
352
+ if (clock.now() >= serverNotBefore) {
353
+ clearRetry();
354
+ notBefore = 0;
355
+ retryAttempt = 0;
356
+ }
357
+ clearPolling();
358
+ refreshInBackground();
359
+ ensurePolling();
360
+ }
361
+ function pause() {
362
+ if (stopped)
363
+ return;
364
+ streaming = false;
365
+ clearPolling();
366
+ }
338
367
  function close() {
339
368
  if (stopped)
340
369
  return;
@@ -349,16 +378,26 @@ function createRefreshCoordinator(options) {
349
378
  activeRequest?.abort();
350
379
  }
351
380
  ensurePolling();
352
- return { sync, refreshInBackground, onVersion, setStreaming, close };
381
+ return {
382
+ sync,
383
+ refreshInBackground,
384
+ onVersion,
385
+ setStreaming,
386
+ resume,
387
+ pause,
388
+ close
389
+ };
353
390
  }
354
- // ../sdk-core/src/exposures.ts
391
+ // ../core/src/exposures.ts
355
392
  var WINDOW_MS = 30000;
393
+ var FLUSH_MS = 30000;
356
394
  var MAX_RECORDS = 1000;
357
395
  var BATCH_SIZE = 100;
358
396
  function createExposureReporter(options) {
359
397
  const reporterId = crypto.randomUUID();
360
398
  const active = new Map;
361
- const pending = [];
399
+ const sealed = [];
400
+ let sealedEvents = 0;
362
401
  let droppedChecks = 0;
363
402
  let reportedDroppedChecks = 0;
364
403
  let inFlight;
@@ -387,39 +426,62 @@ function createExposureReporter(options) {
387
426
  bucket
388
427
  ]);
389
428
  const existing = active.get(key);
390
- if (existing && existing.checkCount < 1e9) {
391
- existing.checkCount++;
392
- existing.lastCheckedAt = new Date(now).toISOString();
429
+ if (existing && existing.event.checkCount < 1e9) {
430
+ existing.event.checkCount++;
431
+ existing.event.lastCheckedAt = new Date(now).toISOString();
393
432
  return;
394
433
  }
395
- if (existing || active.size + pending.length >= MAX_RECORDS) {
434
+ if (existing || active.size + sealedEvents >= MAX_RECORDS) {
396
435
  droppedChecks++;
397
436
  return;
398
437
  }
399
438
  active.set(key, {
400
- eventId: crypto.randomUUID(),
401
- flagKey,
402
- userKey,
403
- userEmail: email,
404
- result: details.value,
405
- reason: details.reason,
406
- configurationVersion: details.configurationVersion,
407
- bucket,
408
- checkCount: 1,
409
- firstCheckedAt: new Date(now).toISOString(),
410
- lastCheckedAt: new Date(now).toISOString()
439
+ user: { key: userKey, email },
440
+ event: {
441
+ flagKey,
442
+ result: details.value,
443
+ reason: details.reason,
444
+ configurationVersion: details.configurationVersion,
445
+ bucket,
446
+ checkCount: 1,
447
+ firstCheckedAt: new Date(now).toISOString(),
448
+ lastCheckedAt: new Date(now).toISOString()
449
+ }
411
450
  });
412
451
  } catch {
413
452
  droppedChecks++;
414
453
  }
415
454
  }
416
- async function send() {
417
- for (const [key, record] of active) {
418
- pending.push(record);
455
+ function seal() {
456
+ const open = new Map;
457
+ for (const [key, { user, event }] of active) {
458
+ const userId = JSON.stringify([user.key, user.email]);
459
+ let batch = open.get(userId);
460
+ if (!batch || batch.events.length >= BATCH_SIZE) {
461
+ batch = { batchId: crypto.randomUUID(), user, events: [] };
462
+ open.set(userId, batch);
463
+ sealed.push(batch);
464
+ }
465
+ batch.events.push(event);
466
+ sealedEvents++;
419
467
  active.delete(key);
420
468
  }
421
- while (pending.length > 0 || droppedChecks > reportedDroppedChecks) {
422
- const batch = pending.slice(0, BATCH_SIZE);
469
+ }
470
+ function settle(batch) {
471
+ if (sealed[0] !== batch)
472
+ return;
473
+ sealed.shift();
474
+ sealedEvents -= batch.events.length;
475
+ }
476
+ async function send() {
477
+ seal();
478
+ while (sealed.length > 0 || droppedChecks > reportedDroppedChecks) {
479
+ const batch = sealed[0] ?? {
480
+ batchId: crypto.randomUUID(),
481
+ user: {},
482
+ events: []
483
+ };
484
+ const checks = batch.events.reduce((sum, item) => sum + item.checkCount, 0);
423
485
  const dropped = droppedChecks;
424
486
  const response = await fetch(options.url, {
425
487
  method: "POST",
@@ -430,15 +492,17 @@ function createExposureReporter(options) {
430
492
  Authorization: `Bearer ${options.key}`
431
493
  },
432
494
  body: JSON.stringify({
495
+ batchId: batch.batchId,
433
496
  reporterId,
434
- events: batch,
497
+ user: batch.user,
498
+ events: batch.events,
435
499
  droppedChecks: dropped,
436
500
  sdk: options.sdk,
437
- sdkVersion: "0.0.0",
501
+ sdkVersion: options.sdkVersion,
438
502
  appVersion: options.appVersion?.slice(0, 100)
439
503
  }),
440
504
  signal: AbortSignal.timeout(1e4),
441
- keepalive: batch.length < 10
505
+ keepalive: batch.events.length < 10
442
506
  });
443
507
  if (!response.ok) {
444
508
  const retryAfter = Number(response.headers.get("Retry-After"));
@@ -446,18 +510,18 @@ function createExposureReporter(options) {
446
510
  retryAt = Math.max(retryAt, Date.now() + Math.min(retryAfter, 3600) * 1000);
447
511
  }
448
512
  if (response.status >= 400 && response.status < 500 && response.status !== 429) {
449
- droppedChecks += batch.reduce((sum, item) => sum + item.checkCount, 0);
450
- pending.splice(0, batch.length);
513
+ droppedChecks += checks;
514
+ settle(batch);
451
515
  retryAt = Date.now() + 60000;
452
516
  }
453
517
  throw new Error(`Exposure upload failed (${response.status})`);
454
518
  }
455
519
  const receipt = await response.json();
456
- if (typeof receipt !== "object" || receipt === null || !("accepted" in receipt) || typeof receipt.accepted !== "number" || !Number.isSafeInteger(receipt.accepted) || receipt.accepted < 0 || receipt.accepted > batch.length || !("rejectedChecks" in receipt) || typeof receipt.rejectedChecks !== "number" || !Number.isSafeInteger(receipt.rejectedChecks) || receipt.rejectedChecks < 0 || receipt.rejectedChecks > batch.reduce((sum, item) => sum + item.checkCount, 0)) {
520
+ if (typeof receipt !== "object" || receipt === null || !("accepted" in receipt) || typeof receipt.accepted !== "number" || !Number.isSafeInteger(receipt.accepted) || receipt.accepted < 0 || receipt.accepted > batch.events.length || !("rejectedChecks" in receipt) || typeof receipt.rejectedChecks !== "number" || !Number.isSafeInteger(receipt.rejectedChecks) || receipt.rejectedChecks < 0 || receipt.rejectedChecks > checks) {
457
521
  throw new Error("Invalid exposure upload receipt");
458
522
  }
459
523
  droppedChecks += receipt.rejectedChecks;
460
- pending.splice(0, batch.length);
524
+ settle(batch);
461
525
  reportedDroppedChecks = Math.max(reportedDroppedChecks, dropped);
462
526
  }
463
527
  failures = 0;
@@ -477,7 +541,7 @@ function createExposureReporter(options) {
477
541
  const timer = setInterval(() => {
478
542
  if (!closed && Date.now() >= retryAt)
479
543
  flush().catch(() => {});
480
- }, 1e4);
544
+ }, FLUSH_MS);
481
545
  timer.unref?.();
482
546
  function close() {
483
547
  closed = true;
@@ -638,6 +702,9 @@ function createVersionStream(options) {
638
702
  return { close };
639
703
  }
640
704
 
705
+ // src/version.ts
706
+ var SDK_VERSION = "0.1.5";
707
+
641
708
  // src/index.ts
642
709
  var DEFAULT_URL = "https://sdk.flagbearer.dev";
643
710
  function init({
@@ -659,6 +726,7 @@ function init({
659
726
  url: new URL("/v1/exposures", baseUrl),
660
727
  key,
661
728
  sdk: "node",
729
+ sdkVersion: SDK_VERSION,
662
730
  appVersion
663
731
  });
664
732
  let configurationVersion = null;
@@ -0,0 +1 @@
1
+ export declare const SDK_VERSION: string;
package/package.json CHANGED
@@ -1,13 +1,13 @@
1
1
  {
2
2
  "name": "@flagbearer/node",
3
- "version": "0.1.4",
3
+ "version": "0.1.5",
4
4
  "description": "Flagbearer Node.js SDK",
5
5
  "license": "MIT",
6
6
  "homepage": "https://flagbearer.dev",
7
7
  "repository": {
8
8
  "type": "git",
9
- "url": "git+https://github.com/ajsmth/flagbearer.git",
10
- "directory": "packages/sdk-node"
9
+ "url": "git+https://github.com/flagbearer-dev/sdks.git",
10
+ "directory": "js/node"
11
11
  },
12
12
  "keywords": [
13
13
  "feature-flags",
@@ -34,7 +34,7 @@
34
34
  "access": "public"
35
35
  },
36
36
  "scripts": {
37
- "build": "bun build ./src/index.ts --target node --format esm --outdir ./dist && tsc -p tsconfig.json --emitDeclarationOnly",
37
+ "build": "bun build ./src/index.ts --target node --format esm --outdir ./dist --define __FLAGBEARER_SDK_VERSION__=\\\"$npm_package_version\\\" && tsc -p tsconfig.json --emitDeclarationOnly",
38
38
  "typecheck": "tsc --noEmit -p tsconfig.json",
39
39
  "prepack": "bun run build"
40
40
  },