@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/vue.cjs CHANGED
@@ -92,7 +92,7 @@ function typeMapForStatus(status) {
92
92
  }
93
93
 
94
94
  // src/_version.ts
95
- var SDK_VERSION = "1.5.7";
95
+ var SDK_VERSION = "1.6.1";
96
96
  var SDK_NAME = "@cross-deck/web";
97
97
 
98
98
  // src/http.ts
@@ -1441,6 +1441,8 @@ var DEFAULT_AUTO_TRACK = {
1441
1441
  errors: true
1442
1442
  };
1443
1443
  var SESSION_RESUME_THRESHOLD_MS = 30 * 60 * 1e3;
1444
+ var SESSION_STORAGE_KEY = "crossdeck:session";
1445
+ var ACTIVITY_PERSIST_THROTTLE_MS = 5e3;
1444
1446
  var EMPTY_ACQUISITION = {
1445
1447
  utm_source: "",
1446
1448
  utm_medium: "",
@@ -1456,11 +1458,13 @@ var EMPTY_ACQUISITION = {
1456
1458
  twclid: ""
1457
1459
  };
1458
1460
  var AutoTracker = class {
1459
- constructor(cfg, track) {
1461
+ constructor(cfg, track, opts) {
1460
1462
  this.cfg = cfg;
1461
1463
  this.track = track;
1462
1464
  this.session = null;
1463
1465
  this.cleanups = [];
1466
+ /** Last time we flushed lastActivityAt to storage (throttle gate). */
1467
+ this.lastPersistAt = 0;
1464
1468
  /**
1465
1469
  * Stable per-page-view identifier. Minted at every `page.viewed`
1466
1470
  * emission and attached to every subsequent event until the next
@@ -1472,6 +1476,8 @@ var AutoTracker = class {
1472
1476
  * install if `autoTrack.pageViews !== false`).
1473
1477
  */
1474
1478
  this.pageviewId = null;
1479
+ this.storage = opts?.storage ?? null;
1480
+ this.sessionKey = opts?.storageKey ?? SESSION_STORAGE_KEY;
1475
1481
  }
1476
1482
  install() {
1477
1483
  if (!isBrowserSafe()) return;
@@ -1490,6 +1496,7 @@ var AutoTracker = class {
1490
1496
  if (this.session && !this.session.endedSent) {
1491
1497
  this.emitSessionEnd();
1492
1498
  }
1499
+ this.clearStoredSession();
1493
1500
  this.session = null;
1494
1501
  }
1495
1502
  /** Exposed for tests + consumers that want to reset the session manually. */
@@ -1497,8 +1504,24 @@ var AutoTracker = class {
1497
1504
  if (this.session && !this.session.endedSent) this.emitSessionEnd();
1498
1505
  this.pageviewId = null;
1499
1506
  this.session = this.startNewSession();
1507
+ this.persistSession();
1500
1508
  this.emitSessionStart();
1501
1509
  }
1510
+ /**
1511
+ * Keep the rolling session window alive. Called by the host on EVERY
1512
+ * tracked event (auto or custom) so any activity — not just the
1513
+ * pageviews/clicks AutoTracker emits itself — pushes the 30-min idle
1514
+ * boundary forward. In-memory time is updated exactly; the storage
1515
+ * flush is throttled (pagehide forces a final flush).
1516
+ */
1517
+ markActivity() {
1518
+ if (!this.session) return;
1519
+ const now = Date.now();
1520
+ this.session.lastActivityAt = now;
1521
+ if (now - this.lastPersistAt >= ACTIVITY_PERSIST_THROTTLE_MS) {
1522
+ this.persistSession();
1523
+ }
1524
+ }
1502
1525
  /** Exposed for inspection/tests — returns the current sessionId (or null if not in a session). */
1503
1526
  get currentSessionId() {
1504
1527
  return this.session?.sessionId ?? null;
@@ -1521,26 +1544,43 @@ var AutoTracker = class {
1521
1544
  }
1522
1545
  // ---------- sessions ----------
1523
1546
  installSessionTracking() {
1524
- this.session = this.startNewSession();
1525
- this.emitSessionStart();
1547
+ const now = Date.now();
1548
+ const stored = this.readStoredSession();
1549
+ if (stored && now - stored.lastActivityAt < SESSION_RESUME_THRESHOLD_MS) {
1550
+ this.session = {
1551
+ sessionId: stored.id,
1552
+ startedAt: stored.startedAt,
1553
+ lastActivityAt: now,
1554
+ hiddenAt: null,
1555
+ endedSent: false,
1556
+ acquisition: stored.acquisition
1557
+ };
1558
+ this.persistSession();
1559
+ } else {
1560
+ this.session = this.startNewSession();
1561
+ this.persistSession();
1562
+ this.emitSessionStart();
1563
+ }
1526
1564
  const onVisChange = () => {
1527
1565
  if (!this.session) return;
1528
1566
  const doc2 = globalThis.document;
1529
1567
  if (doc2.visibilityState === "hidden") {
1530
1568
  this.session.hiddenAt = Date.now();
1569
+ this.persistSession();
1531
1570
  } else if (doc2.visibilityState === "visible") {
1532
- const hiddenFor = this.session.hiddenAt ? Date.now() - this.session.hiddenAt : 0;
1533
- if (hiddenFor >= SESSION_RESUME_THRESHOLD_MS) {
1571
+ const idleFor = Date.now() - this.session.lastActivityAt;
1572
+ if (idleFor >= SESSION_RESUME_THRESHOLD_MS) {
1534
1573
  this.emitSessionEnd();
1535
1574
  this.pageviewId = null;
1536
1575
  this.session = this.startNewSession();
1576
+ this.persistSession();
1537
1577
  this.emitSessionStart();
1538
1578
  } else {
1539
1579
  this.session.hiddenAt = null;
1540
1580
  }
1541
1581
  }
1542
1582
  };
1543
- const onPageHide = () => this.emitSessionEnd();
1583
+ const onPageHide = () => this.persistSession();
1544
1584
  const w = globalThis.window;
1545
1585
  const doc = globalThis.document;
1546
1586
  doc.addEventListener("visibilitychange", onVisChange);
@@ -1553,14 +1593,63 @@ var AutoTracker = class {
1553
1593
  });
1554
1594
  }
1555
1595
  startNewSession() {
1596
+ const now = Date.now();
1556
1597
  return {
1557
1598
  sessionId: mintSessionId(),
1558
- startedAt: Date.now(),
1599
+ startedAt: now,
1600
+ lastActivityAt: now,
1559
1601
  hiddenAt: null,
1560
1602
  endedSent: false,
1561
1603
  acquisition: captureAcquisition()
1562
1604
  };
1563
1605
  }
1606
+ /**
1607
+ * Read the persisted session continuity record. Returns null on no
1608
+ * storage, no record, malformed JSON, or a record missing its required
1609
+ * fields — every failure degrades to "no session to resume", which is
1610
+ * the safe (start-fresh) path.
1611
+ */
1612
+ readStoredSession() {
1613
+ if (!this.storage) return null;
1614
+ try {
1615
+ const raw = this.storage.getItem(this.sessionKey);
1616
+ if (!raw) return null;
1617
+ const p = JSON.parse(raw);
1618
+ if (!p || typeof p.id !== "string" || typeof p.startedAt !== "number" || typeof p.lastActivityAt !== "number") {
1619
+ return null;
1620
+ }
1621
+ return {
1622
+ id: p.id,
1623
+ startedAt: p.startedAt,
1624
+ lastActivityAt: p.lastActivityAt,
1625
+ acquisition: p.acquisition && typeof p.acquisition === "object" ? p.acquisition : EMPTY_ACQUISITION
1626
+ };
1627
+ } catch {
1628
+ return null;
1629
+ }
1630
+ }
1631
+ /** Flush the current session to storage. No-op without storage/session. */
1632
+ persistSession() {
1633
+ if (!this.storage || !this.session) return;
1634
+ this.lastPersistAt = Date.now();
1635
+ try {
1636
+ const rec = {
1637
+ id: this.session.sessionId,
1638
+ startedAt: this.session.startedAt,
1639
+ lastActivityAt: this.session.lastActivityAt,
1640
+ acquisition: this.session.acquisition
1641
+ };
1642
+ this.storage.setItem(this.sessionKey, JSON.stringify(rec));
1643
+ } catch {
1644
+ }
1645
+ }
1646
+ clearStoredSession() {
1647
+ if (!this.storage) return;
1648
+ try {
1649
+ this.storage.removeItem(this.sessionKey);
1650
+ } catch {
1651
+ }
1652
+ }
1564
1653
  emitSessionStart() {
1565
1654
  if (!this.session) return;
1566
1655
  this.track("session.started", { sessionId: this.session.sessionId });
@@ -1732,6 +1821,78 @@ function isInsidePasswordField(el) {
1732
1821
  if (el.closest('input[type="password"]')) return true;
1733
1822
  return false;
1734
1823
  }
1824
+ var INLINE_LABEL_TAGS = /* @__PURE__ */ new Set([
1825
+ "span",
1826
+ "b",
1827
+ "strong",
1828
+ "em",
1829
+ "i",
1830
+ "small",
1831
+ "mark",
1832
+ "u",
1833
+ "label",
1834
+ "abbr",
1835
+ "time",
1836
+ "bdi",
1837
+ "cite",
1838
+ "code",
1839
+ "kbd",
1840
+ "q",
1841
+ "sub",
1842
+ "sup"
1843
+ ]);
1844
+ var NON_LABEL_SUBTREES = /* @__PURE__ */ new Set(["svg", "style", "script", "noscript"]);
1845
+ var CONTAINER_MARKERS = "a[href], button, input, select, textarea, [role='button'], [role='link'], h1, h2, h3, h4, h5, h6";
1846
+ var HEADING_SELECTOR = "h1, h2, h3, h4, h5, h6";
1847
+ function collapseWs(s) {
1848
+ return s.replace(/\s+/g, " ").trim();
1849
+ }
1850
+ function boundaryText(el) {
1851
+ let out = "";
1852
+ const walk = (node) => {
1853
+ const kids = node.childNodes;
1854
+ for (let i = 0; i < kids.length; i++) {
1855
+ const child = kids[i];
1856
+ if (!child) continue;
1857
+ if (child.nodeType === 3) {
1858
+ out += child.textContent || "";
1859
+ } else if (child.nodeType === 1) {
1860
+ if (NON_LABEL_SUBTREES.has(child.tagName.toLowerCase())) continue;
1861
+ out += " ";
1862
+ walk(child);
1863
+ out += " ";
1864
+ }
1865
+ }
1866
+ };
1867
+ walk(el);
1868
+ return collapseWs(out);
1869
+ }
1870
+ function directLabel(el) {
1871
+ let out = "";
1872
+ const kids = el.childNodes;
1873
+ for (let i = 0; i < kids.length; i++) {
1874
+ const child = kids[i];
1875
+ if (!child) continue;
1876
+ if (child.nodeType === 3) {
1877
+ out += " " + (child.textContent || "");
1878
+ } else if (child.nodeType === 1 && INLINE_LABEL_TAGS.has(child.tagName.toLowerCase())) {
1879
+ out += " " + boundaryText(child);
1880
+ }
1881
+ }
1882
+ return collapseWs(out);
1883
+ }
1884
+ function visibleLabel(el) {
1885
+ const isContainer = el.querySelector(CONTAINER_MARKERS) !== null;
1886
+ if (!isContainer) return boundaryText(el);
1887
+ const direct = directLabel(el);
1888
+ if (direct) return direct;
1889
+ const heading = el.querySelector(HEADING_SELECTOR);
1890
+ if (heading) {
1891
+ const h = boundaryText(heading);
1892
+ if (h) return h;
1893
+ }
1894
+ return "";
1895
+ }
1735
1896
  function extractText(el) {
1736
1897
  const clean = (s) => s.replace(/\s+/g, " ").trim();
1737
1898
  const explicit = el.getAttribute("data-cd-track") || el.getAttribute("data-track") || el.getAttribute("data-testid");
@@ -1758,7 +1919,7 @@ function extractText(el) {
1758
1919
  const t = clean(el.value);
1759
1920
  if (t) return t;
1760
1921
  }
1761
- const text = clean(el.textContent || "");
1922
+ const text = visibleLabel(el);
1762
1923
  if (text) return text;
1763
1924
  const title = el.getAttribute("title");
1764
1925
  if (title) {
@@ -3305,19 +3466,58 @@ var DEFAULT_ERROR_CAPTURE = {
3305
3466
  "ResizeObserver loop completed with undelivered notifications",
3306
3467
  "Non-Error promise rejection captured"
3307
3468
  // NOTE: We deliberately do NOT drop cross-origin "Script error."
3308
- // events here. They used to be silently filtered as noise, but
3309
- // silent drops are the opposite of "developer sleeps well at
3310
- // night". We now capture them with a clear label and the
3311
- // `cross_origin` tag so the dashboard surfaces them as a
3312
- // distinct, actionable category (the fix is always the same
3313
- // add `crossorigin="anonymous"` to the script tag + CORS
3314
- // headers on the script's origin). Apps that genuinely want
3315
- // them muted can re-add "Script error" to ignoreErrors via
3316
- // init config.
3469
+ // events here. The actionability principle (see denyUrls
3470
+ // comment below) draws the line at "developer cannot act":
3471
+ // cross-origin CORS opacity HAS a real, code-shaped fix (add
3472
+ // `crossorigin="anonymous"` to the script tag + CORS headers
3473
+ // on the script's origin). The dashboard surfaces these with a
3474
+ // `cross_origin` tag pointing at that fix. Apps that genuinely
3475
+ // want them muted can re-add "Script error" to ignoreErrors
3476
+ // via init config.
3317
3477
  ],
3318
3478
  allowUrls: [],
3319
3479
  denyUrls: [
3320
- // Common third-party extensions that pollute error streams.
3480
+ // The actionability principle
3481
+ // ---------------------------
3482
+ // Crossdeck's default philosophy is "classify, don't silently
3483
+ // drop" — surfacing errors the developer can fix is more useful
3484
+ // than hiding them. That's right for cross-origin "Script
3485
+ // error." (real, code-shaped CORS fix) and for plain
3486
+ // application bugs (obviously real).
3487
+ //
3488
+ // It's NOT right for events the developer cannot act on.
3489
+ // - A user's installed browser extension throwing inside
3490
+ // its own `chrome-extension://` URL: the developer can't
3491
+ // ship a fix for someone else's extension.
3492
+ // - An ad blocker preventing `googletagmanager.com` from
3493
+ // loading: the developer can't unblock the user's blocker.
3494
+ //
3495
+ // Capturing these creates a noise tab that's always non-empty
3496
+ // and never actionable, which trains the dev to ignore the
3497
+ // noise tab — and then the actionable noise (CORS opacity,
3498
+ // etc.) gets ignored along with it. Same lesson as Crossdeck's
3499
+ // "0-2 notifications a week" discipline: a signal that fires
3500
+ // constantly with nothing actionable behind it stops being a
3501
+ // signal.
3502
+ //
3503
+ // So we drop at the source for the unactionable category, and
3504
+ // keep capturing for the actionable category. Same principle
3505
+ // applied to two structurally different inputs — not a new
3506
+ // philosophy.
3507
+ //
3508
+ // The bootstrap list below is the minimum that ships in code.
3509
+ // The full, versioned list arrives at boot via /v1/config —
3510
+ // see `backend/src/lib/error-noise-deny-list.ts`. The SDK
3511
+ // applies the union of (bootstrap + remote), so a freshly-
3512
+ // installed SDK protects users immediately, and remote updates
3513
+ // (new ad-network domains, new pixel hosts) reach every
3514
+ // install without an SDK release.
3515
+ //
3516
+ // The backend Layer-2 classifier
3517
+ // (`backend/src/api/lib/noise-classifier.ts`) is the safety
3518
+ // net for events that slip past these patterns — older SDK
3519
+ // versions in the wild that haven't fetched the remote list
3520
+ // yet, or brand-new patterns the list hasn't named.
3321
3521
  /^chrome-extension:\/\//,
3322
3522
  /^moz-extension:\/\//,
3323
3523
  /^safari-extension:\/\//,
@@ -4110,7 +4310,12 @@ var CrossdeckClient = class {
4110
4310
  if (autoTrack.sessions || autoTrack.pageViews) {
4111
4311
  const tracker = new AutoTracker(
4112
4312
  autoTrack,
4113
- (name, properties) => this.track(name, properties)
4313
+ (name, properties) => this.track(name, properties),
4314
+ // Persist session continuity on the SAME adapter as identity, so
4315
+ // a visit survives full-page navigations (multi-page sites
4316
+ // re-install the SDK on every page) and honours the same consent
4317
+ // posture — MemoryStorage when identity persistence is off.
4318
+ { storage: effectiveStorage, storageKey: opts.storagePrefix + "session" }
4114
4319
  );
4115
4320
  this.state.autoTracker = tracker;
4116
4321
  tracker.install();
@@ -4575,6 +4780,12 @@ var CrossdeckClient = class {
4575
4780
  * true even before the session's first network round-trip. Returns
4576
4781
  * false only for a genuinely new install that has never completed a
4577
4782
  * getEntitlements(), or for an entitlement past its own validUntil.
4783
+ *
4784
+ * Throws `not_initialized` (CrossdeckError, type
4785
+ * `configuration_error`) if called before `Crossdeck.init()`. The
4786
+ * `useEntitlement` React hook and the Vue composable both swallow
4787
+ * this and return `false`; bare callers must guard for it (or call
4788
+ * after `init()` resolves).
4578
4789
  */
4579
4790
  isEntitled(key) {
4580
4791
  const s = this.requireStarted();
@@ -4590,8 +4801,21 @@ var CrossdeckClient = class {
4590
4801
  *
4591
4802
  * The listener is invoked AFTER the cache mutates — once after a
4592
4803
  * successful `getEntitlements()` warms it, again after `syncPurchases()`
4593
- * delivers fresh entitlements, and once on `reset()` to fire the
4594
- * empty-cache state for logout flows.
4804
+ * delivers fresh entitlements, once on `reset()` to fire the empty-
4805
+ * cache state for logout flows, AND once on `identify()` after the
4806
+ * per-user cache slot rotates and re-hydrates from device storage.
4807
+ *
4808
+ * IMPORTANT — the `identify()` fire is a TRAP if you treat it as
4809
+ * authoritative network state. `identify()` does NOT fetch entitlements;
4810
+ * it switches the per-user cache slot and rehydrates from device
4811
+ * storage (which is empty for a brand-new install, and last-known-good
4812
+ * — possibly stale — for a returning user). A listener that gates a
4813
+ * paywall on the first fire after an identity switch will read
4814
+ * `false` for a paying customer on a fresh device and let them past
4815
+ * the gate as free. The network-truth fire is the one that follows
4816
+ * the next `getEntitlements()` resolution. Either call
4817
+ * `getEntitlements()` explicitly after `identify()`, or have your
4818
+ * gating code tolerate the empty-then-populated transition.
4595
4819
  *
4596
4820
  * It is NOT invoked synchronously on subscribe. Callers that need
4597
4821
  * the current state should read it via `isEntitled()` / `listEntitlements()`
@@ -4700,6 +4924,7 @@ var CrossdeckClient = class {
4700
4924
  );
4701
4925
  }
4702
4926
  }
4927
+ s.autoTracker?.markActivity();
4703
4928
  const enriched = { ...s.deviceInfo };
4704
4929
  const sessionId = s.autoTracker?.currentSessionId;
4705
4930
  if (sessionId) enriched.sessionId = sessionId;
@@ -4954,6 +5179,25 @@ var CrossdeckClient = class {
4954
5179
  events: s.events.getStats()
4955
5180
  };
4956
5181
  }
5182
+ /**
5183
+ * The stable reference to hand Stripe at checkout so the resulting
5184
+ * purchase attributes to THIS person. Stamp it on the Checkout Session
5185
+ * as `metadata.crossdeck_ref` (see docs/connect-stripe) — the platform
5186
+ * webhook reads it back, validates it, and attaches the subscription to
5187
+ * the right customer instead of stranding it on an anonymous record.
5188
+ *
5189
+ * Returns the strongest identifier the SDK currently holds, in the same
5190
+ * precedence the server resolves by:
5191
+ * crossdeckCustomerId (cdcust_…) > developerUserId > anonymousId
5192
+ * anonymousId is always present, so this always returns a usable,
5193
+ * non-empty reference — even before identify(). Identify the user as
5194
+ * early as you can, though, so the reference is their stable identity
5195
+ * and not a per-device anon id.
5196
+ */
5197
+ getCheckoutReference() {
5198
+ const s = this.requireStarted();
5199
+ return s.identity.crossdeckCustomerId ?? s.developerUserId ?? s.identity.anonymousId;
5200
+ }
4957
5201
  // ---------- private helpers ----------
4958
5202
  requireStarted() {
4959
5203
  if (!this.state) {