@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/CHANGELOG.md +35 -0
- package/README.md +27 -5
- package/dist/index.cjs +207 -24
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.mts +38 -3
- package/dist/index.d.ts +38 -3
- package/dist/index.mjs +207 -24
- package/dist/index.mjs.map +1 -1
- package/dist/react.cjs +207 -24
- package/dist/react.cjs.map +1 -1
- package/dist/react.mjs +207 -24
- package/dist/react.mjs.map +1 -1
- package/package.json +1 -1
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.
|
|
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(
|
|
155
|
-
this.
|
|
155
|
+
constructor(primary, prefix, secondary) {
|
|
156
|
+
this.primary = primary;
|
|
156
157
|
this.prefix = prefix;
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
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:
|
|
163
|
-
crossdeckCustomerId:
|
|
166
|
+
anonymousId: anon ?? this.mintAnonymousId(),
|
|
167
|
+
crossdeckCustomerId: cdcust
|
|
164
168
|
};
|
|
165
|
-
if (!
|
|
166
|
-
|
|
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.
|
|
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.
|
|
189
|
-
this.
|
|
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.
|
|
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
|
-
|
|
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
|
|
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) {
|