@shipeasy/sdk 5.1.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.
@@ -170,7 +170,27 @@ interface Experiment {
170
170
  salt: string;
171
171
  groups: ExperimentGroup[];
172
172
  status: "draft" | "running" | "stopped" | "archived";
173
+ /** Attribute to bucket on (e.g. company_id); defaults to user_id/anonymous_id. */
174
+ bucketBy?: string | null;
173
175
  }
176
+ /** One persisted sticky assignment: group + 8-char salt prefix (reshuffle key). */
177
+ interface StickyEntry {
178
+ g: string;
179
+ s: string;
180
+ }
181
+ /**
182
+ * Pluggable sticky-bucketing store for the server (doc 20 §2). Keyed by the
183
+ * bucketing unit; the value is that unit's per-experiment assignments. Absent
184
+ * from {@link FlagsClientOptions} ⇒ today's deterministic behaviour. Use
185
+ * {@link createInMemoryStickyStore} or a cookie-bridge built from request
186
+ * cookies.
187
+ */
188
+ interface StickyBucketStore {
189
+ get(unit: string): Record<string, StickyEntry> | undefined;
190
+ set(unit: string, exp: string, entry: StickyEntry): void;
191
+ }
192
+ /** A process-local sticky store (Map-backed). Handy for tests + single-process servers. */
193
+ declare function createInMemoryStickyStore(seed?: Record<string, Record<string, StickyEntry>>): StickyBucketStore;
174
194
  interface Universe {
175
195
  holdout_range: [number, number] | null;
176
196
  }
@@ -223,6 +243,21 @@ interface FlagsClientOptions {
223
243
  disableTelemetry?: boolean;
224
244
  /** Override the telemetry beacon host. Defaults to {@link DEFAULT_TELEMETRY_URL}. */
225
245
  telemetryUrl?: string;
246
+ /**
247
+ * Attribute names usable for targeting but never persisted in analytics
248
+ * (LD/Statsig `privateAttributes`). The server evaluates locally so private
249
+ * attrs never leave for evaluation at all; the only egress is `/collect`, and
250
+ * the listed keys are stripped from every outbound `track()` payload.
251
+ */
252
+ privateAttributes?: string[];
253
+ /**
254
+ * Sticky-bucketing store (doc 20 §2). When provided, `getExperiment` locks a
255
+ * unit to its first-assigned variant — changing allocation % or weights won't
256
+ * re-bucket enrolled units (changing the experiment salt is the reshuffle
257
+ * lever). Absent ⇒ deterministic (fully backward compatible). Built-ins:
258
+ * {@link createInMemoryStickyStore}, or a cookie-bridge over `__se_sticky`.
259
+ */
260
+ stickyStore?: StickyBucketStore;
226
261
  /**
227
262
  * Test mode — no network at all. init()/initOnce() are no-ops (never fetch),
228
263
  * track() is a no-op, telemetry is forced off, and the client starts
@@ -235,6 +270,8 @@ declare class FlagsClient {
235
270
  private readonly apiKey;
236
271
  private readonly baseUrl;
237
272
  private readonly env;
273
+ private readonly privateAttributes;
274
+ private readonly stickyStore;
238
275
  private readonly telemetry;
239
276
  private readonly seeLimiter;
240
277
  private flagsBlob;
@@ -328,7 +365,18 @@ declare class FlagsClient {
328
365
  getConfig<T = unknown>(name: string, decode?: (raw: unknown) => T): T | undefined;
329
366
  getConfig<T = unknown>(name: string, opts: GetConfigOptions<T>): T;
330
367
  getExperiment<P extends Record<string, unknown>>(name: string, user: User, defaultParams: P, decode?: (raw: unknown) => P): ExperimentResult<P>;
368
+ /** Drop caller-marked private attributes from an outbound props bag. */
369
+ private stripPrivate;
331
370
  track(userId: string, eventName: string, props?: Record<string, unknown>): void;
371
+ /**
372
+ * Emit an exposure event for an experiment at the server-side decision point
373
+ * (parity with the browser's auto-exposure). The server is stateless and
374
+ * never auto-logs, so call this when you actually present the treatment.
375
+ * Re-evaluates the experiment for `user` (a bare `user_id` string is wrapped
376
+ * as `{ user_id }`); if the user is enrolled, POSTs a single exposure to
377
+ * `/collect`. No-op in test mode or when the user isn't enrolled.
378
+ */
379
+ logExposure(user: string | User, name: string): void;
332
380
  /**
333
381
  * Report a structured error into the errors primitive. Fire-and-forget —
334
382
  * never blocks or throws into the request path. Spam-guarded by a 30s
@@ -418,6 +466,12 @@ interface ShipeasyServerConfig {
418
466
  * that evaluate many flags per request. See {@link FlagsClientOptions.disableTelemetry}.
419
467
  */
420
468
  disableTelemetry?: boolean;
469
+ /**
470
+ * Attribute names usable for targeting but never persisted in analytics
471
+ * (LD/Statsig `privateAttributes`). Stripped from every outbound `track()`
472
+ * payload. See {@link FlagsClientOptions.privateAttributes}.
473
+ */
474
+ privateAttributes?: string[];
421
475
  }
422
476
  interface ShipeasyServerHandle {
423
477
  flags: Record<string, boolean>;
@@ -485,6 +539,9 @@ declare const flags: {
485
539
  */
486
540
  ks(name: string, switchKey?: string): boolean;
487
541
  track(userId: string, eventName: string, props?: Record<string, unknown>): void;
542
+ /** Emit an exposure for an enrolled experiment at the decision point. See
543
+ * {@link FlagsClient.logExposure}. No-op before configure(). */
544
+ logExposure(user: string | User, name: string): void;
488
545
  /**
489
546
  * Evaluate all flags / configs / experiments for a user against the locally
490
547
  * cached blob. Pass the request URL to apply ?se_ks_* / ?se_cf_* / ?se_exp_*
@@ -543,4 +600,4 @@ interface SeeApi {
543
600
  */
544
601
  declare const see: SeeApi;
545
602
 
546
- export { ANON_ID_COOKIE, type BootstrapHtmlOptions, type BootstrapPayload, type Consequence, type ExperimentResult, type ExpsBlob, FLAG_REASONS, type FetchLabelsOptions, type FlagDetail, type FlagReason, type FlagsBlob, FlagsClient, type FlagsClientEnv, type FlagsClientOptions, type GetConfigOptions, type I18nForRequest, type LabelFile, type SeeApi, type SeeChain, type SeeControlFlowChain, type SeeErrorEvent, type SeeExtras, type SeeKind, type SeeViolationChain, type ShipeasyServerConfig, type ShipeasyServerHandle, type User, type Violation, _murmur3ForTests, _resetShipeasyServerForTests, configureShipeasyServer, fetchLabelsForSSR, flags, getBootstrapHtml, getShipeasyServerClient, i18n, isExpected, see, seeContext, shipeasy, version };
603
+ export { ANON_ID_COOKIE, type BootstrapHtmlOptions, type BootstrapPayload, type Consequence, type ExperimentResult, type ExpsBlob, FLAG_REASONS, type FetchLabelsOptions, type FlagDetail, type FlagReason, type FlagsBlob, FlagsClient, type FlagsClientEnv, type FlagsClientOptions, type GetConfigOptions, type I18nForRequest, type LabelFile, type SeeApi, type SeeChain, type SeeControlFlowChain, type SeeErrorEvent, type SeeExtras, type SeeKind, type SeeViolationChain, type ShipeasyServerConfig, type ShipeasyServerHandle, type StickyBucketStore, type StickyEntry, type User, type Violation, _murmur3ForTests, _resetShipeasyServerForTests, configureShipeasyServer, createInMemoryStickyStore, fetchLabelsForSSR, flags, getBootstrapHtml, getShipeasyServerClient, i18n, isExpected, see, seeContext, shipeasy, version };
@@ -36,6 +36,7 @@ __export(server_exports, {
36
36
  _murmur3ForTests: () => _murmur3ForTests,
37
37
  _resetShipeasyServerForTests: () => _resetShipeasyServerForTests,
38
38
  configureShipeasyServer: () => configureShipeasyServer,
39
+ createInMemoryStickyStore: () => createInMemoryStickyStore,
39
40
  fetchLabelsForSSR: () => fetchLabelsForSSR,
40
41
  flags: () => flags,
41
42
  getBootstrapHtml: () => getBootstrapHtml,
@@ -415,6 +416,17 @@ var FLAG_REASONS = [
415
416
  "RULE_MATCH",
416
417
  "DEFAULT"
417
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
+ }
418
430
  function isEnabled(v) {
419
431
  return v === 1 || v === true;
420
432
  }
@@ -467,6 +479,14 @@ var ANON_ID_RX = /^[A-Za-z0-9_-]{1,64}$/;
467
479
  function mintAnonId() {
468
480
  return typeof crypto !== "undefined" && typeof crypto.randomUUID === "function" ? crypto.randomUUID() : `anon_${Math.random().toString(36).slice(2)}`;
469
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
+ }
470
490
  function evalGateInternal(gate, user) {
471
491
  if (isEnabled(gate.killswitch)) return false;
472
492
  if (!isEnabled(gate.enabled)) return false;
@@ -526,6 +546,8 @@ var FlagsClient = class _FlagsClient {
526
546
  apiKey;
527
547
  baseUrl;
528
548
  env;
549
+ privateAttributes;
550
+ stickyStore;
529
551
  telemetry;
530
552
  seeLimiter = new SeeLimiter();
531
553
  flagsBlob = null;
@@ -552,6 +574,8 @@ var FlagsClient = class _FlagsClient {
552
574
  this.apiKey = opts.apiKey;
553
575
  this.baseUrl = (opts.baseUrl ?? "https://cdn.shipeasy.ai").replace(/\/$/, "");
554
576
  this.env = opts.env ?? "prod";
577
+ this.privateAttributes = opts.privateAttributes ?? [];
578
+ this.stickyStore = opts.stickyStore;
555
579
  this.testMode = opts.testMode === true;
556
580
  this.telemetry = new Telemetry({
557
581
  endpoint: opts.telemetryUrl ?? DEFAULT_TELEMETRY_URL,
@@ -782,7 +806,7 @@ var FlagsClient = class _FlagsClient {
782
806
  const gate = this.flagsBlob.gates[exp.targetingGate];
783
807
  if (!gate || !evalGateInternal(gate, user)) return notIn;
784
808
  }
785
- const uid = user.user_id ?? user.anonymous_id;
809
+ const uid = pickIdentifier(user, exp.bucketBy);
786
810
  if (!uid) return notIn;
787
811
  const universe = this.expsBlob.universes[exp.universe];
788
812
  const holdoutRange = universe?.holdout_range ?? null;
@@ -791,6 +815,23 @@ var FlagsClient = class _FlagsClient {
791
815
  const [lo, hi] = holdoutRange;
792
816
  if (seg >= lo && seg <= hi) return notIn;
793
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
+ }
794
835
  if (murmur3(`${exp.salt}:alloc:${uid}`) % 1e4 >= exp.allocationPct) return notIn;
795
836
  const groupHash = murmur3(`${exp.salt}:group:${uid}`) % 1e4;
796
837
  let cumulative = 0;
@@ -798,21 +839,24 @@ var FlagsClient = class _FlagsClient {
798
839
  const g = exp.groups[i];
799
840
  cumulative += g.weight;
800
841
  if (groupHash < cumulative || i === exp.groups.length - 1) {
801
- if (!decode) {
802
- return { inExperiment: true, group: g.name, params: g.params };
803
- }
804
- try {
805
- return { inExperiment: true, group: g.name, params: decode(g.params) };
806
- } catch (err) {
807
- console.warn(`[shipeasy] getExperiment('${name}') decode failed:`, String(err));
808
- return notIn;
809
- }
842
+ this.stickyStore?.set(uid, name, { g: g.name, s: salt8 });
843
+ return asResult(g);
810
844
  }
811
845
  }
812
846
  return notIn;
813
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
+ }
814
857
  track(userId, eventName, props) {
815
858
  if (this.testMode) return;
859
+ const safeProps = this.stripPrivate(props);
816
860
  const body = JSON.stringify({
817
861
  events: [
818
862
  {
@@ -820,7 +864,7 @@ var FlagsClient = class _FlagsClient {
820
864
  event_name: eventName,
821
865
  user_id: userId,
822
866
  ts: Date.now(),
823
- ...props !== void 0 ? { properties: props } : {}
867
+ ...safeProps !== void 0 ? { properties: safeProps } : {}
824
868
  }
825
869
  ]
826
870
  });
@@ -830,6 +874,37 @@ var FlagsClient = class _FlagsClient {
830
874
  body
831
875
  }).catch((err) => console.warn("[shipeasy] track failed:", String(err)));
832
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
+ }
833
908
  /**
834
909
  * Report a structured error into the errors primitive. Fire-and-forget —
835
910
  * never blocks or throws into the request path. Spam-guarded by a 30s
@@ -1036,7 +1111,11 @@ async function shipeasy(opts) {
1036
1111
  );
1037
1112
  }
1038
1113
  const profile = opts.i18nDefaultProfile ?? "en:prod";
1039
- flags.configure({ apiKey: serverKey, disableTelemetry: opts.disableTelemetry });
1114
+ flags.configure({
1115
+ apiKey: serverKey,
1116
+ disableTelemetry: opts.disableTelemetry,
1117
+ privateAttributes: opts.privateAttributes
1118
+ });
1040
1119
  let resolvedUrlOverrides = opts.urlOverrides;
1041
1120
  if (!resolvedUrlOverrides) {
1042
1121
  try {
@@ -1175,6 +1254,11 @@ var flags = {
1175
1254
  track(userId, eventName, props) {
1176
1255
  _server?.track(userId, eventName, props);
1177
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
+ },
1178
1262
  /**
1179
1263
  * Evaluate all flags / configs / experiments for a user against the locally
1180
1264
  * cached blob. Pass the request URL to apply ?se_ks_* / ?se_cf_* / ?se_exp_*
@@ -1211,6 +1295,7 @@ var see = Object.assign(
1211
1295
  _murmur3ForTests,
1212
1296
  _resetShipeasyServerForTests,
1213
1297
  configureShipeasyServer,
1298
+ createInMemoryStickyStore,
1214
1299
  fetchLabelsForSSR,
1215
1300
  flags,
1216
1301
  getBootstrapHtml,
@@ -373,6 +373,17 @@ var FLAG_REASONS = [
373
373
  "RULE_MATCH",
374
374
  "DEFAULT"
375
375
  ];
376
+ function createInMemoryStickyStore(seed) {
377
+ const m = new Map(Object.entries(seed ?? {}));
378
+ return {
379
+ get: (unit) => m.get(unit),
380
+ set: (unit, exp, entry) => {
381
+ const cur = m.get(unit) ?? {};
382
+ cur[exp] = entry;
383
+ m.set(unit, cur);
384
+ }
385
+ };
386
+ }
376
387
  function isEnabled(v) {
377
388
  return v === 1 || v === true;
378
389
  }
@@ -425,6 +436,14 @@ var ANON_ID_RX = /^[A-Za-z0-9_-]{1,64}$/;
425
436
  function mintAnonId() {
426
437
  return typeof crypto !== "undefined" && typeof crypto.randomUUID === "function" ? crypto.randomUUID() : `anon_${Math.random().toString(36).slice(2)}`;
427
438
  }
439
+ function pickIdentifier(user, bucketBy) {
440
+ if (bucketBy) {
441
+ const v = user[bucketBy];
442
+ if (typeof v === "string" && v.length > 0) return v;
443
+ if (typeof v === "number") return String(v);
444
+ }
445
+ return user.user_id ?? user.anonymous_id;
446
+ }
428
447
  function evalGateInternal(gate, user) {
429
448
  if (isEnabled(gate.killswitch)) return false;
430
449
  if (!isEnabled(gate.enabled)) return false;
@@ -484,6 +503,8 @@ var FlagsClient = class _FlagsClient {
484
503
  apiKey;
485
504
  baseUrl;
486
505
  env;
506
+ privateAttributes;
507
+ stickyStore;
487
508
  telemetry;
488
509
  seeLimiter = new SeeLimiter();
489
510
  flagsBlob = null;
@@ -510,6 +531,8 @@ var FlagsClient = class _FlagsClient {
510
531
  this.apiKey = opts.apiKey;
511
532
  this.baseUrl = (opts.baseUrl ?? "https://cdn.shipeasy.ai").replace(/\/$/, "");
512
533
  this.env = opts.env ?? "prod";
534
+ this.privateAttributes = opts.privateAttributes ?? [];
535
+ this.stickyStore = opts.stickyStore;
513
536
  this.testMode = opts.testMode === true;
514
537
  this.telemetry = new Telemetry({
515
538
  endpoint: opts.telemetryUrl ?? DEFAULT_TELEMETRY_URL,
@@ -740,7 +763,7 @@ var FlagsClient = class _FlagsClient {
740
763
  const gate = this.flagsBlob.gates[exp.targetingGate];
741
764
  if (!gate || !evalGateInternal(gate, user)) return notIn;
742
765
  }
743
- const uid = user.user_id ?? user.anonymous_id;
766
+ const uid = pickIdentifier(user, exp.bucketBy);
744
767
  if (!uid) return notIn;
745
768
  const universe = this.expsBlob.universes[exp.universe];
746
769
  const holdoutRange = universe?.holdout_range ?? null;
@@ -749,6 +772,23 @@ var FlagsClient = class _FlagsClient {
749
772
  const [lo, hi] = holdoutRange;
750
773
  if (seg >= lo && seg <= hi) return notIn;
751
774
  }
775
+ const asResult = (g) => {
776
+ if (!decode) return { inExperiment: true, group: g.name, params: g.params };
777
+ try {
778
+ return { inExperiment: true, group: g.name, params: decode(g.params) };
779
+ } catch (err) {
780
+ console.warn(`[shipeasy] getExperiment('${name}') decode failed:`, String(err));
781
+ return notIn;
782
+ }
783
+ };
784
+ const salt8 = exp.salt.slice(0, 8);
785
+ if (this.stickyStore) {
786
+ const entry = this.stickyStore.get(uid)?.[name];
787
+ if (entry && entry.s === salt8) {
788
+ const g = exp.groups.find((x) => x.name === entry.g);
789
+ if (g) return asResult(g);
790
+ }
791
+ }
752
792
  if (murmur3(`${exp.salt}:alloc:${uid}`) % 1e4 >= exp.allocationPct) return notIn;
753
793
  const groupHash = murmur3(`${exp.salt}:group:${uid}`) % 1e4;
754
794
  let cumulative = 0;
@@ -756,21 +796,24 @@ var FlagsClient = class _FlagsClient {
756
796
  const g = exp.groups[i];
757
797
  cumulative += g.weight;
758
798
  if (groupHash < cumulative || i === exp.groups.length - 1) {
759
- if (!decode) {
760
- return { inExperiment: true, group: g.name, params: g.params };
761
- }
762
- try {
763
- return { inExperiment: true, group: g.name, params: decode(g.params) };
764
- } catch (err) {
765
- console.warn(`[shipeasy] getExperiment('${name}') decode failed:`, String(err));
766
- return notIn;
767
- }
799
+ this.stickyStore?.set(uid, name, { g: g.name, s: salt8 });
800
+ return asResult(g);
768
801
  }
769
802
  }
770
803
  return notIn;
771
804
  }
805
+ /** Drop caller-marked private attributes from an outbound props bag. */
806
+ stripPrivate(props) {
807
+ if (!props || this.privateAttributes.length === 0) return props;
808
+ const out = {};
809
+ for (const [k, v] of Object.entries(props)) {
810
+ if (!this.privateAttributes.includes(k)) out[k] = v;
811
+ }
812
+ return out;
813
+ }
772
814
  track(userId, eventName, props) {
773
815
  if (this.testMode) return;
816
+ const safeProps = this.stripPrivate(props);
774
817
  const body = JSON.stringify({
775
818
  events: [
776
819
  {
@@ -778,7 +821,7 @@ var FlagsClient = class _FlagsClient {
778
821
  event_name: eventName,
779
822
  user_id: userId,
780
823
  ts: Date.now(),
781
- ...props !== void 0 ? { properties: props } : {}
824
+ ...safeProps !== void 0 ? { properties: safeProps } : {}
782
825
  }
783
826
  ]
784
827
  });
@@ -788,6 +831,37 @@ var FlagsClient = class _FlagsClient {
788
831
  body
789
832
  }).catch((err) => console.warn("[shipeasy] track failed:", String(err)));
790
833
  }
834
+ /**
835
+ * Emit an exposure event for an experiment at the server-side decision point
836
+ * (parity with the browser's auto-exposure). The server is stateless and
837
+ * never auto-logs, so call this when you actually present the treatment.
838
+ * Re-evaluates the experiment for `user` (a bare `user_id` string is wrapped
839
+ * as `{ user_id }`); if the user is enrolled, POSTs a single exposure to
840
+ * `/collect`. No-op in test mode or when the user isn't enrolled.
841
+ */
842
+ logExposure(user, name) {
843
+ if (this.testMode) return;
844
+ const u = typeof user === "string" ? { user_id: user } : user;
845
+ const result = this.getExperiment(name, u, {});
846
+ if (!result.inExperiment) return;
847
+ const body = JSON.stringify({
848
+ events: [
849
+ {
850
+ type: "exposure",
851
+ experiment: name,
852
+ group: result.group,
853
+ ...u.user_id !== void 0 ? { user_id: u.user_id } : {},
854
+ ...u.anonymous_id !== void 0 ? { anonymous_id: u.anonymous_id } : {},
855
+ ts: Date.now()
856
+ }
857
+ ]
858
+ });
859
+ globalThis.fetch(`${this.baseUrl}/collect`, {
860
+ method: "POST",
861
+ headers: { "X-SDK-Key": this.apiKey, "Content-Type": "text/plain" },
862
+ body
863
+ }).catch((err) => console.warn("[shipeasy] logExposure failed:", String(err)));
864
+ }
791
865
  /**
792
866
  * Report a structured error into the errors primitive. Fire-and-forget —
793
867
  * never blocks or throws into the request path. Spam-guarded by a 30s
@@ -994,7 +1068,11 @@ async function shipeasy(opts) {
994
1068
  );
995
1069
  }
996
1070
  const profile = opts.i18nDefaultProfile ?? "en:prod";
997
- flags.configure({ apiKey: serverKey, disableTelemetry: opts.disableTelemetry });
1071
+ flags.configure({
1072
+ apiKey: serverKey,
1073
+ disableTelemetry: opts.disableTelemetry,
1074
+ privateAttributes: opts.privateAttributes
1075
+ });
998
1076
  let resolvedUrlOverrides = opts.urlOverrides;
999
1077
  if (!resolvedUrlOverrides) {
1000
1078
  try {
@@ -1133,6 +1211,11 @@ var flags = {
1133
1211
  track(userId, eventName, props) {
1134
1212
  _server?.track(userId, eventName, props);
1135
1213
  },
1214
+ /** Emit an exposure for an enrolled experiment at the decision point. See
1215
+ * {@link FlagsClient.logExposure}. No-op before configure(). */
1216
+ logExposure(user, name) {
1217
+ _server?.logExposure(user, name);
1218
+ },
1136
1219
  /**
1137
1220
  * Evaluate all flags / configs / experiments for a user against the locally
1138
1221
  * cached blob. Pass the request URL to apply ?se_ks_* / ?se_cf_* / ?se_exp_*
@@ -1168,6 +1251,7 @@ export {
1168
1251
  _murmur3ForTests,
1169
1252
  _resetShipeasyServerForTests,
1170
1253
  configureShipeasyServer,
1254
+ createInMemoryStickyStore,
1171
1255
  fetchLabelsForSSR,
1172
1256
  flags,
1173
1257
  getBootstrapHtml,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@shipeasy/sdk",
3
- "version": "5.1.0",
3
+ "version": "5.2.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",
@@ -47,12 +47,24 @@
47
47
  "import": "./dist/next/index.mjs",
48
48
  "default": "./dist/next/index.js"
49
49
  },
50
+ "./openfeature-server": {
51
+ "types": "./dist/openfeature-server/index.d.ts",
52
+ "import": "./dist/openfeature-server/index.mjs",
53
+ "default": "./dist/openfeature-server/index.js"
54
+ },
55
+ "./openfeature-web": {
56
+ "types": "./dist/openfeature-web/index.d.ts",
57
+ "import": "./dist/openfeature-web/index.mjs",
58
+ "default": "./dist/openfeature-web/index.js"
59
+ },
50
60
  "./templates/*": "./templates/*.js"
51
61
  },
52
62
  "files": [
53
63
  "dist/server/",
54
64
  "dist/client/",
55
65
  "dist/next/",
66
+ "dist/openfeature-server/",
67
+ "dist/openfeature-web/",
56
68
  "templates/",
57
69
  "LICENSE",
58
70
  "README.md"
@@ -68,14 +80,24 @@
68
80
  "murmurhash-js": "^1.0.0"
69
81
  },
70
82
  "peerDependencies": {
83
+ "@openfeature/server-sdk": ">=1.13.0",
84
+ "@openfeature/web-sdk": ">=1.2.0",
71
85
  "next": ">=13"
72
86
  },
73
87
  "peerDependenciesMeta": {
88
+ "@openfeature/server-sdk": {
89
+ "optional": true
90
+ },
91
+ "@openfeature/web-sdk": {
92
+ "optional": true
93
+ },
74
94
  "next": {
75
95
  "optional": true
76
96
  }
77
97
  },
78
98
  "devDependencies": {
99
+ "@openfeature/server-sdk": "^1.22.0",
100
+ "@openfeature/web-sdk": "^1.9.0",
79
101
  "@types/murmurhash-js": "^1.0.6",
80
102
  "@types/node": "^20.0.0",
81
103
  "next": "^16.2.3",