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