@shipeasy/sdk 5.0.0 → 5.2.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.
@@ -20,6 +20,7 @@ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: tru
20
20
  // src/client/index.ts
21
21
  var client_exports = {};
22
22
  __export(client_exports, {
23
+ FLAG_REASONS: () => FLAG_REASONS,
23
24
  FlagsClientBrowser: () => FlagsClientBrowser,
24
25
  LABEL_MARKER_END: () => LABEL_MARKER_END,
25
26
  LABEL_MARKER_RE: () => LABEL_MARKER_RE,
@@ -358,6 +359,14 @@ var SeeLimiter = class {
358
359
 
359
360
  // src/client/index.ts
360
361
  var version = "4.0.0";
362
+ var FLAG_REASONS = [
363
+ "CLIENT_NOT_READY",
364
+ "FLAG_NOT_FOUND",
365
+ "OFF",
366
+ "OVERRIDE",
367
+ "RULE_MATCH",
368
+ "DEFAULT"
369
+ ];
361
370
  var FLUSH_INTERVAL_MS = 5e3;
362
371
  var MAX_BUFFER = 100;
363
372
  var ANON_ID_KEY = "__se_anon_id";
@@ -722,6 +731,40 @@ function writeAnonCookie(id) {
722
731
  } catch {
723
732
  }
724
733
  }
734
+ var STICKY_COOKIE = "__se_sticky";
735
+ var STICKY_MAX_BYTES = 3800;
736
+ function readStickyCookie() {
737
+ try {
738
+ const m = ("; " + document.cookie).match(/; __se_sticky=([^;]+)/);
739
+ if (!m) return {};
740
+ const parsed = JSON.parse(decodeURIComponent(m[1]));
741
+ if (!parsed || typeof parsed !== "object") return {};
742
+ const out = {};
743
+ for (const [k, v] of Object.entries(parsed)) {
744
+ if (v && typeof v === "object" && typeof v.g === "string") {
745
+ out[k] = { g: v.g, s: String(v.s ?? "") };
746
+ }
747
+ }
748
+ return out;
749
+ } catch {
750
+ return {};
751
+ }
752
+ }
753
+ function writeStickyCookie(map) {
754
+ try {
755
+ let json = JSON.stringify(map);
756
+ if (json.length > STICKY_MAX_BYTES) {
757
+ const entries = Object.entries(map);
758
+ while (entries.length > 0 && JSON.stringify(Object.fromEntries(entries)).length > STICKY_MAX_BYTES) {
759
+ entries.shift();
760
+ }
761
+ json = JSON.stringify(Object.fromEntries(entries));
762
+ }
763
+ const secure = location.protocol === "https:" ? ";secure" : "";
764
+ document.cookie = `${STICKY_COOKIE}=${encodeURIComponent(json)};path=/;max-age=31536000;samesite=lax${secure}`;
765
+ } catch {
766
+ }
767
+ }
725
768
  function getOrCreateAnonId() {
726
769
  let id = readAnonCookie();
727
770
  if (!id && typeof window !== "undefined") {
@@ -869,12 +912,15 @@ function readExperimentOverridesFromUrl() {
869
912
  }
870
913
  return out;
871
914
  }
872
- var FlagsClientBrowser = class {
915
+ var FlagsClientBrowser = class _FlagsClientBrowser {
873
916
  sdkKey;
874
917
  baseUrl;
875
918
  autoGuardrails;
876
919
  autoGuardrailGroups;
877
920
  autoCollectAlways;
921
+ disableAutoExposure;
922
+ privateAttributes;
923
+ stickyBucketing;
878
924
  env;
879
925
  evalResult = null;
880
926
  anonId;
@@ -888,6 +934,16 @@ var FlagsClientBrowser = class {
888
934
  // Monotonic counter so a later identify() always wins even if its /sdk/evaluate
889
935
  // response races and lands before an earlier in-flight call's response.
890
936
  identifySeq = 0;
937
+ // Test mode: built by `FlagsClientBrowser.forTesting()`. When set, identify()
938
+ // never fetches, track() is a no-op, telemetry is off, and the client is
939
+ // already ready with an empty eval result.
940
+ testMode;
941
+ // Programmatic overrides (Statsig-style). Set on any client via
942
+ // overrideFlag/overrideConfig/overrideExperiment; they win over BOTH the URL
943
+ // overrides and the fetched eval result. Cleared by clearOverrides().
944
+ flagOverrides = /* @__PURE__ */ new Map();
945
+ configOverrides = /* @__PURE__ */ new Map();
946
+ experimentOverrides = /* @__PURE__ */ new Map();
891
947
  onOverrideChange = () => {
892
948
  this.installBridge();
893
949
  this.notify();
@@ -896,8 +952,12 @@ var FlagsClientBrowser = class {
896
952
  this.sdkKey = opts.sdkKey;
897
953
  this.baseUrl = (opts.baseUrl ?? "https://edge.shipeasy.dev").replace(/\/$/, "");
898
954
  this.env = opts.env ?? "prod";
955
+ this.testMode = opts.testMode === true;
899
956
  this.autoGuardrails = opts.autoGuardrails !== false;
900
957
  this.autoCollectAlways = opts.autoCollectAlways === true;
958
+ this.disableAutoExposure = opts.disableAutoExposure === true;
959
+ this.privateAttributes = opts.privateAttributes ?? [];
960
+ this.stickyBucketing = opts.stickyBucketing !== false;
901
961
  const g = opts.autoGuardrailGroups ?? {};
902
962
  this.autoGuardrailGroups = {
903
963
  vitals: g.vitals ?? this.autoGuardrails,
@@ -911,11 +971,42 @@ var FlagsClientBrowser = class {
911
971
  sdkKey: this.sdkKey,
912
972
  side: "client",
913
973
  env: this.env,
914
- disabled: opts.disableTelemetry
974
+ // Test mode never talks to the network — telemetry off regardless of opt.
975
+ disabled: this.testMode || opts.disableTelemetry
976
+ });
977
+ if (this.testMode) {
978
+ this.evalResult = { flags: {}, configs: {}, experiments: {}, killswitches: {} };
979
+ } else {
980
+ void this.buffer.flushPendingAlias();
981
+ }
982
+ }
983
+ /**
984
+ * Build a no-network, immediately-usable browser client for tests
985
+ * (Statsig-style). identify() is a no-op (never calls /sdk/evaluate), track()
986
+ * is a no-op, telemetry is disabled, and the client is already ready — seed
987
+ * every entity with overrideFlag/overrideConfig/overrideExperiment. No SDK
988
+ * key required.
989
+ *
990
+ * ```ts
991
+ * const client = FlagsClientBrowser.forTesting();
992
+ * client.overrideFlag("new_checkout", true);
993
+ * client.getFlag("new_checkout"); // true
994
+ * ```
995
+ */
996
+ static forTesting(opts) {
997
+ return new _FlagsClientBrowser({
998
+ sdkKey: "",
999
+ autoGuardrails: false,
1000
+ ...opts,
1001
+ testMode: true
915
1002
  });
916
- void this.buffer.flushPendingAlias();
917
1003
  }
918
1004
  async identify(user) {
1005
+ if (this.testMode) {
1006
+ if (user.user_id !== void 0) this.userId = user.user_id;
1007
+ this.notify();
1008
+ return;
1009
+ }
919
1010
  const seq = ++this.identifySeq;
920
1011
  const prevUserId = this.userId;
921
1012
  if (user.user_id !== void 0) this.userId = user.user_id;
@@ -932,13 +1023,22 @@ var FlagsClientBrowser = class {
932
1023
  headers: { "X-SDK-Key": this.sdkKey, "Content-Type": "application/json" },
933
1024
  body: JSON.stringify({
934
1025
  user: userPayload,
935
- experiment_overrides: readExperimentOverridesFromUrl()
1026
+ experiment_overrides: readExperimentOverridesFromUrl(),
1027
+ // Private attributes still reach the edge for evaluation (unavoidable —
1028
+ // the edge evaluates), but the worker never persists them. See doc 20 §5.
1029
+ ...this.privateAttributes.length > 0 ? { private_attributes: [...this.privateAttributes] } : {},
1030
+ // Sticky state round-trip: send the current cookie map; the worker
1031
+ // applies the sticky short-circuit and returns any new assignments.
1032
+ ...this.stickyBucketing ? { sticky: readStickyCookie() } : {}
936
1033
  })
937
1034
  });
938
1035
  if (!res.ok) throw new Error(`/sdk/evaluate returned ${res.status}`);
939
1036
  const data = await res.json();
940
1037
  if (seq !== this.identifySeq) return;
941
1038
  this.evalResult = data;
1039
+ if (this.stickyBucketing && data.sticky && Object.keys(data.sticky).length > 0) {
1040
+ writeStickyCookie({ ...readStickyCookie(), ...data.sticky });
1041
+ }
942
1042
  const anyGroupOn = this.autoGuardrailGroups.vitals || this.autoGuardrailGroups.errors || this.autoGuardrailGroups.engagement;
943
1043
  if (anyGroupOn && !this.guardrailsInstalled) {
944
1044
  this.guardrailsInstalled = true;
@@ -990,34 +1090,95 @@ var FlagsClientBrowser = class {
990
1090
  initFromBootstrap(data) {
991
1091
  this.evalResult = data;
992
1092
  }
993
- getFlag(name) {
1093
+ // ---- Local overrides (Statsig-style) ----
1094
+ //
1095
+ // Precedence (highest first): programmatic override (these methods) > URL
1096
+ // override (?se_gate_/?se_config_/?se_exp_) > fetched eval result. A
1097
+ // programmatic override is an explicit in-code decision, so it wins over the
1098
+ // ad-hoc URL/devtools overrides as well as the server's evaluation.
1099
+ /** Force `getFlag(name)` to return `value`, ignoring URL overrides + eval. */
1100
+ overrideFlag(name, value) {
1101
+ this.flagOverrides.set(name, value);
1102
+ }
1103
+ /** Force `getConfig(name)` to return `value`, ignoring URL overrides + eval. */
1104
+ overrideConfig(name, value) {
1105
+ this.configOverrides.set(name, value);
1106
+ }
1107
+ /**
1108
+ * Force `getExperiment(name, …)` to return `{ inExperiment: true, group, params }`,
1109
+ * ignoring URL overrides, the eval result, and exposure logging.
1110
+ */
1111
+ overrideExperiment(name, group, params) {
1112
+ this.experimentOverrides.set(name, { group, params });
1113
+ }
1114
+ /** Remove every programmatic override set via the override* methods. */
1115
+ clearOverrides() {
1116
+ this.flagOverrides.clear();
1117
+ this.configOverrides.clear();
1118
+ this.experimentOverrides.clear();
1119
+ }
1120
+ /**
1121
+ * Evaluate a gate and report WHY (LaunchDarkly variationDetail parity). A
1122
+ * local override OR a ?se_gate_/?se_ks_ URL override short-circuits BEFORE
1123
+ * telemetry; otherwise exactly one "gate" beacon is emitted. The server
1124
+ * pre-evaluates a gate's enabled/killed state into a plain boolean, so OFF
1125
+ * folds into DEFAULT here (the browser can't tell "disabled" from "rule
1126
+ * denied").
1127
+ */
1128
+ getFlagDetail(name) {
1129
+ const pov = this.flagOverrides.get(name);
1130
+ if (pov !== void 0) return { value: pov, reason: "OVERRIDE" };
1131
+ const urlOv = readGateOverride(name);
1132
+ if (urlOv !== null) return { value: urlOv, reason: "OVERRIDE" };
994
1133
  this.telemetry.emit("gate", name);
995
- if (this.evalResult === null) return false;
996
- const ov = readGateOverride(name);
997
- if (ov !== null) return ov;
998
- return this.evalResult.flags[name] ?? false;
1134
+ if (this.evalResult === null) return { value: false, reason: "CLIENT_NOT_READY" };
1135
+ if (!(name in this.evalResult.flags)) return { value: false, reason: "FLAG_NOT_FOUND" };
1136
+ const value = this.evalResult.flags[name] ?? false;
1137
+ return { value, reason: value ? "RULE_MATCH" : "DEFAULT" };
999
1138
  }
1000
- getConfig(name, decode) {
1139
+ /**
1140
+ * Read a feature gate. Returns `defaultValue` ONLY when the gate cannot be
1141
+ * evaluated (not ready or flag not found) — never for a gate that legitimately
1142
+ * evaluates to false. Plain `getFlag(name)` keeps returning false for a
1143
+ * missing flag.
1144
+ */
1145
+ getFlag(name, defaultValue = false) {
1146
+ const d = this.getFlagDetail(name);
1147
+ if (d.reason === "CLIENT_NOT_READY" || d.reason === "FLAG_NOT_FOUND") return defaultValue;
1148
+ return d.value;
1149
+ }
1150
+ getConfig(name, decodeOrOpts) {
1001
1151
  this.telemetry.emit("config", name);
1002
- if (this.evalResult === null) return void 0;
1003
- const ov = readConfigOverride(name);
1004
- const raw = ov !== void 0 ? ov : this.evalResult.configs?.[name];
1005
- if (raw === void 0) return void 0;
1006
- if (!decode) return raw;
1152
+ const opts = typeof decodeOrOpts === "function" ? { decode: decodeOrOpts } : decodeOrOpts ?? {};
1153
+ const fallback = "defaultValue" in opts ? opts.defaultValue : void 0;
1154
+ const hasProgrammatic = this.configOverrides.has(name);
1155
+ if (!hasProgrammatic && this.evalResult === null) return fallback;
1156
+ const urlOv = hasProgrammatic ? void 0 : readConfigOverride(name);
1157
+ const raw = hasProgrammatic ? this.configOverrides.get(name) : urlOv !== void 0 ? urlOv : this.evalResult?.configs?.[name];
1158
+ if (raw === void 0) return fallback;
1159
+ if (!opts.decode) return raw;
1007
1160
  try {
1008
- return decode(raw);
1161
+ return opts.decode(raw);
1009
1162
  } catch (err) {
1010
1163
  console.warn(`[shipeasy] getConfig('${name}') decode failed:`, String(err));
1011
1164
  return void 0;
1012
1165
  }
1013
1166
  }
1014
- getExperiment(name, defaultParams, decode, variants) {
1167
+ getExperiment(name, defaultParams, decodeOrOpts, variantsArg) {
1168
+ const opts = typeof decodeOrOpts === "function" ? { decode: decodeOrOpts, variants: variantsArg } : decodeOrOpts ?? {};
1169
+ const { decode, variants } = opts;
1015
1170
  this.telemetry.emit("experiment", name);
1016
1171
  const notIn = {
1017
1172
  inExperiment: false,
1018
1173
  group: "control",
1019
1174
  params: defaultParams
1020
1175
  };
1176
+ const pov = this.experimentOverrides.get(name);
1177
+ if (pov) {
1178
+ const variantParams = variants?.[pov.group];
1179
+ const params = { ...defaultParams, ...pov.params, ...variantParams ?? {} };
1180
+ return { inExperiment: true, group: pov.group, params };
1181
+ }
1021
1182
  const ov = readExpOverride(name);
1022
1183
  if (ov !== null) {
1023
1184
  const variantParams = variants?.[ov];
@@ -1026,7 +1187,8 @@ var FlagsClientBrowser = class {
1026
1187
  }
1027
1188
  const entry = this.evalResult?.experiments[name];
1028
1189
  if (!entry || !entry.inExperiment) return notIn;
1029
- this.buffer.pushExposure(name, entry.group, this.userId, this.anonId);
1190
+ const shouldLog = opts.logExposure ?? !this.disableAutoExposure;
1191
+ if (shouldLog) this.buffer.pushExposure(name, entry.group, this.userId, this.anonId);
1030
1192
  if (!decode) return { inExperiment: true, group: entry.group, params: entry.params };
1031
1193
  try {
1032
1194
  return { inExperiment: true, group: entry.group, params: decode(entry.params) };
@@ -1035,6 +1197,18 @@ var FlagsClientBrowser = class {
1035
1197
  return notIn;
1036
1198
  }
1037
1199
  }
1200
+ /**
1201
+ * Manually log an exposure for an enrolled experiment (Statsig's
1202
+ * `manuallyLogExposure`). Reads the cached eval result; if the visitor is in
1203
+ * the experiment, pushes the session-deduped exposure. Pair this with the
1204
+ * render of the treatment when reading with `{ logExposure: false }` (or
1205
+ * `disableAutoExposure: true`). No-op if the visitor isn't enrolled.
1206
+ */
1207
+ logExposure(name) {
1208
+ const entry = this.evalResult?.experiments[name];
1209
+ if (!entry || !entry.inExperiment) return;
1210
+ this.buffer.pushExposure(name, entry.group, this.userId, this.anonId);
1211
+ }
1038
1212
  /**
1039
1213
  * Subscribe to state changes — fires after identify() completes and on
1040
1214
  * `se:override:change` events from the devtools overlay. Returns an
@@ -1069,7 +1243,17 @@ var FlagsClientBrowser = class {
1069
1243
  return bridge;
1070
1244
  }
1071
1245
  track(eventName, props) {
1072
- this.buffer.pushMetric(eventName, this.userId, this.anonId, props);
1246
+ if (this.testMode) return;
1247
+ this.buffer.pushMetric(eventName, this.userId, this.anonId, this.stripPrivate(props));
1248
+ }
1249
+ /** Drop caller-marked private attributes from an outbound props bag. */
1250
+ stripPrivate(props) {
1251
+ if (!props || this.privateAttributes.length === 0) return props;
1252
+ const out = {};
1253
+ for (const [k, v] of Object.entries(props)) {
1254
+ if (!this.privateAttributes.includes(k)) out[k] = v;
1255
+ }
1256
+ return out;
1073
1257
  }
1074
1258
  /**
1075
1259
  * Read a killswitch from the server's evaluated state. Without `switchKey`,
@@ -1215,7 +1399,10 @@ function shipeasy(opts) {
1215
1399
  autoGuardrails: blanket,
1216
1400
  autoGuardrailGroups: groups,
1217
1401
  autoCollectAlways: acObj?.always === true,
1218
- disableTelemetry: opts.disableTelemetry
1402
+ disableTelemetry: opts.disableTelemetry,
1403
+ disableAutoExposure: opts.disableAutoExposure,
1404
+ privateAttributes: opts.privateAttributes,
1405
+ stickyBucketing: opts.stickyBucketing
1219
1406
  });
1220
1407
  injectI18nLoader(opts.clientKey, baseUrl, opts.i18nProfile);
1221
1408
  flags.notifyMounted();
@@ -1287,12 +1474,19 @@ var flags = {
1287
1474
  * Everything else gates on _mountedAndReady to prevent hydration mismatches on
1288
1475
  * force-static pages where SSR has no flag data.
1289
1476
  */
1290
- get(name) {
1477
+ get(name, defaultValue = false) {
1291
1478
  const bs = getBootstrap();
1292
1479
  if (bs !== null && name in bs.flags) return bs.flags[name];
1293
- if (!_mountedAndReady) return false;
1294
- if (_client) return _client.getFlag(name);
1295
- return readGateOverride(name) ?? false;
1480
+ if (!_mountedAndReady) return defaultValue;
1481
+ if (_client) return _client.getFlag(name, defaultValue);
1482
+ return readGateOverride(name) ?? defaultValue;
1483
+ },
1484
+ /** Evaluate a gate and report why (value + reason). See {@link FlagDetail}. */
1485
+ getDetail(name) {
1486
+ if (_client) return _client.getFlagDetail(name);
1487
+ const ov = readGateOverride(name);
1488
+ if (ov !== null) return { value: ov, reason: "OVERRIDE" };
1489
+ return { value: false, reason: "CLIENT_NOT_READY" };
1296
1490
  },
1297
1491
  getConfig(name, decode) {
1298
1492
  const bs = getBootstrap();
@@ -1316,12 +1510,19 @@ var flags = {
1316
1510
  return void 0;
1317
1511
  }
1318
1512
  },
1319
- getExperiment(name, defaultParams, decode, variants) {
1320
- return _client?.getExperiment(name, defaultParams, decode, variants) ?? {
1513
+ getExperiment(name, defaultParams, decodeOrOpts, variants) {
1514
+ const fallback = {
1321
1515
  inExperiment: false,
1322
1516
  group: "control",
1323
1517
  params: defaultParams
1324
1518
  };
1519
+ if (!_client) return fallback;
1520
+ return typeof decodeOrOpts === "function" ? _client.getExperiment(name, defaultParams, decodeOrOpts, variants) : _client.getExperiment(name, defaultParams, decodeOrOpts ?? {});
1521
+ },
1522
+ /** Manually log an exposure for an enrolled experiment. See
1523
+ * {@link FlagsClientBrowser.logExposure}. No-op before configure(). */
1524
+ logExposure(name) {
1525
+ _client?.logExposure(name);
1325
1526
  },
1326
1527
  track(eventName, props) {
1327
1528
  _client?.track(eventName, props);
@@ -1624,6 +1825,7 @@ var i18n = {
1624
1825
  };
1625
1826
  // Annotate the CommonJS export names for ESM import in node:
1626
1827
  0 && (module.exports = {
1828
+ FLAG_REASONS,
1627
1829
  FlagsClientBrowser,
1628
1830
  LABEL_MARKER_END,
1629
1831
  LABEL_MARKER_RE,