@kharon-labs/browser 0.0.0-stage → 0.1.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/LICENSE +21 -0
- package/README.md +73 -2
- package/dist/index.cjs +579 -0
- package/dist/index.d.ts +159 -0
- package/dist/index.js +556 -0
- package/dist/tracker.cjs +170 -0
- package/dist/tracker.d.ts +95 -0
- package/dist/tracker.js +147 -0
- package/dist/v1.js +1 -0
- package/package.json +32 -4
package/dist/tracker.cjs
ADDED
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __defProp = Object.defineProperty;
|
|
3
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
4
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
5
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
6
|
+
var __export = (target, all) => {
|
|
7
|
+
for (var name in all)
|
|
8
|
+
__defProp(target, name, { get: all[name], enumerable: true });
|
|
9
|
+
};
|
|
10
|
+
var __copyProps = (to, from, except, desc) => {
|
|
11
|
+
if (from && typeof from === "object" || typeof from === "function") {
|
|
12
|
+
for (let key of __getOwnPropNames(from))
|
|
13
|
+
if (!__hasOwnProp.call(to, key) && key !== except)
|
|
14
|
+
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
|
15
|
+
}
|
|
16
|
+
return to;
|
|
17
|
+
};
|
|
18
|
+
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
|
19
|
+
|
|
20
|
+
// packages/browser/src/tracker.ts
|
|
21
|
+
var tracker_exports = {};
|
|
22
|
+
__export(tracker_exports, {
|
|
23
|
+
COLLECT_PATH: () => COLLECT_PATH,
|
|
24
|
+
PIN_KEY: () => PIN_KEY,
|
|
25
|
+
SESSION_KEY: () => SESSION_KEY,
|
|
26
|
+
startTracker: () => startTracker
|
|
27
|
+
});
|
|
28
|
+
module.exports = __toCommonJS(tracker_exports);
|
|
29
|
+
|
|
30
|
+
// src/engine/analytics/beacon.ts
|
|
31
|
+
var BEACON_VERSION = 1;
|
|
32
|
+
var MAX_BEACON_EVENTS = 50;
|
|
33
|
+
var SLOT_ATTRIBUTES = {
|
|
34
|
+
slot: "data-ge-slot",
|
|
35
|
+
variant: "data-ge-variant",
|
|
36
|
+
counterfactual: "data-ge-counterfactual",
|
|
37
|
+
/** Why the slot shows what it shows (matched, default, holdout). For debugging; not sent. */
|
|
38
|
+
reason: "data-ge-reason"
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
// packages/browser/src/tracker.ts
|
|
42
|
+
var COLLECT_PATH = "/api/collect";
|
|
43
|
+
var SESSION_KEY = "ge_sid";
|
|
44
|
+
var PIN_KEY = "ge_pin";
|
|
45
|
+
var VISIBLE_RATIO = 0.5;
|
|
46
|
+
var FLUSH_INTERVAL_MS = 2e3;
|
|
47
|
+
function startTracker({
|
|
48
|
+
pageId,
|
|
49
|
+
configVersion,
|
|
50
|
+
endpoint = COLLECT_PATH,
|
|
51
|
+
root = document,
|
|
52
|
+
match,
|
|
53
|
+
envelope = (events) => ({ v: BEACON_VERSION, events, ...match ? { m: match } : {} }),
|
|
54
|
+
crossOrigin = false
|
|
55
|
+
}) {
|
|
56
|
+
const win = root.defaultView ?? window;
|
|
57
|
+
const sessionId = sessionIdFor(win);
|
|
58
|
+
const queue = [];
|
|
59
|
+
const seenSlots = /* @__PURE__ */ new Set();
|
|
60
|
+
const push = (type, body) => {
|
|
61
|
+
queue.push({ type, sessionId, ...body });
|
|
62
|
+
if (queue.length >= MAX_BEACON_EVENTS) flush();
|
|
63
|
+
};
|
|
64
|
+
const flush = () => {
|
|
65
|
+
while (queue.length) send(win, endpoint, envelope(queue.splice(0, MAX_BEACON_EVENTS)), crossOrigin);
|
|
66
|
+
};
|
|
67
|
+
push("page_view", { pageId, configVersion });
|
|
68
|
+
const slots = Array.from(root.querySelectorAll(`[${SLOT_ATTRIBUTES.slot}]`));
|
|
69
|
+
const observer = "IntersectionObserver" in win ? new win.IntersectionObserver(
|
|
70
|
+
(entries) => {
|
|
71
|
+
for (const entry of entries) {
|
|
72
|
+
if (!entry.isIntersecting || entry.intersectionRatio < VISIBLE_RATIO) continue;
|
|
73
|
+
const slot = readSlot(entry.target);
|
|
74
|
+
observer?.unobserve(entry.target);
|
|
75
|
+
if (!slot || seenSlots.has(slot.slotId)) continue;
|
|
76
|
+
seenSlots.add(slot.slotId);
|
|
77
|
+
push("slot_impression", slot);
|
|
78
|
+
}
|
|
79
|
+
},
|
|
80
|
+
{ threshold: VISIBLE_RATIO }
|
|
81
|
+
) : null;
|
|
82
|
+
for (const slot of slots) observer?.observe(slot);
|
|
83
|
+
const interaction = (action) => (event) => {
|
|
84
|
+
const slot = closestSlot(event.target);
|
|
85
|
+
if (slot) push("interaction", { slotId: slot.slotId, variantId: slot.variantId, action });
|
|
86
|
+
};
|
|
87
|
+
const onClick = interaction("click");
|
|
88
|
+
const onCopy = interaction("copy");
|
|
89
|
+
const onToggle = (event) => {
|
|
90
|
+
if (event.target.open) interaction("expand")(event);
|
|
91
|
+
};
|
|
92
|
+
const onSubmit = (event) => {
|
|
93
|
+
const goal = event.target.closest?.("form")?.getAttribute("data-ge-goal");
|
|
94
|
+
if (goal) conversion(goal);
|
|
95
|
+
};
|
|
96
|
+
const onHide = () => {
|
|
97
|
+
if (root.visibilityState === "hidden") flush();
|
|
98
|
+
};
|
|
99
|
+
const conversion = (goal, value) => {
|
|
100
|
+
push("conversion", value === void 0 ? { goal } : { goal, value });
|
|
101
|
+
flush();
|
|
102
|
+
};
|
|
103
|
+
root.addEventListener("click", onClick, true);
|
|
104
|
+
root.addEventListener("copy", onCopy, true);
|
|
105
|
+
root.addEventListener("toggle", onToggle, true);
|
|
106
|
+
root.addEventListener("submit", onSubmit, true);
|
|
107
|
+
root.addEventListener("visibilitychange", onHide);
|
|
108
|
+
win.addEventListener("pagehide", flush);
|
|
109
|
+
const timer = win.setInterval(flush, FLUSH_INTERVAL_MS);
|
|
110
|
+
return {
|
|
111
|
+
conversion,
|
|
112
|
+
flush,
|
|
113
|
+
stop({ discard = false } = {}) {
|
|
114
|
+
if (discard) queue.length = 0;
|
|
115
|
+
flush();
|
|
116
|
+
observer?.disconnect();
|
|
117
|
+
win.clearInterval(timer);
|
|
118
|
+
root.removeEventListener("click", onClick, true);
|
|
119
|
+
root.removeEventListener("copy", onCopy, true);
|
|
120
|
+
root.removeEventListener("toggle", onToggle, true);
|
|
121
|
+
root.removeEventListener("submit", onSubmit, true);
|
|
122
|
+
root.removeEventListener("visibilitychange", onHide);
|
|
123
|
+
win.removeEventListener("pagehide", flush);
|
|
124
|
+
}
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
function readSlot(el) {
|
|
128
|
+
const slotId = el.getAttribute(SLOT_ATTRIBUTES.slot);
|
|
129
|
+
if (!slotId) return null;
|
|
130
|
+
return {
|
|
131
|
+
slotId,
|
|
132
|
+
variantId: el.getAttribute(SLOT_ATTRIBUTES.variant) || null,
|
|
133
|
+
counterfactualVariantId: el.getAttribute(SLOT_ATTRIBUTES.counterfactual) || null
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
function closestSlot(target) {
|
|
137
|
+
const el = target instanceof Element ? target.closest(`[${SLOT_ATTRIBUTES.slot}]`) : null;
|
|
138
|
+
return el ? readSlot(el) : null;
|
|
139
|
+
}
|
|
140
|
+
function sessionIdFor(win) {
|
|
141
|
+
try {
|
|
142
|
+
const existing = win.sessionStorage.getItem(SESSION_KEY);
|
|
143
|
+
if (existing) return existing;
|
|
144
|
+
const id = uuid(win);
|
|
145
|
+
win.sessionStorage.setItem(SESSION_KEY, id);
|
|
146
|
+
return id;
|
|
147
|
+
} catch {
|
|
148
|
+
return uuid(win);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
function uuid(win) {
|
|
152
|
+
if (typeof win.crypto.randomUUID === "function") return win.crypto.randomUUID();
|
|
153
|
+
const b = win.crypto.getRandomValues(new Uint8Array(16));
|
|
154
|
+
b[6] = b[6] & 15 | 64;
|
|
155
|
+
b[8] = b[8] & 63 | 128;
|
|
156
|
+
const hex = Array.from(b, (x) => x.toString(16).padStart(2, "0")).join("");
|
|
157
|
+
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
|
|
158
|
+
}
|
|
159
|
+
function send(win, endpoint, payload, crossOrigin) {
|
|
160
|
+
const body = JSON.stringify(payload);
|
|
161
|
+
const type = crossOrigin ? "text/plain" : "application/json";
|
|
162
|
+
if (win.navigator.sendBeacon?.(endpoint, new Blob([body], { type }))) return;
|
|
163
|
+
void win.fetch(endpoint, {
|
|
164
|
+
method: "POST",
|
|
165
|
+
body,
|
|
166
|
+
keepalive: true,
|
|
167
|
+
credentials: crossOrigin ? "omit" : "same-origin",
|
|
168
|
+
headers: { "content-type": type }
|
|
169
|
+
}).catch((error) => console.warn("[ge] analytics beacon failed", error));
|
|
170
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
type SignalSource = "campaign_link" | "form_email" | "known_visitor" | "reverse_ip" | "referrer";
|
|
2
|
+
interface BaseEvent {
|
|
3
|
+
siteId: string;
|
|
4
|
+
visitorId: string;
|
|
5
|
+
sessionId: string;
|
|
6
|
+
/** Epoch milliseconds, stamped by the collector (not the browser) to avoid clock skew. */
|
|
7
|
+
ts: number;
|
|
8
|
+
/**
|
|
9
|
+
* The company identity resolution matched the visitor to, by domain, stamped by the collector from
|
|
10
|
+
* the server-signed match token. Null when resolve matched no company; absent when unknown (no valid
|
|
11
|
+
* token, e.g. an older SDK, or stored before stamping).
|
|
12
|
+
* Firmographics are joined from the company source at report time.
|
|
13
|
+
*/
|
|
14
|
+
companyDomain?: string | null;
|
|
15
|
+
/** The evidence behind that match (a campaign link, reverse IP…), so reports can weigh or exclude it. */
|
|
16
|
+
companySource?: SignalSource | null;
|
|
17
|
+
}
|
|
18
|
+
interface PageViewEvent extends BaseEvent {
|
|
19
|
+
type: "page_view";
|
|
20
|
+
pageId: string;
|
|
21
|
+
/** Site config version that was resolved, so results are attributable to what was live. */
|
|
22
|
+
configVersion: number;
|
|
23
|
+
}
|
|
24
|
+
interface SlotImpressionEvent extends BaseEvent {
|
|
25
|
+
type: "slot_impression";
|
|
26
|
+
slotId: string;
|
|
27
|
+
variantId: string | null;
|
|
28
|
+
/** Set for holdout visitors: the variant withheld from them. See Decision.counterfactualVariantId. */
|
|
29
|
+
counterfactualVariantId: string | null;
|
|
30
|
+
}
|
|
31
|
+
interface InteractionEvent extends BaseEvent {
|
|
32
|
+
type: "interaction";
|
|
33
|
+
slotId: string;
|
|
34
|
+
variantId: string | null;
|
|
35
|
+
action: "click" | "hover" | "copy" | "expand";
|
|
36
|
+
}
|
|
37
|
+
interface ConversionEvent extends BaseEvent {
|
|
38
|
+
type: "conversion";
|
|
39
|
+
goal: string;
|
|
40
|
+
value?: number;
|
|
41
|
+
}
|
|
42
|
+
type AnalyticsEvent = PageViewEvent | SlotImpressionEvent | InteractionEvent | ConversionEvent;
|
|
43
|
+
type ServerStamped = "siteId" | "visitorId" | "ts" | "companyDomain" | "companySource";
|
|
44
|
+
type BeaconEvent = {
|
|
45
|
+
[K in AnalyticsEvent["type"]]: Omit<Extract<AnalyticsEvent, {
|
|
46
|
+
type: K;
|
|
47
|
+
}>, ServerStamped>;
|
|
48
|
+
}[AnalyticsEvent["type"]];
|
|
49
|
+
export declare const COLLECT_PATH = "/api/collect";
|
|
50
|
+
/** sessionStorage key for the tab's session id. Removed when a visitor withdraws consent. */
|
|
51
|
+
export declare const SESSION_KEY = "ge_sid";
|
|
52
|
+
/**
|
|
53
|
+
* sessionStorage key for the config pin (set by the client, not the tracker): the tab keeps the copy
|
|
54
|
+
* it started with when someone publishes, until a rollback. Stored and removed with the visitor id.
|
|
55
|
+
*/
|
|
56
|
+
export declare const PIN_KEY = "ge_pin";
|
|
57
|
+
export interface TrackerOptions {
|
|
58
|
+
pageId: string;
|
|
59
|
+
configVersion: number;
|
|
60
|
+
endpoint?: string;
|
|
61
|
+
root?: Document;
|
|
62
|
+
/**
|
|
63
|
+
* The server-signed match token for this page view (hosted: rendered by the page). Sent with every
|
|
64
|
+
* batch of the default envelope so the collector can stamp the matched company. Opaque.
|
|
65
|
+
*/
|
|
66
|
+
match?: string;
|
|
67
|
+
/**
|
|
68
|
+
* Wraps a batch of events into the request body. Defaults to beacon v1, which the same-origin
|
|
69
|
+
* collector stamps with the visitor from its own cookie.
|
|
70
|
+
*/
|
|
71
|
+
envelope?: (events: BeaconEvent[]) => object;
|
|
72
|
+
/**
|
|
73
|
+
* The collector is on another origin (snippet mode). Bodies go as `text/plain` without cookies,
|
|
74
|
+
* so the browser sends them without a CORS preflight.
|
|
75
|
+
*/
|
|
76
|
+
crossOrigin?: boolean;
|
|
77
|
+
}
|
|
78
|
+
export interface Tracker {
|
|
79
|
+
/** Record that a workspace goal fired. Flushes at once, since a conversion often precedes navigation. */
|
|
80
|
+
conversion(goal: string, value?: number): void;
|
|
81
|
+
flush(): void;
|
|
82
|
+
/** Stops measuring. Queued events are sent, unless `discard` (consent was withdrawn). */
|
|
83
|
+
stop(options?: {
|
|
84
|
+
discard?: boolean;
|
|
85
|
+
}): void;
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* The client half of the measurement pipeline. Sends one page view, one impression per slot once at
|
|
89
|
+
* least half of it is on screen, interactions inside slots, and conversions (forms marked
|
|
90
|
+
* `data-ge-goal`, or `conversion()`). Events are batched and sent with `sendBeacon` so a closing tab
|
|
91
|
+
* still delivers them.
|
|
92
|
+
*/
|
|
93
|
+
export declare function startTracker({ pageId, configVersion, endpoint, root, match, envelope, crossOrigin, }: TrackerOptions): Tracker;
|
|
94
|
+
|
|
95
|
+
export {};
|
package/dist/tracker.js
ADDED
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
// src/engine/analytics/beacon.ts
|
|
2
|
+
var BEACON_VERSION = 1;
|
|
3
|
+
var MAX_BEACON_EVENTS = 50;
|
|
4
|
+
var SLOT_ATTRIBUTES = {
|
|
5
|
+
slot: "data-ge-slot",
|
|
6
|
+
variant: "data-ge-variant",
|
|
7
|
+
counterfactual: "data-ge-counterfactual",
|
|
8
|
+
/** Why the slot shows what it shows (matched, default, holdout). For debugging; not sent. */
|
|
9
|
+
reason: "data-ge-reason"
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
// packages/browser/src/tracker.ts
|
|
13
|
+
var COLLECT_PATH = "/api/collect";
|
|
14
|
+
var SESSION_KEY = "ge_sid";
|
|
15
|
+
var PIN_KEY = "ge_pin";
|
|
16
|
+
var VISIBLE_RATIO = 0.5;
|
|
17
|
+
var FLUSH_INTERVAL_MS = 2e3;
|
|
18
|
+
function startTracker({
|
|
19
|
+
pageId,
|
|
20
|
+
configVersion,
|
|
21
|
+
endpoint = COLLECT_PATH,
|
|
22
|
+
root = document,
|
|
23
|
+
match,
|
|
24
|
+
envelope = (events) => ({ v: BEACON_VERSION, events, ...match ? { m: match } : {} }),
|
|
25
|
+
crossOrigin = false
|
|
26
|
+
}) {
|
|
27
|
+
const win = root.defaultView ?? window;
|
|
28
|
+
const sessionId = sessionIdFor(win);
|
|
29
|
+
const queue = [];
|
|
30
|
+
const seenSlots = /* @__PURE__ */ new Set();
|
|
31
|
+
const push = (type, body) => {
|
|
32
|
+
queue.push({ type, sessionId, ...body });
|
|
33
|
+
if (queue.length >= MAX_BEACON_EVENTS) flush();
|
|
34
|
+
};
|
|
35
|
+
const flush = () => {
|
|
36
|
+
while (queue.length) send(win, endpoint, envelope(queue.splice(0, MAX_BEACON_EVENTS)), crossOrigin);
|
|
37
|
+
};
|
|
38
|
+
push("page_view", { pageId, configVersion });
|
|
39
|
+
const slots = Array.from(root.querySelectorAll(`[${SLOT_ATTRIBUTES.slot}]`));
|
|
40
|
+
const observer = "IntersectionObserver" in win ? new win.IntersectionObserver(
|
|
41
|
+
(entries) => {
|
|
42
|
+
for (const entry of entries) {
|
|
43
|
+
if (!entry.isIntersecting || entry.intersectionRatio < VISIBLE_RATIO) continue;
|
|
44
|
+
const slot = readSlot(entry.target);
|
|
45
|
+
observer?.unobserve(entry.target);
|
|
46
|
+
if (!slot || seenSlots.has(slot.slotId)) continue;
|
|
47
|
+
seenSlots.add(slot.slotId);
|
|
48
|
+
push("slot_impression", slot);
|
|
49
|
+
}
|
|
50
|
+
},
|
|
51
|
+
{ threshold: VISIBLE_RATIO }
|
|
52
|
+
) : null;
|
|
53
|
+
for (const slot of slots) observer?.observe(slot);
|
|
54
|
+
const interaction = (action) => (event) => {
|
|
55
|
+
const slot = closestSlot(event.target);
|
|
56
|
+
if (slot) push("interaction", { slotId: slot.slotId, variantId: slot.variantId, action });
|
|
57
|
+
};
|
|
58
|
+
const onClick = interaction("click");
|
|
59
|
+
const onCopy = interaction("copy");
|
|
60
|
+
const onToggle = (event) => {
|
|
61
|
+
if (event.target.open) interaction("expand")(event);
|
|
62
|
+
};
|
|
63
|
+
const onSubmit = (event) => {
|
|
64
|
+
const goal = event.target.closest?.("form")?.getAttribute("data-ge-goal");
|
|
65
|
+
if (goal) conversion(goal);
|
|
66
|
+
};
|
|
67
|
+
const onHide = () => {
|
|
68
|
+
if (root.visibilityState === "hidden") flush();
|
|
69
|
+
};
|
|
70
|
+
const conversion = (goal, value) => {
|
|
71
|
+
push("conversion", value === void 0 ? { goal } : { goal, value });
|
|
72
|
+
flush();
|
|
73
|
+
};
|
|
74
|
+
root.addEventListener("click", onClick, true);
|
|
75
|
+
root.addEventListener("copy", onCopy, true);
|
|
76
|
+
root.addEventListener("toggle", onToggle, true);
|
|
77
|
+
root.addEventListener("submit", onSubmit, true);
|
|
78
|
+
root.addEventListener("visibilitychange", onHide);
|
|
79
|
+
win.addEventListener("pagehide", flush);
|
|
80
|
+
const timer = win.setInterval(flush, FLUSH_INTERVAL_MS);
|
|
81
|
+
return {
|
|
82
|
+
conversion,
|
|
83
|
+
flush,
|
|
84
|
+
stop({ discard = false } = {}) {
|
|
85
|
+
if (discard) queue.length = 0;
|
|
86
|
+
flush();
|
|
87
|
+
observer?.disconnect();
|
|
88
|
+
win.clearInterval(timer);
|
|
89
|
+
root.removeEventListener("click", onClick, true);
|
|
90
|
+
root.removeEventListener("copy", onCopy, true);
|
|
91
|
+
root.removeEventListener("toggle", onToggle, true);
|
|
92
|
+
root.removeEventListener("submit", onSubmit, true);
|
|
93
|
+
root.removeEventListener("visibilitychange", onHide);
|
|
94
|
+
win.removeEventListener("pagehide", flush);
|
|
95
|
+
}
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
function readSlot(el) {
|
|
99
|
+
const slotId = el.getAttribute(SLOT_ATTRIBUTES.slot);
|
|
100
|
+
if (!slotId) return null;
|
|
101
|
+
return {
|
|
102
|
+
slotId,
|
|
103
|
+
variantId: el.getAttribute(SLOT_ATTRIBUTES.variant) || null,
|
|
104
|
+
counterfactualVariantId: el.getAttribute(SLOT_ATTRIBUTES.counterfactual) || null
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
function closestSlot(target) {
|
|
108
|
+
const el = target instanceof Element ? target.closest(`[${SLOT_ATTRIBUTES.slot}]`) : null;
|
|
109
|
+
return el ? readSlot(el) : null;
|
|
110
|
+
}
|
|
111
|
+
function sessionIdFor(win) {
|
|
112
|
+
try {
|
|
113
|
+
const existing = win.sessionStorage.getItem(SESSION_KEY);
|
|
114
|
+
if (existing) return existing;
|
|
115
|
+
const id = uuid(win);
|
|
116
|
+
win.sessionStorage.setItem(SESSION_KEY, id);
|
|
117
|
+
return id;
|
|
118
|
+
} catch {
|
|
119
|
+
return uuid(win);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
function uuid(win) {
|
|
123
|
+
if (typeof win.crypto.randomUUID === "function") return win.crypto.randomUUID();
|
|
124
|
+
const b = win.crypto.getRandomValues(new Uint8Array(16));
|
|
125
|
+
b[6] = b[6] & 15 | 64;
|
|
126
|
+
b[8] = b[8] & 63 | 128;
|
|
127
|
+
const hex = Array.from(b, (x) => x.toString(16).padStart(2, "0")).join("");
|
|
128
|
+
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
|
|
129
|
+
}
|
|
130
|
+
function send(win, endpoint, payload, crossOrigin) {
|
|
131
|
+
const body = JSON.stringify(payload);
|
|
132
|
+
const type = crossOrigin ? "text/plain" : "application/json";
|
|
133
|
+
if (win.navigator.sendBeacon?.(endpoint, new Blob([body], { type }))) return;
|
|
134
|
+
void win.fetch(endpoint, {
|
|
135
|
+
method: "POST",
|
|
136
|
+
body,
|
|
137
|
+
keepalive: true,
|
|
138
|
+
credentials: crossOrigin ? "omit" : "same-origin",
|
|
139
|
+
headers: { "content-type": type }
|
|
140
|
+
}).catch((error) => console.warn("[ge] analytics beacon failed", error));
|
|
141
|
+
}
|
|
142
|
+
export {
|
|
143
|
+
COLLECT_PATH,
|
|
144
|
+
PIN_KEY,
|
|
145
|
+
SESSION_KEY,
|
|
146
|
+
startTracker
|
|
147
|
+
};
|
package/dist/v1.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"use strict";(()=>{var Te=Object.defineProperty;var Ve=(e,t)=>{for(var n in t)Te(e,n,{get:t[n],enumerable:!0})};var ee={};Ve(ee,{ANTI_FLICKER_SNIPPET:()=>me,DEFAULT_API_BASE:()=>X,DEFAULT_TIMEOUT_MS:()=>U,VISITOR_COOKIE:()=>R,antiFlickerStart:()=>q,applyDecision:()=>J,conversion:()=>We,createClient:()=>F,hashSlotDefaults:()=>O,init:()=>Q,isWritable:()=>Z,pageIdFor:()=>Y,parseResolveResponse:()=>$,refresh:()=>qe,reveal:()=>B,scanSlots:()=>W,setConsent:()=>He});var d={slot:"data-ge-slot",variant:"data-ge-variant",counterfactual:"data-ge-counterfactual",reason:"data-ge-reason"};var Oe="/api/collect",D="ge_sid",w="ge_pin",ne=.5,Ce=2e3;function re({pageId:e,configVersion:t,endpoint:n=Oe,root:o=document,match:r,envelope:s=c=>({v:1,events:c,...r?{m:r}:{}}),crossOrigin:a=!1}){let c=o.defaultView??window,u=be(c),f=[],h=new Set,A=(l,p)=>{f.push({type:l,sessionId:u,...p}),f.length>=50&&m()},m=()=>{for(;f.length;)Pe(c,n,s(f.splice(0,50)),a)};A("page_view",{pageId:e,configVersion:t});let i=Array.from(o.querySelectorAll(`[${d.slot}]`)),v="IntersectionObserver"in c?new c.IntersectionObserver(l=>{for(let p of l){if(!p.isIntersecting||p.intersectionRatio<ne)continue;let g=se(p.target);v?.unobserve(p.target),!(!g||h.has(g.slotId))&&(h.add(g.slotId),A("slot_impression",g))}},{threshold:ne}):null;for(let l of i)v?.observe(l);let I=l=>p=>{let g=ke(p.target);g&&A("interaction",{slotId:g.slotId,variantId:g.variantId,action:l})},C=I("click"),_=I("copy"),N=l=>{l.target.open&&I("expand")(l)},k=l=>{let p=l.target.closest?.("form")?.getAttribute("data-ge-goal");p&&b(p)},x=()=>{o.visibilityState==="hidden"&&m()},b=(l,p)=>{A("conversion",p===void 0?{goal:l}:{goal:l,value:p}),m()};o.addEventListener("click",C,!0),o.addEventListener("copy",_,!0),o.addEventListener("toggle",N,!0),o.addEventListener("submit",k,!0),o.addEventListener("visibilitychange",x),c.addEventListener("pagehide",m);let y=c.setInterval(m,Ce);return{conversion:b,flush:m,stop({discard:l=!1}={}){l&&(f.length=0),m(),v?.disconnect(),c.clearInterval(y),o.removeEventListener("click",C,!0),o.removeEventListener("copy",_,!0),o.removeEventListener("toggle",N,!0),o.removeEventListener("submit",k,!0),o.removeEventListener("visibilitychange",x),c.removeEventListener("pagehide",m)}}}function se(e){let t=e.getAttribute(d.slot);return t?{slotId:t,variantId:e.getAttribute(d.variant)||null,counterfactualVariantId:e.getAttribute(d.counterfactual)||null}:null}function ke(e){let t=e instanceof Element?e.closest(`[${d.slot}]`):null;return t?se(t):null}function be(e){try{let t=e.sessionStorage.getItem(D);if(t)return t;let n=oe(e);return e.sessionStorage.setItem(D,n),n}catch{return oe(e)}}function oe(e){if(typeof e.crypto.randomUUID=="function")return e.crypto.randomUUID();let t=e.crypto.getRandomValues(new Uint8Array(16));t[6]=t[6]&15|64,t[8]=t[8]&63|128;let n=Array.from(t,o=>o.toString(16).padStart(2,"0")).join("");return`${n.slice(0,8)}-${n.slice(8,12)}-${n.slice(12,16)}-${n.slice(16,20)}-${n.slice(20)}`}function Pe(e,t,n,o){let r=JSON.stringify(n),s=o?"text/plain":"application/json";e.navigator.sendBeacon?.(t,new Blob([r],{type:s}))||e.fetch(t,{method:"POST",body:r,keepalive:!0,credentials:o?"omit":"same-origin",headers:{"content-type":s}}).catch(a=>console.warn("[ge] analytics beacon failed",a))}var S="ge_preview";var ie=/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/;function T(e){return typeof e=="string"&&ie.test(e)}function O(e){let t=JSON.stringify(Object.keys(e).sort().map(o=>[o,e[o]])),n=2166136261;for(let o=0;o<t.length;o++)n^=t.charCodeAt(o),n=Math.imul(n,16777619);return(n>>>0).toString(16).padStart(8,"0")}var ce="/api/v1/resolve",le="/api/v1/collect",Be=["matched","default","holdout"];function $(e){if(!M(e)||e.v!==1||!M(e.decisions)||!Number.isSafeInteger(e.configVersion)||typeof e.persist!="boolean")return null;let t=T(e.vid)?e.vid:void 0,n=e.persist&&t!==void 0,o=n&&typeof e.pin=="string"&&/^\d{1,9}\.\d{1,9}$/.test(e.pin)?e.pin:void 0,r={};for(let[a,c]of Object.entries(e.decisions)){let u=Le(c);u&&(r[a]=u)}let s=n&&typeof e.m=="string"&&e.m.length<=1024?e.m:void 0;return e.preview===!0?{v:1,configVersion:e.configVersion,persist:!1,preview:!0,decisions:r}:{v:1,configVersion:e.configVersion,persist:n,vid:t,...o?{pin:o}:{},decisions:r,...s?{m:s}:{}}}function Le(e){if(!M(e)||!M(e.content)||!ae(e.variantId)||!ae(e.counterfactualVariantId)||!Be.includes(e.reason))return null;let t={};for(let[n,o]of Object.entries(e.content))typeof o=="string"&&(t[n]=o);return{variantId:e.variantId,counterfactualVariantId:e.counterfactualVariantId,reason:e.reason,content:t}}function M(e){return typeof e=="object"&&e!==null&&!Array.isArray(e)}function ae(e){return e===null||typeof e=="string"}var X="https://kharonlabs.com",U=500,Ne=5e3,R="ge_vid",De=365*24*60*60;function F(e){let t=e.root??document,n=t.defaultView??window,o=(e.apiBase??X).replace(/\/+$/,""),r=new Set,s=e.consent??"unknown",a=!1,c,u=null,f=!1,h=(i=!1)=>{u?.stop({discard:i}),u=null},A=()=>{a=!1,c=void 0,h(!0),Ke(t,R,e.cookieDomain);try{n.sessionStorage.removeItem(D),n.sessionStorage.removeItem(w)}catch{}},m=i=>{if(i.preview){f=!0,h(!0);return}i.persist&&i.vid&&s!=="denied"?(a=!0,c=i.m,K(t,R)!==i.vid&&$e(t,R,i.vid,e.cookieDomain),i.pin&&ue(n,w,i.pin)):(a||K(t,R))&&A()};return{get consent(){return s},async resolve(i,v=Date.now()+Ne){let I=K(t,R),C=pe(n,w),_=we(n),N={v:1,key:e.key,...T(I)?{vid:I}:{},...C?{pin:C}:{},url:_.url,referrer:t.referrer,slots:i.map(y=>({id:y.id,h:O(y.defaults)})),consent:s,gpc:n.navigator.globalPrivacyControl===!0},k=v-Date.now();if(k<=0||typeof n.fetch!="function")return null;let x=typeof AbortController=="function"?new AbortController:null,b;try{let y=n.fetch(o+ce,{method:"POST",body:JSON.stringify(N),credentials:"omit",signal:x?.signal}),l=new Promise(_e=>{b=setTimeout(()=>{x?.abort(),_e(null)},k)}),p=await Promise.race([y,l]);if(!p)return null;if(!p.ok)return console.warn(`[kharon] resolve answered ${p.status}; keeping defaults`),null;let g=$(await Promise.race([p.json(),l]));return!g||Date.now()>v?null:(g.preview&&_.token?ue(n,S,_.token):!g.preview&&_.token&&Me(n,S),m(g),g)}catch(y){return x?.signal.aborted||console.warn("[kharon] resolve failed; keeping defaults",y),null}finally{clearTimeout(b)}},adopt:m,track(i){h();let v=K(t,R);f||!a||!T(v)||(u=re({pageId:e.pageId??Y(n.location.pathname),configVersion:i,endpoint:o+le,root:t,crossOrigin:!0,envelope:I=>({v:2,key:e.key,vid:v,consent:s,events:I,...c?{m:c}:{}})}))},setConsent(i){if(i!==s){s=i,i==="denied"&&A();for(let v of r)v(i)}},onConsentChange(i){return r.add(i),()=>r.delete(i)},conversion(i,v){u?.conversion(i,v)},stop:()=>h()}}function Y(e){return e.split("/").map(n=>n.replace(/[^A-Za-z0-9_:-]/g,"")).filter(Boolean).join(".").slice(0,128)||"home"}function K(e,t){for(let n of e.cookie.split(";")){let[o,...r]=n.trim().split("=");if(o===t)try{return decodeURIComponent(r.join("="))}catch{return}}}function pe(e,t){try{return e.sessionStorage.getItem(t)}catch{return null}}function we(e){let t=e.location.href,n;try{n=new URL(t)}catch{return{url:t,token:null}}let o=n.searchParams.get(S);if(o)return{url:t,token:o};let r=pe(e,S);return r?(n.searchParams.set(S,r),{url:n.toString(),token:r}):{url:t,token:null}}function Me(e,t){try{e.sessionStorage.removeItem(t)}catch{}}function ue(e,t,n){try{e.sessionStorage.setItem(t,n)}catch{}}function de(e,t){let n=e.location?.protocol==="https:"?"; Secure":"";return`; Path=/; SameSite=Lax${t?`; Domain=${t}`:""}${n}`}function $e(e,t,n,o){e.cookie=`${t}=${encodeURIComponent(n)}; Max-Age=${De}${de(e,o)}`}function Ke(e,t,n){e.cookie=`${t}=; Max-Age=0${de(e,n)}`}var fe="data-ge-field",ge="data-ge-attr",G="kharon-af",me=`<style id="${G}">[${d.slot}]{visibility:hidden!important}</style><script>window.__kharonAf=Date.now();setTimeout(function(){var s=document.getElementById("${G}");s&&s.parentNode.removeChild(s)},500)<\/script>`,Ue=new Set(["href","src","action","formaction","poster","cite","xlink:href"]),Fe=/^(on|style$|srcdoc$|srcset$)/,H=new WeakMap,P="#text";function Ee(e,t){return t===null?(e.textContent??"").trim():e.getAttribute(t)}function Ie(e,t){let n=H.get(e);return n?.has(t??P)?n.get(t??P):Ee(e,t)}function ve(e,t,n){let o=H.get(e);o||H.set(e,o=new Map),o.has(t??P)||o.set(t??P,Ee(e,t)),t===null?e.textContent=n??"":n===null?e.removeAttribute(t):e.setAttribute(t,n)}function W(e){let t=new Map;for(let n of Se(e)){let o=n.getAttribute(d.slot);if(t.has(o))continue;let r={};for(let s of Re(n))s.field in r||(r[s.field]=Ie(s.el,s.attr)??"");t.set(o,{id:o,defaults:r})}return[...t.values()]}function ye(e,t){for(let n of Se(e)){let o=t.decisions[n.getAttribute(d.slot)];o&&J(n,o,e.baseURI)}}function J(e,t,n){for(let{el:o,field:r,attr:s}of Re(e)){let a=t.content[r];typeof a=="string"&&(s===null||Z(s,a,n))?ve(o,s,a):H.get(o)?.has(s??P)&&ve(o,s,Ie(o,s))}e.setAttribute(d.variant,t.variantId??""),e.setAttribute(d.counterfactual,t.counterfactualVariantId??""),e.setAttribute(d.reason,t.reason)}function B(e){e.getElementById(G)?.remove()}function q(e){let t=e.defaultView?.__kharonAf;return typeof t=="number"?t:void 0}function Z(e,t,n){let o=e.toLowerCase();if(Fe.test(o))return!1;if(!Ue.has(o))return!0;try{let{protocol:r}=new URL(t,n);return r==="http:"||r==="https:"}catch{return!1}}function Se(e){return Array.from(e.querySelectorAll(`[${d.slot}]`))}function Re(e){let t=[],n=[e,...Array.from(e.querySelectorAll(`[${fe}],[${ge}]`))];for(let o of n){if(o!==e&&o.closest(`[${d.slot}]`)!==e)continue;let r=o.getAttribute(fe);r&&t.push({el:o,field:r,attr:null});for(let s of(o.getAttribute(ge)??"").split(",")){let a=s.lastIndexOf(":"),c=s.slice(0,a).trim(),u=s.slice(a+1).trim();a>0&&c&&u&&t.push({el:o,field:u,attr:c})}}return t}var E=null,he;function Q(e){if(E)return console.warn("[kharon] init called twice; keeping the first instance"),E;let t=e.root??document,n=e.timeoutMs??U,o=F({...e,consent:he??e.consent}),r=async u=>{try{let f=await o.resolve(W(t),u);f&&ye(t,f),B(t),f&&o.track(f.configVersion)}catch(f){B(t),console.warn("[kharon] personalization failed; keeping defaults",f)}},a=new Promise(u=>{t.readyState!=="loading"?u():t.addEventListener("DOMContentLoaded",()=>u(),{once:!0})}).then(()=>r((q(t)??Date.now())+n));o.onConsentChange(u=>{u==="granted"&&(a=a.then(()=>r()))});let c={client:o,get ready(){return a},setConsent:o.setConsent,conversion:o.conversion,refresh(){return a=a.then(()=>r(Date.now()+n)),a},stop(){o.stop(),E===c&&(E=null)}};return E=c,c}function He(e){E?E.setConsent(e):he=e}function We(e,t){E?.conversion(e,t)}function qe(){return E?.refresh()??Promise.resolve()}var je=["granted","denied","unknown"],L=document.currentScript;window.kharon=ee;var Ae=L?.getAttribute("data-key");L&&Ae&&Q({key:Ae,apiBase:L.getAttribute("data-api")??new URL(L.src).origin,consent:je.find(e=>e===L.getAttribute("data-consent"))});})();
|
package/package.json
CHANGED
|
@@ -1,6 +1,34 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@kharon-labs/browser",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Personalize slots on any website with Kharon Labs: swaps in the variant for each visitor and measures lift against a holdout.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"repository": {
|
|
7
|
+
"type": "git",
|
|
8
|
+
"url": "git+https://github.com/alexarmenciu/growth-engine.git",
|
|
9
|
+
"directory": "packages/browser"
|
|
10
|
+
},
|
|
11
|
+
"homepage": "https://kharonlabs.com",
|
|
12
|
+
"type": "module",
|
|
13
|
+
"sideEffects": false,
|
|
14
|
+
"main": "./dist/index.cjs",
|
|
15
|
+
"module": "./dist/index.js",
|
|
16
|
+
"types": "./dist/index.d.ts",
|
|
17
|
+
"exports": {
|
|
18
|
+
".": {
|
|
19
|
+
"types": "./dist/index.d.ts",
|
|
20
|
+
"import": "./dist/index.js",
|
|
21
|
+
"require": "./dist/index.cjs"
|
|
22
|
+
},
|
|
23
|
+
"./tracker": {
|
|
24
|
+
"types": "./dist/tracker.d.ts",
|
|
25
|
+
"import": "./dist/tracker.js",
|
|
26
|
+
"require": "./dist/tracker.cjs"
|
|
27
|
+
}
|
|
28
|
+
},
|
|
29
|
+
"files": ["dist"],
|
|
30
|
+
"browserslist": ["defaults"],
|
|
31
|
+
"publishConfig": {
|
|
32
|
+
"access": "public"
|
|
33
|
+
}
|
|
34
|
+
}
|