@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.
@@ -0,0 +1,228 @@
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/touch.ts
31
+ var MAX_TOUCH_VALUE = 100;
32
+ var CLICK_ID_PARAMS = {
33
+ gclid: "google",
34
+ gbraid: "google",
35
+ wbraid: "google",
36
+ msclkid: "microsoft",
37
+ fbclid: "meta",
38
+ li_fat_id: "linkedin",
39
+ ttclid: "tiktok",
40
+ twclid: "x"
41
+ };
42
+ var CLICK_ID_KINDS = new Set(Object.values(CLICK_ID_PARAMS));
43
+ var UTM_FIELDS = ["source", "medium", "campaign", "term", "content"];
44
+ var KHARON_LINK_PARAM = "ge_co";
45
+ function touchFromUrl(url, referrer) {
46
+ const touch = {};
47
+ let page;
48
+ try {
49
+ page = new URL(url);
50
+ } catch {
51
+ return touch;
52
+ }
53
+ for (const field of UTM_FIELDS) {
54
+ const value = clean(page.searchParams.get(`utm_${field}`));
55
+ if (value) touch[field] = value;
56
+ }
57
+ for (const [param, kind] of Object.entries(CLICK_ID_PARAMS)) {
58
+ if (page.searchParams.get(param)) {
59
+ touch.clickId = kind;
60
+ break;
61
+ }
62
+ }
63
+ if (page.searchParams.get(KHARON_LINK_PARAM)) touch.kharonLink = true;
64
+ const from = referrerHost(referrer);
65
+ if (from && from !== bareHost(page.hostname)) touch.referrer = from;
66
+ return touch;
67
+ }
68
+ function clean(value) {
69
+ const trimmed = value?.replace(/[\u0000-\u001f\u007f]/g, "").trim();
70
+ return trimmed ? trimmed.slice(0, MAX_TOUCH_VALUE) : null;
71
+ }
72
+ function referrerHost(referrer) {
73
+ if (!referrer) return null;
74
+ try {
75
+ const { protocol, hostname } = new URL(referrer);
76
+ return protocol === "http:" || protocol === "https:" ? bareHost(hostname) : null;
77
+ } catch {
78
+ return null;
79
+ }
80
+ }
81
+ function bareHost(hostname) {
82
+ return hostname.toLowerCase().replace(/^www\./, "");
83
+ }
84
+
85
+ // src/engine/analytics/beacon.ts
86
+ var BEACON_VERSION = 1;
87
+ var MAX_BEACON_EVENTS = 50;
88
+ var SLOT_ATTRIBUTES = {
89
+ slot: "data-ge-slot",
90
+ variant: "data-ge-variant",
91
+ counterfactual: "data-ge-counterfactual",
92
+ /** Why the slot shows what it shows (matched, default, holdout). For debugging; not sent. */
93
+ reason: "data-ge-reason",
94
+ /** Which step of the fallback ladder chose the content (exact, near, segment, default). Sent on impressions. */
95
+ rung: "data-ge-rung"
96
+ };
97
+
98
+ // packages/browser/src/tracker.ts
99
+ var COLLECT_PATH = "/api/collect";
100
+ var SESSION_KEY = "ge_sid";
101
+ var PIN_KEY = "ge_pin";
102
+ var VISIBLE_RATIO = 0.5;
103
+ var FLUSH_INTERVAL_MS = 2e3;
104
+ function startTracker({
105
+ pageId,
106
+ configVersion,
107
+ endpoint = COLLECT_PATH,
108
+ root = document,
109
+ match,
110
+ envelope = (events) => ({ v: BEACON_VERSION, events, ...match ? { m: match } : {} }),
111
+ crossOrigin = false
112
+ }) {
113
+ const win = root.defaultView ?? window;
114
+ const { id: sessionId, started } = sessionFor(win);
115
+ const queue = [];
116
+ const seenSlots = /* @__PURE__ */ new Set();
117
+ const push = (type, body) => {
118
+ queue.push({ type, sessionId, ...body });
119
+ if (queue.length >= MAX_BEACON_EVENTS) flush();
120
+ };
121
+ const flush = () => {
122
+ while (queue.length) send(win, endpoint, envelope(queue.splice(0, MAX_BEACON_EVENTS)), crossOrigin);
123
+ };
124
+ push("page_view", started ? { pageId, configVersion, touch: touchFromUrl(win.location.href, root.referrer) } : { pageId, configVersion });
125
+ const slots = Array.from(root.querySelectorAll(`[${SLOT_ATTRIBUTES.slot}]`));
126
+ const observer = "IntersectionObserver" in win ? new win.IntersectionObserver(
127
+ (entries) => {
128
+ for (const entry of entries) {
129
+ if (!entry.isIntersecting || entry.intersectionRatio < VISIBLE_RATIO) continue;
130
+ const slot = readSlot(entry.target);
131
+ observer?.unobserve(entry.target);
132
+ if (!slot || seenSlots.has(slot.slotId)) continue;
133
+ seenSlots.add(slot.slotId);
134
+ push("slot_impression", slot);
135
+ }
136
+ },
137
+ { threshold: VISIBLE_RATIO }
138
+ ) : null;
139
+ for (const slot of slots) observer?.observe(slot);
140
+ const interaction = (action) => (event) => {
141
+ const slot = closestSlot(event.target);
142
+ if (slot) push("interaction", { slotId: slot.slotId, variantId: slot.variantId, action });
143
+ };
144
+ const onClick = interaction("click");
145
+ const onCopy = interaction("copy");
146
+ const onToggle = (event) => {
147
+ if (event.target.open) interaction("expand")(event);
148
+ };
149
+ const onSubmit = (event) => {
150
+ const goal = event.target.closest?.("form")?.getAttribute("data-ge-goal");
151
+ if (goal) conversion(goal);
152
+ };
153
+ const onHide = () => {
154
+ if (root.visibilityState === "hidden") flush();
155
+ };
156
+ const conversion = (goal, value) => {
157
+ push("conversion", value === void 0 ? { goal } : { goal, value });
158
+ flush();
159
+ };
160
+ root.addEventListener("click", onClick, true);
161
+ root.addEventListener("copy", onCopy, true);
162
+ root.addEventListener("toggle", onToggle, true);
163
+ root.addEventListener("submit", onSubmit, true);
164
+ root.addEventListener("visibilitychange", onHide);
165
+ win.addEventListener("pagehide", flush);
166
+ const timer = win.setInterval(flush, FLUSH_INTERVAL_MS);
167
+ return {
168
+ conversion,
169
+ flush,
170
+ stop({ discard = false } = {}) {
171
+ if (discard) queue.length = 0;
172
+ flush();
173
+ observer?.disconnect();
174
+ win.clearInterval(timer);
175
+ root.removeEventListener("click", onClick, true);
176
+ root.removeEventListener("copy", onCopy, true);
177
+ root.removeEventListener("toggle", onToggle, true);
178
+ root.removeEventListener("submit", onSubmit, true);
179
+ root.removeEventListener("visibilitychange", onHide);
180
+ win.removeEventListener("pagehide", flush);
181
+ }
182
+ };
183
+ }
184
+ function readSlot(el) {
185
+ const slotId = el.getAttribute(SLOT_ATTRIBUTES.slot);
186
+ if (!slotId) return null;
187
+ return {
188
+ slotId,
189
+ variantId: el.getAttribute(SLOT_ATTRIBUTES.variant) || null,
190
+ counterfactualVariantId: el.getAttribute(SLOT_ATTRIBUTES.counterfactual) || null,
191
+ ...el.getAttribute(SLOT_ATTRIBUTES.rung) ? { rung: el.getAttribute(SLOT_ATTRIBUTES.rung) } : {}
192
+ };
193
+ }
194
+ function closestSlot(target) {
195
+ const el = target instanceof Element ? target.closest(`[${SLOT_ATTRIBUTES.slot}]`) : null;
196
+ return el ? readSlot(el) : null;
197
+ }
198
+ function sessionFor(win) {
199
+ try {
200
+ const existing = win.sessionStorage.getItem(SESSION_KEY);
201
+ if (existing) return { id: existing, started: false };
202
+ const id = uuid(win);
203
+ win.sessionStorage.setItem(SESSION_KEY, id);
204
+ return { id, started: true };
205
+ } catch {
206
+ return { id: uuid(win), started: true };
207
+ }
208
+ }
209
+ function uuid(win) {
210
+ if (typeof win.crypto.randomUUID === "function") return win.crypto.randomUUID();
211
+ const b = win.crypto.getRandomValues(new Uint8Array(16));
212
+ b[6] = b[6] & 15 | 64;
213
+ b[8] = b[8] & 63 | 128;
214
+ const hex = Array.from(b, (x) => x.toString(16).padStart(2, "0")).join("");
215
+ return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
216
+ }
217
+ function send(win, endpoint, payload, crossOrigin) {
218
+ const body = JSON.stringify(payload);
219
+ const type = crossOrigin ? "text/plain" : "application/json";
220
+ if (win.navigator.sendBeacon?.(endpoint, new Blob([body], { type }))) return;
221
+ void win.fetch(endpoint, {
222
+ method: "POST",
223
+ body,
224
+ keepalive: true,
225
+ credentials: crossOrigin ? "omit" : "same-origin",
226
+ headers: { "content-type": type }
227
+ }).catch((error) => console.warn("[ge] analytics beacon failed", error));
228
+ }
@@ -0,0 +1,130 @@
1
+ type SignalSource = "campaign_link" | "form_email" | "known_visitor" | "reverse_ip" | "referrer";
2
+ type DecisionRung = "exact" | "near" | "segment" | "default";
3
+ interface Touch$1 {
4
+ /** `utm_source`, `utm_medium`, … as tagged, trimmed to `MAX_TOUCH_VALUE` characters. */
5
+ source?: string;
6
+ medium?: string;
7
+ campaign?: string;
8
+ term?: string;
9
+ content?: string;
10
+ /** Which ad network's click id the URL carried (`gclid` → `google`). The id itself is dropped. */
11
+ clickId?: ClickIdKind;
12
+ /** The landing URL was one of our campaign links (`?ge_co=`). */
13
+ kharonLink?: true;
14
+ /** Host of an external referring page, lowercased and without `www.`. */
15
+ referrer?: string;
16
+ }
17
+ declare const CLICK_ID_PARAMS: {
18
+ readonly gclid: "google";
19
+ readonly gbraid: "google";
20
+ readonly wbraid: "google";
21
+ readonly msclkid: "microsoft";
22
+ readonly fbclid: "meta";
23
+ readonly li_fat_id: "linkedin";
24
+ readonly ttclid: "tiktok";
25
+ readonly twclid: "x";
26
+ };
27
+ type ClickIdKind = (typeof CLICK_ID_PARAMS)[keyof typeof CLICK_ID_PARAMS];
28
+ interface BaseEvent {
29
+ siteId: string;
30
+ visitorId: string;
31
+ sessionId: string;
32
+ /** Epoch milliseconds, stamped by the collector (not the browser) to avoid clock skew. */
33
+ ts: number;
34
+ /**
35
+ * The company identity resolution matched the visitor to, by domain, stamped by the collector from
36
+ * the server-signed match token. Null when resolve matched no company; absent when unknown (no valid
37
+ * token, e.g. an older SDK, or stored before stamping).
38
+ * Firmographics are joined from the company source at report time.
39
+ */
40
+ companyDomain?: string | null;
41
+ /** The evidence behind that match (a campaign link, reverse IP…), so reports can weigh or exclude it. */
42
+ companySource?: SignalSource | null;
43
+ }
44
+ interface PageViewEvent extends BaseEvent {
45
+ type: "page_view";
46
+ pageId: string;
47
+ /** Site config version that was resolved, so results are attributable to what was live. */
48
+ configVersion: number;
49
+ /**
50
+ * How the session arrived, on its first page view only (`{}` for a direct visit). Absent on later
51
+ * page views and from trackers older than signup attribution.
52
+ */
53
+ touch?: Touch$1;
54
+ }
55
+ interface SlotImpressionEvent extends BaseEvent {
56
+ type: "slot_impression";
57
+ slotId: string;
58
+ variantId: string | null;
59
+ /** Set for holdout visitors: the variant withheld from them. See Decision.counterfactualVariantId. */
60
+ counterfactualVariantId: string | null;
61
+ /** The fallback-ladder step behind the decision (Decision.rung). Absent from older scripts and events. */
62
+ rung?: DecisionRung;
63
+ }
64
+ interface InteractionEvent extends BaseEvent {
65
+ type: "interaction";
66
+ slotId: string;
67
+ variantId: string | null;
68
+ action: "click" | "hover" | "copy" | "expand";
69
+ }
70
+ interface ConversionEvent extends BaseEvent {
71
+ type: "conversion";
72
+ goal: string;
73
+ value?: number;
74
+ /** `server`: confirmed by the site's backend (`POST /api/v1/signups`), not reported by the page. */
75
+ source?: "server";
76
+ }
77
+ type AnalyticsEvent = PageViewEvent | SlotImpressionEvent | InteractionEvent | ConversionEvent;
78
+ type ServerStamped = "siteId" | "visitorId" | "ts" | "companyDomain" | "companySource" | "source";
79
+ type BeaconEvent = {
80
+ [K in AnalyticsEvent["type"]]: Omit<Extract<AnalyticsEvent, {
81
+ type: K;
82
+ }>, ServerStamped>;
83
+ }[AnalyticsEvent["type"]];
84
+ export declare const COLLECT_PATH = "/api/collect";
85
+ /** sessionStorage key for the tab's session id. Removed when a visitor withdraws consent. */
86
+ export declare const SESSION_KEY = "ge_sid";
87
+ /**
88
+ * sessionStorage key for the config pin (set by the client, not the tracker): the tab keeps the copy
89
+ * it started with when someone publishes, until a rollback. Stored and removed with the visitor id.
90
+ */
91
+ export declare const PIN_KEY = "ge_pin";
92
+ export interface TrackerOptions {
93
+ pageId: string;
94
+ configVersion: number;
95
+ endpoint?: string;
96
+ root?: Document;
97
+ /**
98
+ * The server-signed match token for this page view (hosted: rendered by the page). Sent with every
99
+ * batch of the default envelope so the collector can stamp the matched company. Opaque.
100
+ */
101
+ match?: string;
102
+ /**
103
+ * Wraps a batch of events into the request body. Defaults to beacon v1, which the same-origin
104
+ * collector stamps with the visitor from its own cookie.
105
+ */
106
+ envelope?: (events: BeaconEvent[]) => object;
107
+ /**
108
+ * The collector is on another origin (snippet mode). Bodies go as `text/plain` without cookies,
109
+ * so the browser sends them without a CORS preflight.
110
+ */
111
+ crossOrigin?: boolean;
112
+ }
113
+ export interface Tracker {
114
+ /** Record that a workspace goal fired. Flushes at once, since a conversion often precedes navigation. */
115
+ conversion(goal: string, value?: number): void;
116
+ flush(): void;
117
+ /** Stops measuring. Queued events are sent, unless `discard` (consent was withdrawn). */
118
+ stop(options?: {
119
+ discard?: boolean;
120
+ }): void;
121
+ }
122
+ /**
123
+ * The client half of the measurement pipeline. Sends one page view, one impression per slot once at
124
+ * least half of it is on screen, interactions inside slots, and conversions (forms marked
125
+ * `data-ge-goal`, or `conversion()`). Events are batched and sent with `sendBeacon` so a closing tab
126
+ * still delivers them.
127
+ */
128
+ export declare function startTracker({ pageId, configVersion, endpoint, root, match, envelope, crossOrigin, }: TrackerOptions): Tracker;
129
+
130
+ export {};
@@ -0,0 +1,205 @@
1
+ // src/engine/analytics/touch.ts
2
+ var MAX_TOUCH_VALUE = 100;
3
+ var CLICK_ID_PARAMS = {
4
+ gclid: "google",
5
+ gbraid: "google",
6
+ wbraid: "google",
7
+ msclkid: "microsoft",
8
+ fbclid: "meta",
9
+ li_fat_id: "linkedin",
10
+ ttclid: "tiktok",
11
+ twclid: "x"
12
+ };
13
+ var CLICK_ID_KINDS = new Set(Object.values(CLICK_ID_PARAMS));
14
+ var UTM_FIELDS = ["source", "medium", "campaign", "term", "content"];
15
+ var KHARON_LINK_PARAM = "ge_co";
16
+ function touchFromUrl(url, referrer) {
17
+ const touch = {};
18
+ let page;
19
+ try {
20
+ page = new URL(url);
21
+ } catch {
22
+ return touch;
23
+ }
24
+ for (const field of UTM_FIELDS) {
25
+ const value = clean(page.searchParams.get(`utm_${field}`));
26
+ if (value) touch[field] = value;
27
+ }
28
+ for (const [param, kind] of Object.entries(CLICK_ID_PARAMS)) {
29
+ if (page.searchParams.get(param)) {
30
+ touch.clickId = kind;
31
+ break;
32
+ }
33
+ }
34
+ if (page.searchParams.get(KHARON_LINK_PARAM)) touch.kharonLink = true;
35
+ const from = referrerHost(referrer);
36
+ if (from && from !== bareHost(page.hostname)) touch.referrer = from;
37
+ return touch;
38
+ }
39
+ function clean(value) {
40
+ const trimmed = value?.replace(/[\u0000-\u001f\u007f]/g, "").trim();
41
+ return trimmed ? trimmed.slice(0, MAX_TOUCH_VALUE) : null;
42
+ }
43
+ function referrerHost(referrer) {
44
+ if (!referrer) return null;
45
+ try {
46
+ const { protocol, hostname } = new URL(referrer);
47
+ return protocol === "http:" || protocol === "https:" ? bareHost(hostname) : null;
48
+ } catch {
49
+ return null;
50
+ }
51
+ }
52
+ function bareHost(hostname) {
53
+ return hostname.toLowerCase().replace(/^www\./, "");
54
+ }
55
+
56
+ // src/engine/analytics/beacon.ts
57
+ var BEACON_VERSION = 1;
58
+ var MAX_BEACON_EVENTS = 50;
59
+ var SLOT_ATTRIBUTES = {
60
+ slot: "data-ge-slot",
61
+ variant: "data-ge-variant",
62
+ counterfactual: "data-ge-counterfactual",
63
+ /** Why the slot shows what it shows (matched, default, holdout). For debugging; not sent. */
64
+ reason: "data-ge-reason",
65
+ /** Which step of the fallback ladder chose the content (exact, near, segment, default). Sent on impressions. */
66
+ rung: "data-ge-rung"
67
+ };
68
+
69
+ // packages/browser/src/tracker.ts
70
+ var COLLECT_PATH = "/api/collect";
71
+ var SESSION_KEY = "ge_sid";
72
+ var PIN_KEY = "ge_pin";
73
+ var VISIBLE_RATIO = 0.5;
74
+ var FLUSH_INTERVAL_MS = 2e3;
75
+ function startTracker({
76
+ pageId,
77
+ configVersion,
78
+ endpoint = COLLECT_PATH,
79
+ root = document,
80
+ match,
81
+ envelope = (events) => ({ v: BEACON_VERSION, events, ...match ? { m: match } : {} }),
82
+ crossOrigin = false
83
+ }) {
84
+ const win = root.defaultView ?? window;
85
+ const { id: sessionId, started } = sessionFor(win);
86
+ const queue = [];
87
+ const seenSlots = /* @__PURE__ */ new Set();
88
+ const push = (type, body) => {
89
+ queue.push({ type, sessionId, ...body });
90
+ if (queue.length >= MAX_BEACON_EVENTS) flush();
91
+ };
92
+ const flush = () => {
93
+ while (queue.length) send(win, endpoint, envelope(queue.splice(0, MAX_BEACON_EVENTS)), crossOrigin);
94
+ };
95
+ push("page_view", started ? { pageId, configVersion, touch: touchFromUrl(win.location.href, root.referrer) } : { pageId, configVersion });
96
+ const slots = Array.from(root.querySelectorAll(`[${SLOT_ATTRIBUTES.slot}]`));
97
+ const observer = "IntersectionObserver" in win ? new win.IntersectionObserver(
98
+ (entries) => {
99
+ for (const entry of entries) {
100
+ if (!entry.isIntersecting || entry.intersectionRatio < VISIBLE_RATIO) continue;
101
+ const slot = readSlot(entry.target);
102
+ observer?.unobserve(entry.target);
103
+ if (!slot || seenSlots.has(slot.slotId)) continue;
104
+ seenSlots.add(slot.slotId);
105
+ push("slot_impression", slot);
106
+ }
107
+ },
108
+ { threshold: VISIBLE_RATIO }
109
+ ) : null;
110
+ for (const slot of slots) observer?.observe(slot);
111
+ const interaction = (action) => (event) => {
112
+ const slot = closestSlot(event.target);
113
+ if (slot) push("interaction", { slotId: slot.slotId, variantId: slot.variantId, action });
114
+ };
115
+ const onClick = interaction("click");
116
+ const onCopy = interaction("copy");
117
+ const onToggle = (event) => {
118
+ if (event.target.open) interaction("expand")(event);
119
+ };
120
+ const onSubmit = (event) => {
121
+ const goal = event.target.closest?.("form")?.getAttribute("data-ge-goal");
122
+ if (goal) conversion(goal);
123
+ };
124
+ const onHide = () => {
125
+ if (root.visibilityState === "hidden") flush();
126
+ };
127
+ const conversion = (goal, value) => {
128
+ push("conversion", value === void 0 ? { goal } : { goal, value });
129
+ flush();
130
+ };
131
+ root.addEventListener("click", onClick, true);
132
+ root.addEventListener("copy", onCopy, true);
133
+ root.addEventListener("toggle", onToggle, true);
134
+ root.addEventListener("submit", onSubmit, true);
135
+ root.addEventListener("visibilitychange", onHide);
136
+ win.addEventListener("pagehide", flush);
137
+ const timer = win.setInterval(flush, FLUSH_INTERVAL_MS);
138
+ return {
139
+ conversion,
140
+ flush,
141
+ stop({ discard = false } = {}) {
142
+ if (discard) queue.length = 0;
143
+ flush();
144
+ observer?.disconnect();
145
+ win.clearInterval(timer);
146
+ root.removeEventListener("click", onClick, true);
147
+ root.removeEventListener("copy", onCopy, true);
148
+ root.removeEventListener("toggle", onToggle, true);
149
+ root.removeEventListener("submit", onSubmit, true);
150
+ root.removeEventListener("visibilitychange", onHide);
151
+ win.removeEventListener("pagehide", flush);
152
+ }
153
+ };
154
+ }
155
+ function readSlot(el) {
156
+ const slotId = el.getAttribute(SLOT_ATTRIBUTES.slot);
157
+ if (!slotId) return null;
158
+ return {
159
+ slotId,
160
+ variantId: el.getAttribute(SLOT_ATTRIBUTES.variant) || null,
161
+ counterfactualVariantId: el.getAttribute(SLOT_ATTRIBUTES.counterfactual) || null,
162
+ ...el.getAttribute(SLOT_ATTRIBUTES.rung) ? { rung: el.getAttribute(SLOT_ATTRIBUTES.rung) } : {}
163
+ };
164
+ }
165
+ function closestSlot(target) {
166
+ const el = target instanceof Element ? target.closest(`[${SLOT_ATTRIBUTES.slot}]`) : null;
167
+ return el ? readSlot(el) : null;
168
+ }
169
+ function sessionFor(win) {
170
+ try {
171
+ const existing = win.sessionStorage.getItem(SESSION_KEY);
172
+ if (existing) return { id: existing, started: false };
173
+ const id = uuid(win);
174
+ win.sessionStorage.setItem(SESSION_KEY, id);
175
+ return { id, started: true };
176
+ } catch {
177
+ return { id: uuid(win), started: true };
178
+ }
179
+ }
180
+ function uuid(win) {
181
+ if (typeof win.crypto.randomUUID === "function") return win.crypto.randomUUID();
182
+ const b = win.crypto.getRandomValues(new Uint8Array(16));
183
+ b[6] = b[6] & 15 | 64;
184
+ b[8] = b[8] & 63 | 128;
185
+ const hex = Array.from(b, (x) => x.toString(16).padStart(2, "0")).join("");
186
+ return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
187
+ }
188
+ function send(win, endpoint, payload, crossOrigin) {
189
+ const body = JSON.stringify(payload);
190
+ const type = crossOrigin ? "text/plain" : "application/json";
191
+ if (win.navigator.sendBeacon?.(endpoint, new Blob([body], { type }))) return;
192
+ void win.fetch(endpoint, {
193
+ method: "POST",
194
+ body,
195
+ keepalive: true,
196
+ credentials: crossOrigin ? "omit" : "same-origin",
197
+ headers: { "content-type": type }
198
+ }).catch((error) => console.warn("[ge] analytics beacon failed", error));
199
+ }
200
+ export {
201
+ COLLECT_PATH,
202
+ PIN_KEY,
203
+ SESSION_KEY,
204
+ startTracker
205
+ };
package/dist/v1.js ADDED
@@ -0,0 +1 @@
1
+ "use strict";(()=>{var be=Object.defineProperty;var Oe=(e,t)=>{for(var n in t)be(e,n,{get:t[n],enumerable:!0})};var ie={};Oe(ie,{ANTI_FLICKER_SNIPPET:()=>Ee,DEFAULT_API_BASE:()=>Q,DEFAULT_TIMEOUT_MS:()=>F,VISITOR_COOKIE:()=>E,antiFlickerStart:()=>j,applyDecision:()=>ne,conversion:()=>et,createClient:()=>q,hashSlotDefaults:()=>T,init:()=>re,isWritable:()=>oe,pageIdFor:()=>ee,parseResolveResponse:()=>$,refresh:()=>nt,reveal:()=>D,scanSlots:()=>W,setConsent:()=>Qe,signup:()=>tt});var G="signup";var X={gclid:"google",gbraid:"google",wbraid:"google",msclkid:"microsoft",fbclid:"meta",li_fat_id:"linkedin",ttclid:"tiktok",twclid:"x"},st=new Set(Object.values(X)),Ve=["source","medium","campaign","term","content"],Le="ge_co";function Y(e,t){let n={},o;try{o=new URL(e)}catch{return n}for(let i of Ve){let a=we(o.searchParams.get(`utm_${i}`));a&&(n[i]=a)}for(let[i,a]of Object.entries(X))if(o.searchParams.get(i)){n.clickId=a;break}o.searchParams.get(Le)&&(n.kharonLink=!0);let r=De(t);return r&&r!==se(o.hostname)&&(n.referrer=r),n}function we(e){let t=e?.replace(/[\u0000-\u001f\u007f]/g,"").trim();return t?t.slice(0,100):null}function De(e){if(!e)return null;try{let{protocol:t,hostname:n}=new URL(e);return t==="http:"||t==="https:"?se(n):null}catch{return null}}function se(e){return e.toLowerCase().replace(/^www\./,"")}var Z=1,B=50;var p={slot:"data-ge-slot",variant:"data-ge-variant",counterfactual:"data-ge-counterfactual",reason:"data-ge-reason",rung:"data-ge-rung"};var Me="/api/collect",U="ge_sid",K="ge_pin",ae=.5,Be=2e3;function le({pageId:e,configVersion:t,endpoint:n=Me,root:o=document,match:r,envelope:i=c=>({v:Z,events:c,...r?{m:r}:{}}),crossOrigin:a=!1}){let c=o.defaultView??window,{id:f,started:d}=Ke(c),m=[],b=new Set,R=(l,u)=>{m.push({type:l,sessionId:f,...u}),m.length>=B&&s()},s=()=>{for(;m.length;)$e(c,n,i(m.splice(0,B)),a)};R("page_view",d?{pageId:e,configVersion:t,touch:Y(c.location.href,o.referrer)}:{pageId:e,configVersion:t});let g=Array.from(o.querySelectorAll(`[${p.slot}]`)),h="IntersectionObserver"in c?new c.IntersectionObserver(l=>{for(let u of l){if(!u.isIntersecting||u.intersectionRatio<ae)continue;let v=ue(u.target);h?.unobserve(u.target),!(!v||b.has(v.slotId))&&(b.add(v.slotId),R("slot_impression",v))}},{threshold:ae}):null;for(let l of g)h?.observe(l);let x=l=>u=>{let v=Ue(u.target);v&&R("interaction",{slotId:v.slotId,variantId:v.variantId,action:l})},_=x("click"),N=x("copy"),O=l=>{l.target.open&&x("expand")(l)},C=l=>{let u=l.target.closest?.("form")?.getAttribute("data-ge-goal");u&&S(u)},V=()=>{o.visibilityState==="hidden"&&s()},S=(l,u)=>{R("conversion",u===void 0?{goal:l}:{goal:l,value:u}),s()};o.addEventListener("click",_,!0),o.addEventListener("copy",N,!0),o.addEventListener("toggle",O,!0),o.addEventListener("submit",C,!0),o.addEventListener("visibilitychange",V),c.addEventListener("pagehide",s);let M=c.setInterval(s,Be);return{conversion:S,flush:s,stop({discard:l=!1}={}){l&&(m.length=0),s(),h?.disconnect(),c.clearInterval(M),o.removeEventListener("click",_,!0),o.removeEventListener("copy",N,!0),o.removeEventListener("toggle",O,!0),o.removeEventListener("submit",C,!0),o.removeEventListener("visibilitychange",V),c.removeEventListener("pagehide",s)}}}function ue(e){let t=e.getAttribute(p.slot);return t?{slotId:t,variantId:e.getAttribute(p.variant)||null,counterfactualVariantId:e.getAttribute(p.counterfactual)||null,...e.getAttribute(p.rung)?{rung:e.getAttribute(p.rung)}:{}}:null}function Ue(e){let t=e instanceof Element?e.closest(`[${p.slot}]`):null;return t?ue(t):null}function Ke(e){try{let t=e.sessionStorage.getItem(U);if(t)return{id:t,started:!1};let n=ce(e);return e.sessionStorage.setItem(U,n),{id:n,started:!0}}catch{return{id:ce(e),started:!0}}}function ce(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 $e(e,t,n,o){let r=JSON.stringify(n),i=o?"text/plain":"application/json";e.navigator.sendBeacon?.(t,new Blob([r],{type:i}))||e.fetch(t,{method:"POST",body:r,keepalive:!0,credentials:o?"omit":"same-origin",headers:{"content-type":i}}).catch(a=>console.warn("[ge] analytics beacon failed",a))}var y="ge_preview";var pe=/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/;function A(e){return typeof e=="string"&&pe.test(e)}function T(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 de="/api/v1/resolve",ge="/api/v1/collect",He=["matched","default","holdout"],Fe=["exact","near","segment","default"];function $(e){if(!L(e)||e.v!==1||!L(e.decisions)||!Number.isSafeInteger(e.configVersion)||typeof e.persist!="boolean")return null;let t=A(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 f=qe(c);f&&(r[a]=f)}let i=n&&typeof e.m=="string"&&e.m.length<=1024?e.m:void 0;if(e.preview===!0){let a=L(e.explain)?e.explain:void 0;return{v:1,configVersion:e.configVersion,persist:!1,preview:!0,decisions:r,...a?{explain:a}:{}}}return{v:1,configVersion:e.configVersion,persist:n,vid:t,...o?{pin:o}:{},decisions:r,...i?{m:i}:{}}}function qe(e){if(!L(e)||!L(e.content)||!fe(e.variantId)||!fe(e.counterfactualVariantId)||!He.includes(e.reason))return null;let t={};for(let[o,r]of Object.entries(e.content))typeof r=="string"&&(t[o]=r);let n=Fe.includes(e.rung)?e.rung:void 0;return{variantId:e.variantId,counterfactualVariantId:e.counterfactualVariantId,reason:e.reason,...n&&{rung:n},content:t}}function L(e){return typeof e=="object"&&e!==null&&!Array.isArray(e)}function fe(e){return e===null||typeof e=="string"}var Q="https://kharonlabs.com",F=500,ze=5e3,E="ge_vid",We=365*24*60*60;function q(e){let t=e.root??document,n=t.defaultView??window,o=(e.apiBase??Q).replace(/\/+$/,""),r=new Set,i=e.consent??"unknown",a=!1,c,f=null,d=!1,m=(s=!1)=>{f?.stop({discard:s}),f=null},b=()=>{a=!1,c=void 0,m(!0),Ye(t,E,e.cookieDomain);try{n.sessionStorage.removeItem(U),n.sessionStorage.removeItem(K)}catch{}},R=s=>{if(s.preview){d=!0,m(!0),s.explain&&n.console?.info("[kharon] debug link: why this page shows what it does",s.explain);return}s.persist&&s.vid&&i!=="denied"?(a=!0,c=s.m,H(t,E)!==s.vid&&Xe(t,E,s.vid,e.cookieDomain),s.pin&&me(n,K,s.pin)):(a||H(t,E))&&b()};return{get consent(){return i},async resolve(s,g=Date.now()+ze){let h=H(t,E),x=ve(n,K),_=je(n),N={v:1,key:e.key,...A(h)?{vid:h}:{},...x?{pin:x}:{},url:_.url,referrer:t.referrer,slots:s.map(S=>({id:S.id,h:T(S.defaults)})),consent:i,gpc:n.navigator.globalPrivacyControl===!0},O=g-Date.now();if(O<=0||typeof n.fetch!="function")return null;let C=typeof AbortController=="function"?new AbortController:null,V;try{let S=n.fetch(o+de,{method:"POST",body:JSON.stringify(N),credentials:"omit",signal:C?.signal}),M=new Promise(v=>{V=setTimeout(()=>{C?.abort(),v(null)},O)}),l=await Promise.race([S,M]);if(!l)return null;if(!l.ok)return console.warn(`[kharon] resolve answered ${l.status}; keeping defaults`),null;let u=$(await Promise.race([l.json(),M]));return!u||Date.now()>g?null:(u.preview&&_.token?me(n,y,_.token):!u.preview&&_.token&&Ge(n,y),R(u),u)}catch(S){return C?.signal.aborted||console.warn("[kharon] resolve failed; keeping defaults",S),null}finally{clearTimeout(V)}},adopt:R,track(s){m();let g=H(t,E);d||!a||!A(g)||(f=le({pageId:e.pageId??ee(n.location.pathname),configVersion:s,endpoint:o+ge,root:t,crossOrigin:!0,envelope:h=>({v:2,key:e.key,vid:g,consent:i,events:h,...c?{m:c}:{}})}))},setConsent(s){if(s!==i){i=s,s==="denied"&&b();for(let g of r)g(s)}},onConsentChange(s){return r.add(s),()=>r.delete(s)},conversion(s,g){f?.conversion(s,g)},stop:()=>m()}}function ee(e){return e.split("/").map(n=>n.replace(/[^A-Za-z0-9_:-]/g,"")).filter(Boolean).join(".").slice(0,128)||"home"}function H(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 ve(e,t){try{return e.sessionStorage.getItem(t)}catch{return null}}function je(e){let t=e.location.href,n;try{n=new URL(t)}catch{return{url:t,token:null}}let o=n.searchParams.get(y);if(o)return{url:t,token:o};let r=ve(e,y);return r?(n.searchParams.set(y,r),{url:n.toString(),token:r}):{url:t,token:null}}function Ge(e,t){try{e.sessionStorage.removeItem(t)}catch{}}function me(e,t,n){try{e.sessionStorage.setItem(t,n)}catch{}}function Ie(e,t){let n=e.location?.protocol==="https:"?"; Secure":"";return`; Path=/; SameSite=Lax${t?`; Domain=${t}`:""}${n}`}function Xe(e,t,n,o){e.cookie=`${t}=${encodeURIComponent(n)}; Max-Age=${We}${Ie(e,o)}`}function Ye(e,t,n){e.cookie=`${t}=; Max-Age=0${Ie(e,n)}`}var Se="data-ge-field",he="data-ge-attr",te="kharon-af",Ee=`<style id="${te}">[${p.slot}]{visibility:hidden!important}</style><script>window.__kharonAf=Date.now();setTimeout(function(){var s=document.getElementById("${te}");s&&s.parentNode.removeChild(s)},500)<\/script>`,Ze=new Set(["href","src","action","formaction","poster","cite","xlink:href"]),Je=/^(on|style$|srcdoc$|srcset$)/,z=new WeakMap,w="#text";function Re(e,t){return t===null?(e.textContent??"").trim():e.getAttribute(t)}function _e(e,t){let n=z.get(e);return n?.has(t??w)?n.get(t??w):Re(e,t)}function ye(e,t,n){let o=z.get(e);o||z.set(e,o=new Map),o.has(t??w)||o.set(t??w,Re(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 ke(e)){let o=n.getAttribute(p.slot);if(t.has(o))continue;let r={};for(let i of xe(n))i.field in r||(r[i.field]=_e(i.el,i.attr)??"");t.set(o,{id:o,defaults:r})}return[...t.values()]}function Ae(e,t){for(let n of ke(e)){let o=t.decisions[n.getAttribute(p.slot)];o&&ne(n,o,e.baseURI)}}function ne(e,t,n){for(let{el:o,field:r,attr:i}of xe(e)){let a=t.content[r];typeof a=="string"&&(i===null||oe(i,a,n))?ye(o,i,a):z.get(o)?.has(i??w)&&ye(o,i,_e(o,i))}e.setAttribute(p.variant,t.variantId??""),e.setAttribute(p.counterfactual,t.counterfactualVariantId??""),e.setAttribute(p.reason,t.reason),t.rung?e.setAttribute(p.rung,t.rung):e.removeAttribute(p.rung)}function D(e){e.getElementById(te)?.remove()}function j(e){let t=e.defaultView?.__kharonAf;return typeof t=="number"?t:void 0}function oe(e,t,n){let o=e.toLowerCase();if(Je.test(o))return!1;if(!Ze.has(o))return!0;try{let{protocol:r}=new URL(t,n);return r==="http:"||r==="https:"}catch{return!1}}function ke(e){return Array.from(e.querySelectorAll(`[${p.slot}]`))}function xe(e){let t=[],n=[e,...Array.from(e.querySelectorAll(`[${Se}],[${he}]`))];for(let o of n){if(o!==e&&o.closest(`[${p.slot}]`)!==e)continue;let r=o.getAttribute(Se);r&&t.push({el:o,field:r,attr:null});for(let i of(o.getAttribute(he)??"").split(",")){let a=i.lastIndexOf(":"),c=i.slice(0,a).trim(),f=i.slice(a+1).trim();a>0&&c&&f&&t.push({el:o,field:f,attr:c})}}return t}var I=null,Ce;function re(e){if(I)return console.warn("[kharon] init called twice; keeping the first instance"),I;let t=e.root??document,n=e.timeoutMs??F,o=q({...e,consent:Ce??e.consent}),r=async f=>{try{let d=await o.resolve(W(t),f);d&&Ae(t,d),D(t),d&&o.track(d.configVersion)}catch(d){D(t),console.warn("[kharon] personalization failed; keeping defaults",d)}},a=new Promise(f=>{t.readyState!=="loading"?f():t.addEventListener("DOMContentLoaded",()=>f(),{once:!0})}).then(()=>r((j(t)??Date.now())+n));o.onConsentChange(f=>{f==="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(),I===c&&(I=null)}};return I=c,c}function Qe(e){I?I.setConsent(e):Ce=e}function et(e,t){I?.conversion(e,t)}function tt(){I?.conversion(G)}function nt(){return I?.refresh()??Promise.resolve()}var ot=["granted","denied","unknown"],P=document.currentScript;window.kharon=ie;var Te=P?.getAttribute("data-key");P&&Te&&re({key:Te,apiBase:P.getAttribute("data-api")??new URL(P.src).origin,consent:ot.find(e=>e===P.getAttribute("data-consent"))});})();
package/package.json CHANGED
@@ -1,6 +1,29 @@
1
1
  {
2
2
  "name": "@kharon-labs/browser",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.2.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
+ "homepage": "https://kharonlabs.com",
7
+ "type": "module",
8
+ "sideEffects": false,
9
+ "main": "./dist/index.cjs",
10
+ "module": "./dist/index.js",
11
+ "types": "./dist/index.d.ts",
12
+ "exports": {
13
+ ".": {
14
+ "types": "./dist/index.d.ts",
15
+ "import": "./dist/index.js",
16
+ "require": "./dist/index.cjs"
17
+ },
18
+ "./tracker": {
19
+ "types": "./dist/tracker.d.ts",
20
+ "import": "./dist/tracker.js",
21
+ "require": "./dist/tracker.cjs"
22
+ }
23
+ },
24
+ "files": ["dist"],
25
+ "browserslist": ["defaults"],
26
+ "publishConfig": {
27
+ "access": "public"
28
+ }
29
+ }