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