@shipeasy/sdk 6.3.1 → 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.
@@ -122,53 +122,6 @@ function send(url) {
122
122
  }
123
123
  }
124
124
 
125
- // src/logger.ts
126
- var LOG_LEVELS = ["silent", "error", "warn", "info", "debug"];
127
- var RANK = {
128
- silent: 0,
129
- error: 1,
130
- warn: 2,
131
- info: 3,
132
- debug: 4
133
- };
134
- var currentLevel = "warn";
135
- function setLogLevel(level) {
136
- if (typeof level === "string" && Object.prototype.hasOwnProperty.call(RANK, level)) {
137
- currentLevel = level;
138
- }
139
- }
140
- function write(method, args) {
141
- try {
142
- const c = globalThis.console;
143
- if (!c) return;
144
- const fn = c[method] ?? c.log;
145
- if (typeof fn === "function") fn.call(c, ...args);
146
- } catch {
147
- }
148
- }
149
- var logger = {
150
- error(...args) {
151
- if (RANK[currentLevel] >= RANK.error) write("error", args);
152
- },
153
- warn(...args) {
154
- if (RANK[currentLevel] >= RANK.warn) write("warn", args);
155
- },
156
- info(...args) {
157
- if (RANK[currentLevel] >= RANK.info) write("info", args);
158
- },
159
- debug(...args) {
160
- if (RANK[currentLevel] >= RANK.debug) write("debug", args);
161
- }
162
- };
163
- function safeRun(label, fallback, fn) {
164
- try {
165
- return fn();
166
- } catch (err) {
167
- logger.error(`[shipeasy] ${label} failed \u2014 returning safe default:`, String(err));
168
- return fallback;
169
- }
170
- }
171
-
172
125
  // src/see/core.ts
173
126
  var SEE_MAX_MESSAGE = 500;
174
127
  var SEE_MAX_STACK = 8e3;
@@ -286,7 +239,7 @@ function captureCallsiteStack() {
286
239
  const kept = lines.slice(1).filter((l) => !/@shipeasy[\\/]sdk|see[\\/]core|captureCallsiteStack|\bsee\b\s*\(/.test(l));
287
240
  return kept.length ? kept.join("\n") : void 0;
288
241
  }
289
- function buildSeeEvent(problem, consequence, extras, ctx, kindOverride, correlationId) {
242
+ function buildSeeEvent(problem, consequence, extras, ctx2, kindOverride, correlationId) {
290
243
  let errorType;
291
244
  let message;
292
245
  let stack;
@@ -314,8 +267,8 @@ function buildSeeEvent(problem, consequence, extras, ctx, kindOverride, correlat
314
267
  message: truncate(message, SEE_MAX_MESSAGE),
315
268
  subject: consequence.subject,
316
269
  outcome: consequence.outcome,
317
- side: ctx.side,
318
- sdk_version: ctx.sdkVersion,
270
+ side: ctx2.side,
271
+ sdk_version: ctx2.sdkVersion,
319
272
  ts: Date.now()
320
273
  };
321
274
  if (stack) ev.stack = truncate(stack, SEE_MAX_STACK);
@@ -324,10 +277,10 @@ function buildSeeEvent(problem, consequence, extras, ctx, kindOverride, correlat
324
277
  if (causedBy) ev.caused_by = causedBy;
325
278
  const cleanExtras = sanitizeExtras(extras);
326
279
  if (cleanExtras) ev.extras = cleanExtras;
327
- if (ctx.url) ev.url = truncate(ctx.url, SEE_MAX_SUBJECT);
328
- if (ctx.userId) ev.user_id = ctx.userId;
329
- if (ctx.anonId) ev.anonymous_id = ctx.anonId;
330
- if (ctx.env) ev.env = ctx.env;
280
+ if (ctx2.url) ev.url = truncate(ctx2.url, SEE_MAX_SUBJECT);
281
+ if (ctx2.userId) ev.user_id = ctx2.userId;
282
+ if (ctx2.anonId) ev.anonymous_id = ctx2.anonId;
283
+ if (ctx2.env) ev.env = ctx2.env;
331
284
  markReported(problem, ev);
332
285
  return ev;
333
286
  }
@@ -420,6 +373,98 @@ var SeeLimiter = class {
420
373
  }
421
374
  };
422
375
 
376
+ // src/internal-report.ts
377
+ var INGEST_URL = "https://api.shipeasy.ai/collect";
378
+ var PLACEHOLDER_KEY = "sdk_client_REPLACE_WITH_SHIPEASY_INTERNAL_ERROR_KEY";
379
+ var INGEST_KEY = "sdk_client_00bd4608a03e4084922978f9522614d5";
380
+ function keyConfigured() {
381
+ return !!INGEST_KEY && INGEST_KEY !== PLACEHOLDER_KEY;
382
+ }
383
+ var OUTCOME = "returned a safe default";
384
+ var SDK_ID = "ts";
385
+ var ctx = null;
386
+ var limiter = new SeeLimiter();
387
+ function setInternalReportContext(c) {
388
+ ctx = { side: c.side, sdkVersion: c.sdkVersion, enabled: c.enabled !== false };
389
+ }
390
+ function reportInternalError(label, err) {
391
+ try {
392
+ if (!ctx || !ctx.enabled || !keyConfigured()) return;
393
+ const ev = buildSeeEvent(
394
+ err,
395
+ causesThe(label).to(OUTCOME),
396
+ { sdk: SDK_ID },
397
+ { side: ctx.side, sdkVersion: ctx.sdkVersion },
398
+ "caught"
399
+ );
400
+ if (!limiter.shouldSend(ev)) return;
401
+ send2(INGEST_URL, INGEST_KEY, JSON.stringify({ events: [ev] }));
402
+ } catch {
403
+ }
404
+ }
405
+ function send2(url, key, body) {
406
+ try {
407
+ const f = globalThis.fetch;
408
+ if (typeof f !== "function") return;
409
+ void f(url, {
410
+ method: "POST",
411
+ headers: { "X-SDK-Key": key, "Content-Type": "text/plain" },
412
+ body,
413
+ keepalive: true
414
+ }).catch(() => {
415
+ });
416
+ } catch {
417
+ }
418
+ }
419
+
420
+ // src/logger.ts
421
+ var LOG_LEVELS = ["silent", "error", "warn", "info", "debug"];
422
+ var RANK = {
423
+ silent: 0,
424
+ error: 1,
425
+ warn: 2,
426
+ info: 3,
427
+ debug: 4
428
+ };
429
+ var currentLevel = "warn";
430
+ function setLogLevel(level) {
431
+ if (typeof level === "string" && Object.prototype.hasOwnProperty.call(RANK, level)) {
432
+ currentLevel = level;
433
+ }
434
+ }
435
+ function write(method, args) {
436
+ try {
437
+ const c = globalThis.console;
438
+ if (!c) return;
439
+ const fn = c[method] ?? c.log;
440
+ if (typeof fn === "function") fn.call(c, ...args);
441
+ } catch {
442
+ }
443
+ }
444
+ var logger = {
445
+ error(...args) {
446
+ if (RANK[currentLevel] >= RANK.error) write("error", args);
447
+ },
448
+ warn(...args) {
449
+ if (RANK[currentLevel] >= RANK.warn) write("warn", args);
450
+ },
451
+ info(...args) {
452
+ if (RANK[currentLevel] >= RANK.info) write("info", args);
453
+ },
454
+ debug(...args) {
455
+ if (RANK[currentLevel] >= RANK.debug) write("debug", args);
456
+ }
457
+ };
458
+ function safeRun(label, fallback, fn) {
459
+ try {
460
+ return fn();
461
+ } catch (err) {
462
+ logger.error(`[shipeasy] ${label} failed \u2014 returning safe default:`, String(err));
463
+ reportInternalError(label, err);
464
+ return fallback;
465
+ }
466
+ }
467
+
423
468
  // src/server/index.ts
424
469
  var version = "4.0.0";
425
470
  var C1 = 3432918353;
@@ -556,6 +601,78 @@ function evalGateInternal(gate, user) {
556
601
  if (!uid) return gate.rolloutPct >= 1e4;
557
602
  return murmur3(`${gate.salt}:${uid}`) % 1e4 < gate.rolloutPct;
558
603
  }
604
+ function paramDefaultsFromSchema(schema) {
605
+ if (!schema || schema.length === 0) return null;
606
+ const out = {};
607
+ for (const p of schema) out[p.name] = p.default;
608
+ return out;
609
+ }
610
+ function mergeParams(paramDefaults, groupParams) {
611
+ return paramDefaults ? { ...paramDefaults, ...groupParams } : { ...groupParams };
612
+ }
613
+ function classifyExperimentLocal(exp, user, holdoutRange, paramDefaults, evalGate, sticky) {
614
+ const asGroup = (g) => ({
615
+ state: "group",
616
+ group: g.name,
617
+ params: mergeParams(paramDefaults, g.params)
618
+ });
619
+ if (exp.targetingGate && !evalGate(exp.targetingGate)) return { state: "out" };
620
+ const uid = pickIdentifier(user, exp.bucketBy);
621
+ if (!uid) return { state: "out" };
622
+ const universeSeg = murmur3(`${exp.universe}:${uid}`) % 1e4;
623
+ if (holdoutRange) {
624
+ const [lo, hi] = holdoutRange;
625
+ if (universeSeg >= lo && universeSeg <= hi) return { state: "holdout" };
626
+ }
627
+ if (exp.holdoutGate && evalGate(exp.holdoutGate)) return { state: "holdout" };
628
+ const salt8 = exp.salt.slice(0, 8);
629
+ if (sticky) {
630
+ const entry = sticky.get();
631
+ if (entry && entry.s === salt8) {
632
+ const g = exp.groups.find((x) => x.name === entry.g);
633
+ if (g) return asGroup(g);
634
+ }
635
+ }
636
+ const pooled = (exp.hashVersion ?? 1) >= 2 && exp.poolOffsetBp != null && exp.poolSizeBp != null && exp.poolSizeBp > 0;
637
+ if (pooled) {
638
+ const lo = exp.poolOffsetBp;
639
+ const hi = lo + exp.poolSizeBp;
640
+ if (universeSeg < lo || universeSeg >= hi) return { state: "out" };
641
+ } else {
642
+ if (murmur3(`${exp.salt}:alloc:${uid}`) % 1e4 >= exp.allocationPct) return { state: "out" };
643
+ }
644
+ const reserved = Math.max(0, Math.min(1e4, exp.reservedHeadroomBp ?? 0));
645
+ const usable = 1e4 - reserved;
646
+ const groupHash = murmur3(`${exp.salt}:group:${uid}`) % 1e4;
647
+ if (groupHash >= usable) return { state: "out" };
648
+ let cumulative = 0;
649
+ for (let i = 0; i < exp.groups.length; i++) {
650
+ const g = exp.groups[i];
651
+ cumulative += g.weight;
652
+ if (groupHash < cumulative || i === exp.groups.length - 1) {
653
+ sticky?.set({ g: g.name, s: salt8 });
654
+ return asGroup(g);
655
+ }
656
+ }
657
+ return { state: "out" };
658
+ }
659
+ var AssignmentImpl = class {
660
+ constructor(name, group, params) {
661
+ this.name = name;
662
+ this.group = group;
663
+ this.params = params;
664
+ }
665
+ name;
666
+ group;
667
+ params;
668
+ get enrolled() {
669
+ return this.group !== null;
670
+ }
671
+ get(field, fallback) {
672
+ const v = this.params[field];
673
+ return v === void 0 ? fallback : v;
674
+ }
675
+ };
559
676
  var TRUE_RX = /^(true|on|1|yes)$/i;
560
677
  var FALSE_RX = /^(false|off|0|no)$/i;
561
678
  function parseOverrideBool(raw) {
@@ -626,6 +743,9 @@ var Engine = class _Engine {
626
743
  flagOverrides = /* @__PURE__ */ new Map();
627
744
  configOverrides = /* @__PURE__ */ new Map();
628
745
  experimentOverrides = /* @__PURE__ */ new Map();
746
+ // Bounded per-process exposure dedup (`uid:exp:group`) so auto-exposure from
747
+ // repeated assign() calls doesn't spam /collect. Cleared past a soft cap.
748
+ exposureSeen = /* @__PURE__ */ new Set();
629
749
  // Change listeners fired after a background poll returns NEW data (200, not
630
750
  // 304). Never fired in testMode/offline (no polling happens there).
631
751
  changeListeners = /* @__PURE__ */ new Set();
@@ -637,6 +757,11 @@ var Engine = class _Engine {
637
757
  this.privateAttributes = opts.privateAttributes ?? [];
638
758
  this.stickyStore = opts.stickyStore;
639
759
  this.testMode = opts.testMode === true;
760
+ setInternalReportContext({
761
+ side: "server",
762
+ sdkVersion: version,
763
+ enabled: !this.testMode && opts.disableInternalErrorReporting !== true
764
+ });
640
765
  this.telemetry = new Telemetry({
641
766
  endpoint: opts.telemetryUrl ?? DEFAULT_TELEMETRY_URL,
642
767
  sdkKey: this.apiKey,
@@ -847,68 +972,77 @@ var Engine = class _Engine {
847
972
  return "defaultValue" in opts ? opts.defaultValue : void 0;
848
973
  }
849
974
  }
850
- getExperiment(name, user, defaultParams, decode) {
851
- this.telemetry.emit("experiment", name);
852
- const notIn = {
853
- inExperiment: false,
854
- group: "control",
855
- params: defaultParams
975
+ /**
976
+ * Bind the per-experiment sticky seam over the configured {@link StickyBucketStore}
977
+ * for one (unit, experiment). Absent store ⇒ deterministic (no sticky).
978
+ */
979
+ bindSticky(name, uid) {
980
+ const store = this.stickyStore;
981
+ if (!store) return void 0;
982
+ return {
983
+ get: () => store.get(uid)?.[name],
984
+ set: (entry) => store.set(uid, name, entry)
856
985
  };
986
+ }
987
+ /**
988
+ * Evaluate one experiment by name for `user` — override → full classify
989
+ * pipeline (targeting → universe holdout → holdout gate → sticky → allocation
990
+ * → group), merging the universe defaults under the assigned variant (§B2).
991
+ * Internal: the public surface is `universe(name).assign(user)`. Reused by the
992
+ * SSR `evaluate()` bootstrap (keyed by experiment name) and by `assignUniverse`.
993
+ */
994
+ evalExperiment(name, exp, user) {
995
+ const paramDefaults = paramDefaultsFromSchema(
996
+ this.expsBlob?.universes[exp.universe]?.param_schema
997
+ );
857
998
  const ov = this.experimentOverrides.get(name);
858
- if (ov) {
859
- if (!decode) return { inExperiment: true, group: ov.group, params: ov.params };
860
- try {
861
- return { inExperiment: true, group: ov.group, params: decode(ov.params) };
862
- } catch (err) {
863
- logger.warn(`[shipeasy] getExperiment('${name}') override decode failed:`, String(err));
864
- return notIn;
865
- }
866
- }
867
- if (!this.flagsBlob || !this.expsBlob) return notIn;
868
- const exp = this.expsBlob.experiments[name];
869
- if (!exp || exp.status !== "running") return notIn;
870
- if (exp.targetingGate) {
871
- const gate = this.flagsBlob.gates[exp.targetingGate];
872
- if (!gate || !evalGateInternal(gate, user)) return notIn;
873
- }
874
- const uid = pickIdentifier(user, exp.bucketBy);
875
- if (!uid) return notIn;
876
- const universe = this.expsBlob.universes[exp.universe];
877
- const holdoutRange = universe?.holdout_range ?? null;
878
- if (holdoutRange) {
879
- const seg = murmur3(`${exp.universe}:${uid}`) % 1e4;
880
- const [lo, hi] = holdoutRange;
881
- if (seg >= lo && seg <= hi) return notIn;
882
- }
883
- const asResult = (g) => {
884
- if (!decode) return { inExperiment: true, group: g.name, params: g.params };
885
- try {
886
- return { inExperiment: true, group: g.name, params: decode(g.params) };
887
- } catch (err) {
888
- logger.warn(`[shipeasy] getExperiment('${name}') decode failed:`, String(err));
889
- return notIn;
890
- }
999
+ if (ov) return { state: "group", group: ov.group, params: mergeParams(paramDefaults, ov.params) };
1000
+ if (!this.flagsBlob || !this.expsBlob) return { state: "out" };
1001
+ if (exp.status !== "running") return { state: "out" };
1002
+ const holdoutRange = this.expsBlob.universes[exp.universe]?.holdout_range ?? null;
1003
+ const evalGate = (gname) => {
1004
+ const gate = this.flagsBlob?.gates[gname];
1005
+ return gate ? evalGateInternal(gate, user) : false;
891
1006
  };
892
- const salt8 = exp.salt.slice(0, 8);
893
- if (this.stickyStore) {
894
- const entry = this.stickyStore.get(uid)?.[name];
895
- if (entry && entry.s === salt8) {
896
- const g = exp.groups.find((x) => x.name === entry.g);
897
- if (g) return asResult(g);
898
- }
899
- }
900
- if (murmur3(`${exp.salt}:alloc:${uid}`) % 1e4 >= exp.allocationPct) return notIn;
901
- const groupHash = murmur3(`${exp.salt}:group:${uid}`) % 1e4;
902
- let cumulative = 0;
903
- for (let i = 0; i < exp.groups.length; i++) {
904
- const g = exp.groups[i];
905
- cumulative += g.weight;
906
- if (groupHash < cumulative || i === exp.groups.length - 1) {
907
- this.stickyStore?.set(uid, name, { g: g.name, s: salt8 });
908
- return asResult(g);
1007
+ const uid = pickIdentifier(user, exp.bucketBy);
1008
+ const sticky = uid ? this.bindSticky(name, uid) : void 0;
1009
+ return classifyExperimentLocal(exp, user, holdoutRange, paramDefaults, evalGate, sticky);
1010
+ }
1011
+ /**
1012
+ * Assign `user` within `universeName`. A universe is a mutual-exclusion pool,
1013
+ * so a unit lands in **at most one** experiment; the returned {@link Assignment}
1014
+ * exposes the variant + resolved params and auto-logs a single exposure when
1015
+ * enrolled. An un-enrolled unit still resolves `get()` to the universe defaults.
1016
+ * Never throws. This is the sole experiment read path (there is no
1017
+ * `getExperiment` a caller asks a universe, not an experiment).
1018
+ */
1019
+ assignUniverse(universeName, user) {
1020
+ this.telemetry.emit("experiment", universeName);
1021
+ const paramDefaults = paramDefaultsFromSchema(
1022
+ this.expsBlob?.universes[universeName]?.param_schema
1023
+ );
1024
+ const notEnrolled = () => new AssignmentImpl(null, null, paramDefaults ?? {});
1025
+ if (!this.expsBlob) return notEnrolled();
1026
+ const candidates = Object.entries(this.expsBlob.experiments).filter(([, e]) => e.universe === universeName && e.status === "running").sort(
1027
+ (a, b) => (a[1].poolOffsetBp ?? 0) - (b[1].poolOffsetBp ?? 0) || a[0].localeCompare(b[0])
1028
+ );
1029
+ for (const [name, exp] of candidates) {
1030
+ const c = this.evalExperiment(name, exp, user);
1031
+ if (c.state === "group") {
1032
+ this.postExposure(user, name, c.group);
1033
+ return new AssignmentImpl(name, c.group, c.params ?? {});
909
1034
  }
910
1035
  }
911
- return notIn;
1036
+ return notEnrolled();
1037
+ }
1038
+ /**
1039
+ * The universe-first experiment read entry point:
1040
+ * `engine.universe("checkout").assign(user)`. Returns a reusable handle bound
1041
+ * to one universe; `assign(user)` picks the ≤1 experiment the unit is pooled
1042
+ * into and auto-logs a single exposure. See {@link assignUniverse}.
1043
+ */
1044
+ universe(name) {
1045
+ return { assign: (user) => this.assignUniverse(name, user) };
912
1046
  }
913
1047
  /** Drop caller-marked private attributes from an outbound props bag. */
914
1048
  stripPrivate(props) {
@@ -940,26 +1074,26 @@ var Engine = class _Engine {
940
1074
  }).catch((err) => logger.warn("[shipeasy] track failed:", String(err)));
941
1075
  }
942
1076
  /**
943
- * Emit an exposure event for an experiment at the server-side decision point
944
- * (parity with the browser's auto-exposure). The server is stateless and
945
- * never auto-logs, so call this when you actually present the treatment.
946
- * Re-evaluates the experiment for `user` (a bare `user_id` string is wrapped
947
- * as `{ user_id }`); if the user is enrolled, POSTs a single exposure to
948
- * `/collect`. No-op in test mode or when the user isn't enrolled.
1077
+ * POST a single exposure for an enrolled `(user, experiment, group)`. Deduped
1078
+ * per process (bounded set) so repeated `assign()` calls in one server don't
1079
+ * spam `/collect`. Fire-and-forget; no-op in test mode. This is how
1080
+ * `assignUniverse` auto-logs the browser's auto-exposure parity for SSR.
949
1081
  */
950
- logExposure(user, name) {
1082
+ postExposure(user, experiment, group) {
951
1083
  if (this.testMode) return;
952
- const u = typeof user === "string" ? { user_id: user } : user;
953
- const result = this.getExperiment(name, u, {});
954
- if (!result.inExperiment) return;
1084
+ const uid = user.user_id ?? user.anonymous_id;
1085
+ const dedupKey = `${uid ?? ""}:${experiment}:${group}`;
1086
+ if (this.exposureSeen.has(dedupKey)) return;
1087
+ if (this.exposureSeen.size > 5e3) this.exposureSeen.clear();
1088
+ this.exposureSeen.add(dedupKey);
955
1089
  const body = JSON.stringify({
956
1090
  events: [
957
1091
  {
958
1092
  type: "exposure",
959
- experiment: name,
960
- group: result.group,
961
- ...u.user_id !== void 0 ? { user_id: u.user_id } : {},
962
- ...u.anonymous_id !== void 0 ? { anonymous_id: u.anonymous_id } : {},
1093
+ experiment,
1094
+ group,
1095
+ ...user.user_id !== void 0 ? { user_id: user.user_id } : {},
1096
+ ...user.anonymous_id !== void 0 ? { anonymous_id: user.anonymous_id } : {},
963
1097
  ts: Date.now()
964
1098
  }
965
1099
  ]
@@ -968,7 +1102,7 @@ var Engine = class _Engine {
968
1102
  method: "POST",
969
1103
  headers: { "X-SDK-Key": this.apiKey, "Content-Type": "text/plain" },
970
1104
  body
971
- }).catch((err) => logger.warn("[shipeasy] logExposure failed:", String(err)));
1105
+ }).catch((err) => logger.warn("[shipeasy] exposure send failed:", String(err)));
972
1106
  }
973
1107
  /**
974
1108
  * Report a structured error into the errors primitive. Fire-and-forget —
@@ -1014,8 +1148,17 @@ var Engine = class _Engine {
1014
1148
  this.telemetry.emit("config", name);
1015
1149
  configs[name] = entry.value;
1016
1150
  }
1017
- for (const [name] of Object.entries(this.expsBlob?.experiments ?? {})) {
1018
- experiments[name] = this.getExperiment(name, user, {});
1151
+ const universes = {};
1152
+ for (const [name, exp] of Object.entries(this.expsBlob?.experiments ?? {})) {
1153
+ this.telemetry.emit("experiment", exp.universe);
1154
+ const uniName = exp.universe;
1155
+ if (!(uniName in universes)) {
1156
+ universes[uniName] = {
1157
+ defaults: paramDefaultsFromSchema(this.expsBlob?.universes[uniName]?.param_schema) ?? {}
1158
+ };
1159
+ }
1160
+ const c = this.evalExperiment(name, exp, user);
1161
+ experiments[name] = c.state === "group" ? { inExperiment: true, group: c.group, params: c.params ?? {}, universe: uniName } : { inExperiment: false, group: "control", params: {}, universe: uniName };
1019
1162
  }
1020
1163
  for (const [name, ks] of Object.entries(this.flagsBlob?.killswitches ?? {})) {
1021
1164
  this.telemetry.emit("ks", name);
@@ -1032,10 +1175,11 @@ var Engine = class _Engine {
1032
1175
  Object.assign(flags2, ov.gates);
1033
1176
  Object.assign(configs, ov.configs);
1034
1177
  for (const [name, group] of Object.entries(ov.experiments)) {
1035
- experiments[name] = { inExperiment: true, group, params: {} };
1178
+ const uni = this.expsBlob?.experiments[name]?.universe;
1179
+ experiments[name] = { inExperiment: true, group, params: {}, ...uni ? { universe: uni } : {} };
1036
1180
  }
1037
1181
  }
1038
- return { flags: flags2, configs, experiments, killswitches };
1182
+ return { flags: flags2, configs, experiments, killswitches, universes };
1039
1183
  }
1040
1184
  getKillswitch(name, switchKey) {
1041
1185
  this.telemetry.emit("ks", name);
@@ -1183,6 +1327,7 @@ async function shipeasy(opts) {
1183
1327
  flags.configure({
1184
1328
  apiKey: serverKey,
1185
1329
  disableTelemetry: opts.disableTelemetry,
1330
+ disableInternalErrorReporting: opts.disableInternalErrorReporting,
1186
1331
  privateAttributes: opts.privateAttributes,
1187
1332
  logLevel: opts.logLevel
1188
1333
  });
@@ -1327,13 +1472,22 @@ var flags = {
1327
1472
  () => _server?.getConfig(name, decodeOrOpts)
1328
1473
  );
1329
1474
  },
1330
- getExperiment(name, user, defaultParams, decode) {
1331
- const notIn = { inExperiment: false, group: "control", params: defaultParams };
1332
- return safeRun(
1333
- "flags.getExperiment",
1334
- notIn,
1335
- () => _server?.getExperiment(name, user, defaultParams, decode) ?? notIn
1336
- );
1475
+ /**
1476
+ * Assign `user` within a universe: `flags.universe("checkout").assign(user)`.
1477
+ * A universe is a mutual-exclusion pool, so the unit lands in ≤1 experiment;
1478
+ * the returned {@link Assignment} exposes `.group` / `.get(field, fallback)`
1479
+ * and auto-logs one exposure when enrolled. Before configure() (or on any
1480
+ * error) it returns a safe not-enrolled handle. This replaces the removed
1481
+ * `getExperiment` — read experiments by universe, never by name.
1482
+ */
1483
+ universe(name) {
1484
+ return {
1485
+ assign: (user) => safeRun(
1486
+ "flags.universe.assign",
1487
+ new AssignmentImpl(null, null, {}),
1488
+ () => _server?.assignUniverse(name, user) ?? new AssignmentImpl(null, null, {})
1489
+ )
1490
+ };
1337
1491
  },
1338
1492
  /**
1339
1493
  * Read a killswitch. Without `switchKey`, returns true when the whole
@@ -1346,11 +1500,6 @@ var flags = {
1346
1500
  track(userId, eventName, props) {
1347
1501
  safeRun("flags.track", void 0, () => _server?.track(userId, eventName, props));
1348
1502
  },
1349
- /** Emit an exposure for an enrolled experiment at the decision point. See
1350
- * {@link Engine.logExposure}. No-op before configure(). */
1351
- logExposure(user, name) {
1352
- safeRun("flags.logExposure", void 0, () => _server?.logExposure(user, name));
1353
- },
1354
1503
  /**
1355
1504
  * Evaluate all flags / configs / experiments for a user against the locally
1356
1505
  * cached blob. Pass the request URL to apply ?se_ks_* / ?se_cf_* / ?se_exp_*
@@ -1467,13 +1616,20 @@ var Client = class {
1467
1616
  () => this.engine.getConfig(name, decodeOrOpts)
1468
1617
  );
1469
1618
  }
1470
- getExperiment(name, defaultParams, decode) {
1471
- const notIn = { inExperiment: false, group: "control", params: defaultParams };
1472
- return safeRun(
1473
- "Client.getExperiment",
1474
- notIn,
1475
- () => this.engine.getExperiment(name, this.attributes, defaultParams, decode)
1476
- );
1619
+ /**
1620
+ * Assign the bound user within a universe: `client.universe("checkout").assign()`.
1621
+ * The user is already bound at construction, so `assign()` takes no arg. Returns
1622
+ * an {@link Assignment} (`.group` / `.get(field, fallback)`) and auto-logs one
1623
+ * exposure when enrolled. Replaces the removed `getExperiment`.
1624
+ */
1625
+ universe(name) {
1626
+ return {
1627
+ assign: () => safeRun(
1628
+ "Client.universe.assign",
1629
+ new AssignmentImpl(null, null, {}),
1630
+ () => this.engine.assignUniverse(name, this.attributes)
1631
+ )
1632
+ };
1477
1633
  }
1478
1634
  /** Read a killswitch (not user-bound; mirrors {@link Engine.getKillswitch}). */
1479
1635
  getKillswitch(name, switchKey) {
@@ -1493,15 +1649,6 @@ var Client = class {
1493
1649
  this.engine.track(String(id), eventName, props);
1494
1650
  });
1495
1651
  }
1496
- /**
1497
- * Emit an exposure event for `name` at this server-side decision point for the
1498
- * bound user. Delegates to {@link Engine.logExposure} with the resolved
1499
- * attribute bag (re-evaluates enrolment; no-op when the user isn't enrolled or
1500
- * in test mode).
1501
- */
1502
- logExposure(name) {
1503
- safeRun("Client.logExposure", void 0, () => this.engine.logExposure(this.attributes, name));
1504
- }
1505
1652
  };
1506
1653
  function dispatchSee(problem, consequence, extras, kind) {
1507
1654
  if (!_server) {