@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.mjs CHANGED
@@ -49,7 +49,7 @@ function typeMapForStatus(status) {
49
49
 
50
50
  // src/http.ts
51
51
  var SDK_NAME = "@cross-deck/web";
52
- var SDK_VERSION = "0.3.0";
52
+ var SDK_VERSION = "0.6.0";
53
53
  var DEFAULT_BASE_URL = "https://api.cross-deck.com/v1";
54
54
  var HttpClient = class {
55
55
  constructor(config) {
@@ -81,7 +81,8 @@ var HttpClient = class {
81
81
  response = await fetch(url, {
82
82
  method,
83
83
  headers,
84
- body: bodyInit
84
+ body: bodyInit,
85
+ keepalive: options.keepalive === true
85
86
  });
86
87
  } catch (err) {
87
88
  throw new CrossdeckError({
@@ -126,19 +127,25 @@ var HttpClient = class {
126
127
  var KEY_ANON = "anon_id";
127
128
  var KEY_CDCUST = "cdcust_id";
128
129
  var IdentityStore = class {
129
- constructor(storage, prefix) {
130
- this.storage = storage;
130
+ constructor(primary, prefix, secondary) {
131
+ this.primary = primary;
131
132
  this.prefix = prefix;
132
- const stored = {
133
- anon: storage.getItem(prefix + KEY_ANON),
134
- cdcust: storage.getItem(prefix + KEY_CDCUST)
135
- };
133
+ this.secondary = secondary ?? null;
134
+ const anonFromPrimary = primary.getItem(prefix + KEY_ANON);
135
+ const cdcustFromPrimary = primary.getItem(prefix + KEY_CDCUST);
136
+ const anonFromSecondary = this.secondary?.getItem(prefix + KEY_ANON) ?? null;
137
+ const cdcustFromSecondary = this.secondary?.getItem(prefix + KEY_CDCUST) ?? null;
138
+ const anon = anonFromPrimary ?? anonFromSecondary;
139
+ const cdcust = cdcustFromPrimary ?? cdcustFromSecondary;
136
140
  this.state = {
137
- anonymousId: stored.anon ?? this.mintAnonymousId(),
138
- crossdeckCustomerId: stored.cdcust
141
+ anonymousId: anon ?? this.mintAnonymousId(),
142
+ crossdeckCustomerId: cdcust
139
143
  };
140
- if (!stored.anon) {
141
- storage.setItem(prefix + KEY_ANON, this.state.anonymousId);
144
+ if (!anonFromPrimary || !anonFromSecondary) {
145
+ this.writeBoth(prefix + KEY_ANON, this.state.anonymousId);
146
+ }
147
+ if (cdcust && (!cdcustFromPrimary || !cdcustFromSecondary)) {
148
+ this.writeBoth(prefix + KEY_CDCUST, cdcust);
142
149
  }
143
150
  }
144
151
  /** Return the persisted anonymous device ID (always set). */
@@ -152,7 +159,7 @@ var IdentityStore = class {
152
159
  /** Persist a newly-resolved Crossdeck customer ID. */
153
160
  setCrossdeckCustomerId(value) {
154
161
  this.state.crossdeckCustomerId = value;
155
- this.storage.setItem(this.prefix + KEY_CDCUST, value);
162
+ this.writeBoth(this.prefix + KEY_CDCUST, value);
156
163
  }
157
164
  /**
158
165
  * Wipe persisted identity. Called by reset() — used when an end-user
@@ -160,13 +167,13 @@ var IdentityStore = class {
160
167
  * pre-login session is a fresh customer in the identity graph.
161
168
  */
162
169
  reset() {
163
- this.storage.removeItem(this.prefix + KEY_ANON);
164
- this.storage.removeItem(this.prefix + KEY_CDCUST);
170
+ this.deleteBoth(this.prefix + KEY_ANON);
171
+ this.deleteBoth(this.prefix + KEY_CDCUST);
165
172
  this.state = {
166
173
  anonymousId: this.mintAnonymousId(),
167
174
  crossdeckCustomerId: null
168
175
  };
169
- this.storage.setItem(this.prefix + KEY_ANON, this.state.anonymousId);
176
+ this.writeBoth(this.prefix + KEY_ANON, this.state.anonymousId);
170
177
  }
171
178
  /**
172
179
  * Generate an anonymousId. Crockford-ish base32 timestamp + random
@@ -178,6 +185,30 @@ var IdentityStore = class {
178
185
  const rand = randomChars(10);
179
186
  return `anon_${ts}${rand}`;
180
187
  }
188
+ writeBoth(key, value) {
189
+ try {
190
+ this.primary.setItem(key, value);
191
+ } catch {
192
+ }
193
+ if (this.secondary) {
194
+ try {
195
+ this.secondary.setItem(key, value);
196
+ } catch {
197
+ }
198
+ }
199
+ }
200
+ deleteBoth(key) {
201
+ try {
202
+ this.primary.removeItem(key);
203
+ } catch {
204
+ }
205
+ if (this.secondary) {
206
+ try {
207
+ this.secondary.removeItem(key);
208
+ } catch {
209
+ }
210
+ }
211
+ }
181
212
  };
182
213
  function randomChars(count) {
183
214
  const alphabet = "0123456789abcdefghijklmnopqrstuvwxyz";
@@ -306,8 +337,12 @@ var EventQueue = class {
306
337
  * Flush the buffer to /v1/events. Resolves when the network call
307
338
  * completes (success or failure). On failure, events stay in the
308
339
  * buffer for the next flush attempt.
340
+ *
341
+ * `options.keepalive` marks the underlying fetch as keepalive so the
342
+ * browser keeps the request alive past page unload. Use this for
343
+ * terminal flushes (pagehide / visibilitychange→hidden / beforeunload).
309
344
  */
310
- async flush() {
345
+ async flush(options = {}) {
311
346
  if (this.buffer.length === 0) return null;
312
347
  this.cancelTimerIfSet();
313
348
  const batch = this.buffer.splice(0);
@@ -323,7 +358,8 @@ var EventQueue = class {
323
358
  environment: env.environment,
324
359
  sdk: env.sdk,
325
360
  events: batch
326
- }
361
+ },
362
+ keepalive: options.keepalive === true
327
363
  });
328
364
  this.lastFlushAt = Date.now();
329
365
  this.lastError = null;
@@ -398,6 +434,59 @@ var MemoryStorage = class {
398
434
  this.store.delete(key);
399
435
  }
400
436
  };
437
+ var CookieStorage = class {
438
+ constructor(options) {
439
+ this.maxAgeSec = options?.maxAgeSec ?? 63072e3;
440
+ this.secure = options?.secure ?? defaultSecure();
441
+ this.sameSite = options?.sameSite ?? "Lax";
442
+ }
443
+ getItem(key) {
444
+ if (!hasDocument()) return null;
445
+ const doc = globalThis.document;
446
+ const cookies = doc.cookie ? doc.cookie.split(/;\s*/) : [];
447
+ const prefix = encodeURIComponent(key) + "=";
448
+ for (const c of cookies) {
449
+ if (c.startsWith(prefix)) {
450
+ try {
451
+ return decodeURIComponent(c.slice(prefix.length));
452
+ } catch {
453
+ return null;
454
+ }
455
+ }
456
+ }
457
+ return null;
458
+ }
459
+ setItem(key, value) {
460
+ if (!hasDocument()) return;
461
+ const doc = globalThis.document;
462
+ const parts = [
463
+ `${encodeURIComponent(key)}=${encodeURIComponent(value)}`,
464
+ "Path=/",
465
+ `Max-Age=${this.maxAgeSec}`,
466
+ `SameSite=${this.sameSite}`
467
+ ];
468
+ if (this.secure) parts.push("Secure");
469
+ try {
470
+ doc.cookie = parts.join("; ");
471
+ } catch {
472
+ }
473
+ }
474
+ removeItem(key) {
475
+ if (!hasDocument()) return;
476
+ const doc = globalThis.document;
477
+ const parts = [
478
+ `${encodeURIComponent(key)}=`,
479
+ "Path=/",
480
+ "Max-Age=0",
481
+ `SameSite=${this.sameSite}`
482
+ ];
483
+ if (this.secure) parts.push("Secure");
484
+ try {
485
+ doc.cookie = parts.join("; ");
486
+ } catch {
487
+ }
488
+ }
489
+ };
401
490
  function detectDefaultStorage() {
402
491
  try {
403
492
  const ls = globalThis.localStorage;
@@ -411,6 +500,17 @@ function detectDefaultStorage() {
411
500
  }
412
501
  return new MemoryStorage();
413
502
  }
503
+ function defaultSecure() {
504
+ try {
505
+ const loc = globalThis.location;
506
+ return loc?.protocol === "https:";
507
+ } catch {
508
+ return false;
509
+ }
510
+ }
511
+ function hasDocument() {
512
+ return typeof globalThis.document !== "undefined";
513
+ }
414
514
 
415
515
  // src/device-info.ts
416
516
  function isBrowser() {
@@ -511,6 +611,14 @@ var DEFAULT_AUTO_TRACK = {
511
611
  deviceInfo: true
512
612
  };
513
613
  var SESSION_RESUME_THRESHOLD_MS = 30 * 60 * 1e3;
614
+ var EMPTY_ACQUISITION = {
615
+ utm_source: "",
616
+ utm_medium: "",
617
+ utm_campaign: "",
618
+ utm_content: "",
619
+ utm_term: "",
620
+ referrer: ""
621
+ };
514
622
  var AutoTracker = class {
515
623
  constructor(cfg, track) {
516
624
  this.cfg = cfg;
@@ -546,6 +654,18 @@ var AutoTracker = class {
546
654
  get currentSessionId() {
547
655
  return this.session?.sessionId ?? null;
548
656
  }
657
+ /**
658
+ * Per-session acquisition context — utm_* + referrer, captured once
659
+ * at session start. Returns empty strings when there's no session
660
+ * (Node, before init, after uninstall) so callers can spread without
661
+ * conditional logic. Bank-grade rule: capture once, attach to every
662
+ * event of the session, don't re-read on every track() (the URL
663
+ * changes via SPA pushState; the source-of-record is the URL we
664
+ * landed on).
665
+ */
666
+ get currentAcquisition() {
667
+ return this.session?.acquisition ?? EMPTY_ACQUISITION;
668
+ }
549
669
  // ---------- sessions ----------
550
670
  installSessionTracking() {
551
671
  this.session = this.startNewSession();
@@ -583,7 +703,8 @@ var AutoTracker = class {
583
703
  sessionId: mintSessionId(),
584
704
  startedAt: Date.now(),
585
705
  hiddenAt: null,
586
- endedSent: false
706
+ endedSent: false,
707
+ acquisition: captureAcquisition()
587
708
  };
588
709
  }
589
710
  emitSessionStart() {
@@ -649,6 +770,26 @@ function mintSessionId() {
649
770
  const ts = Date.now().toString(36);
650
771
  return `sess_${ts}${randomChars(10)}`;
651
772
  }
773
+ function captureAcquisition() {
774
+ if (!isBrowserSafe()) return { ...EMPTY_ACQUISITION };
775
+ const result = { ...EMPTY_ACQUISITION };
776
+ try {
777
+ const w = globalThis.window;
778
+ const params = new URLSearchParams(w.location.search ?? "");
779
+ result.utm_source = params.get("utm_source") ?? "";
780
+ result.utm_medium = params.get("utm_medium") ?? "";
781
+ result.utm_campaign = params.get("utm_campaign") ?? "";
782
+ result.utm_content = params.get("utm_content") ?? "";
783
+ result.utm_term = params.get("utm_term") ?? "";
784
+ } catch {
785
+ }
786
+ try {
787
+ const doc = globalThis.document;
788
+ if (typeof doc.referrer === "string") result.referrer = doc.referrer;
789
+ } catch {
790
+ }
791
+ return result;
792
+ }
652
793
 
653
794
  // src/debug.ts
654
795
  var SENSITIVE_KEY_PATTERNS = [
@@ -753,7 +894,11 @@ var CrossdeckClient = class {
753
894
  storagePrefix: options.storagePrefix ?? "crossdeck:",
754
895
  autoHeartbeat: options.autoHeartbeat ?? true,
755
896
  eventFlushBatchSize: options.eventFlushBatchSize ?? 20,
756
- eventFlushIntervalMs: options.eventFlushIntervalMs ?? 5e3,
897
+ // 1500ms idle window. Short enough that an event queued on page
898
+ // load still flushes if the user leaves quickly (the keepalive
899
+ // pagehide handler picks up anything that doesn't); long enough
900
+ // that bursts of clicks coalesce into one network round-trip.
901
+ eventFlushIntervalMs: options.eventFlushIntervalMs ?? 1500,
757
902
  sdkVersion: options.sdkVersion ?? SDK_VERSION,
758
903
  autoTrack,
759
904
  appVersion: options.appVersion ?? null
@@ -766,7 +911,10 @@ var CrossdeckClient = class {
766
911
  sdkVersion: opts.sdkVersion
767
912
  });
768
913
  const effectiveStorage = persistIdentity ? storage : new MemoryStorage();
769
- const identity = new IdentityStore(effectiveStorage, opts.storagePrefix);
914
+ const useCookieRedundancy = persistIdentity && !options.storage && // honour caller's adapter choice
915
+ typeof globalThis.document !== "undefined";
916
+ const cookieStore = useCookieRedundancy ? new CookieStorage() : void 0;
917
+ const identity = new IdentityStore(effectiveStorage, opts.storagePrefix, cookieStore);
770
918
  const entitlements = new EntitlementCache();
771
919
  const events = new EventQueue({
772
920
  http,
@@ -795,7 +943,8 @@ var CrossdeckClient = class {
795
943
  deviceInfo,
796
944
  options: opts,
797
945
  debug,
798
- developerUserId: null
946
+ developerUserId: null,
947
+ uninstallUnloadFlush: null
799
948
  };
800
949
  debug.emit("sdk.configured", `Crossdeck connected to ${opts.appId} in ${opts.environment} mode.`, {
801
950
  appId: opts.appId,
@@ -810,6 +959,9 @@ var CrossdeckClient = class {
810
959
  this.state.autoTracker = tracker;
811
960
  tracker.install();
812
961
  }
962
+ this.state.uninstallUnloadFlush = installUnloadFlush(() => {
963
+ void this.flush({ keepalive: true }).catch(() => void 0);
964
+ });
813
965
  if (opts.autoHeartbeat) {
814
966
  void this.heartbeat().catch(() => void 0);
815
967
  }
@@ -943,6 +1095,15 @@ var CrossdeckClient = class {
943
1095
  const enriched = { ...s.deviceInfo };
944
1096
  const sessionId = s.autoTracker?.currentSessionId;
945
1097
  if (sessionId) enriched.sessionId = sessionId;
1098
+ const acquisition = s.autoTracker?.currentAcquisition;
1099
+ if (acquisition) {
1100
+ if (acquisition.utm_source) enriched.utm_source = acquisition.utm_source;
1101
+ if (acquisition.utm_medium) enriched.utm_medium = acquisition.utm_medium;
1102
+ if (acquisition.utm_campaign) enriched.utm_campaign = acquisition.utm_campaign;
1103
+ if (acquisition.utm_content) enriched.utm_content = acquisition.utm_content;
1104
+ if (acquisition.utm_term) enriched.utm_term = acquisition.utm_term;
1105
+ if (acquisition.referrer) enriched.referrer = acquisition.referrer;
1106
+ }
946
1107
  if (properties) Object.assign(enriched, properties);
947
1108
  const event = {
948
1109
  eventId: this.mintEventId(),
@@ -956,11 +1117,16 @@ var CrossdeckClient = class {
956
1117
  /**
957
1118
  * Force-flush queued events. Useful to call from page-unload handlers.
958
1119
  *
1120
+ * Pass `{ keepalive: true }` from terminal handlers (pagehide /
1121
+ * visibilitychange→hidden / beforeunload). The browser keeps the
1122
+ * request alive after the page tears down, so the final batch
1123
+ * actually lands instead of being cancelled with the unload.
1124
+ *
959
1125
  * NorthStar §4: standard method name across all Crossdeck SDKs.
960
1126
  */
961
- async flush() {
1127
+ async flush(options = {}) {
962
1128
  const s = this.requireStarted();
963
- await s.events.flush();
1129
+ await s.events.flush(options);
964
1130
  }
965
1131
  /** @deprecated Use `flush()` instead. NorthStar §4 standardised the name. */
966
1132
  async flushEvents() {
@@ -1143,6 +1309,23 @@ function resolveAutoTrack(input) {
1143
1309
  deviceInfo: input.deviceInfo ?? DEFAULT_AUTO_TRACK.deviceInfo
1144
1310
  };
1145
1311
  }
1312
+ function installUnloadFlush(onUnload) {
1313
+ const w = globalThis.window;
1314
+ const doc = globalThis.document;
1315
+ if (!w || !doc) return () => void 0;
1316
+ const onVisChange = () => {
1317
+ if (doc.visibilityState === "hidden") onUnload();
1318
+ };
1319
+ const onTerminal = () => onUnload();
1320
+ doc.addEventListener("visibilitychange", onVisChange);
1321
+ w.addEventListener("pagehide", onTerminal);
1322
+ w.addEventListener("beforeunload", onTerminal);
1323
+ return () => {
1324
+ doc.removeEventListener("visibilitychange", onVisChange);
1325
+ w.removeEventListener("pagehide", onTerminal);
1326
+ w.removeEventListener("beforeunload", onTerminal);
1327
+ };
1328
+ }
1146
1329
 
1147
1330
  // src/react.ts
1148
1331
  function useEntitlement(key) {