@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.
@@ -310,6 +310,14 @@ var SeeLimiter = class {
310
310
 
311
311
  // src/client/index.ts
312
312
  var version = "4.0.0";
313
+ var FLAG_REASONS = [
314
+ "CLIENT_NOT_READY",
315
+ "FLAG_NOT_FOUND",
316
+ "OFF",
317
+ "OVERRIDE",
318
+ "RULE_MATCH",
319
+ "DEFAULT"
320
+ ];
313
321
  var FLUSH_INTERVAL_MS = 5e3;
314
322
  var MAX_BUFFER = 100;
315
323
  var ANON_ID_KEY = "__se_anon_id";
@@ -674,6 +682,40 @@ function writeAnonCookie(id) {
674
682
  } catch {
675
683
  }
676
684
  }
685
+ var STICKY_COOKIE = "__se_sticky";
686
+ var STICKY_MAX_BYTES = 3800;
687
+ function readStickyCookie() {
688
+ try {
689
+ const m = ("; " + document.cookie).match(/; __se_sticky=([^;]+)/);
690
+ if (!m) return {};
691
+ const parsed = JSON.parse(decodeURIComponent(m[1]));
692
+ if (!parsed || typeof parsed !== "object") return {};
693
+ const out = {};
694
+ for (const [k, v] of Object.entries(parsed)) {
695
+ if (v && typeof v === "object" && typeof v.g === "string") {
696
+ out[k] = { g: v.g, s: String(v.s ?? "") };
697
+ }
698
+ }
699
+ return out;
700
+ } catch {
701
+ return {};
702
+ }
703
+ }
704
+ function writeStickyCookie(map) {
705
+ try {
706
+ let json = JSON.stringify(map);
707
+ if (json.length > STICKY_MAX_BYTES) {
708
+ const entries = Object.entries(map);
709
+ while (entries.length > 0 && JSON.stringify(Object.fromEntries(entries)).length > STICKY_MAX_BYTES) {
710
+ entries.shift();
711
+ }
712
+ json = JSON.stringify(Object.fromEntries(entries));
713
+ }
714
+ const secure = location.protocol === "https:" ? ";secure" : "";
715
+ document.cookie = `${STICKY_COOKIE}=${encodeURIComponent(json)};path=/;max-age=31536000;samesite=lax${secure}`;
716
+ } catch {
717
+ }
718
+ }
677
719
  function getOrCreateAnonId() {
678
720
  let id = readAnonCookie();
679
721
  if (!id && typeof window !== "undefined") {
@@ -821,12 +863,15 @@ function readExperimentOverridesFromUrl() {
821
863
  }
822
864
  return out;
823
865
  }
824
- var FlagsClientBrowser = class {
866
+ var FlagsClientBrowser = class _FlagsClientBrowser {
825
867
  sdkKey;
826
868
  baseUrl;
827
869
  autoGuardrails;
828
870
  autoGuardrailGroups;
829
871
  autoCollectAlways;
872
+ disableAutoExposure;
873
+ privateAttributes;
874
+ stickyBucketing;
830
875
  env;
831
876
  evalResult = null;
832
877
  anonId;
@@ -840,6 +885,16 @@ var FlagsClientBrowser = class {
840
885
  // Monotonic counter so a later identify() always wins even if its /sdk/evaluate
841
886
  // response races and lands before an earlier in-flight call's response.
842
887
  identifySeq = 0;
888
+ // Test mode: built by `FlagsClientBrowser.forTesting()`. When set, identify()
889
+ // never fetches, track() is a no-op, telemetry is off, and the client is
890
+ // already ready with an empty eval result.
891
+ testMode;
892
+ // Programmatic overrides (Statsig-style). Set on any client via
893
+ // overrideFlag/overrideConfig/overrideExperiment; they win over BOTH the URL
894
+ // overrides and the fetched eval result. Cleared by clearOverrides().
895
+ flagOverrides = /* @__PURE__ */ new Map();
896
+ configOverrides = /* @__PURE__ */ new Map();
897
+ experimentOverrides = /* @__PURE__ */ new Map();
843
898
  onOverrideChange = () => {
844
899
  this.installBridge();
845
900
  this.notify();
@@ -848,8 +903,12 @@ var FlagsClientBrowser = class {
848
903
  this.sdkKey = opts.sdkKey;
849
904
  this.baseUrl = (opts.baseUrl ?? "https://edge.shipeasy.dev").replace(/\/$/, "");
850
905
  this.env = opts.env ?? "prod";
906
+ this.testMode = opts.testMode === true;
851
907
  this.autoGuardrails = opts.autoGuardrails !== false;
852
908
  this.autoCollectAlways = opts.autoCollectAlways === true;
909
+ this.disableAutoExposure = opts.disableAutoExposure === true;
910
+ this.privateAttributes = opts.privateAttributes ?? [];
911
+ this.stickyBucketing = opts.stickyBucketing !== false;
853
912
  const g = opts.autoGuardrailGroups ?? {};
854
913
  this.autoGuardrailGroups = {
855
914
  vitals: g.vitals ?? this.autoGuardrails,
@@ -863,11 +922,42 @@ var FlagsClientBrowser = class {
863
922
  sdkKey: this.sdkKey,
864
923
  side: "client",
865
924
  env: this.env,
866
- disabled: opts.disableTelemetry
925
+ // Test mode never talks to the network — telemetry off regardless of opt.
926
+ disabled: this.testMode || opts.disableTelemetry
927
+ });
928
+ if (this.testMode) {
929
+ this.evalResult = { flags: {}, configs: {}, experiments: {}, killswitches: {} };
930
+ } else {
931
+ void this.buffer.flushPendingAlias();
932
+ }
933
+ }
934
+ /**
935
+ * Build a no-network, immediately-usable browser client for tests
936
+ * (Statsig-style). identify() is a no-op (never calls /sdk/evaluate), track()
937
+ * is a no-op, telemetry is disabled, and the client is already ready — seed
938
+ * every entity with overrideFlag/overrideConfig/overrideExperiment. No SDK
939
+ * key required.
940
+ *
941
+ * ```ts
942
+ * const client = FlagsClientBrowser.forTesting();
943
+ * client.overrideFlag("new_checkout", true);
944
+ * client.getFlag("new_checkout"); // true
945
+ * ```
946
+ */
947
+ static forTesting(opts) {
948
+ return new _FlagsClientBrowser({
949
+ sdkKey: "",
950
+ autoGuardrails: false,
951
+ ...opts,
952
+ testMode: true
867
953
  });
868
- void this.buffer.flushPendingAlias();
869
954
  }
870
955
  async identify(user) {
956
+ if (this.testMode) {
957
+ if (user.user_id !== void 0) this.userId = user.user_id;
958
+ this.notify();
959
+ return;
960
+ }
871
961
  const seq = ++this.identifySeq;
872
962
  const prevUserId = this.userId;
873
963
  if (user.user_id !== void 0) this.userId = user.user_id;
@@ -884,13 +974,22 @@ var FlagsClientBrowser = class {
884
974
  headers: { "X-SDK-Key": this.sdkKey, "Content-Type": "application/json" },
885
975
  body: JSON.stringify({
886
976
  user: userPayload,
887
- experiment_overrides: readExperimentOverridesFromUrl()
977
+ experiment_overrides: readExperimentOverridesFromUrl(),
978
+ // Private attributes still reach the edge for evaluation (unavoidable —
979
+ // the edge evaluates), but the worker never persists them. See doc 20 §5.
980
+ ...this.privateAttributes.length > 0 ? { private_attributes: [...this.privateAttributes] } : {},
981
+ // Sticky state round-trip: send the current cookie map; the worker
982
+ // applies the sticky short-circuit and returns any new assignments.
983
+ ...this.stickyBucketing ? { sticky: readStickyCookie() } : {}
888
984
  })
889
985
  });
890
986
  if (!res.ok) throw new Error(`/sdk/evaluate returned ${res.status}`);
891
987
  const data = await res.json();
892
988
  if (seq !== this.identifySeq) return;
893
989
  this.evalResult = data;
990
+ if (this.stickyBucketing && data.sticky && Object.keys(data.sticky).length > 0) {
991
+ writeStickyCookie({ ...readStickyCookie(), ...data.sticky });
992
+ }
894
993
  const anyGroupOn = this.autoGuardrailGroups.vitals || this.autoGuardrailGroups.errors || this.autoGuardrailGroups.engagement;
895
994
  if (anyGroupOn && !this.guardrailsInstalled) {
896
995
  this.guardrailsInstalled = true;
@@ -942,34 +1041,95 @@ var FlagsClientBrowser = class {
942
1041
  initFromBootstrap(data) {
943
1042
  this.evalResult = data;
944
1043
  }
945
- getFlag(name) {
1044
+ // ---- Local overrides (Statsig-style) ----
1045
+ //
1046
+ // Precedence (highest first): programmatic override (these methods) > URL
1047
+ // override (?se_gate_/?se_config_/?se_exp_) > fetched eval result. A
1048
+ // programmatic override is an explicit in-code decision, so it wins over the
1049
+ // ad-hoc URL/devtools overrides as well as the server's evaluation.
1050
+ /** Force `getFlag(name)` to return `value`, ignoring URL overrides + eval. */
1051
+ overrideFlag(name, value) {
1052
+ this.flagOverrides.set(name, value);
1053
+ }
1054
+ /** Force `getConfig(name)` to return `value`, ignoring URL overrides + eval. */
1055
+ overrideConfig(name, value) {
1056
+ this.configOverrides.set(name, value);
1057
+ }
1058
+ /**
1059
+ * Force `getExperiment(name, …)` to return `{ inExperiment: true, group, params }`,
1060
+ * ignoring URL overrides, the eval result, and exposure logging.
1061
+ */
1062
+ overrideExperiment(name, group, params) {
1063
+ this.experimentOverrides.set(name, { group, params });
1064
+ }
1065
+ /** Remove every programmatic override set via the override* methods. */
1066
+ clearOverrides() {
1067
+ this.flagOverrides.clear();
1068
+ this.configOverrides.clear();
1069
+ this.experimentOverrides.clear();
1070
+ }
1071
+ /**
1072
+ * Evaluate a gate and report WHY (LaunchDarkly variationDetail parity). A
1073
+ * local override OR a ?se_gate_/?se_ks_ URL override short-circuits BEFORE
1074
+ * telemetry; otherwise exactly one "gate" beacon is emitted. The server
1075
+ * pre-evaluates a gate's enabled/killed state into a plain boolean, so OFF
1076
+ * folds into DEFAULT here (the browser can't tell "disabled" from "rule
1077
+ * denied").
1078
+ */
1079
+ getFlagDetail(name) {
1080
+ const pov = this.flagOverrides.get(name);
1081
+ if (pov !== void 0) return { value: pov, reason: "OVERRIDE" };
1082
+ const urlOv = readGateOverride(name);
1083
+ if (urlOv !== null) return { value: urlOv, reason: "OVERRIDE" };
946
1084
  this.telemetry.emit("gate", name);
947
- if (this.evalResult === null) return false;
948
- const ov = readGateOverride(name);
949
- if (ov !== null) return ov;
950
- return this.evalResult.flags[name] ?? false;
1085
+ if (this.evalResult === null) return { value: false, reason: "CLIENT_NOT_READY" };
1086
+ if (!(name in this.evalResult.flags)) return { value: false, reason: "FLAG_NOT_FOUND" };
1087
+ const value = this.evalResult.flags[name] ?? false;
1088
+ return { value, reason: value ? "RULE_MATCH" : "DEFAULT" };
951
1089
  }
952
- getConfig(name, decode) {
1090
+ /**
1091
+ * Read a feature gate. Returns `defaultValue` ONLY when the gate cannot be
1092
+ * evaluated (not ready or flag not found) — never for a gate that legitimately
1093
+ * evaluates to false. Plain `getFlag(name)` keeps returning false for a
1094
+ * missing flag.
1095
+ */
1096
+ getFlag(name, defaultValue = false) {
1097
+ const d = this.getFlagDetail(name);
1098
+ if (d.reason === "CLIENT_NOT_READY" || d.reason === "FLAG_NOT_FOUND") return defaultValue;
1099
+ return d.value;
1100
+ }
1101
+ getConfig(name, decodeOrOpts) {
953
1102
  this.telemetry.emit("config", name);
954
- if (this.evalResult === null) return void 0;
955
- const ov = readConfigOverride(name);
956
- const raw = ov !== void 0 ? ov : this.evalResult.configs?.[name];
957
- if (raw === void 0) return void 0;
958
- if (!decode) return raw;
1103
+ const opts = typeof decodeOrOpts === "function" ? { decode: decodeOrOpts } : decodeOrOpts ?? {};
1104
+ const fallback = "defaultValue" in opts ? opts.defaultValue : void 0;
1105
+ const hasProgrammatic = this.configOverrides.has(name);
1106
+ if (!hasProgrammatic && this.evalResult === null) return fallback;
1107
+ const urlOv = hasProgrammatic ? void 0 : readConfigOverride(name);
1108
+ const raw = hasProgrammatic ? this.configOverrides.get(name) : urlOv !== void 0 ? urlOv : this.evalResult?.configs?.[name];
1109
+ if (raw === void 0) return fallback;
1110
+ if (!opts.decode) return raw;
959
1111
  try {
960
- return decode(raw);
1112
+ return opts.decode(raw);
961
1113
  } catch (err) {
962
1114
  console.warn(`[shipeasy] getConfig('${name}') decode failed:`, String(err));
963
1115
  return void 0;
964
1116
  }
965
1117
  }
966
- getExperiment(name, defaultParams, decode, variants) {
1118
+ getExperiment(name, defaultParams, decodeOrOpts, variantsArg) {
1119
+ const opts = typeof decodeOrOpts === "function" ? { decode: decodeOrOpts, variants: variantsArg } : decodeOrOpts ?? {};
1120
+ const { decode, variants } = opts;
967
1121
  this.telemetry.emit("experiment", name);
968
1122
  const notIn = {
969
1123
  inExperiment: false,
970
1124
  group: "control",
971
1125
  params: defaultParams
972
1126
  };
1127
+ const pov = this.experimentOverrides.get(name);
1128
+ if (pov) {
1129
+ const variantParams = variants?.[pov.group];
1130
+ const params = { ...defaultParams, ...pov.params, ...variantParams ?? {} };
1131
+ return { inExperiment: true, group: pov.group, params };
1132
+ }
973
1133
  const ov = readExpOverride(name);
974
1134
  if (ov !== null) {
975
1135
  const variantParams = variants?.[ov];
@@ -978,7 +1138,8 @@ var FlagsClientBrowser = class {
978
1138
  }
979
1139
  const entry = this.evalResult?.experiments[name];
980
1140
  if (!entry || !entry.inExperiment) return notIn;
981
- this.buffer.pushExposure(name, entry.group, this.userId, this.anonId);
1141
+ const shouldLog = opts.logExposure ?? !this.disableAutoExposure;
1142
+ if (shouldLog) this.buffer.pushExposure(name, entry.group, this.userId, this.anonId);
982
1143
  if (!decode) return { inExperiment: true, group: entry.group, params: entry.params };
983
1144
  try {
984
1145
  return { inExperiment: true, group: entry.group, params: decode(entry.params) };
@@ -987,6 +1148,18 @@ var FlagsClientBrowser = class {
987
1148
  return notIn;
988
1149
  }
989
1150
  }
1151
+ /**
1152
+ * Manually log an exposure for an enrolled experiment (Statsig's
1153
+ * `manuallyLogExposure`). Reads the cached eval result; if the visitor is in
1154
+ * the experiment, pushes the session-deduped exposure. Pair this with the
1155
+ * render of the treatment when reading with `{ logExposure: false }` (or
1156
+ * `disableAutoExposure: true`). No-op if the visitor isn't enrolled.
1157
+ */
1158
+ logExposure(name) {
1159
+ const entry = this.evalResult?.experiments[name];
1160
+ if (!entry || !entry.inExperiment) return;
1161
+ this.buffer.pushExposure(name, entry.group, this.userId, this.anonId);
1162
+ }
990
1163
  /**
991
1164
  * Subscribe to state changes — fires after identify() completes and on
992
1165
  * `se:override:change` events from the devtools overlay. Returns an
@@ -1021,7 +1194,17 @@ var FlagsClientBrowser = class {
1021
1194
  return bridge;
1022
1195
  }
1023
1196
  track(eventName, props) {
1024
- this.buffer.pushMetric(eventName, this.userId, this.anonId, props);
1197
+ if (this.testMode) return;
1198
+ this.buffer.pushMetric(eventName, this.userId, this.anonId, this.stripPrivate(props));
1199
+ }
1200
+ /** Drop caller-marked private attributes from an outbound props bag. */
1201
+ stripPrivate(props) {
1202
+ if (!props || this.privateAttributes.length === 0) return props;
1203
+ const out = {};
1204
+ for (const [k, v] of Object.entries(props)) {
1205
+ if (!this.privateAttributes.includes(k)) out[k] = v;
1206
+ }
1207
+ return out;
1025
1208
  }
1026
1209
  /**
1027
1210
  * Read a killswitch from the server's evaluated state. Without `switchKey`,
@@ -1167,7 +1350,10 @@ function shipeasy(opts) {
1167
1350
  autoGuardrails: blanket,
1168
1351
  autoGuardrailGroups: groups,
1169
1352
  autoCollectAlways: acObj?.always === true,
1170
- disableTelemetry: opts.disableTelemetry
1353
+ disableTelemetry: opts.disableTelemetry,
1354
+ disableAutoExposure: opts.disableAutoExposure,
1355
+ privateAttributes: opts.privateAttributes,
1356
+ stickyBucketing: opts.stickyBucketing
1171
1357
  });
1172
1358
  injectI18nLoader(opts.clientKey, baseUrl, opts.i18nProfile);
1173
1359
  flags.notifyMounted();
@@ -1239,12 +1425,19 @@ var flags = {
1239
1425
  * Everything else gates on _mountedAndReady to prevent hydration mismatches on
1240
1426
  * force-static pages where SSR has no flag data.
1241
1427
  */
1242
- get(name) {
1428
+ get(name, defaultValue = false) {
1243
1429
  const bs = getBootstrap();
1244
1430
  if (bs !== null && name in bs.flags) return bs.flags[name];
1245
- if (!_mountedAndReady) return false;
1246
- if (_client) return _client.getFlag(name);
1247
- return readGateOverride(name) ?? false;
1431
+ if (!_mountedAndReady) return defaultValue;
1432
+ if (_client) return _client.getFlag(name, defaultValue);
1433
+ return readGateOverride(name) ?? defaultValue;
1434
+ },
1435
+ /** Evaluate a gate and report why (value + reason). See {@link FlagDetail}. */
1436
+ getDetail(name) {
1437
+ if (_client) return _client.getFlagDetail(name);
1438
+ const ov = readGateOverride(name);
1439
+ if (ov !== null) return { value: ov, reason: "OVERRIDE" };
1440
+ return { value: false, reason: "CLIENT_NOT_READY" };
1248
1441
  },
1249
1442
  getConfig(name, decode) {
1250
1443
  const bs = getBootstrap();
@@ -1268,12 +1461,19 @@ var flags = {
1268
1461
  return void 0;
1269
1462
  }
1270
1463
  },
1271
- getExperiment(name, defaultParams, decode, variants) {
1272
- return _client?.getExperiment(name, defaultParams, decode, variants) ?? {
1464
+ getExperiment(name, defaultParams, decodeOrOpts, variants) {
1465
+ const fallback = {
1273
1466
  inExperiment: false,
1274
1467
  group: "control",
1275
1468
  params: defaultParams
1276
1469
  };
1470
+ if (!_client) return fallback;
1471
+ return typeof decodeOrOpts === "function" ? _client.getExperiment(name, defaultParams, decodeOrOpts, variants) : _client.getExperiment(name, defaultParams, decodeOrOpts ?? {});
1472
+ },
1473
+ /** Manually log an exposure for an enrolled experiment. See
1474
+ * {@link FlagsClientBrowser.logExposure}. No-op before configure(). */
1475
+ logExposure(name) {
1476
+ _client?.logExposure(name);
1277
1477
  },
1278
1478
  track(eventName, props) {
1279
1479
  _client?.track(eventName, props);
@@ -1575,6 +1775,7 @@ var i18n = {
1575
1775
  }
1576
1776
  };
1577
1777
  export {
1778
+ FLAG_REASONS,
1578
1779
  FlagsClientBrowser,
1579
1780
  LABEL_MARKER_END,
1580
1781
  LABEL_MARKER_RE,