@cross-deck/web 0.4.0 → 0.6.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.
package/dist/react.cjs CHANGED
@@ -74,7 +74,7 @@ function typeMapForStatus(status) {
74
74
 
75
75
  // src/http.ts
76
76
  var SDK_NAME = "@cross-deck/web";
77
- var SDK_VERSION = "0.3.0";
77
+ var SDK_VERSION = "0.6.0";
78
78
  var DEFAULT_BASE_URL = "https://api.cross-deck.com/v1";
79
79
  var HttpClient = class {
80
80
  constructor(config) {
@@ -106,7 +106,8 @@ var HttpClient = class {
106
106
  response = await fetch(url, {
107
107
  method,
108
108
  headers,
109
- body: bodyInit
109
+ body: bodyInit,
110
+ keepalive: options.keepalive === true
110
111
  });
111
112
  } catch (err) {
112
113
  throw new CrossdeckError({
@@ -151,19 +152,25 @@ var HttpClient = class {
151
152
  var KEY_ANON = "anon_id";
152
153
  var KEY_CDCUST = "cdcust_id";
153
154
  var IdentityStore = class {
154
- constructor(storage, prefix) {
155
- this.storage = storage;
155
+ constructor(primary, prefix, secondary) {
156
+ this.primary = primary;
156
157
  this.prefix = prefix;
157
- const stored = {
158
- anon: storage.getItem(prefix + KEY_ANON),
159
- cdcust: storage.getItem(prefix + KEY_CDCUST)
160
- };
158
+ this.secondary = secondary ?? null;
159
+ const anonFromPrimary = primary.getItem(prefix + KEY_ANON);
160
+ const cdcustFromPrimary = primary.getItem(prefix + KEY_CDCUST);
161
+ const anonFromSecondary = this.secondary?.getItem(prefix + KEY_ANON) ?? null;
162
+ const cdcustFromSecondary = this.secondary?.getItem(prefix + KEY_CDCUST) ?? null;
163
+ const anon = anonFromPrimary ?? anonFromSecondary;
164
+ const cdcust = cdcustFromPrimary ?? cdcustFromSecondary;
161
165
  this.state = {
162
- anonymousId: stored.anon ?? this.mintAnonymousId(),
163
- crossdeckCustomerId: stored.cdcust
166
+ anonymousId: anon ?? this.mintAnonymousId(),
167
+ crossdeckCustomerId: cdcust
164
168
  };
165
- if (!stored.anon) {
166
- storage.setItem(prefix + KEY_ANON, this.state.anonymousId);
169
+ if (!anonFromPrimary || !anonFromSecondary) {
170
+ this.writeBoth(prefix + KEY_ANON, this.state.anonymousId);
171
+ }
172
+ if (cdcust && (!cdcustFromPrimary || !cdcustFromSecondary)) {
173
+ this.writeBoth(prefix + KEY_CDCUST, cdcust);
167
174
  }
168
175
  }
169
176
  /** Return the persisted anonymous device ID (always set). */
@@ -177,7 +184,7 @@ var IdentityStore = class {
177
184
  /** Persist a newly-resolved Crossdeck customer ID. */
178
185
  setCrossdeckCustomerId(value) {
179
186
  this.state.crossdeckCustomerId = value;
180
- this.storage.setItem(this.prefix + KEY_CDCUST, value);
187
+ this.writeBoth(this.prefix + KEY_CDCUST, value);
181
188
  }
182
189
  /**
183
190
  * Wipe persisted identity. Called by reset() — used when an end-user
@@ -185,13 +192,13 @@ var IdentityStore = class {
185
192
  * pre-login session is a fresh customer in the identity graph.
186
193
  */
187
194
  reset() {
188
- this.storage.removeItem(this.prefix + KEY_ANON);
189
- this.storage.removeItem(this.prefix + KEY_CDCUST);
195
+ this.deleteBoth(this.prefix + KEY_ANON);
196
+ this.deleteBoth(this.prefix + KEY_CDCUST);
190
197
  this.state = {
191
198
  anonymousId: this.mintAnonymousId(),
192
199
  crossdeckCustomerId: null
193
200
  };
194
- this.storage.setItem(this.prefix + KEY_ANON, this.state.anonymousId);
201
+ this.writeBoth(this.prefix + KEY_ANON, this.state.anonymousId);
195
202
  }
196
203
  /**
197
204
  * Generate an anonymousId. Crockford-ish base32 timestamp + random
@@ -203,6 +210,30 @@ var IdentityStore = class {
203
210
  const rand = randomChars(10);
204
211
  return `anon_${ts}${rand}`;
205
212
  }
213
+ writeBoth(key, value) {
214
+ try {
215
+ this.primary.setItem(key, value);
216
+ } catch {
217
+ }
218
+ if (this.secondary) {
219
+ try {
220
+ this.secondary.setItem(key, value);
221
+ } catch {
222
+ }
223
+ }
224
+ }
225
+ deleteBoth(key) {
226
+ try {
227
+ this.primary.removeItem(key);
228
+ } catch {
229
+ }
230
+ if (this.secondary) {
231
+ try {
232
+ this.secondary.removeItem(key);
233
+ } catch {
234
+ }
235
+ }
236
+ }
206
237
  };
207
238
  function randomChars(count) {
208
239
  const alphabet = "0123456789abcdefghijklmnopqrstuvwxyz";
@@ -331,8 +362,12 @@ var EventQueue = class {
331
362
  * Flush the buffer to /v1/events. Resolves when the network call
332
363
  * completes (success or failure). On failure, events stay in the
333
364
  * buffer for the next flush attempt.
365
+ *
366
+ * `options.keepalive` marks the underlying fetch as keepalive so the
367
+ * browser keeps the request alive past page unload. Use this for
368
+ * terminal flushes (pagehide / visibilitychange→hidden / beforeunload).
334
369
  */
335
- async flush() {
370
+ async flush(options = {}) {
336
371
  if (this.buffer.length === 0) return null;
337
372
  this.cancelTimerIfSet();
338
373
  const batch = this.buffer.splice(0);
@@ -348,7 +383,8 @@ var EventQueue = class {
348
383
  environment: env.environment,
349
384
  sdk: env.sdk,
350
385
  events: batch
351
- }
386
+ },
387
+ keepalive: options.keepalive === true
352
388
  });
353
389
  this.lastFlushAt = Date.now();
354
390
  this.lastError = null;
@@ -423,6 +459,59 @@ var MemoryStorage = class {
423
459
  this.store.delete(key);
424
460
  }
425
461
  };
462
+ var CookieStorage = class {
463
+ constructor(options) {
464
+ this.maxAgeSec = options?.maxAgeSec ?? 63072e3;
465
+ this.secure = options?.secure ?? defaultSecure();
466
+ this.sameSite = options?.sameSite ?? "Lax";
467
+ }
468
+ getItem(key) {
469
+ if (!hasDocument()) return null;
470
+ const doc = globalThis.document;
471
+ const cookies = doc.cookie ? doc.cookie.split(/;\s*/) : [];
472
+ const prefix = encodeURIComponent(key) + "=";
473
+ for (const c of cookies) {
474
+ if (c.startsWith(prefix)) {
475
+ try {
476
+ return decodeURIComponent(c.slice(prefix.length));
477
+ } catch {
478
+ return null;
479
+ }
480
+ }
481
+ }
482
+ return null;
483
+ }
484
+ setItem(key, value) {
485
+ if (!hasDocument()) return;
486
+ const doc = globalThis.document;
487
+ const parts = [
488
+ `${encodeURIComponent(key)}=${encodeURIComponent(value)}`,
489
+ "Path=/",
490
+ `Max-Age=${this.maxAgeSec}`,
491
+ `SameSite=${this.sameSite}`
492
+ ];
493
+ if (this.secure) parts.push("Secure");
494
+ try {
495
+ doc.cookie = parts.join("; ");
496
+ } catch {
497
+ }
498
+ }
499
+ removeItem(key) {
500
+ if (!hasDocument()) return;
501
+ const doc = globalThis.document;
502
+ const parts = [
503
+ `${encodeURIComponent(key)}=`,
504
+ "Path=/",
505
+ "Max-Age=0",
506
+ `SameSite=${this.sameSite}`
507
+ ];
508
+ if (this.secure) parts.push("Secure");
509
+ try {
510
+ doc.cookie = parts.join("; ");
511
+ } catch {
512
+ }
513
+ }
514
+ };
426
515
  function detectDefaultStorage() {
427
516
  try {
428
517
  const ls = globalThis.localStorage;
@@ -436,6 +525,17 @@ function detectDefaultStorage() {
436
525
  }
437
526
  return new MemoryStorage();
438
527
  }
528
+ function defaultSecure() {
529
+ try {
530
+ const loc = globalThis.location;
531
+ return loc?.protocol === "https:";
532
+ } catch {
533
+ return false;
534
+ }
535
+ }
536
+ function hasDocument() {
537
+ return typeof globalThis.document !== "undefined";
538
+ }
439
539
 
440
540
  // src/device-info.ts
441
541
  function isBrowser() {
@@ -536,6 +636,14 @@ var DEFAULT_AUTO_TRACK = {
536
636
  deviceInfo: true
537
637
  };
538
638
  var SESSION_RESUME_THRESHOLD_MS = 30 * 60 * 1e3;
639
+ var EMPTY_ACQUISITION = {
640
+ utm_source: "",
641
+ utm_medium: "",
642
+ utm_campaign: "",
643
+ utm_content: "",
644
+ utm_term: "",
645
+ referrer: ""
646
+ };
539
647
  var AutoTracker = class {
540
648
  constructor(cfg, track) {
541
649
  this.cfg = cfg;
@@ -571,6 +679,18 @@ var AutoTracker = class {
571
679
  get currentSessionId() {
572
680
  return this.session?.sessionId ?? null;
573
681
  }
682
+ /**
683
+ * Per-session acquisition context — utm_* + referrer, captured once
684
+ * at session start. Returns empty strings when there's no session
685
+ * (Node, before init, after uninstall) so callers can spread without
686
+ * conditional logic. Bank-grade rule: capture once, attach to every
687
+ * event of the session, don't re-read on every track() (the URL
688
+ * changes via SPA pushState; the source-of-record is the URL we
689
+ * landed on).
690
+ */
691
+ get currentAcquisition() {
692
+ return this.session?.acquisition ?? EMPTY_ACQUISITION;
693
+ }
574
694
  // ---------- sessions ----------
575
695
  installSessionTracking() {
576
696
  this.session = this.startNewSession();
@@ -608,7 +728,8 @@ var AutoTracker = class {
608
728
  sessionId: mintSessionId(),
609
729
  startedAt: Date.now(),
610
730
  hiddenAt: null,
611
- endedSent: false
731
+ endedSent: false,
732
+ acquisition: captureAcquisition()
612
733
  };
613
734
  }
614
735
  emitSessionStart() {
@@ -674,6 +795,26 @@ function mintSessionId() {
674
795
  const ts = Date.now().toString(36);
675
796
  return `sess_${ts}${randomChars(10)}`;
676
797
  }
798
+ function captureAcquisition() {
799
+ if (!isBrowserSafe()) return { ...EMPTY_ACQUISITION };
800
+ const result = { ...EMPTY_ACQUISITION };
801
+ try {
802
+ const w = globalThis.window;
803
+ const params = new URLSearchParams(w.location.search ?? "");
804
+ result.utm_source = params.get("utm_source") ?? "";
805
+ result.utm_medium = params.get("utm_medium") ?? "";
806
+ result.utm_campaign = params.get("utm_campaign") ?? "";
807
+ result.utm_content = params.get("utm_content") ?? "";
808
+ result.utm_term = params.get("utm_term") ?? "";
809
+ } catch {
810
+ }
811
+ try {
812
+ const doc = globalThis.document;
813
+ if (typeof doc.referrer === "string") result.referrer = doc.referrer;
814
+ } catch {
815
+ }
816
+ return result;
817
+ }
677
818
 
678
819
  // src/debug.ts
679
820
  var SENSITIVE_KEY_PATTERNS = [
@@ -778,7 +919,11 @@ var CrossdeckClient = class {
778
919
  storagePrefix: options.storagePrefix ?? "crossdeck:",
779
920
  autoHeartbeat: options.autoHeartbeat ?? true,
780
921
  eventFlushBatchSize: options.eventFlushBatchSize ?? 20,
781
- eventFlushIntervalMs: options.eventFlushIntervalMs ?? 5e3,
922
+ // 1500ms idle window. Short enough that an event queued on page
923
+ // load still flushes if the user leaves quickly (the keepalive
924
+ // pagehide handler picks up anything that doesn't); long enough
925
+ // that bursts of clicks coalesce into one network round-trip.
926
+ eventFlushIntervalMs: options.eventFlushIntervalMs ?? 1500,
782
927
  sdkVersion: options.sdkVersion ?? SDK_VERSION,
783
928
  autoTrack,
784
929
  appVersion: options.appVersion ?? null
@@ -791,7 +936,10 @@ var CrossdeckClient = class {
791
936
  sdkVersion: opts.sdkVersion
792
937
  });
793
938
  const effectiveStorage = persistIdentity ? storage : new MemoryStorage();
794
- const identity = new IdentityStore(effectiveStorage, opts.storagePrefix);
939
+ const useCookieRedundancy = persistIdentity && !options.storage && // honour caller's adapter choice
940
+ typeof globalThis.document !== "undefined";
941
+ const cookieStore = useCookieRedundancy ? new CookieStorage() : void 0;
942
+ const identity = new IdentityStore(effectiveStorage, opts.storagePrefix, cookieStore);
795
943
  const entitlements = new EntitlementCache();
796
944
  const events = new EventQueue({
797
945
  http,
@@ -820,7 +968,8 @@ var CrossdeckClient = class {
820
968
  deviceInfo,
821
969
  options: opts,
822
970
  debug,
823
- developerUserId: null
971
+ developerUserId: null,
972
+ uninstallUnloadFlush: null
824
973
  };
825
974
  debug.emit("sdk.configured", `Crossdeck connected to ${opts.appId} in ${opts.environment} mode.`, {
826
975
  appId: opts.appId,
@@ -835,6 +984,9 @@ var CrossdeckClient = class {
835
984
  this.state.autoTracker = tracker;
836
985
  tracker.install();
837
986
  }
987
+ this.state.uninstallUnloadFlush = installUnloadFlush(() => {
988
+ void this.flush({ keepalive: true }).catch(() => void 0);
989
+ });
838
990
  if (opts.autoHeartbeat) {
839
991
  void this.heartbeat().catch(() => void 0);
840
992
  }
@@ -968,6 +1120,15 @@ var CrossdeckClient = class {
968
1120
  const enriched = { ...s.deviceInfo };
969
1121
  const sessionId = s.autoTracker?.currentSessionId;
970
1122
  if (sessionId) enriched.sessionId = sessionId;
1123
+ const acquisition = s.autoTracker?.currentAcquisition;
1124
+ if (acquisition) {
1125
+ if (acquisition.utm_source) enriched.utm_source = acquisition.utm_source;
1126
+ if (acquisition.utm_medium) enriched.utm_medium = acquisition.utm_medium;
1127
+ if (acquisition.utm_campaign) enriched.utm_campaign = acquisition.utm_campaign;
1128
+ if (acquisition.utm_content) enriched.utm_content = acquisition.utm_content;
1129
+ if (acquisition.utm_term) enriched.utm_term = acquisition.utm_term;
1130
+ if (acquisition.referrer) enriched.referrer = acquisition.referrer;
1131
+ }
971
1132
  if (properties) Object.assign(enriched, properties);
972
1133
  const event = {
973
1134
  eventId: this.mintEventId(),
@@ -981,11 +1142,16 @@ var CrossdeckClient = class {
981
1142
  /**
982
1143
  * Force-flush queued events. Useful to call from page-unload handlers.
983
1144
  *
1145
+ * Pass `{ keepalive: true }` from terminal handlers (pagehide /
1146
+ * visibilitychange→hidden / beforeunload). The browser keeps the
1147
+ * request alive after the page tears down, so the final batch
1148
+ * actually lands instead of being cancelled with the unload.
1149
+ *
984
1150
  * NorthStar §4: standard method name across all Crossdeck SDKs.
985
1151
  */
986
- async flush() {
1152
+ async flush(options = {}) {
987
1153
  const s = this.requireStarted();
988
- await s.events.flush();
1154
+ await s.events.flush(options);
989
1155
  }
990
1156
  /** @deprecated Use `flush()` instead. NorthStar §4 standardised the name. */
991
1157
  async flushEvents() {
@@ -1168,6 +1334,23 @@ function resolveAutoTrack(input) {
1168
1334
  deviceInfo: input.deviceInfo ?? DEFAULT_AUTO_TRACK.deviceInfo
1169
1335
  };
1170
1336
  }
1337
+ function installUnloadFlush(onUnload) {
1338
+ const w = globalThis.window;
1339
+ const doc = globalThis.document;
1340
+ if (!w || !doc) return () => void 0;
1341
+ const onVisChange = () => {
1342
+ if (doc.visibilityState === "hidden") onUnload();
1343
+ };
1344
+ const onTerminal = () => onUnload();
1345
+ doc.addEventListener("visibilitychange", onVisChange);
1346
+ w.addEventListener("pagehide", onTerminal);
1347
+ w.addEventListener("beforeunload", onTerminal);
1348
+ return () => {
1349
+ doc.removeEventListener("visibilitychange", onVisChange);
1350
+ w.removeEventListener("pagehide", onTerminal);
1351
+ w.removeEventListener("beforeunload", onTerminal);
1352
+ };
1353
+ }
1171
1354
 
1172
1355
  // src/react.ts
1173
1356
  function useEntitlement(key) {