@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.
@@ -31,9 +31,12 @@ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: tru
31
31
  var server_exports = {};
32
32
  __export(server_exports, {
33
33
  ANON_ID_COOKIE: () => ANON_ID_COOKIE,
34
+ FLAG_REASONS: () => FLAG_REASONS,
34
35
  FlagsClient: () => FlagsClient,
36
+ _murmur3ForTests: () => _murmur3ForTests,
35
37
  _resetShipeasyServerForTests: () => _resetShipeasyServerForTests,
36
38
  configureShipeasyServer: () => configureShipeasyServer,
39
+ createInMemoryStickyStore: () => createInMemoryStickyStore,
37
40
  fetchLabelsForSSR: () => fetchLabelsForSSR,
38
41
  flags: () => flags,
39
42
  getBootstrapHtml: () => getBootstrapHtml,
@@ -362,6 +365,9 @@ var SeeLimiter = class {
362
365
  var version = "4.0.0";
363
366
  var C1 = 3432918353;
364
367
  var C2 = 461845907;
368
+ function _murmur3ForTests(key) {
369
+ return murmur3(key);
370
+ }
365
371
  function murmur3(key) {
366
372
  const bytes = new TextEncoder().encode(key);
367
373
  const len = bytes.length;
@@ -402,6 +408,25 @@ function murmur3(key) {
402
408
  h1 ^= h1 >>> 16;
403
409
  return h1 >>> 0;
404
410
  }
411
+ var FLAG_REASONS = [
412
+ "CLIENT_NOT_READY",
413
+ "FLAG_NOT_FOUND",
414
+ "OFF",
415
+ "OVERRIDE",
416
+ "RULE_MATCH",
417
+ "DEFAULT"
418
+ ];
419
+ function createInMemoryStickyStore(seed) {
420
+ const m = new Map(Object.entries(seed ?? {}));
421
+ return {
422
+ get: (unit) => m.get(unit),
423
+ set: (unit, exp, entry) => {
424
+ const cur = m.get(unit) ?? {};
425
+ cur[exp] = entry;
426
+ m.set(unit, cur);
427
+ }
428
+ };
429
+ }
405
430
  function isEnabled(v) {
406
431
  return v === 1 || v === true;
407
432
  }
@@ -454,6 +479,14 @@ var ANON_ID_RX = /^[A-Za-z0-9_-]{1,64}$/;
454
479
  function mintAnonId() {
455
480
  return typeof crypto !== "undefined" && typeof crypto.randomUUID === "function" ? crypto.randomUUID() : `anon_${Math.random().toString(36).slice(2)}`;
456
481
  }
482
+ function pickIdentifier(user, bucketBy) {
483
+ if (bucketBy) {
484
+ const v = user[bucketBy];
485
+ if (typeof v === "string" && v.length > 0) return v;
486
+ if (typeof v === "number") return String(v);
487
+ }
488
+ return user.user_id ?? user.anonymous_id;
489
+ }
457
490
  function evalGateInternal(gate, user) {
458
491
  if (isEnabled(gate.killswitch)) return false;
459
492
  if (!isEnabled(gate.enabled)) return false;
@@ -509,10 +542,12 @@ function parseOverrides(rawUrl) {
509
542
  }
510
543
  return { gates, configs, experiments };
511
544
  }
512
- var FlagsClient = class {
545
+ var FlagsClient = class _FlagsClient {
513
546
  apiKey;
514
547
  baseUrl;
515
548
  env;
549
+ privateAttributes;
550
+ stickyStore;
516
551
  telemetry;
517
552
  seeLimiter = new SeeLimiter();
518
553
  flagsBlob = null;
@@ -522,47 +557,157 @@ var FlagsClient = class {
522
557
  pollInterval = 30;
523
558
  timer = null;
524
559
  initialized = false;
560
+ // Test mode: built by `FlagsClient.forTesting()`. When set, init()/initOnce()
561
+ // never fetch, track() is a no-op, and telemetry is off — the client is a
562
+ // fully self-contained, network-free seam for unit tests.
563
+ testMode;
564
+ // Programmatic overrides (Statsig-style). Set on any client via
565
+ // overrideFlag/overrideConfig/overrideExperiment; they win over the fetched
566
+ // blob in getFlag/getConfig/getExperiment. Cleared by clearOverrides().
567
+ flagOverrides = /* @__PURE__ */ new Map();
568
+ configOverrides = /* @__PURE__ */ new Map();
569
+ experimentOverrides = /* @__PURE__ */ new Map();
570
+ // Change listeners fired after a background poll returns NEW data (200, not
571
+ // 304). Never fired in testMode/offline (no polling happens there).
572
+ changeListeners = /* @__PURE__ */ new Set();
525
573
  constructor(opts) {
526
574
  this.apiKey = opts.apiKey;
527
575
  this.baseUrl = (opts.baseUrl ?? "https://cdn.shipeasy.ai").replace(/\/$/, "");
528
576
  this.env = opts.env ?? "prod";
577
+ this.privateAttributes = opts.privateAttributes ?? [];
578
+ this.stickyStore = opts.stickyStore;
579
+ this.testMode = opts.testMode === true;
529
580
  this.telemetry = new Telemetry({
530
581
  endpoint: opts.telemetryUrl ?? DEFAULT_TELEMETRY_URL,
531
582
  sdkKey: this.apiKey,
532
583
  side: "server",
533
584
  env: this.env,
534
- disabled: opts.disableTelemetry
585
+ // Test mode never talks to the network — telemetry off regardless of opt.
586
+ disabled: this.testMode || opts.disableTelemetry
535
587
  });
536
- if (opts.initialBlob) {
537
- this.flagsBlob = opts.initialBlob;
588
+ if (opts.initialBlob || this.testMode) {
589
+ this.flagsBlob = opts.initialBlob ?? { version: "test", plan: "free", gates: {}, configs: {}, killswitches: {} };
590
+ this.expsBlob = this.expsBlob ?? { version: "test", universes: {}, experiments: {} };
538
591
  this.initialized = true;
539
592
  }
540
593
  }
594
+ /**
595
+ * Build a no-network, immediately-usable client for tests (Statsig-style).
596
+ * init()/initOnce() are no-ops (never fetch), track() is a no-op, telemetry
597
+ * is disabled, and the client is already "initialized" — seed every entity
598
+ * with overrideFlag/overrideConfig/overrideExperiment. No SDK key required.
599
+ *
600
+ * ```ts
601
+ * const client = FlagsClient.forTesting();
602
+ * client.overrideFlag("new_checkout", true);
603
+ * client.getFlag("new_checkout", { user_id: "u1" }); // true
604
+ * ```
605
+ */
606
+ static forTesting(opts) {
607
+ return new _FlagsClient({ apiKey: "", ...opts, testMode: true });
608
+ }
609
+ /**
610
+ * Build a fully OFFLINE client from a pre-captured snapshot — no network ever.
611
+ * Reuses the test-mode plumbing (init()/initOnce()/track() are no-ops,
612
+ * telemetry off) but, unlike forTesting(), seeds the REAL flags + experiments
613
+ * blobs so evaluations run the canonical eval against the snapshot. Local
614
+ * overrides still apply on top.
615
+ *
616
+ * Snapshot shape mirrors the wire bodies:
617
+ * `{ flags: <GET /sdk/flags body>, experiments: <GET /sdk/experiments body> }`.
618
+ */
619
+ static fromSnapshot(snapshot) {
620
+ const client = new _FlagsClient({ apiKey: "", testMode: true });
621
+ client.flagsBlob = snapshot.flags;
622
+ client.expsBlob = snapshot.experiments;
623
+ client.initialized = true;
624
+ return client;
625
+ }
626
+ /**
627
+ * Build a fully OFFLINE client from a snapshot JSON file on disk (Node only —
628
+ * not available in the browser entrypoint). The file must contain
629
+ * `{ "flags": <GET /sdk/flags body>, "experiments": <GET /sdk/experiments body> }`.
630
+ * See {@link FlagsClient.fromSnapshot}.
631
+ */
632
+ static fromFile(path) {
633
+ const fs = require("fs");
634
+ const raw = fs.readFileSync(path, "utf8");
635
+ const snapshot = JSON.parse(raw);
636
+ return _FlagsClient.fromSnapshot(snapshot);
637
+ }
541
638
  async init() {
639
+ if (this.testMode) {
640
+ this.initialized = true;
641
+ return;
642
+ }
542
643
  await this.fetchAll();
543
644
  this.initialized = true;
544
645
  this.startPoll();
545
646
  }
546
647
  async initOnce() {
547
- if (this.initialized) return;
648
+ if (this.testMode || this.initialized) return;
548
649
  await this.fetchAll();
549
650
  this.initialized = true;
550
651
  }
652
+ // ---- Local overrides (Statsig-style) ----
653
+ /** Force `getFlag(name, …)` to return `value`, ignoring the fetched gate. */
654
+ overrideFlag(name, value) {
655
+ this.flagOverrides.set(name, value);
656
+ }
657
+ /** Force `getConfig(name)` to return `value`, ignoring the fetched config. */
658
+ overrideConfig(name, value) {
659
+ this.configOverrides.set(name, value);
660
+ }
661
+ /**
662
+ * Force `getExperiment(name, …)` to return `{ inExperiment: true, group, params }`,
663
+ * ignoring allocation, holdouts, and targeting.
664
+ */
665
+ overrideExperiment(name, group, params) {
666
+ this.experimentOverrides.set(name, { group, params });
667
+ }
668
+ /** Remove every programmatic override set via the override* methods. */
669
+ clearOverrides() {
670
+ this.flagOverrides.clear();
671
+ this.configOverrides.clear();
672
+ this.experimentOverrides.clear();
673
+ }
551
674
  destroy() {
552
675
  if (this.timer !== null) {
553
676
  clearInterval(this.timer);
554
677
  this.timer = null;
555
678
  }
556
679
  }
680
+ /**
681
+ * Subscribe to data-change notifications. The listener fires after a
682
+ * background poll fetch returns NEW data (200, not 304) — i.e. after the
683
+ * cached blob is updated. Never fires in testMode/offline (no polling).
684
+ * Returns an unsubscribe function.
685
+ */
686
+ onChange(listener) {
687
+ this.changeListeners.add(listener);
688
+ return () => {
689
+ this.changeListeners.delete(listener);
690
+ };
691
+ }
692
+ notifyChange() {
693
+ for (const l of this.changeListeners) {
694
+ try {
695
+ l();
696
+ } catch (err) {
697
+ console.warn("[shipeasy] onChange listener threw:", String(err));
698
+ }
699
+ }
700
+ }
557
701
  startPoll() {
558
702
  this.timer = setInterval(() => {
559
- this.fetchAll().catch(
703
+ this.fetchAll(true).catch(
560
704
  (err) => console.warn("[shipeasy] background poll failed:", String(err))
561
705
  );
562
706
  }, this.pollInterval * 1e3);
563
707
  }
564
- async fetchAll() {
565
- const [interval] = await Promise.all([this.fetchFlags(), this.fetchExps()]);
708
+ async fetchAll(fromPoll = false) {
709
+ const [flagsRes, expsChanged] = await Promise.all([this.fetchFlags(), this.fetchExps()]);
710
+ const { interval, changed: flagsChanged } = flagsRes;
566
711
  if (interval !== null && interval !== this.pollInterval) {
567
712
  this.pollInterval = interval;
568
713
  if (this.timer !== null) {
@@ -570,41 +715,72 @@ var FlagsClient = class {
570
715
  this.startPoll();
571
716
  }
572
717
  }
718
+ if (fromPoll && (flagsChanged || expsChanged)) this.notifyChange();
573
719
  }
574
720
  async fetchFlags() {
575
721
  const headers = { "X-SDK-Key": this.apiKey };
576
722
  if (this.flagsEtag) headers["If-None-Match"] = this.flagsEtag;
577
723
  const res = await globalThis.fetch(`${this.baseUrl}/sdk/flags?env=${this.env}`, { headers });
578
724
  const interval = Number(res.headers.get("X-Poll-Interval") ?? "30") || 30;
579
- if (res.status === 304) return interval;
725
+ if (res.status === 304) return { interval, changed: false };
580
726
  if (!res.ok) throw new Error(`/sdk/flags returned ${res.status}`);
581
727
  const etag = res.headers.get("ETag");
582
728
  if (etag) this.flagsEtag = etag;
583
729
  this.flagsBlob = await res.json();
584
- return interval;
730
+ return { interval, changed: true };
585
731
  }
586
732
  async fetchExps() {
587
733
  const headers = { "X-SDK-Key": this.apiKey };
588
734
  if (this.expsEtag) headers["If-None-Match"] = this.expsEtag;
589
735
  const res = await globalThis.fetch(`${this.baseUrl}/sdk/experiments`, { headers });
590
- if (res.status === 304) return;
736
+ if (res.status === 304) return false;
591
737
  if (!res.ok) throw new Error(`/sdk/experiments returned ${res.status}`);
592
738
  const etag = res.headers.get("ETag");
593
739
  if (etag) this.expsEtag = etag;
594
740
  this.expsBlob = await res.json();
741
+ return true;
595
742
  }
596
- getFlag(name, user) {
743
+ /**
744
+ * Evaluate a gate and report WHY (LaunchDarkly variationDetail parity). The
745
+ * reason is computed entirely at this boundary — the canonical eval
746
+ * (evalGateInternal) is untouched. A local override short-circuits BEFORE
747
+ * telemetry, exactly like getFlag's override path; otherwise exactly one
748
+ * "gate" beacon is emitted.
749
+ */
750
+ getFlagDetail(name, user) {
751
+ const ov = this.flagOverrides.get(name);
752
+ if (ov !== void 0) return { value: ov, reason: "OVERRIDE" };
597
753
  this.telemetry.emit("gate", name);
598
- const gate = this.flagsBlob?.gates[name];
599
- if (!gate) return false;
600
- return evalGateInternal(gate, user);
754
+ if (!this.flagsBlob) return { value: false, reason: "CLIENT_NOT_READY" };
755
+ const gate = this.flagsBlob.gates[name];
756
+ if (!gate) return { value: false, reason: "FLAG_NOT_FOUND" };
757
+ if (isEnabled(gate.killswitch) || !isEnabled(gate.enabled)) {
758
+ return { value: false, reason: "OFF" };
759
+ }
760
+ const value = evalGateInternal(gate, user);
761
+ return { value, reason: value ? "RULE_MATCH" : "DEFAULT" };
601
762
  }
602
- getConfig(name, decode) {
763
+ /**
764
+ * Read a feature gate. Returns `defaultValue` ONLY when the gate cannot be
765
+ * evaluated (client not initialized or flag not found) — never for a gate that
766
+ * legitimately evaluates to false. Plain `getFlag(name, user)` keeps returning
767
+ * false for a missing flag.
768
+ */
769
+ getFlag(name, user, defaultValue = false) {
770
+ const d = this.getFlagDetail(name, user);
771
+ if (d.reason === "CLIENT_NOT_READY" || d.reason === "FLAG_NOT_FOUND") return defaultValue;
772
+ return d.value;
773
+ }
774
+ getConfig(name, decodeOrOpts) {
603
775
  this.telemetry.emit("config", name);
604
- const entry = this.flagsBlob?.configs[name];
605
- if (!entry) return void 0;
606
- if (!decode) return entry.value;
607
- return decode(entry.value);
776
+ const opts = typeof decodeOrOpts === "function" ? { decode: decodeOrOpts } : decodeOrOpts ?? {};
777
+ const has = this.configOverrides.has(name);
778
+ const raw = has ? this.configOverrides.get(name) : this.flagsBlob?.configs[name]?.value;
779
+ if (raw === void 0 && !has) {
780
+ return "defaultValue" in opts ? opts.defaultValue : void 0;
781
+ }
782
+ if (!opts.decode) return raw;
783
+ return opts.decode(raw);
608
784
  }
609
785
  getExperiment(name, user, defaultParams, decode) {
610
786
  this.telemetry.emit("experiment", name);
@@ -613,6 +789,16 @@ var FlagsClient = class {
613
789
  group: "control",
614
790
  params: defaultParams
615
791
  };
792
+ const ov = this.experimentOverrides.get(name);
793
+ if (ov) {
794
+ if (!decode) return { inExperiment: true, group: ov.group, params: ov.params };
795
+ try {
796
+ return { inExperiment: true, group: ov.group, params: decode(ov.params) };
797
+ } catch (err) {
798
+ console.warn(`[shipeasy] getExperiment('${name}') override decode failed:`, String(err));
799
+ return notIn;
800
+ }
801
+ }
616
802
  if (!this.flagsBlob || !this.expsBlob) return notIn;
617
803
  const exp = this.expsBlob.experiments[name];
618
804
  if (!exp || exp.status !== "running") return notIn;
@@ -620,7 +806,7 @@ var FlagsClient = class {
620
806
  const gate = this.flagsBlob.gates[exp.targetingGate];
621
807
  if (!gate || !evalGateInternal(gate, user)) return notIn;
622
808
  }
623
- const uid = user.user_id ?? user.anonymous_id;
809
+ const uid = pickIdentifier(user, exp.bucketBy);
624
810
  if (!uid) return notIn;
625
811
  const universe = this.expsBlob.universes[exp.universe];
626
812
  const holdoutRange = universe?.holdout_range ?? null;
@@ -629,6 +815,23 @@ var FlagsClient = class {
629
815
  const [lo, hi] = holdoutRange;
630
816
  if (seg >= lo && seg <= hi) return notIn;
631
817
  }
818
+ const asResult = (g) => {
819
+ if (!decode) return { inExperiment: true, group: g.name, params: g.params };
820
+ try {
821
+ return { inExperiment: true, group: g.name, params: decode(g.params) };
822
+ } catch (err) {
823
+ console.warn(`[shipeasy] getExperiment('${name}') decode failed:`, String(err));
824
+ return notIn;
825
+ }
826
+ };
827
+ const salt8 = exp.salt.slice(0, 8);
828
+ if (this.stickyStore) {
829
+ const entry = this.stickyStore.get(uid)?.[name];
830
+ if (entry && entry.s === salt8) {
831
+ const g = exp.groups.find((x) => x.name === entry.g);
832
+ if (g) return asResult(g);
833
+ }
834
+ }
632
835
  if (murmur3(`${exp.salt}:alloc:${uid}`) % 1e4 >= exp.allocationPct) return notIn;
633
836
  const groupHash = murmur3(`${exp.salt}:group:${uid}`) % 1e4;
634
837
  let cumulative = 0;
@@ -636,20 +839,24 @@ var FlagsClient = class {
636
839
  const g = exp.groups[i];
637
840
  cumulative += g.weight;
638
841
  if (groupHash < cumulative || i === exp.groups.length - 1) {
639
- if (!decode) {
640
- return { inExperiment: true, group: g.name, params: g.params };
641
- }
642
- try {
643
- return { inExperiment: true, group: g.name, params: decode(g.params) };
644
- } catch (err) {
645
- console.warn(`[shipeasy] getExperiment('${name}') decode failed:`, String(err));
646
- return notIn;
647
- }
842
+ this.stickyStore?.set(uid, name, { g: g.name, s: salt8 });
843
+ return asResult(g);
648
844
  }
649
845
  }
650
846
  return notIn;
651
847
  }
848
+ /** Drop caller-marked private attributes from an outbound props bag. */
849
+ stripPrivate(props) {
850
+ if (!props || this.privateAttributes.length === 0) return props;
851
+ const out = {};
852
+ for (const [k, v] of Object.entries(props)) {
853
+ if (!this.privateAttributes.includes(k)) out[k] = v;
854
+ }
855
+ return out;
856
+ }
652
857
  track(userId, eventName, props) {
858
+ if (this.testMode) return;
859
+ const safeProps = this.stripPrivate(props);
653
860
  const body = JSON.stringify({
654
861
  events: [
655
862
  {
@@ -657,7 +864,7 @@ var FlagsClient = class {
657
864
  event_name: eventName,
658
865
  user_id: userId,
659
866
  ts: Date.now(),
660
- ...props !== void 0 ? { properties: props } : {}
867
+ ...safeProps !== void 0 ? { properties: safeProps } : {}
661
868
  }
662
869
  ]
663
870
  });
@@ -667,6 +874,37 @@ var FlagsClient = class {
667
874
  body
668
875
  }).catch((err) => console.warn("[shipeasy] track failed:", String(err)));
669
876
  }
877
+ /**
878
+ * Emit an exposure event for an experiment at the server-side decision point
879
+ * (parity with the browser's auto-exposure). The server is stateless and
880
+ * never auto-logs, so call this when you actually present the treatment.
881
+ * Re-evaluates the experiment for `user` (a bare `user_id` string is wrapped
882
+ * as `{ user_id }`); if the user is enrolled, POSTs a single exposure to
883
+ * `/collect`. No-op in test mode or when the user isn't enrolled.
884
+ */
885
+ logExposure(user, name) {
886
+ if (this.testMode) return;
887
+ const u = typeof user === "string" ? { user_id: user } : user;
888
+ const result = this.getExperiment(name, u, {});
889
+ if (!result.inExperiment) return;
890
+ const body = JSON.stringify({
891
+ events: [
892
+ {
893
+ type: "exposure",
894
+ experiment: name,
895
+ group: result.group,
896
+ ...u.user_id !== void 0 ? { user_id: u.user_id } : {},
897
+ ...u.anonymous_id !== void 0 ? { anonymous_id: u.anonymous_id } : {},
898
+ ts: Date.now()
899
+ }
900
+ ]
901
+ });
902
+ globalThis.fetch(`${this.baseUrl}/collect`, {
903
+ method: "POST",
904
+ headers: { "X-SDK-Key": this.apiKey, "Content-Type": "text/plain" },
905
+ body
906
+ }).catch((err) => console.warn("[shipeasy] logExposure failed:", String(err)));
907
+ }
670
908
  /**
671
909
  * Report a structured error into the errors primitive. Fire-and-forget —
672
910
  * never blocks or throws into the request path. Spam-guarded by a 30s
@@ -873,7 +1111,11 @@ async function shipeasy(opts) {
873
1111
  );
874
1112
  }
875
1113
  const profile = opts.i18nDefaultProfile ?? "en:prod";
876
- flags.configure({ apiKey: serverKey, disableTelemetry: opts.disableTelemetry });
1114
+ flags.configure({
1115
+ apiKey: serverKey,
1116
+ disableTelemetry: opts.disableTelemetry,
1117
+ privateAttributes: opts.privateAttributes
1118
+ });
877
1119
  let resolvedUrlOverrides = opts.urlOverrides;
878
1120
  if (!resolvedUrlOverrides) {
879
1121
  try {
@@ -984,11 +1226,15 @@ var flags = {
984
1226
  destroy() {
985
1227
  _server?.destroy();
986
1228
  },
987
- get(name, user) {
988
- return _server?.getFlag(name, user) ?? false;
1229
+ get(name, user, defaultValue = false) {
1230
+ return _server?.getFlag(name, user, defaultValue) ?? defaultValue;
989
1231
  },
990
- getConfig(name, decode) {
991
- return _server?.getConfig(name, decode);
1232
+ /** Evaluate a gate and report why (value + reason). See {@link FlagDetail}. */
1233
+ getDetail(name, user) {
1234
+ return _server?.getFlagDetail(name, user) ?? { value: false, reason: "CLIENT_NOT_READY" };
1235
+ },
1236
+ getConfig(name, decodeOrOpts) {
1237
+ return _server?.getConfig(name, decodeOrOpts);
992
1238
  },
993
1239
  getExperiment(name, user, defaultParams, decode) {
994
1240
  return _server?.getExperiment(name, user, defaultParams, decode) ?? {
@@ -1008,6 +1254,11 @@ var flags = {
1008
1254
  track(userId, eventName, props) {
1009
1255
  _server?.track(userId, eventName, props);
1010
1256
  },
1257
+ /** Emit an exposure for an enrolled experiment at the decision point. See
1258
+ * {@link FlagsClient.logExposure}. No-op before configure(). */
1259
+ logExposure(user, name) {
1260
+ _server?.logExposure(user, name);
1261
+ },
1011
1262
  /**
1012
1263
  * Evaluate all flags / configs / experiments for a user against the locally
1013
1264
  * cached blob. Pass the request URL to apply ?se_ks_* / ?se_cf_* / ?se_exp_*
@@ -1039,9 +1290,12 @@ var see = Object.assign(
1039
1290
  // Annotate the CommonJS export names for ESM import in node:
1040
1291
  0 && (module.exports = {
1041
1292
  ANON_ID_COOKIE,
1293
+ FLAG_REASONS,
1042
1294
  FlagsClient,
1295
+ _murmur3ForTests,
1043
1296
  _resetShipeasyServerForTests,
1044
1297
  configureShipeasyServer,
1298
+ createInMemoryStickyStore,
1045
1299
  fetchLabelsForSSR,
1046
1300
  flags,
1047
1301
  getBootstrapHtml,