@avsbhq/node 1.8.0 → 1.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.cjs CHANGED
@@ -32,33 +32,25 @@ var index_exports = {};
32
32
  __export(index_exports, {
33
33
  AvsbServer: () => AvsbServer,
34
34
  DEFAULT_TOLERANCE_SECONDS: () => DEFAULT_TOLERANCE_SECONDS,
35
- DecideOption: () => import_core9.DecideOption,
35
+ DecideOption: () => import_core13.DecideOption,
36
36
  InMemoryStickyBucketService: () => InMemoryStickyBucketService,
37
37
  RedisStickyBucketService: () => RedisStickyBucketService,
38
38
  SDK_VERSION: () => SDK_VERSION,
39
- consoleLogger: () => import_core4.consoleLogger,
40
- consoleTransport: () => import_core4.consoleTransport,
39
+ consoleLogger: () => import_core6.consoleLogger,
40
+ consoleTransport: () => import_core6.consoleTransport,
41
41
  createDecisionLog: () => createDecisionLog,
42
- createLogger: () => import_core4.createLogger,
42
+ createLogger: () => import_core6.createLogger,
43
43
  createStreamingClient: () => import_streaming.createStreamingClient,
44
44
  defaultLogger: () => defaultLogger,
45
45
  expressMiddleware: () => import_express.expressMiddleware,
46
46
  fastifyPlugin: () => import_fastify.fastifyPlugin,
47
47
  isDevEnvironment: () => isDevEnvironment,
48
- noopLogger: () => import_core4.noopLogger,
48
+ noopLogger: () => import_core6.noopLogger,
49
49
  resolveServerLogger: () => resolveServerLogger,
50
50
  verifyWebhookSignature: () => verifyWebhookSignature
51
51
  });
52
52
  module.exports = __toCommonJS(index_exports);
53
53
 
54
- // src/AvsbServer.ts
55
- var import_node_crypto2 = require("crypto");
56
- var import_engine = require("@avsbhq/core/engine");
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");
61
-
62
54
  // src/datasets.ts
63
55
  var DEFAULT_REQUEST_TIMEOUT = 1e4;
64
56
  function miss(slug, version) {
@@ -393,20 +385,140 @@ function appendDecisionLogEntry(log, input) {
393
385
  return entry;
394
386
  }
395
387
 
396
- // src/decideOptions.ts
388
+ // src/AvsbServer.ts
389
+ var import_core12 = require("@avsbhq/core");
390
+
391
+ // src/UserBoundClient.ts
397
392
  var import_core2 = require("@avsbhq/core");
398
- var KNOWN = Object.values(import_core2.DecideOption);
393
+ function createUserBoundClient(server, context) {
394
+ const decisionLog = createDecisionLog();
395
+ const forcedDecisions = /* @__PURE__ */ new Map();
396
+ function evaluate(flagKey, defaultValue, expectedType, options) {
397
+ const forced = forcedDecisions.get(flagKey);
398
+ return server.evaluateInternal(flagKey, defaultValue, context, {
399
+ decideOptions: options?.decideOptions,
400
+ // A caller may point one evaluation at their own log; the bound log is
401
+ // the default so `getDecisionLog()` is complete without extra wiring.
402
+ decisionLog: options?.decisionLog ?? decisionLog,
403
+ ...expectedType ? { expectedType } : {},
404
+ ...forced ? { forcedVariationKey: forced.variationKey } : {}
405
+ });
406
+ }
407
+ return {
408
+ getFlag(flagKey, defaultValue, options) {
409
+ return evaluate(flagKey, defaultValue, void 0, options);
410
+ },
411
+ getBoolFlag(flagKey, defaultValue, options) {
412
+ return evaluate(flagKey, defaultValue, "boolean", options);
413
+ },
414
+ getStringFlag(flagKey, defaultValue, options) {
415
+ return evaluate(flagKey, defaultValue, "string", options);
416
+ },
417
+ getNumberFlag(flagKey, defaultValue, options) {
418
+ return evaluate(flagKey, defaultValue, "number", options);
419
+ },
420
+ getJsonFlag(flagKey, defaultValue, options) {
421
+ return evaluate(flagKey, defaultValue, "json", options);
422
+ },
423
+ getAllFlags(options) {
424
+ return server.getAllFlags(context, {
425
+ ...options,
426
+ decisionLog: options?.decisionLog ?? decisionLog
427
+ });
428
+ },
429
+ track(eventKey, payload) {
430
+ server.track(eventKey, { ...payload, context });
431
+ },
432
+ trackPurchase(order) {
433
+ return server.trackPurchase((0, import_core2.bucketingKey)(context), order);
434
+ },
435
+ manualExposure(flagKey) {
436
+ server.manualExposure(flagKey, context);
437
+ },
438
+ getContext() {
439
+ return context;
440
+ },
441
+ setForcedDecision(flagKey, opts) {
442
+ forcedDecisions.set(flagKey, opts);
443
+ },
444
+ clearForcedDecision(flagKey) {
445
+ forcedDecisions.delete(flagKey);
446
+ },
447
+ getDecisionLog() {
448
+ return decisionLog;
449
+ }
450
+ };
451
+ }
452
+
453
+ // src/utils.ts
454
+ var import_utils = require("@avsbhq/utils");
455
+ var import_core3 = require("@avsbhq/core");
456
+ function buildServerUtils(server) {
457
+ const adapter = {
458
+ getFlag(flagKey, defaultValue) {
459
+ return (0, import_core3.notFoundFlag)(
460
+ defaultValue,
461
+ "no bound context: call server.forUser(context) and use its flag methods"
462
+ );
463
+ },
464
+ getAllFlags(opts) {
465
+ const ctx = opts?.context;
466
+ if (!ctx) return {};
467
+ return server.getAllFlags(ctx, {
468
+ fireExposures: opts.fireExposures
469
+ });
470
+ },
471
+ track(eventKey, payload) {
472
+ if (!payload?.context) return;
473
+ server.track(eventKey, {
474
+ ...payload,
475
+ context: payload.context
476
+ });
477
+ },
478
+ on(event, cb) {
479
+ return server.on(event, cb);
480
+ },
481
+ getDatafile: () => server.getDatafile(),
482
+ getCurrentContext: () => null,
483
+ /**
484
+ * `snapshotFresh` and `refreshAndSnapshot` call this before they read, so
485
+ * a "fresh" snapshot really is taken against the datafile published right
486
+ * now. Without it both helpers quietly returned the in-memory datafile.
487
+ */
488
+ refresh: () => server.refreshDatafile(),
489
+ /**
490
+ * Real, not a comment: `client.utils.hydrateBootstrap(blob)` on the server
491
+ * adopts the blob's datafile and marks the SDK ready. That is the
492
+ * serverless cold-start path (read a datafile from KV or a file, hand it
493
+ * over, evaluate on the same tick).
494
+ */
495
+ applyDatafile: (df) => server.applyDatafileBootstrap(df)
496
+ // `applySnapshot` is deliberately ABSENT. A snapshot carries one context's
497
+ // evaluated values, and this client evaluates a different context per
498
+ // request, so adopting one process-wide would serve user A's values to user
499
+ // B. hydrateBootstrap throws with that explanation when it is handed a
500
+ // snapshot-only blob here, instead of quietly ignoring it.
501
+ };
502
+ return (0, import_utils.createUtils)(adapter);
503
+ }
504
+
505
+ // src/serverState.ts
506
+ var import_sdkKey = require("@avsbhq/utils/sdkKey");
507
+
508
+ // src/decideOptions.ts
509
+ var import_core4 = require("@avsbhq/core");
510
+ var KNOWN = Object.values(import_core4.DecideOption);
399
511
  function createDecideOptionResolver(logger) {
400
512
  const warned = /* @__PURE__ */ new Set();
401
513
  return function resolve(options) {
402
514
  if (!options || options.length === 0) return { suppressExposure: false };
403
515
  let suppressExposure = false;
404
516
  for (const option of options) {
405
- if (option === import_core2.DecideOption.DISABLE_EXPOSURE) {
517
+ if (option === import_core4.DecideOption.DISABLE_EXPOSURE) {
406
518
  suppressExposure = true;
407
519
  continue;
408
520
  }
409
- if (option === import_core2.DecideOption.INCLUDE_REASONS) {
521
+ if (option === import_core4.DecideOption.INCLUDE_REASONS) {
410
522
  continue;
411
523
  }
412
524
  const unknown = String(option);
@@ -468,9 +580,9 @@ function createOverrideStore() {
468
580
  }
469
581
 
470
582
  // src/datafileSource.ts
471
- var import_core3 = require("@avsbhq/core");
583
+ var import_core5 = require("@avsbhq/core");
472
584
  async function fetchDatafile(input) {
473
- const url = (0, import_core3.datafileUrl)(input.sdkKey, input.cdnHost);
585
+ const url = (0, import_core5.datafileUrl)(input.sdkKey, input.cdnHost);
474
586
  const headers = {};
475
587
  if (input.etag) headers["If-None-Match"] = input.etag;
476
588
  try {
@@ -483,7 +595,7 @@ async function fetchDatafile(input) {
483
595
  return { status: "failed" };
484
596
  }
485
597
  const body = await response.json();
486
- const problem = (0, import_core3.datafileShapeProblem)(body);
598
+ const problem = (0, import_core5.datafileShapeProblem)(body);
487
599
  if (problem) {
488
600
  input.logger.error(
489
601
  `AvsB datafile from ${url} is not a datafile the SDK can read: ${problem}. The SDK keeps serving whatever datafile it already has and retries on the polling interval.`
@@ -500,7 +612,7 @@ async function fetchDatafile(input) {
500
612
  }
501
613
  }
502
614
  function acceptDatafile(candidate, origin, logger) {
503
- const problem = (0, import_core3.datafileShapeProblem)(candidate);
615
+ const problem = (0, import_core5.datafileShapeProblem)(candidate);
504
616
  if (problem === null) return candidate;
505
617
  logger.error(
506
618
  origin === "bootstrap" ? `AvsB ignored the bootstrap datafile passed to new AvsbServer(): ${problem}. Flags return the default you passed, with source "not_ready", until a datafile loads from the CDN. Pass the datafile JSON exactly as the CDN serves it for this SDK key.` : `AvsB ignored the datafile passed to applyDatafileBootstrap(): ${problem}. The SDK keeps serving the datafile it already has, if any. Pass the datafile JSON exactly as the CDN serves it for this SDK key.`
@@ -508,7 +620,7 @@ function acceptDatafile(candidate, origin, logger) {
508
620
  return null;
509
621
  }
510
622
  function sendHeartbeat(input) {
511
- const endpoint = (0, import_core3.resolveHeartbeatEndpoint)(input.datafileHeartbeatEndpoint);
623
+ const endpoint = (0, import_core5.resolveHeartbeatEndpoint)(input.datafileHeartbeatEndpoint);
512
624
  if (!endpoint) return null;
513
625
  void fetch(endpoint, {
514
626
  method: "POST",
@@ -524,7 +636,7 @@ function sendHeartbeat(input) {
524
636
  }
525
637
 
526
638
  // src/logger.ts
527
- var import_core4 = require("@avsbhq/core");
639
+ var import_core6 = require("@avsbhq/core");
528
640
  function readDevGlobal() {
529
641
  const flag = globalThis.__AVSB_DEV__;
530
642
  return typeof flag === "boolean" ? flag : null;
@@ -541,18 +653,131 @@ function isDevEnvironment() {
541
653
  return nodeEnv !== "production" && nodeEnv !== "test";
542
654
  }
543
655
  function defaultLogger() {
544
- return isDevEnvironment() ? (0, import_core4.consoleLogger)("warn") : import_core4.noopLogger;
656
+ return isDevEnvironment() ? (0, import_core6.consoleLogger)("warn") : import_core6.noopLogger;
545
657
  }
546
658
  function resolveServerLogger(options) {
547
659
  if (options.logger) return options.logger;
548
- if (options.logLevel === "silent") return import_core4.noopLogger;
549
- if (options.logLevel) return (0, import_core4.consoleLogger)(options.logLevel);
660
+ if (options.logLevel === "silent") return import_core6.noopLogger;
661
+ if (options.logLevel) return (0, import_core6.consoleLogger)(options.logLevel);
550
662
  return defaultLogger();
551
663
  }
552
664
 
665
+ // src/serverState.ts
666
+ var DEFAULT_EVENTS = {
667
+ flushInterval: 2e3,
668
+ batchSize: 10,
669
+ maxQueueSize: 1e4,
670
+ retryAttempts: 3
671
+ };
672
+ function createServerState(options) {
673
+ const logger = resolveServerLogger(options);
674
+ const datafile = options.bootstrap ? acceptDatafile(options.bootstrap, "bootstrap", logger) : null;
675
+ const state = {
676
+ sdkKey: options.sdkKey,
677
+ pollingInterval: Math.max(options.pollingInterval ?? 6e4, 5e3),
678
+ cdnHost: options.cdnHost,
679
+ initTimeout: options.initTimeout ?? 1e4,
680
+ logger,
681
+ stickyBucketService: options.stickyBucketService ?? null,
682
+ eventOptions: { ...DEFAULT_EVENTS, ...options.events },
683
+ // Streaming is ON by default on the server (B12). Resolution happens once
684
+ // a datafile exists, because the endpoint is a datafile field.
685
+ streamingEnabled: options.streaming !== false,
686
+ streamingEndpointOption: options.streamingEndpoint ?? null,
687
+ overrides: createOverrideStore(),
688
+ resolveDecideOptions: createDecideOptionResolver(logger),
689
+ datafile,
690
+ datafileEtag: null,
691
+ ready: datafile !== null,
692
+ destroyed: false,
693
+ tracker: null,
694
+ streamingClient: null,
695
+ pollingTimer: null,
696
+ abortController: new AbortController(),
697
+ listeners: /* @__PURE__ */ new Map(),
698
+ readyPromise: null,
699
+ pendingEvents: [],
700
+ pendingPurchases: [],
701
+ pendingExposures: [],
702
+ lastHeartbeatAt: 0,
703
+ warnedOnce: /* @__PURE__ */ new Set()
704
+ };
705
+ const keyShape = (0, import_sdkKey.validateSdkKeyShape)(state.sdkKey);
706
+ if (!keyShape.valid && keyShape.message) {
707
+ logger.error(keyShape.message);
708
+ }
709
+ return state;
710
+ }
711
+ function warnOnce(s, key, message) {
712
+ if (s.warnedOnce.has(key)) return;
713
+ s.warnedOnce.add(key);
714
+ s.logger.warn(message);
715
+ }
716
+ function emit(s, event, ...args) {
717
+ const cbs = s.listeners.get(event);
718
+ if (!cbs) return;
719
+ for (const cb of cbs) {
720
+ try {
721
+ ;
722
+ cb(...args);
723
+ } catch (e) {
724
+ s.logger.error("Listener error", { event, error: String(e) });
725
+ }
726
+ }
727
+ }
728
+ function addListener(s, event, cb) {
729
+ if (!s.listeners.has(event)) s.listeners.set(event, /* @__PURE__ */ new Set());
730
+ s.listeners.get(event).add(cb);
731
+ return () => {
732
+ s.listeners.get(event)?.delete(cb);
733
+ };
734
+ }
735
+ function onSharedEvent(s, server, event, cb) {
736
+ if (event === "flagChange") {
737
+ warnOnce(
738
+ s,
739
+ "no-flag-change",
740
+ '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.'
741
+ );
742
+ return () => void 0;
743
+ }
744
+ return server.on(
745
+ event,
746
+ cb
747
+ );
748
+ }
749
+
750
+ // src/serverDatafile.ts
751
+ var import_backoff2 = require("@avsbhq/utils/backoff");
752
+
753
+ // src/initTimeout.ts
754
+ async function raceInitTimeout(work, timeoutMs) {
755
+ let timer;
756
+ const timeout = new Promise((resolve) => {
757
+ timer = setTimeout(() => resolve({ timedOut: true }), timeoutMs);
758
+ if (typeof timer === "object" && timer !== null && "unref" in timer) timer.unref();
759
+ });
760
+ try {
761
+ return await Promise.race([
762
+ work.then((value) => ({ timedOut: false, value })),
763
+ timeout
764
+ ]);
765
+ } finally {
766
+ clearTimeout(timer);
767
+ }
768
+ }
769
+
770
+ // src/version.ts
771
+ var SDK_VERSION = "1.9.0";
772
+ var SDK_TYPE = "@avsbhq/node";
773
+
774
+ // src/serverTracking.ts
775
+ var import_node_crypto2 = require("crypto");
776
+ var import_core8 = require("@avsbhq/core");
777
+
553
778
  // src/serverTracker.ts
554
779
  var import_node_crypto = require("crypto");
555
- var import_core5 = require("@avsbhq/core");
780
+ var import_core7 = require("@avsbhq/core");
556
781
  var import_backoff = require("@avsbhq/utils/backoff");
557
782
  function stampExposure(event) {
558
783
  return event.eventId ? event : { ...event, eventId: (0, import_node_crypto.randomUUID)() };
@@ -570,9 +795,9 @@ function createServerTracker(options) {
570
795
  retryAttempts,
571
796
  logger
572
797
  } = options;
573
- const batchEndpoint = (0, import_core5.collectUrl)("batch", collectEndpoint);
574
- const metricEndpoint = (0, import_core5.collectUrl)("metricBatch", collectEndpoint);
575
- const ordersEndpoint = (0, import_core5.collectUrl)("orders", collectEndpoint);
798
+ const batchEndpoint = (0, import_core7.collectUrl)("batch", collectEndpoint);
799
+ const metricEndpoint = (0, import_core7.collectUrl)("metricBatch", collectEndpoint);
800
+ const ordersEndpoint = (0, import_core7.collectUrl)("orders", collectEndpoint);
576
801
  let exposureQueue = [];
577
802
  let metricQueue = [];
578
803
  let flushTimer = null;
@@ -660,7 +885,7 @@ function createServerTracker(options) {
660
885
  }
661
886
  }
662
887
  }
663
- async function trackPurchase(visitorId, order) {
888
+ async function trackPurchase2(visitorId, order) {
664
889
  if (destroyed) return;
665
890
  const payload = {
666
891
  ...order,
@@ -709,183 +934,616 @@ function createServerTracker(options) {
709
934
  }
710
935
  await flush();
711
936
  }
712
- return { queue, queueMetric, trackPurchase, flush, destroy };
937
+ return { queue, queueMetric, trackPurchase: trackPurchase2, flush, destroy };
938
+ }
939
+
940
+ // src/serverTracking.ts
941
+ function trackMetric(s, eventKey, payload) {
942
+ if (payload.properties) {
943
+ warnOnce(
944
+ s,
945
+ "track-properties",
946
+ `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.`
947
+ );
948
+ }
949
+ if (!s.tracker) {
950
+ enqueuePending(s, eventKey, payload);
951
+ return;
952
+ }
953
+ s.tracker.queueMetric({
954
+ visitorId: (0, import_core8.bucketingKey)(payload.context),
955
+ eventName: eventKey,
956
+ // TWO columns, not one. `revenue` is money in decimal major units;
957
+ // `value` is the numeric metric value for average-value metrics. Storage
958
+ // declares both (`metric_events.revenue`, `metric_events.value`). This
959
+ // SDK used to ship `payload.value` into the revenue column and could
960
+ // never write the value column at all, so a quantity was recorded as
961
+ // money and every average-value metric read empty.
962
+ revenue: payload.revenue ?? null,
963
+ value: payload.value ?? null,
964
+ timestamp: Date.now(),
965
+ pageUrl: ""
966
+ });
967
+ }
968
+ async function trackPurchase(s, visitorId, order) {
969
+ if (s.destroyed) return;
970
+ if (!s.tracker) {
971
+ if (s.pendingPurchases.length >= s.eventOptions.maxQueueSize) {
972
+ s.pendingPurchases.shift();
973
+ warnOnce(
974
+ s,
975
+ "pending-purchase-full",
976
+ `Pre-ready purchase queue reached ${s.eventOptions.maxQueueSize} orders; the oldest is being dropped. This means the datafile never loaded: check the datafile error logged above.`
977
+ );
978
+ }
979
+ s.pendingPurchases.push({ visitorId, order });
980
+ return;
981
+ }
982
+ return s.tracker.trackPurchase(visitorId, order);
983
+ }
984
+ function deliverExposure(s, event) {
985
+ const stamped = event.eventId ? event : { ...event, eventId: (0, import_node_crypto2.randomUUID)() };
986
+ if (s.tracker) {
987
+ s.tracker.queue(stamped);
988
+ } else {
989
+ enqueuePendingExposure(s, stamped);
990
+ }
991
+ emit(s, "exposure", stamped);
992
+ }
993
+ function initTracker(s, server) {
994
+ if (!s.datafile || s.tracker) return;
995
+ const tracker = createServerTracker({
996
+ sdkKey: s.sdkKey,
997
+ collectEndpoint: s.datafile.collectEndpoint,
998
+ flushInterval: s.eventOptions.flushInterval,
999
+ batchSize: s.eventOptions.batchSize,
1000
+ maxQueueSize: s.eventOptions.maxQueueSize,
1001
+ retryAttempts: s.eventOptions.retryAttempts,
1002
+ logger: s.logger
1003
+ });
1004
+ s.tracker = tracker;
1005
+ if (s.pendingExposures.length > 0) {
1006
+ const queued = s.pendingExposures;
1007
+ s.pendingExposures = [];
1008
+ for (const event of queued) tracker.queue(event);
1009
+ }
1010
+ if (s.pendingEvents.length > 0) {
1011
+ const queued = s.pendingEvents;
1012
+ s.pendingEvents = [];
1013
+ for (const item of queued) server.track(item.eventKey, item.payload);
1014
+ }
1015
+ if (s.pendingPurchases.length > 0) {
1016
+ const queued = s.pendingPurchases;
1017
+ s.pendingPurchases = [];
1018
+ for (const item of queued) void server.trackPurchase(item.visitorId, item.order);
1019
+ }
1020
+ }
1021
+ function enqueuePendingExposure(s, event) {
1022
+ if (s.pendingExposures.length >= s.eventOptions.maxQueueSize) {
1023
+ s.pendingExposures.shift();
1024
+ warnOnce(
1025
+ s,
1026
+ "pending-exposures-full",
1027
+ `Pre-ready exposure queue reached ${s.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.`
1028
+ );
1029
+ }
1030
+ s.pendingExposures.push(event);
1031
+ }
1032
+ function enqueuePending(s, eventKey, payload) {
1033
+ if (s.pendingEvents.length >= s.eventOptions.maxQueueSize) {
1034
+ s.pendingEvents.shift();
1035
+ warnOnce(
1036
+ s,
1037
+ "pending-events-full",
1038
+ `Pre-ready event queue reached ${s.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.`
1039
+ );
1040
+ }
1041
+ s.pendingEvents.push({ eventKey, payload });
713
1042
  }
714
1043
 
1044
+ // src/serverStreaming.ts
1045
+ var import_core9 = require("@avsbhq/core");
1046
+
715
1047
  // src/streaming.ts
716
1048
  var import_streaming = require("@avsbhq/utils/streaming");
717
1049
 
718
- // src/version.ts
719
- var SDK_VERSION = "1.8.0";
720
- var SDK_TYPE = "@avsbhq/node";
1050
+ // src/serverStreaming.ts
1051
+ function startStreamingIfEnabled(s, onDatafileUpdated) {
1052
+ if (!s.streamingEnabled || s.streamingClient || s.destroyed) return;
1053
+ const resolution = (0, import_core9.resolveStreamEndpoint)({
1054
+ optionEndpoint: s.streamingEndpointOption,
1055
+ datafileStreamEndpoint: s.datafile?.streamEndpoint ?? null
1056
+ });
1057
+ if (!resolution.available) {
1058
+ s.logger.debug(`Streaming stays off, polling continues. ${resolution.reason}`);
1059
+ return;
1060
+ }
1061
+ s.streamingClient = (0, import_streaming.createStreamingClient)({
1062
+ endpoint: resolution.endpoint,
1063
+ sdkKey: s.sdkKey,
1064
+ onDatafileUpdated,
1065
+ logger: s.logger
1066
+ });
1067
+ s.streamingClient.connect();
1068
+ s.logger.debug(
1069
+ `Streaming datafile updates from ${resolution.endpoint} (${resolution.source}).`
1070
+ );
1071
+ }
721
1072
 
722
- // src/UserBoundClient.ts
723
- var import_core6 = require("@avsbhq/core");
724
- function createUserBoundClient(server, context) {
725
- const decisionLog = createDecisionLog();
726
- const forcedDecisions = /* @__PURE__ */ new Map();
727
- function evaluate(flagKey, defaultValue, expectedType, options) {
728
- const forced = forcedDecisions.get(flagKey);
729
- return server.evaluateInternal(flagKey, defaultValue, context, {
730
- decideOptions: options?.decideOptions,
731
- // A caller may point one evaluation at their own log; the bound log is
732
- // the default so `getDecisionLog()` is complete without extra wiring.
733
- decisionLog: options?.decisionLog ?? decisionLog,
734
- ...expectedType ? { expectedType } : {},
735
- ...forced ? { forcedVariationKey: forced.variationKey } : {}
1073
+ // src/serverDatafile.ts
1074
+ var HEARTBEAT_MIN_INTERVAL_MS = 12e4;
1075
+ async function initializeAndReady(s, server) {
1076
+ if (s.ready && s.datafile) {
1077
+ onDatafileReadable(s, server);
1078
+ return { success: true, source: "bootstrap" };
1079
+ }
1080
+ try {
1081
+ const outcome = await raceInitTimeout(
1082
+ // At init there is no stored etag, so 'unchanged' cannot happen here;
1083
+ // it is folded in anyway so a future caching layer cannot make init
1084
+ // silently report failure.
1085
+ loadDatafile(s).then((result) => result !== "failed"),
1086
+ s.initTimeout
1087
+ );
1088
+ if (outcome.timedOut) {
1089
+ startPolling(s, server);
1090
+ const error2 = new Error(
1091
+ `AvsB init timed out after ${s.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.`
1092
+ );
1093
+ emit(s, "error", error2);
1094
+ return { success: false, source: "timeout", error: error2 };
1095
+ }
1096
+ if (outcome.value) {
1097
+ onDatafileReadable(s, server);
1098
+ return { success: true, source: "network" };
1099
+ }
1100
+ startPolling(s, server);
1101
+ const error = new Error(
1102
+ '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.'
1103
+ );
1104
+ emit(s, "error", error);
1105
+ return { success: false, source: "error", error };
1106
+ } catch (e) {
1107
+ startPolling(s, server);
1108
+ const error = e instanceof Error ? e : new Error(String(e));
1109
+ emit(s, "error", error);
1110
+ return { success: false, source: "error", error };
1111
+ }
1112
+ }
1113
+ function onDatafileReadable(s, server) {
1114
+ initTracker(s, server);
1115
+ s.ready = true;
1116
+ startPolling(s, server);
1117
+ startStreamingIfEnabled(s, () => {
1118
+ void loadDatafile(s, "stream");
1119
+ });
1120
+ sendHeartbeat2(s);
1121
+ emit(s, "ready");
1122
+ }
1123
+ async function closeServer(s, drainUtils) {
1124
+ s.destroyed = true;
1125
+ s.abortController.abort();
1126
+ if (s.streamingClient) {
1127
+ s.streamingClient.disconnect();
1128
+ s.streamingClient = null;
1129
+ }
1130
+ if (s.pollingTimer) {
1131
+ clearTimeout(s.pollingTimer);
1132
+ s.pollingTimer = null;
1133
+ }
1134
+ if (s.tracker) {
1135
+ await s.tracker.destroy();
1136
+ s.tracker = null;
1137
+ }
1138
+ drainUtils();
1139
+ s.datafile = null;
1140
+ s.ready = false;
1141
+ }
1142
+ async function refreshDatafile(s, server) {
1143
+ const result = await loadDatafile(s, "manual");
1144
+ if (result === "failed") {
1145
+ s.logger.warn(
1146
+ "AvsB datafile refresh did not apply a new datafile; the SDK keeps serving the one it already has. The request error is logged above."
1147
+ );
1148
+ return;
1149
+ }
1150
+ if (result === "applied" && !s.ready) onDatafileReadable(s, server);
1151
+ }
1152
+ function applyDatafileBootstrap(s, server, datafile) {
1153
+ if (s.destroyed) return;
1154
+ if (!acceptDatafile(datafile, "applyDatafileBootstrap", s.logger)) return;
1155
+ const changed = s.datafile?.publishedAt !== datafile.publishedAt;
1156
+ s.datafile = datafile;
1157
+ s.datafileEtag = null;
1158
+ if (!s.ready) {
1159
+ onDatafileReadable(s, server);
1160
+ return;
1161
+ }
1162
+ initTracker(s, server);
1163
+ if (changed) {
1164
+ emit(s, "configUpdate", {
1165
+ publishedAt: datafile.publishedAt,
1166
+ reason: "manual"
736
1167
  });
737
1168
  }
738
- return {
739
- getFlag(flagKey, defaultValue, options) {
740
- return evaluate(flagKey, defaultValue, void 0, options);
741
- },
742
- getBoolFlag(flagKey, defaultValue, options) {
743
- return evaluate(flagKey, defaultValue, "boolean", options);
744
- },
745
- getStringFlag(flagKey, defaultValue, options) {
746
- return evaluate(flagKey, defaultValue, "string", options);
747
- },
748
- getNumberFlag(flagKey, defaultValue, options) {
749
- return evaluate(flagKey, defaultValue, "number", options);
750
- },
751
- getJsonFlag(flagKey, defaultValue, options) {
752
- return evaluate(flagKey, defaultValue, "json", options);
753
- },
754
- getAllFlags(options) {
755
- return server.getAllFlags(context, {
756
- ...options,
757
- decisionLog: options?.decisionLog ?? decisionLog
758
- });
759
- },
760
- track(eventKey, payload) {
761
- server.track(eventKey, { ...payload, context });
762
- },
763
- trackPurchase(order) {
764
- return server.trackPurchase((0, import_core6.bucketingKey)(context), order);
765
- },
766
- manualExposure(flagKey) {
767
- server.manualExposure(flagKey, context);
768
- },
769
- getContext() {
770
- return context;
771
- },
772
- setForcedDecision(flagKey, opts) {
773
- forcedDecisions.set(flagKey, opts);
774
- },
775
- clearForcedDecision(flagKey) {
776
- forcedDecisions.delete(flagKey);
777
- },
778
- getDecisionLog() {
779
- return decisionLog;
780
- }
1169
+ }
1170
+ async function loadDatafile(s, reason = "manual") {
1171
+ const outcome = await fetchDatafile({
1172
+ sdkKey: s.sdkKey,
1173
+ cdnHost: s.cdnHost,
1174
+ signal: s.abortController.signal,
1175
+ logger: s.logger,
1176
+ etag: s.datafileEtag
1177
+ });
1178
+ if (outcome.status === "ok" || outcome.status === "not-modified") {
1179
+ sendHeartbeat2(s);
1180
+ }
1181
+ if (outcome.status === "not-modified") return "unchanged";
1182
+ if (outcome.status !== "ok") return "failed";
1183
+ const changed = !s.datafile || s.datafile.publishedAt !== outcome.datafile.publishedAt;
1184
+ s.datafile = outcome.datafile;
1185
+ s.datafileEtag = outcome.etag;
1186
+ if (s.ready && changed) {
1187
+ emit(s, "configUpdate", {
1188
+ publishedAt: outcome.datafile.publishedAt,
1189
+ reason
1190
+ });
1191
+ s.logger.info("Datafile updated", {
1192
+ publishedAt: outcome.datafile.publishedAt,
1193
+ reason
1194
+ });
1195
+ startStreamingIfEnabled(s, () => {
1196
+ void loadDatafile(s, "stream");
1197
+ });
1198
+ }
1199
+ return "applied";
1200
+ }
1201
+ function startPolling(s, server) {
1202
+ if (s.pollingTimer || s.destroyed) return;
1203
+ const schedule = () => {
1204
+ s.pollingTimer = setTimeout(() => {
1205
+ s.pollingTimer = null;
1206
+ void (async () => {
1207
+ const result = await loadDatafile(s, "poll");
1208
+ if (result === "applied" && !s.ready) onDatafileReadable(s, server);
1209
+ if (!s.destroyed) schedule();
1210
+ })();
1211
+ }, (0, import_backoff2.jitteredInterval)(s.pollingInterval));
781
1212
  };
1213
+ schedule();
1214
+ }
1215
+ function sendHeartbeat2(s) {
1216
+ const now = Date.now();
1217
+ if (now - s.lastHeartbeatAt < HEARTBEAT_MIN_INTERVAL_MS) return;
1218
+ const posted = sendHeartbeat({
1219
+ sdkKey: s.sdkKey,
1220
+ sdkType: SDK_TYPE,
1221
+ sdkVersion: SDK_VERSION,
1222
+ datafileHeartbeatEndpoint: s.datafile?.heartbeatEndpoint,
1223
+ signal: s.abortController.signal
1224
+ });
1225
+ if (posted) s.lastHeartbeatAt = now;
782
1226
  }
783
1227
 
784
- // src/utils.ts
785
- var import_utils = require("@avsbhq/utils");
786
- var import_core7 = require("@avsbhq/core");
787
- function buildServerUtils(server) {
788
- const adapter = {
789
- getFlag(flagKey, defaultValue) {
790
- return (0, import_core7.notFoundFlag)(
791
- defaultValue,
792
- "no bound context: call server.forUser(context) and use its flag methods"
793
- );
794
- },
795
- getAllFlags(opts) {
796
- const ctx = opts?.context;
797
- if (!ctx) return {};
798
- return server.getAllFlags(ctx, {
799
- fireExposures: opts.fireExposures
800
- });
801
- },
802
- track(eventKey, payload) {
803
- if (!payload?.context) return;
804
- server.track(eventKey, {
805
- ...payload,
806
- context: payload.context
1228
+ // src/serverEvaluation.ts
1229
+ var import_core11 = require("@avsbhq/core");
1230
+
1231
+ // src/serverDecision.ts
1232
+ var import_engine = require("@avsbhq/core/engine");
1233
+ var import_core10 = require("@avsbhq/core");
1234
+ var import_exposure = require("@avsbhq/utils/exposure");
1235
+ function evaluateDecision(s, flagKey, uc, defaultValue) {
1236
+ const reasons = [];
1237
+ const overrideKey = s.overrides.resolve(flagKey, uc.userId);
1238
+ const rtOver = overrideKey ? findVariation(s, flagKey, overrideKey) : null;
1239
+ if (rtOver) {
1240
+ reasons.push(`matched runtime override for flagKey="${flagKey}"`);
1241
+ return {
1242
+ value: rtOver.value,
1243
+ variationKey: rtOver.key,
1244
+ source: "runtimeOverride",
1245
+ ruleId: null,
1246
+ ruleType: null,
1247
+ reasons
1248
+ };
1249
+ }
1250
+ if (s.stickyBucketService) {
1251
+ const sticky = resolveStickyBucket(s, flagKey, uc.userId);
1252
+ if (sticky !== void 0) {
1253
+ reasons.push(`matched sticky bucket assignment for userId="${uc.userId}"`);
1254
+ if (!uc.suppressExposure) {
1255
+ fireExposureForStickyAssignment(s, flagKey, sticky, uc);
1256
+ }
1257
+ return {
1258
+ value: sticky.vref.value,
1259
+ variationKey: sticky.vref.key,
1260
+ source: "sticky",
1261
+ ruleId: sticky.assignment.ruleId,
1262
+ ruleType: sticky.assignment.ruleType,
1263
+ reasons
1264
+ };
1265
+ }
1266
+ }
1267
+ const legacyCtx = { attributes: { userId: uc.userId, ...uc.attributes } };
1268
+ const onExposure = uc.suppressExposure ? void 0 : (event) => {
1269
+ deliverExposure(s, (0, import_exposure.redactExposure)(event, uc.privateAttributes));
1270
+ };
1271
+ const r = (0, import_engine.evaluateFlag)(
1272
+ flagKey,
1273
+ defaultValue,
1274
+ legacyCtx,
1275
+ // Callers check readiness first, so a datafile is loaded here.
1276
+ s.datafile,
1277
+ onExposure,
1278
+ reasons
1279
+ );
1280
+ if (s.stickyBucketService && r.variationKey && r.source === "rule" && r.ruleId && r.ruleType === "ab_test") {
1281
+ const v = findVariation(s, flagKey, r.variationKey);
1282
+ if (v) {
1283
+ s.stickyBucketService.save(uc.userId, flagKey, {
1284
+ variationId: v.id,
1285
+ ruleId: r.ruleId,
1286
+ ruleType: r.ruleType,
1287
+ assignedAt: Date.now()
807
1288
  });
808
- },
809
- on(event, cb) {
810
- return server.on(event, cb);
811
- },
812
- getDatafile: () => server.getDatafile(),
813
- getCurrentContext: () => null,
814
- /**
815
- * Real, not a comment: `client.utils.hydrateBootstrap(blob)` on the server
816
- * adopts the blob's datafile and marks the SDK ready. That is the
817
- * serverless cold-start path (read a datafile from KV or a file, hand it
818
- * over, evaluate on the same tick).
819
- */
820
- applyDatafile: (df) => server.applyDatafileBootstrap(df)
821
- // `applySnapshot` is deliberately ABSENT. A snapshot carries one context's
822
- // evaluated values, and this client evaluates a different context per
823
- // request, so adopting one process-wide would serve user A's values to user
824
- // B. hydrateBootstrap throws with that explanation when it is handed a
825
- // snapshot-only blob here, instead of quietly ignoring it.
1289
+ }
1290
+ }
1291
+ return {
1292
+ value: r.value,
1293
+ variationKey: r.variationKey,
1294
+ source: (0, import_core10.mapLegacySource)(r.source),
1295
+ ruleId: r.ruleId,
1296
+ ruleType: r.ruleType,
1297
+ reasons
826
1298
  };
827
- return (0, import_utils.createUtils)(adapter);
1299
+ }
1300
+ function buildLegacyContext(context, suppressExposure) {
1301
+ const legacy = (0, import_core10.toLegacyEvalContext)(context);
1302
+ const userId = legacy.attributes["userId"] ?? "";
1303
+ const privateAttrs = (0, import_core10.privateAttributesFrom)(context);
1304
+ const { userId: _uid, ...rest } = legacy.attributes;
1305
+ return {
1306
+ userId,
1307
+ attributes: rest,
1308
+ suppressExposure,
1309
+ privateAttributes: privateAttrs.size > 0 ? [...privateAttrs] : void 0
1310
+ };
1311
+ }
1312
+ function manualExposure(s, server, flagKey, context) {
1313
+ if (!server.isReady() || !s.datafile || !s.tracker) return;
1314
+ const legacyCtx = buildLegacyContext(context, true);
1315
+ const result = evaluateDecision(s, flagKey, legacyCtx, void 0);
1316
+ if (result.source !== "rule" || !result.ruleId || !result.variationKey) return;
1317
+ if (result.ruleType !== "ab_test" && result.ruleType !== "targeted_delivery") return;
1318
+ const flag = s.datafile.flags.find((f) => f.key === flagKey);
1319
+ if (!flag) return;
1320
+ const variation = flag.variations.find((v) => v.key === result.variationKey);
1321
+ if (!variation) return;
1322
+ deliverExposure(s, {
1323
+ visitorId: (0, import_core10.bucketingKey)(context),
1324
+ experimentId: result.ruleId,
1325
+ variationId: variation.id,
1326
+ eventName: "flag_exposure",
1327
+ revenue: null,
1328
+ timestamp: Date.now(),
1329
+ pageUrl: "",
1330
+ properties: { flagKey, flagId: flag.id, manuallyFired: true }
1331
+ });
1332
+ }
1333
+ function resolveStickyBucket(s, flagKey, userId) {
1334
+ if (!s.stickyBucketService || !s.datafile) return void 0;
1335
+ const assignment = s.stickyBucketService.lookup(userId, flagKey);
1336
+ if (!assignment) return void 0;
1337
+ const flag = s.datafile.flags.find((f) => f.key === flagKey);
1338
+ if (!flag) return void 0;
1339
+ const v = flag.variations.find((x) => x.id === assignment.variationId);
1340
+ if (!v) return void 0;
1341
+ return { vref: { value: v.value, key: v.key, id: v.id }, assignment };
1342
+ }
1343
+ function findVariation(s, flagKey, variationKey) {
1344
+ if (!s.datafile) return null;
1345
+ const flag = s.datafile.flags.find((f) => f.key === flagKey);
1346
+ if (!flag) return null;
1347
+ const v = flag.variations.find((x) => x.key === variationKey);
1348
+ if (!v) return null;
1349
+ return { value: v.value, key: v.key, id: v.id };
1350
+ }
1351
+ function fireExposureForStickyAssignment(s, flagKey, sticky, uc) {
1352
+ if (!s.tracker || !s.datafile) return;
1353
+ const flag = s.datafile.flags.find((f) => f.key === flagKey);
1354
+ if (!flag) return;
1355
+ const rule = flag.rules.find((r) => r.id === sticky.assignment.ruleId);
1356
+ if (!rule || !rule.variations.some((rv) => rv.variationId === sticky.vref.id)) {
1357
+ s.logger.warn(
1358
+ "Sticky assignment references a rule no longer matching the datafile",
1359
+ {
1360
+ flagKey,
1361
+ userId: uc.userId,
1362
+ assignedRuleId: sticky.assignment.ruleId,
1363
+ assignedVariationId: sticky.vref.id
1364
+ }
1365
+ );
1366
+ emit(s, "stickyOrphan", {
1367
+ flagKey,
1368
+ userId: uc.userId,
1369
+ assignment: sticky.assignment
1370
+ });
1371
+ return;
1372
+ }
1373
+ deliverExposure(s, {
1374
+ visitorId: uc.userId,
1375
+ experimentId: rule.id,
1376
+ variationId: sticky.vref.id,
1377
+ eventName: "flag_exposure",
1378
+ revenue: null,
1379
+ timestamp: Date.now(),
1380
+ pageUrl: "",
1381
+ properties: { flagKey, flagId: flag.id }
1382
+ });
828
1383
  }
829
1384
 
830
- // src/initTimeout.ts
831
- async function raceInitTimeout(work, timeoutMs) {
832
- let timer;
833
- const timeout = new Promise((resolve) => {
834
- timer = setTimeout(() => resolve({ timedOut: true }), timeoutMs);
835
- if (typeof timer === "object" && timer !== null && "unref" in timer) timer.unref();
1385
+ // src/serverEvaluation.ts
1386
+ function evaluateServerFlag(s, server, flagKey, defaultValue, context, options) {
1387
+ if (!context) {
1388
+ warnOnce(
1389
+ s,
1390
+ "no-context",
1391
+ `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 }).`
1392
+ );
1393
+ return (0, import_core11.notReadyFlag)(
1394
+ defaultValue,
1395
+ "no context passed; call server.forUser(context) or pass the context positionally"
1396
+ );
1397
+ }
1398
+ if (!server.isReady() || !s.datafile) {
1399
+ warnOnce(
1400
+ s,
1401
+ "not-ready",
1402
+ `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.`
1403
+ );
1404
+ return (0, import_core11.notReadyFlag)(defaultValue, "SDK not ready: no datafile loaded yet");
1405
+ }
1406
+ const decide = s.resolveDecideOptions(options?.decideOptions);
1407
+ const flag = options?.forcedVariationKey ? buildForcedFlag(
1408
+ s,
1409
+ flagKey,
1410
+ options.forcedVariationKey,
1411
+ defaultValue,
1412
+ context,
1413
+ options
1414
+ ) : evaluateAndBuild(
1415
+ s,
1416
+ flagKey,
1417
+ defaultValue,
1418
+ context,
1419
+ decide,
1420
+ options
1421
+ );
1422
+ return flag;
1423
+ }
1424
+ function evaluateAndBuild(s, flagKey, defaultValue, context, decide, options) {
1425
+ const legacyCtx = buildLegacyContext(context, decide.suppressExposure);
1426
+ const result = evaluateDecision(s, flagKey, legacyCtx, defaultValue);
1427
+ if (result.source === "not_found") {
1428
+ warnOnce(s, `missing:${flagKey}`, (0, import_core11.flagNotFoundMessage)(flagKey));
1429
+ }
1430
+ const typeMismatch = checkType(s, flagKey, options?.expectedType, result);
1431
+ if (typeMismatch) {
1432
+ s.logger.warn(
1433
+ `Type mismatch on flag "${flagKey}": ${typeMismatch}; returning the default.`
1434
+ );
1435
+ const fallback = (0, import_core11.notFoundFlag)(defaultValue, typeMismatch);
1436
+ recordDecision(s, flagKey, fallback, context, options);
1437
+ return fallback;
1438
+ }
1439
+ const flag = toFlag(result, defaultValue);
1440
+ recordDecision(s, flagKey, flag, context, options);
1441
+ return flag;
1442
+ }
1443
+ function checkType(s, flagKey, expectedType, result) {
1444
+ if (!expectedType) return null;
1445
+ if (result.source === "not_found" || result.source === "disabled") return null;
1446
+ const datafileFlag = (0, import_core11.findDatafileFlag)(s.datafile, flagKey);
1447
+ return (0, import_core11.checkExpectedType)(datafileFlag, expectedType, result.value) ?? null;
1448
+ }
1449
+ function toFlag(result, defaultValue) {
1450
+ return (0, import_core11.createFlag)({
1451
+ value: result.value ?? defaultValue,
1452
+ variationKey: result.variationKey,
1453
+ source: result.source,
1454
+ ruleId: result.ruleId,
1455
+ ruleType: result.ruleType,
1456
+ reasons: result.reasons,
1457
+ evaluatedAt: Date.now(),
1458
+ durationMicros: 0
836
1459
  });
837
- try {
838
- return await Promise.race([
839
- work.then((value) => ({ timedOut: false, value })),
840
- timeout
841
- ]);
842
- } finally {
843
- clearTimeout(timer);
1460
+ }
1461
+ function buildForcedFlag(s, flagKey, variationKey, defaultValue, context, options) {
1462
+ const vref = findVariation(s, flagKey, variationKey);
1463
+ const flag = (0, import_core11.createFlag)({
1464
+ value: vref ? vref.value : defaultValue,
1465
+ variationKey: vref ? vref.key : null,
1466
+ source: "runtimeOverride",
1467
+ ruleId: null,
1468
+ ruleType: null,
1469
+ reasons: [`forced decision: variationKey="${variationKey}"`]
1470
+ });
1471
+ recordDecision(s, flagKey, flag, context, options);
1472
+ return flag;
1473
+ }
1474
+ function recordDecision(s, flagKey, flag, context, options) {
1475
+ const summary = (0, import_core11.contextSummary)(context);
1476
+ emit(s, "evaluation", {
1477
+ flagKey,
1478
+ flag,
1479
+ contextKeys: summary.map((entry) => entry.key)
1480
+ });
1481
+ if (!options?.decisionLog) return;
1482
+ appendDecisionLogEntry(options.decisionLog, {
1483
+ flagKey,
1484
+ value: flag.value,
1485
+ variationKey: flag.variationKey,
1486
+ source: flag.source,
1487
+ ruleId: flag.ruleId,
1488
+ ruleType: flag.ruleType,
1489
+ reasons: flag.reasons,
1490
+ evaluatedAt: flag.evaluatedAt,
1491
+ durationMicros: flag.durationMicros,
1492
+ contextSummary: summary,
1493
+ privateValues: privateAttributeValues(context)
1494
+ });
1495
+ }
1496
+ function evaluateAllServerFlags(s, server, context, options) {
1497
+ if (!context) {
1498
+ warnOnce(
1499
+ s,
1500
+ "get-all-no-context",
1501
+ "getAllFlags() was called without a context and returned an empty object. Pass the context positionally, or use server.forUser(context).getAllFlags()."
1502
+ );
1503
+ return {};
1504
+ }
1505
+ if (!server.isReady() || !s.datafile) {
1506
+ warnOnce(
1507
+ s,
1508
+ "get-all-not-ready",
1509
+ "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."
1510
+ );
1511
+ return {};
1512
+ }
1513
+ const fireExposures = options?.fireExposures === true;
1514
+ const decideOptions = [...options?.decideOptions ?? []];
1515
+ if (!fireExposures && !decideOptions.includes("DISABLE_EXPOSURE")) {
1516
+ decideOptions.push("DISABLE_EXPOSURE");
1517
+ }
1518
+ const out = {};
1519
+ for (const entry of s.datafile.flags) {
1520
+ out[entry.key] = server.evaluateInternal(entry.key, void 0, context, {
1521
+ decideOptions,
1522
+ decisionLog: options?.decisionLog
1523
+ });
844
1524
  }
1525
+ return out;
845
1526
  }
846
1527
 
847
1528
  // src/AvsbServer.ts
848
- var DEFAULT_EVENTS = {
849
- flushInterval: 2e3,
850
- batchSize: 10,
851
- maxQueueSize: 1e4,
852
- retryAttempts: 3
853
- };
854
- var HEARTBEAT_MIN_INTERVAL_MS = 12e4;
855
1529
  var AvsbServer = class {
856
- sdkKey;
857
- cdnHost;
858
- pollingInterval;
859
- initTimeout;
860
- logger;
861
- stickyBucketService;
862
- eventOptions;
863
- streamingEnabled;
864
- streamingEndpointOption;
865
- overrides = createOverrideStore();
866
- resolveDecideOptions;
867
- datafile;
868
- datafileEtag = null;
869
- ready;
870
- destroyed;
871
- tracker;
872
- streamingClient;
873
- pollingTimer;
874
- abortController = new AbortController();
875
- listeners = /* @__PURE__ */ new Map();
876
- readyPromise = null;
877
- pendingEvents = [];
878
- pendingPurchases = [];
879
- /**
880
- * Exposures produced before the tracker existed. Third member of the
881
- * pre-ready family alongside `pendingEvents` and `pendingPurchases`; it used
882
- * to be missing, and exposures were dropped on the floor instead.
883
- */
884
- pendingExposures = [];
885
- /** ms-epoch of the last liveness heartbeat, for the throttle. */
886
- lastHeartbeatAt = 0;
887
- /** Keys of one-time warnings, so a request loop cannot flood a log. */
888
- warnedOnce = /* @__PURE__ */ new Set();
1530
+ /** Everything mutable about this client. See ./serverState. */
1531
+ s;
1532
+ // Private seams kept on the instance because
1533
+ // AvsbServer.exposureDelivery.test.ts reaches them directly to stage a
1534
+ // missing tracker. Each one is a straight pass-through to the state.
1535
+ get tracker() {
1536
+ return this.s.tracker;
1537
+ }
1538
+ set tracker(tracker) {
1539
+ this.s.tracker = tracker;
1540
+ }
1541
+ deliverExposure(event) {
1542
+ deliverExposure(this.s, event);
1543
+ }
1544
+ initTracker() {
1545
+ initTracker(this.s, this);
1546
+ }
889
1547
  /**
890
1548
  * `client.utils.*` surface, constructed lazily on first access so the utils
891
1549
  * package is only imported when actually consumed.
@@ -897,9 +1555,10 @@ var AvsbServer = class {
897
1555
  getAllFlags: (ctx, opts) => this.getAllFlags(ctx, opts),
898
1556
  getFlag: (flagKey, defaultValue, context) => this.getFlag(flagKey, defaultValue, context),
899
1557
  track: (eventKey, payload) => this.track(eventKey, payload),
900
- on: (event, cb) => this.onSharedEvent(event, cb),
901
- getDatafile: () => this.datafile,
902
- applyDatafileBootstrap: (df) => this.applyDatafileBootstrap(df)
1558
+ on: (event, cb) => onSharedEvent(this.s, this, event, cb),
1559
+ getDatafile: () => this.s.datafile,
1560
+ applyDatafileBootstrap: (df) => this.applyDatafileBootstrap(df),
1561
+ refreshDatafile: () => this.refreshDatafile()
903
1562
  });
904
1563
  }
905
1564
  return this._utils;
@@ -912,9 +1571,9 @@ var AvsbServer = class {
912
1571
  get datasets() {
913
1572
  if (!this._datasets) {
914
1573
  this._datasets = createDatasetsClient({
915
- sdkKey: this.sdkKey,
916
- getDatafile: () => this.datafile,
917
- logger: this.logger
1574
+ sdkKey: this.s.sdkKey,
1575
+ getDatafile: () => this.s.datafile,
1576
+ logger: this.s.logger
918
1577
  });
919
1578
  }
920
1579
  return this._datasets;
@@ -929,124 +1588,38 @@ var AvsbServer = class {
929
1588
  get recs() {
930
1589
  if (!this._recs) {
931
1590
  this._recs = createRecsClient({
932
- sdkKey: this.sdkKey,
933
- getDatafile: () => this.datafile,
934
- getTracker: () => this.tracker,
935
- logger: this.logger
1591
+ sdkKey: this.s.sdkKey,
1592
+ getDatafile: () => this.s.datafile,
1593
+ getTracker: () => this.s.tracker,
1594
+ logger: this.s.logger
936
1595
  });
937
1596
  }
938
1597
  return this._recs;
939
1598
  }
940
1599
  constructor(options) {
941
- this.sdkKey = options.sdkKey;
942
- this.pollingInterval = Math.max(options.pollingInterval ?? 6e4, 5e3);
943
- this.cdnHost = options.cdnHost;
944
- this.initTimeout = options.initTimeout ?? 1e4;
945
- this.logger = resolveServerLogger(options);
946
- this.stickyBucketService = options.stickyBucketService ?? null;
947
- this.eventOptions = { ...DEFAULT_EVENTS, ...options.events };
948
- this.streamingEnabled = options.streaming !== false;
949
- this.streamingEndpointOption = options.streamingEndpoint ?? null;
950
- this.datafile = options.bootstrap ? acceptDatafile(options.bootstrap, "bootstrap", this.logger) : null;
951
- this.ready = this.datafile !== null;
952
- this.destroyed = false;
953
- this.tracker = null;
954
- this.streamingClient = null;
955
- this.pollingTimer = null;
956
- this.resolveDecideOptions = createDecideOptionResolver(this.logger);
957
- const keyShape = (0, import_sdkKey.validateSdkKeyShape)(this.sdkKey);
958
- if (!keyShape.valid && keyShape.message) {
959
- this.logger.error(keyShape.message);
960
- }
961
- if (this.datafile) this.initTracker();
1600
+ this.s = createServerState(options);
1601
+ if (this.s.datafile) this.initTracker();
962
1602
  }
963
- // ---------------------------------------------------------------------------
964
- // Readiness
965
- // ---------------------------------------------------------------------------
1603
+ // ---- Readiness ----
966
1604
  /**
967
1605
  * Await the init attempt. Never rejects; always resolves to an InitResult.
968
1606
  * Calling it twice returns the same promise.
969
1607
  */
970
1608
  onReady() {
971
- if (this.readyPromise) return this.readyPromise;
972
- this.readyPromise = this.initializeAndReady();
973
- return this.readyPromise;
1609
+ if (this.s.readyPromise) return this.s.readyPromise;
1610
+ this.s.readyPromise = initializeAndReady(this.s, this);
1611
+ return this.s.readyPromise;
974
1612
  }
975
1613
  /** True when a datafile is loaded and flags are answerable right now. */
976
1614
  isReady() {
977
- return this.ready && this.datafile !== null;
1615
+ return this.s.ready && this.s.datafile !== null;
978
1616
  }
979
- async initializeAndReady() {
980
- if (this.ready && this.datafile) {
981
- this.onDatafileReadable();
982
- return { success: true, source: "bootstrap" };
983
- }
984
- try {
985
- const outcome = await raceInitTimeout(
986
- // At init there is no stored etag, so 'unchanged' cannot happen here;
987
- // it is folded in anyway so a future caching layer cannot make init
988
- // silently report failure.
989
- this.loadDatafile().then((result) => result !== "failed"),
990
- this.initTimeout
991
- );
992
- if (outcome.timedOut) {
993
- this.startPolling();
994
- const error2 = new Error(
995
- `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.`
996
- );
997
- this.emit("error", error2);
998
- return { success: false, source: "timeout", error: error2 };
999
- }
1000
- if (outcome.value) {
1001
- this.onDatafileReadable();
1002
- return { success: true, source: "network" };
1003
- }
1004
- this.startPolling();
1005
- const error = new Error(
1006
- '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.'
1007
- );
1008
- this.emit("error", error);
1009
- return { success: false, source: "error", error };
1010
- } catch (e) {
1011
- this.startPolling();
1012
- const error = e instanceof Error ? e : new Error(String(e));
1013
- this.emit("error", error);
1014
- return { success: false, source: "error", error };
1015
- }
1016
- }
1017
- /** Wiring that runs once a datafile is readable, however it arrived. */
1018
- onDatafileReadable() {
1019
- this.initTracker();
1020
- this.ready = true;
1021
- this.startPolling();
1022
- this.startStreamingIfEnabled();
1023
- this.sendHeartbeat();
1024
- this.emit("ready");
1025
- }
1026
- // ---------------------------------------------------------------------------
1027
- // Lifecycle
1028
- // ---------------------------------------------------------------------------
1029
- async close() {
1030
- this.destroyed = true;
1031
- this.abortController.abort();
1032
- if (this.streamingClient) {
1033
- this.streamingClient.disconnect();
1034
- this.streamingClient = null;
1035
- }
1036
- if (this.pollingTimer) {
1037
- clearTimeout(this.pollingTimer);
1038
- this.pollingTimer = null;
1039
- }
1040
- if (this.tracker) {
1041
- await this.tracker.destroy();
1042
- this.tracker = null;
1043
- }
1044
- this._utils?._drain();
1045
- this.datafile = null;
1046
- this.ready = false;
1617
+ // ---- Lifecycle ----
1618
+ close() {
1619
+ return closeServer(this.s, () => this._utils?._drain());
1047
1620
  }
1048
1621
  async flush() {
1049
- if (this.tracker) await this.tracker.flush();
1622
+ if (this.s.tracker) await this.s.tracker.flush();
1050
1623
  }
1051
1624
  /**
1052
1625
  * Fetch the datafile now and apply it if it changed.
@@ -1054,15 +1627,8 @@ var AvsbServer = class {
1054
1627
  * A 304 is not a problem and is not warned about: it means the datafile in
1055
1628
  * memory IS the published one.
1056
1629
  */
1057
- async refreshDatafile() {
1058
- const result = await this.loadDatafile("manual");
1059
- if (result === "failed") {
1060
- this.logger.warn(
1061
- "AvsB datafile refresh did not apply a new datafile; the SDK keeps serving the one it already has. The request error is logged above."
1062
- );
1063
- return;
1064
- }
1065
- if (result === "applied" && !this.ready) this.onDatafileReadable();
1630
+ refreshDatafile() {
1631
+ return refreshDatafile(this.s, this);
1066
1632
  }
1067
1633
  /**
1068
1634
  * Adopt a datafile received out of band: a bootstrap blob from
@@ -1074,34 +1640,16 @@ var AvsbServer = class {
1074
1640
  * handoff silently do nothing.
1075
1641
  */
1076
1642
  applyDatafileBootstrap(datafile) {
1077
- if (this.destroyed) return;
1078
- if (!acceptDatafile(datafile, "applyDatafileBootstrap", this.logger)) return;
1079
- const changed = this.datafile?.publishedAt !== datafile.publishedAt;
1080
- this.datafile = datafile;
1081
- this.datafileEtag = null;
1082
- if (!this.ready) {
1083
- this.onDatafileReadable();
1084
- return;
1085
- }
1086
- this.initTracker();
1087
- if (changed) {
1088
- this.emit("configUpdate", {
1089
- publishedAt: datafile.publishedAt,
1090
- reason: "manual"
1091
- });
1092
- }
1643
+ applyDatafileBootstrap(this.s, this, datafile);
1093
1644
  }
1094
1645
  /** The datafile currently in memory, or null before init completes. */
1095
1646
  getDatafile() {
1096
- return this.datafile;
1647
+ return this.s.datafile;
1097
1648
  }
1098
- // ---------------------------------------------------------------------------
1099
- // Evaluation
1100
- //
1649
+ // ---- Evaluation ----
1101
1650
  // Context is the THIRD POSITIONAL argument on every call (B3), matching
1102
1651
  // @avsbhq/edge. `forUser(ctx)` is the primary server pattern and binds it
1103
1652
  // once per request.
1104
- // ---------------------------------------------------------------------------
1105
1653
  getFlag(flagKey, defaultValue, context, options) {
1106
1654
  return this.evaluateInternal(flagKey, defaultValue, context, options);
1107
1655
  }
@@ -1135,63 +1683,7 @@ var AvsbServer = class {
1135
1683
  * published API: its options bag carries package-internal fields.
1136
1684
  */
1137
1685
  evaluateInternal(flagKey, defaultValue, context, options) {
1138
- if (!context) {
1139
- this.warnOnce(
1140
- "no-context",
1141
- `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 }).`
1142
- );
1143
- return (0, import_core8.notReadyFlag)(
1144
- defaultValue,
1145
- "no context passed; call server.forUser(context) or pass the context positionally"
1146
- );
1147
- }
1148
- if (!this.isReady() || !this.datafile) {
1149
- this.warnOnce(
1150
- "not-ready",
1151
- `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.`
1152
- );
1153
- return (0, import_core8.notReadyFlag)(defaultValue, "SDK not ready: no datafile loaded yet");
1154
- }
1155
- const decide = this.resolveDecideOptions(options?.decideOptions);
1156
- const flag = options?.forcedVariationKey ? this.buildForcedFlag(
1157
- flagKey,
1158
- options.forcedVariationKey,
1159
- defaultValue,
1160
- context,
1161
- options
1162
- ) : this.evaluateAndBuild(
1163
- flagKey,
1164
- defaultValue,
1165
- context,
1166
- decide,
1167
- options
1168
- );
1169
- return flag;
1170
- }
1171
- evaluateAndBuild(flagKey, defaultValue, context, decide, options) {
1172
- const legacyCtx = this.buildLegacyContext(context, decide.suppressExposure);
1173
- const result = this.evaluate(flagKey, legacyCtx, defaultValue);
1174
- if (result.source === "not_found") {
1175
- this.warnOnce(`missing:${flagKey}`, (0, import_core8.flagNotFoundMessage)(flagKey));
1176
- }
1177
- const typeMismatch = this.checkType(flagKey, options?.expectedType, result);
1178
- if (typeMismatch) {
1179
- this.logger.warn(
1180
- `Type mismatch on flag "${flagKey}": ${typeMismatch}; returning the default.`
1181
- );
1182
- const fallback = (0, import_core8.notFoundFlag)(defaultValue, typeMismatch);
1183
- this.recordDecision(flagKey, fallback, context, options);
1184
- return fallback;
1185
- }
1186
- const flag = this.toFlag(result, defaultValue);
1187
- this.recordDecision(flagKey, flag, context, options);
1188
- return flag;
1189
- }
1190
- checkType(flagKey, expectedType, result) {
1191
- if (!expectedType) return null;
1192
- if (result.source === "not_found" || result.source === "disabled") return null;
1193
- const datafileFlag = (0, import_core8.findDatafileFlag)(this.datafile, flagKey);
1194
- return (0, import_core8.checkExpectedType)(datafileFlag, expectedType, result.value) ?? null;
1686
+ return evaluateServerFlag(this.s, this, flagKey, defaultValue, context, options);
1195
1687
  }
1196
1688
  /**
1197
1689
  * Every flag in the environment, evaluated against one context.
@@ -1202,37 +1694,9 @@ var AvsbServer = class {
1202
1694
  * arguments.
1203
1695
  */
1204
1696
  getAllFlags(context, options) {
1205
- if (!context) {
1206
- this.warnOnce(
1207
- "get-all-no-context",
1208
- "getAllFlags() was called without a context and returned an empty object. Pass the context positionally, or use server.forUser(context).getAllFlags()."
1209
- );
1210
- return {};
1211
- }
1212
- if (!this.isReady() || !this.datafile) {
1213
- this.warnOnce(
1214
- "get-all-not-ready",
1215
- "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."
1216
- );
1217
- return {};
1218
- }
1219
- const fireExposures = options?.fireExposures === true;
1220
- const decideOptions = [...options?.decideOptions ?? []];
1221
- if (!fireExposures && !decideOptions.includes("DISABLE_EXPOSURE")) {
1222
- decideOptions.push("DISABLE_EXPOSURE");
1223
- }
1224
- const out = {};
1225
- for (const entry of this.datafile.flags) {
1226
- out[entry.key] = this.evaluateInternal(entry.key, void 0, context, {
1227
- decideOptions,
1228
- decisionLog: options?.decisionLog
1229
- });
1230
- }
1231
- return out;
1697
+ return evaluateAllServerFlags(this.s, this, context, options);
1232
1698
  }
1233
- // ---------------------------------------------------------------------------
1234
- // Tracking
1235
- // ---------------------------------------------------------------------------
1699
+ // ---- Tracking ----
1236
1700
  /**
1237
1701
  * Queue a metric (conversion) event. Synchronous: it returns as soon as the
1238
1702
  * event is queued, and the tracker batches it.
@@ -1242,30 +1706,7 @@ var AvsbServer = class {
1242
1706
  * the server; see the README for why the two runtimes read differently here.
1243
1707
  */
1244
1708
  track(eventKey, payload) {
1245
- if (payload.properties) {
1246
- this.warnOnce(
1247
- "track-properties",
1248
- `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.`
1249
- );
1250
- }
1251
- if (!this.tracker) {
1252
- this.enqueuePending(eventKey, payload);
1253
- return;
1254
- }
1255
- this.tracker.queueMetric({
1256
- visitorId: (0, import_core8.bucketingKey)(payload.context),
1257
- eventName: eventKey,
1258
- // TWO columns, not one. `revenue` is money in decimal major units;
1259
- // `value` is the numeric metric value for average-value metrics. Storage
1260
- // declares both (`metric_events.revenue`, `metric_events.value`). This
1261
- // SDK used to ship `payload.value` into the revenue column and could
1262
- // never write the value column at all, so a quantity was recorded as
1263
- // money and every average-value metric read empty.
1264
- revenue: payload.revenue ?? null,
1265
- value: payload.value ?? null,
1266
- timestamp: Date.now(),
1267
- pageUrl: ""
1268
- });
1709
+ trackMetric(this.s, eventKey, payload);
1269
1710
  }
1270
1711
  /**
1271
1712
  * Send a purchase to `/v1/collect/orders`.
@@ -1273,20 +1714,8 @@ var AvsbServer = class {
1273
1714
  * Queued before the tracker exists, exactly like `track` (they used to
1274
1715
  * disagree: one queued, the other warned and dropped the order).
1275
1716
  */
1276
- async trackPurchase(visitorId, order) {
1277
- if (this.destroyed) return;
1278
- if (!this.tracker) {
1279
- if (this.pendingPurchases.length >= this.eventOptions.maxQueueSize) {
1280
- this.pendingPurchases.shift();
1281
- this.warnOnce(
1282
- "pending-purchase-full",
1283
- `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.`
1284
- );
1285
- }
1286
- this.pendingPurchases.push({ visitorId, order });
1287
- return;
1288
- }
1289
- return this.tracker.trackPurchase(visitorId, order);
1717
+ trackPurchase(visitorId, order) {
1718
+ return trackPurchase(this.s, visitorId, order);
1290
1719
  }
1291
1720
  /**
1292
1721
  * Mark the moment a signup or login claimed an anonymous session. Same shape
@@ -1307,8 +1736,8 @@ var AvsbServer = class {
1307
1736
  * the README for the cross-surface version of the same rule.
1308
1737
  */
1309
1738
  alias(previousContext, newContext) {
1310
- this.logger.debug(
1311
- `alias: ${(0, import_core8.bucketingKey)(previousContext)} to ${(0, import_core8.bucketingKey)(newContext)}`
1739
+ this.s.logger.debug(
1740
+ `alias: ${(0, import_core12.bucketingKey)(previousContext)} to ${(0, import_core12.bucketingKey)(newContext)}`
1312
1741
  );
1313
1742
  this.track("avsb_alias", { context: newContext });
1314
1743
  }
@@ -1318,29 +1747,9 @@ var AvsbServer = class {
1318
1747
  * now actually seen the result.
1319
1748
  */
1320
1749
  manualExposure(flagKey, context) {
1321
- if (!this.isReady() || !this.datafile || !this.tracker) return;
1322
- const legacyCtx = this.buildLegacyContext(context, true);
1323
- const result = this.evaluate(flagKey, legacyCtx, void 0);
1324
- if (result.source !== "rule" || !result.ruleId || !result.variationKey) return;
1325
- if (result.ruleType !== "ab_test" && result.ruleType !== "targeted_delivery") return;
1326
- const flag = this.datafile.flags.find((f) => f.key === flagKey);
1327
- if (!flag) return;
1328
- const variation = flag.variations.find((v) => v.key === result.variationKey);
1329
- if (!variation) return;
1330
- this.deliverExposure({
1331
- visitorId: (0, import_core8.bucketingKey)(context),
1332
- experimentId: result.ruleId,
1333
- variationId: variation.id,
1334
- eventName: "flag_exposure",
1335
- revenue: null,
1336
- timestamp: Date.now(),
1337
- pageUrl: "",
1338
- properties: { flagKey, flagId: flag.id, manuallyFired: true }
1339
- });
1750
+ manualExposure(this.s, this, flagKey, context);
1340
1751
  }
1341
- // ---------------------------------------------------------------------------
1342
- // Per-request scoping
1343
- // ---------------------------------------------------------------------------
1752
+ // ---- Per-request scoping ----
1344
1753
  /** A client with the context bound. The primary server pattern. */
1345
1754
  forUser(context) {
1346
1755
  return createUserBoundClient(this, context);
@@ -1349,437 +1758,32 @@ var AvsbServer = class {
1349
1758
  createUserContext(context) {
1350
1759
  return createUserBoundClient(this, context);
1351
1760
  }
1352
- // ---------------------------------------------------------------------------
1353
- // Overrides
1354
- // ---------------------------------------------------------------------------
1761
+ // ---- Overrides ----
1355
1762
  setOverrideForUser(flagKey, contextKey, variationKey, contextKind = "user") {
1356
- this.overrides.setForContext(flagKey, contextKey, variationKey, contextKind);
1763
+ this.s.overrides.setForContext(flagKey, contextKey, variationKey, contextKind);
1357
1764
  }
1358
1765
  setGlobalOverride(flagKey, variationKey) {
1359
- this.overrides.setGlobal(flagKey, variationKey);
1766
+ this.s.overrides.setGlobal(flagKey, variationKey);
1360
1767
  }
1361
1768
  clearOverrideForUser(flagKey, contextKey, contextKind = "user") {
1362
- this.overrides.clearForContext(flagKey, contextKey, contextKind);
1769
+ this.s.overrides.clearForContext(flagKey, contextKey, contextKind);
1363
1770
  }
1364
1771
  clearGlobalOverride(flagKey) {
1365
- this.overrides.clearGlobal(flagKey);
1772
+ this.s.overrides.clearGlobal(flagKey);
1366
1773
  }
1367
1774
  clearAllOverrides() {
1368
- this.overrides.clearAll();
1775
+ this.s.overrides.clearAll();
1369
1776
  }
1370
1777
  /** A fresh decision log to pass per call. `forUser` carries its own. */
1371
1778
  createDecisionLog() {
1372
1779
  return createDecisionLog();
1373
1780
  }
1374
- // ---------------------------------------------------------------------------
1375
- // Events
1376
- // ---------------------------------------------------------------------------
1781
+ // ---- Events ----
1377
1782
  on(event, cb) {
1378
- if (!this.listeners.has(event)) this.listeners.set(event, /* @__PURE__ */ new Set());
1379
- this.listeners.get(event).add(cb);
1380
- return () => {
1381
- this.listeners.get(event)?.delete(cb);
1382
- };
1783
+ return addListener(this.s, event, cb);
1383
1784
  }
1384
1785
  off(event, cb) {
1385
- this.listeners.get(event)?.delete(cb);
1386
- }
1387
- /**
1388
- * `on` for the shared `ClientEventMap` names the utils layer speaks. The
1389
- * server emits every one of them except `flagChange`, which needs per-flag
1390
- * diffing the server does not do; subscribing to it warns and hands back a
1391
- * no-op disposer instead of pretending.
1392
- */
1393
- onSharedEvent(event, cb) {
1394
- if (event === "flagChange") {
1395
- this.warnOnce(
1396
- "no-flag-change",
1397
- '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.'
1398
- );
1399
- return () => void 0;
1400
- }
1401
- return this.on(
1402
- event,
1403
- cb
1404
- );
1405
- }
1406
- emit(event, ...args) {
1407
- const cbs = this.listeners.get(event);
1408
- if (!cbs) return;
1409
- for (const cb of cbs) {
1410
- try {
1411
- ;
1412
- cb(...args);
1413
- } catch (e) {
1414
- this.logger.error("Listener error", { event, error: String(e) });
1415
- }
1416
- }
1417
- }
1418
- warnOnce(key, message) {
1419
- if (this.warnedOnce.has(key)) return;
1420
- this.warnedOnce.add(key);
1421
- this.logger.warn(message);
1422
- }
1423
- // ---------------------------------------------------------------------------
1424
- // Private — evaluation core
1425
- // ---------------------------------------------------------------------------
1426
- evaluate(flagKey, uc, defaultValue) {
1427
- const reasons = [];
1428
- const overrideKey = this.overrides.resolve(flagKey, uc.userId);
1429
- const rtOver = overrideKey ? this.findVariation(flagKey, overrideKey) : null;
1430
- if (rtOver) {
1431
- reasons.push(`matched runtime override for flagKey="${flagKey}"`);
1432
- return {
1433
- value: rtOver.value,
1434
- variationKey: rtOver.key,
1435
- source: "runtimeOverride",
1436
- ruleId: null,
1437
- ruleType: null,
1438
- reasons
1439
- };
1440
- }
1441
- if (this.stickyBucketService) {
1442
- const sticky = this.resolveStickyBucket(flagKey, uc.userId);
1443
- if (sticky !== void 0) {
1444
- reasons.push(`matched sticky bucket assignment for userId="${uc.userId}"`);
1445
- if (!uc.suppressExposure) {
1446
- this.fireExposureForStickyAssignment(flagKey, sticky, uc);
1447
- }
1448
- return {
1449
- value: sticky.vref.value,
1450
- variationKey: sticky.vref.key,
1451
- source: "sticky",
1452
- ruleId: sticky.assignment.ruleId,
1453
- ruleType: sticky.assignment.ruleType,
1454
- reasons
1455
- };
1456
- }
1457
- }
1458
- const legacyCtx = { attributes: { userId: uc.userId, ...uc.attributes } };
1459
- const onExposure = uc.suppressExposure ? void 0 : (event) => {
1460
- this.deliverExposure((0, import_exposure.redactExposure)(event, uc.privateAttributes));
1461
- };
1462
- const r = (0, import_engine.evaluateFlag)(
1463
- flagKey,
1464
- defaultValue,
1465
- legacyCtx,
1466
- this.datafile,
1467
- onExposure,
1468
- reasons
1469
- );
1470
- if (this.stickyBucketService && r.variationKey && r.source === "rule" && r.ruleId && r.ruleType === "ab_test") {
1471
- const v = this.findVariation(flagKey, r.variationKey);
1472
- if (v) {
1473
- this.stickyBucketService.save(uc.userId, flagKey, {
1474
- variationId: v.id,
1475
- ruleId: r.ruleId,
1476
- ruleType: r.ruleType,
1477
- assignedAt: Date.now()
1478
- });
1479
- }
1480
- }
1481
- return {
1482
- value: r.value,
1483
- variationKey: r.variationKey,
1484
- source: (0, import_core8.mapLegacySource)(r.source),
1485
- ruleId: r.ruleId,
1486
- ruleType: r.ruleType,
1487
- reasons
1488
- };
1489
- }
1490
- /**
1491
- * One exposure, delivered everywhere: queued for ingestion AND emitted on
1492
- * the event bus with the SAME `eventId`, so a customer forwarding decisions
1493
- * to their own analytics can reconcile rows with A vs B results.
1494
- */
1495
- deliverExposure(event) {
1496
- const stamped = event.eventId ? event : { ...event, eventId: (0, import_node_crypto2.randomUUID)() };
1497
- if (this.tracker) {
1498
- this.tracker.queue(stamped);
1499
- } else {
1500
- this.enqueuePendingExposure(stamped);
1501
- }
1502
- this.emit("exposure", stamped);
1503
- }
1504
- toFlag(result, defaultValue) {
1505
- return (0, import_core8.createFlag)({
1506
- value: result.value ?? defaultValue,
1507
- variationKey: result.variationKey,
1508
- source: result.source,
1509
- ruleId: result.ruleId,
1510
- ruleType: result.ruleType,
1511
- reasons: result.reasons,
1512
- evaluatedAt: Date.now(),
1513
- durationMicros: 0
1514
- });
1515
- }
1516
- /** Build a Flag from a forced variation key (per-request forced decision). */
1517
- buildForcedFlag(flagKey, variationKey, defaultValue, context, options) {
1518
- const vref = this.findVariation(flagKey, variationKey);
1519
- const flag = (0, import_core8.createFlag)({
1520
- value: vref ? vref.value : defaultValue,
1521
- variationKey: vref ? vref.key : null,
1522
- source: "runtimeOverride",
1523
- ruleId: null,
1524
- ruleType: null,
1525
- reasons: [`forced decision: variationKey="${variationKey}"`]
1526
- });
1527
- this.recordDecision(flagKey, flag, context, options);
1528
- return flag;
1529
- }
1530
- /** Emit the evaluation event and append to a decision log if one was passed. */
1531
- recordDecision(flagKey, flag, context, options) {
1532
- const summary = (0, import_core8.contextSummary)(context);
1533
- this.emit("evaluation", {
1534
- flagKey,
1535
- flag,
1536
- contextKeys: summary.map((entry) => entry.key)
1537
- });
1538
- if (!options?.decisionLog) return;
1539
- appendDecisionLogEntry(options.decisionLog, {
1540
- flagKey,
1541
- value: flag.value,
1542
- variationKey: flag.variationKey,
1543
- source: flag.source,
1544
- ruleId: flag.ruleId,
1545
- ruleType: flag.ruleType,
1546
- reasons: flag.reasons,
1547
- evaluatedAt: flag.evaluatedAt,
1548
- durationMicros: flag.durationMicros,
1549
- contextSummary: summary,
1550
- privateValues: privateAttributeValues(context)
1551
- });
1552
- }
1553
- buildLegacyContext(context, suppressExposure) {
1554
- const legacy = (0, import_core8.toLegacyEvalContext)(context);
1555
- const userId = legacy.attributes["userId"] ?? "";
1556
- const privateAttrs = (0, import_core8.privateAttributesFrom)(context);
1557
- const { userId: _uid, ...rest } = legacy.attributes;
1558
- return {
1559
- userId,
1560
- attributes: rest,
1561
- suppressExposure,
1562
- privateAttributes: privateAttrs.size > 0 ? [...privateAttrs] : void 0
1563
- };
1564
- }
1565
- // ---------------------------------------------------------------------------
1566
- // Private — sticky helpers
1567
- // ---------------------------------------------------------------------------
1568
- resolveStickyBucket(flagKey, userId) {
1569
- if (!this.stickyBucketService || !this.datafile) return void 0;
1570
- const assignment = this.stickyBucketService.lookup(userId, flagKey);
1571
- if (!assignment) return void 0;
1572
- const flag = this.datafile.flags.find((f) => f.key === flagKey);
1573
- if (!flag) return void 0;
1574
- const v = flag.variations.find((x) => x.id === assignment.variationId);
1575
- if (!v) return void 0;
1576
- return { vref: { value: v.value, key: v.key, id: v.id }, assignment };
1577
- }
1578
- findVariation(flagKey, variationKey) {
1579
- if (!this.datafile) return null;
1580
- const flag = this.datafile.flags.find((f) => f.key === flagKey);
1581
- if (!flag) return null;
1582
- const v = flag.variations.find((x) => x.key === variationKey);
1583
- if (!v) return null;
1584
- return { value: v.value, key: v.key, id: v.id };
1585
- }
1586
- fireExposureForStickyAssignment(flagKey, sticky, uc) {
1587
- if (!this.tracker || !this.datafile) return;
1588
- const flag = this.datafile.flags.find((f) => f.key === flagKey);
1589
- if (!flag) return;
1590
- const rule = flag.rules.find((r) => r.id === sticky.assignment.ruleId);
1591
- if (!rule || !rule.variations.some((rv) => rv.variationId === sticky.vref.id)) {
1592
- this.logger.warn(
1593
- "Sticky assignment references a rule no longer matching the datafile",
1594
- {
1595
- flagKey,
1596
- userId: uc.userId,
1597
- assignedRuleId: sticky.assignment.ruleId,
1598
- assignedVariationId: sticky.vref.id
1599
- }
1600
- );
1601
- this.emit("stickyOrphan", {
1602
- flagKey,
1603
- userId: uc.userId,
1604
- assignment: sticky.assignment
1605
- });
1606
- return;
1607
- }
1608
- this.deliverExposure({
1609
- visitorId: uc.userId,
1610
- experimentId: rule.id,
1611
- variationId: sticky.vref.id,
1612
- eventName: "flag_exposure",
1613
- revenue: null,
1614
- timestamp: Date.now(),
1615
- pageUrl: "",
1616
- properties: { flagKey, flagId: flag.id }
1617
- });
1618
- }
1619
- // ---------------------------------------------------------------------------
1620
- // Private — tracker, polling, streaming, heartbeat
1621
- // ---------------------------------------------------------------------------
1622
- initTracker() {
1623
- if (!this.datafile || this.tracker) return;
1624
- const tracker = createServerTracker({
1625
- sdkKey: this.sdkKey,
1626
- collectEndpoint: this.datafile.collectEndpoint,
1627
- flushInterval: this.eventOptions.flushInterval,
1628
- batchSize: this.eventOptions.batchSize,
1629
- maxQueueSize: this.eventOptions.maxQueueSize,
1630
- retryAttempts: this.eventOptions.retryAttempts,
1631
- logger: this.logger
1632
- });
1633
- this.tracker = tracker;
1634
- if (this.pendingExposures.length > 0) {
1635
- const queued = this.pendingExposures;
1636
- this.pendingExposures = [];
1637
- for (const event of queued) tracker.queue(event);
1638
- }
1639
- if (this.pendingEvents.length > 0) {
1640
- const queued = this.pendingEvents;
1641
- this.pendingEvents = [];
1642
- for (const item of queued) this.track(item.eventKey, item.payload);
1643
- }
1644
- if (this.pendingPurchases.length > 0) {
1645
- const queued = this.pendingPurchases;
1646
- this.pendingPurchases = [];
1647
- for (const item of queued) void this.trackPurchase(item.visitorId, item.order);
1648
- }
1649
- }
1650
- enqueuePendingExposure(event) {
1651
- if (this.pendingExposures.length >= this.eventOptions.maxQueueSize) {
1652
- this.pendingExposures.shift();
1653
- this.warnOnce(
1654
- "pending-exposures-full",
1655
- `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.`
1656
- );
1657
- }
1658
- this.pendingExposures.push(event);
1659
- }
1660
- enqueuePending(eventKey, payload) {
1661
- if (this.pendingEvents.length >= this.eventOptions.maxQueueSize) {
1662
- this.pendingEvents.shift();
1663
- this.warnOnce(
1664
- "pending-events-full",
1665
- `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.`
1666
- );
1667
- }
1668
- this.pendingEvents.push({ eventKey, payload });
1669
- }
1670
- /**
1671
- * Fetch and apply the datafile.
1672
- *
1673
- * Three outcomes, not two, because a conditional request has three:
1674
- * 'applied' a datafile came back and is now in memory
1675
- * 'unchanged' the CDN answered 304: what we hold IS current
1676
- * 'failed' nothing usable came back; the error is already logged
1677
- *
1678
- * 'unchanged' is a success. Collapsing it into 'failed' made
1679
- * `refreshDatafile()` warn that a refresh "did not apply a new datafile"
1680
- * every time the datafile was simply up to date.
1681
- */
1682
- async loadDatafile(reason = "manual") {
1683
- const outcome = await fetchDatafile({
1684
- sdkKey: this.sdkKey,
1685
- cdnHost: this.cdnHost,
1686
- signal: this.abortController.signal,
1687
- logger: this.logger,
1688
- etag: this.datafileEtag
1689
- });
1690
- if (outcome.status === "ok" || outcome.status === "not-modified") {
1691
- this.sendHeartbeat();
1692
- }
1693
- if (outcome.status === "not-modified") return "unchanged";
1694
- if (outcome.status !== "ok") return "failed";
1695
- const changed = !this.datafile || this.datafile.publishedAt !== outcome.datafile.publishedAt;
1696
- this.datafile = outcome.datafile;
1697
- this.datafileEtag = outcome.etag;
1698
- if (this.ready && changed) {
1699
- this.emit("configUpdate", {
1700
- publishedAt: outcome.datafile.publishedAt,
1701
- reason
1702
- });
1703
- this.logger.info("Datafile updated", {
1704
- publishedAt: outcome.datafile.publishedAt,
1705
- reason
1706
- });
1707
- this.startStreamingIfEnabled();
1708
- }
1709
- return "applied";
1710
- }
1711
- /**
1712
- * Poll on a JITTERED interval. Without the jitter every process that
1713
- * deployed together polls the CDN on the same tick forever.
1714
- */
1715
- startPolling() {
1716
- if (this.pollingTimer || this.destroyed) return;
1717
- const schedule = () => {
1718
- this.pollingTimer = setTimeout(() => {
1719
- this.pollingTimer = null;
1720
- void (async () => {
1721
- const result = await this.loadDatafile("poll");
1722
- if (result === "applied" && !this.ready) this.onDatafileReadable();
1723
- if (!this.destroyed) schedule();
1724
- })();
1725
- }, (0, import_backoff2.jitteredInterval)(this.pollingInterval));
1726
- };
1727
- schedule();
1728
- }
1729
- /**
1730
- * Open the SSE stream when one is available, and fall back to polling in
1731
- * silence when it is not (B12). The endpoint comes from the datafile's
1732
- * `streamEndpoint` field, or an explicit option; it is never guessed from
1733
- * another host, which is what gave one port an infinite reconnect loop
1734
- * against DNS that does not resolve.
1735
- */
1736
- startStreamingIfEnabled() {
1737
- if (!this.streamingEnabled || this.streamingClient || this.destroyed) return;
1738
- const resolution = (0, import_core8.resolveStreamEndpoint)({
1739
- optionEndpoint: this.streamingEndpointOption,
1740
- datafileStreamEndpoint: this.datafile?.streamEndpoint ?? null
1741
- });
1742
- if (!resolution.available) {
1743
- this.logger.debug(`Streaming stays off, polling continues. ${resolution.reason}`);
1744
- return;
1745
- }
1746
- this.streamingClient = (0, import_streaming.createStreamingClient)({
1747
- endpoint: resolution.endpoint,
1748
- sdkKey: this.sdkKey,
1749
- onDatafileUpdated: () => {
1750
- void this.loadDatafile("stream");
1751
- },
1752
- logger: this.logger
1753
- });
1754
- this.streamingClient.connect();
1755
- this.logger.debug(
1756
- `Streaming datafile updates from ${resolution.endpoint} (${resolution.source}).`
1757
- );
1758
- }
1759
- /**
1760
- * Report liveness, throttled.
1761
- *
1762
- * Called on every successful datafile load, not just at init: the dashboard
1763
- * marks an environment Stale after five minutes without a heartbeat, so an
1764
- * init-only heartbeat made every long-running process look dead a few
1765
- * minutes after it started. The throttle keeps a fast polling interval (or a
1766
- * burst of stream-triggered refreshes) from turning liveness into traffic.
1767
- *
1768
- * Fire and forget: a failed heartbeat never affects evaluation, and the
1769
- * throttle clock is stamped by the synchronous send below, so a hanging
1770
- * request cannot queue a second one.
1771
- */
1772
- sendHeartbeat() {
1773
- const now = Date.now();
1774
- if (now - this.lastHeartbeatAt < HEARTBEAT_MIN_INTERVAL_MS) return;
1775
- const posted = sendHeartbeat({
1776
- sdkKey: this.sdkKey,
1777
- sdkType: SDK_TYPE,
1778
- sdkVersion: SDK_VERSION,
1779
- datafileHeartbeatEndpoint: this.datafile?.heartbeatEndpoint,
1780
- signal: this.abortController.signal
1781
- });
1782
- if (posted) this.lastHeartbeatAt = now;
1786
+ this.s.listeners.get(event)?.delete(cb);
1783
1787
  }
1784
1788
  };
1785
1789
 
@@ -1817,7 +1821,7 @@ var RedisStickyBucketService = class {
1817
1821
  this.prefix = options.prefix ?? "avsb:sticky:";
1818
1822
  this.ttl = options.ttl ?? 2592e3;
1819
1823
  this.maxCacheSize = options.cacheSize ?? 1e4;
1820
- this.logger = options.logger ?? import_core4.noopLogger;
1824
+ this.logger = options.logger ?? import_core6.noopLogger;
1821
1825
  }
1822
1826
  redisKey(userId, flagKey) {
1823
1827
  return `${this.prefix}${userId}:${flagKey}`;
@@ -1956,7 +1960,7 @@ function verifyWebhookSignature(payload, headers, secret, options = {}) {
1956
1960
  }
1957
1961
 
1958
1962
  // src/index.ts
1959
- var import_core9 = require("@avsbhq/core");
1963
+ var import_core13 = require("@avsbhq/core");
1960
1964
  // Annotate the CommonJS export names for ESM import in node:
1961
1965
  0 && (module.exports = {
1962
1966
  AvsbServer,