@shipeasy/sdk 6.2.0 → 7.0.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.
@@ -1,5 +1,7 @@
1
1
  import { Provider, EvaluationContext, Logger, ResolutionDetails, JsonValue } from '@openfeature/web-sdk';
2
2
 
3
+ type LogLevel = "silent" | "error" | "warn" | "info" | "debug";
4
+
3
5
  type SeeExtras = Record<string, string | number | boolean | null | undefined>;
4
6
  type SeeKind = "caught" | "uncaught" | "unhandled_rejection" | "network" | "violation";
5
7
  /** Built by `causesThe(subject).to(outcome)` — never constructed by hand. */
@@ -23,25 +25,6 @@ interface User {
23
25
  user_id?: string;
24
26
  [attr: string]: unknown;
25
27
  }
26
- interface ExperimentResult<P> {
27
- inExperiment: boolean;
28
- group: string;
29
- params: P;
30
- }
31
- /** Options object form of `getExperiment` — the legacy `decode`/`variants`
32
- * positional args plus per-call exposure control. */
33
- interface GetExperimentOptions<P> {
34
- /** Decode the raw stored params into the typed shape callers want. */
35
- decode?: (raw: unknown) => P;
36
- /** Variant-specific param overrides merged on top of group params. */
37
- variants?: Record<string, Partial<P>>;
38
- /**
39
- * Override automatic exposure logging for this read. Defaults to the client's
40
- * setting (`disableAutoExposure` flips it). `false` reads the variant without
41
- * logging an exposure — pair with `logExposure(name)` at render time.
42
- */
43
- logExposure?: boolean;
44
- }
45
28
  /**
46
29
  * Why a flag evaluated the way it did (LaunchDarkly variationDetail parity).
47
30
  * Computed at the client boundary:
@@ -73,11 +56,19 @@ interface EvalExpResult {
73
56
  inExperiment: boolean;
74
57
  group: string;
75
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;
76
62
  }
77
63
  interface EvalResponse {
78
64
  flags: Record<string, boolean>;
79
65
  configs: Record<string, unknown>;
80
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
+ }>;
81
72
  /**
82
73
  * Killswitch state, flattened by the server. A boolean means the killswitch
83
74
  * is whole-killed; an object means it's not whole-killed and carries per-
@@ -91,6 +82,23 @@ interface EvalResponse {
91
82
  */
92
83
  sticky?: Record<string, StickyCookieEntry>;
93
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
+ }
94
102
  /** One persisted sticky assignment: group + 8-char salt prefix (reshuffle key). */
95
103
  interface StickyCookieEntry {
96
104
  g: string;
@@ -102,6 +110,33 @@ interface AutoCollectGroups {
102
110
  engagement: boolean;
103
111
  }
104
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
+ }
105
140
  interface EngineOptions {
106
141
  sdkKey: string;
107
142
  baseUrl?: string;
@@ -131,6 +166,22 @@ interface EngineOptions {
131
166
  disableTelemetry?: boolean;
132
167
  /** Override the telemetry beacon host. Defaults to {@link DEFAULT_TELEMETRY_URL}. */
133
168
  telemetryUrl?: string;
169
+ /**
170
+ * How chatty the SDK is on `console` when it catches an error internally.
171
+ * Every public runtime method (getFlag/getConfig/getExperiment/getKillswitch/
172
+ * track/logExposure/see) fails silently — returning a safe default instead of
173
+ * throwing — and surfaces the swallowed error through this level. Ordering:
174
+ * `silent` < `error` < `warn` < `info` < `debug`. Defaults to `"warn"`.
175
+ */
176
+ logLevel?: LogLevel;
177
+ /**
178
+ * Opt out of SDK-internal error self-monitoring. When one of the SDK's own
179
+ * last-resort guards catches an internal failure (an "on our end" bug), the
180
+ * SDK reports it to Shipeasy's own project so we can track SDK bugs across
181
+ * apps — never to your project. ON by default; forced off in test mode. Pass
182
+ * `true` to disable.
183
+ */
184
+ disableInternalErrorReporting?: boolean;
134
185
  /**
135
186
  * Suppress automatic exposure logging in `getExperiment` (Statsig's
136
187
  * `disableExposureLogging`). Default false — reading an enrolled variant
@@ -154,6 +205,14 @@ interface EngineOptions {
154
205
  * lever. Pass `false` to opt out (pure deterministic eval).
155
206
  */
156
207
  stickyBucketing?: boolean;
208
+ /**
209
+ * Pluggable persistence for the anonymous id (React Native / Expo, or any
210
+ * runtime without a cookie / `localStorage`). When set, the SDK hydrates the
211
+ * stored id before the first `/sdk/evaluate` so bucketing stays stable across
212
+ * app launches; on a fresh device it persists the freshly-minted id. See
213
+ * {@link AnonymousStore}.
214
+ */
215
+ anonymousStore?: AnonymousStore;
157
216
  /**
158
217
  * Test mode — no network at all. identify()/init are no-ops (never call
159
218
  * /sdk/evaluate), track() is a no-op, telemetry is forced off, and the client
@@ -174,6 +233,7 @@ declare class Engine {
174
233
  private readonly env;
175
234
  private evalResult;
176
235
  private anonId;
236
+ private anonReady;
177
237
  private userId;
178
238
  private buffer;
179
239
  private telemetry;
@@ -188,6 +248,13 @@ declare class Engine {
188
248
  private readonly experimentOverrides;
189
249
  private onOverrideChange;
190
250
  constructor(opts: EngineOptions);
251
+ /**
252
+ * Consult the caller-supplied {@link AnonymousStore}: adopt a persisted id so
253
+ * the visitor buckets identically across app launches, or persist the id just
254
+ * minted by getOrCreateAnonId() on a fresh device. Best-effort — any store
255
+ * failure leaves the in-memory id in place.
256
+ */
257
+ private hydrateAnonFromStore;
191
258
  /**
192
259
  * Build a no-network, immediately-usable browser client for tests
193
260
  * (Statsig-style). identify() is a no-op (never calls /sdk/evaluate), track()
@@ -241,16 +308,21 @@ declare class Engine {
241
308
  getFlag(name: string, defaultValue?: boolean): boolean;
242
309
  getConfig<T = unknown>(name: string, decode?: (raw: unknown) => T): T | undefined;
243
310
  getConfig<T = unknown>(name: string, opts: GetConfigOptions<T>): T;
244
- getExperiment<P extends Record<string, unknown>>(name: string, defaultParams: P, decode?: (raw: unknown) => P, variants?: Record<string, Partial<P>>): ExperimentResult<P>;
245
- getExperiment<P extends Record<string, unknown>>(name: string, defaultParams: P, opts: GetExperimentOptions<P>): ExperimentResult<P>;
246
311
  /**
247
- * Manually log an exposure for an enrolled experiment (Statsig's
248
- * `manuallyLogExposure`). Reads the cached eval result; if the visitor is in
249
- * the experiment, pushes the session-deduped exposure. Pair this with the
250
- * render of the treatment when reading with `{ logExposure: false }` (or
251
- * `disableAutoExposure: true`). No-op if the visitor isn't enrolled.
312
+ * Assign the visitor within a universe: `universe("checkout").assign()`. A
313
+ * universe is a mutual-exclusion pool, so the visitor lands in ≤1 experiment;
314
+ * the returned {@link Assignment} exposes `.group` / `.get(field, fallback)`
315
+ * and auto-logs one exposure when enrolled (unless `disableAutoExposure` or
316
+ * `assign({ logExposure: false })`). An un-enrolled visitor still resolves
317
+ * `get()` to the universe defaults. This is the sole experiment read path —
318
+ * there is no `getExperiment` (ask a universe, not an experiment).
252
319
  */
253
- logExposure(name: string): void;
320
+ universe(name: string): {
321
+ assign(opts?: {
322
+ logExposure?: boolean;
323
+ }): Assignment;
324
+ };
325
+ private assignUniverse;
254
326
  /**
255
327
  * Subscribe to state changes — fires after identify() completes and on
256
328
  * `se:override:change` events from the devtools overlay. Returns an
@@ -1,5 +1,7 @@
1
1
  import { Provider, EvaluationContext, Logger, ResolutionDetails, JsonValue } from '@openfeature/web-sdk';
2
2
 
3
+ type LogLevel = "silent" | "error" | "warn" | "info" | "debug";
4
+
3
5
  type SeeExtras = Record<string, string | number | boolean | null | undefined>;
4
6
  type SeeKind = "caught" | "uncaught" | "unhandled_rejection" | "network" | "violation";
5
7
  /** Built by `causesThe(subject).to(outcome)` — never constructed by hand. */
@@ -23,25 +25,6 @@ interface User {
23
25
  user_id?: string;
24
26
  [attr: string]: unknown;
25
27
  }
26
- interface ExperimentResult<P> {
27
- inExperiment: boolean;
28
- group: string;
29
- params: P;
30
- }
31
- /** Options object form of `getExperiment` — the legacy `decode`/`variants`
32
- * positional args plus per-call exposure control. */
33
- interface GetExperimentOptions<P> {
34
- /** Decode the raw stored params into the typed shape callers want. */
35
- decode?: (raw: unknown) => P;
36
- /** Variant-specific param overrides merged on top of group params. */
37
- variants?: Record<string, Partial<P>>;
38
- /**
39
- * Override automatic exposure logging for this read. Defaults to the client's
40
- * setting (`disableAutoExposure` flips it). `false` reads the variant without
41
- * logging an exposure — pair with `logExposure(name)` at render time.
42
- */
43
- logExposure?: boolean;
44
- }
45
28
  /**
46
29
  * Why a flag evaluated the way it did (LaunchDarkly variationDetail parity).
47
30
  * Computed at the client boundary:
@@ -73,11 +56,19 @@ interface EvalExpResult {
73
56
  inExperiment: boolean;
74
57
  group: string;
75
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;
76
62
  }
77
63
  interface EvalResponse {
78
64
  flags: Record<string, boolean>;
79
65
  configs: Record<string, unknown>;
80
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
+ }>;
81
72
  /**
82
73
  * Killswitch state, flattened by the server. A boolean means the killswitch
83
74
  * is whole-killed; an object means it's not whole-killed and carries per-
@@ -91,6 +82,23 @@ interface EvalResponse {
91
82
  */
92
83
  sticky?: Record<string, StickyCookieEntry>;
93
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
+ }
94
102
  /** One persisted sticky assignment: group + 8-char salt prefix (reshuffle key). */
95
103
  interface StickyCookieEntry {
96
104
  g: string;
@@ -102,6 +110,33 @@ interface AutoCollectGroups {
102
110
  engagement: boolean;
103
111
  }
104
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
+ }
105
140
  interface EngineOptions {
106
141
  sdkKey: string;
107
142
  baseUrl?: string;
@@ -131,6 +166,22 @@ interface EngineOptions {
131
166
  disableTelemetry?: boolean;
132
167
  /** Override the telemetry beacon host. Defaults to {@link DEFAULT_TELEMETRY_URL}. */
133
168
  telemetryUrl?: string;
169
+ /**
170
+ * How chatty the SDK is on `console` when it catches an error internally.
171
+ * Every public runtime method (getFlag/getConfig/getExperiment/getKillswitch/
172
+ * track/logExposure/see) fails silently — returning a safe default instead of
173
+ * throwing — and surfaces the swallowed error through this level. Ordering:
174
+ * `silent` < `error` < `warn` < `info` < `debug`. Defaults to `"warn"`.
175
+ */
176
+ logLevel?: LogLevel;
177
+ /**
178
+ * Opt out of SDK-internal error self-monitoring. When one of the SDK's own
179
+ * last-resort guards catches an internal failure (an "on our end" bug), the
180
+ * SDK reports it to Shipeasy's own project so we can track SDK bugs across
181
+ * apps — never to your project. ON by default; forced off in test mode. Pass
182
+ * `true` to disable.
183
+ */
184
+ disableInternalErrorReporting?: boolean;
134
185
  /**
135
186
  * Suppress automatic exposure logging in `getExperiment` (Statsig's
136
187
  * `disableExposureLogging`). Default false — reading an enrolled variant
@@ -154,6 +205,14 @@ interface EngineOptions {
154
205
  * lever. Pass `false` to opt out (pure deterministic eval).
155
206
  */
156
207
  stickyBucketing?: boolean;
208
+ /**
209
+ * Pluggable persistence for the anonymous id (React Native / Expo, or any
210
+ * runtime without a cookie / `localStorage`). When set, the SDK hydrates the
211
+ * stored id before the first `/sdk/evaluate` so bucketing stays stable across
212
+ * app launches; on a fresh device it persists the freshly-minted id. See
213
+ * {@link AnonymousStore}.
214
+ */
215
+ anonymousStore?: AnonymousStore;
157
216
  /**
158
217
  * Test mode — no network at all. identify()/init are no-ops (never call
159
218
  * /sdk/evaluate), track() is a no-op, telemetry is forced off, and the client
@@ -174,6 +233,7 @@ declare class Engine {
174
233
  private readonly env;
175
234
  private evalResult;
176
235
  private anonId;
236
+ private anonReady;
177
237
  private userId;
178
238
  private buffer;
179
239
  private telemetry;
@@ -188,6 +248,13 @@ declare class Engine {
188
248
  private readonly experimentOverrides;
189
249
  private onOverrideChange;
190
250
  constructor(opts: EngineOptions);
251
+ /**
252
+ * Consult the caller-supplied {@link AnonymousStore}: adopt a persisted id so
253
+ * the visitor buckets identically across app launches, or persist the id just
254
+ * minted by getOrCreateAnonId() on a fresh device. Best-effort — any store
255
+ * failure leaves the in-memory id in place.
256
+ */
257
+ private hydrateAnonFromStore;
191
258
  /**
192
259
  * Build a no-network, immediately-usable browser client for tests
193
260
  * (Statsig-style). identify() is a no-op (never calls /sdk/evaluate), track()
@@ -241,16 +308,21 @@ declare class Engine {
241
308
  getFlag(name: string, defaultValue?: boolean): boolean;
242
309
  getConfig<T = unknown>(name: string, decode?: (raw: unknown) => T): T | undefined;
243
310
  getConfig<T = unknown>(name: string, opts: GetConfigOptions<T>): T;
244
- getExperiment<P extends Record<string, unknown>>(name: string, defaultParams: P, decode?: (raw: unknown) => P, variants?: Record<string, Partial<P>>): ExperimentResult<P>;
245
- getExperiment<P extends Record<string, unknown>>(name: string, defaultParams: P, opts: GetExperimentOptions<P>): ExperimentResult<P>;
246
311
  /**
247
- * Manually log an exposure for an enrolled experiment (Statsig's
248
- * `manuallyLogExposure`). Reads the cached eval result; if the visitor is in
249
- * the experiment, pushes the session-deduped exposure. Pair this with the
250
- * render of the treatment when reading with `{ logExposure: false }` (or
251
- * `disableAutoExposure: true`). No-op if the visitor isn't enrolled.
312
+ * Assign the visitor within a universe: `universe("checkout").assign()`. A
313
+ * universe is a mutual-exclusion pool, so the visitor lands in ≤1 experiment;
314
+ * the returned {@link Assignment} exposes `.group` / `.get(field, fallback)`
315
+ * and auto-logs one exposure when enrolled (unless `disableAutoExposure` or
316
+ * `assign({ logExposure: false })`). An un-enrolled visitor still resolves
317
+ * `get()` to the universe defaults. This is the sole experiment read path —
318
+ * there is no `getExperiment` (ask a universe, not an experiment).
252
319
  */
253
- logExposure(name: string): void;
320
+ universe(name: string): {
321
+ assign(opts?: {
322
+ logExposure?: boolean;
323
+ }): Assignment;
324
+ };
325
+ private assignUniverse;
254
326
  /**
255
327
  * Subscribe to state changes — fires after identify() completes and on
256
328
  * `se:override:change` events from the devtools overlay. Returns an