@cross-deck/web 1.5.7 → 1.6.1

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
@@ -67,7 +67,7 @@ function typeMapForStatus(status) {
67
67
  }
68
68
 
69
69
  // src/_version.ts
70
- var SDK_VERSION = "1.5.7";
70
+ var SDK_VERSION = "1.6.1";
71
71
  var SDK_NAME = "@cross-deck/web";
72
72
 
73
73
  // src/http.ts
@@ -1416,6 +1416,8 @@ var DEFAULT_AUTO_TRACK = {
1416
1416
  errors: true
1417
1417
  };
1418
1418
  var SESSION_RESUME_THRESHOLD_MS = 30 * 60 * 1e3;
1419
+ var SESSION_STORAGE_KEY = "crossdeck:session";
1420
+ var ACTIVITY_PERSIST_THROTTLE_MS = 5e3;
1419
1421
  var EMPTY_ACQUISITION = {
1420
1422
  utm_source: "",
1421
1423
  utm_medium: "",
@@ -1431,11 +1433,13 @@ var EMPTY_ACQUISITION = {
1431
1433
  twclid: ""
1432
1434
  };
1433
1435
  var AutoTracker = class {
1434
- constructor(cfg, track) {
1436
+ constructor(cfg, track, opts) {
1435
1437
  this.cfg = cfg;
1436
1438
  this.track = track;
1437
1439
  this.session = null;
1438
1440
  this.cleanups = [];
1441
+ /** Last time we flushed lastActivityAt to storage (throttle gate). */
1442
+ this.lastPersistAt = 0;
1439
1443
  /**
1440
1444
  * Stable per-page-view identifier. Minted at every `page.viewed`
1441
1445
  * emission and attached to every subsequent event until the next
@@ -1447,6 +1451,8 @@ var AutoTracker = class {
1447
1451
  * install if `autoTrack.pageViews !== false`).
1448
1452
  */
1449
1453
  this.pageviewId = null;
1454
+ this.storage = opts?.storage ?? null;
1455
+ this.sessionKey = opts?.storageKey ?? SESSION_STORAGE_KEY;
1450
1456
  }
1451
1457
  install() {
1452
1458
  if (!isBrowserSafe()) return;
@@ -1465,6 +1471,7 @@ var AutoTracker = class {
1465
1471
  if (this.session && !this.session.endedSent) {
1466
1472
  this.emitSessionEnd();
1467
1473
  }
1474
+ this.clearStoredSession();
1468
1475
  this.session = null;
1469
1476
  }
1470
1477
  /** Exposed for tests + consumers that want to reset the session manually. */
@@ -1472,8 +1479,24 @@ var AutoTracker = class {
1472
1479
  if (this.session && !this.session.endedSent) this.emitSessionEnd();
1473
1480
  this.pageviewId = null;
1474
1481
  this.session = this.startNewSession();
1482
+ this.persistSession();
1475
1483
  this.emitSessionStart();
1476
1484
  }
1485
+ /**
1486
+ * Keep the rolling session window alive. Called by the host on EVERY
1487
+ * tracked event (auto or custom) so any activity — not just the
1488
+ * pageviews/clicks AutoTracker emits itself — pushes the 30-min idle
1489
+ * boundary forward. In-memory time is updated exactly; the storage
1490
+ * flush is throttled (pagehide forces a final flush).
1491
+ */
1492
+ markActivity() {
1493
+ if (!this.session) return;
1494
+ const now = Date.now();
1495
+ this.session.lastActivityAt = now;
1496
+ if (now - this.lastPersistAt >= ACTIVITY_PERSIST_THROTTLE_MS) {
1497
+ this.persistSession();
1498
+ }
1499
+ }
1477
1500
  /** Exposed for inspection/tests — returns the current sessionId (or null if not in a session). */
1478
1501
  get currentSessionId() {
1479
1502
  return this.session?.sessionId ?? null;
@@ -1496,26 +1519,43 @@ var AutoTracker = class {
1496
1519
  }
1497
1520
  // ---------- sessions ----------
1498
1521
  installSessionTracking() {
1499
- this.session = this.startNewSession();
1500
- this.emitSessionStart();
1522
+ const now = Date.now();
1523
+ const stored = this.readStoredSession();
1524
+ if (stored && now - stored.lastActivityAt < SESSION_RESUME_THRESHOLD_MS) {
1525
+ this.session = {
1526
+ sessionId: stored.id,
1527
+ startedAt: stored.startedAt,
1528
+ lastActivityAt: now,
1529
+ hiddenAt: null,
1530
+ endedSent: false,
1531
+ acquisition: stored.acquisition
1532
+ };
1533
+ this.persistSession();
1534
+ } else {
1535
+ this.session = this.startNewSession();
1536
+ this.persistSession();
1537
+ this.emitSessionStart();
1538
+ }
1501
1539
  const onVisChange = () => {
1502
1540
  if (!this.session) return;
1503
1541
  const doc2 = globalThis.document;
1504
1542
  if (doc2.visibilityState === "hidden") {
1505
1543
  this.session.hiddenAt = Date.now();
1544
+ this.persistSession();
1506
1545
  } else if (doc2.visibilityState === "visible") {
1507
- const hiddenFor = this.session.hiddenAt ? Date.now() - this.session.hiddenAt : 0;
1508
- if (hiddenFor >= SESSION_RESUME_THRESHOLD_MS) {
1546
+ const idleFor = Date.now() - this.session.lastActivityAt;
1547
+ if (idleFor >= SESSION_RESUME_THRESHOLD_MS) {
1509
1548
  this.emitSessionEnd();
1510
1549
  this.pageviewId = null;
1511
1550
  this.session = this.startNewSession();
1551
+ this.persistSession();
1512
1552
  this.emitSessionStart();
1513
1553
  } else {
1514
1554
  this.session.hiddenAt = null;
1515
1555
  }
1516
1556
  }
1517
1557
  };
1518
- const onPageHide = () => this.emitSessionEnd();
1558
+ const onPageHide = () => this.persistSession();
1519
1559
  const w = globalThis.window;
1520
1560
  const doc = globalThis.document;
1521
1561
  doc.addEventListener("visibilitychange", onVisChange);
@@ -1528,14 +1568,63 @@ var AutoTracker = class {
1528
1568
  });
1529
1569
  }
1530
1570
  startNewSession() {
1571
+ const now = Date.now();
1531
1572
  return {
1532
1573
  sessionId: mintSessionId(),
1533
- startedAt: Date.now(),
1574
+ startedAt: now,
1575
+ lastActivityAt: now,
1534
1576
  hiddenAt: null,
1535
1577
  endedSent: false,
1536
1578
  acquisition: captureAcquisition()
1537
1579
  };
1538
1580
  }
1581
+ /**
1582
+ * Read the persisted session continuity record. Returns null on no
1583
+ * storage, no record, malformed JSON, or a record missing its required
1584
+ * fields — every failure degrades to "no session to resume", which is
1585
+ * the safe (start-fresh) path.
1586
+ */
1587
+ readStoredSession() {
1588
+ if (!this.storage) return null;
1589
+ try {
1590
+ const raw = this.storage.getItem(this.sessionKey);
1591
+ if (!raw) return null;
1592
+ const p = JSON.parse(raw);
1593
+ if (!p || typeof p.id !== "string" || typeof p.startedAt !== "number" || typeof p.lastActivityAt !== "number") {
1594
+ return null;
1595
+ }
1596
+ return {
1597
+ id: p.id,
1598
+ startedAt: p.startedAt,
1599
+ lastActivityAt: p.lastActivityAt,
1600
+ acquisition: p.acquisition && typeof p.acquisition === "object" ? p.acquisition : EMPTY_ACQUISITION
1601
+ };
1602
+ } catch {
1603
+ return null;
1604
+ }
1605
+ }
1606
+ /** Flush the current session to storage. No-op without storage/session. */
1607
+ persistSession() {
1608
+ if (!this.storage || !this.session) return;
1609
+ this.lastPersistAt = Date.now();
1610
+ try {
1611
+ const rec = {
1612
+ id: this.session.sessionId,
1613
+ startedAt: this.session.startedAt,
1614
+ lastActivityAt: this.session.lastActivityAt,
1615
+ acquisition: this.session.acquisition
1616
+ };
1617
+ this.storage.setItem(this.sessionKey, JSON.stringify(rec));
1618
+ } catch {
1619
+ }
1620
+ }
1621
+ clearStoredSession() {
1622
+ if (!this.storage) return;
1623
+ try {
1624
+ this.storage.removeItem(this.sessionKey);
1625
+ } catch {
1626
+ }
1627
+ }
1539
1628
  emitSessionStart() {
1540
1629
  if (!this.session) return;
1541
1630
  this.track("session.started", { sessionId: this.session.sessionId });
@@ -1707,6 +1796,78 @@ function isInsidePasswordField(el) {
1707
1796
  if (el.closest('input[type="password"]')) return true;
1708
1797
  return false;
1709
1798
  }
1799
+ var INLINE_LABEL_TAGS = /* @__PURE__ */ new Set([
1800
+ "span",
1801
+ "b",
1802
+ "strong",
1803
+ "em",
1804
+ "i",
1805
+ "small",
1806
+ "mark",
1807
+ "u",
1808
+ "label",
1809
+ "abbr",
1810
+ "time",
1811
+ "bdi",
1812
+ "cite",
1813
+ "code",
1814
+ "kbd",
1815
+ "q",
1816
+ "sub",
1817
+ "sup"
1818
+ ]);
1819
+ var NON_LABEL_SUBTREES = /* @__PURE__ */ new Set(["svg", "style", "script", "noscript"]);
1820
+ var CONTAINER_MARKERS = "a[href], button, input, select, textarea, [role='button'], [role='link'], h1, h2, h3, h4, h5, h6";
1821
+ var HEADING_SELECTOR = "h1, h2, h3, h4, h5, h6";
1822
+ function collapseWs(s) {
1823
+ return s.replace(/\s+/g, " ").trim();
1824
+ }
1825
+ function boundaryText(el) {
1826
+ let out = "";
1827
+ const walk = (node) => {
1828
+ const kids = node.childNodes;
1829
+ for (let i = 0; i < kids.length; i++) {
1830
+ const child = kids[i];
1831
+ if (!child) continue;
1832
+ if (child.nodeType === 3) {
1833
+ out += child.textContent || "";
1834
+ } else if (child.nodeType === 1) {
1835
+ if (NON_LABEL_SUBTREES.has(child.tagName.toLowerCase())) continue;
1836
+ out += " ";
1837
+ walk(child);
1838
+ out += " ";
1839
+ }
1840
+ }
1841
+ };
1842
+ walk(el);
1843
+ return collapseWs(out);
1844
+ }
1845
+ function directLabel(el) {
1846
+ let out = "";
1847
+ const kids = el.childNodes;
1848
+ for (let i = 0; i < kids.length; i++) {
1849
+ const child = kids[i];
1850
+ if (!child) continue;
1851
+ if (child.nodeType === 3) {
1852
+ out += " " + (child.textContent || "");
1853
+ } else if (child.nodeType === 1 && INLINE_LABEL_TAGS.has(child.tagName.toLowerCase())) {
1854
+ out += " " + boundaryText(child);
1855
+ }
1856
+ }
1857
+ return collapseWs(out);
1858
+ }
1859
+ function visibleLabel(el) {
1860
+ const isContainer = el.querySelector(CONTAINER_MARKERS) !== null;
1861
+ if (!isContainer) return boundaryText(el);
1862
+ const direct = directLabel(el);
1863
+ if (direct) return direct;
1864
+ const heading = el.querySelector(HEADING_SELECTOR);
1865
+ if (heading) {
1866
+ const h = boundaryText(heading);
1867
+ if (h) return h;
1868
+ }
1869
+ return "";
1870
+ }
1710
1871
  function extractText(el) {
1711
1872
  const clean = (s) => s.replace(/\s+/g, " ").trim();
1712
1873
  const explicit = el.getAttribute("data-cd-track") || el.getAttribute("data-track") || el.getAttribute("data-testid");
@@ -1733,7 +1894,7 @@ function extractText(el) {
1733
1894
  const t = clean(el.value);
1734
1895
  if (t) return t;
1735
1896
  }
1736
- const text = clean(el.textContent || "");
1897
+ const text = visibleLabel(el);
1737
1898
  if (text) return text;
1738
1899
  const title = el.getAttribute("title");
1739
1900
  if (title) {
@@ -3280,19 +3441,58 @@ var DEFAULT_ERROR_CAPTURE = {
3280
3441
  "ResizeObserver loop completed with undelivered notifications",
3281
3442
  "Non-Error promise rejection captured"
3282
3443
  // NOTE: We deliberately do NOT drop cross-origin "Script error."
3283
- // events here. They used to be silently filtered as noise, but
3284
- // silent drops are the opposite of "developer sleeps well at
3285
- // night". We now capture them with a clear label and the
3286
- // `cross_origin` tag so the dashboard surfaces them as a
3287
- // distinct, actionable category (the fix is always the same
3288
- // add `crossorigin="anonymous"` to the script tag + CORS
3289
- // headers on the script's origin). Apps that genuinely want
3290
- // them muted can re-add "Script error" to ignoreErrors via
3291
- // init config.
3444
+ // events here. The actionability principle (see denyUrls
3445
+ // comment below) draws the line at "developer cannot act":
3446
+ // cross-origin CORS opacity HAS a real, code-shaped fix (add
3447
+ // `crossorigin="anonymous"` to the script tag + CORS headers
3448
+ // on the script's origin). The dashboard surfaces these with a
3449
+ // `cross_origin` tag pointing at that fix. Apps that genuinely
3450
+ // want them muted can re-add "Script error" to ignoreErrors
3451
+ // via init config.
3292
3452
  ],
3293
3453
  allowUrls: [],
3294
3454
  denyUrls: [
3295
- // Common third-party extensions that pollute error streams.
3455
+ // The actionability principle
3456
+ // ---------------------------
3457
+ // Crossdeck's default philosophy is "classify, don't silently
3458
+ // drop" — surfacing errors the developer can fix is more useful
3459
+ // than hiding them. That's right for cross-origin "Script
3460
+ // error." (real, code-shaped CORS fix) and for plain
3461
+ // application bugs (obviously real).
3462
+ //
3463
+ // It's NOT right for events the developer cannot act on.
3464
+ // - A user's installed browser extension throwing inside
3465
+ // its own `chrome-extension://` URL: the developer can't
3466
+ // ship a fix for someone else's extension.
3467
+ // - An ad blocker preventing `googletagmanager.com` from
3468
+ // loading: the developer can't unblock the user's blocker.
3469
+ //
3470
+ // Capturing these creates a noise tab that's always non-empty
3471
+ // and never actionable, which trains the dev to ignore the
3472
+ // noise tab — and then the actionable noise (CORS opacity,
3473
+ // etc.) gets ignored along with it. Same lesson as Crossdeck's
3474
+ // "0-2 notifications a week" discipline: a signal that fires
3475
+ // constantly with nothing actionable behind it stops being a
3476
+ // signal.
3477
+ //
3478
+ // So we drop at the source for the unactionable category, and
3479
+ // keep capturing for the actionable category. Same principle
3480
+ // applied to two structurally different inputs — not a new
3481
+ // philosophy.
3482
+ //
3483
+ // The bootstrap list below is the minimum that ships in code.
3484
+ // The full, versioned list arrives at boot via /v1/config —
3485
+ // see `backend/src/lib/error-noise-deny-list.ts`. The SDK
3486
+ // applies the union of (bootstrap + remote), so a freshly-
3487
+ // installed SDK protects users immediately, and remote updates
3488
+ // (new ad-network domains, new pixel hosts) reach every
3489
+ // install without an SDK release.
3490
+ //
3491
+ // The backend Layer-2 classifier
3492
+ // (`backend/src/api/lib/noise-classifier.ts`) is the safety
3493
+ // net for events that slip past these patterns — older SDK
3494
+ // versions in the wild that haven't fetched the remote list
3495
+ // yet, or brand-new patterns the list hasn't named.
3296
3496
  /^chrome-extension:\/\//,
3297
3497
  /^moz-extension:\/\//,
3298
3498
  /^safari-extension:\/\//,
@@ -4085,7 +4285,12 @@ var CrossdeckClient = class {
4085
4285
  if (autoTrack.sessions || autoTrack.pageViews) {
4086
4286
  const tracker = new AutoTracker(
4087
4287
  autoTrack,
4088
- (name, properties) => this.track(name, properties)
4288
+ (name, properties) => this.track(name, properties),
4289
+ // Persist session continuity on the SAME adapter as identity, so
4290
+ // a visit survives full-page navigations (multi-page sites
4291
+ // re-install the SDK on every page) and honours the same consent
4292
+ // posture — MemoryStorage when identity persistence is off.
4293
+ { storage: effectiveStorage, storageKey: opts.storagePrefix + "session" }
4089
4294
  );
4090
4295
  this.state.autoTracker = tracker;
4091
4296
  tracker.install();
@@ -4550,6 +4755,12 @@ var CrossdeckClient = class {
4550
4755
  * true even before the session's first network round-trip. Returns
4551
4756
  * false only for a genuinely new install that has never completed a
4552
4757
  * getEntitlements(), or for an entitlement past its own validUntil.
4758
+ *
4759
+ * Throws `not_initialized` (CrossdeckError, type
4760
+ * `configuration_error`) if called before `Crossdeck.init()`. The
4761
+ * `useEntitlement` React hook and the Vue composable both swallow
4762
+ * this and return `false`; bare callers must guard for it (or call
4763
+ * after `init()` resolves).
4553
4764
  */
4554
4765
  isEntitled(key) {
4555
4766
  const s = this.requireStarted();
@@ -4565,8 +4776,21 @@ var CrossdeckClient = class {
4565
4776
  *
4566
4777
  * The listener is invoked AFTER the cache mutates — once after a
4567
4778
  * successful `getEntitlements()` warms it, again after `syncPurchases()`
4568
- * delivers fresh entitlements, and once on `reset()` to fire the
4569
- * empty-cache state for logout flows.
4779
+ * delivers fresh entitlements, once on `reset()` to fire the empty-
4780
+ * cache state for logout flows, AND once on `identify()` after the
4781
+ * per-user cache slot rotates and re-hydrates from device storage.
4782
+ *
4783
+ * IMPORTANT — the `identify()` fire is a TRAP if you treat it as
4784
+ * authoritative network state. `identify()` does NOT fetch entitlements;
4785
+ * it switches the per-user cache slot and rehydrates from device
4786
+ * storage (which is empty for a brand-new install, and last-known-good
4787
+ * — possibly stale — for a returning user). A listener that gates a
4788
+ * paywall on the first fire after an identity switch will read
4789
+ * `false` for a paying customer on a fresh device and let them past
4790
+ * the gate as free. The network-truth fire is the one that follows
4791
+ * the next `getEntitlements()` resolution. Either call
4792
+ * `getEntitlements()` explicitly after `identify()`, or have your
4793
+ * gating code tolerate the empty-then-populated transition.
4570
4794
  *
4571
4795
  * It is NOT invoked synchronously on subscribe. Callers that need
4572
4796
  * the current state should read it via `isEntitled()` / `listEntitlements()`
@@ -4675,6 +4899,7 @@ var CrossdeckClient = class {
4675
4899
  );
4676
4900
  }
4677
4901
  }
4902
+ s.autoTracker?.markActivity();
4678
4903
  const enriched = { ...s.deviceInfo };
4679
4904
  const sessionId = s.autoTracker?.currentSessionId;
4680
4905
  if (sessionId) enriched.sessionId = sessionId;
@@ -4929,6 +5154,25 @@ var CrossdeckClient = class {
4929
5154
  events: s.events.getStats()
4930
5155
  };
4931
5156
  }
5157
+ /**
5158
+ * The stable reference to hand Stripe at checkout so the resulting
5159
+ * purchase attributes to THIS person. Stamp it on the Checkout Session
5160
+ * as `metadata.crossdeck_ref` (see docs/connect-stripe) — the platform
5161
+ * webhook reads it back, validates it, and attaches the subscription to
5162
+ * the right customer instead of stranding it on an anonymous record.
5163
+ *
5164
+ * Returns the strongest identifier the SDK currently holds, in the same
5165
+ * precedence the server resolves by:
5166
+ * crossdeckCustomerId (cdcust_…) > developerUserId > anonymousId
5167
+ * anonymousId is always present, so this always returns a usable,
5168
+ * non-empty reference — even before identify(). Identify the user as
5169
+ * early as you can, though, so the reference is their stable identity
5170
+ * and not a per-device anon id.
5171
+ */
5172
+ getCheckoutReference() {
5173
+ const s = this.requireStarted();
5174
+ return s.identity.crossdeckCustomerId ?? s.developerUserId ?? s.identity.anonymousId;
5175
+ }
4932
5176
  // ---------- private helpers ----------
4933
5177
  requireStarted() {
4934
5178
  if (!this.state) {