@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.
@@ -310,6 +310,14 @@ var SeeLimiter = class {
310
310
 
311
311
  // src/client/index.ts
312
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
+ ];
313
321
  var FLUSH_INTERVAL_MS = 5e3;
314
322
  var MAX_BUFFER = 100;
315
323
  var ANON_ID_KEY = "__se_anon_id";
@@ -821,7 +829,7 @@ function readExperimentOverridesFromUrl() {
821
829
  }
822
830
  return out;
823
831
  }
824
- var FlagsClientBrowser = class {
832
+ var FlagsClientBrowser = class _FlagsClientBrowser {
825
833
  sdkKey;
826
834
  baseUrl;
827
835
  autoGuardrails;
@@ -840,6 +848,16 @@ var FlagsClientBrowser = class {
840
848
  // Monotonic counter so a later identify() always wins even if its /sdk/evaluate
841
849
  // response races and lands before an earlier in-flight call's response.
842
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();
843
861
  onOverrideChange = () => {
844
862
  this.installBridge();
845
863
  this.notify();
@@ -848,6 +866,7 @@ var FlagsClientBrowser = class {
848
866
  this.sdkKey = opts.sdkKey;
849
867
  this.baseUrl = (opts.baseUrl ?? "https://edge.shipeasy.dev").replace(/\/$/, "");
850
868
  this.env = opts.env ?? "prod";
869
+ this.testMode = opts.testMode === true;
851
870
  this.autoGuardrails = opts.autoGuardrails !== false;
852
871
  this.autoCollectAlways = opts.autoCollectAlways === true;
853
872
  const g = opts.autoGuardrailGroups ?? {};
@@ -863,11 +882,42 @@ var FlagsClientBrowser = class {
863
882
  sdkKey: this.sdkKey,
864
883
  side: "client",
865
884
  env: this.env,
866
- 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
867
913
  });
868
- void this.buffer.flushPendingAlias();
869
914
  }
870
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
+ }
871
921
  const seq = ++this.identifySeq;
872
922
  const prevUserId = this.userId;
873
923
  if (user.user_id !== void 0) this.userId = user.user_id;
@@ -942,22 +992,75 @@ var FlagsClientBrowser = class {
942
992
  initFromBootstrap(data) {
943
993
  this.evalResult = data;
944
994
  }
945
- 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" };
946
1035
  this.telemetry.emit("gate", name);
947
- if (this.evalResult === null) return false;
948
- const ov = readGateOverride(name);
949
- if (ov !== null) return ov;
950
- 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" };
951
1040
  }
952
- 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) {
953
1053
  this.telemetry.emit("config", name);
954
- if (this.evalResult === null) return void 0;
955
- const ov = readConfigOverride(name);
956
- const raw = ov !== void 0 ? ov : this.evalResult.configs?.[name];
957
- if (raw === void 0) return void 0;
958
- 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;
959
1062
  try {
960
- return decode(raw);
1063
+ return opts.decode(raw);
961
1064
  } catch (err) {
962
1065
  console.warn(`[shipeasy] getConfig('${name}') decode failed:`, String(err));
963
1066
  return void 0;
@@ -970,6 +1073,12 @@ var FlagsClientBrowser = class {
970
1073
  group: "control",
971
1074
  params: defaultParams
972
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
+ }
973
1082
  const ov = readExpOverride(name);
974
1083
  if (ov !== null) {
975
1084
  const variantParams = variants?.[ov];
@@ -1021,6 +1130,7 @@ var FlagsClientBrowser = class {
1021
1130
  return bridge;
1022
1131
  }
1023
1132
  track(eventName, props) {
1133
+ if (this.testMode) return;
1024
1134
  this.buffer.pushMetric(eventName, this.userId, this.anonId, props);
1025
1135
  }
1026
1136
  /**
@@ -1239,12 +1349,19 @@ var flags = {
1239
1349
  * Everything else gates on _mountedAndReady to prevent hydration mismatches on
1240
1350
  * force-static pages where SSR has no flag data.
1241
1351
  */
1242
- get(name) {
1352
+ get(name, defaultValue = false) {
1243
1353
  const bs = getBootstrap();
1244
1354
  if (bs !== null && name in bs.flags) return bs.flags[name];
1245
- if (!_mountedAndReady) return false;
1246
- if (_client) return _client.getFlag(name);
1247
- 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" };
1248
1365
  },
1249
1366
  getConfig(name, decode) {
1250
1367
  const bs = getBootstrap();
@@ -1575,6 +1692,7 @@ var i18n = {
1575
1692
  }
1576
1693
  };
1577
1694
  export {
1695
+ FLAG_REASONS,
1578
1696
  FlagsClientBrowser,
1579
1697
  LABEL_MARKER_END,
1580
1698
  LABEL_MARKER_RE,
@@ -104,6 +104,14 @@ interface SeeControlFlowChain {
104
104
  }
105
105
 
106
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;
107
115
  interface User {
108
116
  user_id?: string;
109
117
  anonymous_id?: string;
@@ -114,6 +122,30 @@ interface ExperimentResult<P> {
114
122
  group: string;
115
123
  params: P;
116
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
+ }
117
149
  interface GateRule {
118
150
  attr: string;
119
151
  op: "eq" | "neq" | "in" | "not_in" | "gt" | "gte" | "lt" | "lte" | "contains" | "regex";
@@ -126,10 +158,27 @@ interface Gate {
126
158
  enabled: 0 | 1 | boolean;
127
159
  killswitch?: 0 | 1 | boolean;
128
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
+ }
129
177
  interface Killswitch {
130
178
  killed: 0 | 1 | boolean;
131
179
  switches?: Record<string, 0 | 1 | boolean>;
132
180
  }
181
+ /** Body of `GET /sdk/flags` — the snapshot's `flags` field. See {@link FlagsClient.fromSnapshot}. */
133
182
  interface FlagsBlob {
134
183
  version: string;
135
184
  plan: string;
@@ -139,6 +188,12 @@ interface FlagsBlob {
139
188
  }>;
140
189
  killswitches: Record<string, Killswitch>;
141
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
+ }
142
197
  interface BootstrapPayload {
143
198
  flags: Record<string, boolean>;
144
199
  configs: Record<string, unknown>;
@@ -168,6 +223,13 @@ interface FlagsClientOptions {
168
223
  disableTelemetry?: boolean;
169
224
  /** Override the telemetry beacon host. Defaults to {@link DEFAULT_TELEMETRY_URL}. */
170
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;
171
233
  }
172
234
  declare class FlagsClient {
173
235
  private readonly apiKey;
@@ -182,16 +244,89 @@ declare class FlagsClient {
182
244
  private pollInterval;
183
245
  private timer;
184
246
  private initialized;
247
+ private readonly testMode;
248
+ private readonly flagOverrides;
249
+ private readonly configOverrides;
250
+ private readonly experimentOverrides;
251
+ private readonly changeListeners;
185
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;
186
287
  init(): Promise<void>;
187
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;
188
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;
189
309
  private startPoll;
190
310
  private fetchAll;
191
311
  private fetchFlags;
192
312
  private fetchExps;
193
- 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;
194
328
  getConfig<T = unknown>(name: string, decode?: (raw: unknown) => T): T | undefined;
329
+ getConfig<T = unknown>(name: string, opts: GetConfigOptions<T>): T;
195
330
  getExperiment<P extends Record<string, unknown>>(name: string, user: User, defaultParams: P, decode?: (raw: unknown) => P): ExperimentResult<P>;
196
331
  track(userId: string, eventName: string, props?: Record<string, unknown>): void;
197
332
  /**
@@ -338,8 +473,10 @@ declare const flags: {
338
473
  initOnce(): Promise<void>;
339
474
  /** Stop background timers. Safe to call repeatedly. */
340
475
  destroy(): void;
341
- get(name: string, user: User): boolean;
342
- 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;
343
480
  getExperiment<P extends Record<string, unknown>>(name: string, user: User, defaultParams: P, decode?: (raw: unknown) => P): ExperimentResult<P>;
344
481
  /**
345
482
  * Read a killswitch. Without `switchKey`, returns true when the whole
@@ -406,4 +543,4 @@ interface SeeApi {
406
543
  */
407
544
  declare const see: SeeApi;
408
545
 
409
- 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 SeeControlFlowChain, 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 };
@@ -104,6 +104,14 @@ interface SeeControlFlowChain {
104
104
  }
105
105
 
106
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;
107
115
  interface User {
108
116
  user_id?: string;
109
117
  anonymous_id?: string;
@@ -114,6 +122,30 @@ interface ExperimentResult<P> {
114
122
  group: string;
115
123
  params: P;
116
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
+ }
117
149
  interface GateRule {
118
150
  attr: string;
119
151
  op: "eq" | "neq" | "in" | "not_in" | "gt" | "gte" | "lt" | "lte" | "contains" | "regex";
@@ -126,10 +158,27 @@ interface Gate {
126
158
  enabled: 0 | 1 | boolean;
127
159
  killswitch?: 0 | 1 | boolean;
128
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
+ }
129
177
  interface Killswitch {
130
178
  killed: 0 | 1 | boolean;
131
179
  switches?: Record<string, 0 | 1 | boolean>;
132
180
  }
181
+ /** Body of `GET /sdk/flags` — the snapshot's `flags` field. See {@link FlagsClient.fromSnapshot}. */
133
182
  interface FlagsBlob {
134
183
  version: string;
135
184
  plan: string;
@@ -139,6 +188,12 @@ interface FlagsBlob {
139
188
  }>;
140
189
  killswitches: Record<string, Killswitch>;
141
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
+ }
142
197
  interface BootstrapPayload {
143
198
  flags: Record<string, boolean>;
144
199
  configs: Record<string, unknown>;
@@ -168,6 +223,13 @@ interface FlagsClientOptions {
168
223
  disableTelemetry?: boolean;
169
224
  /** Override the telemetry beacon host. Defaults to {@link DEFAULT_TELEMETRY_URL}. */
170
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;
171
233
  }
172
234
  declare class FlagsClient {
173
235
  private readonly apiKey;
@@ -182,16 +244,89 @@ declare class FlagsClient {
182
244
  private pollInterval;
183
245
  private timer;
184
246
  private initialized;
247
+ private readonly testMode;
248
+ private readonly flagOverrides;
249
+ private readonly configOverrides;
250
+ private readonly experimentOverrides;
251
+ private readonly changeListeners;
185
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;
186
287
  init(): Promise<void>;
187
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;
188
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;
189
309
  private startPoll;
190
310
  private fetchAll;
191
311
  private fetchFlags;
192
312
  private fetchExps;
193
- 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;
194
328
  getConfig<T = unknown>(name: string, decode?: (raw: unknown) => T): T | undefined;
329
+ getConfig<T = unknown>(name: string, opts: GetConfigOptions<T>): T;
195
330
  getExperiment<P extends Record<string, unknown>>(name: string, user: User, defaultParams: P, decode?: (raw: unknown) => P): ExperimentResult<P>;
196
331
  track(userId: string, eventName: string, props?: Record<string, unknown>): void;
197
332
  /**
@@ -338,8 +473,10 @@ declare const flags: {
338
473
  initOnce(): Promise<void>;
339
474
  /** Stop background timers. Safe to call repeatedly. */
340
475
  destroy(): void;
341
- get(name: string, user: User): boolean;
342
- 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;
343
480
  getExperiment<P extends Record<string, unknown>>(name: string, user: User, defaultParams: P, decode?: (raw: unknown) => P): ExperimentResult<P>;
344
481
  /**
345
482
  * Read a killswitch. Without `switchKey`, returns true when the whole
@@ -406,4 +543,4 @@ interface SeeApi {
406
543
  */
407
544
  declare const see: SeeApi;
408
545
 
409
- 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 SeeControlFlowChain, 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 };