@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/index.d.ts CHANGED
@@ -337,9 +337,16 @@ declare class CrossdeckClient {
337
337
  /**
338
338
  * Force-flush queued events. Useful to call from page-unload handlers.
339
339
  *
340
+ * Pass `{ keepalive: true }` from terminal handlers (pagehide /
341
+ * visibilitychange→hidden / beforeunload). The browser keeps the
342
+ * request alive after the page tears down, so the final batch
343
+ * actually lands instead of being cancelled with the unload.
344
+ *
340
345
  * NorthStar §4: standard method name across all Crossdeck SDKs.
341
346
  */
342
- flush(): Promise<void>;
347
+ flush(options?: {
348
+ keepalive?: boolean;
349
+ }): Promise<void>;
343
350
  /** @deprecated Use `flush()` instead. NorthStar §4 standardised the name. */
344
351
  flushEvents(): Promise<void>;
345
352
  /**
@@ -441,12 +448,40 @@ declare class CrossdeckError extends Error {
441
448
  /**
442
449
  * Storage adapters for SDK-persisted state.
443
450
  *
444
- * Two flavours:
451
+ * Three flavours:
445
452
  * - browser localStorage (default in browsers)
453
+ * - 1st-party document.cookie (redundancy for cleared localStorage)
446
454
  * - in-memory (default in Node, or as an explicit fallback)
447
455
  *
448
456
  * Detection is at construction time, not at every call — picking the
449
457
  * adapter once means we don't hit `typeof window` checks on hot paths.
458
+ *
459
+ * ----- Bank-grade identity continuity -----
460
+ *
461
+ * Plain localStorage is not enough. ITP, private browsing, "clear site
462
+ * data" actions, and aggressive privacy extensions all wipe it. When
463
+ * that happens, the SDK mints a fresh anonymousId on next page load
464
+ * and the customer's analytics see one human as multiple "new
465
+ * visitors" — a credibility hit on every dashboard chart that depends
466
+ * on visitor uniqueness (new vs returning, retention, funnels).
467
+ *
468
+ * The fix is redundancy: we write the same identity to BOTH
469
+ * localStorage AND a 1st-party cookie. On boot we read both; whichever
470
+ * survived wins. On set, we write to both stores so a future clear of
471
+ * either doesn't lose the user.
472
+ *
473
+ * Caveats (documented honestly):
474
+ * 1. Safari ITP caps client-set 1st-party cookies at 7 days. Cookie
475
+ * redundancy protects against localStorage clears WITHIN that
476
+ * 7-day window, not beyond it. The full ITP-bypass story (server-
477
+ * set cookies via a customer-CNAMEd subdomain) is a Phase 2
478
+ * follow-up that requires customer DNS configuration.
479
+ * 2. We never write fingerprintable data — only the same anonymousId
480
+ * already in localStorage. Privacy posture is unchanged from
481
+ * single-store identity.
482
+ * 3. `persistIdentity: false` disables BOTH stores so customers
483
+ * running strict consent flows can defer cookie writes until the
484
+ * user opts in.
450
485
  */
451
486
 
452
487
  /**
@@ -469,7 +504,7 @@ declare class MemoryStorage implements KeyValueStorage {
469
504
  * fetch shim, no transitive deps.
470
505
  */
471
506
  declare const SDK_NAME = "@cross-deck/web";
472
- declare const SDK_VERSION = "0.3.0";
507
+ declare const SDK_VERSION = "0.6.0";
473
508
  declare const DEFAULT_BASE_URL = "https://api.cross-deck.com/v1";
474
509
 
475
510
  /**
package/dist/index.mjs CHANGED
@@ -46,7 +46,7 @@ function typeMapForStatus(status) {
46
46
 
47
47
  // src/http.ts
48
48
  var SDK_NAME = "@cross-deck/web";
49
- var SDK_VERSION = "0.3.0";
49
+ var SDK_VERSION = "0.6.0";
50
50
  var DEFAULT_BASE_URL = "https://api.cross-deck.com/v1";
51
51
  var HttpClient = class {
52
52
  constructor(config) {
@@ -78,7 +78,8 @@ var HttpClient = class {
78
78
  response = await fetch(url, {
79
79
  method,
80
80
  headers,
81
- body: bodyInit
81
+ body: bodyInit,
82
+ keepalive: options.keepalive === true
82
83
  });
83
84
  } catch (err) {
84
85
  throw new CrossdeckError({
@@ -123,19 +124,25 @@ var HttpClient = class {
123
124
  var KEY_ANON = "anon_id";
124
125
  var KEY_CDCUST = "cdcust_id";
125
126
  var IdentityStore = class {
126
- constructor(storage, prefix) {
127
- this.storage = storage;
127
+ constructor(primary, prefix, secondary) {
128
+ this.primary = primary;
128
129
  this.prefix = prefix;
129
- const stored = {
130
- anon: storage.getItem(prefix + KEY_ANON),
131
- cdcust: storage.getItem(prefix + KEY_CDCUST)
132
- };
130
+ this.secondary = secondary ?? null;
131
+ const anonFromPrimary = primary.getItem(prefix + KEY_ANON);
132
+ const cdcustFromPrimary = primary.getItem(prefix + KEY_CDCUST);
133
+ const anonFromSecondary = this.secondary?.getItem(prefix + KEY_ANON) ?? null;
134
+ const cdcustFromSecondary = this.secondary?.getItem(prefix + KEY_CDCUST) ?? null;
135
+ const anon = anonFromPrimary ?? anonFromSecondary;
136
+ const cdcust = cdcustFromPrimary ?? cdcustFromSecondary;
133
137
  this.state = {
134
- anonymousId: stored.anon ?? this.mintAnonymousId(),
135
- crossdeckCustomerId: stored.cdcust
138
+ anonymousId: anon ?? this.mintAnonymousId(),
139
+ crossdeckCustomerId: cdcust
136
140
  };
137
- if (!stored.anon) {
138
- storage.setItem(prefix + KEY_ANON, this.state.anonymousId);
141
+ if (!anonFromPrimary || !anonFromSecondary) {
142
+ this.writeBoth(prefix + KEY_ANON, this.state.anonymousId);
143
+ }
144
+ if (cdcust && (!cdcustFromPrimary || !cdcustFromSecondary)) {
145
+ this.writeBoth(prefix + KEY_CDCUST, cdcust);
139
146
  }
140
147
  }
141
148
  /** Return the persisted anonymous device ID (always set). */
@@ -149,7 +156,7 @@ var IdentityStore = class {
149
156
  /** Persist a newly-resolved Crossdeck customer ID. */
150
157
  setCrossdeckCustomerId(value) {
151
158
  this.state.crossdeckCustomerId = value;
152
- this.storage.setItem(this.prefix + KEY_CDCUST, value);
159
+ this.writeBoth(this.prefix + KEY_CDCUST, value);
153
160
  }
154
161
  /**
155
162
  * Wipe persisted identity. Called by reset() — used when an end-user
@@ -157,13 +164,13 @@ var IdentityStore = class {
157
164
  * pre-login session is a fresh customer in the identity graph.
158
165
  */
159
166
  reset() {
160
- this.storage.removeItem(this.prefix + KEY_ANON);
161
- this.storage.removeItem(this.prefix + KEY_CDCUST);
167
+ this.deleteBoth(this.prefix + KEY_ANON);
168
+ this.deleteBoth(this.prefix + KEY_CDCUST);
162
169
  this.state = {
163
170
  anonymousId: this.mintAnonymousId(),
164
171
  crossdeckCustomerId: null
165
172
  };
166
- this.storage.setItem(this.prefix + KEY_ANON, this.state.anonymousId);
173
+ this.writeBoth(this.prefix + KEY_ANON, this.state.anonymousId);
167
174
  }
168
175
  /**
169
176
  * Generate an anonymousId. Crockford-ish base32 timestamp + random
@@ -175,6 +182,30 @@ var IdentityStore = class {
175
182
  const rand = randomChars(10);
176
183
  return `anon_${ts}${rand}`;
177
184
  }
185
+ writeBoth(key, value) {
186
+ try {
187
+ this.primary.setItem(key, value);
188
+ } catch {
189
+ }
190
+ if (this.secondary) {
191
+ try {
192
+ this.secondary.setItem(key, value);
193
+ } catch {
194
+ }
195
+ }
196
+ }
197
+ deleteBoth(key) {
198
+ try {
199
+ this.primary.removeItem(key);
200
+ } catch {
201
+ }
202
+ if (this.secondary) {
203
+ try {
204
+ this.secondary.removeItem(key);
205
+ } catch {
206
+ }
207
+ }
208
+ }
178
209
  };
179
210
  function randomChars(count) {
180
211
  const alphabet = "0123456789abcdefghijklmnopqrstuvwxyz";
@@ -303,8 +334,12 @@ var EventQueue = class {
303
334
  * Flush the buffer to /v1/events. Resolves when the network call
304
335
  * completes (success or failure). On failure, events stay in the
305
336
  * buffer for the next flush attempt.
337
+ *
338
+ * `options.keepalive` marks the underlying fetch as keepalive so the
339
+ * browser keeps the request alive past page unload. Use this for
340
+ * terminal flushes (pagehide / visibilitychange→hidden / beforeunload).
306
341
  */
307
- async flush() {
342
+ async flush(options = {}) {
308
343
  if (this.buffer.length === 0) return null;
309
344
  this.cancelTimerIfSet();
310
345
  const batch = this.buffer.splice(0);
@@ -320,7 +355,8 @@ var EventQueue = class {
320
355
  environment: env.environment,
321
356
  sdk: env.sdk,
322
357
  events: batch
323
- }
358
+ },
359
+ keepalive: options.keepalive === true
324
360
  });
325
361
  this.lastFlushAt = Date.now();
326
362
  this.lastError = null;
@@ -395,6 +431,59 @@ var MemoryStorage = class {
395
431
  this.store.delete(key);
396
432
  }
397
433
  };
434
+ var CookieStorage = class {
435
+ constructor(options) {
436
+ this.maxAgeSec = options?.maxAgeSec ?? 63072e3;
437
+ this.secure = options?.secure ?? defaultSecure();
438
+ this.sameSite = options?.sameSite ?? "Lax";
439
+ }
440
+ getItem(key) {
441
+ if (!hasDocument()) return null;
442
+ const doc = globalThis.document;
443
+ const cookies = doc.cookie ? doc.cookie.split(/;\s*/) : [];
444
+ const prefix = encodeURIComponent(key) + "=";
445
+ for (const c of cookies) {
446
+ if (c.startsWith(prefix)) {
447
+ try {
448
+ return decodeURIComponent(c.slice(prefix.length));
449
+ } catch {
450
+ return null;
451
+ }
452
+ }
453
+ }
454
+ return null;
455
+ }
456
+ setItem(key, value) {
457
+ if (!hasDocument()) return;
458
+ const doc = globalThis.document;
459
+ const parts = [
460
+ `${encodeURIComponent(key)}=${encodeURIComponent(value)}`,
461
+ "Path=/",
462
+ `Max-Age=${this.maxAgeSec}`,
463
+ `SameSite=${this.sameSite}`
464
+ ];
465
+ if (this.secure) parts.push("Secure");
466
+ try {
467
+ doc.cookie = parts.join("; ");
468
+ } catch {
469
+ }
470
+ }
471
+ removeItem(key) {
472
+ if (!hasDocument()) return;
473
+ const doc = globalThis.document;
474
+ const parts = [
475
+ `${encodeURIComponent(key)}=`,
476
+ "Path=/",
477
+ "Max-Age=0",
478
+ `SameSite=${this.sameSite}`
479
+ ];
480
+ if (this.secure) parts.push("Secure");
481
+ try {
482
+ doc.cookie = parts.join("; ");
483
+ } catch {
484
+ }
485
+ }
486
+ };
398
487
  function detectDefaultStorage() {
399
488
  try {
400
489
  const ls = globalThis.localStorage;
@@ -408,6 +497,17 @@ function detectDefaultStorage() {
408
497
  }
409
498
  return new MemoryStorage();
410
499
  }
500
+ function defaultSecure() {
501
+ try {
502
+ const loc = globalThis.location;
503
+ return loc?.protocol === "https:";
504
+ } catch {
505
+ return false;
506
+ }
507
+ }
508
+ function hasDocument() {
509
+ return typeof globalThis.document !== "undefined";
510
+ }
411
511
 
412
512
  // src/device-info.ts
413
513
  function isBrowser() {
@@ -508,6 +608,14 @@ var DEFAULT_AUTO_TRACK = {
508
608
  deviceInfo: true
509
609
  };
510
610
  var SESSION_RESUME_THRESHOLD_MS = 30 * 60 * 1e3;
611
+ var EMPTY_ACQUISITION = {
612
+ utm_source: "",
613
+ utm_medium: "",
614
+ utm_campaign: "",
615
+ utm_content: "",
616
+ utm_term: "",
617
+ referrer: ""
618
+ };
511
619
  var AutoTracker = class {
512
620
  constructor(cfg, track) {
513
621
  this.cfg = cfg;
@@ -543,6 +651,18 @@ var AutoTracker = class {
543
651
  get currentSessionId() {
544
652
  return this.session?.sessionId ?? null;
545
653
  }
654
+ /**
655
+ * Per-session acquisition context — utm_* + referrer, captured once
656
+ * at session start. Returns empty strings when there's no session
657
+ * (Node, before init, after uninstall) so callers can spread without
658
+ * conditional logic. Bank-grade rule: capture once, attach to every
659
+ * event of the session, don't re-read on every track() (the URL
660
+ * changes via SPA pushState; the source-of-record is the URL we
661
+ * landed on).
662
+ */
663
+ get currentAcquisition() {
664
+ return this.session?.acquisition ?? EMPTY_ACQUISITION;
665
+ }
546
666
  // ---------- sessions ----------
547
667
  installSessionTracking() {
548
668
  this.session = this.startNewSession();
@@ -580,7 +700,8 @@ var AutoTracker = class {
580
700
  sessionId: mintSessionId(),
581
701
  startedAt: Date.now(),
582
702
  hiddenAt: null,
583
- endedSent: false
703
+ endedSent: false,
704
+ acquisition: captureAcquisition()
584
705
  };
585
706
  }
586
707
  emitSessionStart() {
@@ -646,6 +767,26 @@ function mintSessionId() {
646
767
  const ts = Date.now().toString(36);
647
768
  return `sess_${ts}${randomChars(10)}`;
648
769
  }
770
+ function captureAcquisition() {
771
+ if (!isBrowserSafe()) return { ...EMPTY_ACQUISITION };
772
+ const result = { ...EMPTY_ACQUISITION };
773
+ try {
774
+ const w = globalThis.window;
775
+ const params = new URLSearchParams(w.location.search ?? "");
776
+ result.utm_source = params.get("utm_source") ?? "";
777
+ result.utm_medium = params.get("utm_medium") ?? "";
778
+ result.utm_campaign = params.get("utm_campaign") ?? "";
779
+ result.utm_content = params.get("utm_content") ?? "";
780
+ result.utm_term = params.get("utm_term") ?? "";
781
+ } catch {
782
+ }
783
+ try {
784
+ const doc = globalThis.document;
785
+ if (typeof doc.referrer === "string") result.referrer = doc.referrer;
786
+ } catch {
787
+ }
788
+ return result;
789
+ }
649
790
 
650
791
  // src/debug.ts
651
792
  var SENSITIVE_KEY_PATTERNS = [
@@ -750,7 +891,11 @@ var CrossdeckClient = class {
750
891
  storagePrefix: options.storagePrefix ?? "crossdeck:",
751
892
  autoHeartbeat: options.autoHeartbeat ?? true,
752
893
  eventFlushBatchSize: options.eventFlushBatchSize ?? 20,
753
- eventFlushIntervalMs: options.eventFlushIntervalMs ?? 5e3,
894
+ // 1500ms idle window. Short enough that an event queued on page
895
+ // load still flushes if the user leaves quickly (the keepalive
896
+ // pagehide handler picks up anything that doesn't); long enough
897
+ // that bursts of clicks coalesce into one network round-trip.
898
+ eventFlushIntervalMs: options.eventFlushIntervalMs ?? 1500,
754
899
  sdkVersion: options.sdkVersion ?? SDK_VERSION,
755
900
  autoTrack,
756
901
  appVersion: options.appVersion ?? null
@@ -763,7 +908,10 @@ var CrossdeckClient = class {
763
908
  sdkVersion: opts.sdkVersion
764
909
  });
765
910
  const effectiveStorage = persistIdentity ? storage : new MemoryStorage();
766
- const identity = new IdentityStore(effectiveStorage, opts.storagePrefix);
911
+ const useCookieRedundancy = persistIdentity && !options.storage && // honour caller's adapter choice
912
+ typeof globalThis.document !== "undefined";
913
+ const cookieStore = useCookieRedundancy ? new CookieStorage() : void 0;
914
+ const identity = new IdentityStore(effectiveStorage, opts.storagePrefix, cookieStore);
767
915
  const entitlements = new EntitlementCache();
768
916
  const events = new EventQueue({
769
917
  http,
@@ -792,7 +940,8 @@ var CrossdeckClient = class {
792
940
  deviceInfo,
793
941
  options: opts,
794
942
  debug,
795
- developerUserId: null
943
+ developerUserId: null,
944
+ uninstallUnloadFlush: null
796
945
  };
797
946
  debug.emit("sdk.configured", `Crossdeck connected to ${opts.appId} in ${opts.environment} mode.`, {
798
947
  appId: opts.appId,
@@ -807,6 +956,9 @@ var CrossdeckClient = class {
807
956
  this.state.autoTracker = tracker;
808
957
  tracker.install();
809
958
  }
959
+ this.state.uninstallUnloadFlush = installUnloadFlush(() => {
960
+ void this.flush({ keepalive: true }).catch(() => void 0);
961
+ });
810
962
  if (opts.autoHeartbeat) {
811
963
  void this.heartbeat().catch(() => void 0);
812
964
  }
@@ -940,6 +1092,15 @@ var CrossdeckClient = class {
940
1092
  const enriched = { ...s.deviceInfo };
941
1093
  const sessionId = s.autoTracker?.currentSessionId;
942
1094
  if (sessionId) enriched.sessionId = sessionId;
1095
+ const acquisition = s.autoTracker?.currentAcquisition;
1096
+ if (acquisition) {
1097
+ if (acquisition.utm_source) enriched.utm_source = acquisition.utm_source;
1098
+ if (acquisition.utm_medium) enriched.utm_medium = acquisition.utm_medium;
1099
+ if (acquisition.utm_campaign) enriched.utm_campaign = acquisition.utm_campaign;
1100
+ if (acquisition.utm_content) enriched.utm_content = acquisition.utm_content;
1101
+ if (acquisition.utm_term) enriched.utm_term = acquisition.utm_term;
1102
+ if (acquisition.referrer) enriched.referrer = acquisition.referrer;
1103
+ }
943
1104
  if (properties) Object.assign(enriched, properties);
944
1105
  const event = {
945
1106
  eventId: this.mintEventId(),
@@ -953,11 +1114,16 @@ var CrossdeckClient = class {
953
1114
  /**
954
1115
  * Force-flush queued events. Useful to call from page-unload handlers.
955
1116
  *
1117
+ * Pass `{ keepalive: true }` from terminal handlers (pagehide /
1118
+ * visibilitychange→hidden / beforeunload). The browser keeps the
1119
+ * request alive after the page tears down, so the final batch
1120
+ * actually lands instead of being cancelled with the unload.
1121
+ *
956
1122
  * NorthStar §4: standard method name across all Crossdeck SDKs.
957
1123
  */
958
- async flush() {
1124
+ async flush(options = {}) {
959
1125
  const s = this.requireStarted();
960
- await s.events.flush();
1126
+ await s.events.flush(options);
961
1127
  }
962
1128
  /** @deprecated Use `flush()` instead. NorthStar §4 standardised the name. */
963
1129
  async flushEvents() {
@@ -1140,6 +1306,23 @@ function resolveAutoTrack(input) {
1140
1306
  deviceInfo: input.deviceInfo ?? DEFAULT_AUTO_TRACK.deviceInfo
1141
1307
  };
1142
1308
  }
1309
+ function installUnloadFlush(onUnload) {
1310
+ const w = globalThis.window;
1311
+ const doc = globalThis.document;
1312
+ if (!w || !doc) return () => void 0;
1313
+ const onVisChange = () => {
1314
+ if (doc.visibilityState === "hidden") onUnload();
1315
+ };
1316
+ const onTerminal = () => onUnload();
1317
+ doc.addEventListener("visibilitychange", onVisChange);
1318
+ w.addEventListener("pagehide", onTerminal);
1319
+ w.addEventListener("beforeunload", onTerminal);
1320
+ return () => {
1321
+ doc.removeEventListener("visibilitychange", onVisChange);
1322
+ w.removeEventListener("pagehide", onTerminal);
1323
+ w.removeEventListener("beforeunload", onTerminal);
1324
+ };
1325
+ }
1143
1326
  export {
1144
1327
  Crossdeck,
1145
1328
  CrossdeckClient,