@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.
@@ -67,53 +67,6 @@ 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
- }
84
- }
85
- function write(method, args) {
86
- 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);
91
- } 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;
114
- }
115
- }
116
-
117
70
  // src/see/core.ts
118
71
  var SEE_MAX_MESSAGE = 500;
119
72
  var SEE_MAX_STACK = 8e3;
@@ -231,7 +184,7 @@ function captureCallsiteStack() {
231
184
  const kept = lines.slice(1).filter((l) => !/@shipeasy[\\/]sdk|see[\\/]core|captureCallsiteStack|\bsee\b\s*\(/.test(l));
232
185
  return kept.length ? kept.join("\n") : void 0;
233
186
  }
234
- function buildSeeEvent(problem, consequence, extras, ctx, kindOverride, correlationId) {
187
+ function buildSeeEvent(problem, consequence, extras, ctx2, kindOverride, correlationId) {
235
188
  let errorType;
236
189
  let message;
237
190
  let stack;
@@ -259,8 +212,8 @@ function buildSeeEvent(problem, consequence, extras, ctx, kindOverride, correlat
259
212
  message: truncate(message, SEE_MAX_MESSAGE),
260
213
  subject: consequence.subject,
261
214
  outcome: consequence.outcome,
262
- side: ctx.side,
263
- sdk_version: ctx.sdkVersion,
215
+ side: ctx2.side,
216
+ sdk_version: ctx2.sdkVersion,
264
217
  ts: Date.now()
265
218
  };
266
219
  if (stack) ev.stack = truncate(stack, SEE_MAX_STACK);
@@ -269,10 +222,10 @@ function buildSeeEvent(problem, consequence, extras, ctx, kindOverride, correlat
269
222
  if (causedBy) ev.caused_by = causedBy;
270
223
  const cleanExtras = sanitizeExtras(extras);
271
224
  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;
225
+ if (ctx2.url) ev.url = truncate(ctx2.url, SEE_MAX_SUBJECT);
226
+ if (ctx2.userId) ev.user_id = ctx2.userId;
227
+ if (ctx2.anonId) ev.anonymous_id = ctx2.anonId;
228
+ if (ctx2.env) ev.env = ctx2.env;
276
229
  markReported(problem, ev);
277
230
  return ev;
278
231
  }
@@ -365,6 +318,98 @@ var SeeLimiter = class {
365
318
  }
366
319
  };
367
320
 
321
+ // src/internal-report.ts
322
+ var INGEST_URL = "https://api.shipeasy.ai/collect";
323
+ var PLACEHOLDER_KEY = "sdk_client_REPLACE_WITH_SHIPEASY_INTERNAL_ERROR_KEY";
324
+ var INGEST_KEY = "sdk_client_00bd4608a03e4084922978f9522614d5";
325
+ function keyConfigured() {
326
+ return !!INGEST_KEY && INGEST_KEY !== PLACEHOLDER_KEY;
327
+ }
328
+ var OUTCOME = "returned a safe default";
329
+ var SDK_ID = "ts";
330
+ var ctx = null;
331
+ var limiter = new SeeLimiter();
332
+ function setInternalReportContext(c) {
333
+ ctx = { side: c.side, sdkVersion: c.sdkVersion, enabled: c.enabled !== false };
334
+ }
335
+ function reportInternalError(label, err) {
336
+ try {
337
+ if (!ctx || !ctx.enabled || !keyConfigured()) return;
338
+ const ev = buildSeeEvent(
339
+ err,
340
+ causesThe(label).to(OUTCOME),
341
+ { sdk: SDK_ID },
342
+ { side: ctx.side, sdkVersion: ctx.sdkVersion },
343
+ "caught"
344
+ );
345
+ if (!limiter.shouldSend(ev)) return;
346
+ send2(INGEST_URL, INGEST_KEY, JSON.stringify({ events: [ev] }));
347
+ } catch {
348
+ }
349
+ }
350
+ function send2(url, key, body) {
351
+ try {
352
+ const f = globalThis.fetch;
353
+ if (typeof f !== "function") return;
354
+ void f(url, {
355
+ method: "POST",
356
+ headers: { "X-SDK-Key": key, "Content-Type": "text/plain" },
357
+ body,
358
+ keepalive: true
359
+ }).catch(() => {
360
+ });
361
+ } catch {
362
+ }
363
+ }
364
+
365
+ // src/logger.ts
366
+ var LOG_LEVELS = ["silent", "error", "warn", "info", "debug"];
367
+ var RANK = {
368
+ silent: 0,
369
+ error: 1,
370
+ warn: 2,
371
+ info: 3,
372
+ debug: 4
373
+ };
374
+ var currentLevel = "warn";
375
+ function setLogLevel(level) {
376
+ if (typeof level === "string" && Object.prototype.hasOwnProperty.call(RANK, level)) {
377
+ currentLevel = level;
378
+ }
379
+ }
380
+ function write(method, args) {
381
+ try {
382
+ const c = globalThis.console;
383
+ if (!c) return;
384
+ const fn = c[method] ?? c.log;
385
+ if (typeof fn === "function") fn.call(c, ...args);
386
+ } catch {
387
+ }
388
+ }
389
+ var logger = {
390
+ error(...args) {
391
+ if (RANK[currentLevel] >= RANK.error) write("error", args);
392
+ },
393
+ warn(...args) {
394
+ if (RANK[currentLevel] >= RANK.warn) write("warn", args);
395
+ },
396
+ info(...args) {
397
+ if (RANK[currentLevel] >= RANK.info) write("info", args);
398
+ },
399
+ debug(...args) {
400
+ if (RANK[currentLevel] >= RANK.debug) write("debug", args);
401
+ }
402
+ };
403
+ function safeRun(label, fallback, fn) {
404
+ try {
405
+ return fn();
406
+ } catch (err) {
407
+ logger.error(`[shipeasy] ${label} failed \u2014 returning safe default:`, String(err));
408
+ reportInternalError(label, err);
409
+ return fallback;
410
+ }
411
+ }
412
+
368
413
  // src/server/index.ts
369
414
  var version = "4.0.0";
370
415
  var C1 = 3432918353;
@@ -501,6 +546,78 @@ function evalGateInternal(gate, user) {
501
546
  if (!uid) return gate.rolloutPct >= 1e4;
502
547
  return murmur3(`${gate.salt}:${uid}`) % 1e4 < gate.rolloutPct;
503
548
  }
549
+ function paramDefaultsFromSchema(schema) {
550
+ if (!schema || schema.length === 0) return null;
551
+ const out = {};
552
+ for (const p of schema) out[p.name] = p.default;
553
+ return out;
554
+ }
555
+ function mergeParams(paramDefaults, groupParams) {
556
+ return paramDefaults ? { ...paramDefaults, ...groupParams } : { ...groupParams };
557
+ }
558
+ function classifyExperimentLocal(exp, user, holdoutRange, paramDefaults, evalGate, sticky) {
559
+ const asGroup = (g) => ({
560
+ state: "group",
561
+ group: g.name,
562
+ params: mergeParams(paramDefaults, g.params)
563
+ });
564
+ if (exp.targetingGate && !evalGate(exp.targetingGate)) return { state: "out" };
565
+ const uid = pickIdentifier(user, exp.bucketBy);
566
+ if (!uid) return { state: "out" };
567
+ const universeSeg = murmur3(`${exp.universe}:${uid}`) % 1e4;
568
+ if (holdoutRange) {
569
+ const [lo, hi] = holdoutRange;
570
+ if (universeSeg >= lo && universeSeg <= hi) return { state: "holdout" };
571
+ }
572
+ if (exp.holdoutGate && evalGate(exp.holdoutGate)) return { state: "holdout" };
573
+ const salt8 = exp.salt.slice(0, 8);
574
+ if (sticky) {
575
+ const entry = sticky.get();
576
+ if (entry && entry.s === salt8) {
577
+ const g = exp.groups.find((x) => x.name === entry.g);
578
+ if (g) return asGroup(g);
579
+ }
580
+ }
581
+ const pooled = (exp.hashVersion ?? 1) >= 2 && exp.poolOffsetBp != null && exp.poolSizeBp != null && exp.poolSizeBp > 0;
582
+ if (pooled) {
583
+ const lo = exp.poolOffsetBp;
584
+ const hi = lo + exp.poolSizeBp;
585
+ if (universeSeg < lo || universeSeg >= hi) return { state: "out" };
586
+ } else {
587
+ if (murmur3(`${exp.salt}:alloc:${uid}`) % 1e4 >= exp.allocationPct) return { state: "out" };
588
+ }
589
+ const reserved = Math.max(0, Math.min(1e4, exp.reservedHeadroomBp ?? 0));
590
+ const usable = 1e4 - reserved;
591
+ const groupHash = murmur3(`${exp.salt}:group:${uid}`) % 1e4;
592
+ if (groupHash >= usable) return { state: "out" };
593
+ let cumulative = 0;
594
+ for (let i = 0; i < exp.groups.length; i++) {
595
+ const g = exp.groups[i];
596
+ cumulative += g.weight;
597
+ if (groupHash < cumulative || i === exp.groups.length - 1) {
598
+ sticky?.set({ g: g.name, s: salt8 });
599
+ return asGroup(g);
600
+ }
601
+ }
602
+ return { state: "out" };
603
+ }
604
+ var AssignmentImpl = class {
605
+ constructor(name, group, params) {
606
+ this.name = name;
607
+ this.group = group;
608
+ this.params = params;
609
+ }
610
+ name;
611
+ group;
612
+ params;
613
+ get enrolled() {
614
+ return this.group !== null;
615
+ }
616
+ get(field, fallback) {
617
+ const v = this.params[field];
618
+ return v === void 0 ? fallback : v;
619
+ }
620
+ };
504
621
  var TRUE_RX = /^(true|on|1|yes)$/i;
505
622
  var FALSE_RX = /^(false|off|0|no)$/i;
506
623
  function parseOverrideBool(raw) {
@@ -571,6 +688,9 @@ var Engine = class _Engine {
571
688
  flagOverrides = /* @__PURE__ */ new Map();
572
689
  configOverrides = /* @__PURE__ */ new Map();
573
690
  experimentOverrides = /* @__PURE__ */ new Map();
691
+ // Bounded per-process exposure dedup (`uid:exp:group`) so auto-exposure from
692
+ // repeated assign() calls doesn't spam /collect. Cleared past a soft cap.
693
+ exposureSeen = /* @__PURE__ */ new Set();
574
694
  // Change listeners fired after a background poll returns NEW data (200, not
575
695
  // 304). Never fired in testMode/offline (no polling happens there).
576
696
  changeListeners = /* @__PURE__ */ new Set();
@@ -582,6 +702,11 @@ var Engine = class _Engine {
582
702
  this.privateAttributes = opts.privateAttributes ?? [];
583
703
  this.stickyStore = opts.stickyStore;
584
704
  this.testMode = opts.testMode === true;
705
+ setInternalReportContext({
706
+ side: "server",
707
+ sdkVersion: version,
708
+ enabled: !this.testMode && opts.disableInternalErrorReporting !== true
709
+ });
585
710
  this.telemetry = new Telemetry({
586
711
  endpoint: opts.telemetryUrl ?? DEFAULT_TELEMETRY_URL,
587
712
  sdkKey: this.apiKey,
@@ -792,68 +917,77 @@ var Engine = class _Engine {
792
917
  return "defaultValue" in opts ? opts.defaultValue : void 0;
793
918
  }
794
919
  }
795
- getExperiment(name, user, defaultParams, decode) {
796
- this.telemetry.emit("experiment", name);
797
- const notIn = {
798
- inExperiment: false,
799
- group: "control",
800
- params: defaultParams
920
+ /**
921
+ * Bind the per-experiment sticky seam over the configured {@link StickyBucketStore}
922
+ * for one (unit, experiment). Absent store ⇒ deterministic (no sticky).
923
+ */
924
+ bindSticky(name, uid) {
925
+ const store = this.stickyStore;
926
+ if (!store) return void 0;
927
+ return {
928
+ get: () => store.get(uid)?.[name],
929
+ set: (entry) => store.set(uid, name, entry)
801
930
  };
931
+ }
932
+ /**
933
+ * Evaluate one experiment by name for `user` — override → full classify
934
+ * pipeline (targeting → universe holdout → holdout gate → sticky → allocation
935
+ * → group), merging the universe defaults under the assigned variant (§B2).
936
+ * Internal: the public surface is `universe(name).assign(user)`. Reused by the
937
+ * SSR `evaluate()` bootstrap (keyed by experiment name) and by `assignUniverse`.
938
+ */
939
+ evalExperiment(name, exp, user) {
940
+ const paramDefaults = paramDefaultsFromSchema(
941
+ this.expsBlob?.universes[exp.universe]?.param_schema
942
+ );
802
943
  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
- }
944
+ if (ov) return { state: "group", group: ov.group, params: mergeParams(paramDefaults, ov.params) };
945
+ if (!this.flagsBlob || !this.expsBlob) return { state: "out" };
946
+ if (exp.status !== "running") return { state: "out" };
947
+ const holdoutRange = this.expsBlob.universes[exp.universe]?.holdout_range ?? null;
948
+ const evalGate = (gname) => {
949
+ const gate = this.flagsBlob?.gates[gname];
950
+ return gate ? evalGateInternal(gate, user) : false;
836
951
  };
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);
952
+ const uid = pickIdentifier(user, exp.bucketBy);
953
+ const sticky = uid ? this.bindSticky(name, uid) : void 0;
954
+ return classifyExperimentLocal(exp, user, holdoutRange, paramDefaults, evalGate, sticky);
955
+ }
956
+ /**
957
+ * Assign `user` within `universeName`. A universe is a mutual-exclusion pool,
958
+ * so a unit lands in **at most one** experiment; the returned {@link Assignment}
959
+ * exposes the variant + resolved params and auto-logs a single exposure when
960
+ * enrolled. An un-enrolled unit still resolves `get()` to the universe defaults.
961
+ * Never throws. This is the sole experiment read path (there is no
962
+ * `getExperiment` a caller asks a universe, not an experiment).
963
+ */
964
+ assignUniverse(universeName, user) {
965
+ this.telemetry.emit("experiment", universeName);
966
+ const paramDefaults = paramDefaultsFromSchema(
967
+ this.expsBlob?.universes[universeName]?.param_schema
968
+ );
969
+ const notEnrolled = () => new AssignmentImpl(null, null, paramDefaults ?? {});
970
+ if (!this.expsBlob) return notEnrolled();
971
+ const candidates = Object.entries(this.expsBlob.experiments).filter(([, e]) => e.universe === universeName && e.status === "running").sort(
972
+ (a, b) => (a[1].poolOffsetBp ?? 0) - (b[1].poolOffsetBp ?? 0) || a[0].localeCompare(b[0])
973
+ );
974
+ for (const [name, exp] of candidates) {
975
+ const c = this.evalExperiment(name, exp, user);
976
+ if (c.state === "group") {
977
+ this.postExposure(user, name, c.group);
978
+ return new AssignmentImpl(name, c.group, c.params ?? {});
854
979
  }
855
980
  }
856
- return notIn;
981
+ return notEnrolled();
982
+ }
983
+ /**
984
+ * The universe-first experiment read entry point:
985
+ * `engine.universe("checkout").assign(user)`. Returns a reusable handle bound
986
+ * to one universe; `assign(user)` picks the ≤1 experiment the unit is pooled
987
+ * into and auto-logs a single exposure. See {@link assignUniverse}.
988
+ */
989
+ universe(name) {
990
+ return { assign: (user) => this.assignUniverse(name, user) };
857
991
  }
858
992
  /** Drop caller-marked private attributes from an outbound props bag. */
859
993
  stripPrivate(props) {
@@ -885,26 +1019,26 @@ var Engine = class _Engine {
885
1019
  }).catch((err) => logger.warn("[shipeasy] track failed:", String(err)));
886
1020
  }
887
1021
  /**
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.
1022
+ * POST a single exposure for an enrolled `(user, experiment, group)`. Deduped
1023
+ * per process (bounded set) so repeated `assign()` calls in one server don't
1024
+ * spam `/collect`. Fire-and-forget; no-op in test mode. This is how
1025
+ * `assignUniverse` auto-logs the browser's auto-exposure parity for SSR.
894
1026
  */
895
- logExposure(user, name) {
1027
+ postExposure(user, experiment, group) {
896
1028
  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;
1029
+ const uid = user.user_id ?? user.anonymous_id;
1030
+ const dedupKey = `${uid ?? ""}:${experiment}:${group}`;
1031
+ if (this.exposureSeen.has(dedupKey)) return;
1032
+ if (this.exposureSeen.size > 5e3) this.exposureSeen.clear();
1033
+ this.exposureSeen.add(dedupKey);
900
1034
  const body = JSON.stringify({
901
1035
  events: [
902
1036
  {
903
1037
  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 } : {},
1038
+ experiment,
1039
+ group,
1040
+ ...user.user_id !== void 0 ? { user_id: user.user_id } : {},
1041
+ ...user.anonymous_id !== void 0 ? { anonymous_id: user.anonymous_id } : {},
908
1042
  ts: Date.now()
909
1043
  }
910
1044
  ]
@@ -913,7 +1047,7 @@ var Engine = class _Engine {
913
1047
  method: "POST",
914
1048
  headers: { "X-SDK-Key": this.apiKey, "Content-Type": "text/plain" },
915
1049
  body
916
- }).catch((err) => logger.warn("[shipeasy] logExposure failed:", String(err)));
1050
+ }).catch((err) => logger.warn("[shipeasy] exposure send failed:", String(err)));
917
1051
  }
918
1052
  /**
919
1053
  * Report a structured error into the errors primitive. Fire-and-forget —
@@ -959,8 +1093,17 @@ var Engine = class _Engine {
959
1093
  this.telemetry.emit("config", name);
960
1094
  configs[name] = entry.value;
961
1095
  }
962
- for (const [name] of Object.entries(this.expsBlob?.experiments ?? {})) {
963
- experiments[name] = this.getExperiment(name, user, {});
1096
+ const universes = {};
1097
+ for (const [name, exp] of Object.entries(this.expsBlob?.experiments ?? {})) {
1098
+ this.telemetry.emit("experiment", exp.universe);
1099
+ const uniName = exp.universe;
1100
+ if (!(uniName in universes)) {
1101
+ universes[uniName] = {
1102
+ defaults: paramDefaultsFromSchema(this.expsBlob?.universes[uniName]?.param_schema) ?? {}
1103
+ };
1104
+ }
1105
+ const c = this.evalExperiment(name, exp, user);
1106
+ experiments[name] = c.state === "group" ? { inExperiment: true, group: c.group, params: c.params ?? {}, universe: uniName } : { inExperiment: false, group: "control", params: {}, universe: uniName };
964
1107
  }
965
1108
  for (const [name, ks] of Object.entries(this.flagsBlob?.killswitches ?? {})) {
966
1109
  this.telemetry.emit("ks", name);
@@ -977,10 +1120,11 @@ var Engine = class _Engine {
977
1120
  Object.assign(flags2, ov.gates);
978
1121
  Object.assign(configs, ov.configs);
979
1122
  for (const [name, group] of Object.entries(ov.experiments)) {
980
- experiments[name] = { inExperiment: true, group, params: {} };
1123
+ const uni = this.expsBlob?.experiments[name]?.universe;
1124
+ experiments[name] = { inExperiment: true, group, params: {}, ...uni ? { universe: uni } : {} };
981
1125
  }
982
1126
  }
983
- return { flags: flags2, configs, experiments, killswitches };
1127
+ return { flags: flags2, configs, experiments, killswitches, universes };
984
1128
  }
985
1129
  getKillswitch(name, switchKey) {
986
1130
  this.telemetry.emit("ks", name);
@@ -1128,6 +1272,7 @@ async function shipeasy(opts) {
1128
1272
  flags.configure({
1129
1273
  apiKey: serverKey,
1130
1274
  disableTelemetry: opts.disableTelemetry,
1275
+ disableInternalErrorReporting: opts.disableInternalErrorReporting,
1131
1276
  privateAttributes: opts.privateAttributes,
1132
1277
  logLevel: opts.logLevel
1133
1278
  });
@@ -1272,13 +1417,22 @@ var flags = {
1272
1417
  () => _server?.getConfig(name, decodeOrOpts)
1273
1418
  );
1274
1419
  },
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
- );
1420
+ /**
1421
+ * Assign `user` within a universe: `flags.universe("checkout").assign(user)`.
1422
+ * A universe is a mutual-exclusion pool, so the unit lands in ≤1 experiment;
1423
+ * the returned {@link Assignment} exposes `.group` / `.get(field, fallback)`
1424
+ * and auto-logs one exposure when enrolled. Before configure() (or on any
1425
+ * error) it returns a safe not-enrolled handle. This replaces the removed
1426
+ * `getExperiment` — read experiments by universe, never by name.
1427
+ */
1428
+ universe(name) {
1429
+ return {
1430
+ assign: (user) => safeRun(
1431
+ "flags.universe.assign",
1432
+ new AssignmentImpl(null, null, {}),
1433
+ () => _server?.assignUniverse(name, user) ?? new AssignmentImpl(null, null, {})
1434
+ )
1435
+ };
1282
1436
  },
1283
1437
  /**
1284
1438
  * Read a killswitch. Without `switchKey`, returns true when the whole
@@ -1291,11 +1445,6 @@ var flags = {
1291
1445
  track(userId, eventName, props) {
1292
1446
  safeRun("flags.track", void 0, () => _server?.track(userId, eventName, props));
1293
1447
  },
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
1448
  /**
1300
1449
  * Evaluate all flags / configs / experiments for a user against the locally
1301
1450
  * cached blob. Pass the request URL to apply ?se_ks_* / ?se_cf_* / ?se_exp_*
@@ -1412,13 +1561,20 @@ var Client = class {
1412
1561
  () => this.engine.getConfig(name, decodeOrOpts)
1413
1562
  );
1414
1563
  }
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
- );
1564
+ /**
1565
+ * Assign the bound user within a universe: `client.universe("checkout").assign()`.
1566
+ * The user is already bound at construction, so `assign()` takes no arg. Returns
1567
+ * an {@link Assignment} (`.group` / `.get(field, fallback)`) and auto-logs one
1568
+ * exposure when enrolled. Replaces the removed `getExperiment`.
1569
+ */
1570
+ universe(name) {
1571
+ return {
1572
+ assign: () => safeRun(
1573
+ "Client.universe.assign",
1574
+ new AssignmentImpl(null, null, {}),
1575
+ () => this.engine.assignUniverse(name, this.attributes)
1576
+ )
1577
+ };
1422
1578
  }
1423
1579
  /** Read a killswitch (not user-bound; mirrors {@link Engine.getKillswitch}). */
1424
1580
  getKillswitch(name, switchKey) {
@@ -1438,15 +1594,6 @@ var Client = class {
1438
1594
  this.engine.track(String(id), eventName, props);
1439
1595
  });
1440
1596
  }
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
1597
  };
1451
1598
  function dispatchSee(problem, consequence, extras, kind) {
1452
1599
  if (!_server) {
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: shipeasy-typescript
3
- description: Use Shipeasy (feature flags, configs, kill switches, A/B experiments, i18n) from TypeScript / JavaScript. Covers configure() + Client(user), getFlag/getConfig/getKillswitch/getExperiment, track, testing, OpenFeature, and the see() error reporter — server (@shipeasy/sdk/server) and browser (@shipeasy/sdk/client).
3
+ description: Use Shipeasy (feature flags, configs, kill switches, A/B experiments, i18n) from TypeScript / JavaScript. Covers configure() + Client(user), getFlag/getConfig/getKillswitch/universe().assign(), track, testing, OpenFeature, and the see() error reporter — server (@shipeasy/sdk/server) and browser (@shipeasy/sdk/client).
4
4
  ---
5
5
 
6
6
  # Shipeasy TypeScript SDK
@@ -52,17 +52,21 @@ snippets <https://shipeasy-ai.github.io/sdk-ts/snippets/release/flags.md> ·
52
52
 
53
53
  ```ts
54
54
  const flags = new Client(currentUser); // construct once per callsite
55
- const { inExperiment, group, params } = flags.getExperiment("hero_cta", {
56
- primary_label: "Sign up", // default params (control / not-enrolled)
57
- });
58
- render(params.primary_label);
59
55
 
60
- flags.logExposure("hero_cta"); // record the exposure where you present it
56
+ // Read experiments by UNIVERSE (a mutual-exclusion pool — the unit lands in ≤1
57
+ // experiment). assign() auto-logs one deduped exposure when enrolled.
58
+ const exp = flags.universe("hero_cta").assign();
59
+ render(exp.get("primary_label", "Sign up")); // variant ?? universe default ?? fallback
60
+
61
+ if (exp.enrolled) {
62
+ // exp.group is the variant, exp.name is the experiment
63
+ }
61
64
  flags.track("purchase", { value: 42 }); // record a conversion for the bound user
62
65
  ```
63
66
 
64
- `ExperimentResult = { inExperiment: boolean; group: string; params: P }`. Full
65
- reference: <https://shipeasy-ai.github.io/sdk-ts/pages/experiments.md> · track snippet
67
+ `Assignment = { name: string | null; group: string | null; enrolled: boolean;
68
+ get(field, fallback) }`. Full reference:
69
+ <https://shipeasy-ai.github.io/sdk-ts/pages/experiments.md> · track snippet
66
70
  <https://shipeasy-ai.github.io/sdk-ts/snippets/metrics/track.md>
67
71
 
68
72
  ## i18n
@@ -138,8 +142,8 @@ Full reference: <https://shipeasy-ai.github.io/sdk-ts/pages/openfeature.md>
138
142
  ## Advanced
139
143
 
140
144
  `privateAttributes`, `bucketBy` (custom bucketing unit), `stickyBucketing`
141
- (browser: on by default, `__se_sticky` cookie), manual exposure
142
- (`getExperiment(..., { logExposure: false })` + `flags.logExposure(name)`),
145
+ (browser: on by default, `__se_sticky` cookie), exposure control (browser:
146
+ `universe(name).assign({ logExposure: false })` + `disableAutoExposure`),
143
147
  `onChange(cb)` (requires `configure({ poll: true })`) / browser `subscribe()`.
144
148
  Devtools overlay: `Shift+Alt+S` or `?se=1`. Full reference:
145
149
  <https://shipeasy-ai.github.io/sdk-ts/pages/advanced.md>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@shipeasy/sdk",
3
- "version": "6.3.1",
3
+ "version": "7.0.0",
4
4
  "description": "Shipeasy SDK — feature gates, runtime configs, experiments, and metrics for the Shipeasy hosted service.",
5
5
  "license": "SEE LICENSE IN LICENSE",
6
6
  "homepage": "https://shipeasy.ai",
@@ -14,6 +14,7 @@
14
14
  "main": "./dist/server/index.js",
15
15
  "module": "./dist/server/index.mjs",
16
16
  "browser": "./dist/client/index.js",
17
+ "react-native": "./dist/client/index.js",
17
18
  "typesVersions": {
18
19
  "*": {
19
20
  "client": [
@@ -29,6 +30,7 @@
29
30
  },
30
31
  "exports": {
31
32
  ".": {
33
+ "react-native": "./dist/client/index.js",
32
34
  "node": "./dist/server/index.js",
33
35
  "browser": "./dist/client/index.js",
34
36
  "default": "./dist/server/index.js"