@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/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.3";
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) {
@@ -2399,7 +2560,7 @@ var BreadcrumbBuffer = class {
2399
2560
  };
2400
2561
 
2401
2562
  // src/_diagnostic-telemetry.ts
2402
- var DIAGNOSTIC_TELEMETRY_ENDPOINT = "https://api.cross-deck.com/v1/sdk/diagnostic";
2563
+ var DIAGNOSTIC_TELEMETRY_ENDPOINT = "https://api.cross-deck.com/v1/events";
2403
2564
  var DIAGNOSTIC_TELEMETRY_PUBLISHABLE_KEY = "cd_pub_live_9490e7aa029c432abf";
2404
2565
  function isDiagnosticTelemetryEnabled() {
2405
2566
  return !DIAGNOSTIC_TELEMETRY_PUBLISHABLE_KEY.startsWith(
@@ -2437,7 +2598,15 @@ function sendDiagnosticTelemetry(payload) {
2437
2598
  if (!isDiagnosticTelemetryEnabled()) return;
2438
2599
  const filtered = filterDiagnosticPayload(payload);
2439
2600
  if (Object.keys(filtered).length === 0) return;
2440
- const body = JSON.stringify(filtered);
2601
+ const body = JSON.stringify({
2602
+ events: [
2603
+ {
2604
+ name: "crossdeck.contract_failed",
2605
+ timestamp: Date.now(),
2606
+ properties: filtered
2607
+ }
2608
+ ]
2609
+ });
2441
2610
  const f = globalThis.fetch;
2442
2611
  if (typeof f !== "function") return;
2443
2612
  try {
@@ -3297,19 +3466,58 @@ var DEFAULT_ERROR_CAPTURE = {
3297
3466
  "ResizeObserver loop completed with undelivered notifications",
3298
3467
  "Non-Error promise rejection captured"
3299
3468
  // NOTE: We deliberately do NOT drop cross-origin "Script error."
3300
- // events here. They used to be silently filtered as noise, but
3301
- // silent drops are the opposite of "developer sleeps well at
3302
- // night". We now capture them with a clear label and the
3303
- // `cross_origin` tag so the dashboard surfaces them as a
3304
- // distinct, actionable category (the fix is always the same
3305
- // add `crossorigin="anonymous"` to the script tag + CORS
3306
- // headers on the script's origin). Apps that genuinely want
3307
- // them muted can re-add "Script error" to ignoreErrors via
3308
- // 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.
3309
3477
  ],
3310
3478
  allowUrls: [],
3311
3479
  denyUrls: [
3312
- // 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.
3313
3521
  /^chrome-extension:\/\//,
3314
3522
  /^moz-extension:\/\//,
3315
3523
  /^safari-extension:\/\//,
@@ -4102,7 +4310,12 @@ var CrossdeckClient = class {
4102
4310
  if (autoTrack.sessions || autoTrack.pageViews) {
4103
4311
  const tracker = new AutoTracker(
4104
4312
  autoTrack,
4105
- (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" }
4106
4319
  );
4107
4320
  this.state.autoTracker = tracker;
4108
4321
  tracker.install();
@@ -4567,6 +4780,12 @@ var CrossdeckClient = class {
4567
4780
  * true even before the session's first network round-trip. Returns
4568
4781
  * false only for a genuinely new install that has never completed a
4569
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).
4570
4789
  */
4571
4790
  isEntitled(key) {
4572
4791
  const s = this.requireStarted();
@@ -4582,8 +4801,21 @@ var CrossdeckClient = class {
4582
4801
  *
4583
4802
  * The listener is invoked AFTER the cache mutates — once after a
4584
4803
  * successful `getEntitlements()` warms it, again after `syncPurchases()`
4585
- * delivers fresh entitlements, and once on `reset()` to fire the
4586
- * 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.
4587
4819
  *
4588
4820
  * It is NOT invoked synchronously on subscribe. Callers that need
4589
4821
  * the current state should read it via `isEntitled()` / `listEntitlements()`
@@ -4692,6 +4924,7 @@ var CrossdeckClient = class {
4692
4924
  );
4693
4925
  }
4694
4926
  }
4927
+ s.autoTracker?.markActivity();
4695
4928
  const enriched = { ...s.deviceInfo };
4696
4929
  const sessionId = s.autoTracker?.currentSessionId;
4697
4930
  if (sessionId) enriched.sessionId = sessionId;
@@ -4737,7 +4970,7 @@ var CrossdeckClient = class {
4737
4970
  callerProperties: validation.properties,
4738
4971
  superProperties: supers,
4739
4972
  deviceProperties: s.deviceInfo,
4740
- mergedProperties: finalProperties
4973
+ mergedProperties: enriched
4741
4974
  });
4742
4975
  }
4743
4976
  if (!isError && !isWebVital) {
@@ -4946,6 +5179,25 @@ var CrossdeckClient = class {
4946
5179
  events: s.events.getStats()
4947
5180
  };
4948
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
+ }
4949
5201
  // ---------- private helpers ----------
4950
5202
  requireStarted() {
4951
5203
  if (!this.state) {