@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.
@@ -78,25 +78,29 @@ function causesThe(subject) {
78
78
  };
79
79
  }
80
80
  function violation(name) {
81
- const make = (msg) => ({
82
- __seViolation: true,
83
- violationName: String(name),
84
- ...msg !== void 0 ? { violationMessage: msg } : {},
85
- message(m) {
86
- return make(String(m));
87
- }
88
- });
89
- return make();
81
+ return { __seViolation: true, violationName: String(name) };
90
82
  }
91
83
  function isViolation(p) {
92
84
  return typeof p === "object" && p !== null && p.__seViolation === true;
93
85
  }
94
86
  var EXPECTED_SYM = /* @__PURE__ */ Symbol.for("@shipeasy/sdk:see-expected");
95
- function markExpected(err, because) {
87
+ function readExpectedMark(err) {
88
+ if (typeof err !== "object" || err === null) return void 0;
89
+ const v = err[EXPECTED_SYM];
90
+ return v !== void 0 && v !== null && typeof v === "object" ? v : void 0;
91
+ }
92
+ function markExpected(err, because, extras) {
96
93
  if (typeof err !== "object" || err === null) return;
94
+ const prev = readExpectedMark(err);
95
+ const clean = sanitizeExtras(extras);
96
+ const merged = prev?.extras || clean ? { ...prev?.extras, ...clean } : void 0;
97
+ const mark = {
98
+ because: String(because),
99
+ ...merged ? { extras: merged } : {}
100
+ };
97
101
  try {
98
102
  Object.defineProperty(err, EXPECTED_SYM, {
99
- value: String(because),
103
+ value: mark,
100
104
  enumerable: false,
101
105
  configurable: true
102
106
  });
@@ -177,7 +181,7 @@ function buildSeeEvent(problem, consequence, extras, ctx, kindOverride, correlat
177
181
  let kind;
178
182
  if (isViolation(problem)) {
179
183
  errorType = problem.violationName;
180
- message = problem.violationMessage ?? problem.violationName;
184
+ message = problem.violationName;
181
185
  stack = captureCallsiteStack();
182
186
  kind = kindOverride ?? "violation";
183
187
  } else if (problem instanceof Error) {
@@ -260,19 +264,21 @@ function startSeeChain(getProblem, dispatch) {
260
264
  return { causes_the: start, causesThe: start };
261
265
  }
262
266
  function startSeeViolationChain(name, dispatch) {
263
- let msg;
264
- const base = startSeeChain(
265
- () => msg !== void 0 ? violation(name).message(msg) : violation(name),
266
- dispatch
267
- );
268
- const chain = {
269
- ...base,
270
- message(m) {
271
- msg = String(m);
272
- return chain;
267
+ return startSeeChain(() => violation(name), dispatch);
268
+ }
269
+ function startControlFlowChain(err) {
270
+ return {
271
+ because(reason) {
272
+ markExpected(err, reason);
273
+ const tail = {
274
+ extras(x) {
275
+ markExpected(err, reason, x);
276
+ return tail;
277
+ }
278
+ };
279
+ return tail;
273
280
  }
274
281
  };
275
- return chain;
276
282
  }
277
283
  function topStackLine(stack) {
278
284
  if (!stack) return "";
@@ -304,6 +310,14 @@ var SeeLimiter = class {
304
310
 
305
311
  // src/client/index.ts
306
312
  var version = "4.0.0";
313
+ var FLAG_REASONS = [
314
+ "CLIENT_NOT_READY",
315
+ "FLAG_NOT_FOUND",
316
+ "OFF",
317
+ "OVERRIDE",
318
+ "RULE_MATCH",
319
+ "DEFAULT"
320
+ ];
307
321
  var FLUSH_INTERVAL_MS = 5e3;
308
322
  var MAX_BUFFER = 100;
309
323
  var ANON_ID_KEY = "__se_anon_id";
@@ -527,39 +541,11 @@ function installAutoGuardrails(buffer, userId, anonId, groups, reportSee, ignore
527
541
  }
528
542
  }
529
543
  if (groups.errors) {
530
- const origOnError = window.onerror;
531
- window.onerror = (msg, source, lineno, _colno, err) => {
532
- if (!isExpected(err)) {
533
- const problem = err ?? (typeof msg === "string" && msg ? msg : "Unknown error");
534
- reportSee(
535
- problem,
536
- causesThe("page").to("hit an unhandled error"),
537
- {
538
- source: typeof source === "string" ? source : void 0,
539
- line: lineno ?? void 0
540
- },
541
- "uncaught"
542
- );
543
- }
544
- if (typeof origOnError === "function") return origOnError(msg, source, lineno, _colno, err);
545
- return false;
546
- };
547
- window.addEventListener("unhandledrejection", (e) => {
548
- const reason = e.reason;
549
- if (isExpected(reason)) return;
550
- reportSee(
551
- reason ?? "Unhandled promise rejection",
552
- causesThe("page").to("hit an unhandled promise rejection"),
553
- void 0,
554
- "unhandled_rejection"
555
- );
556
- });
557
544
  const origFetch = window.fetch;
558
545
  window.fetch = async function(...args) {
559
546
  const startedAt = typeof performance !== "undefined" ? performance.now() : 0;
560
547
  const url = typeof args[0] === "string" ? args[0] : args[0].toString();
561
548
  const ignored = ignoreUrlPrefixes.some((p) => p && url.startsWith(p));
562
- const bareUrl = url.split("?")[0].slice(0, 200);
563
549
  let corr;
564
550
  if (!ignored && sameOrigin(url) && typeof crypto !== "undefined" && crypto.randomUUID) {
565
551
  corr = crypto.randomUUID();
@@ -571,7 +557,7 @@ function installAutoGuardrails(buffer, userId, anonId, groups, reportSee, ignore
571
557
  } catch (err) {
572
558
  if (!ignored && !isExpected(err)) {
573
559
  reportSee(
574
- violation("NetworkError").message(`request to ${bareUrl} failed`),
560
+ violation("NetworkError"),
575
561
  causesThe(`request to ${endpointTemplate(url)}`).to("get no response"),
576
562
  { status: 0, url: url.slice(0, 200) },
577
563
  "network"
@@ -582,7 +568,7 @@ function installAutoGuardrails(buffer, userId, anonId, groups, reportSee, ignore
582
568
  if (!ignored && res.status >= 500) {
583
569
  const elapsed = typeof performance !== "undefined" ? performance.now() - startedAt : 0;
584
570
  reportSee(
585
- violation("Http5xx").message(`request to ${bareUrl} returned ${res.status}`),
571
+ violation("Http5xx"),
586
572
  causesThe(`request to ${endpointTemplate(url)}`).to("fail with a server error"),
587
573
  { status: res.status, url: url.slice(0, 200), duration_ms: Math.round(elapsed) },
588
574
  "network",
@@ -843,7 +829,7 @@ function readExperimentOverridesFromUrl() {
843
829
  }
844
830
  return out;
845
831
  }
846
- var FlagsClientBrowser = class {
832
+ var FlagsClientBrowser = class _FlagsClientBrowser {
847
833
  sdkKey;
848
834
  baseUrl;
849
835
  autoGuardrails;
@@ -862,6 +848,16 @@ var FlagsClientBrowser = class {
862
848
  // Monotonic counter so a later identify() always wins even if its /sdk/evaluate
863
849
  // response races and lands before an earlier in-flight call's response.
864
850
  identifySeq = 0;
851
+ // Test mode: built by `FlagsClientBrowser.forTesting()`. When set, identify()
852
+ // never fetches, track() is a no-op, telemetry is off, and the client is
853
+ // already ready with an empty eval result.
854
+ testMode;
855
+ // Programmatic overrides (Statsig-style). Set on any client via
856
+ // overrideFlag/overrideConfig/overrideExperiment; they win over BOTH the URL
857
+ // overrides and the fetched eval result. Cleared by clearOverrides().
858
+ flagOverrides = /* @__PURE__ */ new Map();
859
+ configOverrides = /* @__PURE__ */ new Map();
860
+ experimentOverrides = /* @__PURE__ */ new Map();
865
861
  onOverrideChange = () => {
866
862
  this.installBridge();
867
863
  this.notify();
@@ -870,6 +866,7 @@ var FlagsClientBrowser = class {
870
866
  this.sdkKey = opts.sdkKey;
871
867
  this.baseUrl = (opts.baseUrl ?? "https://edge.shipeasy.dev").replace(/\/$/, "");
872
868
  this.env = opts.env ?? "prod";
869
+ this.testMode = opts.testMode === true;
873
870
  this.autoGuardrails = opts.autoGuardrails !== false;
874
871
  this.autoCollectAlways = opts.autoCollectAlways === true;
875
872
  const g = opts.autoGuardrailGroups ?? {};
@@ -885,11 +882,42 @@ var FlagsClientBrowser = class {
885
882
  sdkKey: this.sdkKey,
886
883
  side: "client",
887
884
  env: this.env,
888
- disabled: opts.disableTelemetry
885
+ // Test mode never talks to the network — telemetry off regardless of opt.
886
+ disabled: this.testMode || opts.disableTelemetry
887
+ });
888
+ if (this.testMode) {
889
+ this.evalResult = { flags: {}, configs: {}, experiments: {}, killswitches: {} };
890
+ } else {
891
+ void this.buffer.flushPendingAlias();
892
+ }
893
+ }
894
+ /**
895
+ * Build a no-network, immediately-usable browser client for tests
896
+ * (Statsig-style). identify() is a no-op (never calls /sdk/evaluate), track()
897
+ * is a no-op, telemetry is disabled, and the client is already ready — seed
898
+ * every entity with overrideFlag/overrideConfig/overrideExperiment. No SDK
899
+ * key required.
900
+ *
901
+ * ```ts
902
+ * const client = FlagsClientBrowser.forTesting();
903
+ * client.overrideFlag("new_checkout", true);
904
+ * client.getFlag("new_checkout"); // true
905
+ * ```
906
+ */
907
+ static forTesting(opts) {
908
+ return new _FlagsClientBrowser({
909
+ sdkKey: "",
910
+ autoGuardrails: false,
911
+ ...opts,
912
+ testMode: true
889
913
  });
890
- void this.buffer.flushPendingAlias();
891
914
  }
892
915
  async identify(user) {
916
+ if (this.testMode) {
917
+ if (user.user_id !== void 0) this.userId = user.user_id;
918
+ this.notify();
919
+ return;
920
+ }
893
921
  const seq = ++this.identifySeq;
894
922
  const prevUserId = this.userId;
895
923
  if (user.user_id !== void 0) this.userId = user.user_id;
@@ -964,22 +992,75 @@ var FlagsClientBrowser = class {
964
992
  initFromBootstrap(data) {
965
993
  this.evalResult = data;
966
994
  }
967
- getFlag(name) {
995
+ // ---- Local overrides (Statsig-style) ----
996
+ //
997
+ // Precedence (highest first): programmatic override (these methods) > URL
998
+ // override (?se_gate_/?se_config_/?se_exp_) > fetched eval result. A
999
+ // programmatic override is an explicit in-code decision, so it wins over the
1000
+ // ad-hoc URL/devtools overrides as well as the server's evaluation.
1001
+ /** Force `getFlag(name)` to return `value`, ignoring URL overrides + eval. */
1002
+ overrideFlag(name, value) {
1003
+ this.flagOverrides.set(name, value);
1004
+ }
1005
+ /** Force `getConfig(name)` to return `value`, ignoring URL overrides + eval. */
1006
+ overrideConfig(name, value) {
1007
+ this.configOverrides.set(name, value);
1008
+ }
1009
+ /**
1010
+ * Force `getExperiment(name, …)` to return `{ inExperiment: true, group, params }`,
1011
+ * ignoring URL overrides, the eval result, and exposure logging.
1012
+ */
1013
+ overrideExperiment(name, group, params) {
1014
+ this.experimentOverrides.set(name, { group, params });
1015
+ }
1016
+ /** Remove every programmatic override set via the override* methods. */
1017
+ clearOverrides() {
1018
+ this.flagOverrides.clear();
1019
+ this.configOverrides.clear();
1020
+ this.experimentOverrides.clear();
1021
+ }
1022
+ /**
1023
+ * Evaluate a gate and report WHY (LaunchDarkly variationDetail parity). A
1024
+ * local override OR a ?se_gate_/?se_ks_ URL override short-circuits BEFORE
1025
+ * telemetry; otherwise exactly one "gate" beacon is emitted. The server
1026
+ * pre-evaluates a gate's enabled/killed state into a plain boolean, so OFF
1027
+ * folds into DEFAULT here (the browser can't tell "disabled" from "rule
1028
+ * denied").
1029
+ */
1030
+ getFlagDetail(name) {
1031
+ const pov = this.flagOverrides.get(name);
1032
+ if (pov !== void 0) return { value: pov, reason: "OVERRIDE" };
1033
+ const urlOv = readGateOverride(name);
1034
+ if (urlOv !== null) return { value: urlOv, reason: "OVERRIDE" };
968
1035
  this.telemetry.emit("gate", name);
969
- if (this.evalResult === null) return false;
970
- const ov = readGateOverride(name);
971
- if (ov !== null) return ov;
972
- return this.evalResult.flags[name] ?? false;
1036
+ if (this.evalResult === null) return { value: false, reason: "CLIENT_NOT_READY" };
1037
+ if (!(name in this.evalResult.flags)) return { value: false, reason: "FLAG_NOT_FOUND" };
1038
+ const value = this.evalResult.flags[name] ?? false;
1039
+ return { value, reason: value ? "RULE_MATCH" : "DEFAULT" };
973
1040
  }
974
- getConfig(name, decode) {
1041
+ /**
1042
+ * Read a feature gate. Returns `defaultValue` ONLY when the gate cannot be
1043
+ * evaluated (not ready or flag not found) — never for a gate that legitimately
1044
+ * evaluates to false. Plain `getFlag(name)` keeps returning false for a
1045
+ * missing flag.
1046
+ */
1047
+ getFlag(name, defaultValue = false) {
1048
+ const d = this.getFlagDetail(name);
1049
+ if (d.reason === "CLIENT_NOT_READY" || d.reason === "FLAG_NOT_FOUND") return defaultValue;
1050
+ return d.value;
1051
+ }
1052
+ getConfig(name, decodeOrOpts) {
975
1053
  this.telemetry.emit("config", name);
976
- if (this.evalResult === null) return void 0;
977
- const ov = readConfigOverride(name);
978
- const raw = ov !== void 0 ? ov : this.evalResult.configs?.[name];
979
- if (raw === void 0) return void 0;
980
- if (!decode) return raw;
1054
+ const opts = typeof decodeOrOpts === "function" ? { decode: decodeOrOpts } : decodeOrOpts ?? {};
1055
+ const fallback = "defaultValue" in opts ? opts.defaultValue : void 0;
1056
+ const hasProgrammatic = this.configOverrides.has(name);
1057
+ if (!hasProgrammatic && this.evalResult === null) return fallback;
1058
+ const urlOv = hasProgrammatic ? void 0 : readConfigOverride(name);
1059
+ const raw = hasProgrammatic ? this.configOverrides.get(name) : urlOv !== void 0 ? urlOv : this.evalResult?.configs?.[name];
1060
+ if (raw === void 0) return fallback;
1061
+ if (!opts.decode) return raw;
981
1062
  try {
982
- return decode(raw);
1063
+ return opts.decode(raw);
983
1064
  } catch (err) {
984
1065
  console.warn(`[shipeasy] getConfig('${name}') decode failed:`, String(err));
985
1066
  return void 0;
@@ -992,6 +1073,12 @@ var FlagsClientBrowser = class {
992
1073
  group: "control",
993
1074
  params: defaultParams
994
1075
  };
1076
+ const pov = this.experimentOverrides.get(name);
1077
+ if (pov) {
1078
+ const variantParams = variants?.[pov.group];
1079
+ const params = { ...defaultParams, ...pov.params, ...variantParams ?? {} };
1080
+ return { inExperiment: true, group: pov.group, params };
1081
+ }
995
1082
  const ov = readExpOverride(name);
996
1083
  if (ov !== null) {
997
1084
  const variantParams = variants?.[ov];
@@ -1043,6 +1130,7 @@ var FlagsClientBrowser = class {
1043
1130
  return bridge;
1044
1131
  }
1045
1132
  track(eventName, props) {
1133
+ if (this.testMode) return;
1046
1134
  this.buffer.pushMetric(eventName, this.userId, this.anonId, props);
1047
1135
  }
1048
1136
  /**
@@ -1261,12 +1349,19 @@ var flags = {
1261
1349
  * Everything else gates on _mountedAndReady to prevent hydration mismatches on
1262
1350
  * force-static pages where SSR has no flag data.
1263
1351
  */
1264
- get(name) {
1352
+ get(name, defaultValue = false) {
1265
1353
  const bs = getBootstrap();
1266
1354
  if (bs !== null && name in bs.flags) return bs.flags[name];
1267
- if (!_mountedAndReady) return false;
1268
- if (_client) return _client.getFlag(name);
1269
- return readGateOverride(name) ?? false;
1355
+ if (!_mountedAndReady) return defaultValue;
1356
+ if (_client) return _client.getFlag(name, defaultValue);
1357
+ return readGateOverride(name) ?? defaultValue;
1358
+ },
1359
+ /** Evaluate a gate and report why (value + reason). See {@link FlagDetail}. */
1360
+ getDetail(name) {
1361
+ if (_client) return _client.getFlagDetail(name);
1362
+ const ov = readGateOverride(name);
1363
+ if (ov !== null) return { value: ov, reason: "OVERRIDE" };
1364
+ return { value: false, reason: "CLIENT_NOT_READY" };
1270
1365
  },
1271
1366
  getConfig(name, decode) {
1272
1367
  const bs = getBootstrap();
@@ -1361,7 +1456,7 @@ var see = Object.assign(
1361
1456
  (problem) => startSeeChain(() => problem, dispatchSee),
1362
1457
  {
1363
1458
  Violation: (name) => startSeeViolationChain(name, dispatchSee),
1364
- ControlFlowException: markExpected
1459
+ ControlFlowException: (err) => startControlFlowChain(err)
1365
1460
  }
1366
1461
  );
1367
1462
  var LABEL_MARKER_START = "\uFFF9";
@@ -1597,6 +1692,7 @@ var i18n = {
1597
1692
  }
1598
1693
  };
1599
1694
  export {
1695
+ FLAG_REASONS,
1600
1696
  FlagsClientBrowser,
1601
1697
  LABEL_MARKER_END,
1602
1698
  LABEL_MARKER_RE,
@@ -10,15 +10,13 @@ interface Consequence {
10
10
  }
11
11
  /**
12
12
  * Non-exception problem, built by `violation(name)`. A plain branded object
13
- * (not an Error subclass) so `.message()` can be a builder method without
14
- * colliding with `Error.prototype.message`.
13
+ * (not an Error subclass). The name is the whole identity there is no
14
+ * separate message; any variable/context data belongs in `.extras()` on the
15
+ * see chain, never on the violation itself.
15
16
  */
16
17
  interface Violation {
17
18
  readonly __seViolation: true;
18
19
  readonly violationName: string;
19
- readonly violationMessage?: string;
20
- /** Attach free-form detail. Variable data goes HERE (or in extras), never in the name. */
21
- message(msg: string): Violation;
22
20
  }
23
21
  /**
24
22
  * Identity of a problem that see() already reported, carried on the wire as
@@ -87,12 +85,33 @@ interface SeeChain {
87
85
  /** camelCase alias of {@link SeeChain.causes_the}. */
88
86
  causesThe(subject: string): SeeOutcomeStep;
89
87
  }
90
- interface SeeViolationChain extends SeeChain {
91
- /** Free-form detail. Variable data goes here (or extras), never in the name. */
92
- message(msg: string): SeeViolationChain;
88
+ /**
89
+ * Violations share the exception consequence grammar exactly there is no
90
+ * separate `.message()`. Put any variable/context data in `.extras()`.
91
+ */
92
+ type SeeViolationChain = SeeChain;
93
+ interface SeeControlFlowTail {
94
+ /**
95
+ * Optional debugging context for the expected exception. Kept on the mark for
96
+ * local debugging only (expected exceptions are never reported). Callable
97
+ * repeatedly — keys merge, later wins.
98
+ */
99
+ extras(extras: SeeExtras): SeeControlFlowTail;
100
+ }
101
+ interface SeeControlFlowChain {
102
+ /** Document why the exception is expected. The reason should start with "because". */
103
+ because(reason: string): SeeControlFlowTail;
93
104
  }
94
105
 
95
106
  declare const version = "4.0.0";
107
+ /**
108
+ * @internal Exported ONLY so the cross-language eval-parity golden-vector test
109
+ * (src/__tests__/eval-vectors.test.ts) can assert the raw unsigned-32-bit hash
110
+ * against the canonical fixture. Not part of the public SDK surface — do not
111
+ * rely on it in product code; the bucketing contract lives behind getFlag /
112
+ * getExperiment. Name-prefixed with `_` to signal "test seam, not API".
113
+ */
114
+ declare function _murmur3ForTests(key: string): number;
96
115
  interface User {
97
116
  user_id?: string;
98
117
  anonymous_id?: string;
@@ -103,6 +122,30 @@ interface ExperimentResult<P> {
103
122
  group: string;
104
123
  params: P;
105
124
  }
125
+ /**
126
+ * Why a flag evaluated the way it did (LaunchDarkly variationDetail parity).
127
+ * Computed at the client boundary, never inside the canonical eval:
128
+ * - CLIENT_NOT_READY — no rules blob loaded yet (init()/initOnce() pending)
129
+ * - FLAG_NOT_FOUND — the gate name isn't present in the loaded blob
130
+ * - OFF — the gate exists but is disabled / killed
131
+ * - OVERRIDE — a local override (overrideFlag) decided the value
132
+ * - RULE_MATCH — the gate evaluated true (rules + rollout passed)
133
+ * - DEFAULT — the gate evaluated false (a rule or the rollout denied)
134
+ */
135
+ declare const FLAG_REASONS: readonly ["CLIENT_NOT_READY", "FLAG_NOT_FOUND", "OFF", "OVERRIDE", "RULE_MATCH", "DEFAULT"];
136
+ type FlagReason = (typeof FLAG_REASONS)[number];
137
+ interface FlagDetail {
138
+ value: boolean;
139
+ reason: FlagReason;
140
+ }
141
+ /** Options object form of `getConfig` — keeps the legacy `decode` callback and
142
+ * adds a `defaultValue` returned when the config key is absent. */
143
+ interface GetConfigOptions<T = unknown> {
144
+ /** Decode the raw stored value into the typed shape callers want. */
145
+ decode?: (raw: unknown) => T;
146
+ /** Returned when the config key is absent (not overridden, not in the blob). */
147
+ defaultValue?: T;
148
+ }
106
149
  interface GateRule {
107
150
  attr: string;
108
151
  op: "eq" | "neq" | "in" | "not_in" | "gt" | "gte" | "lt" | "lte" | "contains" | "regex";
@@ -115,10 +158,27 @@ interface Gate {
115
158
  enabled: 0 | 1 | boolean;
116
159
  killswitch?: 0 | 1 | boolean;
117
160
  }
161
+ interface ExperimentGroup {
162
+ name: string;
163
+ weight: number;
164
+ params: Record<string, unknown>;
165
+ }
166
+ interface Experiment {
167
+ universe: string;
168
+ targetingGate?: string | null;
169
+ allocationPct: number;
170
+ salt: string;
171
+ groups: ExperimentGroup[];
172
+ status: "draft" | "running" | "stopped" | "archived";
173
+ }
174
+ interface Universe {
175
+ holdout_range: [number, number] | null;
176
+ }
118
177
  interface Killswitch {
119
178
  killed: 0 | 1 | boolean;
120
179
  switches?: Record<string, 0 | 1 | boolean>;
121
180
  }
181
+ /** Body of `GET /sdk/flags` — the snapshot's `flags` field. See {@link FlagsClient.fromSnapshot}. */
122
182
  interface FlagsBlob {
123
183
  version: string;
124
184
  plan: string;
@@ -128,6 +188,12 @@ interface FlagsBlob {
128
188
  }>;
129
189
  killswitches: Record<string, Killswitch>;
130
190
  }
191
+ /** Body of `GET /sdk/experiments` — the snapshot's `experiments` field. See {@link FlagsClient.fromSnapshot}. */
192
+ interface ExpsBlob {
193
+ version: string;
194
+ universes: Record<string, Universe>;
195
+ experiments: Record<string, Experiment>;
196
+ }
131
197
  interface BootstrapPayload {
132
198
  flags: Record<string, boolean>;
133
199
  configs: Record<string, unknown>;
@@ -157,6 +223,13 @@ interface FlagsClientOptions {
157
223
  disableTelemetry?: boolean;
158
224
  /** Override the telemetry beacon host. Defaults to {@link DEFAULT_TELEMETRY_URL}. */
159
225
  telemetryUrl?: string;
226
+ /**
227
+ * Test mode — no network at all. init()/initOnce() are no-ops (never fetch),
228
+ * track() is a no-op, telemetry is forced off, and the client starts
229
+ * "initialized" with an empty blob. Prefer the {@link FlagsClient.forTesting}
230
+ * factory over passing this directly.
231
+ */
232
+ testMode?: boolean;
160
233
  }
161
234
  declare class FlagsClient {
162
235
  private readonly apiKey;
@@ -171,16 +244,89 @@ declare class FlagsClient {
171
244
  private pollInterval;
172
245
  private timer;
173
246
  private initialized;
247
+ private readonly testMode;
248
+ private readonly flagOverrides;
249
+ private readonly configOverrides;
250
+ private readonly experimentOverrides;
251
+ private readonly changeListeners;
174
252
  constructor(opts: FlagsClientOptions);
253
+ /**
254
+ * Build a no-network, immediately-usable client for tests (Statsig-style).
255
+ * init()/initOnce() are no-ops (never fetch), track() is a no-op, telemetry
256
+ * is disabled, and the client is already "initialized" — seed every entity
257
+ * with overrideFlag/overrideConfig/overrideExperiment. No SDK key required.
258
+ *
259
+ * ```ts
260
+ * const client = FlagsClient.forTesting();
261
+ * client.overrideFlag("new_checkout", true);
262
+ * client.getFlag("new_checkout", { user_id: "u1" }); // true
263
+ * ```
264
+ */
265
+ static forTesting(opts?: Partial<FlagsClientOptions>): FlagsClient;
266
+ /**
267
+ * Build a fully OFFLINE client from a pre-captured snapshot — no network ever.
268
+ * Reuses the test-mode plumbing (init()/initOnce()/track() are no-ops,
269
+ * telemetry off) but, unlike forTesting(), seeds the REAL flags + experiments
270
+ * blobs so evaluations run the canonical eval against the snapshot. Local
271
+ * overrides still apply on top.
272
+ *
273
+ * Snapshot shape mirrors the wire bodies:
274
+ * `{ flags: <GET /sdk/flags body>, experiments: <GET /sdk/experiments body> }`.
275
+ */
276
+ static fromSnapshot(snapshot: {
277
+ flags: FlagsBlob;
278
+ experiments: ExpsBlob;
279
+ }): FlagsClient;
280
+ /**
281
+ * Build a fully OFFLINE client from a snapshot JSON file on disk (Node only —
282
+ * not available in the browser entrypoint). The file must contain
283
+ * `{ "flags": <GET /sdk/flags body>, "experiments": <GET /sdk/experiments body> }`.
284
+ * See {@link FlagsClient.fromSnapshot}.
285
+ */
286
+ static fromFile(path: string): FlagsClient;
175
287
  init(): Promise<void>;
176
288
  initOnce(): Promise<void>;
289
+ /** Force `getFlag(name, …)` to return `value`, ignoring the fetched gate. */
290
+ overrideFlag(name: string, value: boolean): void;
291
+ /** Force `getConfig(name)` to return `value`, ignoring the fetched config. */
292
+ overrideConfig(name: string, value: unknown): void;
293
+ /**
294
+ * Force `getExperiment(name, …)` to return `{ inExperiment: true, group, params }`,
295
+ * ignoring allocation, holdouts, and targeting.
296
+ */
297
+ overrideExperiment(name: string, group: string, params: Record<string, unknown>): void;
298
+ /** Remove every programmatic override set via the override* methods. */
299
+ clearOverrides(): void;
177
300
  destroy(): void;
301
+ /**
302
+ * Subscribe to data-change notifications. The listener fires after a
303
+ * background poll fetch returns NEW data (200, not 304) — i.e. after the
304
+ * cached blob is updated. Never fires in testMode/offline (no polling).
305
+ * Returns an unsubscribe function.
306
+ */
307
+ onChange(listener: () => void): () => void;
308
+ private notifyChange;
178
309
  private startPoll;
179
310
  private fetchAll;
180
311
  private fetchFlags;
181
312
  private fetchExps;
182
- getFlag(name: string, user: User): boolean;
313
+ /**
314
+ * Evaluate a gate and report WHY (LaunchDarkly variationDetail parity). The
315
+ * reason is computed entirely at this boundary — the canonical eval
316
+ * (evalGateInternal) is untouched. A local override short-circuits BEFORE
317
+ * telemetry, exactly like getFlag's override path; otherwise exactly one
318
+ * "gate" beacon is emitted.
319
+ */
320
+ getFlagDetail(name: string, user: User): FlagDetail;
321
+ /**
322
+ * Read a feature gate. Returns `defaultValue` ONLY when the gate cannot be
323
+ * evaluated (client not initialized or flag not found) — never for a gate that
324
+ * legitimately evaluates to false. Plain `getFlag(name, user)` keeps returning
325
+ * false for a missing flag.
326
+ */
327
+ getFlag(name: string, user: User, defaultValue?: boolean): boolean;
183
328
  getConfig<T = unknown>(name: string, decode?: (raw: unknown) => T): T | undefined;
329
+ getConfig<T = unknown>(name: string, opts: GetConfigOptions<T>): T;
184
330
  getExperiment<P extends Record<string, unknown>>(name: string, user: User, defaultParams: P, decode?: (raw: unknown) => P): ExperimentResult<P>;
185
331
  track(userId: string, eventName: string, props?: Record<string, unknown>): void;
186
332
  /**
@@ -327,8 +473,10 @@ declare const flags: {
327
473
  initOnce(): Promise<void>;
328
474
  /** Stop background timers. Safe to call repeatedly. */
329
475
  destroy(): void;
330
- get(name: string, user: User): boolean;
331
- getConfig<T = unknown>(name: string, decode?: (raw: unknown) => T): T | undefined;
476
+ get(name: string, user: User, defaultValue?: boolean): boolean;
477
+ /** Evaluate a gate and report why (value + reason). See {@link FlagDetail}. */
478
+ getDetail(name: string, user: User): FlagDetail;
479
+ getConfig<T = unknown>(name: string, decodeOrOpts?: ((raw: unknown) => T) | GetConfigOptions<T>): T | undefined;
332
480
  getExperiment<P extends Record<string, unknown>>(name: string, user: User, defaultParams: P, decode?: (raw: unknown) => P): ExperimentResult<P>;
333
481
  /**
334
482
  * Read a killswitch. Without `switchKey`, returns true when the whole
@@ -367,20 +515,26 @@ interface SeeApi {
367
515
  /**
368
516
  * Report a non-exception problem. Prefer passing a caught Error to `see()`
369
517
  * when one exists. The name is a stable identifier (it participates in the
370
- * issue fingerprint) — variable data goes in `.message()` or `.extras()`.
518
+ * issue fingerprint) — variable data goes in `.extras()`, never the name.
371
519
  *
372
520
  * ```ts
373
521
  * if (results.length > LIMIT) {
374
- * see.Violation("large query").causes_the("search results").to("be trimmed");
522
+ * see.Violation("large query")
523
+ * .causes_the("search results").to("be trimmed").extras({ rows: results.length });
375
524
  * }
376
525
  * ```
377
526
  */
378
527
  Violation(name: string): SeeViolationChain;
379
528
  /**
380
529
  * Mark an exception as expected control flow — documents the expectation and
381
- * reports nothing. The reason must start with "because".
530
+ * reports nothing. Say why with `.because()` (reason should start with
531
+ * "because"); attach optional debug context with `.extras()`.
532
+ *
533
+ * ```ts
534
+ * see.ControlFlowException(e).because("because the metric may not exist yet");
535
+ * ```
382
536
  */
383
- ControlFlowException(err: unknown, because: string): void;
537
+ ControlFlowException(err: unknown): SeeControlFlowChain;
384
538
  }
385
539
  /**
386
540
  * Structured error reporter — the whole grammar hangs off this one import.
@@ -389,4 +543,4 @@ interface SeeApi {
389
543
  */
390
544
  declare const see: SeeApi;
391
545
 
392
- export { ANON_ID_COOKIE, type BootstrapHtmlOptions, type BootstrapPayload, type Consequence, type ExperimentResult, type FetchLabelsOptions, FlagsClient, type FlagsClientEnv, type FlagsClientOptions, type I18nForRequest, type LabelFile, type SeeApi, type SeeChain, type SeeErrorEvent, type SeeExtras, type SeeKind, type SeeViolationChain, type ShipeasyServerConfig, type ShipeasyServerHandle, type User, type Violation, _resetShipeasyServerForTests, configureShipeasyServer, fetchLabelsForSSR, flags, getBootstrapHtml, getShipeasyServerClient, i18n, isExpected, see, seeContext, shipeasy, version };
546
+ export { ANON_ID_COOKIE, type BootstrapHtmlOptions, type BootstrapPayload, type Consequence, type ExperimentResult, type ExpsBlob, FLAG_REASONS, type FetchLabelsOptions, type FlagDetail, type FlagReason, type FlagsBlob, FlagsClient, type FlagsClientEnv, type FlagsClientOptions, type GetConfigOptions, type I18nForRequest, type LabelFile, type SeeApi, type SeeChain, type SeeControlFlowChain, type SeeErrorEvent, type SeeExtras, type SeeKind, type SeeViolationChain, type ShipeasyServerConfig, type ShipeasyServerHandle, type User, type Violation, _murmur3ForTests, _resetShipeasyServerForTests, configureShipeasyServer, fetchLabelsForSSR, flags, getBootstrapHtml, getShipeasyServerClient, i18n, isExpected, see, seeContext, shipeasy, version };