@avsbhq/node 1.6.2 → 1.7.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/index.cjs CHANGED
@@ -1,7 +1,9 @@
1
1
  "use strict";
2
+ var __create = Object.create;
2
3
  var __defProp = Object.defineProperty;
3
4
  var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
4
5
  var __getOwnPropNames = Object.getOwnPropertyNames;
6
+ var __getProtoOf = Object.getPrototypeOf;
5
7
  var __hasOwnProp = Object.prototype.hasOwnProperty;
6
8
  var __export = (target, all) => {
7
9
  for (var name in all)
@@ -15,30 +17,47 @@ var __copyProps = (to, from, except, desc) => {
15
17
  }
16
18
  return to;
17
19
  };
20
+ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
21
+ // If the importer is in node compatibility mode or this is not an ESM
22
+ // file that has been converted to a CommonJS file using a Babel-
23
+ // compatible transform (i.e. "__esModule" has not been set), then set
24
+ // "default" to the CommonJS "module.exports" for node compatibility.
25
+ isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
26
+ mod
27
+ ));
18
28
  var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
19
29
 
20
30
  // src/index.ts
21
31
  var index_exports = {};
22
32
  __export(index_exports, {
23
33
  AvsbServer: () => AvsbServer,
24
- DecideOption: () => import_core4.DecideOption,
34
+ DEFAULT_TOLERANCE_SECONDS: () => DEFAULT_TOLERANCE_SECONDS,
35
+ DecideOption: () => import_core9.DecideOption,
25
36
  InMemoryStickyBucketService: () => InMemoryStickyBucketService,
26
37
  RedisStickyBucketService: () => RedisStickyBucketService,
27
38
  SDK_VERSION: () => SDK_VERSION,
28
- consoleLogger: () => import_core3.consoleLogger,
29
- consoleTransport: () => import_core3.consoleTransport,
39
+ consoleLogger: () => import_core4.consoleLogger,
40
+ consoleTransport: () => import_core4.consoleTransport,
30
41
  createDecisionLog: () => createDecisionLog,
31
- createLogger: () => import_core3.createLogger,
42
+ createLogger: () => import_core4.createLogger,
32
43
  createStreamingClient: () => import_streaming.createStreamingClient,
33
- expressMiddleware: () => expressMiddleware,
34
- fastifyPlugin: () => fastifyPlugin,
35
- noopLogger: () => import_core3.noopLogger
44
+ defaultLogger: () => defaultLogger,
45
+ expressMiddleware: () => import_express.expressMiddleware,
46
+ fastifyPlugin: () => import_fastify.fastifyPlugin,
47
+ isDevEnvironment: () => isDevEnvironment,
48
+ noopLogger: () => import_core4.noopLogger,
49
+ resolveServerLogger: () => resolveServerLogger,
50
+ verifyWebhookSignature: () => verifyWebhookSignature
36
51
  });
37
52
  module.exports = __toCommonJS(index_exports);
38
53
 
39
54
  // src/AvsbServer.ts
55
+ var import_node_crypto2 = require("crypto");
40
56
  var import_engine = require("@avsbhq/core/engine");
41
- var import_core2 = require("@avsbhq/core");
57
+ var import_core8 = require("@avsbhq/core");
58
+ var import_sdkKey = require("@avsbhq/utils/sdkKey");
59
+ var import_backoff2 = require("@avsbhq/utils/backoff");
60
+ var import_exposure = require("@avsbhq/utils/exposure");
42
61
 
43
62
  // src/datasets.ts
44
63
  var DEFAULT_REQUEST_TIMEOUT = 1e4;
@@ -292,42 +311,234 @@ function createRecsClient(options) {
292
311
  }
293
312
 
294
313
  // src/decisionLog.ts
314
+ var import_core = require("@avsbhq/core");
315
+ var REDACTED = "[REDACTED]";
316
+ var MIN_SUBSTRING_LENGTH = 2;
295
317
  function createDecisionLog() {
296
318
  const entries = [];
297
319
  return { entries };
298
320
  }
321
+ function privateAttributeValues(ctx) {
322
+ const out = /* @__PURE__ */ new Set();
323
+ const collect = (sub) => {
324
+ const names = sub._meta?.privateAttributes ?? [];
325
+ for (const name of names) {
326
+ const raw = sub[name];
327
+ if (raw === void 0 || raw === null) continue;
328
+ const text = typeof raw === "string" ? raw : stringify(raw);
329
+ if (text) out.add(text);
330
+ }
331
+ };
332
+ if ((0, import_core.isMultiContext)(ctx)) {
333
+ for (const [kind, value] of Object.entries(ctx)) {
334
+ if (kind === "kind") continue;
335
+ if (!value || typeof value !== "object") continue;
336
+ collect(value);
337
+ }
338
+ } else {
339
+ collect(ctx);
340
+ }
341
+ return [...out].sort((a, b) => b.length - a.length);
342
+ }
343
+ function stringify(value) {
344
+ if (typeof value === "string") return value;
345
+ try {
346
+ return JSON.stringify(value) ?? "";
347
+ } catch {
348
+ return String(value);
349
+ }
350
+ }
351
+ function matches(haystack, needle) {
352
+ if (haystack === needle) return true;
353
+ if (needle.length < MIN_SUBSTRING_LENGTH) return false;
354
+ return haystack.includes(needle);
355
+ }
356
+ function redactValue(value, privateValues) {
357
+ if (privateValues.length === 0) return value;
358
+ if (value === void 0 || value === null) return value;
359
+ const serialised = stringify(value);
360
+ if (!serialised) return value;
361
+ for (const priv of privateValues) {
362
+ if (matches(serialised, priv)) return REDACTED;
363
+ }
364
+ return value;
365
+ }
366
+ function redactReasons(reasons, privateValues) {
367
+ if (privateValues.length === 0) return [...reasons];
368
+ return reasons.map((reason) => {
369
+ let out = reason;
370
+ for (const priv of privateValues) {
371
+ if (out === priv) return REDACTED;
372
+ if (priv.length < MIN_SUBSTRING_LENGTH) continue;
373
+ out = out.split(priv).join(REDACTED);
374
+ }
375
+ return out;
376
+ });
377
+ }
378
+ function appendDecisionLogEntry(log, input) {
379
+ const entry = {
380
+ flagKey: input.flagKey,
381
+ value: redactValue(input.value, input.privateValues),
382
+ variationKey: input.variationKey,
383
+ source: input.source,
384
+ ruleId: input.ruleId,
385
+ ruleType: input.ruleType,
386
+ reasons: redactReasons(input.reasons, input.privateValues),
387
+ evaluatedAt: input.evaluatedAt,
388
+ durationMicros: input.durationMicros,
389
+ contextSummary: input.contextSummary,
390
+ ...input.manuallyFired ? { manuallyFired: true } : {}
391
+ };
392
+ log.entries.push(entry);
393
+ return entry;
394
+ }
299
395
 
300
- // src/serverTracker.ts
301
- var import_node_crypto = require("crypto");
396
+ // src/decideOptions.ts
397
+ var import_core2 = require("@avsbhq/core");
398
+ var KNOWN = Object.values(import_core2.DecideOption);
399
+ function createDecideOptionResolver(logger) {
400
+ const warned = /* @__PURE__ */ new Set();
401
+ return function resolve(options) {
402
+ if (!options || options.length === 0) return { suppressExposure: false };
403
+ let suppressExposure = false;
404
+ for (const option of options) {
405
+ if (option === import_core2.DecideOption.DISABLE_EXPOSURE) {
406
+ suppressExposure = true;
407
+ continue;
408
+ }
409
+ if (option === import_core2.DecideOption.INCLUDE_REASONS) {
410
+ continue;
411
+ }
412
+ const unknown = String(option);
413
+ if (!warned.has(unknown)) {
414
+ warned.add(unknown);
415
+ logger.warn(
416
+ `Unknown decide option "${unknown}" ignored. Accepted values are ${KNOWN.join(", ")}. EXCLUDE_VARIABLES, IGNORE_STICKY_BUCKET and ENABLED_FLAGS_ONLY were deleted because no SDK ever implemented them.`
417
+ );
418
+ }
419
+ }
420
+ return { suppressExposure };
421
+ };
422
+ }
302
423
 
303
- // src/reconnectBackoff.ts
304
- var BASE_DELAY_MS = 1e3;
305
- var MAX_DELAY_MS = 6e4;
306
- var MIN_DELAY_MS = 500;
307
- var JITTER = 0.2;
308
- function nextBackoff(prev, retryAfterMs) {
309
- const attempt = (prev?.attempt ?? 0) + 1;
310
- if (typeof retryAfterMs === "number" && retryAfterMs > 0) {
311
- return { attempt, delayMs: retryAfterMs };
312
- }
313
- const base = Math.min(MAX_DELAY_MS, BASE_DELAY_MS * 2 ** (attempt - 1));
314
- const jitter = base * JITTER * (Math.random() * 2 - 1);
315
- const delayMs = Math.max(MIN_DELAY_MS, Math.round(base + jitter));
316
- return { attempt, delayMs };
424
+ // src/overrideStore.ts
425
+ function createOverrideStore() {
426
+ const byFlagKey = /* @__PURE__ */ new Map();
427
+ function ensure(flagKey) {
428
+ const existing = byFlagKey.get(flagKey);
429
+ if (existing) return existing;
430
+ const created = { perKindKey: /* @__PURE__ */ new Map() };
431
+ byFlagKey.set(flagKey, created);
432
+ return created;
433
+ }
434
+ function prune(flagKey, entry) {
435
+ if (!entry.global && entry.perKindKey.size === 0) byFlagKey.delete(flagKey);
436
+ }
437
+ return {
438
+ resolve(flagKey, contextKey, contextKind = "user") {
439
+ const entry = byFlagKey.get(flagKey);
440
+ if (!entry) return void 0;
441
+ return entry.perKindKey.get(`${contextKind}:${contextKey}`) ?? entry.global;
442
+ },
443
+ setForContext(flagKey, contextKey, variationKey, contextKind = "user") {
444
+ ensure(flagKey).perKindKey.set(`${contextKind}:${contextKey}`, variationKey);
445
+ },
446
+ setGlobal(flagKey, variationKey) {
447
+ ensure(flagKey).global = variationKey;
448
+ },
449
+ clearForContext(flagKey, contextKey, contextKind = "user") {
450
+ const entry = byFlagKey.get(flagKey);
451
+ if (!entry) return;
452
+ entry.perKindKey.delete(`${contextKind}:${contextKey}`);
453
+ prune(flagKey, entry);
454
+ },
455
+ clearGlobal(flagKey) {
456
+ const entry = byFlagKey.get(flagKey);
457
+ if (!entry) return;
458
+ entry.global = void 0;
459
+ prune(flagKey, entry);
460
+ },
461
+ clearAll() {
462
+ byFlagKey.clear();
463
+ },
464
+ size() {
465
+ return byFlagKey.size;
466
+ }
467
+ };
317
468
  }
318
- function resetBackoff() {
319
- return null;
469
+
470
+ // src/datafileSource.ts
471
+ var import_core3 = require("@avsbhq/core");
472
+ async function fetchDatafile(input) {
473
+ const url = (0, import_core3.datafileUrl)(input.sdkKey, input.cdnHost);
474
+ const headers = {};
475
+ if (input.etag) headers["If-None-Match"] = input.etag;
476
+ try {
477
+ const response = await fetch(url, { signal: input.signal, headers });
478
+ if (response.status === 304) return { status: "not-modified" };
479
+ if (!response.ok) {
480
+ input.logger.error(
481
+ `AvsB datafile request failed with HTTP ${response.status} for ${url}. ` + (response.status === 404 ? "That SDK key has no published datafile: check the key belongs to this environment and that the environment has been published at least once (app.avsb.cloud, then Settings, then Environments)." : "The SDK keeps serving whatever datafile it already has and retries on the polling interval.")
482
+ );
483
+ return { status: "failed" };
484
+ }
485
+ const datafile = await response.json();
486
+ return { status: "ok", datafile, etag: response.headers.get("etag") };
487
+ } catch (error) {
488
+ if (input.signal.aborted) return { status: "aborted" };
489
+ input.logger.error(
490
+ `AvsB datafile request to ${url} could not complete: ${String(error)}. This is a network, DNS, or TLS failure rather than a rejected key; the SDK retries on the polling interval.`
491
+ );
492
+ return { status: "failed" };
493
+ }
320
494
  }
321
- function parseRetryAfter(headerValue) {
322
- if (!headerValue) return null;
323
- const trimmed = headerValue.trim();
324
- if (!trimmed) return null;
325
- const n = Number(trimmed);
326
- if (!Number.isFinite(n) || n < 0) return null;
327
- return Math.round(n * 1e3);
495
+ function sendHeartbeat(input) {
496
+ const endpoint = (0, import_core3.resolveHeartbeatEndpoint)(input.datafileHeartbeatEndpoint);
497
+ if (!endpoint) return null;
498
+ void fetch(endpoint, {
499
+ method: "POST",
500
+ headers: { "Content-Type": "application/json" },
501
+ body: JSON.stringify({
502
+ sdkKey: input.sdkKey,
503
+ sdkType: input.sdkType,
504
+ sdkVersion: input.sdkVersion
505
+ }),
506
+ signal: input.signal
507
+ }).catch(() => void 0);
508
+ return endpoint;
509
+ }
510
+
511
+ // src/logger.ts
512
+ var import_core4 = require("@avsbhq/core");
513
+ function readDevGlobal() {
514
+ const flag = globalThis.__AVSB_DEV__;
515
+ return typeof flag === "boolean" ? flag : null;
516
+ }
517
+ function readNodeEnv() {
518
+ const value = globalThis.process?.env?.NODE_ENV;
519
+ return typeof value === "string" && value.length > 0 ? value : null;
520
+ }
521
+ function isDevEnvironment() {
522
+ const explicit = readDevGlobal();
523
+ if (explicit !== null) return explicit;
524
+ const nodeEnv = readNodeEnv();
525
+ if (nodeEnv === null) return true;
526
+ return nodeEnv !== "production" && nodeEnv !== "test";
527
+ }
528
+ function defaultLogger() {
529
+ return isDevEnvironment() ? (0, import_core4.consoleLogger)("warn") : import_core4.noopLogger;
530
+ }
531
+ function resolveServerLogger(options) {
532
+ if (options.logger) return options.logger;
533
+ if (options.logLevel === "silent") return import_core4.noopLogger;
534
+ if (options.logLevel) return (0, import_core4.consoleLogger)(options.logLevel);
535
+ return defaultLogger();
328
536
  }
329
537
 
330
538
  // src/serverTracker.ts
539
+ var import_node_crypto = require("crypto");
540
+ var import_core5 = require("@avsbhq/core");
541
+ var import_backoff = require("@avsbhq/utils/backoff");
331
542
  function stampExposure(event) {
332
543
  return event.eventId ? event : { ...event, eventId: (0, import_node_crypto.randomUUID)() };
333
544
  }
@@ -344,9 +555,9 @@ function createServerTracker(options) {
344
555
  retryAttempts,
345
556
  logger
346
557
  } = options;
347
- const batchEndpoint = collectEndpoint.replace(/\/?$/, "/v1/collect/batch");
348
- const metricEndpoint = collectEndpoint.replace(/\/?$/, "/v1/collect/metric-batch");
349
- const ordersEndpoint = collectEndpoint.replace(/\/?$/, "/v1/collect/orders");
558
+ const batchEndpoint = (0, import_core5.collectUrl)("batch", collectEndpoint);
559
+ const metricEndpoint = (0, import_core5.collectUrl)("metricBatch", collectEndpoint);
560
+ const ordersEndpoint = (0, import_core5.collectUrl)("orders", collectEndpoint);
350
561
  let exposureQueue = [];
351
562
  let metricQueue = [];
352
563
  let flushTimer = null;
@@ -403,7 +614,7 @@ function createServerTracker(options) {
403
614
  }
404
615
  async function sendBatch(endpoint, key, payload) {
405
616
  const body = JSON.stringify({ sdkKey, [key]: payload });
406
- let backoff = resetBackoff();
617
+ let backoff = (0, import_backoff.resetBackoff)();
407
618
  let pendingRetryAfterMs = void 0;
408
619
  for (let attempt = 0; attempt <= retryAttempts; attempt++) {
409
620
  try {
@@ -414,7 +625,7 @@ function createServerTracker(options) {
414
625
  signal: AbortSignal.timeout(1e4)
415
626
  });
416
627
  if (!response.ok) {
417
- const parsed = parseRetryAfter(response.headers.get("retry-after"));
628
+ const parsed = (0, import_backoff.parseRetryAfter)(response.headers.get("retry-after"));
418
629
  if (parsed !== null) pendingRetryAfterMs = parsed;
419
630
  throw new Error(`HTTP ${response.status}`);
420
631
  }
@@ -422,7 +633,7 @@ function createServerTracker(options) {
422
633
  return;
423
634
  } catch (e) {
424
635
  if (attempt < retryAttempts) {
425
- backoff = nextBackoff(backoff, pendingRetryAfterMs);
636
+ backoff = (0, import_backoff.nextBackoff)(backoff, pendingRetryAfterMs);
426
637
  pendingRetryAfterMs = void 0;
427
638
  await new Promise((r) => setTimeout(r, backoff.delayMs));
428
639
  } else {
@@ -444,7 +655,7 @@ function createServerTracker(options) {
444
655
  pageUrl: ""
445
656
  };
446
657
  const body = JSON.stringify({ sdkKey, orders: [payload] });
447
- let backoff = resetBackoff();
658
+ let backoff = (0, import_backoff.resetBackoff)();
448
659
  let pendingRetryAfterMs = void 0;
449
660
  for (let attempt = 0; attempt <= retryAttempts; attempt++) {
450
661
  try {
@@ -455,7 +666,7 @@ function createServerTracker(options) {
455
666
  signal: AbortSignal.timeout(1e4)
456
667
  });
457
668
  if (!response.ok) {
458
- const parsed = parseRetryAfter(response.headers.get("retry-after"));
669
+ const parsed = (0, import_backoff.parseRetryAfter)(response.headers.get("retry-after"));
459
670
  if (parsed !== null) pendingRetryAfterMs = parsed;
460
671
  throw new Error(`HTTP ${response.status}`);
461
672
  }
@@ -463,7 +674,7 @@ function createServerTracker(options) {
463
674
  return;
464
675
  } catch (e) {
465
676
  if (attempt < retryAttempts) {
466
- backoff = nextBackoff(backoff, pendingRetryAfterMs);
677
+ backoff = (0, import_backoff.nextBackoff)(backoff, pendingRetryAfterMs);
467
678
  pendingRetryAfterMs = void 0;
468
679
  await new Promise((r) => setTimeout(r, backoff.delayMs));
469
680
  } else {
@@ -490,76 +701,58 @@ function createServerTracker(options) {
490
701
  var import_streaming = require("@avsbhq/utils/streaming");
491
702
 
492
703
  // src/version.ts
493
- var SDK_VERSION = "1.6.2";
704
+ var SDK_VERSION = "1.7.1";
494
705
 
495
706
  // src/UserBoundClient.ts
707
+ var import_core6 = require("@avsbhq/core");
496
708
  function createUserBoundClient(server, context) {
497
709
  const decisionLog = createDecisionLog();
498
710
  const forcedDecisions = /* @__PURE__ */ new Map();
499
- function buildOptions(options) {
500
- return {
501
- context,
711
+ function evaluate(flagKey, defaultValue, expectedType, options) {
712
+ const forced = forcedDecisions.get(flagKey);
713
+ return server.evaluateInternal(flagKey, defaultValue, context, {
502
714
  decideOptions: options?.decideOptions,
503
- decisionLog
504
- };
715
+ // A caller may point one evaluation at their own log; the bound log is
716
+ // the default so `getDecisionLog()` is complete without extra wiring.
717
+ decisionLog: options?.decisionLog ?? decisionLog,
718
+ ...expectedType ? { expectedType } : {},
719
+ ...forced ? { forcedVariationKey: forced.variationKey } : {}
720
+ });
505
721
  }
506
722
  return {
507
723
  getFlag(flagKey, defaultValue, options) {
508
- const forced = forcedDecisions.get(flagKey);
509
- if (forced) {
510
- return server.getFlag(flagKey, defaultValue, {
511
- ...buildOptions(options),
512
- forcedVariationKey: forced.variationKey
513
- });
514
- }
515
- return server.getFlag(flagKey, defaultValue, buildOptions(options));
724
+ return evaluate(flagKey, defaultValue, void 0, options);
516
725
  },
517
726
  getBoolFlag(flagKey, defaultValue, options) {
518
- const forced = forcedDecisions.get(flagKey);
519
- if (forced) {
520
- return server.getBoolFlag(flagKey, defaultValue, {
521
- ...buildOptions(options),
522
- forcedVariationKey: forced.variationKey
523
- });
524
- }
525
- return server.getBoolFlag(flagKey, defaultValue, buildOptions(options));
727
+ return evaluate(flagKey, defaultValue, "boolean", options);
526
728
  },
527
729
  getStringFlag(flagKey, defaultValue, options) {
528
- const forced = forcedDecisions.get(flagKey);
529
- if (forced) {
530
- return server.getStringFlag(flagKey, defaultValue, {
531
- ...buildOptions(options),
532
- forcedVariationKey: forced.variationKey
533
- });
534
- }
535
- return server.getStringFlag(flagKey, defaultValue, buildOptions(options));
730
+ return evaluate(flagKey, defaultValue, "string", options);
536
731
  },
537
732
  getNumberFlag(flagKey, defaultValue, options) {
538
- const forced = forcedDecisions.get(flagKey);
539
- if (forced) {
540
- return server.getNumberFlag(flagKey, defaultValue, {
541
- ...buildOptions(options),
542
- forcedVariationKey: forced.variationKey
543
- });
544
- }
545
- return server.getNumberFlag(flagKey, defaultValue, buildOptions(options));
733
+ return evaluate(flagKey, defaultValue, "number", options);
546
734
  },
547
735
  getJsonFlag(flagKey, defaultValue, options) {
548
- const forced = forcedDecisions.get(flagKey);
549
- if (forced) {
550
- return server.getJsonFlag(flagKey, defaultValue, {
551
- ...buildOptions(options),
552
- forcedVariationKey: forced.variationKey
553
- });
554
- }
555
- return server.getJsonFlag(flagKey, defaultValue, buildOptions(options));
736
+ return evaluate(flagKey, defaultValue, "json", options);
556
737
  },
557
738
  getAllFlags(options) {
558
- return server.getAllFlags(context, options);
739
+ return server.getAllFlags(context, {
740
+ ...options,
741
+ decisionLog: options?.decisionLog ?? decisionLog
742
+ });
559
743
  },
560
744
  track(eventKey, payload) {
561
745
  server.track(eventKey, { ...payload, context });
562
746
  },
747
+ trackPurchase(order) {
748
+ return server.trackPurchase((0, import_core6.bucketingKey)(context), order);
749
+ },
750
+ manualExposure(flagKey) {
751
+ server.manualExposure(flagKey, context);
752
+ },
753
+ getContext() {
754
+ return context;
755
+ },
563
756
  setForcedDecision(flagKey, opts) {
564
757
  forcedDecisions.set(flagKey, opts);
565
758
  },
@@ -574,11 +767,14 @@ function createUserBoundClient(server, context) {
574
767
 
575
768
  // src/utils.ts
576
769
  var import_utils = require("@avsbhq/utils");
577
- var import_core = require("@avsbhq/core");
770
+ var import_core7 = require("@avsbhq/core");
578
771
  function buildServerUtils(server) {
579
772
  const adapter = {
580
773
  getFlag(flagKey, defaultValue) {
581
- return (0, import_core.notFoundFlag)(defaultValue, "no bound context");
774
+ return (0, import_core7.notFoundFlag)(
775
+ defaultValue,
776
+ "no bound context: call server.forUser(context) and use its flag methods"
777
+ );
582
778
  },
583
779
  getAllFlags(opts) {
584
780
  const ctx = opts?.context;
@@ -599,10 +795,18 @@ function buildServerUtils(server) {
599
795
  },
600
796
  getDatafile: () => server.getDatafile(),
601
797
  getCurrentContext: () => null,
602
- applyDatafile() {
603
- },
604
- applySnapshot() {
605
- }
798
+ /**
799
+ * Real, not a comment: `client.utils.hydrateBootstrap(blob)` on the server
800
+ * adopts the blob's datafile and marks the SDK ready. That is the
801
+ * serverless cold-start path (read a datafile from KV or a file, hand it
802
+ * over, evaluate on the same tick).
803
+ */
804
+ applyDatafile: (df) => server.applyDatafileBootstrap(df)
805
+ // `applySnapshot` is deliberately ABSENT. A snapshot carries one context's
806
+ // evaluated values, and this client evaluates a different context per
807
+ // request, so adopting one process-wide would serve user A's values to user
808
+ // B. hydrateBootstrap throws with that explanation when it is handed a
809
+ // snapshot-only blob here, instead of quietly ignoring it.
606
810
  };
607
811
  return (0, import_utils.createUtils)(adapter);
608
812
  }
@@ -614,63 +818,63 @@ var DEFAULT_EVENTS = {
614
818
  maxQueueSize: 1e4,
615
819
  retryAttempts: 3
616
820
  };
617
- var DEFAULT_STREAMING_ENDPOINT = "https://app.avsb.cloud/api/sdk/stream";
821
+ var SDK_TYPE = "@avsbhq/node";
822
+ var HEARTBEAT_MIN_INTERVAL_MS = 12e4;
618
823
  var AvsbServer = class {
619
824
  sdkKey;
620
- pollingInterval;
621
825
  cdnHost;
826
+ pollingInterval;
622
827
  initTimeout;
623
828
  logger;
624
829
  stickyBucketService;
625
830
  eventOptions;
626
831
  streamingEnabled;
627
- streamingEndpoint;
832
+ streamingEndpointOption;
833
+ overrides = createOverrideStore();
834
+ resolveDecideOptions;
628
835
  datafile;
836
+ datafileEtag = null;
629
837
  ready;
630
838
  destroyed;
631
839
  tracker;
632
840
  streamingClient;
633
- pollingId;
841
+ pollingTimer;
634
842
  abortController = new AbortController();
635
- overrides = /* @__PURE__ */ new Map();
636
843
  listeners = /* @__PURE__ */ new Map();
637
844
  readyPromise = null;
638
845
  pendingEvents = [];
846
+ pendingPurchases = [];
639
847
  /**
640
- * §6: `client.utils.*` surface. Constructed lazily on first access so
641
- * the utils package is only imported when actually consumed.
642
- *
643
- * The server's event surface (`ServerEventMap`) is a subset of the
644
- * shared `ClientEventMap`; the utils-adapter `on` here ignores events
645
- * the server doesn't emit (e.g. `flagChange`). Workstream 5 (CoW 6)
646
- * harmonises the two maps when the streaming evaluator lands.
848
+ * Exposures produced before the tracker existed. Third member of the
849
+ * pre-ready family alongside `pendingEvents` and `pendingPurchases`; it used
850
+ * to be missing, and exposures were dropped on the floor instead.
851
+ */
852
+ pendingExposures = [];
853
+ /** ms-epoch of the last liveness heartbeat, for the throttle. */
854
+ lastHeartbeatAt = 0;
855
+ /** Keys of one-time warnings, so a request loop cannot flood a log. */
856
+ warnedOnce = /* @__PURE__ */ new Set();
857
+ /**
858
+ * `client.utils.*` surface, constructed lazily on first access so the utils
859
+ * package is only imported when actually consumed.
647
860
  */
648
861
  _utils = null;
649
862
  get utils() {
650
863
  if (!this._utils) {
651
864
  this._utils = buildServerUtils({
652
865
  getAllFlags: (ctx, opts) => this.getAllFlags(ctx, opts),
653
- getFlag: (flagKey, defaultValue, opts) => this.getFlag(flagKey, defaultValue, opts),
866
+ getFlag: (flagKey, defaultValue, context) => this.getFlag(flagKey, defaultValue, context),
654
867
  track: (eventKey, payload) => this.track(eventKey, payload),
655
- on: (event, cb) => {
656
- if (event === "ready" || event === "configUpdate" || event === "evaluation" || event === "error" || event === "stickyOrphan") {
657
- return this.on(
658
- event,
659
- cb
660
- );
661
- }
662
- return () => void 0;
663
- },
664
- getDatafile: () => this.datafile
868
+ on: (event, cb) => this.onSharedEvent(event, cb),
869
+ getDatafile: () => this.datafile,
870
+ applyDatafileBootstrap: (df) => this.applyDatafileBootstrap(df)
665
871
  });
666
872
  }
667
873
  return this._utils;
668
874
  }
669
875
  /**
670
- * Server-side datasets read surface (e-commerce suite Phase 2).
671
- * Constructed lazily on first access, mirroring the `utils` getter.
672
- * `client.datasets.get(slug, key)` resolves the live version from the
673
- * current datafile and never throws; see ./datasets.ts.
876
+ * Server-side datasets read surface. `client.datasets.get(slug, key)`
877
+ * resolves the live version from the current datafile and never throws.
674
878
  */
675
879
  _datasets = null;
676
880
  get datasets() {
@@ -684,11 +888,10 @@ var AvsbServer = class {
684
888
  return this._datasets;
685
889
  }
686
890
  /**
687
- * Server-side recommendations surface (e-commerce suite overhaul, C3).
688
- * Constructed lazily on first access, mirroring the `datasets` getter.
689
- * `client.recs.get({ recipe, … })` resolves the recipe registry from the
690
- * current datafile and never throws; impressions/clicks ride the server
691
- * tracker's queue and revenue attaches via the existing `trackPurchase`.
891
+ * Server-side recommendations surface. `client.recs.get({ recipe, … })`
892
+ * resolves the recipe registry from the current datafile and never throws;
893
+ * impressions and clicks ride the server tracker's queue and revenue
894
+ * attaches via `trackPurchase`.
692
895
  */
693
896
  _recs = null;
694
897
  get recs() {
@@ -705,75 +908,97 @@ var AvsbServer = class {
705
908
  constructor(options) {
706
909
  this.sdkKey = options.sdkKey;
707
910
  this.pollingInterval = Math.max(options.pollingInterval ?? 6e4, 5e3);
708
- const rawCdnHost = options.cdnHost ?? "https://cdn.avsb.cloud";
709
- this.cdnHost = rawCdnHost.endsWith("/") ? rawCdnHost.slice(0, -1) : rawCdnHost;
911
+ this.cdnHost = options.cdnHost;
710
912
  this.initTimeout = options.initTimeout ?? 1e4;
711
- this.logger = options.logger ?? import_core2.noopLogger;
913
+ this.logger = resolveServerLogger(options);
712
914
  this.stickyBucketService = options.stickyBucketService ?? null;
713
915
  this.eventOptions = { ...DEFAULT_EVENTS, ...options.events };
714
- this.streamingEnabled = options.streaming ?? false;
715
- this.streamingEndpoint = options.streamingEndpoint ?? DEFAULT_STREAMING_ENDPOINT;
916
+ this.streamingEnabled = options.streaming !== false;
917
+ this.streamingEndpointOption = options.streamingEndpoint ?? null;
716
918
  this.datafile = options.bootstrap ?? null;
717
919
  this.ready = !!options.bootstrap;
718
920
  this.destroyed = false;
719
921
  this.tracker = null;
720
922
  this.streamingClient = null;
721
- this.pollingId = null;
923
+ this.pollingTimer = null;
924
+ this.resolveDecideOptions = createDecideOptionResolver(this.logger);
925
+ const keyShape = (0, import_sdkKey.validateSdkKeyShape)(this.sdkKey);
926
+ if (!keyShape.valid && keyShape.message) {
927
+ this.logger.error(keyShape.message);
928
+ }
929
+ if (this.datafile) this.initTracker();
722
930
  }
723
931
  // ---------------------------------------------------------------------------
724
- // §5.4 onReady — never rejects, always resolves to InitResult
932
+ // Readiness
725
933
  // ---------------------------------------------------------------------------
934
+ /**
935
+ * Await the init attempt. Never rejects; always resolves to an InitResult.
936
+ * Calling it twice returns the same promise.
937
+ */
726
938
  onReady() {
727
939
  if (this.readyPromise) return this.readyPromise;
728
940
  this.readyPromise = this.initializeAndReady();
729
941
  return this.readyPromise;
730
942
  }
943
+ /** True when a datafile is loaded and flags are answerable right now. */
944
+ isReady() {
945
+ return this.ready && this.datafile !== null;
946
+ }
731
947
  async initializeAndReady() {
732
948
  if (this.ready && this.datafile) {
733
- this.initTracker();
734
- this.startPolling();
735
- this.startStreamingIfEnabled();
736
- this.sendHeartbeat();
737
- this.emit("ready");
949
+ this.onDatafileReadable();
738
950
  return { success: true, source: "bootstrap" };
739
951
  }
740
952
  try {
741
- const result = await Promise.race([
742
- this.fetchDatafile().then((df) => ({ df, timedOut: false })),
953
+ const outcome = await Promise.race([
954
+ // At init there is no stored etag, so 'unchanged' cannot happen here;
955
+ // it is folded in anyway so a future caching layer cannot make init
956
+ // silently report failure.
957
+ this.loadDatafile().then((result) => ({
958
+ applied: result !== "failed",
959
+ timedOut: false
960
+ })),
743
961
  new Promise(
744
- (resolve) => setTimeout(() => resolve({ df: null, timedOut: true }), this.initTimeout)
962
+ (resolve) => setTimeout(
963
+ () => resolve({ applied: false, timedOut: true }),
964
+ this.initTimeout
965
+ )
745
966
  )
746
967
  ]);
747
- if (result.timedOut) {
968
+ if (outcome.timedOut) {
748
969
  this.startPolling();
749
- this.startStreamingIfEnabled();
750
- const error2 = new Error(`SDK initialization timed out after ${this.initTimeout}ms`);
970
+ const error2 = new Error(
971
+ `AvsB init timed out after ${this.initTimeout}ms waiting for the datafile. Polling continues in the background, so flags start working as soon as the request completes; raise initTimeout if your cold start is slower.`
972
+ );
751
973
  this.emit("error", error2);
752
974
  return { success: false, source: "timeout", error: error2 };
753
975
  }
754
- if (result.df) {
755
- this.datafile = result.df;
756
- this.initTracker();
757
- this.ready = true;
758
- this.startPolling();
759
- this.startStreamingIfEnabled();
760
- this.sendHeartbeat();
761
- this.emit("ready");
976
+ if (outcome.applied) {
977
+ this.onDatafileReadable();
762
978
  return { success: true, source: "network" };
763
979
  }
764
980
  this.startPolling();
765
- this.startStreamingIfEnabled();
766
- const error = new Error("Failed to fetch datafile");
981
+ const error = new Error(
982
+ 'AvsB init failed: no datafile could be loaded, so every flag serves the default you passed and flag.source is "not_ready". The datafile request error is logged above with the URL and status. Polling continues.'
983
+ );
767
984
  this.emit("error", error);
768
985
  return { success: false, source: "error", error };
769
986
  } catch (e) {
770
987
  this.startPolling();
771
- this.startStreamingIfEnabled();
772
988
  const error = e instanceof Error ? e : new Error(String(e));
773
989
  this.emit("error", error);
774
990
  return { success: false, source: "error", error };
775
991
  }
776
992
  }
993
+ /** Wiring that runs once a datafile is readable, however it arrived. */
994
+ onDatafileReadable() {
995
+ this.initTracker();
996
+ this.ready = true;
997
+ this.startPolling();
998
+ this.startStreamingIfEnabled();
999
+ this.sendHeartbeat();
1000
+ this.emit("ready");
1001
+ }
777
1002
  // ---------------------------------------------------------------------------
778
1003
  // Lifecycle
779
1004
  // ---------------------------------------------------------------------------
@@ -784,9 +1009,9 @@ var AvsbServer = class {
784
1009
  this.streamingClient.disconnect();
785
1010
  this.streamingClient = null;
786
1011
  }
787
- if (this.pollingId) {
788
- clearInterval(this.pollingId);
789
- this.pollingId = null;
1012
+ if (this.pollingTimer) {
1013
+ clearTimeout(this.pollingTimer);
1014
+ this.pollingTimer = null;
790
1015
  }
791
1016
  if (this.tracker) {
792
1017
  await this.tracker.destroy();
@@ -799,127 +1024,274 @@ var AvsbServer = class {
799
1024
  async flush() {
800
1025
  if (this.tracker) await this.tracker.flush();
801
1026
  }
1027
+ /**
1028
+ * Fetch the datafile now and apply it if it changed.
1029
+ *
1030
+ * A 304 is not a problem and is not warned about: it means the datafile in
1031
+ * memory IS the published one.
1032
+ */
802
1033
  async refreshDatafile() {
803
- const df = await this.fetchDatafile();
804
- if (!df) {
805
- this.logger.warn("Datafile refresh failed, keeping current datafile");
1034
+ const result = await this.loadDatafile("manual");
1035
+ if (result === "failed") {
1036
+ this.logger.warn(
1037
+ "AvsB datafile refresh did not apply a new datafile; the SDK keeps serving the one it already has. The request error is logged above."
1038
+ );
806
1039
  return;
807
1040
  }
808
- const changed = !this.datafile || this.datafile.publishedAt !== df.publishedAt;
809
- this.datafile = df;
1041
+ if (result === "applied" && !this.ready) this.onDatafileReadable();
1042
+ }
1043
+ /**
1044
+ * Adopt a datafile received out of band: a bootstrap blob from
1045
+ * `client.utils.hydrateBootstrap`, a file on disk, a KV read at cold start.
1046
+ *
1047
+ * Marks the SDK ready, drains anything queued before it, and starts polling
1048
+ * and streaming exactly as a network arrival would. This used to be a no-op
1049
+ * comment inside the utils adapter, which made the documented bootstrap
1050
+ * handoff silently do nothing.
1051
+ */
1052
+ applyDatafileBootstrap(datafile) {
1053
+ if (this.destroyed) return;
1054
+ const changed = this.datafile?.publishedAt !== datafile.publishedAt;
1055
+ this.datafile = datafile;
1056
+ this.datafileEtag = null;
810
1057
  if (!this.ready) {
811
- this.initTracker();
812
- this.ready = true;
813
- this.emit("ready");
814
- } else if (changed) {
815
- this.emit("configUpdate", { publishedAt: df.publishedAt });
816
- this.logger.info("Datafile updated", { publishedAt: df.publishedAt });
1058
+ this.onDatafileReadable();
1059
+ return;
817
1060
  }
1061
+ this.initTracker();
1062
+ if (changed) {
1063
+ this.emit("configUpdate", {
1064
+ publishedAt: datafile.publishedAt,
1065
+ reason: "manual"
1066
+ });
1067
+ }
1068
+ }
1069
+ /** The datafile currently in memory, or null before init completes. */
1070
+ getDatafile() {
1071
+ return this.datafile;
818
1072
  }
819
1073
  // ---------------------------------------------------------------------------
820
- // §5.3 getFlag — required defaultValue, returns Flag<T>
1074
+ // Evaluation
1075
+ //
1076
+ // Context is the THIRD POSITIONAL argument on every call (B3), matching
1077
+ // @avsbhq/edge. `forUser(ctx)` is the primary server pattern and binds it
1078
+ // once per request.
821
1079
  // ---------------------------------------------------------------------------
822
- getFlag(flagKey, defaultValue, options) {
823
- const context = options?.context;
824
- const decisionLog = options?.decisionLog;
1080
+ getFlag(flagKey, defaultValue, context, options) {
1081
+ return this.evaluateInternal(flagKey, defaultValue, context, options);
1082
+ }
1083
+ getBoolFlag(flagKey, defaultValue, context, options) {
1084
+ return this.evaluateInternal(flagKey, defaultValue, context, {
1085
+ ...options,
1086
+ expectedType: "boolean"
1087
+ });
1088
+ }
1089
+ getStringFlag(flagKey, defaultValue, context, options) {
1090
+ return this.evaluateInternal(flagKey, defaultValue, context, {
1091
+ ...options,
1092
+ expectedType: "string"
1093
+ });
1094
+ }
1095
+ getNumberFlag(flagKey, defaultValue, context, options) {
1096
+ return this.evaluateInternal(flagKey, defaultValue, context, {
1097
+ ...options,
1098
+ expectedType: "number"
1099
+ });
1100
+ }
1101
+ getJsonFlag(flagKey, defaultValue, context, options) {
1102
+ return this.evaluateInternal(flagKey, defaultValue, context, {
1103
+ ...options,
1104
+ expectedType: "json"
1105
+ });
1106
+ }
1107
+ /**
1108
+ * @internal Single evaluation entry point for this package. `UserBoundClient`
1109
+ * calls it with a bound context and forced decisions. Not part of the
1110
+ * published API: its options bag carries package-internal fields.
1111
+ */
1112
+ evaluateInternal(flagKey, defaultValue, context, options) {
825
1113
  if (!context) {
826
- this.logger.warn(
827
- `getFlag called without context for flagKey="${flagKey}"; returning default`
1114
+ this.warnOnce(
1115
+ "no-context",
1116
+ `getFlag("${flagKey}") was called without a context, so it cannot be evaluated and returns the default you passed. Bind one per request with server.forUser({ kind: "user", key: userId }), or pass it as the third argument: server.getFlag(key, default, { kind: "user", key: userId }).`
828
1117
  );
829
- return (0, import_core2.notFoundFlag)(defaultValue, "no context provided");
830
- }
831
- if (!this.ready || !this.datafile) {
832
- this.logger.debug(`SDK not ready, returning default for ${flagKey}`);
833
- return (0, import_core2.notFoundFlag)(defaultValue, "SDK not ready");
834
- }
835
- if (options?.forcedVariationKey) {
836
- return this.buildFlagFromVariationKey(
837
- flagKey,
838
- options.forcedVariationKey,
1118
+ return (0, import_core8.notReadyFlag)(
839
1119
  defaultValue,
840
- "runtimeOverride",
841
- context,
842
- decisionLog
1120
+ "no context passed; call server.forUser(context) or pass the context positionally"
843
1121
  );
844
1122
  }
845
- const legacyCtx = this.buildLegacyContext(context, options);
846
- const result = this.evaluate(flagKey, legacyCtx, defaultValue);
847
- this.emitEvaluation(flagKey, result.value, (0, import_core2.bucketingKey)(context), result.source);
848
- return this.toFlag(result, defaultValue, flagKey, context, decisionLog);
849
- }
850
- // ---------------------------------------------------------------------------
851
- // §5.11 Typed evaluator variants
852
- // ---------------------------------------------------------------------------
853
- getBoolFlag(flagKey, defaultValue, options) {
854
- return this.getTypedFlag(flagKey, defaultValue, "boolean", options);
855
- }
856
- getStringFlag(flagKey, defaultValue, options) {
857
- return this.getTypedFlag(flagKey, defaultValue, "string", options);
1123
+ if (!this.isReady() || !this.datafile) {
1124
+ this.warnOnce(
1125
+ "not-ready",
1126
+ `getFlag("${flagKey}") was called before the datafile loaded, so it returns the default you passed and flag.source is "not_ready". Await server.onReady() during boot, or pass a bootstrap datafile to the constructor for an instant start.`
1127
+ );
1128
+ return (0, import_core8.notReadyFlag)(defaultValue, "SDK not ready: no datafile loaded yet");
1129
+ }
1130
+ const decide = this.resolveDecideOptions(options?.decideOptions);
1131
+ const flag = options?.forcedVariationKey ? this.buildForcedFlag(
1132
+ flagKey,
1133
+ options.forcedVariationKey,
1134
+ defaultValue,
1135
+ context,
1136
+ options
1137
+ ) : this.evaluateAndBuild(
1138
+ flagKey,
1139
+ defaultValue,
1140
+ context,
1141
+ decide,
1142
+ options
1143
+ );
1144
+ return flag;
858
1145
  }
859
- getNumberFlag(flagKey, defaultValue, options) {
860
- return this.getTypedFlag(flagKey, defaultValue, "number", options);
1146
+ evaluateAndBuild(flagKey, defaultValue, context, decide, options) {
1147
+ const legacyCtx = this.buildLegacyContext(context, decide.suppressExposure);
1148
+ const result = this.evaluate(flagKey, legacyCtx, defaultValue);
1149
+ const typeMismatch = this.checkType(flagKey, options?.expectedType, result);
1150
+ if (typeMismatch) {
1151
+ this.logger.warn(
1152
+ `Type mismatch on flag "${flagKey}": ${typeMismatch}; returning the default.`
1153
+ );
1154
+ const fallback = (0, import_core8.notFoundFlag)(defaultValue, typeMismatch);
1155
+ this.recordDecision(flagKey, fallback, context, options);
1156
+ return fallback;
1157
+ }
1158
+ const flag = this.toFlag(result, defaultValue);
1159
+ this.recordDecision(flagKey, flag, context, options);
1160
+ return flag;
861
1161
  }
862
- getJsonFlag(flagKey, defaultValue, options) {
863
- return this.getTypedFlag(flagKey, defaultValue, "json", options);
1162
+ checkType(flagKey, expectedType, result) {
1163
+ if (!expectedType) return null;
1164
+ if (result.source === "not_found" || result.source === "disabled") return null;
1165
+ const datafileFlag = (0, import_core8.findDatafileFlag)(this.datafile, flagKey);
1166
+ return (0, import_core8.checkExpectedType)(datafileFlag, expectedType, result.value) ?? null;
864
1167
  }
865
- // ---------------------------------------------------------------------------
866
- // §5.7 getAllFlags — returns Record<string, Flag>
867
- // ---------------------------------------------------------------------------
1168
+ /**
1169
+ * Every flag in the environment, evaluated against one context.
1170
+ *
1171
+ * Positional context (B3). Note the deliberate difference from
1172
+ * `@avsbhq/browser`, which keeps an options bag (`getAllFlags({context})`)
1173
+ * because it is context-bound and the bag disambiguates its optional
1174
+ * arguments.
1175
+ */
868
1176
  getAllFlags(context, options) {
869
- if (!this.ready || !this.datafile) return {};
1177
+ if (!context) {
1178
+ this.warnOnce(
1179
+ "get-all-no-context",
1180
+ "getAllFlags() was called without a context and returned an empty object. Pass the context positionally, or use server.forUser(context).getAllFlags()."
1181
+ );
1182
+ return {};
1183
+ }
1184
+ if (!this.isReady() || !this.datafile) {
1185
+ this.warnOnce(
1186
+ "get-all-not-ready",
1187
+ "getAllFlags() was called before the datafile loaded and returned an empty object, which is indistinguishable from a project with no flags. Await server.onReady() first."
1188
+ );
1189
+ return {};
1190
+ }
870
1191
  const fireExposures = options?.fireExposures === true;
1192
+ const decideOptions = [...options?.decideOptions ?? []];
1193
+ if (!fireExposures && !decideOptions.includes("DISABLE_EXPOSURE")) {
1194
+ decideOptions.push("DISABLE_EXPOSURE");
1195
+ }
871
1196
  const out = {};
872
- for (const flagEntry of this.datafile.flags) {
873
- const flagOptions = {
874
- context,
875
- decideOptions: options?.decideOptions
876
- };
877
- if (!fireExposures) {
878
- const suppressCtx = this.buildLegacyContext(context, flagOptions);
879
- suppressCtx.suppressExposure = true;
880
- const result = this.evaluate(flagEntry.key, suppressCtx, void 0);
881
- out[flagEntry.key] = this.toFlagNoLog(result, void 0, flagEntry.key);
882
- } else {
883
- out[flagEntry.key] = this.getFlag(flagEntry.key, void 0, flagOptions);
884
- }
1197
+ for (const entry of this.datafile.flags) {
1198
+ out[entry.key] = this.evaluateInternal(entry.key, void 0, context, {
1199
+ decideOptions,
1200
+ decisionLog: options?.decisionLog
1201
+ });
885
1202
  }
886
1203
  return out;
887
1204
  }
888
1205
  // ---------------------------------------------------------------------------
889
- // Purchase tracking — fires immediately to /v1/collect/orders
890
- // ---------------------------------------------------------------------------
891
- async trackPurchase(visitorId, order) {
892
- if (!this.tracker) {
893
- this.logger.warn("trackPurchase called before tracker is ready; order dropped");
894
- return;
895
- }
896
- return this.tracker.trackPurchase(visitorId, order);
897
- }
898
- // ---------------------------------------------------------------------------
899
- // §5.6 Unified track — context required
1206
+ // Tracking
900
1207
  // ---------------------------------------------------------------------------
1208
+ /**
1209
+ * Queue a metric (conversion) event. Synchronous: it returns as soon as the
1210
+ * event is queued, and the tracker batches it.
1211
+ *
1212
+ * The context rides in the payload rather than positionally because it is
1213
+ * OPTIONAL vocabulary on the shared `TrackPayload` type and required only on
1214
+ * the server; see the README for why the two runtimes read differently here.
1215
+ */
901
1216
  track(eventKey, payload) {
902
- const context = payload.context;
903
- const userId = (0, import_core2.bucketingKey)(context);
1217
+ if (payload.properties) {
1218
+ this.warnOnce(
1219
+ "track-properties",
1220
+ `track("${eventKey}") was called with properties, which are NOT stored for metric events: the ingestion contract for a conversion keeps the event name, the numeric value, the timestamp, and the visitor id. Exposure events do carry properties. Nothing else about the call is affected.`
1221
+ );
1222
+ }
904
1223
  if (!this.tracker) {
905
- this.pendingEvents.push({ eventKey, payload });
1224
+ this.enqueuePending(eventKey, payload);
906
1225
  return;
907
1226
  }
908
1227
  this.tracker.queueMetric({
909
- visitorId: userId,
1228
+ visitorId: (0, import_core8.bucketingKey)(payload.context),
910
1229
  eventName: eventKey,
911
- revenue: payload.value ?? null,
1230
+ // TWO columns, not one. `revenue` is money in decimal major units;
1231
+ // `value` is the numeric metric value for average-value metrics. Storage
1232
+ // declares both (`metric_events.revenue`, `metric_events.value`). This
1233
+ // SDK used to ship `payload.value` into the revenue column and could
1234
+ // never write the value column at all, so a quantity was recorded as
1235
+ // money and every average-value metric read empty.
1236
+ revenue: payload.revenue ?? null,
1237
+ value: payload.value ?? null,
912
1238
  timestamp: Date.now(),
913
- pageUrl: "",
914
- ...payload.properties ? { properties: payload.properties } : {}
1239
+ pageUrl: ""
915
1240
  });
916
1241
  }
917
- // ---------------------------------------------------------------------------
918
- // §5.13 manualExposure — updated to EvalContext
919
- // ---------------------------------------------------------------------------
1242
+ /**
1243
+ * Send a purchase to `/v1/collect/orders`.
1244
+ *
1245
+ * Queued before the tracker exists, exactly like `track` (they used to
1246
+ * disagree: one queued, the other warned and dropped the order).
1247
+ */
1248
+ async trackPurchase(visitorId, order) {
1249
+ if (this.destroyed) return;
1250
+ if (!this.tracker) {
1251
+ if (this.pendingPurchases.length >= this.eventOptions.maxQueueSize) {
1252
+ this.pendingPurchases.shift();
1253
+ this.warnOnce(
1254
+ "pending-purchase-full",
1255
+ `Pre-ready purchase queue reached ${this.eventOptions.maxQueueSize} orders; the oldest is being dropped. This means the datafile never loaded: check the datafile error logged above.`
1256
+ );
1257
+ }
1258
+ this.pendingPurchases.push({ visitorId, order });
1259
+ return;
1260
+ }
1261
+ return this.tracker.trackPurchase(visitorId, order);
1262
+ }
1263
+ /**
1264
+ * Mark the moment a signup or login claimed an anonymous session. Same shape
1265
+ * and same honesty as the browser client's `alias`.
1266
+ *
1267
+ * WHAT IT DOES: queues one `avsb_alias` event attributed to the NEW
1268
+ * identity's key.
1269
+ *
1270
+ * WHAT IT DOES NOT DO, precisely:
1271
+ * - It does not rewrite sticky assignments or move past exposures. Nothing
1272
+ * already decided for either identity changes.
1273
+ * - It does not store the PREVIOUS key. The conversion wire
1274
+ * (`/v1/collect/metric-batch`) carries no properties column, so a
1275
+ * retroactive session join is not available yet.
1276
+ *
1277
+ * To keep one visitor's bucketing across a login, evaluate with the SAME
1278
+ * context key on both sides. See "Sharing identity with the web snippet" in
1279
+ * the README for the cross-surface version of the same rule.
1280
+ */
1281
+ alias(previousContext, newContext) {
1282
+ this.logger.debug(
1283
+ `alias: ${(0, import_core8.bucketingKey)(previousContext)} to ${(0, import_core8.bucketingKey)(newContext)}`
1284
+ );
1285
+ this.track("avsb_alias", { context: newContext });
1286
+ }
1287
+ /**
1288
+ * Record an exposure for a flag without re-serving its value. Use it when
1289
+ * the decision was read earlier with exposures suppressed and the visitor has
1290
+ * now actually seen the result.
1291
+ */
920
1292
  manualExposure(flagKey, context) {
921
- if (!this.ready || !this.datafile || !this.tracker) return;
922
- const legacyCtx = this.buildLegacyContext(context, { suppressExposure: true });
1293
+ if (!this.isReady() || !this.datafile || !this.tracker) return;
1294
+ const legacyCtx = this.buildLegacyContext(context, true);
923
1295
  const result = this.evaluate(flagKey, legacyCtx, void 0);
924
1296
  if (result.source !== "rule" || !result.ruleId || !result.variationKey) return;
925
1297
  if (result.ruleType !== "ab_test" && result.ruleType !== "targeted_delivery") return;
@@ -927,9 +1299,8 @@ var AvsbServer = class {
927
1299
  if (!flag) return;
928
1300
  const variation = flag.variations.find((v) => v.key === result.variationKey);
929
1301
  if (!variation) return;
930
- const userId = (0, import_core2.bucketingKey)(context);
931
- this.tracker.queue({
932
- visitorId: userId,
1302
+ this.deliverExposure({
1303
+ visitorId: (0, import_core8.bucketingKey)(context),
933
1304
  experimentId: result.ruleId,
934
1305
  variationId: variation.id,
935
1306
  eventName: "flag_exposure",
@@ -940,57 +1311,40 @@ var AvsbServer = class {
940
1311
  });
941
1312
  }
942
1313
  // ---------------------------------------------------------------------------
943
- // §5.8 forUser / createUserContext — UserBoundClient factory
1314
+ // Per-request scoping
944
1315
  // ---------------------------------------------------------------------------
1316
+ /** A client with the context bound. The primary server pattern. */
945
1317
  forUser(context) {
946
1318
  return createUserBoundClient(this, context);
947
1319
  }
1320
+ /** Ergonomic alias for `forUser`. */
948
1321
  createUserContext(context) {
949
1322
  return createUserBoundClient(this, context);
950
1323
  }
951
1324
  // ---------------------------------------------------------------------------
952
- // §5.9 Override methods — restructured storage
1325
+ // Overrides
953
1326
  // ---------------------------------------------------------------------------
954
1327
  setOverrideForUser(flagKey, contextKey, variationKey, contextKind = "user") {
955
- if (!this.overrides.has(flagKey)) {
956
- this.overrides.set(flagKey, { perKindKey: /* @__PURE__ */ new Map() });
957
- }
958
- const entry = this.overrides.get(flagKey);
959
- entry.perKindKey.set(`${contextKind}:${contextKey}`, variationKey);
1328
+ this.overrides.setForContext(flagKey, contextKey, variationKey, contextKind);
960
1329
  }
961
1330
  setGlobalOverride(flagKey, variationKey) {
962
- if (!this.overrides.has(flagKey)) {
963
- this.overrides.set(flagKey, { perKindKey: /* @__PURE__ */ new Map() });
964
- }
965
- this.overrides.get(flagKey).global = variationKey;
1331
+ this.overrides.setGlobal(flagKey, variationKey);
966
1332
  }
967
1333
  clearOverrideForUser(flagKey, contextKey, contextKind = "user") {
968
- const entry = this.overrides.get(flagKey);
969
- if (!entry) return;
970
- entry.perKindKey.delete(`${contextKind}:${contextKey}`);
971
- if (!entry.global && entry.perKindKey.size === 0) {
972
- this.overrides.delete(flagKey);
973
- }
1334
+ this.overrides.clearForContext(flagKey, contextKey, contextKind);
974
1335
  }
975
1336
  clearGlobalOverride(flagKey) {
976
- const entry = this.overrides.get(flagKey);
977
- if (!entry) return;
978
- entry.global = void 0;
979
- if (entry.perKindKey.size === 0) {
980
- this.overrides.delete(flagKey);
981
- }
1337
+ this.overrides.clearGlobal(flagKey);
982
1338
  }
983
1339
  clearAllOverrides() {
984
- this.overrides.clear();
1340
+ this.overrides.clearAll();
985
1341
  }
986
- // ---------------------------------------------------------------------------
987
- // Decision log factory (convenience)
988
- // ---------------------------------------------------------------------------
1342
+ /** A fresh decision log to pass per call. `forUser` carries its own. */
989
1343
  createDecisionLog() {
990
1344
  return createDecisionLog();
991
1345
  }
992
1346
  // ---------------------------------------------------------------------------
993
- // Event listener API — on() returns unsubscribe; off() kept for compat
1347
+ // Events
994
1348
  // ---------------------------------------------------------------------------
995
1349
  on(event, cb) {
996
1350
  if (!this.listeners.has(event)) this.listeners.set(event, /* @__PURE__ */ new Set());
@@ -1002,13 +1356,50 @@ var AvsbServer = class {
1002
1356
  off(event, cb) {
1003
1357
  this.listeners.get(event)?.delete(cb);
1004
1358
  }
1359
+ /**
1360
+ * `on` for the shared `ClientEventMap` names the utils layer speaks. The
1361
+ * server emits every one of them except `flagChange`, which needs per-flag
1362
+ * diffing the server does not do; subscribing to it warns and hands back a
1363
+ * no-op disposer instead of pretending.
1364
+ */
1365
+ onSharedEvent(event, cb) {
1366
+ if (event === "flagChange") {
1367
+ this.warnOnce(
1368
+ "no-flag-change",
1369
+ 'The server SDK does not emit "flagChange": it holds one datafile for the whole process and does not diff individual flags. Subscribe to "configUpdate" for "a new datafile was published" instead.'
1370
+ );
1371
+ return () => void 0;
1372
+ }
1373
+ return this.on(
1374
+ event,
1375
+ cb
1376
+ );
1377
+ }
1378
+ emit(event, ...args) {
1379
+ const cbs = this.listeners.get(event);
1380
+ if (!cbs) return;
1381
+ for (const cb of cbs) {
1382
+ try {
1383
+ ;
1384
+ cb(...args);
1385
+ } catch (e) {
1386
+ this.logger.error("Listener error", { event, error: String(e) });
1387
+ }
1388
+ }
1389
+ }
1390
+ warnOnce(key, message) {
1391
+ if (this.warnedOnce.has(key)) return;
1392
+ this.warnedOnce.add(key);
1393
+ this.logger.warn(message);
1394
+ }
1005
1395
  // ---------------------------------------------------------------------------
1006
- // Private — core evaluate
1396
+ // Private — evaluation core
1007
1397
  // ---------------------------------------------------------------------------
1008
1398
  evaluate(flagKey, uc, defaultValue) {
1009
1399
  const reasons = [];
1010
- const rtOver = this.resolveRuntimeOverride(flagKey, uc.userId);
1011
- if (rtOver !== void 0) {
1400
+ const overrideKey = this.overrides.resolve(flagKey, uc.userId);
1401
+ const rtOver = overrideKey ? this.findVariation(flagKey, overrideKey) : null;
1402
+ if (rtOver) {
1012
1403
  reasons.push(`matched runtime override for flagKey="${flagKey}"`);
1013
1404
  return {
1014
1405
  value: rtOver.value,
@@ -1037,15 +1428,9 @@ var AvsbServer = class {
1037
1428
  }
1038
1429
  }
1039
1430
  const legacyCtx = { attributes: { userId: uc.userId, ...uc.attributes } };
1040
- const shouldFire = !uc.suppressExposure;
1041
- const onExposure = shouldFire ? (event) => {
1042
- if (uc.privateAttributes?.length && event.properties) {
1043
- const cleaned = { ...event.properties };
1044
- for (const k of uc.privateAttributes) delete cleaned[k];
1045
- event = { ...event, properties: cleaned };
1046
- }
1047
- this.tracker?.queue(event);
1048
- } : void 0;
1431
+ const onExposure = uc.suppressExposure ? void 0 : (event) => {
1432
+ this.deliverExposure((0, import_exposure.redactExposure)(event, uc.privateAttributes));
1433
+ };
1049
1434
  const r = (0, import_engine.evaluateFlag)(
1050
1435
  flagKey,
1051
1436
  defaultValue,
@@ -1068,68 +1453,28 @@ var AvsbServer = class {
1068
1453
  return {
1069
1454
  value: r.value,
1070
1455
  variationKey: r.variationKey,
1071
- source: (0, import_core2.mapLegacySource)(r.source),
1456
+ source: (0, import_core8.mapLegacySource)(r.source),
1072
1457
  ruleId: r.ruleId,
1073
1458
  ruleType: r.ruleType,
1074
1459
  reasons
1075
1460
  };
1076
1461
  }
1077
- // ---------------------------------------------------------------------------
1078
- // Private — typed evaluation helper
1079
- // ---------------------------------------------------------------------------
1080
- getTypedFlag(flagKey, defaultValue, expectedType, options) {
1081
- const flag = this.getFlag(flagKey, defaultValue, options);
1082
- if (flag.source === "not_found" || flag.source === "disabled") {
1083
- return flag;
1084
- }
1085
- const datafileFlag = (0, import_core2.findDatafileFlag)(this.datafile, flagKey);
1086
- const mismatch = (0, import_core2.checkExpectedType)(datafileFlag, expectedType, flag.value);
1087
- if (mismatch) {
1088
- this.logger.warn(
1089
- `Type mismatch on flag "${flagKey}": ${mismatch}; returning default`
1090
- );
1091
- return (0, import_core2.notFoundFlag)(defaultValue, mismatch);
1092
- }
1093
- return flag;
1094
- }
1095
- // ---------------------------------------------------------------------------
1096
- // Private — build Flag<T> from an EvalResult
1097
- // ---------------------------------------------------------------------------
1098
- toFlag(result, defaultValue, flagKey, context, decisionLog, startMicros) {
1099
- const evaluatedAt = Date.now();
1100
- const durationMicros = startMicros !== void 0 ? performance.now() * 1e3 - startMicros : 0;
1101
- const flag = (0, import_core2.createFlag)({
1102
- value: result.value ?? defaultValue,
1103
- variationKey: result.variationKey,
1104
- source: result.source,
1105
- ruleId: result.ruleId,
1106
- ruleType: result.ruleType,
1107
- reasons: result.reasons,
1108
- evaluatedAt,
1109
- durationMicros: Math.round(durationMicros)
1110
- });
1111
- if (decisionLog) {
1112
- const privateAttrs = (0, import_core2.privateAttributesFrom)(context);
1113
- const summary = (0, import_core2.contextSummary)(context);
1114
- const redactedValue = privateAttrs.size > 0 && typeof result.value === "string" && privateAttrs.has(result.value) ? "[REDACTED]" : result.value;
1115
- decisionLog.entries.push({
1116
- flagKey,
1117
- value: redactedValue,
1118
- variationKey: result.variationKey,
1119
- source: result.source,
1120
- ruleId: result.ruleId,
1121
- ruleType: result.ruleType,
1122
- reasons: [...result.reasons],
1123
- evaluatedAt,
1124
- durationMicros: Math.round(durationMicros),
1125
- contextSummary: summary
1126
- });
1462
+ /**
1463
+ * One exposure, delivered everywhere: queued for ingestion AND emitted on
1464
+ * the event bus with the SAME `eventId`, so a customer forwarding decisions
1465
+ * to their own analytics can reconcile rows with A vs B results.
1466
+ */
1467
+ deliverExposure(event) {
1468
+ const stamped = event.eventId ? event : { ...event, eventId: (0, import_node_crypto2.randomUUID)() };
1469
+ if (this.tracker) {
1470
+ this.tracker.queue(stamped);
1471
+ } else {
1472
+ this.enqueuePendingExposure(stamped);
1127
1473
  }
1128
- return flag;
1474
+ this.emit("exposure", stamped);
1129
1475
  }
1130
- /** toFlag variant that never writes a decision log (used by getAllFlags suppressed path). */
1131
- toFlagNoLog(result, defaultValue, _flagKey) {
1132
- return (0, import_core2.createFlag)({
1476
+ toFlag(result, defaultValue) {
1477
+ return (0, import_core8.createFlag)({
1133
1478
  value: result.value ?? defaultValue,
1134
1479
  variationKey: result.variationKey,
1135
1480
  source: result.source,
@@ -1140,28 +1485,47 @@ var AvsbServer = class {
1140
1485
  durationMicros: 0
1141
1486
  });
1142
1487
  }
1143
- /** Build a Flag<T> from a forced variation key (per-request override). */
1144
- buildFlagFromVariationKey(flagKey, variationKey, defaultValue, source, context, decisionLog) {
1488
+ /** Build a Flag from a forced variation key (per-request forced decision). */
1489
+ buildForcedFlag(flagKey, variationKey, defaultValue, context, options) {
1145
1490
  const vref = this.findVariation(flagKey, variationKey);
1146
- const value = vref ? vref.value : defaultValue;
1147
- const result = {
1148
- value,
1491
+ const flag = (0, import_core8.createFlag)({
1492
+ value: vref ? vref.value : defaultValue,
1149
1493
  variationKey: vref ? vref.key : null,
1150
- source,
1494
+ source: "runtimeOverride",
1151
1495
  ruleId: null,
1152
1496
  ruleType: null,
1153
1497
  reasons: [`forced decision: variationKey="${variationKey}"`]
1154
- };
1155
- return this.toFlag(result, defaultValue, flagKey, context, decisionLog);
1498
+ });
1499
+ this.recordDecision(flagKey, flag, context, options);
1500
+ return flag;
1156
1501
  }
1157
- // ---------------------------------------------------------------------------
1158
- // Private — build LegacyUserContext from EvalContext
1159
- // ---------------------------------------------------------------------------
1160
- buildLegacyContext(context, options) {
1161
- const legacy = (0, import_core2.toLegacyEvalContext)(context);
1502
+ /** Emit the evaluation event and append to a decision log if one was passed. */
1503
+ recordDecision(flagKey, flag, context, options) {
1504
+ const summary = (0, import_core8.contextSummary)(context);
1505
+ this.emit("evaluation", {
1506
+ flagKey,
1507
+ flag,
1508
+ contextKeys: summary.map((entry) => entry.key)
1509
+ });
1510
+ if (!options?.decisionLog) return;
1511
+ appendDecisionLogEntry(options.decisionLog, {
1512
+ flagKey,
1513
+ value: flag.value,
1514
+ variationKey: flag.variationKey,
1515
+ source: flag.source,
1516
+ ruleId: flag.ruleId,
1517
+ ruleType: flag.ruleType,
1518
+ reasons: flag.reasons,
1519
+ evaluatedAt: flag.evaluatedAt,
1520
+ durationMicros: flag.durationMicros,
1521
+ contextSummary: summary,
1522
+ privateValues: privateAttributeValues(context)
1523
+ });
1524
+ }
1525
+ buildLegacyContext(context, suppressExposure) {
1526
+ const legacy = (0, import_core8.toLegacyEvalContext)(context);
1162
1527
  const userId = legacy.attributes["userId"] ?? "";
1163
- const privateAttrs = (0, import_core2.privateAttributesFrom)(context);
1164
- const suppressExposure = options?.suppressExposure === true || options?.decideOptions?.includes("DISABLE_EXPOSURE") === true;
1528
+ const privateAttrs = (0, import_core8.privateAttributesFrom)(context);
1165
1529
  const { userId: _uid, ...rest } = legacy.attributes;
1166
1530
  return {
1167
1531
  userId,
@@ -1171,18 +1535,7 @@ var AvsbServer = class {
1171
1535
  };
1172
1536
  }
1173
1537
  // ---------------------------------------------------------------------------
1174
- // Private — override resolution
1175
- // ---------------------------------------------------------------------------
1176
- resolveRuntimeOverride(flagKey, userId) {
1177
- const entry = this.overrides.get(flagKey);
1178
- if (!entry) return void 0;
1179
- const userVk = entry.perKindKey.get(`user:${userId}`);
1180
- const vk = userVk ?? entry.global;
1181
- if (!vk) return void 0;
1182
- return this.findVariation(flagKey, vk) ?? void 0;
1183
- }
1184
- // ---------------------------------------------------------------------------
1185
- // Private — sticky bucket helpers
1538
+ // Private — sticky helpers
1186
1539
  // ---------------------------------------------------------------------------
1187
1540
  resolveStickyBucket(flagKey, userId) {
1188
1541
  if (!this.stickyBucketService || !this.datafile) return void 0;
@@ -1224,7 +1577,7 @@ var AvsbServer = class {
1224
1577
  });
1225
1578
  return;
1226
1579
  }
1227
- this.tracker.queue({
1580
+ this.deliverExposure({
1228
1581
  visitorId: uc.userId,
1229
1582
  experimentId: rule.id,
1230
1583
  variationId: sticky.vref.id,
@@ -1236,29 +1589,11 @@ var AvsbServer = class {
1236
1589
  });
1237
1590
  }
1238
1591
  // ---------------------------------------------------------------------------
1239
- // Private — event emission
1240
- // ---------------------------------------------------------------------------
1241
- emit(event, ...args) {
1242
- const cbs = this.listeners.get(event);
1243
- if (!cbs) return;
1244
- for (const cb of cbs) {
1245
- try {
1246
- ;
1247
- cb(...args);
1248
- } catch (e) {
1249
- this.logger.error("Listener error", { event, error: String(e) });
1250
- }
1251
- }
1252
- }
1253
- emitEvaluation(flagKey, value, userId, source) {
1254
- this.emit("evaluation", { flagKey, value, userId, source });
1255
- }
1256
- // ---------------------------------------------------------------------------
1257
- // Private — tracker + polling + streaming
1592
+ // Private — tracker, polling, streaming, heartbeat
1258
1593
  // ---------------------------------------------------------------------------
1259
1594
  initTracker() {
1260
1595
  if (!this.datafile || this.tracker) return;
1261
- this.tracker = createServerTracker({
1596
+ const tracker = createServerTracker({
1262
1597
  sdkKey: this.sdkKey,
1263
1598
  collectEndpoint: this.datafile.collectEndpoint,
1264
1599
  flushInterval: this.eventOptions.flushInterval,
@@ -1267,155 +1602,162 @@ var AvsbServer = class {
1267
1602
  retryAttempts: this.eventOptions.retryAttempts,
1268
1603
  logger: this.logger
1269
1604
  });
1605
+ this.tracker = tracker;
1606
+ if (this.pendingExposures.length > 0) {
1607
+ const queued = this.pendingExposures;
1608
+ this.pendingExposures = [];
1609
+ for (const event of queued) tracker.queue(event);
1610
+ }
1270
1611
  if (this.pendingEvents.length > 0) {
1271
1612
  const queued = this.pendingEvents;
1272
1613
  this.pendingEvents = [];
1273
- for (const item of queued) {
1274
- this.track(item.eventKey, item.payload);
1275
- }
1614
+ for (const item of queued) this.track(item.eventKey, item.payload);
1615
+ }
1616
+ if (this.pendingPurchases.length > 0) {
1617
+ const queued = this.pendingPurchases;
1618
+ this.pendingPurchases = [];
1619
+ for (const item of queued) void this.trackPurchase(item.visitorId, item.order);
1276
1620
  }
1277
1621
  }
1622
+ enqueuePendingExposure(event) {
1623
+ if (this.pendingExposures.length >= this.eventOptions.maxQueueSize) {
1624
+ this.pendingExposures.shift();
1625
+ this.warnOnce(
1626
+ "pending-exposures-full",
1627
+ `Pre-ready exposure queue reached ${this.eventOptions.maxQueueSize} events; the oldest are being dropped. This means the datafile never loaded, so nothing has been delivered yet: check the datafile error logged above.`
1628
+ );
1629
+ }
1630
+ this.pendingExposures.push(event);
1631
+ }
1632
+ enqueuePending(eventKey, payload) {
1633
+ if (this.pendingEvents.length >= this.eventOptions.maxQueueSize) {
1634
+ this.pendingEvents.shift();
1635
+ this.warnOnce(
1636
+ "pending-events-full",
1637
+ `Pre-ready event queue reached ${this.eventOptions.maxQueueSize} events; the oldest are being dropped. This means the datafile never loaded, so nothing has been delivered yet: check the datafile error logged above.`
1638
+ );
1639
+ }
1640
+ this.pendingEvents.push({ eventKey, payload });
1641
+ }
1642
+ /**
1643
+ * Fetch and apply the datafile.
1644
+ *
1645
+ * Three outcomes, not two, because a conditional request has three:
1646
+ * 'applied' a datafile came back and is now in memory
1647
+ * 'unchanged' the CDN answered 304: what we hold IS current
1648
+ * 'failed' nothing usable came back; the error is already logged
1649
+ *
1650
+ * 'unchanged' is a success. Collapsing it into 'failed' made
1651
+ * `refreshDatafile()` warn that a refresh "did not apply a new datafile"
1652
+ * every time the datafile was simply up to date.
1653
+ */
1654
+ async loadDatafile(reason = "manual") {
1655
+ const outcome = await fetchDatafile({
1656
+ sdkKey: this.sdkKey,
1657
+ cdnHost: this.cdnHost,
1658
+ signal: this.abortController.signal,
1659
+ logger: this.logger,
1660
+ etag: this.datafileEtag
1661
+ });
1662
+ if (outcome.status === "ok" || outcome.status === "not-modified") {
1663
+ this.sendHeartbeat();
1664
+ }
1665
+ if (outcome.status === "not-modified") return "unchanged";
1666
+ if (outcome.status !== "ok") return "failed";
1667
+ const changed = !this.datafile || this.datafile.publishedAt !== outcome.datafile.publishedAt;
1668
+ this.datafile = outcome.datafile;
1669
+ this.datafileEtag = outcome.etag;
1670
+ if (this.ready && changed) {
1671
+ this.emit("configUpdate", {
1672
+ publishedAt: outcome.datafile.publishedAt,
1673
+ reason
1674
+ });
1675
+ this.logger.info("Datafile updated", {
1676
+ publishedAt: outcome.datafile.publishedAt,
1677
+ reason
1678
+ });
1679
+ this.startStreamingIfEnabled();
1680
+ }
1681
+ return "applied";
1682
+ }
1683
+ /**
1684
+ * Poll on a JITTERED interval. Without the jitter every process that
1685
+ * deployed together polls the CDN on the same tick forever.
1686
+ */
1687
+ startPolling() {
1688
+ if (this.pollingTimer || this.destroyed) return;
1689
+ const schedule = () => {
1690
+ this.pollingTimer = setTimeout(() => {
1691
+ this.pollingTimer = null;
1692
+ void (async () => {
1693
+ const result = await this.loadDatafile("poll");
1694
+ if (result === "applied" && !this.ready) this.onDatafileReadable();
1695
+ if (!this.destroyed) schedule();
1696
+ })();
1697
+ }, (0, import_backoff2.jitteredInterval)(this.pollingInterval));
1698
+ };
1699
+ schedule();
1700
+ }
1701
+ /**
1702
+ * Open the SSE stream when one is available, and fall back to polling in
1703
+ * silence when it is not (B12). The endpoint comes from the datafile's
1704
+ * `streamEndpoint` field, or an explicit option; it is never guessed from
1705
+ * another host, which is what gave one port an infinite reconnect loop
1706
+ * against DNS that does not resolve.
1707
+ */
1278
1708
  startStreamingIfEnabled() {
1279
1709
  if (!this.streamingEnabled || this.streamingClient || this.destroyed) return;
1710
+ const resolution = (0, import_core8.resolveStreamEndpoint)({
1711
+ optionEndpoint: this.streamingEndpointOption,
1712
+ datafileStreamEndpoint: this.datafile?.streamEndpoint ?? null
1713
+ });
1714
+ if (!resolution.available) {
1715
+ this.logger.debug(`Streaming stays off, polling continues. ${resolution.reason}`);
1716
+ return;
1717
+ }
1280
1718
  this.streamingClient = (0, import_streaming.createStreamingClient)({
1281
- endpoint: this.streamingEndpoint,
1719
+ endpoint: resolution.endpoint,
1282
1720
  sdkKey: this.sdkKey,
1283
1721
  onDatafileUpdated: () => {
1284
- void this.refreshDatafile();
1722
+ void this.loadDatafile("stream");
1285
1723
  },
1286
1724
  logger: this.logger
1287
1725
  });
1288
1726
  this.streamingClient.connect();
1727
+ this.logger.debug(
1728
+ `Streaming datafile updates from ${resolution.endpoint} (${resolution.source}).`
1729
+ );
1289
1730
  }
1290
- async fetchDatafile() {
1291
- const url = `${this.cdnHost}/${this.sdkKey}/datafile.json`;
1292
- try {
1293
- const res = await fetch(url, { signal: this.abortController.signal });
1294
- if (!res.ok) {
1295
- this.logger.warn("Datafile fetch failed", { status: res.status, url });
1296
- return null;
1297
- }
1298
- return await res.json();
1299
- } catch (e) {
1300
- if (this.abortController.signal.aborted) return null;
1301
- this.logger.warn("Datafile fetch error", { error: String(e), url });
1302
- return null;
1303
- }
1304
- }
1305
- startPolling() {
1306
- if (this.pollingId || this.destroyed) return;
1307
- this.pollingId = setInterval(async () => {
1308
- const df = await this.fetchDatafile();
1309
- if (!df) return;
1310
- const changed = !this.datafile || this.datafile.publishedAt !== df.publishedAt;
1311
- if (!this.ready) {
1312
- this.datafile = df;
1313
- this.initTracker();
1314
- this.ready = true;
1315
- this.emit("ready");
1316
- } else if (changed) {
1317
- this.datafile = df;
1318
- this.emit("configUpdate", { publishedAt: df.publishedAt });
1319
- this.logger.info("Datafile updated", { publishedAt: df.publishedAt });
1320
- }
1321
- }, this.pollingInterval);
1322
- }
1731
+ /**
1732
+ * Report liveness, throttled.
1733
+ *
1734
+ * Called on every successful datafile load, not just at init: the dashboard
1735
+ * marks an environment Stale after five minutes without a heartbeat, so an
1736
+ * init-only heartbeat made every long-running process look dead a few
1737
+ * minutes after it started. The throttle keeps a fast polling interval (or a
1738
+ * burst of stream-triggered refreshes) from turning liveness into traffic.
1739
+ *
1740
+ * Fire and forget: a failed heartbeat never affects evaluation, and the
1741
+ * throttle clock is stamped by the synchronous send below, so a hanging
1742
+ * request cannot queue a second one.
1743
+ */
1323
1744
  sendHeartbeat() {
1324
- if (!this.datafile?.heartbeatEndpoint) return;
1325
- fetch(this.datafile.heartbeatEndpoint, {
1326
- method: "POST",
1327
- headers: { "Content-Type": "application/json" },
1328
- body: JSON.stringify({
1329
- sdkKey: this.sdkKey,
1330
- sdkType: "@avsbhq/node",
1331
- sdkVersion: SDK_VERSION
1332
- }),
1745
+ const now = Date.now();
1746
+ if (now - this.lastHeartbeatAt < HEARTBEAT_MIN_INTERVAL_MS) return;
1747
+ const posted = sendHeartbeat({
1748
+ sdkKey: this.sdkKey,
1749
+ sdkType: SDK_TYPE,
1750
+ sdkVersion: SDK_VERSION,
1751
+ datafileHeartbeatEndpoint: this.datafile?.heartbeatEndpoint,
1333
1752
  signal: this.abortController.signal
1334
- }).catch(() => {
1335
1753
  });
1754
+ if (posted) this.lastHeartbeatAt = now;
1336
1755
  }
1337
1756
  };
1338
1757
 
1339
- // src/middleware.ts
1340
- function createNoopUserBoundClient() {
1341
- const noopFlag = (defaultValue) => ({
1342
- value: defaultValue,
1343
- variationKey: null,
1344
- source: "not_found",
1345
- ruleId: null,
1346
- ruleType: null,
1347
- reasons: ["no userId available"],
1348
- evaluatedAt: Date.now(),
1349
- durationMicros: 0,
1350
- isEnabled: () => false,
1351
- exists: () => false
1352
- });
1353
- return {
1354
- getFlag(_flagKey, defaultValue) {
1355
- return noopFlag(defaultValue);
1356
- },
1357
- getBoolFlag(_flagKey, defaultValue) {
1358
- return noopFlag(defaultValue);
1359
- },
1360
- getStringFlag(_flagKey, defaultValue) {
1361
- return noopFlag(defaultValue);
1362
- },
1363
- getNumberFlag(_flagKey, defaultValue) {
1364
- return noopFlag(defaultValue);
1365
- },
1366
- getJsonFlag(_flagKey, defaultValue) {
1367
- return noopFlag(defaultValue);
1368
- },
1369
- getAllFlags() {
1370
- return {};
1371
- },
1372
- track() {
1373
- },
1374
- setForcedDecision() {
1375
- },
1376
- clearForcedDecision() {
1377
- },
1378
- getDecisionLog() {
1379
- return { entries: [] };
1380
- }
1381
- };
1382
- }
1383
- function expressMiddleware(client, config) {
1384
- return (req, res, next) => {
1385
- const userId = config.userIdFrom(req);
1386
- if (!userId) {
1387
- ;
1388
- req.avsb = createNoopUserBoundClient();
1389
- return next();
1390
- }
1391
- const attributes = config.attributesFrom?.(req) ?? {};
1392
- const context = { kind: "user", key: userId, ...attributes };
1393
- req.avsb = client.forUser(context);
1394
- next();
1395
- };
1396
- }
1397
- function fastifyPlugin(instance, opts, done) {
1398
- const fastify = instance;
1399
- fastify.decorateRequest("avsb", null);
1400
- fastify.addHook("onRequest", (...args) => {
1401
- const req = args[0];
1402
- const hookDone = args[2];
1403
- const userId = opts.userIdFrom(req);
1404
- if (!userId) {
1405
- ;
1406
- req.avsb = createNoopUserBoundClient();
1407
- return hookDone();
1408
- }
1409
- const attributes = opts.attributesFrom?.(req) ?? {};
1410
- const context = { kind: "user", key: userId, ...attributes };
1411
- req.avsb = opts.client.forUser(context);
1412
- hookDone();
1413
- });
1414
- done();
1415
- }
1416
-
1417
- // src/logger.ts
1418
- var import_core3 = require("@avsbhq/core");
1758
+ // src/index.ts
1759
+ var import_express = require("@avsbhq/utils/middleware/express");
1760
+ var import_fastify = require("@avsbhq/utils/middleware/fastify");
1419
1761
 
1420
1762
  // src/stickyBucket.ts
1421
1763
  var InMemoryStickyBucketService = class {
@@ -1447,7 +1789,7 @@ var RedisStickyBucketService = class {
1447
1789
  this.prefix = options.prefix ?? "avsb:sticky:";
1448
1790
  this.ttl = options.ttl ?? 2592e3;
1449
1791
  this.maxCacheSize = options.cacheSize ?? 1e4;
1450
- this.logger = options.logger ?? import_core3.noopLogger;
1792
+ this.logger = options.logger ?? import_core4.noopLogger;
1451
1793
  }
1452
1794
  redisKey(userId, flagKey) {
1453
1795
  return `${this.prefix}${userId}:${flagKey}`;
@@ -1509,11 +1851,88 @@ var RedisStickyBucketService = class {
1509
1851
  }
1510
1852
  };
1511
1853
 
1854
+ // src/webhookSignature.ts
1855
+ var import_crypto = __toESM(require("crypto"), 1);
1856
+ var DEFAULT_TOLERANCE_SECONDS = 300;
1857
+ var HEADER_ID = "x-avsb-delivery-id";
1858
+ var HEADER_TIMESTAMP = "x-avsb-timestamp";
1859
+ var HEADER_SIGNATURE = "x-avsb-signature";
1860
+ function readHeader(headers, name) {
1861
+ if (typeof headers.get === "function") {
1862
+ return headers.get(name);
1863
+ }
1864
+ const bag = headers;
1865
+ const value = bag[name] ?? bag[name.toUpperCase()] ?? findCaseInsensitive(bag, name);
1866
+ if (value === void 0) return null;
1867
+ return Array.isArray(value) ? value[0] ?? null : value;
1868
+ }
1869
+ function findCaseInsensitive(bag, name) {
1870
+ for (const key of Object.keys(bag)) {
1871
+ if (key.toLowerCase() === name) return bag[key];
1872
+ }
1873
+ return void 0;
1874
+ }
1875
+ function digestsMatch(a, b) {
1876
+ if (a.length !== b.length) return false;
1877
+ return import_crypto.default.timingSafeEqual(Buffer.from(a, "utf8"), Buffer.from(b, "utf8"));
1878
+ }
1879
+ function verifyWebhookSignature(payload, headers, secret, options = {}) {
1880
+ const deliveryId = readHeader(headers, HEADER_ID);
1881
+ const timestamp = readHeader(headers, HEADER_TIMESTAMP);
1882
+ const signatureHeader = readHeader(headers, HEADER_SIGNATURE);
1883
+ if (!deliveryId || !timestamp || !signatureHeader) {
1884
+ return {
1885
+ ok: false,
1886
+ reason: "missing_headers",
1887
+ message: `Expected ${HEADER_ID}, ${HEADER_TIMESTAMP} and ${HEADER_SIGNATURE} headers. Got id=${Boolean(deliveryId)} timestamp=${Boolean(timestamp)} signature=${Boolean(signatureHeader)}.`
1888
+ };
1889
+ }
1890
+ if (!signatureHeader.startsWith("sha256=")) {
1891
+ return {
1892
+ ok: false,
1893
+ reason: "malformed_signature",
1894
+ message: `${HEADER_SIGNATURE} must look like "sha256=<hex>", got "${signatureHeader.slice(0, 16)}\u2026".`
1895
+ };
1896
+ }
1897
+ const provided = signatureHeader.slice("sha256=".length);
1898
+ const signedAtMs = Date.parse(timestamp);
1899
+ if (Number.isNaN(signedAtMs)) {
1900
+ return {
1901
+ ok: false,
1902
+ reason: "malformed_timestamp",
1903
+ message: `${HEADER_TIMESTAMP} is not an ISO-8601 instant: "${timestamp}".`
1904
+ };
1905
+ }
1906
+ const tolerance = options.toleranceSeconds ?? DEFAULT_TOLERANCE_SECONDS;
1907
+ if (tolerance > 0) {
1908
+ const nowMs = (options.now ?? Date.now)();
1909
+ const ageSeconds = Math.abs(nowMs - signedAtMs) / 1e3;
1910
+ if (ageSeconds > tolerance) {
1911
+ return {
1912
+ ok: false,
1913
+ reason: "timestamp_out_of_tolerance",
1914
+ message: `Delivery signed ${Math.round(ageSeconds)}s from now, outside the ${tolerance}s tolerance. Check your server clock, or raise toleranceSeconds.`
1915
+ };
1916
+ }
1917
+ }
1918
+ const body = typeof payload === "string" ? payload : payload.toString("utf8");
1919
+ const expected = import_crypto.default.createHmac("sha256", secret).update(`${deliveryId}.${timestamp}.${body}`).digest("hex");
1920
+ if (!digestsMatch(provided, expected)) {
1921
+ return {
1922
+ ok: false,
1923
+ reason: "signature_mismatch",
1924
+ message: "Signature does not match. Most common causes: the body was parsed and re-serialised before verification (use the raw bytes), or the secret is from a different webhook."
1925
+ };
1926
+ }
1927
+ return { ok: true, deliveryId, timestamp };
1928
+ }
1929
+
1512
1930
  // src/index.ts
1513
- var import_core4 = require("@avsbhq/core");
1931
+ var import_core9 = require("@avsbhq/core");
1514
1932
  // Annotate the CommonJS export names for ESM import in node:
1515
1933
  0 && (module.exports = {
1516
1934
  AvsbServer,
1935
+ DEFAULT_TOLERANCE_SECONDS,
1517
1936
  DecideOption,
1518
1937
  InMemoryStickyBucketService,
1519
1938
  RedisStickyBucketService,
@@ -1523,7 +1942,11 @@ var import_core4 = require("@avsbhq/core");
1523
1942
  createDecisionLog,
1524
1943
  createLogger,
1525
1944
  createStreamingClient,
1945
+ defaultLogger,
1526
1946
  expressMiddleware,
1527
1947
  fastifyPlugin,
1528
- noopLogger
1948
+ isDevEnvironment,
1949
+ noopLogger,
1950
+ resolveServerLogger,
1951
+ verifyWebhookSignature
1529
1952
  });