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