@cross-deck/web 0.6.0 → 0.7.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/dist/index.cjs +271 -12
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.mts +23 -1
- package/dist/index.d.ts +23 -1
- package/dist/index.mjs +271 -12
- package/dist/index.mjs.map +1 -1
- package/dist/react.cjs +271 -12
- package/dist/react.cjs.map +1 -1
- package/dist/react.mjs +271 -12
- package/dist/react.mjs.map +1 -1
- package/package.json +1 -1
package/dist/react.mjs
CHANGED
|
@@ -65,6 +65,9 @@ var HttpClient = class {
|
|
|
65
65
|
* - JSON parse failure on a 2xx (treated as `internal_error`)
|
|
66
66
|
*/
|
|
67
67
|
async request(method, path, options = {}) {
|
|
68
|
+
if (this.config.localDevMode) {
|
|
69
|
+
return synthesizeLocalDevResponse(path);
|
|
70
|
+
}
|
|
68
71
|
const url = this.buildUrl(path, options.query);
|
|
69
72
|
const headers = {
|
|
70
73
|
Authorization: `Bearer ${this.config.publicKey}`,
|
|
@@ -107,6 +110,14 @@ var HttpClient = class {
|
|
|
107
110
|
});
|
|
108
111
|
}
|
|
109
112
|
}
|
|
113
|
+
/**
|
|
114
|
+
* Whether this client is in localhost dev-mode short-circuit. Used
|
|
115
|
+
* by other SDK pieces (event-queue) to skip network-bound work
|
|
116
|
+
* entirely rather than going through synthesizeLocalDevResponse.
|
|
117
|
+
*/
|
|
118
|
+
get isLocalDevMode() {
|
|
119
|
+
return this.config.localDevMode === true;
|
|
120
|
+
}
|
|
110
121
|
buildUrl(path, query) {
|
|
111
122
|
const base = this.config.baseUrl.replace(/\/+$/, "");
|
|
112
123
|
const cleanPath = path.startsWith("/") ? path : `/${path}`;
|
|
@@ -122,6 +133,49 @@ var HttpClient = class {
|
|
|
122
133
|
return url;
|
|
123
134
|
}
|
|
124
135
|
};
|
|
136
|
+
var cachedLocalCdcust = null;
|
|
137
|
+
function synthesizeLocalDevResponse(path) {
|
|
138
|
+
if (path.startsWith("/sdk/heartbeat")) {
|
|
139
|
+
return {
|
|
140
|
+
object: "heartbeat",
|
|
141
|
+
ok: true,
|
|
142
|
+
projectId: "proj_local_dev",
|
|
143
|
+
appId: "app_local_dev",
|
|
144
|
+
platform: "web",
|
|
145
|
+
env: "sandbox",
|
|
146
|
+
serverTime: Date.now()
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
if (path.startsWith("/identity/alias")) {
|
|
150
|
+
if (!cachedLocalCdcust) {
|
|
151
|
+
const tail = typeof crypto !== "undefined" && "randomUUID" in crypto ? crypto.randomUUID().replace(/-/g, "").slice(0, 16) : Math.random().toString(36).slice(2, 18);
|
|
152
|
+
cachedLocalCdcust = `cdcust_local_${tail}`;
|
|
153
|
+
}
|
|
154
|
+
return {
|
|
155
|
+
object: "alias_result",
|
|
156
|
+
crossdeckCustomerId: cachedLocalCdcust,
|
|
157
|
+
linked: [],
|
|
158
|
+
mergePending: false,
|
|
159
|
+
env: "sandbox"
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
if (path.startsWith("/entitlements")) {
|
|
163
|
+
return {
|
|
164
|
+
object: "list",
|
|
165
|
+
data: [],
|
|
166
|
+
crossdeckCustomerId: cachedLocalCdcust ?? "",
|
|
167
|
+
env: "sandbox"
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
if (path.startsWith("/events")) {
|
|
171
|
+
return {
|
|
172
|
+
object: "list",
|
|
173
|
+
received: 0,
|
|
174
|
+
env: "sandbox"
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
return {};
|
|
178
|
+
}
|
|
125
179
|
|
|
126
180
|
// src/identity.ts
|
|
127
181
|
var KEY_ANON = "anon_id";
|
|
@@ -608,7 +662,8 @@ function parseUserAgent(ua) {
|
|
|
608
662
|
var DEFAULT_AUTO_TRACK = {
|
|
609
663
|
sessions: true,
|
|
610
664
|
pageViews: true,
|
|
611
|
-
deviceInfo: true
|
|
665
|
+
deviceInfo: true,
|
|
666
|
+
clicks: true
|
|
612
667
|
};
|
|
613
668
|
var SESSION_RESUME_THRESHOLD_MS = 30 * 60 * 1e3;
|
|
614
669
|
var EMPTY_ACQUISITION = {
|
|
@@ -630,6 +685,7 @@ var AutoTracker = class {
|
|
|
630
685
|
if (!isBrowserSafe()) return;
|
|
631
686
|
if (this.cfg.sessions) this.installSessionTracking();
|
|
632
687
|
if (this.cfg.pageViews) this.installPageViewTracking();
|
|
688
|
+
if (this.cfg.clicks) this.installClickTracking();
|
|
633
689
|
}
|
|
634
690
|
uninstall() {
|
|
635
691
|
while (this.cleanups.length) {
|
|
@@ -724,11 +780,19 @@ var AutoTracker = class {
|
|
|
724
780
|
installPageViewTracking() {
|
|
725
781
|
const w = globalThis.window;
|
|
726
782
|
const doc = globalThis.document;
|
|
727
|
-
|
|
783
|
+
let lastFiredAt = 0;
|
|
784
|
+
let lastFiredUrl = "";
|
|
785
|
+
const DEDUP_WINDOW_MS = 250;
|
|
786
|
+
const fire = (force = false) => {
|
|
728
787
|
const loc = w.location;
|
|
788
|
+
const url = loc.href;
|
|
789
|
+
const now = Date.now();
|
|
790
|
+
if (!force && url === lastFiredUrl && now - lastFiredAt < DEDUP_WINDOW_MS) return;
|
|
791
|
+
lastFiredAt = now;
|
|
792
|
+
lastFiredUrl = url;
|
|
729
793
|
this.track("page.viewed", {
|
|
730
794
|
path: loc.pathname,
|
|
731
|
-
url
|
|
795
|
+
url,
|
|
732
796
|
search: loc.search || void 0,
|
|
733
797
|
hash: loc.hash || void 0,
|
|
734
798
|
title: doc.title,
|
|
@@ -750,7 +814,7 @@ var AutoTracker = class {
|
|
|
750
814
|
}
|
|
751
815
|
w.history.pushState = patchedPush;
|
|
752
816
|
w.history.replaceState = patchedReplace;
|
|
753
|
-
const onPopState = () => fire();
|
|
817
|
+
const onPopState = () => fire(true);
|
|
754
818
|
w.addEventListener("popstate", onPopState);
|
|
755
819
|
this.cleanups.push(() => {
|
|
756
820
|
if (w.history.pushState === patchedPush) {
|
|
@@ -762,7 +826,156 @@ var AutoTracker = class {
|
|
|
762
826
|
w.removeEventListener("popstate", onPopState);
|
|
763
827
|
});
|
|
764
828
|
}
|
|
829
|
+
// ---------- click autocapture ----------
|
|
830
|
+
/**
|
|
831
|
+
* Global click tracking — Mixpanel / Amplitude style autocapture.
|
|
832
|
+
* Fires `element.clicked` for every interactive click with the
|
|
833
|
+
* target element's selector path, text content, tag, href, data-*
|
|
834
|
+
* attributes, and viewport coordinates. Powers the funnel /
|
|
835
|
+
* attribution USP: "users who clicked X then converted within
|
|
836
|
+
* 7 days." Default ON because behavioural attribution is the
|
|
837
|
+
* core product promise.
|
|
838
|
+
*
|
|
839
|
+
* Privacy guardrails:
|
|
840
|
+
* - Skip clicks ON inputs / textareas / selects (form interaction
|
|
841
|
+
* isn't button telemetry; the dev should track form submits
|
|
842
|
+
* deliberately via track('form_submitted'))
|
|
843
|
+
* - Skip clicks INSIDE [type="password"] and password-class
|
|
844
|
+
* elements
|
|
845
|
+
* - Skip clicks inside elements opted out via class="cd-noTrack"
|
|
846
|
+
* or data-cd-noTrack attribute (Mixpanel's exact opt-out
|
|
847
|
+
* idiom — most devs already know it)
|
|
848
|
+
* - Capture text content but cap at 64 chars and trim — never
|
|
849
|
+
* more than what you'd see on a button label
|
|
850
|
+
*
|
|
851
|
+
* Volume guardrails:
|
|
852
|
+
* - Coalesce double-clicks within 100ms (React's synthetic click
|
|
853
|
+
* pattern + browser's native dblclick can fire twice)
|
|
854
|
+
* - Listen on document at capture phase so we see the click
|
|
855
|
+
* before any framework's own handlers stop propagation
|
|
856
|
+
*/
|
|
857
|
+
installClickTracking() {
|
|
858
|
+
const w = globalThis.window;
|
|
859
|
+
const doc = globalThis.document;
|
|
860
|
+
let lastFiredAt = 0;
|
|
861
|
+
let lastFiredTarget = null;
|
|
862
|
+
const COALESCE_MS = 100;
|
|
863
|
+
const TEXT_CAP = 64;
|
|
864
|
+
const onClick = (ev) => {
|
|
865
|
+
const target = ev.target;
|
|
866
|
+
if (!target || !(target instanceof Element)) return;
|
|
867
|
+
const now = Date.now();
|
|
868
|
+
if (target === lastFiredTarget && now - lastFiredAt < COALESCE_MS) return;
|
|
869
|
+
lastFiredAt = now;
|
|
870
|
+
lastFiredTarget = target;
|
|
871
|
+
const actionable = closestActionable(target);
|
|
872
|
+
const clicked = actionable || target;
|
|
873
|
+
if (isFormInput(clicked)) return;
|
|
874
|
+
if (isInOptedOut(clicked)) return;
|
|
875
|
+
if (isInsidePasswordField(clicked)) return;
|
|
876
|
+
const tag = clicked.tagName.toLowerCase();
|
|
877
|
+
const text = trimText(extractText(clicked), TEXT_CAP);
|
|
878
|
+
const href = clicked.href || void 0;
|
|
879
|
+
const linkTarget = clicked.target || void 0;
|
|
880
|
+
const elementId = clicked.id || void 0;
|
|
881
|
+
const role = clicked.getAttribute("role") || void 0;
|
|
882
|
+
const ariaLabel = clicked.getAttribute("aria-label") || void 0;
|
|
883
|
+
const selector = buildSelector(clicked);
|
|
884
|
+
const dataAttrs = collectDataAttrs(clicked);
|
|
885
|
+
const isLink = tag === "a" && !!href;
|
|
886
|
+
const explicitName = clicked.getAttribute("data-cd-event");
|
|
887
|
+
const props = {
|
|
888
|
+
selector,
|
|
889
|
+
tag,
|
|
890
|
+
text,
|
|
891
|
+
elementId,
|
|
892
|
+
role,
|
|
893
|
+
ariaLabel,
|
|
894
|
+
href,
|
|
895
|
+
isLink,
|
|
896
|
+
linkTarget,
|
|
897
|
+
viewportX: ev.clientX,
|
|
898
|
+
viewportY: ev.clientY,
|
|
899
|
+
pageX: ev.pageX,
|
|
900
|
+
pageY: ev.pageY,
|
|
901
|
+
...dataAttrs
|
|
902
|
+
};
|
|
903
|
+
for (const k of Object.keys(props)) {
|
|
904
|
+
if (props[k] === void 0 || props[k] === null || props[k] === "") delete props[k];
|
|
905
|
+
}
|
|
906
|
+
this.track(explicitName || "element.clicked", props);
|
|
907
|
+
};
|
|
908
|
+
doc.addEventListener("click", onClick, { capture: true, passive: true });
|
|
909
|
+
this.cleanups.push(() => {
|
|
910
|
+
doc.removeEventListener("click", onClick, { capture: true });
|
|
911
|
+
});
|
|
912
|
+
}
|
|
765
913
|
};
|
|
914
|
+
function closestActionable(el) {
|
|
915
|
+
return el.closest("[data-cd-event]") || el.closest("[data-cd-noTrack]") || el.closest("button, a, [role='button'], [role='link'], input[type='button'], input[type='submit']") || null;
|
|
916
|
+
}
|
|
917
|
+
function isFormInput(el) {
|
|
918
|
+
if (!(el instanceof HTMLElement)) return false;
|
|
919
|
+
const tag = el.tagName.toLowerCase();
|
|
920
|
+
if (tag === "textarea" || tag === "select") return true;
|
|
921
|
+
if (tag === "input") {
|
|
922
|
+
const type = (el.type || "").toLowerCase();
|
|
923
|
+
return type !== "button" && type !== "submit" && type !== "image" && type !== "reset";
|
|
924
|
+
}
|
|
925
|
+
return false;
|
|
926
|
+
}
|
|
927
|
+
function isInOptedOut(el) {
|
|
928
|
+
if (el.closest("[data-cd-noTrack], [data-cd-no-track], .cd-noTrack, .cd-no-track")) return true;
|
|
929
|
+
return false;
|
|
930
|
+
}
|
|
931
|
+
function isInsidePasswordField(el) {
|
|
932
|
+
if (el.closest('input[type="password"]')) return true;
|
|
933
|
+
return false;
|
|
934
|
+
}
|
|
935
|
+
function extractText(el) {
|
|
936
|
+
const aria = el.getAttribute("aria-label");
|
|
937
|
+
if (aria) return aria.replace(/\s+/g, " ").trim();
|
|
938
|
+
if (el instanceof HTMLInputElement && el.value) return el.value;
|
|
939
|
+
const text = (el.textContent || "").replace(/\s+/g, " ").trim();
|
|
940
|
+
return text;
|
|
941
|
+
}
|
|
942
|
+
function trimText(s, cap) {
|
|
943
|
+
if (s.length <= cap) return s;
|
|
944
|
+
return s.slice(0, cap - 1) + "\u2026";
|
|
945
|
+
}
|
|
946
|
+
function buildSelector(el) {
|
|
947
|
+
const parts = [];
|
|
948
|
+
let cur = el;
|
|
949
|
+
let depth = 0;
|
|
950
|
+
while (cur && cur.nodeName.toLowerCase() !== "body" && depth < 5) {
|
|
951
|
+
let part = cur.nodeName.toLowerCase();
|
|
952
|
+
if (cur.id) {
|
|
953
|
+
parts.unshift(`${part}#${cur.id}`);
|
|
954
|
+
break;
|
|
955
|
+
}
|
|
956
|
+
if (cur.classList.length > 0) {
|
|
957
|
+
const cls = Array.from(cur.classList).filter((c) => !c.startsWith("cd-")).slice(0, 2).join(".");
|
|
958
|
+
if (cls) part += `.${cls}`;
|
|
959
|
+
}
|
|
960
|
+
parts.unshift(part);
|
|
961
|
+
cur = cur.parentElement;
|
|
962
|
+
depth++;
|
|
963
|
+
}
|
|
964
|
+
return parts.join(" > ");
|
|
965
|
+
}
|
|
966
|
+
function collectDataAttrs(el) {
|
|
967
|
+
const out = {};
|
|
968
|
+
if (!(el instanceof HTMLElement)) return out;
|
|
969
|
+
for (const name of el.getAttributeNames()) {
|
|
970
|
+
if (!name.startsWith("data-")) continue;
|
|
971
|
+
if (name === "data-cd-noTrack" || name === "data-cd-no-track") continue;
|
|
972
|
+
if (name === "data-cd-event") continue;
|
|
973
|
+
const value = el.getAttribute(name) || "";
|
|
974
|
+
const key = name.replace(/^data-cd-prop-/, "").replace(/^data-/, "");
|
|
975
|
+
out[key] = value;
|
|
976
|
+
}
|
|
977
|
+
return out;
|
|
978
|
+
}
|
|
766
979
|
function isBrowserSafe() {
|
|
767
980
|
return typeof globalThis.window !== "undefined" && typeof globalThis.document !== "undefined";
|
|
768
981
|
}
|
|
@@ -882,6 +1095,7 @@ var CrossdeckClient = class {
|
|
|
882
1095
|
message: `Crossdeck.init: environment "${options.environment}" disagrees with key prefix (${keyEnv}). Reconcile the publishable key with the environment declaration.`
|
|
883
1096
|
});
|
|
884
1097
|
}
|
|
1098
|
+
const localDevMode = isLocalHostname();
|
|
885
1099
|
const storage = options.storage ?? detectDefaultStorage();
|
|
886
1100
|
const persistIdentity = options.persistIdentity ?? true;
|
|
887
1101
|
const autoTrack = resolveAutoTrack(options.autoTrack);
|
|
@@ -908,8 +1122,18 @@ var CrossdeckClient = class {
|
|
|
908
1122
|
const http = new HttpClient({
|
|
909
1123
|
publicKey: opts.publicKey,
|
|
910
1124
|
baseUrl: opts.baseUrl,
|
|
911
|
-
sdkVersion: opts.sdkVersion
|
|
1125
|
+
sdkVersion: opts.sdkVersion,
|
|
1126
|
+
// Localhost auto-route: HttpClient short-circuits every request
|
|
1127
|
+
// to a successful no-op response when localDevMode is set.
|
|
1128
|
+
// SDK methods continue to work locally; nothing reaches the
|
|
1129
|
+
// server.
|
|
1130
|
+
localDevMode
|
|
912
1131
|
});
|
|
1132
|
+
if (localDevMode) {
|
|
1133
|
+
console.log(
|
|
1134
|
+
"[crossdeck] Localhost detected \u2014 running in dev mode (no network calls). Set publicKey: 'cd_pub_test_\u2026' and deploy to a real domain to test against the Crossdeck Sandbox."
|
|
1135
|
+
);
|
|
1136
|
+
}
|
|
913
1137
|
const effectiveStorage = persistIdentity ? storage : new MemoryStorage();
|
|
914
1138
|
const useCookieRedundancy = persistIdentity && !options.storage && // honour caller's adapter choice
|
|
915
1139
|
typeof globalThis.document !== "undefined";
|
|
@@ -962,7 +1186,7 @@ var CrossdeckClient = class {
|
|
|
962
1186
|
this.state.uninstallUnloadFlush = installUnloadFlush(() => {
|
|
963
1187
|
void this.flush({ keepalive: true }).catch(() => void 0);
|
|
964
1188
|
});
|
|
965
|
-
if (opts.autoHeartbeat) {
|
|
1189
|
+
if (opts.autoHeartbeat && !localDevMode) {
|
|
966
1190
|
void this.heartbeat().catch(() => void 0);
|
|
967
1191
|
}
|
|
968
1192
|
}
|
|
@@ -1196,6 +1420,12 @@ var CrossdeckClient = class {
|
|
|
1196
1420
|
*/
|
|
1197
1421
|
reset() {
|
|
1198
1422
|
if (!this.state) return;
|
|
1423
|
+
if (this.state.developerUserId) {
|
|
1424
|
+
try {
|
|
1425
|
+
this.track("user.signed_out", { auto: true });
|
|
1426
|
+
} catch {
|
|
1427
|
+
}
|
|
1428
|
+
}
|
|
1199
1429
|
this.state.autoTracker?.uninstall();
|
|
1200
1430
|
this.state.identity.reset();
|
|
1201
1431
|
this.state.entitlements.clear();
|
|
@@ -1276,14 +1506,30 @@ var CrossdeckClient = class {
|
|
|
1276
1506
|
if (s.developerUserId) return { userId: s.developerUserId };
|
|
1277
1507
|
return { anonymousId: s.identity.anonymousId };
|
|
1278
1508
|
}
|
|
1279
|
-
/**
|
|
1509
|
+
/**
|
|
1510
|
+
* Embed every known identity axis on the event. Earlier this returned
|
|
1511
|
+
* just the highest-priority hint (cdcust → developerUserId → anonymousId)
|
|
1512
|
+
* to keep payloads small, but that leaked into analytics: once a user
|
|
1513
|
+
* was logged in, every subsequent page.viewed shipped without
|
|
1514
|
+
* anonymousId, and `uniqExact(anonymous_id)` on the warehouse side
|
|
1515
|
+
* counted 0 visitors for the entire authenticated app.
|
|
1516
|
+
*
|
|
1517
|
+
* Bank-grade rule: the server is the single source of truth on
|
|
1518
|
+
* dedup. Send everything we know; let CH count by whichever axis
|
|
1519
|
+
* matches the question. Each field is at most 32 bytes — sending
|
|
1520
|
+
* three on every event costs ~80 bytes per request, which is
|
|
1521
|
+
* trivial compared to the analytics correctness it buys.
|
|
1522
|
+
*/
|
|
1280
1523
|
identityHintForEvent() {
|
|
1281
1524
|
const s = this.requireStarted();
|
|
1525
|
+
const hint = {
|
|
1526
|
+
anonymousId: s.identity.anonymousId
|
|
1527
|
+
};
|
|
1528
|
+
if (s.developerUserId) hint.developerUserId = s.developerUserId;
|
|
1282
1529
|
if (s.identity.crossdeckCustomerId) {
|
|
1283
|
-
|
|
1530
|
+
hint.crossdeckCustomerId = s.identity.crossdeckCustomerId;
|
|
1284
1531
|
}
|
|
1285
|
-
|
|
1286
|
-
return { anonymousId: s.identity.anonymousId };
|
|
1532
|
+
return hint;
|
|
1287
1533
|
}
|
|
1288
1534
|
mintEventId() {
|
|
1289
1535
|
const ts = Date.now().toString(36);
|
|
@@ -1296,9 +1542,21 @@ function inferEnvFromKey(publicKey) {
|
|
|
1296
1542
|
if (publicKey.startsWith("cd_pub_live_")) return "production";
|
|
1297
1543
|
return null;
|
|
1298
1544
|
}
|
|
1545
|
+
function isLocalHostname() {
|
|
1546
|
+
const w = globalThis.window;
|
|
1547
|
+
const hostname = w?.location?.hostname;
|
|
1548
|
+
if (!hostname) return false;
|
|
1549
|
+
if (hostname === "localhost" || hostname === "127.0.0.1") return true;
|
|
1550
|
+
if (hostname === "::1" || hostname === "[::1]") return true;
|
|
1551
|
+
if (hostname.endsWith(".local")) return true;
|
|
1552
|
+
if (/^10\./.test(hostname)) return true;
|
|
1553
|
+
if (/^192\.168\./.test(hostname)) return true;
|
|
1554
|
+
if (/^172\.(1[6-9]|2\d|3[0-1])\./.test(hostname)) return true;
|
|
1555
|
+
return false;
|
|
1556
|
+
}
|
|
1299
1557
|
function resolveAutoTrack(input) {
|
|
1300
1558
|
if (input === false) {
|
|
1301
|
-
return { sessions: false, pageViews: false, deviceInfo: false };
|
|
1559
|
+
return { sessions: false, pageViews: false, deviceInfo: false, clicks: false };
|
|
1302
1560
|
}
|
|
1303
1561
|
if (input === void 0 || input === true) {
|
|
1304
1562
|
return { ...DEFAULT_AUTO_TRACK };
|
|
@@ -1306,7 +1564,8 @@ function resolveAutoTrack(input) {
|
|
|
1306
1564
|
return {
|
|
1307
1565
|
sessions: input.sessions ?? DEFAULT_AUTO_TRACK.sessions,
|
|
1308
1566
|
pageViews: input.pageViews ?? DEFAULT_AUTO_TRACK.pageViews,
|
|
1309
|
-
deviceInfo: input.deviceInfo ?? DEFAULT_AUTO_TRACK.deviceInfo
|
|
1567
|
+
deviceInfo: input.deviceInfo ?? DEFAULT_AUTO_TRACK.deviceInfo,
|
|
1568
|
+
clicks: input.clicks ?? DEFAULT_AUTO_TRACK.clicks
|
|
1310
1569
|
};
|
|
1311
1570
|
}
|
|
1312
1571
|
function installUnloadFlush(onUnload) {
|