@shipeasy/sdk 5.0.0 → 5.1.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.
@@ -31,7 +31,9 @@ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: tru
31
31
  var server_exports = {};
32
32
  __export(server_exports, {
33
33
  ANON_ID_COOKIE: () => ANON_ID_COOKIE,
34
+ FLAG_REASONS: () => FLAG_REASONS,
34
35
  FlagsClient: () => FlagsClient,
36
+ _murmur3ForTests: () => _murmur3ForTests,
35
37
  _resetShipeasyServerForTests: () => _resetShipeasyServerForTests,
36
38
  configureShipeasyServer: () => configureShipeasyServer,
37
39
  fetchLabelsForSSR: () => fetchLabelsForSSR,
@@ -362,6 +364,9 @@ var SeeLimiter = class {
362
364
  var version = "4.0.0";
363
365
  var C1 = 3432918353;
364
366
  var C2 = 461845907;
367
+ function _murmur3ForTests(key) {
368
+ return murmur3(key);
369
+ }
365
370
  function murmur3(key) {
366
371
  const bytes = new TextEncoder().encode(key);
367
372
  const len = bytes.length;
@@ -402,6 +407,14 @@ function murmur3(key) {
402
407
  h1 ^= h1 >>> 16;
403
408
  return h1 >>> 0;
404
409
  }
410
+ var FLAG_REASONS = [
411
+ "CLIENT_NOT_READY",
412
+ "FLAG_NOT_FOUND",
413
+ "OFF",
414
+ "OVERRIDE",
415
+ "RULE_MATCH",
416
+ "DEFAULT"
417
+ ];
405
418
  function isEnabled(v) {
406
419
  return v === 1 || v === true;
407
420
  }
@@ -509,7 +522,7 @@ function parseOverrides(rawUrl) {
509
522
  }
510
523
  return { gates, configs, experiments };
511
524
  }
512
- var FlagsClient = class {
525
+ var FlagsClient = class _FlagsClient {
513
526
  apiKey;
514
527
  baseUrl;
515
528
  env;
@@ -522,47 +535,155 @@ var FlagsClient = class {
522
535
  pollInterval = 30;
523
536
  timer = null;
524
537
  initialized = false;
538
+ // Test mode: built by `FlagsClient.forTesting()`. When set, init()/initOnce()
539
+ // never fetch, track() is a no-op, and telemetry is off — the client is a
540
+ // fully self-contained, network-free seam for unit tests.
541
+ testMode;
542
+ // Programmatic overrides (Statsig-style). Set on any client via
543
+ // overrideFlag/overrideConfig/overrideExperiment; they win over the fetched
544
+ // blob in getFlag/getConfig/getExperiment. Cleared by clearOverrides().
545
+ flagOverrides = /* @__PURE__ */ new Map();
546
+ configOverrides = /* @__PURE__ */ new Map();
547
+ experimentOverrides = /* @__PURE__ */ new Map();
548
+ // Change listeners fired after a background poll returns NEW data (200, not
549
+ // 304). Never fired in testMode/offline (no polling happens there).
550
+ changeListeners = /* @__PURE__ */ new Set();
525
551
  constructor(opts) {
526
552
  this.apiKey = opts.apiKey;
527
553
  this.baseUrl = (opts.baseUrl ?? "https://cdn.shipeasy.ai").replace(/\/$/, "");
528
554
  this.env = opts.env ?? "prod";
555
+ this.testMode = opts.testMode === true;
529
556
  this.telemetry = new Telemetry({
530
557
  endpoint: opts.telemetryUrl ?? DEFAULT_TELEMETRY_URL,
531
558
  sdkKey: this.apiKey,
532
559
  side: "server",
533
560
  env: this.env,
534
- disabled: opts.disableTelemetry
561
+ // Test mode never talks to the network — telemetry off regardless of opt.
562
+ disabled: this.testMode || opts.disableTelemetry
535
563
  });
536
- if (opts.initialBlob) {
537
- this.flagsBlob = opts.initialBlob;
564
+ if (opts.initialBlob || this.testMode) {
565
+ this.flagsBlob = opts.initialBlob ?? { version: "test", plan: "free", gates: {}, configs: {}, killswitches: {} };
566
+ this.expsBlob = this.expsBlob ?? { version: "test", universes: {}, experiments: {} };
538
567
  this.initialized = true;
539
568
  }
540
569
  }
570
+ /**
571
+ * Build a no-network, immediately-usable client for tests (Statsig-style).
572
+ * init()/initOnce() are no-ops (never fetch), track() is a no-op, telemetry
573
+ * is disabled, and the client is already "initialized" — seed every entity
574
+ * with overrideFlag/overrideConfig/overrideExperiment. No SDK key required.
575
+ *
576
+ * ```ts
577
+ * const client = FlagsClient.forTesting();
578
+ * client.overrideFlag("new_checkout", true);
579
+ * client.getFlag("new_checkout", { user_id: "u1" }); // true
580
+ * ```
581
+ */
582
+ static forTesting(opts) {
583
+ return new _FlagsClient({ apiKey: "", ...opts, testMode: true });
584
+ }
585
+ /**
586
+ * Build a fully OFFLINE client from a pre-captured snapshot — no network ever.
587
+ * Reuses the test-mode plumbing (init()/initOnce()/track() are no-ops,
588
+ * telemetry off) but, unlike forTesting(), seeds the REAL flags + experiments
589
+ * blobs so evaluations run the canonical eval against the snapshot. Local
590
+ * overrides still apply on top.
591
+ *
592
+ * Snapshot shape mirrors the wire bodies:
593
+ * `{ flags: <GET /sdk/flags body>, experiments: <GET /sdk/experiments body> }`.
594
+ */
595
+ static fromSnapshot(snapshot) {
596
+ const client = new _FlagsClient({ apiKey: "", testMode: true });
597
+ client.flagsBlob = snapshot.flags;
598
+ client.expsBlob = snapshot.experiments;
599
+ client.initialized = true;
600
+ return client;
601
+ }
602
+ /**
603
+ * Build a fully OFFLINE client from a snapshot JSON file on disk (Node only —
604
+ * not available in the browser entrypoint). The file must contain
605
+ * `{ "flags": <GET /sdk/flags body>, "experiments": <GET /sdk/experiments body> }`.
606
+ * See {@link FlagsClient.fromSnapshot}.
607
+ */
608
+ static fromFile(path) {
609
+ const fs = require("fs");
610
+ const raw = fs.readFileSync(path, "utf8");
611
+ const snapshot = JSON.parse(raw);
612
+ return _FlagsClient.fromSnapshot(snapshot);
613
+ }
541
614
  async init() {
615
+ if (this.testMode) {
616
+ this.initialized = true;
617
+ return;
618
+ }
542
619
  await this.fetchAll();
543
620
  this.initialized = true;
544
621
  this.startPoll();
545
622
  }
546
623
  async initOnce() {
547
- if (this.initialized) return;
624
+ if (this.testMode || this.initialized) return;
548
625
  await this.fetchAll();
549
626
  this.initialized = true;
550
627
  }
628
+ // ---- Local overrides (Statsig-style) ----
629
+ /** Force `getFlag(name, …)` to return `value`, ignoring the fetched gate. */
630
+ overrideFlag(name, value) {
631
+ this.flagOverrides.set(name, value);
632
+ }
633
+ /** Force `getConfig(name)` to return `value`, ignoring the fetched config. */
634
+ overrideConfig(name, value) {
635
+ this.configOverrides.set(name, value);
636
+ }
637
+ /**
638
+ * Force `getExperiment(name, …)` to return `{ inExperiment: true, group, params }`,
639
+ * ignoring allocation, holdouts, and targeting.
640
+ */
641
+ overrideExperiment(name, group, params) {
642
+ this.experimentOverrides.set(name, { group, params });
643
+ }
644
+ /** Remove every programmatic override set via the override* methods. */
645
+ clearOverrides() {
646
+ this.flagOverrides.clear();
647
+ this.configOverrides.clear();
648
+ this.experimentOverrides.clear();
649
+ }
551
650
  destroy() {
552
651
  if (this.timer !== null) {
553
652
  clearInterval(this.timer);
554
653
  this.timer = null;
555
654
  }
556
655
  }
656
+ /**
657
+ * Subscribe to data-change notifications. The listener fires after a
658
+ * background poll fetch returns NEW data (200, not 304) — i.e. after the
659
+ * cached blob is updated. Never fires in testMode/offline (no polling).
660
+ * Returns an unsubscribe function.
661
+ */
662
+ onChange(listener) {
663
+ this.changeListeners.add(listener);
664
+ return () => {
665
+ this.changeListeners.delete(listener);
666
+ };
667
+ }
668
+ notifyChange() {
669
+ for (const l of this.changeListeners) {
670
+ try {
671
+ l();
672
+ } catch (err) {
673
+ console.warn("[shipeasy] onChange listener threw:", String(err));
674
+ }
675
+ }
676
+ }
557
677
  startPoll() {
558
678
  this.timer = setInterval(() => {
559
- this.fetchAll().catch(
679
+ this.fetchAll(true).catch(
560
680
  (err) => console.warn("[shipeasy] background poll failed:", String(err))
561
681
  );
562
682
  }, this.pollInterval * 1e3);
563
683
  }
564
- async fetchAll() {
565
- const [interval] = await Promise.all([this.fetchFlags(), this.fetchExps()]);
684
+ async fetchAll(fromPoll = false) {
685
+ const [flagsRes, expsChanged] = await Promise.all([this.fetchFlags(), this.fetchExps()]);
686
+ const { interval, changed: flagsChanged } = flagsRes;
566
687
  if (interval !== null && interval !== this.pollInterval) {
567
688
  this.pollInterval = interval;
568
689
  if (this.timer !== null) {
@@ -570,41 +691,72 @@ var FlagsClient = class {
570
691
  this.startPoll();
571
692
  }
572
693
  }
694
+ if (fromPoll && (flagsChanged || expsChanged)) this.notifyChange();
573
695
  }
574
696
  async fetchFlags() {
575
697
  const headers = { "X-SDK-Key": this.apiKey };
576
698
  if (this.flagsEtag) headers["If-None-Match"] = this.flagsEtag;
577
699
  const res = await globalThis.fetch(`${this.baseUrl}/sdk/flags?env=${this.env}`, { headers });
578
700
  const interval = Number(res.headers.get("X-Poll-Interval") ?? "30") || 30;
579
- if (res.status === 304) return interval;
701
+ if (res.status === 304) return { interval, changed: false };
580
702
  if (!res.ok) throw new Error(`/sdk/flags returned ${res.status}`);
581
703
  const etag = res.headers.get("ETag");
582
704
  if (etag) this.flagsEtag = etag;
583
705
  this.flagsBlob = await res.json();
584
- return interval;
706
+ return { interval, changed: true };
585
707
  }
586
708
  async fetchExps() {
587
709
  const headers = { "X-SDK-Key": this.apiKey };
588
710
  if (this.expsEtag) headers["If-None-Match"] = this.expsEtag;
589
711
  const res = await globalThis.fetch(`${this.baseUrl}/sdk/experiments`, { headers });
590
- if (res.status === 304) return;
712
+ if (res.status === 304) return false;
591
713
  if (!res.ok) throw new Error(`/sdk/experiments returned ${res.status}`);
592
714
  const etag = res.headers.get("ETag");
593
715
  if (etag) this.expsEtag = etag;
594
716
  this.expsBlob = await res.json();
717
+ return true;
595
718
  }
596
- getFlag(name, user) {
719
+ /**
720
+ * Evaluate a gate and report WHY (LaunchDarkly variationDetail parity). The
721
+ * reason is computed entirely at this boundary — the canonical eval
722
+ * (evalGateInternal) is untouched. A local override short-circuits BEFORE
723
+ * telemetry, exactly like getFlag's override path; otherwise exactly one
724
+ * "gate" beacon is emitted.
725
+ */
726
+ getFlagDetail(name, user) {
727
+ const ov = this.flagOverrides.get(name);
728
+ if (ov !== void 0) return { value: ov, reason: "OVERRIDE" };
597
729
  this.telemetry.emit("gate", name);
598
- const gate = this.flagsBlob?.gates[name];
599
- if (!gate) return false;
600
- return evalGateInternal(gate, user);
730
+ if (!this.flagsBlob) return { value: false, reason: "CLIENT_NOT_READY" };
731
+ const gate = this.flagsBlob.gates[name];
732
+ if (!gate) return { value: false, reason: "FLAG_NOT_FOUND" };
733
+ if (isEnabled(gate.killswitch) || !isEnabled(gate.enabled)) {
734
+ return { value: false, reason: "OFF" };
735
+ }
736
+ const value = evalGateInternal(gate, user);
737
+ return { value, reason: value ? "RULE_MATCH" : "DEFAULT" };
738
+ }
739
+ /**
740
+ * Read a feature gate. Returns `defaultValue` ONLY when the gate cannot be
741
+ * evaluated (client not initialized or flag not found) — never for a gate that
742
+ * legitimately evaluates to false. Plain `getFlag(name, user)` keeps returning
743
+ * false for a missing flag.
744
+ */
745
+ getFlag(name, user, defaultValue = false) {
746
+ const d = this.getFlagDetail(name, user);
747
+ if (d.reason === "CLIENT_NOT_READY" || d.reason === "FLAG_NOT_FOUND") return defaultValue;
748
+ return d.value;
601
749
  }
602
- getConfig(name, decode) {
750
+ getConfig(name, decodeOrOpts) {
603
751
  this.telemetry.emit("config", name);
604
- const entry = this.flagsBlob?.configs[name];
605
- if (!entry) return void 0;
606
- if (!decode) return entry.value;
607
- return decode(entry.value);
752
+ const opts = typeof decodeOrOpts === "function" ? { decode: decodeOrOpts } : decodeOrOpts ?? {};
753
+ const has = this.configOverrides.has(name);
754
+ const raw = has ? this.configOverrides.get(name) : this.flagsBlob?.configs[name]?.value;
755
+ if (raw === void 0 && !has) {
756
+ return "defaultValue" in opts ? opts.defaultValue : void 0;
757
+ }
758
+ if (!opts.decode) return raw;
759
+ return opts.decode(raw);
608
760
  }
609
761
  getExperiment(name, user, defaultParams, decode) {
610
762
  this.telemetry.emit("experiment", name);
@@ -613,6 +765,16 @@ var FlagsClient = class {
613
765
  group: "control",
614
766
  params: defaultParams
615
767
  };
768
+ const ov = this.experimentOverrides.get(name);
769
+ if (ov) {
770
+ if (!decode) return { inExperiment: true, group: ov.group, params: ov.params };
771
+ try {
772
+ return { inExperiment: true, group: ov.group, params: decode(ov.params) };
773
+ } catch (err) {
774
+ console.warn(`[shipeasy] getExperiment('${name}') override decode failed:`, String(err));
775
+ return notIn;
776
+ }
777
+ }
616
778
  if (!this.flagsBlob || !this.expsBlob) return notIn;
617
779
  const exp = this.expsBlob.experiments[name];
618
780
  if (!exp || exp.status !== "running") return notIn;
@@ -650,6 +812,7 @@ var FlagsClient = class {
650
812
  return notIn;
651
813
  }
652
814
  track(userId, eventName, props) {
815
+ if (this.testMode) return;
653
816
  const body = JSON.stringify({
654
817
  events: [
655
818
  {
@@ -984,11 +1147,15 @@ var flags = {
984
1147
  destroy() {
985
1148
  _server?.destroy();
986
1149
  },
987
- get(name, user) {
988
- return _server?.getFlag(name, user) ?? false;
1150
+ get(name, user, defaultValue = false) {
1151
+ return _server?.getFlag(name, user, defaultValue) ?? defaultValue;
1152
+ },
1153
+ /** Evaluate a gate and report why (value + reason). See {@link FlagDetail}. */
1154
+ getDetail(name, user) {
1155
+ return _server?.getFlagDetail(name, user) ?? { value: false, reason: "CLIENT_NOT_READY" };
989
1156
  },
990
- getConfig(name, decode) {
991
- return _server?.getConfig(name, decode);
1157
+ getConfig(name, decodeOrOpts) {
1158
+ return _server?.getConfig(name, decodeOrOpts);
992
1159
  },
993
1160
  getExperiment(name, user, defaultParams, decode) {
994
1161
  return _server?.getExperiment(name, user, defaultParams, decode) ?? {
@@ -1039,7 +1206,9 @@ var see = Object.assign(
1039
1206
  // Annotate the CommonJS export names for ESM import in node:
1040
1207
  0 && (module.exports = {
1041
1208
  ANON_ID_COOKIE,
1209
+ FLAG_REASONS,
1042
1210
  FlagsClient,
1211
+ _murmur3ForTests,
1043
1212
  _resetShipeasyServerForTests,
1044
1213
  configureShipeasyServer,
1045
1214
  fetchLabelsForSSR,
@@ -1,3 +1,10 @@
1
+ var __require = /* @__PURE__ */ ((x) => typeof require !== "undefined" ? require : typeof Proxy !== "undefined" ? new Proxy(x, {
2
+ get: (a, b) => (typeof require !== "undefined" ? require : a)[b]
3
+ }) : x)(function(x) {
4
+ if (typeof require !== "undefined") return require.apply(this, arguments);
5
+ throw Error('Dynamic require of "' + x + '" is not supported');
6
+ });
7
+
1
8
  // src/server/index.ts
2
9
  import { AsyncLocalStorage } from "async_hooks";
3
10
 
@@ -315,6 +322,9 @@ var SeeLimiter = class {
315
322
  var version = "4.0.0";
316
323
  var C1 = 3432918353;
317
324
  var C2 = 461845907;
325
+ function _murmur3ForTests(key) {
326
+ return murmur3(key);
327
+ }
318
328
  function murmur3(key) {
319
329
  const bytes = new TextEncoder().encode(key);
320
330
  const len = bytes.length;
@@ -355,6 +365,14 @@ function murmur3(key) {
355
365
  h1 ^= h1 >>> 16;
356
366
  return h1 >>> 0;
357
367
  }
368
+ var FLAG_REASONS = [
369
+ "CLIENT_NOT_READY",
370
+ "FLAG_NOT_FOUND",
371
+ "OFF",
372
+ "OVERRIDE",
373
+ "RULE_MATCH",
374
+ "DEFAULT"
375
+ ];
358
376
  function isEnabled(v) {
359
377
  return v === 1 || v === true;
360
378
  }
@@ -462,7 +480,7 @@ function parseOverrides(rawUrl) {
462
480
  }
463
481
  return { gates, configs, experiments };
464
482
  }
465
- var FlagsClient = class {
483
+ var FlagsClient = class _FlagsClient {
466
484
  apiKey;
467
485
  baseUrl;
468
486
  env;
@@ -475,47 +493,155 @@ var FlagsClient = class {
475
493
  pollInterval = 30;
476
494
  timer = null;
477
495
  initialized = false;
496
+ // Test mode: built by `FlagsClient.forTesting()`. When set, init()/initOnce()
497
+ // never fetch, track() is a no-op, and telemetry is off — the client is a
498
+ // fully self-contained, network-free seam for unit tests.
499
+ testMode;
500
+ // Programmatic overrides (Statsig-style). Set on any client via
501
+ // overrideFlag/overrideConfig/overrideExperiment; they win over the fetched
502
+ // blob in getFlag/getConfig/getExperiment. Cleared by clearOverrides().
503
+ flagOverrides = /* @__PURE__ */ new Map();
504
+ configOverrides = /* @__PURE__ */ new Map();
505
+ experimentOverrides = /* @__PURE__ */ new Map();
506
+ // Change listeners fired after a background poll returns NEW data (200, not
507
+ // 304). Never fired in testMode/offline (no polling happens there).
508
+ changeListeners = /* @__PURE__ */ new Set();
478
509
  constructor(opts) {
479
510
  this.apiKey = opts.apiKey;
480
511
  this.baseUrl = (opts.baseUrl ?? "https://cdn.shipeasy.ai").replace(/\/$/, "");
481
512
  this.env = opts.env ?? "prod";
513
+ this.testMode = opts.testMode === true;
482
514
  this.telemetry = new Telemetry({
483
515
  endpoint: opts.telemetryUrl ?? DEFAULT_TELEMETRY_URL,
484
516
  sdkKey: this.apiKey,
485
517
  side: "server",
486
518
  env: this.env,
487
- disabled: opts.disableTelemetry
519
+ // Test mode never talks to the network — telemetry off regardless of opt.
520
+ disabled: this.testMode || opts.disableTelemetry
488
521
  });
489
- if (opts.initialBlob) {
490
- this.flagsBlob = opts.initialBlob;
522
+ if (opts.initialBlob || this.testMode) {
523
+ this.flagsBlob = opts.initialBlob ?? { version: "test", plan: "free", gates: {}, configs: {}, killswitches: {} };
524
+ this.expsBlob = this.expsBlob ?? { version: "test", universes: {}, experiments: {} };
491
525
  this.initialized = true;
492
526
  }
493
527
  }
528
+ /**
529
+ * Build a no-network, immediately-usable client for tests (Statsig-style).
530
+ * init()/initOnce() are no-ops (never fetch), track() is a no-op, telemetry
531
+ * is disabled, and the client is already "initialized" — seed every entity
532
+ * with overrideFlag/overrideConfig/overrideExperiment. No SDK key required.
533
+ *
534
+ * ```ts
535
+ * const client = FlagsClient.forTesting();
536
+ * client.overrideFlag("new_checkout", true);
537
+ * client.getFlag("new_checkout", { user_id: "u1" }); // true
538
+ * ```
539
+ */
540
+ static forTesting(opts) {
541
+ return new _FlagsClient({ apiKey: "", ...opts, testMode: true });
542
+ }
543
+ /**
544
+ * Build a fully OFFLINE client from a pre-captured snapshot — no network ever.
545
+ * Reuses the test-mode plumbing (init()/initOnce()/track() are no-ops,
546
+ * telemetry off) but, unlike forTesting(), seeds the REAL flags + experiments
547
+ * blobs so evaluations run the canonical eval against the snapshot. Local
548
+ * overrides still apply on top.
549
+ *
550
+ * Snapshot shape mirrors the wire bodies:
551
+ * `{ flags: <GET /sdk/flags body>, experiments: <GET /sdk/experiments body> }`.
552
+ */
553
+ static fromSnapshot(snapshot) {
554
+ const client = new _FlagsClient({ apiKey: "", testMode: true });
555
+ client.flagsBlob = snapshot.flags;
556
+ client.expsBlob = snapshot.experiments;
557
+ client.initialized = true;
558
+ return client;
559
+ }
560
+ /**
561
+ * Build a fully OFFLINE client from a snapshot JSON file on disk (Node only —
562
+ * not available in the browser entrypoint). The file must contain
563
+ * `{ "flags": <GET /sdk/flags body>, "experiments": <GET /sdk/experiments body> }`.
564
+ * See {@link FlagsClient.fromSnapshot}.
565
+ */
566
+ static fromFile(path) {
567
+ const fs = __require("fs");
568
+ const raw = fs.readFileSync(path, "utf8");
569
+ const snapshot = JSON.parse(raw);
570
+ return _FlagsClient.fromSnapshot(snapshot);
571
+ }
494
572
  async init() {
573
+ if (this.testMode) {
574
+ this.initialized = true;
575
+ return;
576
+ }
495
577
  await this.fetchAll();
496
578
  this.initialized = true;
497
579
  this.startPoll();
498
580
  }
499
581
  async initOnce() {
500
- if (this.initialized) return;
582
+ if (this.testMode || this.initialized) return;
501
583
  await this.fetchAll();
502
584
  this.initialized = true;
503
585
  }
586
+ // ---- Local overrides (Statsig-style) ----
587
+ /** Force `getFlag(name, …)` to return `value`, ignoring the fetched gate. */
588
+ overrideFlag(name, value) {
589
+ this.flagOverrides.set(name, value);
590
+ }
591
+ /** Force `getConfig(name)` to return `value`, ignoring the fetched config. */
592
+ overrideConfig(name, value) {
593
+ this.configOverrides.set(name, value);
594
+ }
595
+ /**
596
+ * Force `getExperiment(name, …)` to return `{ inExperiment: true, group, params }`,
597
+ * ignoring allocation, holdouts, and targeting.
598
+ */
599
+ overrideExperiment(name, group, params) {
600
+ this.experimentOverrides.set(name, { group, params });
601
+ }
602
+ /** Remove every programmatic override set via the override* methods. */
603
+ clearOverrides() {
604
+ this.flagOverrides.clear();
605
+ this.configOverrides.clear();
606
+ this.experimentOverrides.clear();
607
+ }
504
608
  destroy() {
505
609
  if (this.timer !== null) {
506
610
  clearInterval(this.timer);
507
611
  this.timer = null;
508
612
  }
509
613
  }
614
+ /**
615
+ * Subscribe to data-change notifications. The listener fires after a
616
+ * background poll fetch returns NEW data (200, not 304) — i.e. after the
617
+ * cached blob is updated. Never fires in testMode/offline (no polling).
618
+ * Returns an unsubscribe function.
619
+ */
620
+ onChange(listener) {
621
+ this.changeListeners.add(listener);
622
+ return () => {
623
+ this.changeListeners.delete(listener);
624
+ };
625
+ }
626
+ notifyChange() {
627
+ for (const l of this.changeListeners) {
628
+ try {
629
+ l();
630
+ } catch (err) {
631
+ console.warn("[shipeasy] onChange listener threw:", String(err));
632
+ }
633
+ }
634
+ }
510
635
  startPoll() {
511
636
  this.timer = setInterval(() => {
512
- this.fetchAll().catch(
637
+ this.fetchAll(true).catch(
513
638
  (err) => console.warn("[shipeasy] background poll failed:", String(err))
514
639
  );
515
640
  }, this.pollInterval * 1e3);
516
641
  }
517
- async fetchAll() {
518
- const [interval] = await Promise.all([this.fetchFlags(), this.fetchExps()]);
642
+ async fetchAll(fromPoll = false) {
643
+ const [flagsRes, expsChanged] = await Promise.all([this.fetchFlags(), this.fetchExps()]);
644
+ const { interval, changed: flagsChanged } = flagsRes;
519
645
  if (interval !== null && interval !== this.pollInterval) {
520
646
  this.pollInterval = interval;
521
647
  if (this.timer !== null) {
@@ -523,41 +649,72 @@ var FlagsClient = class {
523
649
  this.startPoll();
524
650
  }
525
651
  }
652
+ if (fromPoll && (flagsChanged || expsChanged)) this.notifyChange();
526
653
  }
527
654
  async fetchFlags() {
528
655
  const headers = { "X-SDK-Key": this.apiKey };
529
656
  if (this.flagsEtag) headers["If-None-Match"] = this.flagsEtag;
530
657
  const res = await globalThis.fetch(`${this.baseUrl}/sdk/flags?env=${this.env}`, { headers });
531
658
  const interval = Number(res.headers.get("X-Poll-Interval") ?? "30") || 30;
532
- if (res.status === 304) return interval;
659
+ if (res.status === 304) return { interval, changed: false };
533
660
  if (!res.ok) throw new Error(`/sdk/flags returned ${res.status}`);
534
661
  const etag = res.headers.get("ETag");
535
662
  if (etag) this.flagsEtag = etag;
536
663
  this.flagsBlob = await res.json();
537
- return interval;
664
+ return { interval, changed: true };
538
665
  }
539
666
  async fetchExps() {
540
667
  const headers = { "X-SDK-Key": this.apiKey };
541
668
  if (this.expsEtag) headers["If-None-Match"] = this.expsEtag;
542
669
  const res = await globalThis.fetch(`${this.baseUrl}/sdk/experiments`, { headers });
543
- if (res.status === 304) return;
670
+ if (res.status === 304) return false;
544
671
  if (!res.ok) throw new Error(`/sdk/experiments returned ${res.status}`);
545
672
  const etag = res.headers.get("ETag");
546
673
  if (etag) this.expsEtag = etag;
547
674
  this.expsBlob = await res.json();
675
+ return true;
548
676
  }
549
- getFlag(name, user) {
677
+ /**
678
+ * Evaluate a gate and report WHY (LaunchDarkly variationDetail parity). The
679
+ * reason is computed entirely at this boundary — the canonical eval
680
+ * (evalGateInternal) is untouched. A local override short-circuits BEFORE
681
+ * telemetry, exactly like getFlag's override path; otherwise exactly one
682
+ * "gate" beacon is emitted.
683
+ */
684
+ getFlagDetail(name, user) {
685
+ const ov = this.flagOverrides.get(name);
686
+ if (ov !== void 0) return { value: ov, reason: "OVERRIDE" };
550
687
  this.telemetry.emit("gate", name);
551
- const gate = this.flagsBlob?.gates[name];
552
- if (!gate) return false;
553
- return evalGateInternal(gate, user);
688
+ if (!this.flagsBlob) return { value: false, reason: "CLIENT_NOT_READY" };
689
+ const gate = this.flagsBlob.gates[name];
690
+ if (!gate) return { value: false, reason: "FLAG_NOT_FOUND" };
691
+ if (isEnabled(gate.killswitch) || !isEnabled(gate.enabled)) {
692
+ return { value: false, reason: "OFF" };
693
+ }
694
+ const value = evalGateInternal(gate, user);
695
+ return { value, reason: value ? "RULE_MATCH" : "DEFAULT" };
696
+ }
697
+ /**
698
+ * Read a feature gate. Returns `defaultValue` ONLY when the gate cannot be
699
+ * evaluated (client not initialized or flag not found) — never for a gate that
700
+ * legitimately evaluates to false. Plain `getFlag(name, user)` keeps returning
701
+ * false for a missing flag.
702
+ */
703
+ getFlag(name, user, defaultValue = false) {
704
+ const d = this.getFlagDetail(name, user);
705
+ if (d.reason === "CLIENT_NOT_READY" || d.reason === "FLAG_NOT_FOUND") return defaultValue;
706
+ return d.value;
554
707
  }
555
- getConfig(name, decode) {
708
+ getConfig(name, decodeOrOpts) {
556
709
  this.telemetry.emit("config", name);
557
- const entry = this.flagsBlob?.configs[name];
558
- if (!entry) return void 0;
559
- if (!decode) return entry.value;
560
- return decode(entry.value);
710
+ const opts = typeof decodeOrOpts === "function" ? { decode: decodeOrOpts } : decodeOrOpts ?? {};
711
+ const has = this.configOverrides.has(name);
712
+ const raw = has ? this.configOverrides.get(name) : this.flagsBlob?.configs[name]?.value;
713
+ if (raw === void 0 && !has) {
714
+ return "defaultValue" in opts ? opts.defaultValue : void 0;
715
+ }
716
+ if (!opts.decode) return raw;
717
+ return opts.decode(raw);
561
718
  }
562
719
  getExperiment(name, user, defaultParams, decode) {
563
720
  this.telemetry.emit("experiment", name);
@@ -566,6 +723,16 @@ var FlagsClient = class {
566
723
  group: "control",
567
724
  params: defaultParams
568
725
  };
726
+ const ov = this.experimentOverrides.get(name);
727
+ if (ov) {
728
+ if (!decode) return { inExperiment: true, group: ov.group, params: ov.params };
729
+ try {
730
+ return { inExperiment: true, group: ov.group, params: decode(ov.params) };
731
+ } catch (err) {
732
+ console.warn(`[shipeasy] getExperiment('${name}') override decode failed:`, String(err));
733
+ return notIn;
734
+ }
735
+ }
569
736
  if (!this.flagsBlob || !this.expsBlob) return notIn;
570
737
  const exp = this.expsBlob.experiments[name];
571
738
  if (!exp || exp.status !== "running") return notIn;
@@ -603,6 +770,7 @@ var FlagsClient = class {
603
770
  return notIn;
604
771
  }
605
772
  track(userId, eventName, props) {
773
+ if (this.testMode) return;
606
774
  const body = JSON.stringify({
607
775
  events: [
608
776
  {
@@ -937,11 +1105,15 @@ var flags = {
937
1105
  destroy() {
938
1106
  _server?.destroy();
939
1107
  },
940
- get(name, user) {
941
- return _server?.getFlag(name, user) ?? false;
1108
+ get(name, user, defaultValue = false) {
1109
+ return _server?.getFlag(name, user, defaultValue) ?? defaultValue;
1110
+ },
1111
+ /** Evaluate a gate and report why (value + reason). See {@link FlagDetail}. */
1112
+ getDetail(name, user) {
1113
+ return _server?.getFlagDetail(name, user) ?? { value: false, reason: "CLIENT_NOT_READY" };
942
1114
  },
943
- getConfig(name, decode) {
944
- return _server?.getConfig(name, decode);
1115
+ getConfig(name, decodeOrOpts) {
1116
+ return _server?.getConfig(name, decodeOrOpts);
945
1117
  },
946
1118
  getExperiment(name, user, defaultParams, decode) {
947
1119
  return _server?.getExperiment(name, user, defaultParams, decode) ?? {
@@ -991,7 +1163,9 @@ var see = Object.assign(
991
1163
  );
992
1164
  export {
993
1165
  ANON_ID_COOKIE,
1166
+ FLAG_REASONS,
994
1167
  FlagsClient,
1168
+ _murmur3ForTests,
995
1169
  _resetShipeasyServerForTests,
996
1170
  configureShipeasyServer,
997
1171
  fetchLabelsForSSR,