@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.cjs
CHANGED
|
@@ -90,6 +90,9 @@ var HttpClient = class {
|
|
|
90
90
|
* - JSON parse failure on a 2xx (treated as `internal_error`)
|
|
91
91
|
*/
|
|
92
92
|
async request(method, path, options = {}) {
|
|
93
|
+
if (this.config.localDevMode) {
|
|
94
|
+
return synthesizeLocalDevResponse(path);
|
|
95
|
+
}
|
|
93
96
|
const url = this.buildUrl(path, options.query);
|
|
94
97
|
const headers = {
|
|
95
98
|
Authorization: `Bearer ${this.config.publicKey}`,
|
|
@@ -132,6 +135,14 @@ var HttpClient = class {
|
|
|
132
135
|
});
|
|
133
136
|
}
|
|
134
137
|
}
|
|
138
|
+
/**
|
|
139
|
+
* Whether this client is in localhost dev-mode short-circuit. Used
|
|
140
|
+
* by other SDK pieces (event-queue) to skip network-bound work
|
|
141
|
+
* entirely rather than going through synthesizeLocalDevResponse.
|
|
142
|
+
*/
|
|
143
|
+
get isLocalDevMode() {
|
|
144
|
+
return this.config.localDevMode === true;
|
|
145
|
+
}
|
|
135
146
|
buildUrl(path, query) {
|
|
136
147
|
const base = this.config.baseUrl.replace(/\/+$/, "");
|
|
137
148
|
const cleanPath = path.startsWith("/") ? path : `/${path}`;
|
|
@@ -147,6 +158,49 @@ var HttpClient = class {
|
|
|
147
158
|
return url;
|
|
148
159
|
}
|
|
149
160
|
};
|
|
161
|
+
var cachedLocalCdcust = null;
|
|
162
|
+
function synthesizeLocalDevResponse(path) {
|
|
163
|
+
if (path.startsWith("/sdk/heartbeat")) {
|
|
164
|
+
return {
|
|
165
|
+
object: "heartbeat",
|
|
166
|
+
ok: true,
|
|
167
|
+
projectId: "proj_local_dev",
|
|
168
|
+
appId: "app_local_dev",
|
|
169
|
+
platform: "web",
|
|
170
|
+
env: "sandbox",
|
|
171
|
+
serverTime: Date.now()
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
if (path.startsWith("/identity/alias")) {
|
|
175
|
+
if (!cachedLocalCdcust) {
|
|
176
|
+
const tail = typeof crypto !== "undefined" && "randomUUID" in crypto ? crypto.randomUUID().replace(/-/g, "").slice(0, 16) : Math.random().toString(36).slice(2, 18);
|
|
177
|
+
cachedLocalCdcust = `cdcust_local_${tail}`;
|
|
178
|
+
}
|
|
179
|
+
return {
|
|
180
|
+
object: "alias_result",
|
|
181
|
+
crossdeckCustomerId: cachedLocalCdcust,
|
|
182
|
+
linked: [],
|
|
183
|
+
mergePending: false,
|
|
184
|
+
env: "sandbox"
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
if (path.startsWith("/entitlements")) {
|
|
188
|
+
return {
|
|
189
|
+
object: "list",
|
|
190
|
+
data: [],
|
|
191
|
+
crossdeckCustomerId: cachedLocalCdcust ?? "",
|
|
192
|
+
env: "sandbox"
|
|
193
|
+
};
|
|
194
|
+
}
|
|
195
|
+
if (path.startsWith("/events")) {
|
|
196
|
+
return {
|
|
197
|
+
object: "list",
|
|
198
|
+
received: 0,
|
|
199
|
+
env: "sandbox"
|
|
200
|
+
};
|
|
201
|
+
}
|
|
202
|
+
return {};
|
|
203
|
+
}
|
|
150
204
|
|
|
151
205
|
// src/identity.ts
|
|
152
206
|
var KEY_ANON = "anon_id";
|
|
@@ -633,7 +687,8 @@ function parseUserAgent(ua) {
|
|
|
633
687
|
var DEFAULT_AUTO_TRACK = {
|
|
634
688
|
sessions: true,
|
|
635
689
|
pageViews: true,
|
|
636
|
-
deviceInfo: true
|
|
690
|
+
deviceInfo: true,
|
|
691
|
+
clicks: true
|
|
637
692
|
};
|
|
638
693
|
var SESSION_RESUME_THRESHOLD_MS = 30 * 60 * 1e3;
|
|
639
694
|
var EMPTY_ACQUISITION = {
|
|
@@ -655,6 +710,7 @@ var AutoTracker = class {
|
|
|
655
710
|
if (!isBrowserSafe()) return;
|
|
656
711
|
if (this.cfg.sessions) this.installSessionTracking();
|
|
657
712
|
if (this.cfg.pageViews) this.installPageViewTracking();
|
|
713
|
+
if (this.cfg.clicks) this.installClickTracking();
|
|
658
714
|
}
|
|
659
715
|
uninstall() {
|
|
660
716
|
while (this.cleanups.length) {
|
|
@@ -749,11 +805,19 @@ var AutoTracker = class {
|
|
|
749
805
|
installPageViewTracking() {
|
|
750
806
|
const w = globalThis.window;
|
|
751
807
|
const doc = globalThis.document;
|
|
752
|
-
|
|
808
|
+
let lastFiredAt = 0;
|
|
809
|
+
let lastFiredUrl = "";
|
|
810
|
+
const DEDUP_WINDOW_MS = 250;
|
|
811
|
+
const fire = (force = false) => {
|
|
753
812
|
const loc = w.location;
|
|
813
|
+
const url = loc.href;
|
|
814
|
+
const now = Date.now();
|
|
815
|
+
if (!force && url === lastFiredUrl && now - lastFiredAt < DEDUP_WINDOW_MS) return;
|
|
816
|
+
lastFiredAt = now;
|
|
817
|
+
lastFiredUrl = url;
|
|
754
818
|
this.track("page.viewed", {
|
|
755
819
|
path: loc.pathname,
|
|
756
|
-
url
|
|
820
|
+
url,
|
|
757
821
|
search: loc.search || void 0,
|
|
758
822
|
hash: loc.hash || void 0,
|
|
759
823
|
title: doc.title,
|
|
@@ -775,7 +839,7 @@ var AutoTracker = class {
|
|
|
775
839
|
}
|
|
776
840
|
w.history.pushState = patchedPush;
|
|
777
841
|
w.history.replaceState = patchedReplace;
|
|
778
|
-
const onPopState = () => fire();
|
|
842
|
+
const onPopState = () => fire(true);
|
|
779
843
|
w.addEventListener("popstate", onPopState);
|
|
780
844
|
this.cleanups.push(() => {
|
|
781
845
|
if (w.history.pushState === patchedPush) {
|
|
@@ -787,7 +851,156 @@ var AutoTracker = class {
|
|
|
787
851
|
w.removeEventListener("popstate", onPopState);
|
|
788
852
|
});
|
|
789
853
|
}
|
|
854
|
+
// ---------- click autocapture ----------
|
|
855
|
+
/**
|
|
856
|
+
* Global click tracking — Mixpanel / Amplitude style autocapture.
|
|
857
|
+
* Fires `element.clicked` for every interactive click with the
|
|
858
|
+
* target element's selector path, text content, tag, href, data-*
|
|
859
|
+
* attributes, and viewport coordinates. Powers the funnel /
|
|
860
|
+
* attribution USP: "users who clicked X then converted within
|
|
861
|
+
* 7 days." Default ON because behavioural attribution is the
|
|
862
|
+
* core product promise.
|
|
863
|
+
*
|
|
864
|
+
* Privacy guardrails:
|
|
865
|
+
* - Skip clicks ON inputs / textareas / selects (form interaction
|
|
866
|
+
* isn't button telemetry; the dev should track form submits
|
|
867
|
+
* deliberately via track('form_submitted'))
|
|
868
|
+
* - Skip clicks INSIDE [type="password"] and password-class
|
|
869
|
+
* elements
|
|
870
|
+
* - Skip clicks inside elements opted out via class="cd-noTrack"
|
|
871
|
+
* or data-cd-noTrack attribute (Mixpanel's exact opt-out
|
|
872
|
+
* idiom — most devs already know it)
|
|
873
|
+
* - Capture text content but cap at 64 chars and trim — never
|
|
874
|
+
* more than what you'd see on a button label
|
|
875
|
+
*
|
|
876
|
+
* Volume guardrails:
|
|
877
|
+
* - Coalesce double-clicks within 100ms (React's synthetic click
|
|
878
|
+
* pattern + browser's native dblclick can fire twice)
|
|
879
|
+
* - Listen on document at capture phase so we see the click
|
|
880
|
+
* before any framework's own handlers stop propagation
|
|
881
|
+
*/
|
|
882
|
+
installClickTracking() {
|
|
883
|
+
const w = globalThis.window;
|
|
884
|
+
const doc = globalThis.document;
|
|
885
|
+
let lastFiredAt = 0;
|
|
886
|
+
let lastFiredTarget = null;
|
|
887
|
+
const COALESCE_MS = 100;
|
|
888
|
+
const TEXT_CAP = 64;
|
|
889
|
+
const onClick = (ev) => {
|
|
890
|
+
const target = ev.target;
|
|
891
|
+
if (!target || !(target instanceof Element)) return;
|
|
892
|
+
const now = Date.now();
|
|
893
|
+
if (target === lastFiredTarget && now - lastFiredAt < COALESCE_MS) return;
|
|
894
|
+
lastFiredAt = now;
|
|
895
|
+
lastFiredTarget = target;
|
|
896
|
+
const actionable = closestActionable(target);
|
|
897
|
+
const clicked = actionable || target;
|
|
898
|
+
if (isFormInput(clicked)) return;
|
|
899
|
+
if (isInOptedOut(clicked)) return;
|
|
900
|
+
if (isInsidePasswordField(clicked)) return;
|
|
901
|
+
const tag = clicked.tagName.toLowerCase();
|
|
902
|
+
const text = trimText(extractText(clicked), TEXT_CAP);
|
|
903
|
+
const href = clicked.href || void 0;
|
|
904
|
+
const linkTarget = clicked.target || void 0;
|
|
905
|
+
const elementId = clicked.id || void 0;
|
|
906
|
+
const role = clicked.getAttribute("role") || void 0;
|
|
907
|
+
const ariaLabel = clicked.getAttribute("aria-label") || void 0;
|
|
908
|
+
const selector = buildSelector(clicked);
|
|
909
|
+
const dataAttrs = collectDataAttrs(clicked);
|
|
910
|
+
const isLink = tag === "a" && !!href;
|
|
911
|
+
const explicitName = clicked.getAttribute("data-cd-event");
|
|
912
|
+
const props = {
|
|
913
|
+
selector,
|
|
914
|
+
tag,
|
|
915
|
+
text,
|
|
916
|
+
elementId,
|
|
917
|
+
role,
|
|
918
|
+
ariaLabel,
|
|
919
|
+
href,
|
|
920
|
+
isLink,
|
|
921
|
+
linkTarget,
|
|
922
|
+
viewportX: ev.clientX,
|
|
923
|
+
viewportY: ev.clientY,
|
|
924
|
+
pageX: ev.pageX,
|
|
925
|
+
pageY: ev.pageY,
|
|
926
|
+
...dataAttrs
|
|
927
|
+
};
|
|
928
|
+
for (const k of Object.keys(props)) {
|
|
929
|
+
if (props[k] === void 0 || props[k] === null || props[k] === "") delete props[k];
|
|
930
|
+
}
|
|
931
|
+
this.track(explicitName || "element.clicked", props);
|
|
932
|
+
};
|
|
933
|
+
doc.addEventListener("click", onClick, { capture: true, passive: true });
|
|
934
|
+
this.cleanups.push(() => {
|
|
935
|
+
doc.removeEventListener("click", onClick, { capture: true });
|
|
936
|
+
});
|
|
937
|
+
}
|
|
790
938
|
};
|
|
939
|
+
function closestActionable(el) {
|
|
940
|
+
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;
|
|
941
|
+
}
|
|
942
|
+
function isFormInput(el) {
|
|
943
|
+
if (!(el instanceof HTMLElement)) return false;
|
|
944
|
+
const tag = el.tagName.toLowerCase();
|
|
945
|
+
if (tag === "textarea" || tag === "select") return true;
|
|
946
|
+
if (tag === "input") {
|
|
947
|
+
const type = (el.type || "").toLowerCase();
|
|
948
|
+
return type !== "button" && type !== "submit" && type !== "image" && type !== "reset";
|
|
949
|
+
}
|
|
950
|
+
return false;
|
|
951
|
+
}
|
|
952
|
+
function isInOptedOut(el) {
|
|
953
|
+
if (el.closest("[data-cd-noTrack], [data-cd-no-track], .cd-noTrack, .cd-no-track")) return true;
|
|
954
|
+
return false;
|
|
955
|
+
}
|
|
956
|
+
function isInsidePasswordField(el) {
|
|
957
|
+
if (el.closest('input[type="password"]')) return true;
|
|
958
|
+
return false;
|
|
959
|
+
}
|
|
960
|
+
function extractText(el) {
|
|
961
|
+
const aria = el.getAttribute("aria-label");
|
|
962
|
+
if (aria) return aria.replace(/\s+/g, " ").trim();
|
|
963
|
+
if (el instanceof HTMLInputElement && el.value) return el.value;
|
|
964
|
+
const text = (el.textContent || "").replace(/\s+/g, " ").trim();
|
|
965
|
+
return text;
|
|
966
|
+
}
|
|
967
|
+
function trimText(s, cap) {
|
|
968
|
+
if (s.length <= cap) return s;
|
|
969
|
+
return s.slice(0, cap - 1) + "\u2026";
|
|
970
|
+
}
|
|
971
|
+
function buildSelector(el) {
|
|
972
|
+
const parts = [];
|
|
973
|
+
let cur = el;
|
|
974
|
+
let depth = 0;
|
|
975
|
+
while (cur && cur.nodeName.toLowerCase() !== "body" && depth < 5) {
|
|
976
|
+
let part = cur.nodeName.toLowerCase();
|
|
977
|
+
if (cur.id) {
|
|
978
|
+
parts.unshift(`${part}#${cur.id}`);
|
|
979
|
+
break;
|
|
980
|
+
}
|
|
981
|
+
if (cur.classList.length > 0) {
|
|
982
|
+
const cls = Array.from(cur.classList).filter((c) => !c.startsWith("cd-")).slice(0, 2).join(".");
|
|
983
|
+
if (cls) part += `.${cls}`;
|
|
984
|
+
}
|
|
985
|
+
parts.unshift(part);
|
|
986
|
+
cur = cur.parentElement;
|
|
987
|
+
depth++;
|
|
988
|
+
}
|
|
989
|
+
return parts.join(" > ");
|
|
990
|
+
}
|
|
991
|
+
function collectDataAttrs(el) {
|
|
992
|
+
const out = {};
|
|
993
|
+
if (!(el instanceof HTMLElement)) return out;
|
|
994
|
+
for (const name of el.getAttributeNames()) {
|
|
995
|
+
if (!name.startsWith("data-")) continue;
|
|
996
|
+
if (name === "data-cd-noTrack" || name === "data-cd-no-track") continue;
|
|
997
|
+
if (name === "data-cd-event") continue;
|
|
998
|
+
const value = el.getAttribute(name) || "";
|
|
999
|
+
const key = name.replace(/^data-cd-prop-/, "").replace(/^data-/, "");
|
|
1000
|
+
out[key] = value;
|
|
1001
|
+
}
|
|
1002
|
+
return out;
|
|
1003
|
+
}
|
|
791
1004
|
function isBrowserSafe() {
|
|
792
1005
|
return typeof globalThis.window !== "undefined" && typeof globalThis.document !== "undefined";
|
|
793
1006
|
}
|
|
@@ -907,6 +1120,7 @@ var CrossdeckClient = class {
|
|
|
907
1120
|
message: `Crossdeck.init: environment "${options.environment}" disagrees with key prefix (${keyEnv}). Reconcile the publishable key with the environment declaration.`
|
|
908
1121
|
});
|
|
909
1122
|
}
|
|
1123
|
+
const localDevMode = isLocalHostname();
|
|
910
1124
|
const storage = options.storage ?? detectDefaultStorage();
|
|
911
1125
|
const persistIdentity = options.persistIdentity ?? true;
|
|
912
1126
|
const autoTrack = resolveAutoTrack(options.autoTrack);
|
|
@@ -933,8 +1147,18 @@ var CrossdeckClient = class {
|
|
|
933
1147
|
const http = new HttpClient({
|
|
934
1148
|
publicKey: opts.publicKey,
|
|
935
1149
|
baseUrl: opts.baseUrl,
|
|
936
|
-
sdkVersion: opts.sdkVersion
|
|
1150
|
+
sdkVersion: opts.sdkVersion,
|
|
1151
|
+
// Localhost auto-route: HttpClient short-circuits every request
|
|
1152
|
+
// to a successful no-op response when localDevMode is set.
|
|
1153
|
+
// SDK methods continue to work locally; nothing reaches the
|
|
1154
|
+
// server.
|
|
1155
|
+
localDevMode
|
|
937
1156
|
});
|
|
1157
|
+
if (localDevMode) {
|
|
1158
|
+
console.log(
|
|
1159
|
+
"[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."
|
|
1160
|
+
);
|
|
1161
|
+
}
|
|
938
1162
|
const effectiveStorage = persistIdentity ? storage : new MemoryStorage();
|
|
939
1163
|
const useCookieRedundancy = persistIdentity && !options.storage && // honour caller's adapter choice
|
|
940
1164
|
typeof globalThis.document !== "undefined";
|
|
@@ -987,7 +1211,7 @@ var CrossdeckClient = class {
|
|
|
987
1211
|
this.state.uninstallUnloadFlush = installUnloadFlush(() => {
|
|
988
1212
|
void this.flush({ keepalive: true }).catch(() => void 0);
|
|
989
1213
|
});
|
|
990
|
-
if (opts.autoHeartbeat) {
|
|
1214
|
+
if (opts.autoHeartbeat && !localDevMode) {
|
|
991
1215
|
void this.heartbeat().catch(() => void 0);
|
|
992
1216
|
}
|
|
993
1217
|
}
|
|
@@ -1221,6 +1445,12 @@ var CrossdeckClient = class {
|
|
|
1221
1445
|
*/
|
|
1222
1446
|
reset() {
|
|
1223
1447
|
if (!this.state) return;
|
|
1448
|
+
if (this.state.developerUserId) {
|
|
1449
|
+
try {
|
|
1450
|
+
this.track("user.signed_out", { auto: true });
|
|
1451
|
+
} catch {
|
|
1452
|
+
}
|
|
1453
|
+
}
|
|
1224
1454
|
this.state.autoTracker?.uninstall();
|
|
1225
1455
|
this.state.identity.reset();
|
|
1226
1456
|
this.state.entitlements.clear();
|
|
@@ -1301,14 +1531,30 @@ var CrossdeckClient = class {
|
|
|
1301
1531
|
if (s.developerUserId) return { userId: s.developerUserId };
|
|
1302
1532
|
return { anonymousId: s.identity.anonymousId };
|
|
1303
1533
|
}
|
|
1304
|
-
/**
|
|
1534
|
+
/**
|
|
1535
|
+
* Embed every known identity axis on the event. Earlier this returned
|
|
1536
|
+
* just the highest-priority hint (cdcust → developerUserId → anonymousId)
|
|
1537
|
+
* to keep payloads small, but that leaked into analytics: once a user
|
|
1538
|
+
* was logged in, every subsequent page.viewed shipped without
|
|
1539
|
+
* anonymousId, and `uniqExact(anonymous_id)` on the warehouse side
|
|
1540
|
+
* counted 0 visitors for the entire authenticated app.
|
|
1541
|
+
*
|
|
1542
|
+
* Bank-grade rule: the server is the single source of truth on
|
|
1543
|
+
* dedup. Send everything we know; let CH count by whichever axis
|
|
1544
|
+
* matches the question. Each field is at most 32 bytes — sending
|
|
1545
|
+
* three on every event costs ~80 bytes per request, which is
|
|
1546
|
+
* trivial compared to the analytics correctness it buys.
|
|
1547
|
+
*/
|
|
1305
1548
|
identityHintForEvent() {
|
|
1306
1549
|
const s = this.requireStarted();
|
|
1550
|
+
const hint = {
|
|
1551
|
+
anonymousId: s.identity.anonymousId
|
|
1552
|
+
};
|
|
1553
|
+
if (s.developerUserId) hint.developerUserId = s.developerUserId;
|
|
1307
1554
|
if (s.identity.crossdeckCustomerId) {
|
|
1308
|
-
|
|
1555
|
+
hint.crossdeckCustomerId = s.identity.crossdeckCustomerId;
|
|
1309
1556
|
}
|
|
1310
|
-
|
|
1311
|
-
return { anonymousId: s.identity.anonymousId };
|
|
1557
|
+
return hint;
|
|
1312
1558
|
}
|
|
1313
1559
|
mintEventId() {
|
|
1314
1560
|
const ts = Date.now().toString(36);
|
|
@@ -1321,9 +1567,21 @@ function inferEnvFromKey(publicKey) {
|
|
|
1321
1567
|
if (publicKey.startsWith("cd_pub_live_")) return "production";
|
|
1322
1568
|
return null;
|
|
1323
1569
|
}
|
|
1570
|
+
function isLocalHostname() {
|
|
1571
|
+
const w = globalThis.window;
|
|
1572
|
+
const hostname = w?.location?.hostname;
|
|
1573
|
+
if (!hostname) return false;
|
|
1574
|
+
if (hostname === "localhost" || hostname === "127.0.0.1") return true;
|
|
1575
|
+
if (hostname === "::1" || hostname === "[::1]") return true;
|
|
1576
|
+
if (hostname.endsWith(".local")) return true;
|
|
1577
|
+
if (/^10\./.test(hostname)) return true;
|
|
1578
|
+
if (/^192\.168\./.test(hostname)) return true;
|
|
1579
|
+
if (/^172\.(1[6-9]|2\d|3[0-1])\./.test(hostname)) return true;
|
|
1580
|
+
return false;
|
|
1581
|
+
}
|
|
1324
1582
|
function resolveAutoTrack(input) {
|
|
1325
1583
|
if (input === false) {
|
|
1326
|
-
return { sessions: false, pageViews: false, deviceInfo: false };
|
|
1584
|
+
return { sessions: false, pageViews: false, deviceInfo: false, clicks: false };
|
|
1327
1585
|
}
|
|
1328
1586
|
if (input === void 0 || input === true) {
|
|
1329
1587
|
return { ...DEFAULT_AUTO_TRACK };
|
|
@@ -1331,7 +1589,8 @@ function resolveAutoTrack(input) {
|
|
|
1331
1589
|
return {
|
|
1332
1590
|
sessions: input.sessions ?? DEFAULT_AUTO_TRACK.sessions,
|
|
1333
1591
|
pageViews: input.pageViews ?? DEFAULT_AUTO_TRACK.pageViews,
|
|
1334
|
-
deviceInfo: input.deviceInfo ?? DEFAULT_AUTO_TRACK.deviceInfo
|
|
1592
|
+
deviceInfo: input.deviceInfo ?? DEFAULT_AUTO_TRACK.deviceInfo,
|
|
1593
|
+
clicks: input.clicks ?? DEFAULT_AUTO_TRACK.clicks
|
|
1335
1594
|
};
|
|
1336
1595
|
}
|
|
1337
1596
|
function installUnloadFlush(onUnload) {
|