@shipeasy/sdk 6.3.1 → 7.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.
@@ -4,43 +4,12 @@ import { ErrorCode } from "@openfeature/server-sdk";
4
4
  // src/server/index.ts
5
5
  import { AsyncLocalStorage } from "async_hooks";
6
6
 
7
- // src/logger.ts
8
- var RANK = {
9
- silent: 0,
10
- error: 1,
11
- warn: 2,
12
- info: 3,
13
- debug: 4
14
- };
15
- var currentLevel = "warn";
16
- function write(method, args) {
17
- try {
18
- const c = globalThis.console;
19
- if (!c) return;
20
- const fn = c[method] ?? c.log;
21
- if (typeof fn === "function") fn.call(c, ...args);
22
- } catch {
23
- }
24
- }
25
- var logger = {
26
- error(...args) {
27
- if (RANK[currentLevel] >= RANK.error) write("error", args);
28
- },
29
- warn(...args) {
30
- if (RANK[currentLevel] >= RANK.warn) write("warn", args);
31
- },
32
- info(...args) {
33
- if (RANK[currentLevel] >= RANK.info) write("info", args);
34
- },
35
- debug(...args) {
36
- if (RANK[currentLevel] >= RANK.debug) write("debug", args);
37
- }
38
- };
39
-
40
7
  // src/see/core.ts
41
8
  var SEE_MAX_SUBJECT = 200;
42
9
  var SEE_MAX_EXTRA_VALUE = 200;
43
10
  var SEE_MAX_EXTRA_KEYS = 20;
11
+ var SEE_DEDUP_WINDOW_MS = 3e4;
12
+ var SEE_MAX_PER_SESSION = 25;
44
13
  function causesThe(subject) {
45
14
  return {
46
15
  to(outcome) {
@@ -151,6 +120,69 @@ function startControlFlowChain(err) {
151
120
  }
152
121
  };
153
122
  }
123
+ function topStackLine(stack) {
124
+ if (!stack) return "";
125
+ for (const line of stack.split("\n")) {
126
+ if (/^\s*at |@|:\d+:\d+/.test(line)) return line.trim().slice(0, 200);
127
+ }
128
+ return "";
129
+ }
130
+ var SeeLimiter = class {
131
+ constructor(maxPerSession = SEE_MAX_PER_SESSION, dedupWindowMs = SEE_DEDUP_WINDOW_MS) {
132
+ this.maxPerSession = maxPerSession;
133
+ this.dedupWindowMs = dedupWindowMs;
134
+ }
135
+ maxPerSession;
136
+ dedupWindowMs;
137
+ lastSent = /* @__PURE__ */ new Map();
138
+ sent = 0;
139
+ shouldSend(ev) {
140
+ if (this.sent >= this.maxPerSession) return false;
141
+ const key = `${ev.kind}|${ev.error_type}|${ev.message.slice(0, 200)}|${topStackLine(ev.stack)}`;
142
+ const now = Date.now();
143
+ const prev = this.lastSent.get(key);
144
+ if (prev !== void 0 && now - prev < this.dedupWindowMs) return false;
145
+ this.lastSent.set(key, now);
146
+ this.sent += 1;
147
+ return true;
148
+ }
149
+ };
150
+
151
+ // src/internal-report.ts
152
+ var limiter = new SeeLimiter();
153
+
154
+ // src/logger.ts
155
+ var RANK = {
156
+ silent: 0,
157
+ error: 1,
158
+ warn: 2,
159
+ info: 3,
160
+ debug: 4
161
+ };
162
+ var currentLevel = "warn";
163
+ function write(method, args) {
164
+ try {
165
+ const c = globalThis.console;
166
+ if (!c) return;
167
+ const fn = c[method] ?? c.log;
168
+ if (typeof fn === "function") fn.call(c, ...args);
169
+ } catch {
170
+ }
171
+ }
172
+ var logger = {
173
+ error(...args) {
174
+ if (RANK[currentLevel] >= RANK.error) write("error", args);
175
+ },
176
+ warn(...args) {
177
+ if (RANK[currentLevel] >= RANK.warn) write("warn", args);
178
+ },
179
+ info(...args) {
180
+ if (RANK[currentLevel] >= RANK.info) write("info", args);
181
+ },
182
+ debug(...args) {
183
+ if (RANK[currentLevel] >= RANK.debug) write("debug", args);
184
+ }
185
+ };
154
186
 
155
187
  // src/server/index.ts
156
188
  var _I18N_SSR_SYM = /* @__PURE__ */ Symbol.for("@shipeasy/sdk:ssr-i18n");
@@ -25,25 +25,6 @@ interface User {
25
25
  user_id?: string;
26
26
  [attr: string]: unknown;
27
27
  }
28
- interface ExperimentResult<P> {
29
- inExperiment: boolean;
30
- group: string;
31
- params: P;
32
- }
33
- /** Options object form of `getExperiment` — the legacy `decode`/`variants`
34
- * positional args plus per-call exposure control. */
35
- interface GetExperimentOptions<P> {
36
- /** Decode the raw stored params into the typed shape callers want. */
37
- decode?: (raw: unknown) => P;
38
- /** Variant-specific param overrides merged on top of group params. */
39
- variants?: Record<string, Partial<P>>;
40
- /**
41
- * Override automatic exposure logging for this read. Defaults to the client's
42
- * setting (`disableAutoExposure` flips it). `false` reads the variant without
43
- * logging an exposure — pair with `logExposure(name)` at render time.
44
- */
45
- logExposure?: boolean;
46
- }
47
28
  /**
48
29
  * Why a flag evaluated the way it did (LaunchDarkly variationDetail parity).
49
30
  * Computed at the client boundary:
@@ -75,11 +56,19 @@ interface EvalExpResult {
75
56
  inExperiment: boolean;
76
57
  group: string;
77
58
  params: Record<string, unknown>;
59
+ /** The universe this experiment belongs to — lets `universe(name).assign()`
60
+ * find the enrolled experiment within a universe. */
61
+ universe?: string;
78
62
  }
79
63
  interface EvalResponse {
80
64
  flags: Record<string, boolean>;
81
65
  configs: Record<string, unknown>;
82
66
  experiments: Record<string, EvalExpResult>;
67
+ /** Per-universe param defaults (name → default map) so `universe(name).get()`
68
+ * resolves to the universe default even when the unit is not enrolled. */
69
+ universes?: Record<string, {
70
+ defaults: Record<string, unknown>;
71
+ }>;
83
72
  /**
84
73
  * Killswitch state, flattened by the server. A boolean means the killswitch
85
74
  * is whole-killed; an object means it's not whole-killed and carries per-
@@ -93,6 +82,23 @@ interface EvalResponse {
93
82
  */
94
83
  sticky?: Record<string, StickyCookieEntry>;
95
84
  }
85
+ /**
86
+ * The result of `universe(name).assign()` — the visitor's standing in a universe.
87
+ * A universe is a mutual-exclusion pool, so the visitor lands in **at most one**
88
+ * experiment. Never throws: an un-enrolled visitor still resolves `get()` to the
89
+ * universe defaults (or your fallback). `assign()` auto-logs one exposure when
90
+ * enrolled (subject to `disableAutoExposure`).
91
+ */
92
+ interface Assignment {
93
+ /** The experiment the visitor landed in, or `null` when not enrolled. */
94
+ readonly name: string | null;
95
+ /** The assigned variant/group name, or `null` when not enrolled. */
96
+ readonly group: string | null;
97
+ /** True iff the visitor is enrolled in an experiment in this universe. */
98
+ readonly enrolled: boolean;
99
+ /** Read a resolved param: variant override ?? universe default ?? `fallback`. */
100
+ get<T = unknown>(field: string, fallback?: T): T | undefined;
101
+ }
96
102
  /** One persisted sticky assignment: group + 8-char salt prefix (reshuffle key). */
97
103
  interface StickyCookieEntry {
98
104
  g: string;
@@ -104,6 +110,33 @@ interface AutoCollectGroups {
104
110
  engagement: boolean;
105
111
  }
106
112
  type EngineEnv = "dev" | "staging" | "prod";
113
+ /**
114
+ * A pluggable persistence backend for the anonymous id. Primarily for non-DOM
115
+ * runtimes (React Native / Expo) where there is no cookie or `localStorage`, so
116
+ * the anon id would otherwise regenerate every app launch and re-bucket the
117
+ * visitor. Point it at `@react-native-async-storage/async-storage` (or any
118
+ * store) and the SDK hydrates a stable id before the first `/sdk/evaluate`:
119
+ *
120
+ * ```ts
121
+ * import AsyncStorage from "@react-native-async-storage/async-storage";
122
+ * configure({
123
+ * clientKey: "...",
124
+ * anonymousStore: {
125
+ * get: (k) => AsyncStorage.getItem(k),
126
+ * set: (k, v) => AsyncStorage.setItem(k, v),
127
+ * remove: (k) => AsyncStorage.removeItem(k),
128
+ * },
129
+ * });
130
+ * ```
131
+ *
132
+ * Methods may be sync or async — the SDK awaits either. Any thrown error is
133
+ * swallowed (the in-memory id is used as a fallback).
134
+ */
135
+ interface AnonymousStore {
136
+ get(key: string): string | null | Promise<string | null>;
137
+ set(key: string, value: string): void | Promise<void>;
138
+ remove(key: string): void | Promise<void>;
139
+ }
107
140
  interface EngineOptions {
108
141
  sdkKey: string;
109
142
  baseUrl?: string;
@@ -125,10 +158,26 @@ interface EngineOptions {
125
158
  /** Which published env to read values from. Defaults to "prod". */
126
159
  env?: EngineEnv;
127
160
  /**
128
- * Per-evaluation usage telemetry. ON by default each getFlag/getConfig/
161
+ * Master network switch when `false`, the browser SDK makes NO outbound
162
+ * requests at all: identify()/init never call /sdk/evaluate (getters read code
163
+ * defaults / overrides), track() and exposure logging are no-ops, usage
164
+ * telemetry is off, and SDK-internal error self-monitoring is off.
165
+ *
166
+ * DEFAULT is environment-derived (see {@link isProductionEnv}): ON in
167
+ * production, OFF everywhere else. In the browser there is usually no native
168
+ * `NODE_ENV`, so production is taken from the `env` option above (default
169
+ * `"prod"`) — meaning it defaults ON. Pass `false` (or set `env: "dev"`) to
170
+ * keep a local build quiet.
171
+ */
172
+ isNetworkEnabled?: boolean;
173
+ /**
174
+ * Per-evaluation usage telemetry ("tracking") — each getFlag/getConfig/
129
175
  * getExperiment/getKillswitch call fires one fire-and-forget sendBeacon so
130
- * usage is counted by Cloudflare's native per-path analytics. Pass `true` to
131
- * disable entirely.
176
+ * usage is counted by Cloudflare's native per-path analytics.
177
+ *
178
+ * DEFAULT is environment-derived: ON in production, OFF everywhere else (same
179
+ * inference as {@link isNetworkEnabled}). Pass `true` to force it off, `false`
180
+ * to force it on. Forced off whenever `isNetworkEnabled` is false.
132
181
  */
133
182
  disableTelemetry?: boolean;
134
183
  /** Override the telemetry beacon host. Defaults to {@link DEFAULT_TELEMETRY_URL}. */
@@ -141,6 +190,14 @@ interface EngineOptions {
141
190
  * `silent` < `error` < `warn` < `info` < `debug`. Defaults to `"warn"`.
142
191
  */
143
192
  logLevel?: LogLevel;
193
+ /**
194
+ * Opt out of SDK-internal error self-monitoring. When one of the SDK's own
195
+ * last-resort guards catches an internal failure (an "on our end" bug), the
196
+ * SDK reports it to Shipeasy's own project so we can track SDK bugs across
197
+ * apps — never to your project. ON by default; forced off in test mode. Pass
198
+ * `true` to disable.
199
+ */
200
+ disableInternalErrorReporting?: boolean;
144
201
  /**
145
202
  * Suppress automatic exposure logging in `getExperiment` (Statsig's
146
203
  * `disableExposureLogging`). Default false — reading an enrolled variant
@@ -164,6 +221,14 @@ interface EngineOptions {
164
221
  * lever. Pass `false` to opt out (pure deterministic eval).
165
222
  */
166
223
  stickyBucketing?: boolean;
224
+ /**
225
+ * Pluggable persistence for the anonymous id (React Native / Expo, or any
226
+ * runtime without a cookie / `localStorage`). When set, the SDK hydrates the
227
+ * stored id before the first `/sdk/evaluate` so bucketing stays stable across
228
+ * app launches; on a fresh device it persists the freshly-minted id. See
229
+ * {@link AnonymousStore}.
230
+ */
231
+ anonymousStore?: AnonymousStore;
167
232
  /**
168
233
  * Test mode — no network at all. identify()/init are no-ops (never call
169
234
  * /sdk/evaluate), track() is a no-op, telemetry is forced off, and the client
@@ -184,6 +249,7 @@ declare class Engine {
184
249
  private readonly env;
185
250
  private evalResult;
186
251
  private anonId;
252
+ private anonReady;
187
253
  private userId;
188
254
  private buffer;
189
255
  private telemetry;
@@ -193,11 +259,20 @@ declare class Engine {
193
259
  private overrideListenerInstalled;
194
260
  private identifySeq;
195
261
  private readonly testMode;
262
+ private readonly networkEnabled;
263
+ private readonly offline;
196
264
  private readonly flagOverrides;
197
265
  private readonly configOverrides;
198
266
  private readonly experimentOverrides;
199
267
  private onOverrideChange;
200
268
  constructor(opts: EngineOptions);
269
+ /**
270
+ * Consult the caller-supplied {@link AnonymousStore}: adopt a persisted id so
271
+ * the visitor buckets identically across app launches, or persist the id just
272
+ * minted by getOrCreateAnonId() on a fresh device. Best-effort — any store
273
+ * failure leaves the in-memory id in place.
274
+ */
275
+ private hydrateAnonFromStore;
201
276
  /**
202
277
  * Build a no-network, immediately-usable browser client for tests
203
278
  * (Statsig-style). identify() is a no-op (never calls /sdk/evaluate), track()
@@ -251,16 +326,21 @@ declare class Engine {
251
326
  getFlag(name: string, defaultValue?: boolean): boolean;
252
327
  getConfig<T = unknown>(name: string, decode?: (raw: unknown) => T): T | undefined;
253
328
  getConfig<T = unknown>(name: string, opts: GetConfigOptions<T>): T;
254
- getExperiment<P extends Record<string, unknown>>(name: string, defaultParams: P, decode?: (raw: unknown) => P, variants?: Record<string, Partial<P>>): ExperimentResult<P>;
255
- getExperiment<P extends Record<string, unknown>>(name: string, defaultParams: P, opts: GetExperimentOptions<P>): ExperimentResult<P>;
256
329
  /**
257
- * Manually log an exposure for an enrolled experiment (Statsig's
258
- * `manuallyLogExposure`). Reads the cached eval result; if the visitor is in
259
- * the experiment, pushes the session-deduped exposure. Pair this with the
260
- * render of the treatment when reading with `{ logExposure: false }` (or
261
- * `disableAutoExposure: true`). No-op if the visitor isn't enrolled.
330
+ * Assign the visitor within a universe: `universe("checkout").assign()`. A
331
+ * universe is a mutual-exclusion pool, so the visitor lands in ≤1 experiment;
332
+ * the returned {@link Assignment} exposes `.group` / `.get(field, fallback)`
333
+ * and auto-logs one exposure when enrolled (unless `disableAutoExposure` or
334
+ * `assign({ logExposure: false })`). An un-enrolled visitor still resolves
335
+ * `get()` to the universe defaults. This is the sole experiment read path —
336
+ * there is no `getExperiment` (ask a universe, not an experiment).
262
337
  */
263
- logExposure(name: string): void;
338
+ universe(name: string): {
339
+ assign(opts?: {
340
+ logExposure?: boolean;
341
+ }): Assignment;
342
+ };
343
+ private assignUniverse;
264
344
  /**
265
345
  * Subscribe to state changes — fires after identify() completes and on
266
346
  * `se:override:change` events from the devtools overlay. Returns an
@@ -25,25 +25,6 @@ interface User {
25
25
  user_id?: string;
26
26
  [attr: string]: unknown;
27
27
  }
28
- interface ExperimentResult<P> {
29
- inExperiment: boolean;
30
- group: string;
31
- params: P;
32
- }
33
- /** Options object form of `getExperiment` — the legacy `decode`/`variants`
34
- * positional args plus per-call exposure control. */
35
- interface GetExperimentOptions<P> {
36
- /** Decode the raw stored params into the typed shape callers want. */
37
- decode?: (raw: unknown) => P;
38
- /** Variant-specific param overrides merged on top of group params. */
39
- variants?: Record<string, Partial<P>>;
40
- /**
41
- * Override automatic exposure logging for this read. Defaults to the client's
42
- * setting (`disableAutoExposure` flips it). `false` reads the variant without
43
- * logging an exposure — pair with `logExposure(name)` at render time.
44
- */
45
- logExposure?: boolean;
46
- }
47
28
  /**
48
29
  * Why a flag evaluated the way it did (LaunchDarkly variationDetail parity).
49
30
  * Computed at the client boundary:
@@ -75,11 +56,19 @@ interface EvalExpResult {
75
56
  inExperiment: boolean;
76
57
  group: string;
77
58
  params: Record<string, unknown>;
59
+ /** The universe this experiment belongs to — lets `universe(name).assign()`
60
+ * find the enrolled experiment within a universe. */
61
+ universe?: string;
78
62
  }
79
63
  interface EvalResponse {
80
64
  flags: Record<string, boolean>;
81
65
  configs: Record<string, unknown>;
82
66
  experiments: Record<string, EvalExpResult>;
67
+ /** Per-universe param defaults (name → default map) so `universe(name).get()`
68
+ * resolves to the universe default even when the unit is not enrolled. */
69
+ universes?: Record<string, {
70
+ defaults: Record<string, unknown>;
71
+ }>;
83
72
  /**
84
73
  * Killswitch state, flattened by the server. A boolean means the killswitch
85
74
  * is whole-killed; an object means it's not whole-killed and carries per-
@@ -93,6 +82,23 @@ interface EvalResponse {
93
82
  */
94
83
  sticky?: Record<string, StickyCookieEntry>;
95
84
  }
85
+ /**
86
+ * The result of `universe(name).assign()` — the visitor's standing in a universe.
87
+ * A universe is a mutual-exclusion pool, so the visitor lands in **at most one**
88
+ * experiment. Never throws: an un-enrolled visitor still resolves `get()` to the
89
+ * universe defaults (or your fallback). `assign()` auto-logs one exposure when
90
+ * enrolled (subject to `disableAutoExposure`).
91
+ */
92
+ interface Assignment {
93
+ /** The experiment the visitor landed in, or `null` when not enrolled. */
94
+ readonly name: string | null;
95
+ /** The assigned variant/group name, or `null` when not enrolled. */
96
+ readonly group: string | null;
97
+ /** True iff the visitor is enrolled in an experiment in this universe. */
98
+ readonly enrolled: boolean;
99
+ /** Read a resolved param: variant override ?? universe default ?? `fallback`. */
100
+ get<T = unknown>(field: string, fallback?: T): T | undefined;
101
+ }
96
102
  /** One persisted sticky assignment: group + 8-char salt prefix (reshuffle key). */
97
103
  interface StickyCookieEntry {
98
104
  g: string;
@@ -104,6 +110,33 @@ interface AutoCollectGroups {
104
110
  engagement: boolean;
105
111
  }
106
112
  type EngineEnv = "dev" | "staging" | "prod";
113
+ /**
114
+ * A pluggable persistence backend for the anonymous id. Primarily for non-DOM
115
+ * runtimes (React Native / Expo) where there is no cookie or `localStorage`, so
116
+ * the anon id would otherwise regenerate every app launch and re-bucket the
117
+ * visitor. Point it at `@react-native-async-storage/async-storage` (or any
118
+ * store) and the SDK hydrates a stable id before the first `/sdk/evaluate`:
119
+ *
120
+ * ```ts
121
+ * import AsyncStorage from "@react-native-async-storage/async-storage";
122
+ * configure({
123
+ * clientKey: "...",
124
+ * anonymousStore: {
125
+ * get: (k) => AsyncStorage.getItem(k),
126
+ * set: (k, v) => AsyncStorage.setItem(k, v),
127
+ * remove: (k) => AsyncStorage.removeItem(k),
128
+ * },
129
+ * });
130
+ * ```
131
+ *
132
+ * Methods may be sync or async — the SDK awaits either. Any thrown error is
133
+ * swallowed (the in-memory id is used as a fallback).
134
+ */
135
+ interface AnonymousStore {
136
+ get(key: string): string | null | Promise<string | null>;
137
+ set(key: string, value: string): void | Promise<void>;
138
+ remove(key: string): void | Promise<void>;
139
+ }
107
140
  interface EngineOptions {
108
141
  sdkKey: string;
109
142
  baseUrl?: string;
@@ -125,10 +158,26 @@ interface EngineOptions {
125
158
  /** Which published env to read values from. Defaults to "prod". */
126
159
  env?: EngineEnv;
127
160
  /**
128
- * Per-evaluation usage telemetry. ON by default each getFlag/getConfig/
161
+ * Master network switch when `false`, the browser SDK makes NO outbound
162
+ * requests at all: identify()/init never call /sdk/evaluate (getters read code
163
+ * defaults / overrides), track() and exposure logging are no-ops, usage
164
+ * telemetry is off, and SDK-internal error self-monitoring is off.
165
+ *
166
+ * DEFAULT is environment-derived (see {@link isProductionEnv}): ON in
167
+ * production, OFF everywhere else. In the browser there is usually no native
168
+ * `NODE_ENV`, so production is taken from the `env` option above (default
169
+ * `"prod"`) — meaning it defaults ON. Pass `false` (or set `env: "dev"`) to
170
+ * keep a local build quiet.
171
+ */
172
+ isNetworkEnabled?: boolean;
173
+ /**
174
+ * Per-evaluation usage telemetry ("tracking") — each getFlag/getConfig/
129
175
  * getExperiment/getKillswitch call fires one fire-and-forget sendBeacon so
130
- * usage is counted by Cloudflare's native per-path analytics. Pass `true` to
131
- * disable entirely.
176
+ * usage is counted by Cloudflare's native per-path analytics.
177
+ *
178
+ * DEFAULT is environment-derived: ON in production, OFF everywhere else (same
179
+ * inference as {@link isNetworkEnabled}). Pass `true` to force it off, `false`
180
+ * to force it on. Forced off whenever `isNetworkEnabled` is false.
132
181
  */
133
182
  disableTelemetry?: boolean;
134
183
  /** Override the telemetry beacon host. Defaults to {@link DEFAULT_TELEMETRY_URL}. */
@@ -141,6 +190,14 @@ interface EngineOptions {
141
190
  * `silent` < `error` < `warn` < `info` < `debug`. Defaults to `"warn"`.
142
191
  */
143
192
  logLevel?: LogLevel;
193
+ /**
194
+ * Opt out of SDK-internal error self-monitoring. When one of the SDK's own
195
+ * last-resort guards catches an internal failure (an "on our end" bug), the
196
+ * SDK reports it to Shipeasy's own project so we can track SDK bugs across
197
+ * apps — never to your project. ON by default; forced off in test mode. Pass
198
+ * `true` to disable.
199
+ */
200
+ disableInternalErrorReporting?: boolean;
144
201
  /**
145
202
  * Suppress automatic exposure logging in `getExperiment` (Statsig's
146
203
  * `disableExposureLogging`). Default false — reading an enrolled variant
@@ -164,6 +221,14 @@ interface EngineOptions {
164
221
  * lever. Pass `false` to opt out (pure deterministic eval).
165
222
  */
166
223
  stickyBucketing?: boolean;
224
+ /**
225
+ * Pluggable persistence for the anonymous id (React Native / Expo, or any
226
+ * runtime without a cookie / `localStorage`). When set, the SDK hydrates the
227
+ * stored id before the first `/sdk/evaluate` so bucketing stays stable across
228
+ * app launches; on a fresh device it persists the freshly-minted id. See
229
+ * {@link AnonymousStore}.
230
+ */
231
+ anonymousStore?: AnonymousStore;
167
232
  /**
168
233
  * Test mode — no network at all. identify()/init are no-ops (never call
169
234
  * /sdk/evaluate), track() is a no-op, telemetry is forced off, and the client
@@ -184,6 +249,7 @@ declare class Engine {
184
249
  private readonly env;
185
250
  private evalResult;
186
251
  private anonId;
252
+ private anonReady;
187
253
  private userId;
188
254
  private buffer;
189
255
  private telemetry;
@@ -193,11 +259,20 @@ declare class Engine {
193
259
  private overrideListenerInstalled;
194
260
  private identifySeq;
195
261
  private readonly testMode;
262
+ private readonly networkEnabled;
263
+ private readonly offline;
196
264
  private readonly flagOverrides;
197
265
  private readonly configOverrides;
198
266
  private readonly experimentOverrides;
199
267
  private onOverrideChange;
200
268
  constructor(opts: EngineOptions);
269
+ /**
270
+ * Consult the caller-supplied {@link AnonymousStore}: adopt a persisted id so
271
+ * the visitor buckets identically across app launches, or persist the id just
272
+ * minted by getOrCreateAnonId() on a fresh device. Best-effort — any store
273
+ * failure leaves the in-memory id in place.
274
+ */
275
+ private hydrateAnonFromStore;
201
276
  /**
202
277
  * Build a no-network, immediately-usable browser client for tests
203
278
  * (Statsig-style). identify() is a no-op (never calls /sdk/evaluate), track()
@@ -251,16 +326,21 @@ declare class Engine {
251
326
  getFlag(name: string, defaultValue?: boolean): boolean;
252
327
  getConfig<T = unknown>(name: string, decode?: (raw: unknown) => T): T | undefined;
253
328
  getConfig<T = unknown>(name: string, opts: GetConfigOptions<T>): T;
254
- getExperiment<P extends Record<string, unknown>>(name: string, defaultParams: P, decode?: (raw: unknown) => P, variants?: Record<string, Partial<P>>): ExperimentResult<P>;
255
- getExperiment<P extends Record<string, unknown>>(name: string, defaultParams: P, opts: GetExperimentOptions<P>): ExperimentResult<P>;
256
329
  /**
257
- * Manually log an exposure for an enrolled experiment (Statsig's
258
- * `manuallyLogExposure`). Reads the cached eval result; if the visitor is in
259
- * the experiment, pushes the session-deduped exposure. Pair this with the
260
- * render of the treatment when reading with `{ logExposure: false }` (or
261
- * `disableAutoExposure: true`). No-op if the visitor isn't enrolled.
330
+ * Assign the visitor within a universe: `universe("checkout").assign()`. A
331
+ * universe is a mutual-exclusion pool, so the visitor lands in ≤1 experiment;
332
+ * the returned {@link Assignment} exposes `.group` / `.get(field, fallback)`
333
+ * and auto-logs one exposure when enrolled (unless `disableAutoExposure` or
334
+ * `assign({ logExposure: false })`). An un-enrolled visitor still resolves
335
+ * `get()` to the universe defaults. This is the sole experiment read path —
336
+ * there is no `getExperiment` (ask a universe, not an experiment).
262
337
  */
263
- logExposure(name: string): void;
338
+ universe(name: string): {
339
+ assign(opts?: {
340
+ logExposure?: boolean;
341
+ }): Assignment;
342
+ };
343
+ private assignUniverse;
264
344
  /**
265
345
  * Subscribe to state changes — fires after identify() completes and on
266
346
  * `se:override:change` events from the devtools overlay. Returns an