@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.
@@ -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
 
@@ -81,25 +88,29 @@ function causesThe(subject) {
81
88
  };
82
89
  }
83
90
  function violation(name) {
84
- const make = (msg) => ({
85
- __seViolation: true,
86
- violationName: String(name),
87
- ...msg !== void 0 ? { violationMessage: msg } : {},
88
- message(m) {
89
- return make(String(m));
90
- }
91
- });
92
- return make();
91
+ return { __seViolation: true, violationName: String(name) };
93
92
  }
94
93
  function isViolation(p) {
95
94
  return typeof p === "object" && p !== null && p.__seViolation === true;
96
95
  }
97
96
  var EXPECTED_SYM = /* @__PURE__ */ Symbol.for("@shipeasy/sdk:see-expected");
98
- function markExpected(err, because) {
97
+ function readExpectedMark(err) {
98
+ if (typeof err !== "object" || err === null) return void 0;
99
+ const v = err[EXPECTED_SYM];
100
+ return v !== void 0 && v !== null && typeof v === "object" ? v : void 0;
101
+ }
102
+ function markExpected(err, because, extras) {
99
103
  if (typeof err !== "object" || err === null) return;
104
+ const prev = readExpectedMark(err);
105
+ const clean = sanitizeExtras(extras);
106
+ const merged = prev?.extras || clean ? { ...prev?.extras, ...clean } : void 0;
107
+ const mark = {
108
+ because: String(because),
109
+ ...merged ? { extras: merged } : {}
110
+ };
100
111
  try {
101
112
  Object.defineProperty(err, EXPECTED_SYM, {
102
- value: String(because),
113
+ value: mark,
103
114
  enumerable: false,
104
115
  configurable: true
105
116
  });
@@ -180,7 +191,7 @@ function buildSeeEvent(problem, consequence, extras, ctx, kindOverride, correlat
180
191
  let kind;
181
192
  if (isViolation(problem)) {
182
193
  errorType = problem.violationName;
183
- message = problem.violationMessage ?? problem.violationName;
194
+ message = problem.violationName;
184
195
  stack = captureCallsiteStack();
185
196
  kind = kindOverride ?? "violation";
186
197
  } else if (problem instanceof Error) {
@@ -263,19 +274,21 @@ function startSeeChain(getProblem, dispatch) {
263
274
  return { causes_the: start, causesThe: start };
264
275
  }
265
276
  function startSeeViolationChain(name, dispatch) {
266
- let msg;
267
- const base = startSeeChain(
268
- () => msg !== void 0 ? violation(name).message(msg) : violation(name),
269
- dispatch
270
- );
271
- const chain = {
272
- ...base,
273
- message(m) {
274
- msg = String(m);
275
- return chain;
277
+ return startSeeChain(() => violation(name), dispatch);
278
+ }
279
+ function startControlFlowChain(err) {
280
+ return {
281
+ because(reason) {
282
+ markExpected(err, reason);
283
+ const tail = {
284
+ extras(x) {
285
+ markExpected(err, reason, x);
286
+ return tail;
287
+ }
288
+ };
289
+ return tail;
276
290
  }
277
291
  };
278
- return chain;
279
292
  }
280
293
  function topStackLine(stack) {
281
294
  if (!stack) return "";
@@ -309,6 +322,9 @@ var SeeLimiter = class {
309
322
  var version = "4.0.0";
310
323
  var C1 = 3432918353;
311
324
  var C2 = 461845907;
325
+ function _murmur3ForTests(key) {
326
+ return murmur3(key);
327
+ }
312
328
  function murmur3(key) {
313
329
  const bytes = new TextEncoder().encode(key);
314
330
  const len = bytes.length;
@@ -349,6 +365,14 @@ function murmur3(key) {
349
365
  h1 ^= h1 >>> 16;
350
366
  return h1 >>> 0;
351
367
  }
368
+ var FLAG_REASONS = [
369
+ "CLIENT_NOT_READY",
370
+ "FLAG_NOT_FOUND",
371
+ "OFF",
372
+ "OVERRIDE",
373
+ "RULE_MATCH",
374
+ "DEFAULT"
375
+ ];
352
376
  function isEnabled(v) {
353
377
  return v === 1 || v === true;
354
378
  }
@@ -456,7 +480,7 @@ function parseOverrides(rawUrl) {
456
480
  }
457
481
  return { gates, configs, experiments };
458
482
  }
459
- var FlagsClient = class {
483
+ var FlagsClient = class _FlagsClient {
460
484
  apiKey;
461
485
  baseUrl;
462
486
  env;
@@ -469,47 +493,155 @@ var FlagsClient = class {
469
493
  pollInterval = 30;
470
494
  timer = null;
471
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();
472
509
  constructor(opts) {
473
510
  this.apiKey = opts.apiKey;
474
511
  this.baseUrl = (opts.baseUrl ?? "https://cdn.shipeasy.ai").replace(/\/$/, "");
475
512
  this.env = opts.env ?? "prod";
513
+ this.testMode = opts.testMode === true;
476
514
  this.telemetry = new Telemetry({
477
515
  endpoint: opts.telemetryUrl ?? DEFAULT_TELEMETRY_URL,
478
516
  sdkKey: this.apiKey,
479
517
  side: "server",
480
518
  env: this.env,
481
- disabled: opts.disableTelemetry
519
+ // Test mode never talks to the network — telemetry off regardless of opt.
520
+ disabled: this.testMode || opts.disableTelemetry
482
521
  });
483
- if (opts.initialBlob) {
484
- 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: {} };
485
525
  this.initialized = true;
486
526
  }
487
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
+ }
488
572
  async init() {
573
+ if (this.testMode) {
574
+ this.initialized = true;
575
+ return;
576
+ }
489
577
  await this.fetchAll();
490
578
  this.initialized = true;
491
579
  this.startPoll();
492
580
  }
493
581
  async initOnce() {
494
- if (this.initialized) return;
582
+ if (this.testMode || this.initialized) return;
495
583
  await this.fetchAll();
496
584
  this.initialized = true;
497
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
+ }
498
608
  destroy() {
499
609
  if (this.timer !== null) {
500
610
  clearInterval(this.timer);
501
611
  this.timer = null;
502
612
  }
503
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
+ }
504
635
  startPoll() {
505
636
  this.timer = setInterval(() => {
506
- this.fetchAll().catch(
637
+ this.fetchAll(true).catch(
507
638
  (err) => console.warn("[shipeasy] background poll failed:", String(err))
508
639
  );
509
640
  }, this.pollInterval * 1e3);
510
641
  }
511
- async fetchAll() {
512
- 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;
513
645
  if (interval !== null && interval !== this.pollInterval) {
514
646
  this.pollInterval = interval;
515
647
  if (this.timer !== null) {
@@ -517,41 +649,72 @@ var FlagsClient = class {
517
649
  this.startPoll();
518
650
  }
519
651
  }
652
+ if (fromPoll && (flagsChanged || expsChanged)) this.notifyChange();
520
653
  }
521
654
  async fetchFlags() {
522
655
  const headers = { "X-SDK-Key": this.apiKey };
523
656
  if (this.flagsEtag) headers["If-None-Match"] = this.flagsEtag;
524
657
  const res = await globalThis.fetch(`${this.baseUrl}/sdk/flags?env=${this.env}`, { headers });
525
658
  const interval = Number(res.headers.get("X-Poll-Interval") ?? "30") || 30;
526
- if (res.status === 304) return interval;
659
+ if (res.status === 304) return { interval, changed: false };
527
660
  if (!res.ok) throw new Error(`/sdk/flags returned ${res.status}`);
528
661
  const etag = res.headers.get("ETag");
529
662
  if (etag) this.flagsEtag = etag;
530
663
  this.flagsBlob = await res.json();
531
- return interval;
664
+ return { interval, changed: true };
532
665
  }
533
666
  async fetchExps() {
534
667
  const headers = { "X-SDK-Key": this.apiKey };
535
668
  if (this.expsEtag) headers["If-None-Match"] = this.expsEtag;
536
669
  const res = await globalThis.fetch(`${this.baseUrl}/sdk/experiments`, { headers });
537
- if (res.status === 304) return;
670
+ if (res.status === 304) return false;
538
671
  if (!res.ok) throw new Error(`/sdk/experiments returned ${res.status}`);
539
672
  const etag = res.headers.get("ETag");
540
673
  if (etag) this.expsEtag = etag;
541
674
  this.expsBlob = await res.json();
675
+ return true;
542
676
  }
543
- 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" };
544
687
  this.telemetry.emit("gate", name);
545
- const gate = this.flagsBlob?.gates[name];
546
- if (!gate) return false;
547
- 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" };
548
696
  }
549
- getConfig(name, decode) {
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;
707
+ }
708
+ getConfig(name, decodeOrOpts) {
550
709
  this.telemetry.emit("config", name);
551
- const entry = this.flagsBlob?.configs[name];
552
- if (!entry) return void 0;
553
- if (!decode) return entry.value;
554
- 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);
555
718
  }
556
719
  getExperiment(name, user, defaultParams, decode) {
557
720
  this.telemetry.emit("experiment", name);
@@ -560,6 +723,16 @@ var FlagsClient = class {
560
723
  group: "control",
561
724
  params: defaultParams
562
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
+ }
563
736
  if (!this.flagsBlob || !this.expsBlob) return notIn;
564
737
  const exp = this.expsBlob.experiments[name];
565
738
  if (!exp || exp.status !== "running") return notIn;
@@ -597,6 +770,7 @@ var FlagsClient = class {
597
770
  return notIn;
598
771
  }
599
772
  track(userId, eventName, props) {
773
+ if (this.testMode) return;
600
774
  const body = JSON.stringify({
601
775
  events: [
602
776
  {
@@ -931,11 +1105,15 @@ var flags = {
931
1105
  destroy() {
932
1106
  _server?.destroy();
933
1107
  },
934
- get(name, user) {
935
- 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" };
936
1114
  },
937
- getConfig(name, decode) {
938
- return _server?.getConfig(name, decode);
1115
+ getConfig(name, decodeOrOpts) {
1116
+ return _server?.getConfig(name, decodeOrOpts);
939
1117
  },
940
1118
  getExperiment(name, user, defaultParams, decode) {
941
1119
  return _server?.getExperiment(name, user, defaultParams, decode) ?? {
@@ -980,12 +1158,14 @@ var see = Object.assign(
980
1158
  (problem) => startSeeChain(() => problem, dispatchSee),
981
1159
  {
982
1160
  Violation: (name) => startSeeViolationChain(name, dispatchSee),
983
- ControlFlowException: markExpected
1161
+ ControlFlowException: (err) => startControlFlowChain(err)
984
1162
  }
985
1163
  );
986
1164
  export {
987
1165
  ANON_ID_COOKIE,
1166
+ FLAG_REASONS,
988
1167
  FlagsClient,
1168
+ _murmur3ForTests,
989
1169
  _resetShipeasyServerForTests,
990
1170
  configureShipeasyServer,
991
1171
  fetchLabelsForSSR,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@shipeasy/sdk",
3
- "version": "4.5.0",
3
+ "version": "5.1.0",
4
4
  "description": "Shipeasy SDK — feature gates, runtime configs, experiments, and metrics for the Shipeasy hosted service.",
5
5
  "license": "SEE LICENSE IN LICENSE",
6
6
  "homepage": "https://shipeasy.ai",