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