@shipeasy/sdk 4.5.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,
@@ -128,25 +130,29 @@ function causesThe(subject) {
128
130
  };
129
131
  }
130
132
  function violation(name) {
131
- const make = (msg) => ({
132
- __seViolation: true,
133
- violationName: String(name),
134
- ...msg !== void 0 ? { violationMessage: msg } : {},
135
- message(m) {
136
- return make(String(m));
137
- }
138
- });
139
- return make();
133
+ return { __seViolation: true, violationName: String(name) };
140
134
  }
141
135
  function isViolation(p) {
142
136
  return typeof p === "object" && p !== null && p.__seViolation === true;
143
137
  }
144
138
  var EXPECTED_SYM = /* @__PURE__ */ Symbol.for("@shipeasy/sdk:see-expected");
145
- function markExpected(err, because) {
139
+ function readExpectedMark(err) {
140
+ if (typeof err !== "object" || err === null) return void 0;
141
+ const v = err[EXPECTED_SYM];
142
+ return v !== void 0 && v !== null && typeof v === "object" ? v : void 0;
143
+ }
144
+ function markExpected(err, because, extras) {
146
145
  if (typeof err !== "object" || err === null) return;
146
+ const prev = readExpectedMark(err);
147
+ const clean = sanitizeExtras(extras);
148
+ const merged = prev?.extras || clean ? { ...prev?.extras, ...clean } : void 0;
149
+ const mark = {
150
+ because: String(because),
151
+ ...merged ? { extras: merged } : {}
152
+ };
147
153
  try {
148
154
  Object.defineProperty(err, EXPECTED_SYM, {
149
- value: String(because),
155
+ value: mark,
150
156
  enumerable: false,
151
157
  configurable: true
152
158
  });
@@ -227,7 +233,7 @@ function buildSeeEvent(problem, consequence, extras, ctx, kindOverride, correlat
227
233
  let kind;
228
234
  if (isViolation(problem)) {
229
235
  errorType = problem.violationName;
230
- message = problem.violationMessage ?? problem.violationName;
236
+ message = problem.violationName;
231
237
  stack = captureCallsiteStack();
232
238
  kind = kindOverride ?? "violation";
233
239
  } else if (problem instanceof Error) {
@@ -310,19 +316,21 @@ function startSeeChain(getProblem, dispatch) {
310
316
  return { causes_the: start, causesThe: start };
311
317
  }
312
318
  function startSeeViolationChain(name, dispatch) {
313
- let msg;
314
- const base = startSeeChain(
315
- () => msg !== void 0 ? violation(name).message(msg) : violation(name),
316
- dispatch
317
- );
318
- const chain = {
319
- ...base,
320
- message(m) {
321
- msg = String(m);
322
- return chain;
319
+ return startSeeChain(() => violation(name), dispatch);
320
+ }
321
+ function startControlFlowChain(err) {
322
+ return {
323
+ because(reason) {
324
+ markExpected(err, reason);
325
+ const tail = {
326
+ extras(x) {
327
+ markExpected(err, reason, x);
328
+ return tail;
329
+ }
330
+ };
331
+ return tail;
323
332
  }
324
333
  };
325
- return chain;
326
334
  }
327
335
  function topStackLine(stack) {
328
336
  if (!stack) return "";
@@ -356,6 +364,9 @@ var SeeLimiter = class {
356
364
  var version = "4.0.0";
357
365
  var C1 = 3432918353;
358
366
  var C2 = 461845907;
367
+ function _murmur3ForTests(key) {
368
+ return murmur3(key);
369
+ }
359
370
  function murmur3(key) {
360
371
  const bytes = new TextEncoder().encode(key);
361
372
  const len = bytes.length;
@@ -396,6 +407,14 @@ function murmur3(key) {
396
407
  h1 ^= h1 >>> 16;
397
408
  return h1 >>> 0;
398
409
  }
410
+ var FLAG_REASONS = [
411
+ "CLIENT_NOT_READY",
412
+ "FLAG_NOT_FOUND",
413
+ "OFF",
414
+ "OVERRIDE",
415
+ "RULE_MATCH",
416
+ "DEFAULT"
417
+ ];
399
418
  function isEnabled(v) {
400
419
  return v === 1 || v === true;
401
420
  }
@@ -503,7 +522,7 @@ function parseOverrides(rawUrl) {
503
522
  }
504
523
  return { gates, configs, experiments };
505
524
  }
506
- var FlagsClient = class {
525
+ var FlagsClient = class _FlagsClient {
507
526
  apiKey;
508
527
  baseUrl;
509
528
  env;
@@ -516,47 +535,155 @@ var FlagsClient = class {
516
535
  pollInterval = 30;
517
536
  timer = null;
518
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();
519
551
  constructor(opts) {
520
552
  this.apiKey = opts.apiKey;
521
553
  this.baseUrl = (opts.baseUrl ?? "https://cdn.shipeasy.ai").replace(/\/$/, "");
522
554
  this.env = opts.env ?? "prod";
555
+ this.testMode = opts.testMode === true;
523
556
  this.telemetry = new Telemetry({
524
557
  endpoint: opts.telemetryUrl ?? DEFAULT_TELEMETRY_URL,
525
558
  sdkKey: this.apiKey,
526
559
  side: "server",
527
560
  env: this.env,
528
- disabled: opts.disableTelemetry
561
+ // Test mode never talks to the network — telemetry off regardless of opt.
562
+ disabled: this.testMode || opts.disableTelemetry
529
563
  });
530
- if (opts.initialBlob) {
531
- 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: {} };
532
567
  this.initialized = true;
533
568
  }
534
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
+ }
535
614
  async init() {
615
+ if (this.testMode) {
616
+ this.initialized = true;
617
+ return;
618
+ }
536
619
  await this.fetchAll();
537
620
  this.initialized = true;
538
621
  this.startPoll();
539
622
  }
540
623
  async initOnce() {
541
- if (this.initialized) return;
624
+ if (this.testMode || this.initialized) return;
542
625
  await this.fetchAll();
543
626
  this.initialized = true;
544
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
+ }
545
650
  destroy() {
546
651
  if (this.timer !== null) {
547
652
  clearInterval(this.timer);
548
653
  this.timer = null;
549
654
  }
550
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
+ }
551
677
  startPoll() {
552
678
  this.timer = setInterval(() => {
553
- this.fetchAll().catch(
679
+ this.fetchAll(true).catch(
554
680
  (err) => console.warn("[shipeasy] background poll failed:", String(err))
555
681
  );
556
682
  }, this.pollInterval * 1e3);
557
683
  }
558
- async fetchAll() {
559
- 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;
560
687
  if (interval !== null && interval !== this.pollInterval) {
561
688
  this.pollInterval = interval;
562
689
  if (this.timer !== null) {
@@ -564,41 +691,72 @@ var FlagsClient = class {
564
691
  this.startPoll();
565
692
  }
566
693
  }
694
+ if (fromPoll && (flagsChanged || expsChanged)) this.notifyChange();
567
695
  }
568
696
  async fetchFlags() {
569
697
  const headers = { "X-SDK-Key": this.apiKey };
570
698
  if (this.flagsEtag) headers["If-None-Match"] = this.flagsEtag;
571
699
  const res = await globalThis.fetch(`${this.baseUrl}/sdk/flags?env=${this.env}`, { headers });
572
700
  const interval = Number(res.headers.get("X-Poll-Interval") ?? "30") || 30;
573
- if (res.status === 304) return interval;
701
+ if (res.status === 304) return { interval, changed: false };
574
702
  if (!res.ok) throw new Error(`/sdk/flags returned ${res.status}`);
575
703
  const etag = res.headers.get("ETag");
576
704
  if (etag) this.flagsEtag = etag;
577
705
  this.flagsBlob = await res.json();
578
- return interval;
706
+ return { interval, changed: true };
579
707
  }
580
708
  async fetchExps() {
581
709
  const headers = { "X-SDK-Key": this.apiKey };
582
710
  if (this.expsEtag) headers["If-None-Match"] = this.expsEtag;
583
711
  const res = await globalThis.fetch(`${this.baseUrl}/sdk/experiments`, { headers });
584
- if (res.status === 304) return;
712
+ if (res.status === 304) return false;
585
713
  if (!res.ok) throw new Error(`/sdk/experiments returned ${res.status}`);
586
714
  const etag = res.headers.get("ETag");
587
715
  if (etag) this.expsEtag = etag;
588
716
  this.expsBlob = await res.json();
717
+ return true;
589
718
  }
590
- 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" };
591
729
  this.telemetry.emit("gate", name);
592
- const gate = this.flagsBlob?.gates[name];
593
- if (!gate) return false;
594
- 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" };
595
738
  }
596
- getConfig(name, decode) {
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;
749
+ }
750
+ getConfig(name, decodeOrOpts) {
597
751
  this.telemetry.emit("config", name);
598
- const entry = this.flagsBlob?.configs[name];
599
- if (!entry) return void 0;
600
- if (!decode) return entry.value;
601
- 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);
602
760
  }
603
761
  getExperiment(name, user, defaultParams, decode) {
604
762
  this.telemetry.emit("experiment", name);
@@ -607,6 +765,16 @@ var FlagsClient = class {
607
765
  group: "control",
608
766
  params: defaultParams
609
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
+ }
610
778
  if (!this.flagsBlob || !this.expsBlob) return notIn;
611
779
  const exp = this.expsBlob.experiments[name];
612
780
  if (!exp || exp.status !== "running") return notIn;
@@ -644,6 +812,7 @@ var FlagsClient = class {
644
812
  return notIn;
645
813
  }
646
814
  track(userId, eventName, props) {
815
+ if (this.testMode) return;
647
816
  const body = JSON.stringify({
648
817
  events: [
649
818
  {
@@ -978,11 +1147,15 @@ var flags = {
978
1147
  destroy() {
979
1148
  _server?.destroy();
980
1149
  },
981
- get(name, user) {
982
- 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" };
983
1156
  },
984
- getConfig(name, decode) {
985
- return _server?.getConfig(name, decode);
1157
+ getConfig(name, decodeOrOpts) {
1158
+ return _server?.getConfig(name, decodeOrOpts);
986
1159
  },
987
1160
  getExperiment(name, user, defaultParams, decode) {
988
1161
  return _server?.getExperiment(name, user, defaultParams, decode) ?? {
@@ -1027,13 +1200,15 @@ var see = Object.assign(
1027
1200
  (problem) => startSeeChain(() => problem, dispatchSee),
1028
1201
  {
1029
1202
  Violation: (name) => startSeeViolationChain(name, dispatchSee),
1030
- ControlFlowException: markExpected
1203
+ ControlFlowException: (err) => startControlFlowChain(err)
1031
1204
  }
1032
1205
  );
1033
1206
  // Annotate the CommonJS export names for ESM import in node:
1034
1207
  0 && (module.exports = {
1035
1208
  ANON_ID_COOKIE,
1209
+ FLAG_REASONS,
1036
1210
  FlagsClient,
1211
+ _murmur3ForTests,
1037
1212
  _resetShipeasyServerForTests,
1038
1213
  configureShipeasyServer,
1039
1214
  fetchLabelsForSSR,