@kharon-labs/browser 0.0.0-stage → 0.2.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 +98 -2
- package/dist/index.cjs +653 -0
- package/dist/index.d.ts +271 -0
- package/dist/index.js +630 -0
- package/dist/tracker.cjs +228 -0
- package/dist/tracker.d.ts +130 -0
- package/dist/tracker.js +205 -0
- package/dist/v1.js +1 -0
- package/package.json +27 -4
package/dist/index.cjs
ADDED
|
@@ -0,0 +1,653 @@
|
|
|
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/index.ts
|
|
21
|
+
var index_exports = {};
|
|
22
|
+
__export(index_exports, {
|
|
23
|
+
ANTI_FLICKER_SNIPPET: () => ANTI_FLICKER_SNIPPET,
|
|
24
|
+
DEFAULT_API_BASE: () => DEFAULT_API_BASE,
|
|
25
|
+
DEFAULT_TIMEOUT_MS: () => DEFAULT_TIMEOUT_MS,
|
|
26
|
+
VISITOR_COOKIE: () => VISITOR_COOKIE,
|
|
27
|
+
antiFlickerStart: () => antiFlickerStart,
|
|
28
|
+
applyDecision: () => applyDecision,
|
|
29
|
+
conversion: () => conversion,
|
|
30
|
+
createClient: () => createClient,
|
|
31
|
+
hashSlotDefaults: () => hashSlotDefaults,
|
|
32
|
+
init: () => init,
|
|
33
|
+
isWritable: () => isWritable,
|
|
34
|
+
pageIdFor: () => pageIdFor,
|
|
35
|
+
parseResolveResponse: () => parseResolveResponse,
|
|
36
|
+
refresh: () => refresh,
|
|
37
|
+
reveal: () => reveal,
|
|
38
|
+
scanSlots: () => scanSlots,
|
|
39
|
+
setConsent: () => setConsent,
|
|
40
|
+
signup: () => signup
|
|
41
|
+
});
|
|
42
|
+
module.exports = __toCommonJS(index_exports);
|
|
43
|
+
|
|
44
|
+
// src/engine/analytics/types.ts
|
|
45
|
+
var SIGNUP_GOAL = "signup";
|
|
46
|
+
|
|
47
|
+
// src/engine/analytics/touch.ts
|
|
48
|
+
var MAX_TOUCH_VALUE = 100;
|
|
49
|
+
var CLICK_ID_PARAMS = {
|
|
50
|
+
gclid: "google",
|
|
51
|
+
gbraid: "google",
|
|
52
|
+
wbraid: "google",
|
|
53
|
+
msclkid: "microsoft",
|
|
54
|
+
fbclid: "meta",
|
|
55
|
+
li_fat_id: "linkedin",
|
|
56
|
+
ttclid: "tiktok",
|
|
57
|
+
twclid: "x"
|
|
58
|
+
};
|
|
59
|
+
var CLICK_ID_KINDS = new Set(Object.values(CLICK_ID_PARAMS));
|
|
60
|
+
var UTM_FIELDS = ["source", "medium", "campaign", "term", "content"];
|
|
61
|
+
var KHARON_LINK_PARAM = "ge_co";
|
|
62
|
+
function touchFromUrl(url, referrer) {
|
|
63
|
+
const touch = {};
|
|
64
|
+
let page;
|
|
65
|
+
try {
|
|
66
|
+
page = new URL(url);
|
|
67
|
+
} catch {
|
|
68
|
+
return touch;
|
|
69
|
+
}
|
|
70
|
+
for (const field of UTM_FIELDS) {
|
|
71
|
+
const value = clean(page.searchParams.get(`utm_${field}`));
|
|
72
|
+
if (value) touch[field] = value;
|
|
73
|
+
}
|
|
74
|
+
for (const [param, kind] of Object.entries(CLICK_ID_PARAMS)) {
|
|
75
|
+
if (page.searchParams.get(param)) {
|
|
76
|
+
touch.clickId = kind;
|
|
77
|
+
break;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
if (page.searchParams.get(KHARON_LINK_PARAM)) touch.kharonLink = true;
|
|
81
|
+
const from = referrerHost(referrer);
|
|
82
|
+
if (from && from !== bareHost(page.hostname)) touch.referrer = from;
|
|
83
|
+
return touch;
|
|
84
|
+
}
|
|
85
|
+
function clean(value) {
|
|
86
|
+
const trimmed = value?.replace(/[\u0000-\u001f\u007f]/g, "").trim();
|
|
87
|
+
return trimmed ? trimmed.slice(0, MAX_TOUCH_VALUE) : null;
|
|
88
|
+
}
|
|
89
|
+
function referrerHost(referrer) {
|
|
90
|
+
if (!referrer) return null;
|
|
91
|
+
try {
|
|
92
|
+
const { protocol, hostname } = new URL(referrer);
|
|
93
|
+
return protocol === "http:" || protocol === "https:" ? bareHost(hostname) : null;
|
|
94
|
+
} catch {
|
|
95
|
+
return null;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
function bareHost(hostname) {
|
|
99
|
+
return hostname.toLowerCase().replace(/^www\./, "");
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// src/engine/analytics/beacon.ts
|
|
103
|
+
var BEACON_VERSION = 1;
|
|
104
|
+
var MAX_BEACON_EVENTS = 50;
|
|
105
|
+
var SLOT_ATTRIBUTES = {
|
|
106
|
+
slot: "data-ge-slot",
|
|
107
|
+
variant: "data-ge-variant",
|
|
108
|
+
counterfactual: "data-ge-counterfactual",
|
|
109
|
+
/** Why the slot shows what it shows (matched, default, holdout). For debugging; not sent. */
|
|
110
|
+
reason: "data-ge-reason",
|
|
111
|
+
/** Which step of the fallback ladder chose the content (exact, near, segment, default). Sent on impressions. */
|
|
112
|
+
rung: "data-ge-rung"
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
// packages/browser/src/tracker.ts
|
|
116
|
+
var COLLECT_PATH = "/api/collect";
|
|
117
|
+
var SESSION_KEY = "ge_sid";
|
|
118
|
+
var PIN_KEY = "ge_pin";
|
|
119
|
+
var VISIBLE_RATIO = 0.5;
|
|
120
|
+
var FLUSH_INTERVAL_MS = 2e3;
|
|
121
|
+
function startTracker({
|
|
122
|
+
pageId,
|
|
123
|
+
configVersion,
|
|
124
|
+
endpoint = COLLECT_PATH,
|
|
125
|
+
root = document,
|
|
126
|
+
match,
|
|
127
|
+
envelope = (events) => ({ v: BEACON_VERSION, events, ...match ? { m: match } : {} }),
|
|
128
|
+
crossOrigin = false
|
|
129
|
+
}) {
|
|
130
|
+
const win = root.defaultView ?? window;
|
|
131
|
+
const { id: sessionId, started } = sessionFor(win);
|
|
132
|
+
const queue = [];
|
|
133
|
+
const seenSlots = /* @__PURE__ */ new Set();
|
|
134
|
+
const push = (type, body) => {
|
|
135
|
+
queue.push({ type, sessionId, ...body });
|
|
136
|
+
if (queue.length >= MAX_BEACON_EVENTS) flush();
|
|
137
|
+
};
|
|
138
|
+
const flush = () => {
|
|
139
|
+
while (queue.length) send(win, endpoint, envelope(queue.splice(0, MAX_BEACON_EVENTS)), crossOrigin);
|
|
140
|
+
};
|
|
141
|
+
push("page_view", started ? { pageId, configVersion, touch: touchFromUrl(win.location.href, root.referrer) } : { pageId, configVersion });
|
|
142
|
+
const slots = Array.from(root.querySelectorAll(`[${SLOT_ATTRIBUTES.slot}]`));
|
|
143
|
+
const observer = "IntersectionObserver" in win ? new win.IntersectionObserver(
|
|
144
|
+
(entries) => {
|
|
145
|
+
for (const entry of entries) {
|
|
146
|
+
if (!entry.isIntersecting || entry.intersectionRatio < VISIBLE_RATIO) continue;
|
|
147
|
+
const slot = readSlot(entry.target);
|
|
148
|
+
observer?.unobserve(entry.target);
|
|
149
|
+
if (!slot || seenSlots.has(slot.slotId)) continue;
|
|
150
|
+
seenSlots.add(slot.slotId);
|
|
151
|
+
push("slot_impression", slot);
|
|
152
|
+
}
|
|
153
|
+
},
|
|
154
|
+
{ threshold: VISIBLE_RATIO }
|
|
155
|
+
) : null;
|
|
156
|
+
for (const slot of slots) observer?.observe(slot);
|
|
157
|
+
const interaction = (action) => (event) => {
|
|
158
|
+
const slot = closestSlot(event.target);
|
|
159
|
+
if (slot) push("interaction", { slotId: slot.slotId, variantId: slot.variantId, action });
|
|
160
|
+
};
|
|
161
|
+
const onClick = interaction("click");
|
|
162
|
+
const onCopy = interaction("copy");
|
|
163
|
+
const onToggle = (event) => {
|
|
164
|
+
if (event.target.open) interaction("expand")(event);
|
|
165
|
+
};
|
|
166
|
+
const onSubmit = (event) => {
|
|
167
|
+
const goal = event.target.closest?.("form")?.getAttribute("data-ge-goal");
|
|
168
|
+
if (goal) conversion2(goal);
|
|
169
|
+
};
|
|
170
|
+
const onHide = () => {
|
|
171
|
+
if (root.visibilityState === "hidden") flush();
|
|
172
|
+
};
|
|
173
|
+
const conversion2 = (goal, value) => {
|
|
174
|
+
push("conversion", value === void 0 ? { goal } : { goal, value });
|
|
175
|
+
flush();
|
|
176
|
+
};
|
|
177
|
+
root.addEventListener("click", onClick, true);
|
|
178
|
+
root.addEventListener("copy", onCopy, true);
|
|
179
|
+
root.addEventListener("toggle", onToggle, true);
|
|
180
|
+
root.addEventListener("submit", onSubmit, true);
|
|
181
|
+
root.addEventListener("visibilitychange", onHide);
|
|
182
|
+
win.addEventListener("pagehide", flush);
|
|
183
|
+
const timer = win.setInterval(flush, FLUSH_INTERVAL_MS);
|
|
184
|
+
return {
|
|
185
|
+
conversion: conversion2,
|
|
186
|
+
flush,
|
|
187
|
+
stop({ discard = false } = {}) {
|
|
188
|
+
if (discard) queue.length = 0;
|
|
189
|
+
flush();
|
|
190
|
+
observer?.disconnect();
|
|
191
|
+
win.clearInterval(timer);
|
|
192
|
+
root.removeEventListener("click", onClick, true);
|
|
193
|
+
root.removeEventListener("copy", onCopy, true);
|
|
194
|
+
root.removeEventListener("toggle", onToggle, true);
|
|
195
|
+
root.removeEventListener("submit", onSubmit, true);
|
|
196
|
+
root.removeEventListener("visibilitychange", onHide);
|
|
197
|
+
win.removeEventListener("pagehide", flush);
|
|
198
|
+
}
|
|
199
|
+
};
|
|
200
|
+
}
|
|
201
|
+
function readSlot(el) {
|
|
202
|
+
const slotId = el.getAttribute(SLOT_ATTRIBUTES.slot);
|
|
203
|
+
if (!slotId) return null;
|
|
204
|
+
return {
|
|
205
|
+
slotId,
|
|
206
|
+
variantId: el.getAttribute(SLOT_ATTRIBUTES.variant) || null,
|
|
207
|
+
counterfactualVariantId: el.getAttribute(SLOT_ATTRIBUTES.counterfactual) || null,
|
|
208
|
+
...el.getAttribute(SLOT_ATTRIBUTES.rung) ? { rung: el.getAttribute(SLOT_ATTRIBUTES.rung) } : {}
|
|
209
|
+
};
|
|
210
|
+
}
|
|
211
|
+
function closestSlot(target) {
|
|
212
|
+
const el = target instanceof Element ? target.closest(`[${SLOT_ATTRIBUTES.slot}]`) : null;
|
|
213
|
+
return el ? readSlot(el) : null;
|
|
214
|
+
}
|
|
215
|
+
function sessionFor(win) {
|
|
216
|
+
try {
|
|
217
|
+
const existing = win.sessionStorage.getItem(SESSION_KEY);
|
|
218
|
+
if (existing) return { id: existing, started: false };
|
|
219
|
+
const id = uuid(win);
|
|
220
|
+
win.sessionStorage.setItem(SESSION_KEY, id);
|
|
221
|
+
return { id, started: true };
|
|
222
|
+
} catch {
|
|
223
|
+
return { id: uuid(win), started: true };
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
function uuid(win) {
|
|
227
|
+
if (typeof win.crypto.randomUUID === "function") return win.crypto.randomUUID();
|
|
228
|
+
const b = win.crypto.getRandomValues(new Uint8Array(16));
|
|
229
|
+
b[6] = b[6] & 15 | 64;
|
|
230
|
+
b[8] = b[8] & 63 | 128;
|
|
231
|
+
const hex = Array.from(b, (x) => x.toString(16).padStart(2, "0")).join("");
|
|
232
|
+
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
|
|
233
|
+
}
|
|
234
|
+
function send(win, endpoint, payload, crossOrigin) {
|
|
235
|
+
const body = JSON.stringify(payload);
|
|
236
|
+
const type = crossOrigin ? "text/plain" : "application/json";
|
|
237
|
+
if (win.navigator.sendBeacon?.(endpoint, new Blob([body], { type }))) return;
|
|
238
|
+
void win.fetch(endpoint, {
|
|
239
|
+
method: "POST",
|
|
240
|
+
body,
|
|
241
|
+
keepalive: true,
|
|
242
|
+
credentials: crossOrigin ? "omit" : "same-origin",
|
|
243
|
+
headers: { "content-type": type }
|
|
244
|
+
}).catch((error) => console.warn("[ge] analytics beacon failed", error));
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// src/engine/snippet/wire.ts
|
|
248
|
+
var RESOLVE_API_VERSION = 1;
|
|
249
|
+
var PREVIEW_PARAM = "ge_preview";
|
|
250
|
+
var BEACON_V2_VERSION = 2;
|
|
251
|
+
var VISITOR_ID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/;
|
|
252
|
+
function isVisitorId(value) {
|
|
253
|
+
return typeof value === "string" && VISITOR_ID_PATTERN.test(value);
|
|
254
|
+
}
|
|
255
|
+
function hashSlotDefaults(content) {
|
|
256
|
+
const canonical = JSON.stringify(Object.keys(content).sort().map((field) => [field, content[field]]));
|
|
257
|
+
let hash = 2166136261;
|
|
258
|
+
for (let i = 0; i < canonical.length; i++) {
|
|
259
|
+
hash ^= canonical.charCodeAt(i);
|
|
260
|
+
hash = Math.imul(hash, 16777619);
|
|
261
|
+
}
|
|
262
|
+
return (hash >>> 0).toString(16).padStart(8, "0");
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
// packages/browser/src/wire.ts
|
|
266
|
+
var RESOLVE_PATH = "/api/v1/resolve";
|
|
267
|
+
var COLLECT_V2_PATH = "/api/v1/collect";
|
|
268
|
+
var REASONS = ["matched", "default", "holdout"];
|
|
269
|
+
var RUNGS = ["exact", "near", "segment", "default"];
|
|
270
|
+
function parseResolveResponse(body) {
|
|
271
|
+
if (!isRecord(body) || body.v !== RESOLVE_API_VERSION || !isRecord(body.decisions)) return null;
|
|
272
|
+
if (!Number.isSafeInteger(body.configVersion) || typeof body.persist !== "boolean") return null;
|
|
273
|
+
const vid = isVisitorId(body.vid) ? body.vid : void 0;
|
|
274
|
+
const persist = body.persist && vid !== void 0;
|
|
275
|
+
const pin = persist && typeof body.pin === "string" && /^\d{1,9}\.\d{1,9}$/.test(body.pin) ? body.pin : void 0;
|
|
276
|
+
const decisions = {};
|
|
277
|
+
for (const [id, raw] of Object.entries(body.decisions)) {
|
|
278
|
+
const decision = parseDecision(raw);
|
|
279
|
+
if (decision) decisions[id] = decision;
|
|
280
|
+
}
|
|
281
|
+
const m = persist && typeof body.m === "string" && body.m.length <= 1024 ? body.m : void 0;
|
|
282
|
+
if (body.preview === true) {
|
|
283
|
+
const explain = isRecord(body.explain) ? body.explain : void 0;
|
|
284
|
+
return { v: RESOLVE_API_VERSION, configVersion: body.configVersion, persist: false, preview: true, decisions, ...explain ? { explain } : {} };
|
|
285
|
+
}
|
|
286
|
+
return { v: RESOLVE_API_VERSION, configVersion: body.configVersion, persist, vid, ...pin ? { pin } : {}, decisions, ...m ? { m } : {} };
|
|
287
|
+
}
|
|
288
|
+
function parseDecision(raw) {
|
|
289
|
+
if (!isRecord(raw) || !isRecord(raw.content)) return null;
|
|
290
|
+
if (!isOptionalString(raw.variantId) || !isOptionalString(raw.counterfactualVariantId)) return null;
|
|
291
|
+
if (!REASONS.includes(raw.reason)) return null;
|
|
292
|
+
const content = {};
|
|
293
|
+
for (const [field, value] of Object.entries(raw.content)) if (typeof value === "string") content[field] = value;
|
|
294
|
+
const rung = RUNGS.includes(raw.rung) ? raw.rung : void 0;
|
|
295
|
+
return {
|
|
296
|
+
variantId: raw.variantId,
|
|
297
|
+
counterfactualVariantId: raw.counterfactualVariantId,
|
|
298
|
+
reason: raw.reason,
|
|
299
|
+
...rung && { rung },
|
|
300
|
+
content
|
|
301
|
+
};
|
|
302
|
+
}
|
|
303
|
+
function isRecord(value) {
|
|
304
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
305
|
+
}
|
|
306
|
+
function isOptionalString(value) {
|
|
307
|
+
return value === null || typeof value === "string";
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
// packages/browser/src/client.ts
|
|
311
|
+
var DEFAULT_API_BASE = "https://kharonlabs.com";
|
|
312
|
+
var DEFAULT_TIMEOUT_MS = 500;
|
|
313
|
+
var BACKGROUND_TIMEOUT_MS = 5e3;
|
|
314
|
+
var VISITOR_COOKIE = "ge_vid";
|
|
315
|
+
var COOKIE_MAX_AGE_S = 365 * 24 * 60 * 60;
|
|
316
|
+
function createClient(options) {
|
|
317
|
+
const doc = options.root ?? document;
|
|
318
|
+
const win = doc.defaultView ?? window;
|
|
319
|
+
const apiBase = (options.apiBase ?? DEFAULT_API_BASE).replace(/\/+$/, "");
|
|
320
|
+
const listeners = /* @__PURE__ */ new Set();
|
|
321
|
+
let consent = options.consent ?? "unknown";
|
|
322
|
+
let persisted = false;
|
|
323
|
+
let match;
|
|
324
|
+
let tracker = null;
|
|
325
|
+
let previewing = false;
|
|
326
|
+
const stopTracking = (discard = false) => {
|
|
327
|
+
tracker?.stop({ discard });
|
|
328
|
+
tracker = null;
|
|
329
|
+
};
|
|
330
|
+
const forget = () => {
|
|
331
|
+
persisted = false;
|
|
332
|
+
match = void 0;
|
|
333
|
+
stopTracking(true);
|
|
334
|
+
deleteCookie(doc, VISITOR_COOKIE, options.cookieDomain);
|
|
335
|
+
try {
|
|
336
|
+
win.sessionStorage.removeItem(SESSION_KEY);
|
|
337
|
+
win.sessionStorage.removeItem(PIN_KEY);
|
|
338
|
+
} catch {
|
|
339
|
+
}
|
|
340
|
+
};
|
|
341
|
+
const adopt = (response) => {
|
|
342
|
+
if (response.preview) {
|
|
343
|
+
previewing = true;
|
|
344
|
+
stopTracking(true);
|
|
345
|
+
if (response.explain) win.console?.info("[kharon] debug link: why this page shows what it does", response.explain);
|
|
346
|
+
return;
|
|
347
|
+
}
|
|
348
|
+
if (response.persist && response.vid && consent !== "denied") {
|
|
349
|
+
persisted = true;
|
|
350
|
+
match = response.m;
|
|
351
|
+
if (readCookie(doc, VISITOR_COOKIE) !== response.vid) setCookie(doc, VISITOR_COOKIE, response.vid, options.cookieDomain);
|
|
352
|
+
if (response.pin) writeSession(win, PIN_KEY, response.pin);
|
|
353
|
+
} else if (persisted || readCookie(doc, VISITOR_COOKIE)) {
|
|
354
|
+
forget();
|
|
355
|
+
}
|
|
356
|
+
};
|
|
357
|
+
return {
|
|
358
|
+
get consent() {
|
|
359
|
+
return consent;
|
|
360
|
+
},
|
|
361
|
+
async resolve(slots, deadline = Date.now() + BACKGROUND_TIMEOUT_MS) {
|
|
362
|
+
const vid = readCookie(doc, VISITOR_COOKIE);
|
|
363
|
+
const pin = readSession(win, PIN_KEY);
|
|
364
|
+
const page = previewPage(win);
|
|
365
|
+
const body = {
|
|
366
|
+
v: RESOLVE_API_VERSION,
|
|
367
|
+
key: options.key,
|
|
368
|
+
...isVisitorId(vid) ? { vid } : {},
|
|
369
|
+
...pin ? { pin } : {},
|
|
370
|
+
url: page.url,
|
|
371
|
+
referrer: doc.referrer,
|
|
372
|
+
slots: slots.map((slot) => ({ id: slot.id, h: hashSlotDefaults(slot.defaults) })),
|
|
373
|
+
consent,
|
|
374
|
+
gpc: win.navigator.globalPrivacyControl === true
|
|
375
|
+
};
|
|
376
|
+
const remaining = deadline - Date.now();
|
|
377
|
+
if (remaining <= 0 || typeof win.fetch !== "function") return null;
|
|
378
|
+
const controller = typeof AbortController === "function" ? new AbortController() : null;
|
|
379
|
+
let timer;
|
|
380
|
+
try {
|
|
381
|
+
const request = win.fetch(apiBase + RESOLVE_PATH, { method: "POST", body: JSON.stringify(body), credentials: "omit", signal: controller?.signal });
|
|
382
|
+
const timeout = new Promise((resolve) => {
|
|
383
|
+
timer = setTimeout(() => {
|
|
384
|
+
controller?.abort();
|
|
385
|
+
resolve(null);
|
|
386
|
+
}, remaining);
|
|
387
|
+
});
|
|
388
|
+
const res = await Promise.race([request, timeout]);
|
|
389
|
+
if (!res) return null;
|
|
390
|
+
if (!res.ok) {
|
|
391
|
+
console.warn(`[kharon] resolve answered ${res.status}; keeping defaults`);
|
|
392
|
+
return null;
|
|
393
|
+
}
|
|
394
|
+
const parsed = parseResolveResponse(await Promise.race([res.json(), timeout]));
|
|
395
|
+
if (!parsed || Date.now() > deadline) return null;
|
|
396
|
+
if (parsed.preview && page.token) writeSession(win, PREVIEW_PARAM, page.token);
|
|
397
|
+
else if (!parsed.preview && page.token) removeSession(win, PREVIEW_PARAM);
|
|
398
|
+
adopt(parsed);
|
|
399
|
+
return parsed;
|
|
400
|
+
} catch (error) {
|
|
401
|
+
if (!controller?.signal.aborted) console.warn("[kharon] resolve failed; keeping defaults", error);
|
|
402
|
+
return null;
|
|
403
|
+
} finally {
|
|
404
|
+
clearTimeout(timer);
|
|
405
|
+
}
|
|
406
|
+
},
|
|
407
|
+
adopt,
|
|
408
|
+
track(configVersion) {
|
|
409
|
+
stopTracking();
|
|
410
|
+
const vid = readCookie(doc, VISITOR_COOKIE);
|
|
411
|
+
if (previewing || !persisted || !isVisitorId(vid)) return;
|
|
412
|
+
tracker = startTracker({
|
|
413
|
+
pageId: options.pageId ?? pageIdFor(win.location.pathname),
|
|
414
|
+
configVersion,
|
|
415
|
+
endpoint: apiBase + COLLECT_V2_PATH,
|
|
416
|
+
root: doc,
|
|
417
|
+
crossOrigin: true,
|
|
418
|
+
envelope: (events) => ({ v: BEACON_V2_VERSION, key: options.key, vid, consent, events, ...match ? { m: match } : {} })
|
|
419
|
+
});
|
|
420
|
+
},
|
|
421
|
+
setConsent(next) {
|
|
422
|
+
if (next === consent) return;
|
|
423
|
+
consent = next;
|
|
424
|
+
if (next === "denied") forget();
|
|
425
|
+
for (const listener of listeners) listener(next);
|
|
426
|
+
},
|
|
427
|
+
onConsentChange(listener) {
|
|
428
|
+
listeners.add(listener);
|
|
429
|
+
return () => listeners.delete(listener);
|
|
430
|
+
},
|
|
431
|
+
conversion(goal, value) {
|
|
432
|
+
tracker?.conversion(goal, value);
|
|
433
|
+
},
|
|
434
|
+
stop: () => stopTracking()
|
|
435
|
+
};
|
|
436
|
+
}
|
|
437
|
+
function pageIdFor(pathname) {
|
|
438
|
+
const id = pathname.split("/").map((part) => part.replace(/[^A-Za-z0-9_:-]/g, "")).filter(Boolean).join(".").slice(0, 128);
|
|
439
|
+
return id || "home";
|
|
440
|
+
}
|
|
441
|
+
function readCookie(doc, name) {
|
|
442
|
+
for (const part of doc.cookie.split(";")) {
|
|
443
|
+
const [k, ...v] = part.trim().split("=");
|
|
444
|
+
if (k !== name) continue;
|
|
445
|
+
try {
|
|
446
|
+
return decodeURIComponent(v.join("="));
|
|
447
|
+
} catch {
|
|
448
|
+
return void 0;
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
return void 0;
|
|
452
|
+
}
|
|
453
|
+
function readSession(win, key) {
|
|
454
|
+
try {
|
|
455
|
+
return win.sessionStorage.getItem(key);
|
|
456
|
+
} catch {
|
|
457
|
+
return null;
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
function previewPage(win) {
|
|
461
|
+
const url = win.location.href;
|
|
462
|
+
let parsed;
|
|
463
|
+
try {
|
|
464
|
+
parsed = new URL(url);
|
|
465
|
+
} catch {
|
|
466
|
+
return { url, token: null };
|
|
467
|
+
}
|
|
468
|
+
const inUrl = parsed.searchParams.get(PREVIEW_PARAM);
|
|
469
|
+
if (inUrl) return { url, token: inUrl };
|
|
470
|
+
const stored = readSession(win, PREVIEW_PARAM);
|
|
471
|
+
if (!stored) return { url, token: null };
|
|
472
|
+
parsed.searchParams.set(PREVIEW_PARAM, stored);
|
|
473
|
+
return { url: parsed.toString(), token: stored };
|
|
474
|
+
}
|
|
475
|
+
function removeSession(win, key) {
|
|
476
|
+
try {
|
|
477
|
+
win.sessionStorage.removeItem(key);
|
|
478
|
+
} catch {
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
function writeSession(win, key, value) {
|
|
482
|
+
try {
|
|
483
|
+
win.sessionStorage.setItem(key, value);
|
|
484
|
+
} catch {
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
function cookieSuffix(doc, domain) {
|
|
488
|
+
const secure = doc.location?.protocol === "https:" ? "; Secure" : "";
|
|
489
|
+
return `; Path=/; SameSite=Lax${domain ? `; Domain=${domain}` : ""}${secure}`;
|
|
490
|
+
}
|
|
491
|
+
function setCookie(doc, name, value, domain) {
|
|
492
|
+
doc.cookie = `${name}=${encodeURIComponent(value)}; Max-Age=${COOKIE_MAX_AGE_S}${cookieSuffix(doc, domain)}`;
|
|
493
|
+
}
|
|
494
|
+
function deleteCookie(doc, name, domain) {
|
|
495
|
+
doc.cookie = `${name}=; Max-Age=0${cookieSuffix(doc, domain)}`;
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
// packages/browser/src/dom.ts
|
|
499
|
+
var FIELD_ATTRIBUTE = "data-ge-field";
|
|
500
|
+
var ATTR_ATTRIBUTE = "data-ge-attr";
|
|
501
|
+
var ANTI_FLICKER_ID = "kharon-af";
|
|
502
|
+
var ANTI_FLICKER_SNIPPET = `<style id="${ANTI_FLICKER_ID}">[${SLOT_ATTRIBUTES.slot}]{visibility:hidden!important}</style><script>window.__kharonAf=Date.now();setTimeout(function(){var s=document.getElementById("${ANTI_FLICKER_ID}");s&&s.parentNode.removeChild(s)},500)<\/script>`;
|
|
503
|
+
var URL_ATTRIBUTES = /* @__PURE__ */ new Set(["href", "src", "action", "formaction", "poster", "cite", "xlink:href"]);
|
|
504
|
+
var FORBIDDEN_ATTRIBUTES = /^(on|style$|srcdoc$|srcset$)/;
|
|
505
|
+
var originals = /* @__PURE__ */ new WeakMap();
|
|
506
|
+
var TEXT = "#text";
|
|
507
|
+
function currentValue(el, attr) {
|
|
508
|
+
return attr === null ? (el.textContent ?? "").trim() : el.getAttribute(attr);
|
|
509
|
+
}
|
|
510
|
+
function originalValue(el, attr) {
|
|
511
|
+
const saved = originals.get(el);
|
|
512
|
+
return saved?.has(attr ?? TEXT) ? saved.get(attr ?? TEXT) : currentValue(el, attr);
|
|
513
|
+
}
|
|
514
|
+
function write(el, attr, value) {
|
|
515
|
+
let saved = originals.get(el);
|
|
516
|
+
if (!saved) originals.set(el, saved = /* @__PURE__ */ new Map());
|
|
517
|
+
if (!saved.has(attr ?? TEXT)) saved.set(attr ?? TEXT, currentValue(el, attr));
|
|
518
|
+
if (attr === null) el.textContent = value ?? "";
|
|
519
|
+
else if (value === null) el.removeAttribute(attr);
|
|
520
|
+
else el.setAttribute(attr, value);
|
|
521
|
+
}
|
|
522
|
+
function scanSlots(root) {
|
|
523
|
+
const slots = /* @__PURE__ */ new Map();
|
|
524
|
+
for (const el of slotElements(root)) {
|
|
525
|
+
const id = el.getAttribute(SLOT_ATTRIBUTES.slot);
|
|
526
|
+
if (slots.has(id)) continue;
|
|
527
|
+
const defaults = {};
|
|
528
|
+
for (const binding of bindings(el)) {
|
|
529
|
+
if (binding.field in defaults) continue;
|
|
530
|
+
defaults[binding.field] = originalValue(binding.el, binding.attr) ?? "";
|
|
531
|
+
}
|
|
532
|
+
slots.set(id, { id, defaults });
|
|
533
|
+
}
|
|
534
|
+
return [...slots.values()];
|
|
535
|
+
}
|
|
536
|
+
function applyDecisions(root, response) {
|
|
537
|
+
for (const el of slotElements(root)) {
|
|
538
|
+
const decision = response.decisions[el.getAttribute(SLOT_ATTRIBUTES.slot)];
|
|
539
|
+
if (decision) applyDecision(el, decision, root.baseURI);
|
|
540
|
+
}
|
|
541
|
+
}
|
|
542
|
+
function applyDecision(el, decision, baseURI) {
|
|
543
|
+
for (const { el: target, field, attr } of bindings(el)) {
|
|
544
|
+
const value = decision.content[field];
|
|
545
|
+
if (typeof value === "string" && (attr === null || isWritable(attr, value, baseURI))) write(target, attr, value);
|
|
546
|
+
else if (originals.get(target)?.has(attr ?? TEXT)) write(target, attr, originalValue(target, attr));
|
|
547
|
+
}
|
|
548
|
+
el.setAttribute(SLOT_ATTRIBUTES.variant, decision.variantId ?? "");
|
|
549
|
+
el.setAttribute(SLOT_ATTRIBUTES.counterfactual, decision.counterfactualVariantId ?? "");
|
|
550
|
+
el.setAttribute(SLOT_ATTRIBUTES.reason, decision.reason);
|
|
551
|
+
if (decision.rung) el.setAttribute(SLOT_ATTRIBUTES.rung, decision.rung);
|
|
552
|
+
else el.removeAttribute(SLOT_ATTRIBUTES.rung);
|
|
553
|
+
}
|
|
554
|
+
function reveal(root) {
|
|
555
|
+
root.getElementById(ANTI_FLICKER_ID)?.remove();
|
|
556
|
+
}
|
|
557
|
+
function antiFlickerStart(root) {
|
|
558
|
+
const start = root.defaultView?.__kharonAf;
|
|
559
|
+
return typeof start === "number" ? start : void 0;
|
|
560
|
+
}
|
|
561
|
+
function isWritable(attr, value, baseURI) {
|
|
562
|
+
const name = attr.toLowerCase();
|
|
563
|
+
if (FORBIDDEN_ATTRIBUTES.test(name)) return false;
|
|
564
|
+
if (!URL_ATTRIBUTES.has(name)) return true;
|
|
565
|
+
try {
|
|
566
|
+
const { protocol } = new URL(value, baseURI);
|
|
567
|
+
return protocol === "http:" || protocol === "https:";
|
|
568
|
+
} catch {
|
|
569
|
+
return false;
|
|
570
|
+
}
|
|
571
|
+
}
|
|
572
|
+
function slotElements(root) {
|
|
573
|
+
return Array.from(root.querySelectorAll(`[${SLOT_ATTRIBUTES.slot}]`));
|
|
574
|
+
}
|
|
575
|
+
function bindings(slot) {
|
|
576
|
+
const result = [];
|
|
577
|
+
const candidates = [slot, ...Array.from(slot.querySelectorAll(`[${FIELD_ATTRIBUTE}],[${ATTR_ATTRIBUTE}]`))];
|
|
578
|
+
for (const el of candidates) {
|
|
579
|
+
if (el !== slot && el.closest(`[${SLOT_ATTRIBUTES.slot}]`) !== slot) continue;
|
|
580
|
+
const field = el.getAttribute(FIELD_ATTRIBUTE);
|
|
581
|
+
if (field) result.push({ el, field, attr: null });
|
|
582
|
+
for (const pair of (el.getAttribute(ATTR_ATTRIBUTE) ?? "").split(",")) {
|
|
583
|
+
const at = pair.lastIndexOf(":");
|
|
584
|
+
const attr = pair.slice(0, at).trim();
|
|
585
|
+
const mapped = pair.slice(at + 1).trim();
|
|
586
|
+
if (at > 0 && attr && mapped) result.push({ el, field: mapped, attr });
|
|
587
|
+
}
|
|
588
|
+
}
|
|
589
|
+
return result;
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
// packages/browser/src/index.ts
|
|
593
|
+
var current = null;
|
|
594
|
+
var pendingConsent;
|
|
595
|
+
function init(options) {
|
|
596
|
+
if (current) {
|
|
597
|
+
console.warn("[kharon] init called twice; keeping the first instance");
|
|
598
|
+
return current;
|
|
599
|
+
}
|
|
600
|
+
const doc = options.root ?? document;
|
|
601
|
+
const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
602
|
+
const client = createClient({ ...options, consent: pendingConsent ?? options.consent });
|
|
603
|
+
const run = async (deadline) => {
|
|
604
|
+
try {
|
|
605
|
+
const response = await client.resolve(scanSlots(doc), deadline);
|
|
606
|
+
if (response) applyDecisions(doc, response);
|
|
607
|
+
reveal(doc);
|
|
608
|
+
if (response) client.track(response.configVersion);
|
|
609
|
+
} catch (error) {
|
|
610
|
+
reveal(doc);
|
|
611
|
+
console.warn("[kharon] personalization failed; keeping defaults", error);
|
|
612
|
+
}
|
|
613
|
+
};
|
|
614
|
+
const ready = new Promise((resolve) => {
|
|
615
|
+
if (doc.readyState !== "loading") resolve();
|
|
616
|
+
else doc.addEventListener("DOMContentLoaded", () => resolve(), { once: true });
|
|
617
|
+
});
|
|
618
|
+
let pending = ready.then(() => run((antiFlickerStart(doc) ?? Date.now()) + timeoutMs));
|
|
619
|
+
client.onConsentChange((consent) => {
|
|
620
|
+
if (consent === "granted") pending = pending.then(() => run());
|
|
621
|
+
});
|
|
622
|
+
const instance = {
|
|
623
|
+
client,
|
|
624
|
+
get ready() {
|
|
625
|
+
return pending;
|
|
626
|
+
},
|
|
627
|
+
setConsent: client.setConsent,
|
|
628
|
+
conversion: client.conversion,
|
|
629
|
+
refresh() {
|
|
630
|
+
pending = pending.then(() => run(Date.now() + timeoutMs));
|
|
631
|
+
return pending;
|
|
632
|
+
},
|
|
633
|
+
stop() {
|
|
634
|
+
client.stop();
|
|
635
|
+
if (current === instance) current = null;
|
|
636
|
+
}
|
|
637
|
+
};
|
|
638
|
+
current = instance;
|
|
639
|
+
return instance;
|
|
640
|
+
}
|
|
641
|
+
function setConsent(consent) {
|
|
642
|
+
if (current) current.setConsent(consent);
|
|
643
|
+
else pendingConsent = consent;
|
|
644
|
+
}
|
|
645
|
+
function conversion(goal, value) {
|
|
646
|
+
current?.conversion(goal, value);
|
|
647
|
+
}
|
|
648
|
+
function signup() {
|
|
649
|
+
current?.conversion(SIGNUP_GOAL);
|
|
650
|
+
}
|
|
651
|
+
function refresh() {
|
|
652
|
+
return current?.refresh() ?? Promise.resolve();
|
|
653
|
+
}
|