@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.
@@ -67,50 +67,21 @@ function send(url) {
67
67
  }
68
68
  }
69
69
 
70
- // src/logger.ts
71
- var LOG_LEVELS = ["silent", "error", "warn", "info", "debug"];
72
- var RANK = {
73
- silent: 0,
74
- error: 1,
75
- warn: 2,
76
- info: 3,
77
- debug: 4
78
- };
79
- var currentLevel = "warn";
80
- function setLogLevel(level) {
81
- if (typeof level === "string" && Object.prototype.hasOwnProperty.call(RANK, level)) {
82
- currentLevel = level;
83
- }
70
+ // src/env.ts
71
+ function isProductionEnv(configuredEnv) {
72
+ const native = readNativeEnv();
73
+ if (native !== null) return native === "production" || native === "prod";
74
+ return (configuredEnv ?? "prod").toLowerCase() === "prod";
84
75
  }
85
- function write(method, args) {
76
+ function readNativeEnv() {
86
77
  try {
87
- const c = globalThis.console;
88
- if (!c) return;
89
- const fn = c[method] ?? c.log;
90
- if (typeof fn === "function") fn.call(c, ...args);
78
+ const proc = globalThis.process;
79
+ const raw = proc?.env?.SHIPEASY_ENV ?? proc?.env?.NODE_ENV;
80
+ if (typeof raw !== "string") return null;
81
+ const v = raw.trim().toLowerCase();
82
+ return v.length ? v : null;
91
83
  } catch {
92
- }
93
- }
94
- var logger = {
95
- error(...args) {
96
- if (RANK[currentLevel] >= RANK.error) write("error", args);
97
- },
98
- warn(...args) {
99
- if (RANK[currentLevel] >= RANK.warn) write("warn", args);
100
- },
101
- info(...args) {
102
- if (RANK[currentLevel] >= RANK.info) write("info", args);
103
- },
104
- debug(...args) {
105
- if (RANK[currentLevel] >= RANK.debug) write("debug", args);
106
- }
107
- };
108
- function safeRun(label, fallback, fn) {
109
- try {
110
- return fn();
111
- } catch (err) {
112
- logger.error(`[shipeasy] ${label} failed \u2014 returning safe default:`, String(err));
113
- return fallback;
84
+ return null;
114
85
  }
115
86
  }
116
87
 
@@ -231,7 +202,7 @@ function captureCallsiteStack() {
231
202
  const kept = lines.slice(1).filter((l) => !/@shipeasy[\\/]sdk|see[\\/]core|captureCallsiteStack|\bsee\b\s*\(/.test(l));
232
203
  return kept.length ? kept.join("\n") : void 0;
233
204
  }
234
- function buildSeeEvent(problem, consequence, extras, ctx, kindOverride, correlationId) {
205
+ function buildSeeEvent(problem, consequence, extras, ctx2, kindOverride, correlationId) {
235
206
  let errorType;
236
207
  let message;
237
208
  let stack;
@@ -259,8 +230,8 @@ function buildSeeEvent(problem, consequence, extras, ctx, kindOverride, correlat
259
230
  message: truncate(message, SEE_MAX_MESSAGE),
260
231
  subject: consequence.subject,
261
232
  outcome: consequence.outcome,
262
- side: ctx.side,
263
- sdk_version: ctx.sdkVersion,
233
+ side: ctx2.side,
234
+ sdk_version: ctx2.sdkVersion,
264
235
  ts: Date.now()
265
236
  };
266
237
  if (stack) ev.stack = truncate(stack, SEE_MAX_STACK);
@@ -269,10 +240,10 @@ function buildSeeEvent(problem, consequence, extras, ctx, kindOverride, correlat
269
240
  if (causedBy) ev.caused_by = causedBy;
270
241
  const cleanExtras = sanitizeExtras(extras);
271
242
  if (cleanExtras) ev.extras = cleanExtras;
272
- if (ctx.url) ev.url = truncate(ctx.url, SEE_MAX_SUBJECT);
273
- if (ctx.userId) ev.user_id = ctx.userId;
274
- if (ctx.anonId) ev.anonymous_id = ctx.anonId;
275
- if (ctx.env) ev.env = ctx.env;
243
+ if (ctx2.url) ev.url = truncate(ctx2.url, SEE_MAX_SUBJECT);
244
+ if (ctx2.userId) ev.user_id = ctx2.userId;
245
+ if (ctx2.anonId) ev.anonymous_id = ctx2.anonId;
246
+ if (ctx2.env) ev.env = ctx2.env;
276
247
  markReported(problem, ev);
277
248
  return ev;
278
249
  }
@@ -365,6 +336,98 @@ var SeeLimiter = class {
365
336
  }
366
337
  };
367
338
 
339
+ // src/internal-report.ts
340
+ var INGEST_URL = "https://api.shipeasy.ai/collect";
341
+ var PLACEHOLDER_KEY = "sdk_client_REPLACE_WITH_SHIPEASY_INTERNAL_ERROR_KEY";
342
+ var INGEST_KEY = "sdk_client_00bd4608a03e4084922978f9522614d5";
343
+ function keyConfigured() {
344
+ return !!INGEST_KEY && INGEST_KEY !== PLACEHOLDER_KEY;
345
+ }
346
+ var OUTCOME = "returned a safe default";
347
+ var SDK_ID = "ts";
348
+ var ctx = null;
349
+ var limiter = new SeeLimiter();
350
+ function setInternalReportContext(c) {
351
+ ctx = { side: c.side, sdkVersion: c.sdkVersion, enabled: c.enabled !== false };
352
+ }
353
+ function reportInternalError(label, err) {
354
+ try {
355
+ if (!ctx || !ctx.enabled || !keyConfigured()) return;
356
+ const ev = buildSeeEvent(
357
+ err,
358
+ causesThe(label).to(OUTCOME),
359
+ { sdk: SDK_ID },
360
+ { side: ctx.side, sdkVersion: ctx.sdkVersion },
361
+ "caught"
362
+ );
363
+ if (!limiter.shouldSend(ev)) return;
364
+ send2(INGEST_URL, INGEST_KEY, JSON.stringify({ events: [ev] }));
365
+ } catch {
366
+ }
367
+ }
368
+ function send2(url, key, body) {
369
+ try {
370
+ const f = globalThis.fetch;
371
+ if (typeof f !== "function") return;
372
+ void f(url, {
373
+ method: "POST",
374
+ headers: { "X-SDK-Key": key, "Content-Type": "text/plain" },
375
+ body,
376
+ keepalive: true
377
+ }).catch(() => {
378
+ });
379
+ } catch {
380
+ }
381
+ }
382
+
383
+ // src/logger.ts
384
+ var LOG_LEVELS = ["silent", "error", "warn", "info", "debug"];
385
+ var RANK = {
386
+ silent: 0,
387
+ error: 1,
388
+ warn: 2,
389
+ info: 3,
390
+ debug: 4
391
+ };
392
+ var currentLevel = "warn";
393
+ function setLogLevel(level) {
394
+ if (typeof level === "string" && Object.prototype.hasOwnProperty.call(RANK, level)) {
395
+ currentLevel = level;
396
+ }
397
+ }
398
+ function write(method, args) {
399
+ try {
400
+ const c = globalThis.console;
401
+ if (!c) return;
402
+ const fn = c[method] ?? c.log;
403
+ if (typeof fn === "function") fn.call(c, ...args);
404
+ } catch {
405
+ }
406
+ }
407
+ var logger = {
408
+ error(...args) {
409
+ if (RANK[currentLevel] >= RANK.error) write("error", args);
410
+ },
411
+ warn(...args) {
412
+ if (RANK[currentLevel] >= RANK.warn) write("warn", args);
413
+ },
414
+ info(...args) {
415
+ if (RANK[currentLevel] >= RANK.info) write("info", args);
416
+ },
417
+ debug(...args) {
418
+ if (RANK[currentLevel] >= RANK.debug) write("debug", args);
419
+ }
420
+ };
421
+ function safeRun(label, fallback, fn) {
422
+ try {
423
+ return fn();
424
+ } catch (err) {
425
+ logger.error(`[shipeasy] ${label} failed \u2014 returning safe default:`, String(err));
426
+ reportInternalError(label, err);
427
+ return fallback;
428
+ }
429
+ }
430
+
368
431
  // src/server/index.ts
369
432
  var version = "4.0.0";
370
433
  var C1 = 3432918353;
@@ -501,6 +564,78 @@ function evalGateInternal(gate, user) {
501
564
  if (!uid) return gate.rolloutPct >= 1e4;
502
565
  return murmur3(`${gate.salt}:${uid}`) % 1e4 < gate.rolloutPct;
503
566
  }
567
+ function paramDefaultsFromSchema(schema) {
568
+ if (!schema || schema.length === 0) return null;
569
+ const out = {};
570
+ for (const p of schema) out[p.name] = p.default;
571
+ return out;
572
+ }
573
+ function mergeParams(paramDefaults, groupParams) {
574
+ return paramDefaults ? { ...paramDefaults, ...groupParams } : { ...groupParams };
575
+ }
576
+ function classifyExperimentLocal(exp, user, holdoutRange, paramDefaults, evalGate, sticky) {
577
+ const asGroup = (g) => ({
578
+ state: "group",
579
+ group: g.name,
580
+ params: mergeParams(paramDefaults, g.params)
581
+ });
582
+ if (exp.targetingGate && !evalGate(exp.targetingGate)) return { state: "out" };
583
+ const uid = pickIdentifier(user, exp.bucketBy);
584
+ if (!uid) return { state: "out" };
585
+ const universeSeg = murmur3(`${exp.universe}:${uid}`) % 1e4;
586
+ if (holdoutRange) {
587
+ const [lo, hi] = holdoutRange;
588
+ if (universeSeg >= lo && universeSeg <= hi) return { state: "holdout" };
589
+ }
590
+ if (exp.holdoutGate && evalGate(exp.holdoutGate)) return { state: "holdout" };
591
+ const salt8 = exp.salt.slice(0, 8);
592
+ if (sticky) {
593
+ const entry = sticky.get();
594
+ if (entry && entry.s === salt8) {
595
+ const g = exp.groups.find((x) => x.name === entry.g);
596
+ if (g) return asGroup(g);
597
+ }
598
+ }
599
+ const pooled = (exp.hashVersion ?? 1) >= 2 && exp.poolOffsetBp != null && exp.poolSizeBp != null && exp.poolSizeBp > 0;
600
+ if (pooled) {
601
+ const lo = exp.poolOffsetBp;
602
+ const hi = lo + exp.poolSizeBp;
603
+ if (universeSeg < lo || universeSeg >= hi) return { state: "out" };
604
+ } else {
605
+ if (murmur3(`${exp.salt}:alloc:${uid}`) % 1e4 >= exp.allocationPct) return { state: "out" };
606
+ }
607
+ const reserved = Math.max(0, Math.min(1e4, exp.reservedHeadroomBp ?? 0));
608
+ const usable = 1e4 - reserved;
609
+ const groupHash = murmur3(`${exp.salt}:group:${uid}`) % 1e4;
610
+ if (groupHash >= usable) return { state: "out" };
611
+ let cumulative = 0;
612
+ for (let i = 0; i < exp.groups.length; i++) {
613
+ const g = exp.groups[i];
614
+ cumulative += g.weight;
615
+ if (groupHash < cumulative || i === exp.groups.length - 1) {
616
+ sticky?.set({ g: g.name, s: salt8 });
617
+ return asGroup(g);
618
+ }
619
+ }
620
+ return { state: "out" };
621
+ }
622
+ var AssignmentImpl = class {
623
+ constructor(name, group, params) {
624
+ this.name = name;
625
+ this.group = group;
626
+ this.params = params;
627
+ }
628
+ name;
629
+ group;
630
+ params;
631
+ get enrolled() {
632
+ return this.group !== null;
633
+ }
634
+ get(field, fallback) {
635
+ const v = this.params[field];
636
+ return v === void 0 ? fallback : v;
637
+ }
638
+ };
504
639
  var TRUE_RX = /^(true|on|1|yes)$/i;
505
640
  var FALSE_RX = /^(false|off|0|no)$/i;
506
641
  function parseOverrideBool(raw) {
@@ -552,6 +687,13 @@ var Engine = class _Engine {
552
687
  env;
553
688
  privateAttributes;
554
689
  stickyStore;
690
+ // Master network gate — false ⇒ the SDK never touches the network (no fetch,
691
+ // no track, no telemetry, no internal error reports). Defaults to
692
+ // environment-derived (prod-on), forced false by testMode.
693
+ networkEnabled;
694
+ // Convenience inverse of networkEnabled — the "no network at all" state that
695
+ // every fetch/track/init gate keys on. `testMode` implies offline.
696
+ offline;
555
697
  telemetry;
556
698
  seeLimiter = new SeeLimiter();
557
699
  flagsBlob = null;
@@ -571,6 +713,9 @@ var Engine = class _Engine {
571
713
  flagOverrides = /* @__PURE__ */ new Map();
572
714
  configOverrides = /* @__PURE__ */ new Map();
573
715
  experimentOverrides = /* @__PURE__ */ new Map();
716
+ // Bounded per-process exposure dedup (`uid:exp:group`) so auto-exposure from
717
+ // repeated assign() calls doesn't spam /collect. Cleared past a soft cap.
718
+ exposureSeen = /* @__PURE__ */ new Set();
574
719
  // Change listeners fired after a background poll returns NEW data (200, not
575
720
  // 304). Never fired in testMode/offline (no polling happens there).
576
721
  changeListeners = /* @__PURE__ */ new Set();
@@ -582,15 +727,24 @@ var Engine = class _Engine {
582
727
  this.privateAttributes = opts.privateAttributes ?? [];
583
728
  this.stickyStore = opts.stickyStore;
584
729
  this.testMode = opts.testMode === true;
730
+ const prod = isProductionEnv(this.env);
731
+ this.networkEnabled = this.testMode ? false : opts.isNetworkEnabled ?? prod;
732
+ this.offline = !this.networkEnabled;
733
+ setInternalReportContext({
734
+ side: "server",
735
+ sdkVersion: version,
736
+ enabled: this.networkEnabled && opts.disableInternalErrorReporting !== true
737
+ });
585
738
  this.telemetry = new Telemetry({
586
739
  endpoint: opts.telemetryUrl ?? DEFAULT_TELEMETRY_URL,
587
740
  sdkKey: this.apiKey,
588
741
  side: "server",
589
742
  env: this.env,
590
- // Test mode never talks to the network telemetry off regardless of opt.
591
- disabled: this.testMode || opts.disableTelemetry
743
+ // Off when the network is disabled; otherwise honour an explicit
744
+ // disableTelemetry, else default to prod-on (off outside production).
745
+ disabled: this.offline || (opts.disableTelemetry ?? !prod)
592
746
  });
593
- if (opts.initialBlob || this.testMode) {
747
+ if (opts.initialBlob || this.offline) {
594
748
  this.flagsBlob = opts.initialBlob ?? { version: "test", plan: "free", gates: {}, configs: {}, killswitches: {} };
595
749
  this.expsBlob = this.expsBlob ?? { version: "test", universes: {}, experiments: {} };
596
750
  this.initialized = true;
@@ -641,7 +795,7 @@ var Engine = class _Engine {
641
795
  return _Engine.fromSnapshot(snapshot);
642
796
  }
643
797
  async init() {
644
- if (this.testMode) {
798
+ if (this.offline) {
645
799
  this.initialized = true;
646
800
  return;
647
801
  }
@@ -650,7 +804,7 @@ var Engine = class _Engine {
650
804
  this.startPoll();
651
805
  }
652
806
  async initOnce() {
653
- if (this.testMode || this.initialized) return;
807
+ if (this.offline || this.initialized) return;
654
808
  await this.fetchAll();
655
809
  this.initialized = true;
656
810
  }
@@ -792,68 +946,77 @@ var Engine = class _Engine {
792
946
  return "defaultValue" in opts ? opts.defaultValue : void 0;
793
947
  }
794
948
  }
795
- getExperiment(name, user, defaultParams, decode) {
796
- this.telemetry.emit("experiment", name);
797
- const notIn = {
798
- inExperiment: false,
799
- group: "control",
800
- params: defaultParams
949
+ /**
950
+ * Bind the per-experiment sticky seam over the configured {@link StickyBucketStore}
951
+ * for one (unit, experiment). Absent store ⇒ deterministic (no sticky).
952
+ */
953
+ bindSticky(name, uid) {
954
+ const store = this.stickyStore;
955
+ if (!store) return void 0;
956
+ return {
957
+ get: () => store.get(uid)?.[name],
958
+ set: (entry) => store.set(uid, name, entry)
801
959
  };
960
+ }
961
+ /**
962
+ * Evaluate one experiment by name for `user` — override → full classify
963
+ * pipeline (targeting → universe holdout → holdout gate → sticky → allocation
964
+ * → group), merging the universe defaults under the assigned variant (§B2).
965
+ * Internal: the public surface is `universe(name).assign(user)`. Reused by the
966
+ * SSR `evaluate()` bootstrap (keyed by experiment name) and by `assignUniverse`.
967
+ */
968
+ evalExperiment(name, exp, user) {
969
+ const paramDefaults = paramDefaultsFromSchema(
970
+ this.expsBlob?.universes[exp.universe]?.param_schema
971
+ );
802
972
  const ov = this.experimentOverrides.get(name);
803
- if (ov) {
804
- if (!decode) return { inExperiment: true, group: ov.group, params: ov.params };
805
- try {
806
- return { inExperiment: true, group: ov.group, params: decode(ov.params) };
807
- } catch (err) {
808
- logger.warn(`[shipeasy] getExperiment('${name}') override decode failed:`, String(err));
809
- return notIn;
810
- }
811
- }
812
- if (!this.flagsBlob || !this.expsBlob) return notIn;
813
- const exp = this.expsBlob.experiments[name];
814
- if (!exp || exp.status !== "running") return notIn;
815
- if (exp.targetingGate) {
816
- const gate = this.flagsBlob.gates[exp.targetingGate];
817
- if (!gate || !evalGateInternal(gate, user)) return notIn;
818
- }
819
- const uid = pickIdentifier(user, exp.bucketBy);
820
- if (!uid) return notIn;
821
- const universe = this.expsBlob.universes[exp.universe];
822
- const holdoutRange = universe?.holdout_range ?? null;
823
- if (holdoutRange) {
824
- const seg = murmur3(`${exp.universe}:${uid}`) % 1e4;
825
- const [lo, hi] = holdoutRange;
826
- if (seg >= lo && seg <= hi) return notIn;
827
- }
828
- const asResult = (g) => {
829
- if (!decode) return { inExperiment: true, group: g.name, params: g.params };
830
- try {
831
- return { inExperiment: true, group: g.name, params: decode(g.params) };
832
- } catch (err) {
833
- logger.warn(`[shipeasy] getExperiment('${name}') decode failed:`, String(err));
834
- return notIn;
835
- }
973
+ if (ov) return { state: "group", group: ov.group, params: mergeParams(paramDefaults, ov.params) };
974
+ if (!this.flagsBlob || !this.expsBlob) return { state: "out" };
975
+ if (exp.status !== "running") return { state: "out" };
976
+ const holdoutRange = this.expsBlob.universes[exp.universe]?.holdout_range ?? null;
977
+ const evalGate = (gname) => {
978
+ const gate = this.flagsBlob?.gates[gname];
979
+ return gate ? evalGateInternal(gate, user) : false;
836
980
  };
837
- const salt8 = exp.salt.slice(0, 8);
838
- if (this.stickyStore) {
839
- const entry = this.stickyStore.get(uid)?.[name];
840
- if (entry && entry.s === salt8) {
841
- const g = exp.groups.find((x) => x.name === entry.g);
842
- if (g) return asResult(g);
843
- }
844
- }
845
- if (murmur3(`${exp.salt}:alloc:${uid}`) % 1e4 >= exp.allocationPct) return notIn;
846
- const groupHash = murmur3(`${exp.salt}:group:${uid}`) % 1e4;
847
- let cumulative = 0;
848
- for (let i = 0; i < exp.groups.length; i++) {
849
- const g = exp.groups[i];
850
- cumulative += g.weight;
851
- if (groupHash < cumulative || i === exp.groups.length - 1) {
852
- this.stickyStore?.set(uid, name, { g: g.name, s: salt8 });
853
- return asResult(g);
981
+ const uid = pickIdentifier(user, exp.bucketBy);
982
+ const sticky = uid ? this.bindSticky(name, uid) : void 0;
983
+ return classifyExperimentLocal(exp, user, holdoutRange, paramDefaults, evalGate, sticky);
984
+ }
985
+ /**
986
+ * Assign `user` within `universeName`. A universe is a mutual-exclusion pool,
987
+ * so a unit lands in **at most one** experiment; the returned {@link Assignment}
988
+ * exposes the variant + resolved params and auto-logs a single exposure when
989
+ * enrolled. An un-enrolled unit still resolves `get()` to the universe defaults.
990
+ * Never throws. This is the sole experiment read path (there is no
991
+ * `getExperiment` a caller asks a universe, not an experiment).
992
+ */
993
+ assignUniverse(universeName, user) {
994
+ this.telemetry.emit("experiment", universeName);
995
+ const paramDefaults = paramDefaultsFromSchema(
996
+ this.expsBlob?.universes[universeName]?.param_schema
997
+ );
998
+ const notEnrolled = () => new AssignmentImpl(null, null, paramDefaults ?? {});
999
+ if (!this.expsBlob) return notEnrolled();
1000
+ const candidates = Object.entries(this.expsBlob.experiments).filter(([, e]) => e.universe === universeName && e.status === "running").sort(
1001
+ (a, b) => (a[1].poolOffsetBp ?? 0) - (b[1].poolOffsetBp ?? 0) || a[0].localeCompare(b[0])
1002
+ );
1003
+ for (const [name, exp] of candidates) {
1004
+ const c = this.evalExperiment(name, exp, user);
1005
+ if (c.state === "group") {
1006
+ this.postExposure(user, name, c.group);
1007
+ return new AssignmentImpl(name, c.group, c.params ?? {});
854
1008
  }
855
1009
  }
856
- return notIn;
1010
+ return notEnrolled();
1011
+ }
1012
+ /**
1013
+ * The universe-first experiment read entry point:
1014
+ * `engine.universe("checkout").assign(user)`. Returns a reusable handle bound
1015
+ * to one universe; `assign(user)` picks the ≤1 experiment the unit is pooled
1016
+ * into and auto-logs a single exposure. See {@link assignUniverse}.
1017
+ */
1018
+ universe(name) {
1019
+ return { assign: (user) => this.assignUniverse(name, user) };
857
1020
  }
858
1021
  /** Drop caller-marked private attributes from an outbound props bag. */
859
1022
  stripPrivate(props) {
@@ -865,7 +1028,7 @@ var Engine = class _Engine {
865
1028
  return out;
866
1029
  }
867
1030
  track(userId, eventName, props) {
868
- if (this.testMode) return;
1031
+ if (this.offline) return;
869
1032
  const safeProps = this.stripPrivate(props);
870
1033
  const body = JSON.stringify({
871
1034
  events: [
@@ -885,26 +1048,26 @@ var Engine = class _Engine {
885
1048
  }).catch((err) => logger.warn("[shipeasy] track failed:", String(err)));
886
1049
  }
887
1050
  /**
888
- * Emit an exposure event for an experiment at the server-side decision point
889
- * (parity with the browser's auto-exposure). The server is stateless and
890
- * never auto-logs, so call this when you actually present the treatment.
891
- * Re-evaluates the experiment for `user` (a bare `user_id` string is wrapped
892
- * as `{ user_id }`); if the user is enrolled, POSTs a single exposure to
893
- * `/collect`. No-op in test mode or when the user isn't enrolled.
1051
+ * POST a single exposure for an enrolled `(user, experiment, group)`. Deduped
1052
+ * per process (bounded set) so repeated `assign()` calls in one server don't
1053
+ * spam `/collect`. Fire-and-forget; no-op when the network is disabled. This
1054
+ * is how `assignUniverse` auto-logs the browser's auto-exposure parity for SSR.
894
1055
  */
895
- logExposure(user, name) {
896
- if (this.testMode) return;
897
- const u = typeof user === "string" ? { user_id: user } : user;
898
- const result = this.getExperiment(name, u, {});
899
- if (!result.inExperiment) return;
1056
+ postExposure(user, experiment, group) {
1057
+ if (this.offline) return;
1058
+ const uid = user.user_id ?? user.anonymous_id;
1059
+ const dedupKey = `${uid ?? ""}:${experiment}:${group}`;
1060
+ if (this.exposureSeen.has(dedupKey)) return;
1061
+ if (this.exposureSeen.size > 5e3) this.exposureSeen.clear();
1062
+ this.exposureSeen.add(dedupKey);
900
1063
  const body = JSON.stringify({
901
1064
  events: [
902
1065
  {
903
1066
  type: "exposure",
904
- experiment: name,
905
- group: result.group,
906
- ...u.user_id !== void 0 ? { user_id: u.user_id } : {},
907
- ...u.anonymous_id !== void 0 ? { anonymous_id: u.anonymous_id } : {},
1067
+ experiment,
1068
+ group,
1069
+ ...user.user_id !== void 0 ? { user_id: user.user_id } : {},
1070
+ ...user.anonymous_id !== void 0 ? { anonymous_id: user.anonymous_id } : {},
908
1071
  ts: Date.now()
909
1072
  }
910
1073
  ]
@@ -913,7 +1076,7 @@ var Engine = class _Engine {
913
1076
  method: "POST",
914
1077
  headers: { "X-SDK-Key": this.apiKey, "Content-Type": "text/plain" },
915
1078
  body
916
- }).catch((err) => logger.warn("[shipeasy] logExposure failed:", String(err)));
1079
+ }).catch((err) => logger.warn("[shipeasy] exposure send failed:", String(err)));
917
1080
  }
918
1081
  /**
919
1082
  * Report a structured error into the errors primitive. Fire-and-forget —
@@ -921,6 +1084,7 @@ var Engine = class _Engine {
921
1084
  * dedup window + per-process cap.
922
1085
  */
923
1086
  reportError(problem, consequence, extras, kind) {
1087
+ if (this.offline) return;
924
1088
  try {
925
1089
  const correlationId = seeContext.getStore()?.correlationId;
926
1090
  const ev = buildSeeEvent(problem, consequence, extras, {
@@ -959,8 +1123,17 @@ var Engine = class _Engine {
959
1123
  this.telemetry.emit("config", name);
960
1124
  configs[name] = entry.value;
961
1125
  }
962
- for (const [name] of Object.entries(this.expsBlob?.experiments ?? {})) {
963
- experiments[name] = this.getExperiment(name, user, {});
1126
+ const universes = {};
1127
+ for (const [name, exp] of Object.entries(this.expsBlob?.experiments ?? {})) {
1128
+ this.telemetry.emit("experiment", exp.universe);
1129
+ const uniName = exp.universe;
1130
+ if (!(uniName in universes)) {
1131
+ universes[uniName] = {
1132
+ defaults: paramDefaultsFromSchema(this.expsBlob?.universes[uniName]?.param_schema) ?? {}
1133
+ };
1134
+ }
1135
+ const c = this.evalExperiment(name, exp, user);
1136
+ experiments[name] = c.state === "group" ? { inExperiment: true, group: c.group, params: c.params ?? {}, universe: uniName } : { inExperiment: false, group: "control", params: {}, universe: uniName };
964
1137
  }
965
1138
  for (const [name, ks] of Object.entries(this.flagsBlob?.killswitches ?? {})) {
966
1139
  this.telemetry.emit("ks", name);
@@ -977,10 +1150,11 @@ var Engine = class _Engine {
977
1150
  Object.assign(flags2, ov.gates);
978
1151
  Object.assign(configs, ov.configs);
979
1152
  for (const [name, group] of Object.entries(ov.experiments)) {
980
- experiments[name] = { inExperiment: true, group, params: {} };
1153
+ const uni = this.expsBlob?.experiments[name]?.universe;
1154
+ experiments[name] = { inExperiment: true, group, params: {}, ...uni ? { universe: uni } : {} };
981
1155
  }
982
1156
  }
983
- return { flags: flags2, configs, experiments, killswitches };
1157
+ return { flags: flags2, configs, experiments, killswitches, universes };
984
1158
  }
985
1159
  getKillswitch(name, switchKey) {
986
1160
  this.telemetry.emit("ks", name);
@@ -1127,7 +1301,9 @@ async function shipeasy(opts) {
1127
1301
  const profile = opts.i18nDefaultProfile ?? "en:prod";
1128
1302
  flags.configure({
1129
1303
  apiKey: serverKey,
1304
+ isNetworkEnabled: opts.isNetworkEnabled,
1130
1305
  disableTelemetry: opts.disableTelemetry,
1306
+ disableInternalErrorReporting: opts.disableInternalErrorReporting,
1131
1307
  privateAttributes: opts.privateAttributes,
1132
1308
  logLevel: opts.logLevel
1133
1309
  });
@@ -1272,13 +1448,22 @@ var flags = {
1272
1448
  () => _server?.getConfig(name, decodeOrOpts)
1273
1449
  );
1274
1450
  },
1275
- getExperiment(name, user, defaultParams, decode) {
1276
- const notIn = { inExperiment: false, group: "control", params: defaultParams };
1277
- return safeRun(
1278
- "flags.getExperiment",
1279
- notIn,
1280
- () => _server?.getExperiment(name, user, defaultParams, decode) ?? notIn
1281
- );
1451
+ /**
1452
+ * Assign `user` within a universe: `flags.universe("checkout").assign(user)`.
1453
+ * A universe is a mutual-exclusion pool, so the unit lands in ≤1 experiment;
1454
+ * the returned {@link Assignment} exposes `.group` / `.get(field, fallback)`
1455
+ * and auto-logs one exposure when enrolled. Before configure() (or on any
1456
+ * error) it returns a safe not-enrolled handle. This replaces the removed
1457
+ * `getExperiment` — read experiments by universe, never by name.
1458
+ */
1459
+ universe(name) {
1460
+ return {
1461
+ assign: (user) => safeRun(
1462
+ "flags.universe.assign",
1463
+ new AssignmentImpl(null, null, {}),
1464
+ () => _server?.assignUniverse(name, user) ?? new AssignmentImpl(null, null, {})
1465
+ )
1466
+ };
1282
1467
  },
1283
1468
  /**
1284
1469
  * Read a killswitch. Without `switchKey`, returns true when the whole
@@ -1291,11 +1476,6 @@ var flags = {
1291
1476
  track(userId, eventName, props) {
1292
1477
  safeRun("flags.track", void 0, () => _server?.track(userId, eventName, props));
1293
1478
  },
1294
- /** Emit an exposure for an enrolled experiment at the decision point. See
1295
- * {@link Engine.logExposure}. No-op before configure(). */
1296
- logExposure(user, name) {
1297
- safeRun("flags.logExposure", void 0, () => _server?.logExposure(user, name));
1298
- },
1299
1479
  /**
1300
1480
  * Evaluate all flags / configs / experiments for a user against the locally
1301
1481
  * cached blob. Pass the request URL to apply ?se_ks_* / ?se_cf_* / ?se_exp_*
@@ -1412,13 +1592,20 @@ var Client = class {
1412
1592
  () => this.engine.getConfig(name, decodeOrOpts)
1413
1593
  );
1414
1594
  }
1415
- getExperiment(name, defaultParams, decode) {
1416
- const notIn = { inExperiment: false, group: "control", params: defaultParams };
1417
- return safeRun(
1418
- "Client.getExperiment",
1419
- notIn,
1420
- () => this.engine.getExperiment(name, this.attributes, defaultParams, decode)
1421
- );
1595
+ /**
1596
+ * Assign the bound user within a universe: `client.universe("checkout").assign()`.
1597
+ * The user is already bound at construction, so `assign()` takes no arg. Returns
1598
+ * an {@link Assignment} (`.group` / `.get(field, fallback)`) and auto-logs one
1599
+ * exposure when enrolled. Replaces the removed `getExperiment`.
1600
+ */
1601
+ universe(name) {
1602
+ return {
1603
+ assign: () => safeRun(
1604
+ "Client.universe.assign",
1605
+ new AssignmentImpl(null, null, {}),
1606
+ () => this.engine.assignUniverse(name, this.attributes)
1607
+ )
1608
+ };
1422
1609
  }
1423
1610
  /** Read a killswitch (not user-bound; mirrors {@link Engine.getKillswitch}). */
1424
1611
  getKillswitch(name, switchKey) {
@@ -1438,15 +1625,6 @@ var Client = class {
1438
1625
  this.engine.track(String(id), eventName, props);
1439
1626
  });
1440
1627
  }
1441
- /**
1442
- * Emit an exposure event for `name` at this server-side decision point for the
1443
- * bound user. Delegates to {@link Engine.logExposure} with the resolved
1444
- * attribute bag (re-evaluates enrolment; no-op when the user isn't enrolled or
1445
- * in test mode).
1446
- */
1447
- logExposure(name) {
1448
- safeRun("Client.logExposure", void 0, () => this.engine.logExposure(this.attributes, name));
1449
- }
1450
1628
  };
1451
1629
  function dispatchSee(problem, consequence, extras, kind) {
1452
1630
  if (!_server) {