@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/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,41 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to `@cross-deck/web` will be documented here. The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
4
4
|
|
|
5
|
+
## [0.6.0] — 2026-05-10
|
|
6
|
+
|
|
7
|
+
Bank-grade analytics enrichment. Two additive changes that close the gap between Crossdeck's analytics surface and Google Analytics 4 / Google Ads dashboards: identity continuity that survives cleared storage, and first-touch acquisition attribution attached to every event of a session. No public API changes — `Crossdeck.init({...})` callsites do not need to change.
|
|
8
|
+
|
|
9
|
+
### Added
|
|
10
|
+
|
|
11
|
+
- **Identity continuity — dual-store redundancy.** The SDK now writes `anonymousId` and `crossdeckCustomerId` to BOTH `localStorage` (primary) and a 1st-party `document.cookie` (secondary). On boot it reads both and prefers primary; if primary is empty, it recovers from the cookie and resyncs primary. This protects against ITP localStorage purges, "clear site data" actions, and aggressive privacy extensions — a returning user keeps the same Crossdeck identity instead of becoming a phantom new visitor on dashboards. See `sdks/SDK_TRUTH.md` § "Identity continuity — bank-grade redundancy" for the full contract.
|
|
12
|
+
- **`CookieStorage` adapter** in `storage.ts`. Sets `Path=/`, `Max-Age=63072000` (2y), `SameSite=Lax`, `Secure` (when over HTTPS — omitted on `http://localhost` so dev works without a TLS cert). Encodes/decodes cookie names + values defensively so embedded `;` and `=` survive round-trip.
|
|
13
|
+
- **First-touch acquisition capture in `AutoTracker`.** On every `session.started` the SDK reads `window.location.search` and `document.referrer` and captures `utm_source`, `utm_medium`, `utm_campaign`, `utm_content`, `utm_term`, plus `referrer`. Non-empty values are auto-attached to every subsequent event of that session — matching GA4's session-pinned attribution semantics. SPA route changes mid-session do NOT re-read the URL; a new session (>30 min idle, or explicit `resetSession()`) re-captures off the current URL.
|
|
14
|
+
- **`AutoTracker.currentAcquisition`** getter. Returns the captured-once-per-session acquisition context for inspection / tests / framework bindings. Returns empty strings (not undefined) when there's no active session so callers can spread without conditional logic.
|
|
15
|
+
- **`captureAcquisition()` exported** from `auto-track.ts` for unit testing acquisition extraction in isolation.
|
|
16
|
+
- **18 new tests** (138 total, up from 120):
|
|
17
|
+
- `storage.test.ts` — 6 cases covering `CookieStorage` round-trip, URL-encoding survival, attribute emission (Path / SameSite / Max-Age / Secure on HTTPS, Secure-omitted on HTTP), null on broken cookies, no-op in Node (no `document`).
|
|
18
|
+
- `identity.test.ts` — 6 cases covering the redundancy contract: writes-to-both, recovery from secondary when primary cleared, recovery from primary when secondary cleared, primary-wins-on-conflict, set/reset both, defence-in-depth against a throwing secondary.
|
|
19
|
+
- `auto-track.test.ts` — 5 cases: `captureAcquisition` reads utm_*, returns empty for clean URLs, `currentAcquisition` is session-pinned (SPA navigation does NOT change it mid-session), `resetSession` re-captures off the current URL, returns empty when no session exists.
|
|
20
|
+
|
|
21
|
+
### Server-side enrichment (lands without an SDK upgrade)
|
|
22
|
+
|
|
23
|
+
The 0.6.0 SDK pairs with these backend changes that started populating ClickHouse columns ahead of this release — every existing 0.5.0 install starts seeing them in dashboards immediately:
|
|
24
|
+
|
|
25
|
+
- **Geography** — `events.country` populated from the Cloudflare `CF-IPCountry` header at `/v1/events`. Server-decided, not client-trusted.
|
|
26
|
+
- **New vs returning** — `events.is_new` populated by a Firestore-transactional `visitors/{anonymousId}` upsert in the ClickHouse projector. First event for a new anonymousId wins the race; concurrent inserts converge.
|
|
27
|
+
- **Device hoist** — `events.browser`, `events.os`, `events.device_class` hoisted out of `properties_json` to first-class LowCardinality columns for fast slicing.
|
|
28
|
+
- **Acquisition columns** — `events.utm_source`, `events.utm_medium`, `events.utm_campaign`, `events.utm_content`, `events.utm_term`, `events.referrer_host` populated from event properties (which the 0.6.0 SDK now sends; pre-0.6.0 events get empty strings).
|
|
29
|
+
- **Sessions** — `sessions` table aggregates the same enrichment columns via `any` / `max` (for `is_new`) so per-session breakdowns don't have to fan out across raw events.
|
|
30
|
+
- ClickHouse migration `006_analytics_columns.sql` is idempotent and additive — old rows already in `events` keep working with empty / 0 defaults.
|
|
31
|
+
|
|
32
|
+
### Privacy posture
|
|
33
|
+
|
|
34
|
+
Privacy posture is unchanged from single-store identity. The cookie holds only the same `anonymousId` already in `localStorage` — no fingerprintable data, no PII. Anything that can read `localStorage` on the same origin can read this cookie; the security model is identical to Stripe, Segment, and PostHog's 1st-party identity cookies. `persistIdentity: false` continues to disable all persistence (in-memory only) for customers running strict consent flows.
|
|
35
|
+
|
|
36
|
+
### Compatibility
|
|
37
|
+
|
|
38
|
+
Source-compatible with 0.5.0. No public API changes. No deprecated symbols. Existing snippets do not need to change.
|
|
39
|
+
|
|
5
40
|
## [0.4.0] — 2026-05-09
|
|
6
41
|
|
|
7
42
|
Reactive entitlements. Pre-0.4.0, calling `Crossdeck.isEntitled("pro")` directly inside a React render path showed the empty-cache result forever — React had no way to know the cache had populated asynchronously after `init()`. This release closes that gap with a first-class subscribe API on the SDK and a React subpackage that uses it.
|
package/README.md
CHANGED
|
@@ -92,6 +92,8 @@ That's the full happy path.
|
|
|
92
92
|
|
|
93
93
|
Every event — auto-tracked and developer-emitted — is enriched with the device-info payload below. Quick tab switches (Cmd-Tab, switching browser tabs) don't end the session — only real closes do, matching GA4's session-window convention.
|
|
94
94
|
|
|
95
|
+
**Per-session acquisition (v0.6.0+):** when a session starts the SDK reads `window.location.search` and `document.referrer` and captures `utm_source`, `utm_medium`, `utm_campaign`, `utm_content`, `utm_term`, plus `referrer`. Non-empty values are auto-attached to every subsequent event of that session — first-touch attribution stays pinned to the entry URL even after SPA route changes strip the params away. A new session (>30 min idle) re-reads the URL.
|
|
96
|
+
|
|
95
97
|
## Auto-attached device info
|
|
96
98
|
|
|
97
99
|
Every event's `properties` is enriched with whatever the SDK can detect:
|
|
@@ -195,11 +197,14 @@ In `debug: true` mode the SDK warns (one signal per call) when property keys loo
|
|
|
195
197
|
Forward purchase evidence (Apple StoreKit 2) directly to Crossdeck for verification — closes the purchase-to-entitled latency from seconds to milliseconds (faster than waiting for the App Store webhook). (`purchaseApple()` is kept as a deprecated alias.)
|
|
196
198
|
|
|
197
199
|
```ts
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
200
|
+
// Inside an async transaction handler — wrap top-level awaits.
|
|
201
|
+
async function forwardTransaction(transaction) {
|
|
202
|
+
await Crossdeck.syncPurchases({
|
|
203
|
+
signedTransactionInfo: transaction.jsonRepresentation, // from StoreKit 2
|
|
204
|
+
signedRenewalInfo: subscription.signedRenewalInfo, // optional
|
|
205
|
+
appAccountToken: "uuid-…", // optional
|
|
206
|
+
});
|
|
207
|
+
}
|
|
203
208
|
```
|
|
204
209
|
|
|
205
210
|
Stripe and Google purchases are verified via webhooks (Stripe Connect platform endpoint, Google Play RTDN) — there's no SDK-side push for those.
|
|
@@ -293,6 +298,23 @@ Publishable keys aren't secrets — they're identifiers, safe to ship in client
|
|
|
293
298
|
- **Env partition** — a `cd_pub_live_…` key cannot read `cd_pub_test_…` data and vice versa.
|
|
294
299
|
- **No raw payment credentials** ever pass through this SDK or sit in a Crossdeck database. Apple `.p8`s, Stripe secret keys, Google service-account JSON — all in Google Cloud Secret Manager, runtime-only access from the Crossdeck backend.
|
|
295
300
|
|
|
301
|
+
## Identity & cookies (v0.6.0+)
|
|
302
|
+
|
|
303
|
+
The SDK persists `anonymousId` and `crossdeckCustomerId` so a returning user keeps the same Crossdeck identity across page loads. By default in browsers it writes to BOTH `localStorage` (primary) and a 1st-party `document.cookie` (secondary, `Path=/`, `Max-Age=2y`, `SameSite=Lax`, `Secure` over HTTPS). The redundancy keeps "10k unique visitors" actually meaning 10k humans even when one store is wiped by ITP, private browsing, or "clear site data."
|
|
304
|
+
|
|
305
|
+
The cookie holds only the same `anonymousId` already in `localStorage` — no fingerprintable data, no PII. Same security posture as Stripe, Segment, and PostHog's 1st-party identity cookies.
|
|
306
|
+
|
|
307
|
+
**Disabling persistence.** Customers running strict consent flows (e.g. cookies disabled until the visitor opts in via a consent banner) should pass `persistIdentity: false` to `Crossdeck.init`. That switches the SDK to in-memory only — no `localStorage`, no cookie, identity is recreated on every page load. Re-`init` with `persistIdentity: true` once consent lands.
|
|
308
|
+
|
|
309
|
+
```ts
|
|
310
|
+
Crossdeck.init({
|
|
311
|
+
appId, publicKey, environment,
|
|
312
|
+
persistIdentity: false, // strict consent — opt in later
|
|
313
|
+
});
|
|
314
|
+
```
|
|
315
|
+
|
|
316
|
+
**Cookie disclosure.** If your privacy policy enumerates cookies, list this one as a "1st-party functional / analytics cookie used to keep the same visitor identity across page loads." The Crossdeck cookie name uses the configured `storagePrefix` (default `crossdeck:`) followed by `anon_id` (and `cdcust_id` once a user signs in).
|
|
317
|
+
|
|
296
318
|
## Versioning
|
|
297
319
|
|
|
298
320
|
This package follows [semver](https://semver.org). The wire-format types (`PublicEntitlement`, `AliasResult`, etc.) are duplicated from the backend's `v1-types.ts` — they're the stable contract, not a shared module. Breaking changes to those types only ship in major versions.
|
package/dist/index.cjs
CHANGED
|
@@ -78,7 +78,7 @@ function typeMapForStatus(status) {
|
|
|
78
78
|
|
|
79
79
|
// src/http.ts
|
|
80
80
|
var SDK_NAME = "@cross-deck/web";
|
|
81
|
-
var SDK_VERSION = "0.
|
|
81
|
+
var SDK_VERSION = "0.6.0";
|
|
82
82
|
var DEFAULT_BASE_URL = "https://api.cross-deck.com/v1";
|
|
83
83
|
var HttpClient = class {
|
|
84
84
|
constructor(config) {
|
|
@@ -110,7 +110,8 @@ var HttpClient = class {
|
|
|
110
110
|
response = await fetch(url, {
|
|
111
111
|
method,
|
|
112
112
|
headers,
|
|
113
|
-
body: bodyInit
|
|
113
|
+
body: bodyInit,
|
|
114
|
+
keepalive: options.keepalive === true
|
|
114
115
|
});
|
|
115
116
|
} catch (err) {
|
|
116
117
|
throw new CrossdeckError({
|
|
@@ -155,19 +156,25 @@ var HttpClient = class {
|
|
|
155
156
|
var KEY_ANON = "anon_id";
|
|
156
157
|
var KEY_CDCUST = "cdcust_id";
|
|
157
158
|
var IdentityStore = class {
|
|
158
|
-
constructor(
|
|
159
|
-
this.
|
|
159
|
+
constructor(primary, prefix, secondary) {
|
|
160
|
+
this.primary = primary;
|
|
160
161
|
this.prefix = prefix;
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
162
|
+
this.secondary = secondary ?? null;
|
|
163
|
+
const anonFromPrimary = primary.getItem(prefix + KEY_ANON);
|
|
164
|
+
const cdcustFromPrimary = primary.getItem(prefix + KEY_CDCUST);
|
|
165
|
+
const anonFromSecondary = this.secondary?.getItem(prefix + KEY_ANON) ?? null;
|
|
166
|
+
const cdcustFromSecondary = this.secondary?.getItem(prefix + KEY_CDCUST) ?? null;
|
|
167
|
+
const anon = anonFromPrimary ?? anonFromSecondary;
|
|
168
|
+
const cdcust = cdcustFromPrimary ?? cdcustFromSecondary;
|
|
165
169
|
this.state = {
|
|
166
|
-
anonymousId:
|
|
167
|
-
crossdeckCustomerId:
|
|
170
|
+
anonymousId: anon ?? this.mintAnonymousId(),
|
|
171
|
+
crossdeckCustomerId: cdcust
|
|
168
172
|
};
|
|
169
|
-
if (!
|
|
170
|
-
|
|
173
|
+
if (!anonFromPrimary || !anonFromSecondary) {
|
|
174
|
+
this.writeBoth(prefix + KEY_ANON, this.state.anonymousId);
|
|
175
|
+
}
|
|
176
|
+
if (cdcust && (!cdcustFromPrimary || !cdcustFromSecondary)) {
|
|
177
|
+
this.writeBoth(prefix + KEY_CDCUST, cdcust);
|
|
171
178
|
}
|
|
172
179
|
}
|
|
173
180
|
/** Return the persisted anonymous device ID (always set). */
|
|
@@ -181,7 +188,7 @@ var IdentityStore = class {
|
|
|
181
188
|
/** Persist a newly-resolved Crossdeck customer ID. */
|
|
182
189
|
setCrossdeckCustomerId(value) {
|
|
183
190
|
this.state.crossdeckCustomerId = value;
|
|
184
|
-
this.
|
|
191
|
+
this.writeBoth(this.prefix + KEY_CDCUST, value);
|
|
185
192
|
}
|
|
186
193
|
/**
|
|
187
194
|
* Wipe persisted identity. Called by reset() — used when an end-user
|
|
@@ -189,13 +196,13 @@ var IdentityStore = class {
|
|
|
189
196
|
* pre-login session is a fresh customer in the identity graph.
|
|
190
197
|
*/
|
|
191
198
|
reset() {
|
|
192
|
-
this.
|
|
193
|
-
this.
|
|
199
|
+
this.deleteBoth(this.prefix + KEY_ANON);
|
|
200
|
+
this.deleteBoth(this.prefix + KEY_CDCUST);
|
|
194
201
|
this.state = {
|
|
195
202
|
anonymousId: this.mintAnonymousId(),
|
|
196
203
|
crossdeckCustomerId: null
|
|
197
204
|
};
|
|
198
|
-
this.
|
|
205
|
+
this.writeBoth(this.prefix + KEY_ANON, this.state.anonymousId);
|
|
199
206
|
}
|
|
200
207
|
/**
|
|
201
208
|
* Generate an anonymousId. Crockford-ish base32 timestamp + random
|
|
@@ -207,6 +214,30 @@ var IdentityStore = class {
|
|
|
207
214
|
const rand = randomChars(10);
|
|
208
215
|
return `anon_${ts}${rand}`;
|
|
209
216
|
}
|
|
217
|
+
writeBoth(key, value) {
|
|
218
|
+
try {
|
|
219
|
+
this.primary.setItem(key, value);
|
|
220
|
+
} catch {
|
|
221
|
+
}
|
|
222
|
+
if (this.secondary) {
|
|
223
|
+
try {
|
|
224
|
+
this.secondary.setItem(key, value);
|
|
225
|
+
} catch {
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
deleteBoth(key) {
|
|
230
|
+
try {
|
|
231
|
+
this.primary.removeItem(key);
|
|
232
|
+
} catch {
|
|
233
|
+
}
|
|
234
|
+
if (this.secondary) {
|
|
235
|
+
try {
|
|
236
|
+
this.secondary.removeItem(key);
|
|
237
|
+
} catch {
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
}
|
|
210
241
|
};
|
|
211
242
|
function randomChars(count) {
|
|
212
243
|
const alphabet = "0123456789abcdefghijklmnopqrstuvwxyz";
|
|
@@ -335,8 +366,12 @@ var EventQueue = class {
|
|
|
335
366
|
* Flush the buffer to /v1/events. Resolves when the network call
|
|
336
367
|
* completes (success or failure). On failure, events stay in the
|
|
337
368
|
* buffer for the next flush attempt.
|
|
369
|
+
*
|
|
370
|
+
* `options.keepalive` marks the underlying fetch as keepalive so the
|
|
371
|
+
* browser keeps the request alive past page unload. Use this for
|
|
372
|
+
* terminal flushes (pagehide / visibilitychange→hidden / beforeunload).
|
|
338
373
|
*/
|
|
339
|
-
async flush() {
|
|
374
|
+
async flush(options = {}) {
|
|
340
375
|
if (this.buffer.length === 0) return null;
|
|
341
376
|
this.cancelTimerIfSet();
|
|
342
377
|
const batch = this.buffer.splice(0);
|
|
@@ -352,7 +387,8 @@ var EventQueue = class {
|
|
|
352
387
|
environment: env.environment,
|
|
353
388
|
sdk: env.sdk,
|
|
354
389
|
events: batch
|
|
355
|
-
}
|
|
390
|
+
},
|
|
391
|
+
keepalive: options.keepalive === true
|
|
356
392
|
});
|
|
357
393
|
this.lastFlushAt = Date.now();
|
|
358
394
|
this.lastError = null;
|
|
@@ -427,6 +463,59 @@ var MemoryStorage = class {
|
|
|
427
463
|
this.store.delete(key);
|
|
428
464
|
}
|
|
429
465
|
};
|
|
466
|
+
var CookieStorage = class {
|
|
467
|
+
constructor(options) {
|
|
468
|
+
this.maxAgeSec = options?.maxAgeSec ?? 63072e3;
|
|
469
|
+
this.secure = options?.secure ?? defaultSecure();
|
|
470
|
+
this.sameSite = options?.sameSite ?? "Lax";
|
|
471
|
+
}
|
|
472
|
+
getItem(key) {
|
|
473
|
+
if (!hasDocument()) return null;
|
|
474
|
+
const doc = globalThis.document;
|
|
475
|
+
const cookies = doc.cookie ? doc.cookie.split(/;\s*/) : [];
|
|
476
|
+
const prefix = encodeURIComponent(key) + "=";
|
|
477
|
+
for (const c of cookies) {
|
|
478
|
+
if (c.startsWith(prefix)) {
|
|
479
|
+
try {
|
|
480
|
+
return decodeURIComponent(c.slice(prefix.length));
|
|
481
|
+
} catch {
|
|
482
|
+
return null;
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
return null;
|
|
487
|
+
}
|
|
488
|
+
setItem(key, value) {
|
|
489
|
+
if (!hasDocument()) return;
|
|
490
|
+
const doc = globalThis.document;
|
|
491
|
+
const parts = [
|
|
492
|
+
`${encodeURIComponent(key)}=${encodeURIComponent(value)}`,
|
|
493
|
+
"Path=/",
|
|
494
|
+
`Max-Age=${this.maxAgeSec}`,
|
|
495
|
+
`SameSite=${this.sameSite}`
|
|
496
|
+
];
|
|
497
|
+
if (this.secure) parts.push("Secure");
|
|
498
|
+
try {
|
|
499
|
+
doc.cookie = parts.join("; ");
|
|
500
|
+
} catch {
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
removeItem(key) {
|
|
504
|
+
if (!hasDocument()) return;
|
|
505
|
+
const doc = globalThis.document;
|
|
506
|
+
const parts = [
|
|
507
|
+
`${encodeURIComponent(key)}=`,
|
|
508
|
+
"Path=/",
|
|
509
|
+
"Max-Age=0",
|
|
510
|
+
`SameSite=${this.sameSite}`
|
|
511
|
+
];
|
|
512
|
+
if (this.secure) parts.push("Secure");
|
|
513
|
+
try {
|
|
514
|
+
doc.cookie = parts.join("; ");
|
|
515
|
+
} catch {
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
};
|
|
430
519
|
function detectDefaultStorage() {
|
|
431
520
|
try {
|
|
432
521
|
const ls = globalThis.localStorage;
|
|
@@ -440,6 +529,17 @@ function detectDefaultStorage() {
|
|
|
440
529
|
}
|
|
441
530
|
return new MemoryStorage();
|
|
442
531
|
}
|
|
532
|
+
function defaultSecure() {
|
|
533
|
+
try {
|
|
534
|
+
const loc = globalThis.location;
|
|
535
|
+
return loc?.protocol === "https:";
|
|
536
|
+
} catch {
|
|
537
|
+
return false;
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
function hasDocument() {
|
|
541
|
+
return typeof globalThis.document !== "undefined";
|
|
542
|
+
}
|
|
443
543
|
|
|
444
544
|
// src/device-info.ts
|
|
445
545
|
function isBrowser() {
|
|
@@ -540,6 +640,14 @@ var DEFAULT_AUTO_TRACK = {
|
|
|
540
640
|
deviceInfo: true
|
|
541
641
|
};
|
|
542
642
|
var SESSION_RESUME_THRESHOLD_MS = 30 * 60 * 1e3;
|
|
643
|
+
var EMPTY_ACQUISITION = {
|
|
644
|
+
utm_source: "",
|
|
645
|
+
utm_medium: "",
|
|
646
|
+
utm_campaign: "",
|
|
647
|
+
utm_content: "",
|
|
648
|
+
utm_term: "",
|
|
649
|
+
referrer: ""
|
|
650
|
+
};
|
|
543
651
|
var AutoTracker = class {
|
|
544
652
|
constructor(cfg, track) {
|
|
545
653
|
this.cfg = cfg;
|
|
@@ -575,6 +683,18 @@ var AutoTracker = class {
|
|
|
575
683
|
get currentSessionId() {
|
|
576
684
|
return this.session?.sessionId ?? null;
|
|
577
685
|
}
|
|
686
|
+
/**
|
|
687
|
+
* Per-session acquisition context — utm_* + referrer, captured once
|
|
688
|
+
* at session start. Returns empty strings when there's no session
|
|
689
|
+
* (Node, before init, after uninstall) so callers can spread without
|
|
690
|
+
* conditional logic. Bank-grade rule: capture once, attach to every
|
|
691
|
+
* event of the session, don't re-read on every track() (the URL
|
|
692
|
+
* changes via SPA pushState; the source-of-record is the URL we
|
|
693
|
+
* landed on).
|
|
694
|
+
*/
|
|
695
|
+
get currentAcquisition() {
|
|
696
|
+
return this.session?.acquisition ?? EMPTY_ACQUISITION;
|
|
697
|
+
}
|
|
578
698
|
// ---------- sessions ----------
|
|
579
699
|
installSessionTracking() {
|
|
580
700
|
this.session = this.startNewSession();
|
|
@@ -612,7 +732,8 @@ var AutoTracker = class {
|
|
|
612
732
|
sessionId: mintSessionId(),
|
|
613
733
|
startedAt: Date.now(),
|
|
614
734
|
hiddenAt: null,
|
|
615
|
-
endedSent: false
|
|
735
|
+
endedSent: false,
|
|
736
|
+
acquisition: captureAcquisition()
|
|
616
737
|
};
|
|
617
738
|
}
|
|
618
739
|
emitSessionStart() {
|
|
@@ -678,6 +799,26 @@ function mintSessionId() {
|
|
|
678
799
|
const ts = Date.now().toString(36);
|
|
679
800
|
return `sess_${ts}${randomChars(10)}`;
|
|
680
801
|
}
|
|
802
|
+
function captureAcquisition() {
|
|
803
|
+
if (!isBrowserSafe()) return { ...EMPTY_ACQUISITION };
|
|
804
|
+
const result = { ...EMPTY_ACQUISITION };
|
|
805
|
+
try {
|
|
806
|
+
const w = globalThis.window;
|
|
807
|
+
const params = new URLSearchParams(w.location.search ?? "");
|
|
808
|
+
result.utm_source = params.get("utm_source") ?? "";
|
|
809
|
+
result.utm_medium = params.get("utm_medium") ?? "";
|
|
810
|
+
result.utm_campaign = params.get("utm_campaign") ?? "";
|
|
811
|
+
result.utm_content = params.get("utm_content") ?? "";
|
|
812
|
+
result.utm_term = params.get("utm_term") ?? "";
|
|
813
|
+
} catch {
|
|
814
|
+
}
|
|
815
|
+
try {
|
|
816
|
+
const doc = globalThis.document;
|
|
817
|
+
if (typeof doc.referrer === "string") result.referrer = doc.referrer;
|
|
818
|
+
} catch {
|
|
819
|
+
}
|
|
820
|
+
return result;
|
|
821
|
+
}
|
|
681
822
|
|
|
682
823
|
// src/debug.ts
|
|
683
824
|
var SENSITIVE_KEY_PATTERNS = [
|
|
@@ -782,7 +923,11 @@ var CrossdeckClient = class {
|
|
|
782
923
|
storagePrefix: options.storagePrefix ?? "crossdeck:",
|
|
783
924
|
autoHeartbeat: options.autoHeartbeat ?? true,
|
|
784
925
|
eventFlushBatchSize: options.eventFlushBatchSize ?? 20,
|
|
785
|
-
|
|
926
|
+
// 1500ms idle window. Short enough that an event queued on page
|
|
927
|
+
// load still flushes if the user leaves quickly (the keepalive
|
|
928
|
+
// pagehide handler picks up anything that doesn't); long enough
|
|
929
|
+
// that bursts of clicks coalesce into one network round-trip.
|
|
930
|
+
eventFlushIntervalMs: options.eventFlushIntervalMs ?? 1500,
|
|
786
931
|
sdkVersion: options.sdkVersion ?? SDK_VERSION,
|
|
787
932
|
autoTrack,
|
|
788
933
|
appVersion: options.appVersion ?? null
|
|
@@ -795,7 +940,10 @@ var CrossdeckClient = class {
|
|
|
795
940
|
sdkVersion: opts.sdkVersion
|
|
796
941
|
});
|
|
797
942
|
const effectiveStorage = persistIdentity ? storage : new MemoryStorage();
|
|
798
|
-
const
|
|
943
|
+
const useCookieRedundancy = persistIdentity && !options.storage && // honour caller's adapter choice
|
|
944
|
+
typeof globalThis.document !== "undefined";
|
|
945
|
+
const cookieStore = useCookieRedundancy ? new CookieStorage() : void 0;
|
|
946
|
+
const identity = new IdentityStore(effectiveStorage, opts.storagePrefix, cookieStore);
|
|
799
947
|
const entitlements = new EntitlementCache();
|
|
800
948
|
const events = new EventQueue({
|
|
801
949
|
http,
|
|
@@ -824,7 +972,8 @@ var CrossdeckClient = class {
|
|
|
824
972
|
deviceInfo,
|
|
825
973
|
options: opts,
|
|
826
974
|
debug,
|
|
827
|
-
developerUserId: null
|
|
975
|
+
developerUserId: null,
|
|
976
|
+
uninstallUnloadFlush: null
|
|
828
977
|
};
|
|
829
978
|
debug.emit("sdk.configured", `Crossdeck connected to ${opts.appId} in ${opts.environment} mode.`, {
|
|
830
979
|
appId: opts.appId,
|
|
@@ -839,6 +988,9 @@ var CrossdeckClient = class {
|
|
|
839
988
|
this.state.autoTracker = tracker;
|
|
840
989
|
tracker.install();
|
|
841
990
|
}
|
|
991
|
+
this.state.uninstallUnloadFlush = installUnloadFlush(() => {
|
|
992
|
+
void this.flush({ keepalive: true }).catch(() => void 0);
|
|
993
|
+
});
|
|
842
994
|
if (opts.autoHeartbeat) {
|
|
843
995
|
void this.heartbeat().catch(() => void 0);
|
|
844
996
|
}
|
|
@@ -972,6 +1124,15 @@ var CrossdeckClient = class {
|
|
|
972
1124
|
const enriched = { ...s.deviceInfo };
|
|
973
1125
|
const sessionId = s.autoTracker?.currentSessionId;
|
|
974
1126
|
if (sessionId) enriched.sessionId = sessionId;
|
|
1127
|
+
const acquisition = s.autoTracker?.currentAcquisition;
|
|
1128
|
+
if (acquisition) {
|
|
1129
|
+
if (acquisition.utm_source) enriched.utm_source = acquisition.utm_source;
|
|
1130
|
+
if (acquisition.utm_medium) enriched.utm_medium = acquisition.utm_medium;
|
|
1131
|
+
if (acquisition.utm_campaign) enriched.utm_campaign = acquisition.utm_campaign;
|
|
1132
|
+
if (acquisition.utm_content) enriched.utm_content = acquisition.utm_content;
|
|
1133
|
+
if (acquisition.utm_term) enriched.utm_term = acquisition.utm_term;
|
|
1134
|
+
if (acquisition.referrer) enriched.referrer = acquisition.referrer;
|
|
1135
|
+
}
|
|
975
1136
|
if (properties) Object.assign(enriched, properties);
|
|
976
1137
|
const event = {
|
|
977
1138
|
eventId: this.mintEventId(),
|
|
@@ -985,11 +1146,16 @@ var CrossdeckClient = class {
|
|
|
985
1146
|
/**
|
|
986
1147
|
* Force-flush queued events. Useful to call from page-unload handlers.
|
|
987
1148
|
*
|
|
1149
|
+
* Pass `{ keepalive: true }` from terminal handlers (pagehide /
|
|
1150
|
+
* visibilitychange→hidden / beforeunload). The browser keeps the
|
|
1151
|
+
* request alive after the page tears down, so the final batch
|
|
1152
|
+
* actually lands instead of being cancelled with the unload.
|
|
1153
|
+
*
|
|
988
1154
|
* NorthStar §4: standard method name across all Crossdeck SDKs.
|
|
989
1155
|
*/
|
|
990
|
-
async flush() {
|
|
1156
|
+
async flush(options = {}) {
|
|
991
1157
|
const s = this.requireStarted();
|
|
992
|
-
await s.events.flush();
|
|
1158
|
+
await s.events.flush(options);
|
|
993
1159
|
}
|
|
994
1160
|
/** @deprecated Use `flush()` instead. NorthStar §4 standardised the name. */
|
|
995
1161
|
async flushEvents() {
|
|
@@ -1172,6 +1338,23 @@ function resolveAutoTrack(input) {
|
|
|
1172
1338
|
deviceInfo: input.deviceInfo ?? DEFAULT_AUTO_TRACK.deviceInfo
|
|
1173
1339
|
};
|
|
1174
1340
|
}
|
|
1341
|
+
function installUnloadFlush(onUnload) {
|
|
1342
|
+
const w = globalThis.window;
|
|
1343
|
+
const doc = globalThis.document;
|
|
1344
|
+
if (!w || !doc) return () => void 0;
|
|
1345
|
+
const onVisChange = () => {
|
|
1346
|
+
if (doc.visibilityState === "hidden") onUnload();
|
|
1347
|
+
};
|
|
1348
|
+
const onTerminal = () => onUnload();
|
|
1349
|
+
doc.addEventListener("visibilitychange", onVisChange);
|
|
1350
|
+
w.addEventListener("pagehide", onTerminal);
|
|
1351
|
+
w.addEventListener("beforeunload", onTerminal);
|
|
1352
|
+
return () => {
|
|
1353
|
+
doc.removeEventListener("visibilitychange", onVisChange);
|
|
1354
|
+
w.removeEventListener("pagehide", onTerminal);
|
|
1355
|
+
w.removeEventListener("beforeunload", onTerminal);
|
|
1356
|
+
};
|
|
1357
|
+
}
|
|
1175
1358
|
// Annotate the CommonJS export names for ESM import in node:
|
|
1176
1359
|
0 && (module.exports = {
|
|
1177
1360
|
Crossdeck,
|