@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.
package/README.md CHANGED
@@ -130,6 +130,164 @@ The loader IIFE is published to a public R2 bucket on every release and
130
130
  cached for 1y at `loader-vX.Y.Z.js` (immutable) plus a rolling 5-minute
131
131
  `loader.js`.
132
132
 
133
+ ## Testing
134
+
135
+ For unit tests, build a **no-network** client with `forTesting()` and seed every
136
+ entity with local overrides (Statsig-style). In test mode the client is already
137
+ "initialized"/"ready", `init()`/`initOnce()`/`identify()` are no-ops (they never
138
+ fetch), `track()` is a no-op, telemetry is disabled, and no SDK key is required —
139
+ so your tests never touch the network.
140
+
141
+ ```ts
142
+ // Server (Node / Cloudflare Worker / Deno)
143
+ import { FlagsClient } from "@shipeasy/sdk/server";
144
+
145
+ const client = FlagsClient.forTesting();
146
+
147
+ client.overrideFlag("new_checkout", true);
148
+ client.overrideConfig("upload_limits", { max_uploads: 50 });
149
+ client.overrideExperiment("hero_cta", "treatment", { primary_label: "Buy now" });
150
+
151
+ client.getFlag("new_checkout", { user_id: "u1" }); // true
152
+ client.getConfig("upload_limits"); // { max_uploads: 50 }
153
+ client.getExperiment("hero_cta", { user_id: "u1" }, { primary_label: "Sign up" });
154
+ // → { inExperiment: true, group: "treatment", params: { primary_label: "Buy now" } }
155
+
156
+ client.track("u1", "purchase"); // no-op — never hits the network
157
+ client.clearOverrides(); // reset every override back to default
158
+ ```
159
+
160
+ ```ts
161
+ // Browser (vanilla JS — no React required)
162
+ import { FlagsClientBrowser } from "@shipeasy/sdk/client";
163
+
164
+ const client = FlagsClientBrowser.forTesting();
165
+
166
+ client.overrideFlag("new_checkout", true);
167
+ client.overrideConfig("upload_limits", { max_uploads: 50 });
168
+ client.overrideExperiment("hero_cta", "treatment", { primary_label: "Buy now" });
169
+
170
+ client.getFlag("new_checkout"); // true
171
+ client.getConfig("upload_limits"); // { max_uploads: 50 }
172
+ client.getExperiment("hero_cta", { primary_label: "Sign up" });
173
+ // → { inExperiment: true, group: "treatment", params: { primary_label: "Buy now" } }
174
+
175
+ client.track("purchase"); // no-op
176
+ client.clearOverrides();
177
+ ```
178
+
179
+ The `override*` setters also work on a **normal** client (not just `forTesting()`),
180
+ mirroring Statsig — a programmatic override always wins over the fetched values.
181
+ In the browser the precedence is: programmatic override > URL/devtools override
182
+ (`?se_gate_…` / `?se_config_…` / `?se_exp_…`) > the server's evaluation.
183
+
184
+ **API:**
185
+
186
+ ```ts
187
+ overrideFlag(name: string, value: boolean): void;
188
+ overrideConfig(name: string, value: unknown): void;
189
+ overrideExperiment(name: string, group: string, params: Record<string, unknown>): void;
190
+ clearOverrides(): void;
191
+ ```
192
+
193
+ ## Default values
194
+
195
+ `getFlag` / `getConfig` take a caller-supplied default that is returned **only
196
+ when the value can't be evaluated** — the client isn't initialized yet, or the
197
+ key isn't in the loaded rules. A flag that legitimately evaluates to `false`
198
+ (disabled, rule denied, rolled out to 0%) still returns `false`, never the
199
+ default.
200
+
201
+ ```ts
202
+ // Server
203
+ flags.get("new_checkout", { user_id: "u1" }); // false for a missing flag
204
+ flags.get("new_checkout", { user_id: "u1" }, true); // true only if not-ready/not-found
205
+ flags.getConfig("limits", { defaultValue: { max: 50 } }); // default when key absent
206
+
207
+ // Browser (no user arg)
208
+ client.getFlag("new_checkout"); // false for a missing flag
209
+ client.getFlag("new_checkout", true); // default when not-ready/not-found
210
+ client.getConfig("limits", { defaultValue: { max: 50 } });
211
+ ```
212
+
213
+ The legacy `getConfig(name, decode)` callback signature still works unchanged;
214
+ the options object (`{ decode?, defaultValue? }`) is the new, additive form.
215
+
216
+ ## Evaluation detail
217
+
218
+ `getFlagDetail(name[, user])` returns `{ value, reason }` (LaunchDarkly
219
+ `variationDetail` parity) so you can see *why* a flag resolved the way it did:
220
+
221
+ ```ts
222
+ import type { FlagReason } from "@shipeasy/sdk/server"; // or /client
223
+
224
+ const d = client.getFlagDetail("new_checkout", { user_id: "u1" });
225
+ // → { value: true, reason: "RULE_MATCH" }
226
+ ```
227
+
228
+ `FlagReason` is one of:
229
+
230
+ | reason | meaning |
231
+ | ------------------ | ---------------------------------------------------------- |
232
+ | `CLIENT_NOT_READY` | no rules loaded yet (`init()` / `identify()` pending) |
233
+ | `FLAG_NOT_FOUND` | the gate name isn't in the loaded rules |
234
+ | `OFF` | the gate exists but is disabled / killed (server only) |
235
+ | `OVERRIDE` | a local override (or `?se_gate_…` URL override) decided it |
236
+ | `RULE_MATCH` | the gate evaluated `true` |
237
+ | `DEFAULT` | the gate evaluated `false` |
238
+
239
+ `getFlag` is implemented on top of `getFlagDetail` (single evaluation, single
240
+ telemetry beacon). On the browser the server pre-evaluates the gate's
241
+ enabled/killed state into a boolean, so `OFF` folds into `DEFAULT` there.
242
+
243
+ ## Change listeners
244
+
245
+ The server `FlagsClient` fires registered listeners after a **background poll
246
+ returns new data** (HTTP 200, not 304) — handy for invalidating a cache or
247
+ re-rendering when a flag flips. Returns an unsubscribe function. Never fires in
248
+ test/offline mode (no polling happens):
249
+
250
+ ```ts
251
+ const client = new FlagsClient({ apiKey: process.env.SHIPEASY_SERVER_KEY! });
252
+ await client.init();
253
+ const unsubscribe = client.onChange(() => {
254
+ console.log("flag rules changed — re-evaluating");
255
+ });
256
+ // later…
257
+ unsubscribe();
258
+ ```
259
+
260
+ The browser `FlagsClientBrowser` already exposes the equivalent `subscribe()`
261
+ (fires after each `identify()` / override change).
262
+
263
+ ## Offline snapshot
264
+
265
+ Build a fully offline server client from a captured snapshot — zero network.
266
+ Evaluations run the real eval against the snapshot; `init()`/`initOnce()`/
267
+ `track()` are no-ops and overrides still apply on top. The snapshot is just the
268
+ two SDK wire bodies:
269
+
270
+ ```jsonc
271
+ // snapshot.json
272
+ {
273
+ "flags": /* body of GET /sdk/flags */,
274
+ "experiments": /* body of GET /sdk/experiments */
275
+ }
276
+ ```
277
+
278
+ ```ts
279
+ import { FlagsClient } from "@shipeasy/sdk/server";
280
+
281
+ const client = FlagsClient.fromFile("./snapshot.json");
282
+ // or, if you already hold the parsed object:
283
+ const client = FlagsClient.fromSnapshot({ flags, experiments });
284
+
285
+ client.getFlag("new_checkout", { user_id: "u1" });
286
+ ```
287
+
288
+ `fromFile` is Node-only (it reads the file with `node:fs`); `fromSnapshot` works
289
+ anywhere.
290
+
133
291
  ## Devtools overlay
134
292
 
135
293
  Press `Shift+Alt+S` on any page running the SDK (or append `?se=1` to the
@@ -120,6 +120,33 @@ interface ExperimentResult<P> {
120
120
  group: string;
121
121
  params: P;
122
122
  }
123
+ /**
124
+ * Why a flag evaluated the way it did (LaunchDarkly variationDetail parity).
125
+ * Computed at the client boundary:
126
+ * - CLIENT_NOT_READY — no eval result yet (identify() hasn't resolved)
127
+ * - FLAG_NOT_FOUND — the gate name isn't present in the eval result
128
+ * - OFF — folded into DEFAULT here (the server pre-evaluates the
129
+ * gate's enabled/killed state into a plain boolean, so the browser can't
130
+ * distinguish a disabled gate from one a rule denied)
131
+ * - OVERRIDE — a local override or a ?se_gate_/?se_ks_ URL override
132
+ * decided the value
133
+ * - RULE_MATCH — the gate evaluated true
134
+ * - DEFAULT — the gate evaluated false
135
+ */
136
+ declare const FLAG_REASONS: readonly ["CLIENT_NOT_READY", "FLAG_NOT_FOUND", "OFF", "OVERRIDE", "RULE_MATCH", "DEFAULT"];
137
+ type FlagReason = (typeof FLAG_REASONS)[number];
138
+ interface FlagDetail {
139
+ value: boolean;
140
+ reason: FlagReason;
141
+ }
142
+ /** Options object form of `getConfig` — keeps the legacy `decode` callback and
143
+ * adds a `defaultValue` returned when the config key is absent. */
144
+ interface GetConfigOptions<T = unknown> {
145
+ /** Decode the raw stored value into the typed shape callers want. */
146
+ decode?: (raw: unknown) => T;
147
+ /** Returned when the config key is absent (not overridden, not in the eval result). */
148
+ defaultValue?: T;
149
+ }
123
150
  interface EvalExpResult {
124
151
  inExperiment: boolean;
125
152
  group: string;
@@ -180,6 +207,13 @@ interface FlagsClientBrowserOptions {
180
207
  disableTelemetry?: boolean;
181
208
  /** Override the telemetry beacon host. Defaults to {@link DEFAULT_TELEMETRY_URL}. */
182
209
  telemetryUrl?: string;
210
+ /**
211
+ * Test mode — no network at all. identify()/init are no-ops (never call
212
+ * /sdk/evaluate), track() is a no-op, telemetry is forced off, and the client
213
+ * starts "ready" with an empty eval result. Prefer the
214
+ * {@link FlagsClientBrowser.forTesting} factory over passing this directly.
215
+ */
216
+ testMode?: boolean;
183
217
  }
184
218
  declare class FlagsClientBrowser {
185
219
  private readonly sdkKey;
@@ -198,8 +232,26 @@ declare class FlagsClientBrowser {
198
232
  private listeners;
199
233
  private overrideListenerInstalled;
200
234
  private identifySeq;
235
+ private readonly testMode;
236
+ private readonly flagOverrides;
237
+ private readonly configOverrides;
238
+ private readonly experimentOverrides;
201
239
  private onOverrideChange;
202
240
  constructor(opts: FlagsClientBrowserOptions);
241
+ /**
242
+ * Build a no-network, immediately-usable browser client for tests
243
+ * (Statsig-style). identify() is a no-op (never calls /sdk/evaluate), track()
244
+ * is a no-op, telemetry is disabled, and the client is already ready — seed
245
+ * every entity with overrideFlag/overrideConfig/overrideExperiment. No SDK
246
+ * key required.
247
+ *
248
+ * ```ts
249
+ * const client = FlagsClientBrowser.forTesting();
250
+ * client.overrideFlag("new_checkout", true);
251
+ * client.getFlag("new_checkout"); // true
252
+ * ```
253
+ */
254
+ static forTesting(opts?: Partial<FlagsClientBrowserOptions>): FlagsClientBrowser;
203
255
  identify(user: User): Promise<void>;
204
256
  /**
205
257
  * Report a structured error into the errors primitive. Flushes immediately
@@ -210,8 +262,35 @@ declare class FlagsClientBrowser {
210
262
  get ready(): boolean;
211
263
  private notify;
212
264
  initFromBootstrap(data: EvalResponse): void;
213
- getFlag(name: string): boolean;
265
+ /** Force `getFlag(name)` to return `value`, ignoring URL overrides + eval. */
266
+ overrideFlag(name: string, value: boolean): void;
267
+ /** Force `getConfig(name)` to return `value`, ignoring URL overrides + eval. */
268
+ overrideConfig(name: string, value: unknown): void;
269
+ /**
270
+ * Force `getExperiment(name, …)` to return `{ inExperiment: true, group, params }`,
271
+ * ignoring URL overrides, the eval result, and exposure logging.
272
+ */
273
+ overrideExperiment(name: string, group: string, params: Record<string, unknown>): void;
274
+ /** Remove every programmatic override set via the override* methods. */
275
+ clearOverrides(): void;
276
+ /**
277
+ * Evaluate a gate and report WHY (LaunchDarkly variationDetail parity). A
278
+ * local override OR a ?se_gate_/?se_ks_ URL override short-circuits BEFORE
279
+ * telemetry; otherwise exactly one "gate" beacon is emitted. The server
280
+ * pre-evaluates a gate's enabled/killed state into a plain boolean, so OFF
281
+ * folds into DEFAULT here (the browser can't tell "disabled" from "rule
282
+ * denied").
283
+ */
284
+ getFlagDetail(name: string): FlagDetail;
285
+ /**
286
+ * Read a feature gate. Returns `defaultValue` ONLY when the gate cannot be
287
+ * evaluated (not ready or flag not found) — never for a gate that legitimately
288
+ * evaluates to false. Plain `getFlag(name)` keeps returning false for a
289
+ * missing flag.
290
+ */
291
+ getFlag(name: string, defaultValue?: boolean): boolean;
214
292
  getConfig<T = unknown>(name: string, decode?: (raw: unknown) => T): T | undefined;
293
+ getConfig<T = unknown>(name: string, opts: GetConfigOptions<T>): T;
215
294
  getExperiment<P extends Record<string, unknown>>(name: string, defaultParams: P, decode?: (raw: unknown) => P, variants?: Record<string, Partial<P>>): ExperimentResult<P>;
216
295
  /**
217
296
  * Subscribe to state changes — fires after identify() completes and on
@@ -388,7 +467,9 @@ declare const flags: {
388
467
  * Everything else gates on _mountedAndReady to prevent hydration mismatches on
389
468
  * force-static pages where SSR has no flag data.
390
469
  */
391
- get(name: string): boolean;
470
+ get(name: string, defaultValue?: boolean): boolean;
471
+ /** Evaluate a gate and report why (value + reason). See {@link FlagDetail}. */
472
+ getDetail(name: string): FlagDetail;
392
473
  getConfig<T = unknown>(name: string, decode?: (raw: unknown) => T): T | undefined;
393
474
  getExperiment<P extends Record<string, unknown>>(name: string, defaultParams: P, decode?: (raw: unknown) => P, variants?: Record<string, Partial<P>>): ExperimentResult<P>;
394
475
  track(eventName: string, props?: Record<string, unknown>): void;
@@ -509,4 +590,4 @@ interface I18nFacade {
509
590
  }
510
591
  declare const i18n: I18nFacade;
511
592
 
512
- export { type AutoCollectGroups, type BootstrapPayload, type Consequence, type ExperimentResult, FlagsClientBrowser, type FlagsClientBrowserEnv, type FlagsClientBrowserOptions, type I18nFacade, type I18nKey, type I18nRichComponents, type I18nString, type I18nTagRenderer, type I18nVariables, LABEL_MARKER_END, LABEL_MARKER_RE, LABEL_MARKER_SEP, LABEL_MARKER_START, type LabelAttrs, type SeeApi, type SeeChain, type SeeControlFlowChain, type SeeErrorEvent, type SeeExtras, type SeeKind, type SeeViolationChain, type ShipeasyClientConfig, type ShipeasySdkBridge, type User, type Violation, _resetShipeasyForTests, attachDevtools, configureShipeasy, encodeLabelMarker, flags, getShipeasyClient, i18n, injectCorrelationHeader, isDevtoolsRequested, labelAttrs, loadDevtools, readConfigOverride, readExpOverride, readGateOverride, sameOrigin, see, shipeasy, version };
593
+ export { type AutoCollectGroups, type BootstrapPayload, type Consequence, type ExperimentResult, FLAG_REASONS, type FlagDetail, type FlagReason, FlagsClientBrowser, type FlagsClientBrowserEnv, type FlagsClientBrowserOptions, type GetConfigOptions, type I18nFacade, type I18nKey, type I18nRichComponents, type I18nString, type I18nTagRenderer, type I18nVariables, LABEL_MARKER_END, LABEL_MARKER_RE, LABEL_MARKER_SEP, LABEL_MARKER_START, type LabelAttrs, type SeeApi, type SeeChain, type SeeControlFlowChain, type SeeErrorEvent, type SeeExtras, type SeeKind, type SeeViolationChain, type ShipeasyClientConfig, type ShipeasySdkBridge, type User, type Violation, _resetShipeasyForTests, attachDevtools, configureShipeasy, encodeLabelMarker, flags, getShipeasyClient, i18n, injectCorrelationHeader, isDevtoolsRequested, labelAttrs, loadDevtools, readConfigOverride, readExpOverride, readGateOverride, sameOrigin, see, shipeasy, version };
@@ -120,6 +120,33 @@ interface ExperimentResult<P> {
120
120
  group: string;
121
121
  params: P;
122
122
  }
123
+ /**
124
+ * Why a flag evaluated the way it did (LaunchDarkly variationDetail parity).
125
+ * Computed at the client boundary:
126
+ * - CLIENT_NOT_READY — no eval result yet (identify() hasn't resolved)
127
+ * - FLAG_NOT_FOUND — the gate name isn't present in the eval result
128
+ * - OFF — folded into DEFAULT here (the server pre-evaluates the
129
+ * gate's enabled/killed state into a plain boolean, so the browser can't
130
+ * distinguish a disabled gate from one a rule denied)
131
+ * - OVERRIDE — a local override or a ?se_gate_/?se_ks_ URL override
132
+ * decided the value
133
+ * - RULE_MATCH — the gate evaluated true
134
+ * - DEFAULT — the gate evaluated false
135
+ */
136
+ declare const FLAG_REASONS: readonly ["CLIENT_NOT_READY", "FLAG_NOT_FOUND", "OFF", "OVERRIDE", "RULE_MATCH", "DEFAULT"];
137
+ type FlagReason = (typeof FLAG_REASONS)[number];
138
+ interface FlagDetail {
139
+ value: boolean;
140
+ reason: FlagReason;
141
+ }
142
+ /** Options object form of `getConfig` — keeps the legacy `decode` callback and
143
+ * adds a `defaultValue` returned when the config key is absent. */
144
+ interface GetConfigOptions<T = unknown> {
145
+ /** Decode the raw stored value into the typed shape callers want. */
146
+ decode?: (raw: unknown) => T;
147
+ /** Returned when the config key is absent (not overridden, not in the eval result). */
148
+ defaultValue?: T;
149
+ }
123
150
  interface EvalExpResult {
124
151
  inExperiment: boolean;
125
152
  group: string;
@@ -180,6 +207,13 @@ interface FlagsClientBrowserOptions {
180
207
  disableTelemetry?: boolean;
181
208
  /** Override the telemetry beacon host. Defaults to {@link DEFAULT_TELEMETRY_URL}. */
182
209
  telemetryUrl?: string;
210
+ /**
211
+ * Test mode — no network at all. identify()/init are no-ops (never call
212
+ * /sdk/evaluate), track() is a no-op, telemetry is forced off, and the client
213
+ * starts "ready" with an empty eval result. Prefer the
214
+ * {@link FlagsClientBrowser.forTesting} factory over passing this directly.
215
+ */
216
+ testMode?: boolean;
183
217
  }
184
218
  declare class FlagsClientBrowser {
185
219
  private readonly sdkKey;
@@ -198,8 +232,26 @@ declare class FlagsClientBrowser {
198
232
  private listeners;
199
233
  private overrideListenerInstalled;
200
234
  private identifySeq;
235
+ private readonly testMode;
236
+ private readonly flagOverrides;
237
+ private readonly configOverrides;
238
+ private readonly experimentOverrides;
201
239
  private onOverrideChange;
202
240
  constructor(opts: FlagsClientBrowserOptions);
241
+ /**
242
+ * Build a no-network, immediately-usable browser client for tests
243
+ * (Statsig-style). identify() is a no-op (never calls /sdk/evaluate), track()
244
+ * is a no-op, telemetry is disabled, and the client is already ready — seed
245
+ * every entity with overrideFlag/overrideConfig/overrideExperiment. No SDK
246
+ * key required.
247
+ *
248
+ * ```ts
249
+ * const client = FlagsClientBrowser.forTesting();
250
+ * client.overrideFlag("new_checkout", true);
251
+ * client.getFlag("new_checkout"); // true
252
+ * ```
253
+ */
254
+ static forTesting(opts?: Partial<FlagsClientBrowserOptions>): FlagsClientBrowser;
203
255
  identify(user: User): Promise<void>;
204
256
  /**
205
257
  * Report a structured error into the errors primitive. Flushes immediately
@@ -210,8 +262,35 @@ declare class FlagsClientBrowser {
210
262
  get ready(): boolean;
211
263
  private notify;
212
264
  initFromBootstrap(data: EvalResponse): void;
213
- getFlag(name: string): boolean;
265
+ /** Force `getFlag(name)` to return `value`, ignoring URL overrides + eval. */
266
+ overrideFlag(name: string, value: boolean): void;
267
+ /** Force `getConfig(name)` to return `value`, ignoring URL overrides + eval. */
268
+ overrideConfig(name: string, value: unknown): void;
269
+ /**
270
+ * Force `getExperiment(name, …)` to return `{ inExperiment: true, group, params }`,
271
+ * ignoring URL overrides, the eval result, and exposure logging.
272
+ */
273
+ overrideExperiment(name: string, group: string, params: Record<string, unknown>): void;
274
+ /** Remove every programmatic override set via the override* methods. */
275
+ clearOverrides(): void;
276
+ /**
277
+ * Evaluate a gate and report WHY (LaunchDarkly variationDetail parity). A
278
+ * local override OR a ?se_gate_/?se_ks_ URL override short-circuits BEFORE
279
+ * telemetry; otherwise exactly one "gate" beacon is emitted. The server
280
+ * pre-evaluates a gate's enabled/killed state into a plain boolean, so OFF
281
+ * folds into DEFAULT here (the browser can't tell "disabled" from "rule
282
+ * denied").
283
+ */
284
+ getFlagDetail(name: string): FlagDetail;
285
+ /**
286
+ * Read a feature gate. Returns `defaultValue` ONLY when the gate cannot be
287
+ * evaluated (not ready or flag not found) — never for a gate that legitimately
288
+ * evaluates to false. Plain `getFlag(name)` keeps returning false for a
289
+ * missing flag.
290
+ */
291
+ getFlag(name: string, defaultValue?: boolean): boolean;
214
292
  getConfig<T = unknown>(name: string, decode?: (raw: unknown) => T): T | undefined;
293
+ getConfig<T = unknown>(name: string, opts: GetConfigOptions<T>): T;
215
294
  getExperiment<P extends Record<string, unknown>>(name: string, defaultParams: P, decode?: (raw: unknown) => P, variants?: Record<string, Partial<P>>): ExperimentResult<P>;
216
295
  /**
217
296
  * Subscribe to state changes — fires after identify() completes and on
@@ -388,7 +467,9 @@ declare const flags: {
388
467
  * Everything else gates on _mountedAndReady to prevent hydration mismatches on
389
468
  * force-static pages where SSR has no flag data.
390
469
  */
391
- get(name: string): boolean;
470
+ get(name: string, defaultValue?: boolean): boolean;
471
+ /** Evaluate a gate and report why (value + reason). See {@link FlagDetail}. */
472
+ getDetail(name: string): FlagDetail;
392
473
  getConfig<T = unknown>(name: string, decode?: (raw: unknown) => T): T | undefined;
393
474
  getExperiment<P extends Record<string, unknown>>(name: string, defaultParams: P, decode?: (raw: unknown) => P, variants?: Record<string, Partial<P>>): ExperimentResult<P>;
394
475
  track(eventName: string, props?: Record<string, unknown>): void;
@@ -509,4 +590,4 @@ interface I18nFacade {
509
590
  }
510
591
  declare const i18n: I18nFacade;
511
592
 
512
- export { type AutoCollectGroups, type BootstrapPayload, type Consequence, type ExperimentResult, FlagsClientBrowser, type FlagsClientBrowserEnv, type FlagsClientBrowserOptions, type I18nFacade, type I18nKey, type I18nRichComponents, type I18nString, type I18nTagRenderer, type I18nVariables, LABEL_MARKER_END, LABEL_MARKER_RE, LABEL_MARKER_SEP, LABEL_MARKER_START, type LabelAttrs, type SeeApi, type SeeChain, type SeeControlFlowChain, type SeeErrorEvent, type SeeExtras, type SeeKind, type SeeViolationChain, type ShipeasyClientConfig, type ShipeasySdkBridge, type User, type Violation, _resetShipeasyForTests, attachDevtools, configureShipeasy, encodeLabelMarker, flags, getShipeasyClient, i18n, injectCorrelationHeader, isDevtoolsRequested, labelAttrs, loadDevtools, readConfigOverride, readExpOverride, readGateOverride, sameOrigin, see, shipeasy, version };
593
+ export { type AutoCollectGroups, type BootstrapPayload, type Consequence, type ExperimentResult, FLAG_REASONS, type FlagDetail, type FlagReason, FlagsClientBrowser, type FlagsClientBrowserEnv, type FlagsClientBrowserOptions, type GetConfigOptions, type I18nFacade, type I18nKey, type I18nRichComponents, type I18nString, type I18nTagRenderer, type I18nVariables, LABEL_MARKER_END, LABEL_MARKER_RE, LABEL_MARKER_SEP, LABEL_MARKER_START, type LabelAttrs, type SeeApi, type SeeChain, type SeeControlFlowChain, type SeeErrorEvent, type SeeExtras, type SeeKind, type SeeViolationChain, type ShipeasyClientConfig, type ShipeasySdkBridge, type User, type Violation, _resetShipeasyForTests, attachDevtools, configureShipeasy, encodeLabelMarker, flags, getShipeasyClient, i18n, injectCorrelationHeader, isDevtoolsRequested, labelAttrs, loadDevtools, readConfigOverride, readExpOverride, readGateOverride, sameOrigin, see, shipeasy, version };
@@ -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,
@@ -358,6 +359,14 @@ var SeeLimiter = class {
358
359
 
359
360
  // src/client/index.ts
360
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
+ ];
361
370
  var FLUSH_INTERVAL_MS = 5e3;
362
371
  var MAX_BUFFER = 100;
363
372
  var ANON_ID_KEY = "__se_anon_id";
@@ -869,7 +878,7 @@ function readExperimentOverridesFromUrl() {
869
878
  }
870
879
  return out;
871
880
  }
872
- var FlagsClientBrowser = class {
881
+ var FlagsClientBrowser = class _FlagsClientBrowser {
873
882
  sdkKey;
874
883
  baseUrl;
875
884
  autoGuardrails;
@@ -888,6 +897,16 @@ var FlagsClientBrowser = class {
888
897
  // Monotonic counter so a later identify() always wins even if its /sdk/evaluate
889
898
  // response races and lands before an earlier in-flight call's response.
890
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();
891
910
  onOverrideChange = () => {
892
911
  this.installBridge();
893
912
  this.notify();
@@ -896,6 +915,7 @@ var FlagsClientBrowser = class {
896
915
  this.sdkKey = opts.sdkKey;
897
916
  this.baseUrl = (opts.baseUrl ?? "https://edge.shipeasy.dev").replace(/\/$/, "");
898
917
  this.env = opts.env ?? "prod";
918
+ this.testMode = opts.testMode === true;
899
919
  this.autoGuardrails = opts.autoGuardrails !== false;
900
920
  this.autoCollectAlways = opts.autoCollectAlways === true;
901
921
  const g = opts.autoGuardrailGroups ?? {};
@@ -911,11 +931,42 @@ var FlagsClientBrowser = class {
911
931
  sdkKey: this.sdkKey,
912
932
  side: "client",
913
933
  env: this.env,
914
- 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
915
962
  });
916
- void this.buffer.flushPendingAlias();
917
963
  }
918
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
+ }
919
970
  const seq = ++this.identifySeq;
920
971
  const prevUserId = this.userId;
921
972
  if (user.user_id !== void 0) this.userId = user.user_id;
@@ -990,22 +1041,75 @@ var FlagsClientBrowser = class {
990
1041
  initFromBootstrap(data) {
991
1042
  this.evalResult = data;
992
1043
  }
993
- 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" };
994
1084
  this.telemetry.emit("gate", name);
995
- if (this.evalResult === null) return false;
996
- const ov = readGateOverride(name);
997
- if (ov !== null) return ov;
998
- 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" };
999
1089
  }
1000
- 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) {
1001
1102
  this.telemetry.emit("config", name);
1002
- if (this.evalResult === null) return void 0;
1003
- const ov = readConfigOverride(name);
1004
- const raw = ov !== void 0 ? ov : this.evalResult.configs?.[name];
1005
- if (raw === void 0) return void 0;
1006
- 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;
1007
1111
  try {
1008
- return decode(raw);
1112
+ return opts.decode(raw);
1009
1113
  } catch (err) {
1010
1114
  console.warn(`[shipeasy] getConfig('${name}') decode failed:`, String(err));
1011
1115
  return void 0;
@@ -1018,6 +1122,12 @@ var FlagsClientBrowser = class {
1018
1122
  group: "control",
1019
1123
  params: defaultParams
1020
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
+ }
1021
1131
  const ov = readExpOverride(name);
1022
1132
  if (ov !== null) {
1023
1133
  const variantParams = variants?.[ov];
@@ -1069,6 +1179,7 @@ var FlagsClientBrowser = class {
1069
1179
  return bridge;
1070
1180
  }
1071
1181
  track(eventName, props) {
1182
+ if (this.testMode) return;
1072
1183
  this.buffer.pushMetric(eventName, this.userId, this.anonId, props);
1073
1184
  }
1074
1185
  /**
@@ -1287,12 +1398,19 @@ var flags = {
1287
1398
  * Everything else gates on _mountedAndReady to prevent hydration mismatches on
1288
1399
  * force-static pages where SSR has no flag data.
1289
1400
  */
1290
- get(name) {
1401
+ get(name, defaultValue = false) {
1291
1402
  const bs = getBootstrap();
1292
1403
  if (bs !== null && name in bs.flags) return bs.flags[name];
1293
- if (!_mountedAndReady) return false;
1294
- if (_client) return _client.getFlag(name);
1295
- 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" };
1296
1414
  },
1297
1415
  getConfig(name, decode) {
1298
1416
  const bs = getBootstrap();
@@ -1624,6 +1742,7 @@ var i18n = {
1624
1742
  };
1625
1743
  // Annotate the CommonJS export names for ESM import in node:
1626
1744
  0 && (module.exports = {
1745
+ FLAG_REASONS,
1627
1746
  FlagsClientBrowser,
1628
1747
  LABEL_MARKER_END,
1629
1748
  LABEL_MARKER_RE,