@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.
@@ -20,6 +20,7 @@ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: tru
20
20
  // src/client/index.ts
21
21
  var client_exports = {};
22
22
  __export(client_exports, {
23
+ FLAG_REASONS: () => FLAG_REASONS,
23
24
  FlagsClientBrowser: () => FlagsClientBrowser,
24
25
  LABEL_MARKER_END: () => LABEL_MARKER_END,
25
26
  LABEL_MARKER_RE: () => LABEL_MARKER_RE,
@@ -126,25 +127,29 @@ function causesThe(subject) {
126
127
  };
127
128
  }
128
129
  function violation(name) {
129
- const make = (msg) => ({
130
- __seViolation: true,
131
- violationName: String(name),
132
- ...msg !== void 0 ? { violationMessage: msg } : {},
133
- message(m) {
134
- return make(String(m));
135
- }
136
- });
137
- return make();
130
+ return { __seViolation: true, violationName: String(name) };
138
131
  }
139
132
  function isViolation(p) {
140
133
  return typeof p === "object" && p !== null && p.__seViolation === true;
141
134
  }
142
135
  var EXPECTED_SYM = /* @__PURE__ */ Symbol.for("@shipeasy/sdk:see-expected");
143
- function markExpected(err, because) {
136
+ function readExpectedMark(err) {
137
+ if (typeof err !== "object" || err === null) return void 0;
138
+ const v = err[EXPECTED_SYM];
139
+ return v !== void 0 && v !== null && typeof v === "object" ? v : void 0;
140
+ }
141
+ function markExpected(err, because, extras) {
144
142
  if (typeof err !== "object" || err === null) return;
143
+ const prev = readExpectedMark(err);
144
+ const clean = sanitizeExtras(extras);
145
+ const merged = prev?.extras || clean ? { ...prev?.extras, ...clean } : void 0;
146
+ const mark = {
147
+ because: String(because),
148
+ ...merged ? { extras: merged } : {}
149
+ };
145
150
  try {
146
151
  Object.defineProperty(err, EXPECTED_SYM, {
147
- value: String(because),
152
+ value: mark,
148
153
  enumerable: false,
149
154
  configurable: true
150
155
  });
@@ -225,7 +230,7 @@ function buildSeeEvent(problem, consequence, extras, ctx, kindOverride, correlat
225
230
  let kind;
226
231
  if (isViolation(problem)) {
227
232
  errorType = problem.violationName;
228
- message = problem.violationMessage ?? problem.violationName;
233
+ message = problem.violationName;
229
234
  stack = captureCallsiteStack();
230
235
  kind = kindOverride ?? "violation";
231
236
  } else if (problem instanceof Error) {
@@ -308,19 +313,21 @@ function startSeeChain(getProblem, dispatch) {
308
313
  return { causes_the: start, causesThe: start };
309
314
  }
310
315
  function startSeeViolationChain(name, dispatch) {
311
- let msg;
312
- const base = startSeeChain(
313
- () => msg !== void 0 ? violation(name).message(msg) : violation(name),
314
- dispatch
315
- );
316
- const chain = {
317
- ...base,
318
- message(m) {
319
- msg = String(m);
320
- return chain;
316
+ return startSeeChain(() => violation(name), dispatch);
317
+ }
318
+ function startControlFlowChain(err) {
319
+ return {
320
+ because(reason) {
321
+ markExpected(err, reason);
322
+ const tail = {
323
+ extras(x) {
324
+ markExpected(err, reason, x);
325
+ return tail;
326
+ }
327
+ };
328
+ return tail;
321
329
  }
322
330
  };
323
- return chain;
324
331
  }
325
332
  function topStackLine(stack) {
326
333
  if (!stack) return "";
@@ -352,6 +359,14 @@ var SeeLimiter = class {
352
359
 
353
360
  // src/client/index.ts
354
361
  var version = "4.0.0";
362
+ var FLAG_REASONS = [
363
+ "CLIENT_NOT_READY",
364
+ "FLAG_NOT_FOUND",
365
+ "OFF",
366
+ "OVERRIDE",
367
+ "RULE_MATCH",
368
+ "DEFAULT"
369
+ ];
355
370
  var FLUSH_INTERVAL_MS = 5e3;
356
371
  var MAX_BUFFER = 100;
357
372
  var ANON_ID_KEY = "__se_anon_id";
@@ -575,39 +590,11 @@ function installAutoGuardrails(buffer, userId, anonId, groups, reportSee, ignore
575
590
  }
576
591
  }
577
592
  if (groups.errors) {
578
- const origOnError = window.onerror;
579
- window.onerror = (msg, source, lineno, _colno, err) => {
580
- if (!isExpected(err)) {
581
- const problem = err ?? (typeof msg === "string" && msg ? msg : "Unknown error");
582
- reportSee(
583
- problem,
584
- causesThe("page").to("hit an unhandled error"),
585
- {
586
- source: typeof source === "string" ? source : void 0,
587
- line: lineno ?? void 0
588
- },
589
- "uncaught"
590
- );
591
- }
592
- if (typeof origOnError === "function") return origOnError(msg, source, lineno, _colno, err);
593
- return false;
594
- };
595
- window.addEventListener("unhandledrejection", (e) => {
596
- const reason = e.reason;
597
- if (isExpected(reason)) return;
598
- reportSee(
599
- reason ?? "Unhandled promise rejection",
600
- causesThe("page").to("hit an unhandled promise rejection"),
601
- void 0,
602
- "unhandled_rejection"
603
- );
604
- });
605
593
  const origFetch = window.fetch;
606
594
  window.fetch = async function(...args) {
607
595
  const startedAt = typeof performance !== "undefined" ? performance.now() : 0;
608
596
  const url = typeof args[0] === "string" ? args[0] : args[0].toString();
609
597
  const ignored = ignoreUrlPrefixes.some((p) => p && url.startsWith(p));
610
- const bareUrl = url.split("?")[0].slice(0, 200);
611
598
  let corr;
612
599
  if (!ignored && sameOrigin(url) && typeof crypto !== "undefined" && crypto.randomUUID) {
613
600
  corr = crypto.randomUUID();
@@ -619,7 +606,7 @@ function installAutoGuardrails(buffer, userId, anonId, groups, reportSee, ignore
619
606
  } catch (err) {
620
607
  if (!ignored && !isExpected(err)) {
621
608
  reportSee(
622
- violation("NetworkError").message(`request to ${bareUrl} failed`),
609
+ violation("NetworkError"),
623
610
  causesThe(`request to ${endpointTemplate(url)}`).to("get no response"),
624
611
  { status: 0, url: url.slice(0, 200) },
625
612
  "network"
@@ -630,7 +617,7 @@ function installAutoGuardrails(buffer, userId, anonId, groups, reportSee, ignore
630
617
  if (!ignored && res.status >= 500) {
631
618
  const elapsed = typeof performance !== "undefined" ? performance.now() - startedAt : 0;
632
619
  reportSee(
633
- violation("Http5xx").message(`request to ${bareUrl} returned ${res.status}`),
620
+ violation("Http5xx"),
634
621
  causesThe(`request to ${endpointTemplate(url)}`).to("fail with a server error"),
635
622
  { status: res.status, url: url.slice(0, 200), duration_ms: Math.round(elapsed) },
636
623
  "network",
@@ -891,7 +878,7 @@ function readExperimentOverridesFromUrl() {
891
878
  }
892
879
  return out;
893
880
  }
894
- var FlagsClientBrowser = class {
881
+ var FlagsClientBrowser = class _FlagsClientBrowser {
895
882
  sdkKey;
896
883
  baseUrl;
897
884
  autoGuardrails;
@@ -910,6 +897,16 @@ var FlagsClientBrowser = class {
910
897
  // Monotonic counter so a later identify() always wins even if its /sdk/evaluate
911
898
  // response races and lands before an earlier in-flight call's response.
912
899
  identifySeq = 0;
900
+ // Test mode: built by `FlagsClientBrowser.forTesting()`. When set, identify()
901
+ // never fetches, track() is a no-op, telemetry is off, and the client is
902
+ // already ready with an empty eval result.
903
+ testMode;
904
+ // Programmatic overrides (Statsig-style). Set on any client via
905
+ // overrideFlag/overrideConfig/overrideExperiment; they win over BOTH the URL
906
+ // overrides and the fetched eval result. Cleared by clearOverrides().
907
+ flagOverrides = /* @__PURE__ */ new Map();
908
+ configOverrides = /* @__PURE__ */ new Map();
909
+ experimentOverrides = /* @__PURE__ */ new Map();
913
910
  onOverrideChange = () => {
914
911
  this.installBridge();
915
912
  this.notify();
@@ -918,6 +915,7 @@ var FlagsClientBrowser = class {
918
915
  this.sdkKey = opts.sdkKey;
919
916
  this.baseUrl = (opts.baseUrl ?? "https://edge.shipeasy.dev").replace(/\/$/, "");
920
917
  this.env = opts.env ?? "prod";
918
+ this.testMode = opts.testMode === true;
921
919
  this.autoGuardrails = opts.autoGuardrails !== false;
922
920
  this.autoCollectAlways = opts.autoCollectAlways === true;
923
921
  const g = opts.autoGuardrailGroups ?? {};
@@ -933,11 +931,42 @@ var FlagsClientBrowser = class {
933
931
  sdkKey: this.sdkKey,
934
932
  side: "client",
935
933
  env: this.env,
936
- disabled: opts.disableTelemetry
934
+ // Test mode never talks to the network — telemetry off regardless of opt.
935
+ disabled: this.testMode || opts.disableTelemetry
936
+ });
937
+ if (this.testMode) {
938
+ this.evalResult = { flags: {}, configs: {}, experiments: {}, killswitches: {} };
939
+ } else {
940
+ void this.buffer.flushPendingAlias();
941
+ }
942
+ }
943
+ /**
944
+ * Build a no-network, immediately-usable browser client for tests
945
+ * (Statsig-style). identify() is a no-op (never calls /sdk/evaluate), track()
946
+ * is a no-op, telemetry is disabled, and the client is already ready — seed
947
+ * every entity with overrideFlag/overrideConfig/overrideExperiment. No SDK
948
+ * key required.
949
+ *
950
+ * ```ts
951
+ * const client = FlagsClientBrowser.forTesting();
952
+ * client.overrideFlag("new_checkout", true);
953
+ * client.getFlag("new_checkout"); // true
954
+ * ```
955
+ */
956
+ static forTesting(opts) {
957
+ return new _FlagsClientBrowser({
958
+ sdkKey: "",
959
+ autoGuardrails: false,
960
+ ...opts,
961
+ testMode: true
937
962
  });
938
- void this.buffer.flushPendingAlias();
939
963
  }
940
964
  async identify(user) {
965
+ if (this.testMode) {
966
+ if (user.user_id !== void 0) this.userId = user.user_id;
967
+ this.notify();
968
+ return;
969
+ }
941
970
  const seq = ++this.identifySeq;
942
971
  const prevUserId = this.userId;
943
972
  if (user.user_id !== void 0) this.userId = user.user_id;
@@ -1012,22 +1041,75 @@ var FlagsClientBrowser = class {
1012
1041
  initFromBootstrap(data) {
1013
1042
  this.evalResult = data;
1014
1043
  }
1015
- getFlag(name) {
1044
+ // ---- Local overrides (Statsig-style) ----
1045
+ //
1046
+ // Precedence (highest first): programmatic override (these methods) > URL
1047
+ // override (?se_gate_/?se_config_/?se_exp_) > fetched eval result. A
1048
+ // programmatic override is an explicit in-code decision, so it wins over the
1049
+ // ad-hoc URL/devtools overrides as well as the server's evaluation.
1050
+ /** Force `getFlag(name)` to return `value`, ignoring URL overrides + eval. */
1051
+ overrideFlag(name, value) {
1052
+ this.flagOverrides.set(name, value);
1053
+ }
1054
+ /** Force `getConfig(name)` to return `value`, ignoring URL overrides + eval. */
1055
+ overrideConfig(name, value) {
1056
+ this.configOverrides.set(name, value);
1057
+ }
1058
+ /**
1059
+ * Force `getExperiment(name, …)` to return `{ inExperiment: true, group, params }`,
1060
+ * ignoring URL overrides, the eval result, and exposure logging.
1061
+ */
1062
+ overrideExperiment(name, group, params) {
1063
+ this.experimentOverrides.set(name, { group, params });
1064
+ }
1065
+ /** Remove every programmatic override set via the override* methods. */
1066
+ clearOverrides() {
1067
+ this.flagOverrides.clear();
1068
+ this.configOverrides.clear();
1069
+ this.experimentOverrides.clear();
1070
+ }
1071
+ /**
1072
+ * Evaluate a gate and report WHY (LaunchDarkly variationDetail parity). A
1073
+ * local override OR a ?se_gate_/?se_ks_ URL override short-circuits BEFORE
1074
+ * telemetry; otherwise exactly one "gate" beacon is emitted. The server
1075
+ * pre-evaluates a gate's enabled/killed state into a plain boolean, so OFF
1076
+ * folds into DEFAULT here (the browser can't tell "disabled" from "rule
1077
+ * denied").
1078
+ */
1079
+ getFlagDetail(name) {
1080
+ const pov = this.flagOverrides.get(name);
1081
+ if (pov !== void 0) return { value: pov, reason: "OVERRIDE" };
1082
+ const urlOv = readGateOverride(name);
1083
+ if (urlOv !== null) return { value: urlOv, reason: "OVERRIDE" };
1016
1084
  this.telemetry.emit("gate", name);
1017
- if (this.evalResult === null) return false;
1018
- const ov = readGateOverride(name);
1019
- if (ov !== null) return ov;
1020
- return this.evalResult.flags[name] ?? false;
1085
+ if (this.evalResult === null) return { value: false, reason: "CLIENT_NOT_READY" };
1086
+ if (!(name in this.evalResult.flags)) return { value: false, reason: "FLAG_NOT_FOUND" };
1087
+ const value = this.evalResult.flags[name] ?? false;
1088
+ return { value, reason: value ? "RULE_MATCH" : "DEFAULT" };
1021
1089
  }
1022
- getConfig(name, decode) {
1090
+ /**
1091
+ * Read a feature gate. Returns `defaultValue` ONLY when the gate cannot be
1092
+ * evaluated (not ready or flag not found) — never for a gate that legitimately
1093
+ * evaluates to false. Plain `getFlag(name)` keeps returning false for a
1094
+ * missing flag.
1095
+ */
1096
+ getFlag(name, defaultValue = false) {
1097
+ const d = this.getFlagDetail(name);
1098
+ if (d.reason === "CLIENT_NOT_READY" || d.reason === "FLAG_NOT_FOUND") return defaultValue;
1099
+ return d.value;
1100
+ }
1101
+ getConfig(name, decodeOrOpts) {
1023
1102
  this.telemetry.emit("config", name);
1024
- if (this.evalResult === null) return void 0;
1025
- const ov = readConfigOverride(name);
1026
- const raw = ov !== void 0 ? ov : this.evalResult.configs?.[name];
1027
- if (raw === void 0) return void 0;
1028
- if (!decode) return raw;
1103
+ const opts = typeof decodeOrOpts === "function" ? { decode: decodeOrOpts } : decodeOrOpts ?? {};
1104
+ const fallback = "defaultValue" in opts ? opts.defaultValue : void 0;
1105
+ const hasProgrammatic = this.configOverrides.has(name);
1106
+ if (!hasProgrammatic && this.evalResult === null) return fallback;
1107
+ const urlOv = hasProgrammatic ? void 0 : readConfigOverride(name);
1108
+ const raw = hasProgrammatic ? this.configOverrides.get(name) : urlOv !== void 0 ? urlOv : this.evalResult?.configs?.[name];
1109
+ if (raw === void 0) return fallback;
1110
+ if (!opts.decode) return raw;
1029
1111
  try {
1030
- return decode(raw);
1112
+ return opts.decode(raw);
1031
1113
  } catch (err) {
1032
1114
  console.warn(`[shipeasy] getConfig('${name}') decode failed:`, String(err));
1033
1115
  return void 0;
@@ -1040,6 +1122,12 @@ var FlagsClientBrowser = class {
1040
1122
  group: "control",
1041
1123
  params: defaultParams
1042
1124
  };
1125
+ const pov = this.experimentOverrides.get(name);
1126
+ if (pov) {
1127
+ const variantParams = variants?.[pov.group];
1128
+ const params = { ...defaultParams, ...pov.params, ...variantParams ?? {} };
1129
+ return { inExperiment: true, group: pov.group, params };
1130
+ }
1043
1131
  const ov = readExpOverride(name);
1044
1132
  if (ov !== null) {
1045
1133
  const variantParams = variants?.[ov];
@@ -1091,6 +1179,7 @@ var FlagsClientBrowser = class {
1091
1179
  return bridge;
1092
1180
  }
1093
1181
  track(eventName, props) {
1182
+ if (this.testMode) return;
1094
1183
  this.buffer.pushMetric(eventName, this.userId, this.anonId, props);
1095
1184
  }
1096
1185
  /**
@@ -1309,12 +1398,19 @@ var flags = {
1309
1398
  * Everything else gates on _mountedAndReady to prevent hydration mismatches on
1310
1399
  * force-static pages where SSR has no flag data.
1311
1400
  */
1312
- get(name) {
1401
+ get(name, defaultValue = false) {
1313
1402
  const bs = getBootstrap();
1314
1403
  if (bs !== null && name in bs.flags) return bs.flags[name];
1315
- if (!_mountedAndReady) return false;
1316
- if (_client) return _client.getFlag(name);
1317
- return readGateOverride(name) ?? false;
1404
+ if (!_mountedAndReady) return defaultValue;
1405
+ if (_client) return _client.getFlag(name, defaultValue);
1406
+ return readGateOverride(name) ?? defaultValue;
1407
+ },
1408
+ /** Evaluate a gate and report why (value + reason). See {@link FlagDetail}. */
1409
+ getDetail(name) {
1410
+ if (_client) return _client.getFlagDetail(name);
1411
+ const ov = readGateOverride(name);
1412
+ if (ov !== null) return { value: ov, reason: "OVERRIDE" };
1413
+ return { value: false, reason: "CLIENT_NOT_READY" };
1318
1414
  },
1319
1415
  getConfig(name, decode) {
1320
1416
  const bs = getBootstrap();
@@ -1409,7 +1505,7 @@ var see = Object.assign(
1409
1505
  (problem) => startSeeChain(() => problem, dispatchSee),
1410
1506
  {
1411
1507
  Violation: (name) => startSeeViolationChain(name, dispatchSee),
1412
- ControlFlowException: markExpected
1508
+ ControlFlowException: (err) => startControlFlowChain(err)
1413
1509
  }
1414
1510
  );
1415
1511
  var LABEL_MARKER_START = "\uFFF9";
@@ -1646,6 +1742,7 @@ var i18n = {
1646
1742
  };
1647
1743
  // Annotate the CommonJS export names for ESM import in node:
1648
1744
  0 && (module.exports = {
1745
+ FLAG_REASONS,
1649
1746
  FlagsClientBrowser,
1650
1747
  LABEL_MARKER_END,
1651
1748
  LABEL_MARKER_RE,