@holdyapp/sdk 1.1.0 → 1.6.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/README.md +53 -1
- package/dist/arm.d.ts +5 -0
- package/dist/arm.d.ts.map +1 -0
- package/dist/changes.d.ts +42 -0
- package/dist/changes.d.ts.map +1 -0
- package/dist/core.d.ts +17 -6
- package/dist/core.d.ts.map +1 -1
- package/dist/friction.d.ts +91 -0
- package/dist/friction.d.ts.map +1 -0
- package/dist/holdy.js +6 -1
- package/dist/holdy.js.map +4 -4
- package/dist/index.cjs +871 -26
- package/dist/index.cjs.map +4 -4
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.mjs +871 -26
- package/dist/index.mjs.map +4 -4
- package/dist/run.d.ts +37 -0
- package/dist/run.d.ts.map +1 -0
- package/dist/server.cjs +127 -23
- package/dist/server.cjs.map +3 -3
- package/dist/server.d.ts +14 -0
- package/dist/server.d.ts.map +1 -1
- package/dist/server.mjs +124 -22
- package/dist/server.mjs.map +3 -3
- package/dist/skeleton.d.ts +50 -0
- package/dist/skeleton.d.ts.map +1 -0
- package/dist/types.d.ts +2 -0
- package/dist/types.d.ts.map +1 -1
- package/dist/web.d.ts +26 -0
- package/dist/web.d.ts.map +1 -1
- package/dist/web.mjs +824 -26
- package/dist/web.mjs.map +4 -4
- package/package.json +1 -1
- package/src/arm.test.ts +47 -0
- package/src/arm.ts +25 -0
- package/src/changes.test.ts +54 -0
- package/src/changes.ts +357 -0
- package/src/core.test.ts +78 -4
- package/src/core.ts +95 -31
- package/src/friction.test.ts +223 -0
- package/src/friction.ts +419 -0
- package/src/index.ts +1 -0
- package/src/run.test.ts +47 -0
- package/src/run.ts +80 -0
- package/src/server.ts +17 -0
- package/src/skeleton.test.ts +60 -0
- package/src/skeleton.ts +173 -0
- package/src/types.ts +2 -0
- package/src/web.ts +71 -1
package/dist/index.cjs
CHANGED
|
@@ -34,6 +34,7 @@ var DEFAULT_FLUSH_AT = 20;
|
|
|
34
34
|
var DEFAULT_FLUSH_INTERVAL_MS = 5e3;
|
|
35
35
|
var MAX_QUEUE = 1e3;
|
|
36
36
|
var BULK_LIMIT = 500;
|
|
37
|
+
var MAX_BACKOFF_MS = 6e4;
|
|
37
38
|
var HoldyCore = class {
|
|
38
39
|
apiKey;
|
|
39
40
|
baseUrl;
|
|
@@ -43,6 +44,9 @@ var HoldyCore = class {
|
|
|
43
44
|
queue = [];
|
|
44
45
|
timer = null;
|
|
45
46
|
flushing = null;
|
|
47
|
+
retryAt = 0;
|
|
48
|
+
failures = 0;
|
|
49
|
+
warned = /* @__PURE__ */ new Set();
|
|
46
50
|
userId = null;
|
|
47
51
|
anonymousId = null;
|
|
48
52
|
accountKey = null;
|
|
@@ -76,13 +80,14 @@ var HoldyCore = class {
|
|
|
76
80
|
event: name,
|
|
77
81
|
occurred_at: occurredAt,
|
|
78
82
|
source: this.source,
|
|
83
|
+
external_id: newEventId(),
|
|
79
84
|
...userId ? { user_id: userId } : {},
|
|
80
85
|
...this.anonymousId ? { anonymous_id: this.anonymousId } : {},
|
|
81
86
|
properties: properties ?? {},
|
|
82
87
|
...Object.keys(context).length > 0 ? { context } : {}
|
|
83
88
|
};
|
|
84
89
|
this.queue.push(event);
|
|
85
|
-
|
|
90
|
+
this.trimQueue();
|
|
86
91
|
if (this.queue.length >= this.flushAt) void this.flush();
|
|
87
92
|
}
|
|
88
93
|
identify(userId, traits) {
|
|
@@ -95,14 +100,9 @@ var HoldyCore = class {
|
|
|
95
100
|
this.accountKey = accountKey;
|
|
96
101
|
this.track("$group", traits ?? {});
|
|
97
102
|
}
|
|
98
|
-
/** Drena a fila. Nunca lança — falha re-enfileira e
|
|
103
|
+
/** Drena a fila. Nunca lança — falha re-enfileira e espera antes de tentar de novo. */
|
|
99
104
|
async flush() {
|
|
100
|
-
|
|
101
|
-
if (this.queue.length === 0) return;
|
|
102
|
-
this.flushing = this.drain().finally(() => {
|
|
103
|
-
this.flushing = null;
|
|
104
|
-
});
|
|
105
|
-
return this.flushing;
|
|
105
|
+
return this.startFlush(false);
|
|
106
106
|
}
|
|
107
107
|
/** Flush final + para o timer. Chamar em shutdown do server / unload do web. */
|
|
108
108
|
async shutdown() {
|
|
@@ -110,17 +110,42 @@ var HoldyCore = class {
|
|
|
110
110
|
clearInterval(this.timer);
|
|
111
111
|
this.timer = null;
|
|
112
112
|
}
|
|
113
|
-
await this.
|
|
113
|
+
await this.startFlush(true);
|
|
114
|
+
}
|
|
115
|
+
startFlush(force) {
|
|
116
|
+
if (this.flushing) return this.flushing;
|
|
117
|
+
if (this.queue.length === 0) return Promise.resolve();
|
|
118
|
+
if (!force && Date.now() < this.retryAt) return Promise.resolve();
|
|
119
|
+
this.flushing = this.drain().finally(() => {
|
|
120
|
+
this.flushing = null;
|
|
121
|
+
});
|
|
122
|
+
return this.flushing;
|
|
114
123
|
}
|
|
115
124
|
async drain() {
|
|
116
125
|
while (this.queue.length > 0) {
|
|
117
126
|
const batch = this.queue.splice(0, BULK_LIMIT);
|
|
118
|
-
const
|
|
119
|
-
if (
|
|
127
|
+
const outcome = await this.send(batch);
|
|
128
|
+
if (outcome.kind === "retry") {
|
|
120
129
|
this.queue.unshift(...batch);
|
|
130
|
+
this.trimQueue();
|
|
131
|
+
this.scheduleRetry(outcome.afterMs);
|
|
121
132
|
return;
|
|
122
133
|
}
|
|
134
|
+
this.failures = 0;
|
|
135
|
+
this.retryAt = 0;
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
scheduleRetry(afterMs) {
|
|
139
|
+
this.failures += 1;
|
|
140
|
+
if (afterMs !== null) {
|
|
141
|
+
this.retryAt = Date.now() + afterMs;
|
|
142
|
+
return;
|
|
123
143
|
}
|
|
144
|
+
const backoff = Math.min(MAX_BACKOFF_MS, 1e3 * 2 ** (this.failures - 1));
|
|
145
|
+
this.retryAt = Date.now() + backoff + Math.floor(Math.random() * backoff * 0.2);
|
|
146
|
+
}
|
|
147
|
+
trimQueue() {
|
|
148
|
+
if (this.queue.length > MAX_QUEUE) this.queue.splice(0, this.queue.length - MAX_QUEUE);
|
|
124
149
|
}
|
|
125
150
|
async send(events) {
|
|
126
151
|
try {
|
|
@@ -133,23 +158,38 @@ var HoldyCore = class {
|
|
|
133
158
|
body: JSON.stringify({ events }),
|
|
134
159
|
...this.source === "sdk_web" ? { keepalive: true } : {}
|
|
135
160
|
});
|
|
136
|
-
if (
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
}
|
|
141
|
-
this.log(`bulk failed ${res.status} \xB7 will retry`);
|
|
142
|
-
return false;
|
|
161
|
+
if (res.ok) return { kind: "sent" };
|
|
162
|
+
if (res.status === 429) {
|
|
163
|
+
this.log("rate limited \xB7 will retry after Retry-After");
|
|
164
|
+
return { kind: "retry", afterMs: retryAfterMs(res) };
|
|
143
165
|
}
|
|
144
|
-
|
|
166
|
+
if (res.status >= 500) {
|
|
167
|
+
this.log(`bulk failed ${res.status} \xB7 will retry with backoff`);
|
|
168
|
+
return { kind: "retry", afterMs: null };
|
|
169
|
+
}
|
|
170
|
+
if (res.status === 401) {
|
|
171
|
+
this.warnOnce("401", "API key inv\xE1lida ou revogada \xB7 eventos descartados. Confira a chave em Configura\xE7\xF5es \u203A SDK.");
|
|
172
|
+
return { kind: "dropped" };
|
|
173
|
+
}
|
|
174
|
+
if (res.status === 403) {
|
|
175
|
+
this.warnOnce("403", "Origem n\xE3o permitida ou chave sem escopo events:write \xB7 eventos descartados.");
|
|
176
|
+
return { kind: "dropped" };
|
|
177
|
+
}
|
|
178
|
+
this.log(`bulk rejected ${res.status} \xB7 batch dropped`);
|
|
179
|
+
return { kind: "dropped" };
|
|
145
180
|
} catch (err) {
|
|
146
|
-
this.log(`bulk network error: ${err.message} \xB7 will retry`);
|
|
147
|
-
return
|
|
181
|
+
this.log(`bulk network error: ${err.message} \xB7 will retry with backoff`);
|
|
182
|
+
return { kind: "retry", afterMs: null };
|
|
148
183
|
}
|
|
149
184
|
}
|
|
150
185
|
log(msg) {
|
|
151
186
|
if (this.debug) console.warn(`[holdy] ${msg}`);
|
|
152
187
|
}
|
|
188
|
+
warnOnce(key, msg) {
|
|
189
|
+
if (this.warned.has(key)) return;
|
|
190
|
+
this.warned.add(key);
|
|
191
|
+
console.warn(`[holdy] ${msg}`);
|
|
192
|
+
}
|
|
153
193
|
/**
|
|
154
194
|
* Ambiente que o SDK observa sozinho (url, referrer, browser, utm…).
|
|
155
195
|
* Vazio no core; `HoldyWeb` preenche. O server não infere ambiente — não
|
|
@@ -163,12 +203,72 @@ var HoldyCore = class {
|
|
|
163
203
|
return this.queue.length;
|
|
164
204
|
}
|
|
165
205
|
};
|
|
206
|
+
function retryAfterMs(res) {
|
|
207
|
+
const raw = typeof res.headers?.get === "function" ? res.headers.get("Retry-After") : null;
|
|
208
|
+
if (!raw) return null;
|
|
209
|
+
const seconds = Number(raw);
|
|
210
|
+
if (!Number.isFinite(seconds) || seconds < 0) return null;
|
|
211
|
+
return Math.min(MAX_BACKOFF_MS, seconds * 1e3);
|
|
212
|
+
}
|
|
213
|
+
function newEventId() {
|
|
214
|
+
const c = globalThis.crypto;
|
|
215
|
+
if (typeof c?.randomUUID === "function") return c.randomUUID();
|
|
216
|
+
const rand = () => Math.random().toString(36).slice(2, 12);
|
|
217
|
+
return `${Date.now().toString(36)}-${rand()}-${rand()}`;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// src/run.ts
|
|
221
|
+
function newRunId() {
|
|
222
|
+
const c = globalThis.crypto;
|
|
223
|
+
if (typeof c?.randomUUID === "function") return c.randomUUID();
|
|
224
|
+
return `run_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 10)}`;
|
|
225
|
+
}
|
|
226
|
+
function runEventName(name, suffix) {
|
|
227
|
+
const base = name.trim().replace(/\.$/, "");
|
|
228
|
+
return `${base || "run"}.${suffix}`;
|
|
229
|
+
}
|
|
230
|
+
async function withRun(track, opts, fn) {
|
|
231
|
+
if (!opts || !opts.name) throw new Error("[holdy] run: name is required");
|
|
232
|
+
const id = opts.runId && opts.runId.length > 0 ? opts.runId : newRunId();
|
|
233
|
+
const base = { ...opts.properties ?? {}, ai_run_id: id };
|
|
234
|
+
const overrides = {
|
|
235
|
+
...opts.accountKey ? { accountKey: opts.accountKey } : {},
|
|
236
|
+
...opts.userId ? { userId: opts.userId } : {}
|
|
237
|
+
};
|
|
238
|
+
const emit = (event, properties) => track(event, { ...base, ...properties ?? {}, ai_run_id: id }, overrides);
|
|
239
|
+
const startedAt = Date.now();
|
|
240
|
+
emit(runEventName(opts.name, "started"));
|
|
241
|
+
try {
|
|
242
|
+
const result = await fn({ id, track: emit });
|
|
243
|
+
emit(runEventName(opts.name, "completed"), { duration_ms: Date.now() - startedAt });
|
|
244
|
+
return result;
|
|
245
|
+
} catch (err) {
|
|
246
|
+
emit(runEventName(opts.name, "failed"), {
|
|
247
|
+
duration_ms: Date.now() - startedAt,
|
|
248
|
+
error: err instanceof Error ? err.name : "Error"
|
|
249
|
+
});
|
|
250
|
+
throw err;
|
|
251
|
+
}
|
|
252
|
+
}
|
|
166
253
|
|
|
167
254
|
// src/server.ts
|
|
168
255
|
var Holdy = class extends HoldyCore {
|
|
169
256
|
constructor(opts) {
|
|
170
257
|
super("sdk_server", opts);
|
|
171
258
|
}
|
|
259
|
+
/**
|
|
260
|
+
* Executa `fn` como UMA execução de agente: todo evento lá dentro carrega a
|
|
261
|
+
* mesma `ai_run_id`, e `<name>.started` / `.completed` / `.failed` saem
|
|
262
|
+
* sozinhos.
|
|
263
|
+
*
|
|
264
|
+
* await holdy.run({ name: 'support_agent', accountKey: 'acme' }, async (run) => {
|
|
265
|
+
* run.track('ticket_drafted', { ai_tokens: 1200, ai_cost_usd: 0.02 })
|
|
266
|
+
* return resolveTicket(ticket)
|
|
267
|
+
* })
|
|
268
|
+
*/
|
|
269
|
+
run(opts, fn) {
|
|
270
|
+
return withRun((name, properties, overrides) => this.track(name, properties, overrides), opts, fn);
|
|
271
|
+
}
|
|
172
272
|
};
|
|
173
273
|
|
|
174
274
|
// src/auto-capture.ts
|
|
@@ -234,6 +334,415 @@ function currentPath() {
|
|
|
234
334
|
return `${location.pathname}${location.search}${location.hash}`;
|
|
235
335
|
}
|
|
236
336
|
|
|
337
|
+
// src/friction.ts
|
|
338
|
+
var INSTALLED_FLAG2 = "__holdy_friction__";
|
|
339
|
+
var INTERACTIVE_SELECTOR = 'button, a, input, select, textarea, summary, [role="button"], [role="link"], [role="menuitem"], [role="tab"]';
|
|
340
|
+
var INTERACTIVE_TAGS = /* @__PURE__ */ new Set(["button", "a", "input", "select", "textarea", "summary"]);
|
|
341
|
+
var FORM_CONTROL_TAGS = /* @__PURE__ */ new Set(["input", "select", "textarea"]);
|
|
342
|
+
var RageClickDetector = class {
|
|
343
|
+
constructor(threshold = 3, windowMs = 1e3, radiusPx = 30) {
|
|
344
|
+
this.threshold = threshold;
|
|
345
|
+
this.windowMs = windowMs;
|
|
346
|
+
this.radiusPx = radiusPx;
|
|
347
|
+
}
|
|
348
|
+
clicks = [];
|
|
349
|
+
push(x, y, t) {
|
|
350
|
+
this.clicks = this.clicks.filter(
|
|
351
|
+
(c) => t - c.t <= this.windowMs && Math.hypot(c.x - x, c.y - y) <= this.radiusPx
|
|
352
|
+
);
|
|
353
|
+
this.clicks.push({ x, y, t });
|
|
354
|
+
if (this.clicks.length >= this.threshold) {
|
|
355
|
+
this.clicks = [];
|
|
356
|
+
return true;
|
|
357
|
+
}
|
|
358
|
+
return false;
|
|
359
|
+
}
|
|
360
|
+
};
|
|
361
|
+
var NavLoopDetector = class {
|
|
362
|
+
constructor(windowMs = 6e4) {
|
|
363
|
+
this.windowMs = windowMs;
|
|
364
|
+
}
|
|
365
|
+
visits = [];
|
|
366
|
+
push(path, t) {
|
|
367
|
+
this.visits = [...this.visits, { path, t }].filter((v) => t - v.t <= this.windowMs).slice(-4);
|
|
368
|
+
if (this.visits.length < 4) return null;
|
|
369
|
+
const [a, b, c, d] = this.visits.map((v) => v.path);
|
|
370
|
+
if (a === c && b === d && a !== b) {
|
|
371
|
+
this.visits = [];
|
|
372
|
+
return { from: a, to: b };
|
|
373
|
+
}
|
|
374
|
+
return null;
|
|
375
|
+
}
|
|
376
|
+
};
|
|
377
|
+
function resolveInteractive(el) {
|
|
378
|
+
if (!el) return null;
|
|
379
|
+
const viaClosest = el.closest?.(INTERACTIVE_SELECTOR) ?? null;
|
|
380
|
+
if (viaClosest) return viaClosest;
|
|
381
|
+
const tag = el.tagName.toLowerCase();
|
|
382
|
+
if (INTERACTIVE_TAGS.has(tag)) return el;
|
|
383
|
+
const role = el.getAttribute("role");
|
|
384
|
+
if (role === "button" || role === "link" || role === "menuitem" || role === "tab") return el;
|
|
385
|
+
return null;
|
|
386
|
+
}
|
|
387
|
+
function isDeadClickTarget(el) {
|
|
388
|
+
const target = resolveInteractive(el);
|
|
389
|
+
if (!target) return false;
|
|
390
|
+
if (target.disabled === true) return true;
|
|
391
|
+
if (target.getAttribute("disabled") !== null) return true;
|
|
392
|
+
if (target.getAttribute("aria-disabled") === "true") return true;
|
|
393
|
+
const tag = target.tagName.toLowerCase();
|
|
394
|
+
if (tag === "a") {
|
|
395
|
+
const href = target.getAttribute("href");
|
|
396
|
+
if (!href || href === "#" || href.startsWith("javascript:")) return true;
|
|
397
|
+
}
|
|
398
|
+
return false;
|
|
399
|
+
}
|
|
400
|
+
function describeElement(el) {
|
|
401
|
+
const target = resolveInteractive(el) ?? el;
|
|
402
|
+
if (!target) return {};
|
|
403
|
+
const out = { element: target.tagName.toLowerCase() };
|
|
404
|
+
if (target.id) out.element_id = target.id;
|
|
405
|
+
const role = target.getAttribute("role");
|
|
406
|
+
if (role) out.element_role = role;
|
|
407
|
+
const label = target.getAttribute("aria-label") ?? target.textContent ?? "";
|
|
408
|
+
const text = label.replace(/\s+/g, " ").trim().slice(0, 60);
|
|
409
|
+
if (text) out.element_text = text;
|
|
410
|
+
const name = target.getAttribute("name") ?? target.getAttribute("data-testid");
|
|
411
|
+
if (name) out.element_name = name;
|
|
412
|
+
return out;
|
|
413
|
+
}
|
|
414
|
+
function stripRequestUrl(input, base) {
|
|
415
|
+
try {
|
|
416
|
+
const url = new URL(input, base);
|
|
417
|
+
return `${url.origin}${url.pathname}`;
|
|
418
|
+
} catch {
|
|
419
|
+
return input.split("?")[0].split("#")[0];
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
function errorMessage(reason) {
|
|
423
|
+
if (reason instanceof Error) return reason.message;
|
|
424
|
+
if (typeof reason === "string") return reason;
|
|
425
|
+
try {
|
|
426
|
+
return JSON.stringify(reason);
|
|
427
|
+
} catch {
|
|
428
|
+
return String(reason);
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
function isAbortError(err) {
|
|
432
|
+
return typeof err === "object" && err !== null && err.name === "AbortError";
|
|
433
|
+
}
|
|
434
|
+
function isIgnoredUrl(url, ignore) {
|
|
435
|
+
return ignore.some((prefix) => prefix.length > 0 && url.startsWith(prefix));
|
|
436
|
+
}
|
|
437
|
+
function registrableDomain(host) {
|
|
438
|
+
const labels = host.toLowerCase().split(".").filter(Boolean);
|
|
439
|
+
if (labels.length <= 2) return labels.join(".");
|
|
440
|
+
return labels.slice(-2).join(".");
|
|
441
|
+
}
|
|
442
|
+
function isFirstPartyRequest(url, pageUrl, extraOrigins = []) {
|
|
443
|
+
let target;
|
|
444
|
+
let page;
|
|
445
|
+
try {
|
|
446
|
+
target = new URL(url, pageUrl);
|
|
447
|
+
page = new URL(pageUrl);
|
|
448
|
+
} catch {
|
|
449
|
+
return true;
|
|
450
|
+
}
|
|
451
|
+
if (target.protocol !== "http:" && target.protocol !== "https:") return true;
|
|
452
|
+
if (target.host === page.host) return true;
|
|
453
|
+
if (extraOrigins.some((o) => o.length > 0 && target.origin === o)) return true;
|
|
454
|
+
const targetDomain = registrableDomain(target.hostname);
|
|
455
|
+
return targetDomain.length > 0 && targetDomain === registrableDomain(page.hostname);
|
|
456
|
+
}
|
|
457
|
+
function classifyRequest(outcome) {
|
|
458
|
+
if (outcome.aborted) return null;
|
|
459
|
+
if (!outcome.opaque && (outcome.status === null || outcome.status >= 400)) return "http_error";
|
|
460
|
+
return outcome.durationMs >= outcome.slowMs ? "slow_request" : null;
|
|
461
|
+
}
|
|
462
|
+
function documentStatus(perf) {
|
|
463
|
+
try {
|
|
464
|
+
const entry = perf?.getEntriesByType?.("navigation")?.[0];
|
|
465
|
+
const status = entry?.responseStatus;
|
|
466
|
+
return typeof status === "number" && status > 0 ? status : null;
|
|
467
|
+
} catch {
|
|
468
|
+
return null;
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
function formKey(form) {
|
|
472
|
+
return form.id || form.getAttribute("name") || form.getAttribute("data-testid") || stripRequestUrl(form.getAttribute("action") ?? "", location.href).replace(location.origin, "") || "form";
|
|
473
|
+
}
|
|
474
|
+
function currentPath2() {
|
|
475
|
+
return typeof location === "undefined" ? "/" : location.pathname || "/";
|
|
476
|
+
}
|
|
477
|
+
function installFrictionCapture(opts) {
|
|
478
|
+
const noop = { onNavigate: () => void 0, teardown: () => void 0 };
|
|
479
|
+
if (typeof window === "undefined" || typeof document === "undefined") return noop;
|
|
480
|
+
const w = window;
|
|
481
|
+
if (w[INSTALLED_FLAG2]) return noop;
|
|
482
|
+
w[INSTALLED_FLAG2] = true;
|
|
483
|
+
const emit = (kind, properties) => {
|
|
484
|
+
try {
|
|
485
|
+
opts.onFriction({ kind, properties: { path: currentPath2(), ...properties } });
|
|
486
|
+
} catch {
|
|
487
|
+
return;
|
|
488
|
+
}
|
|
489
|
+
};
|
|
490
|
+
const cleanups = [];
|
|
491
|
+
const rage = new RageClickDetector(opts.rageClicks, opts.rageWindowMs, opts.rageRadiusPx);
|
|
492
|
+
const navLoop = new NavLoopDetector(opts.navLoopWindowMs);
|
|
493
|
+
const slowMs = opts.slowRequestMs ?? 3e3;
|
|
494
|
+
const touched = /* @__PURE__ */ new Map();
|
|
495
|
+
const onClick = (ev) => {
|
|
496
|
+
const el = ev.target instanceof Element ? ev.target : null;
|
|
497
|
+
if (isDeadClickTarget(el)) emit("dead_click", describeElement(el));
|
|
498
|
+
if (rage.push(ev.clientX, ev.clientY, ev.timeStamp || Date.now())) {
|
|
499
|
+
emit("rage_click", describeElement(el));
|
|
500
|
+
}
|
|
501
|
+
};
|
|
502
|
+
document.addEventListener("click", onClick, true);
|
|
503
|
+
cleanups.push(() => document.removeEventListener("click", onClick, true));
|
|
504
|
+
const onError = (ev) => {
|
|
505
|
+
emit("js_error", {
|
|
506
|
+
message: String(ev.message ?? "").slice(0, 200),
|
|
507
|
+
...ev.filename ? { source: stripRequestUrl(ev.filename, location.href) } : {},
|
|
508
|
+
...ev.lineno ? { line: ev.lineno } : {}
|
|
509
|
+
});
|
|
510
|
+
};
|
|
511
|
+
window.addEventListener("error", onError);
|
|
512
|
+
cleanups.push(() => window.removeEventListener("error", onError));
|
|
513
|
+
const onRejection = (ev) => {
|
|
514
|
+
emit("js_error", { message: errorMessage(ev.reason).slice(0, 200), unhandled_rejection: true });
|
|
515
|
+
};
|
|
516
|
+
window.addEventListener("unhandledrejection", onRejection);
|
|
517
|
+
cleanups.push(() => window.removeEventListener("unhandledrejection", onRejection));
|
|
518
|
+
const ignore = (opts.ignoreUrls ?? []).map((url) => stripRequestUrl(url, location.href));
|
|
519
|
+
const apiOrigins = (opts.apiOrigins ?? []).map((o) => {
|
|
520
|
+
try {
|
|
521
|
+
return new URL(o, location.href).origin;
|
|
522
|
+
} catch {
|
|
523
|
+
return "";
|
|
524
|
+
}
|
|
525
|
+
});
|
|
526
|
+
const reportRequest = (url, method, started, status, flags = {}) => {
|
|
527
|
+
const target = stripRequestUrl(url, location.href);
|
|
528
|
+
if (isIgnoredUrl(target, ignore)) return;
|
|
529
|
+
if (!isFirstPartyRequest(target, location.href, apiOrigins)) return;
|
|
530
|
+
const durationMs = performance.now() - started;
|
|
531
|
+
const kind = classifyRequest({ status, durationMs, slowMs, ...flags });
|
|
532
|
+
if (!kind) return;
|
|
533
|
+
emit(kind, {
|
|
534
|
+
url: target,
|
|
535
|
+
method,
|
|
536
|
+
duration_ms: Math.round(durationMs),
|
|
537
|
+
...flags.opaque ? {} : status !== null ? { status } : { failed: true }
|
|
538
|
+
});
|
|
539
|
+
};
|
|
540
|
+
const originalFetch = window.fetch;
|
|
541
|
+
if (typeof originalFetch === "function") {
|
|
542
|
+
const patched = async (input, init) => {
|
|
543
|
+
const started = performance.now();
|
|
544
|
+
const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url;
|
|
545
|
+
const method = (init?.method ?? (input instanceof Request ? input.method : "GET")).toUpperCase();
|
|
546
|
+
try {
|
|
547
|
+
const res = await originalFetch(input, init);
|
|
548
|
+
reportRequest(url, method, started, res.status, {
|
|
549
|
+
opaque: res.type === "opaque" || res.type === "opaqueredirect"
|
|
550
|
+
});
|
|
551
|
+
return res;
|
|
552
|
+
} catch (err) {
|
|
553
|
+
reportRequest(url, method, started, null, { aborted: isAbortError(err) });
|
|
554
|
+
throw err;
|
|
555
|
+
}
|
|
556
|
+
};
|
|
557
|
+
window.fetch = patched;
|
|
558
|
+
cleanups.push(() => {
|
|
559
|
+
window.fetch = originalFetch;
|
|
560
|
+
});
|
|
561
|
+
}
|
|
562
|
+
const xhrProto = typeof XMLHttpRequest === "function" ? XMLHttpRequest.prototype : null;
|
|
563
|
+
if (xhrProto) {
|
|
564
|
+
const originalOpen = xhrProto.open;
|
|
565
|
+
const originalSend = xhrProto.send;
|
|
566
|
+
const requests = /* @__PURE__ */ new WeakMap();
|
|
567
|
+
xhrProto.open = function(method, url, ...rest) {
|
|
568
|
+
requests.set(this, { method: String(method).toUpperCase(), url: String(url) });
|
|
569
|
+
return originalOpen.apply(this, [method, url, ...rest]);
|
|
570
|
+
};
|
|
571
|
+
xhrProto.send = function(body) {
|
|
572
|
+
const request = requests.get(this);
|
|
573
|
+
if (request) {
|
|
574
|
+
const started = performance.now();
|
|
575
|
+
let aborted = false;
|
|
576
|
+
this.addEventListener("abort", () => {
|
|
577
|
+
aborted = true;
|
|
578
|
+
});
|
|
579
|
+
this.addEventListener("loadend", () => {
|
|
580
|
+
reportRequest(request.url, request.method, started, this.status === 0 ? null : this.status, { aborted });
|
|
581
|
+
});
|
|
582
|
+
}
|
|
583
|
+
return originalSend.call(this, body);
|
|
584
|
+
};
|
|
585
|
+
cleanups.push(() => {
|
|
586
|
+
xhrProto.open = originalOpen;
|
|
587
|
+
xhrProto.send = originalSend;
|
|
588
|
+
});
|
|
589
|
+
}
|
|
590
|
+
const loadStatus = documentStatus(typeof performance === "undefined" ? void 0 : performance);
|
|
591
|
+
if (loadStatus !== null && loadStatus >= 400) {
|
|
592
|
+
emit("http_error", { document: true, status: loadStatus, url: stripRequestUrl(location.href) });
|
|
593
|
+
}
|
|
594
|
+
const onFocusIn = (ev) => {
|
|
595
|
+
const el = ev.target;
|
|
596
|
+
if (!el || !FORM_CONTROL_TAGS.has(el.tagName.toLowerCase())) return;
|
|
597
|
+
const form = el.form;
|
|
598
|
+
if (!form) return;
|
|
599
|
+
const entry = touched.get(form) ?? { form: formKey(form), fields: /* @__PURE__ */ new Set() };
|
|
600
|
+
const field = el.name || el.id || el.tagName.toLowerCase();
|
|
601
|
+
entry.fields.add(field);
|
|
602
|
+
touched.set(form, entry);
|
|
603
|
+
};
|
|
604
|
+
document.addEventListener("focusin", onFocusIn, true);
|
|
605
|
+
cleanups.push(() => document.removeEventListener("focusin", onFocusIn, true));
|
|
606
|
+
const onSubmit = (ev) => {
|
|
607
|
+
if (ev.target instanceof HTMLFormElement) touched.delete(ev.target);
|
|
608
|
+
};
|
|
609
|
+
document.addEventListener("submit", onSubmit, true);
|
|
610
|
+
cleanups.push(() => document.removeEventListener("submit", onSubmit, true));
|
|
611
|
+
const flushAbandons = () => {
|
|
612
|
+
for (const entry of touched.values()) {
|
|
613
|
+
emit("form_abandon", { form: entry.form, fields_touched: entry.fields.size });
|
|
614
|
+
}
|
|
615
|
+
touched.clear();
|
|
616
|
+
};
|
|
617
|
+
window.addEventListener("pagehide", flushAbandons);
|
|
618
|
+
cleanups.push(() => window.removeEventListener("pagehide", flushAbandons));
|
|
619
|
+
return {
|
|
620
|
+
onNavigate: (path) => {
|
|
621
|
+
flushAbandons();
|
|
622
|
+
const loop = navLoop.push(path, Date.now());
|
|
623
|
+
if (loop) emit("nav_loop", { from: loop.from, to: loop.to });
|
|
624
|
+
},
|
|
625
|
+
teardown: () => {
|
|
626
|
+
for (const cleanup of cleanups) cleanup();
|
|
627
|
+
touched.clear();
|
|
628
|
+
w[INSTALLED_FLAG2] = false;
|
|
629
|
+
}
|
|
630
|
+
};
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
// src/skeleton.ts
|
|
634
|
+
var SKELETON_SELECTOR = 'a[href], button, input, select, textarea, [role="button"], [role="link"], [role="tab"], [role="menuitem"], [role="checkbox"], [role="radio"], [role="switch"], [data-testid]';
|
|
635
|
+
var SKELETON_MAX_ELEMENTS = 80;
|
|
636
|
+
var SKELETON_DELAY_MS = 1500;
|
|
637
|
+
var SENT_STORAGE_KEY = "holdy_skeletons";
|
|
638
|
+
var SENT_STORAGE_MAX = 200;
|
|
639
|
+
function fnv1a(input) {
|
|
640
|
+
let hash = 2166136261;
|
|
641
|
+
for (let i = 0; i < input.length; i++) {
|
|
642
|
+
hash ^= input.charCodeAt(i);
|
|
643
|
+
hash = Math.imul(hash, 16777619) >>> 0;
|
|
644
|
+
}
|
|
645
|
+
return hash.toString(16).padStart(8, "0");
|
|
646
|
+
}
|
|
647
|
+
function attr(el, name, max) {
|
|
648
|
+
const value = el.getAttribute(name);
|
|
649
|
+
if (!value) return void 0;
|
|
650
|
+
const trimmed = value.replace(/\s+/g, " ").trim().slice(0, max);
|
|
651
|
+
return trimmed || void 0;
|
|
652
|
+
}
|
|
653
|
+
function isVisible(el) {
|
|
654
|
+
if (!el.getBoundingClientRect) return true;
|
|
655
|
+
const rect = el.getBoundingClientRect();
|
|
656
|
+
return rect.width > 0 && rect.height > 0;
|
|
657
|
+
}
|
|
658
|
+
function relativeBox(rect, viewport) {
|
|
659
|
+
const pct = (value, base) => base > 0 ? Math.max(0, Math.min(100, Math.round(value / base * 100))) : 0;
|
|
660
|
+
return [pct(rect.x, viewport.width), pct(rect.y + viewport.scrollY, viewport.height), pct(rect.width, viewport.width), pct(rect.height, viewport.height)];
|
|
661
|
+
}
|
|
662
|
+
function describeSkeletonElement(el, viewport) {
|
|
663
|
+
const out = { t: el.tagName.toLowerCase() };
|
|
664
|
+
const role = attr(el, "role", 40);
|
|
665
|
+
if (role) out.r = role;
|
|
666
|
+
const testid = attr(el, "data-testid", 80);
|
|
667
|
+
if (testid) out.id = testid;
|
|
668
|
+
const name = attr(el, "name", 80);
|
|
669
|
+
if (name) out.n = name;
|
|
670
|
+
const label = attr(el, "aria-label", 80);
|
|
671
|
+
if (label) out.l = label;
|
|
672
|
+
if (out.t === "input" || out.t === "button") {
|
|
673
|
+
const type = attr(el, "type", 20);
|
|
674
|
+
if (type) out.k = type.toLowerCase();
|
|
675
|
+
}
|
|
676
|
+
if (viewport && el.getBoundingClientRect) out.b = relativeBox(el.getBoundingClientRect(), viewport);
|
|
677
|
+
return out;
|
|
678
|
+
}
|
|
679
|
+
function collectSkeleton(root, path, viewport) {
|
|
680
|
+
const found = root.querySelectorAll(SKELETON_SELECTOR);
|
|
681
|
+
const visible = [];
|
|
682
|
+
for (let i = 0; i < found.length; i++) {
|
|
683
|
+
const el = found[i];
|
|
684
|
+
if (el.tagName.toLowerCase() === "input" && (el.getAttribute("type") ?? "").toLowerCase() === "hidden") continue;
|
|
685
|
+
if (!isVisible(el)) continue;
|
|
686
|
+
visible.push(el);
|
|
687
|
+
}
|
|
688
|
+
const elements = visible.slice(0, SKELETON_MAX_ELEMENTS).map((el) => describeSkeletonElement(el, viewport));
|
|
689
|
+
const structural = elements.map(({ b: _b, ...rest }) => rest);
|
|
690
|
+
return { path, hash: fnv1a(JSON.stringify(structural)), count: visible.length, elements };
|
|
691
|
+
}
|
|
692
|
+
function loadSent() {
|
|
693
|
+
try {
|
|
694
|
+
const raw = sessionStorage.getItem(SENT_STORAGE_KEY);
|
|
695
|
+
const list = raw ? JSON.parse(raw) : [];
|
|
696
|
+
return new Set(Array.isArray(list) ? list.filter((x) => typeof x === "string") : []);
|
|
697
|
+
} catch {
|
|
698
|
+
return /* @__PURE__ */ new Set();
|
|
699
|
+
}
|
|
700
|
+
}
|
|
701
|
+
function saveSent(sent) {
|
|
702
|
+
try {
|
|
703
|
+
sessionStorage.setItem(SENT_STORAGE_KEY, JSON.stringify([...sent].slice(-SENT_STORAGE_MAX)));
|
|
704
|
+
} catch {
|
|
705
|
+
return;
|
|
706
|
+
}
|
|
707
|
+
}
|
|
708
|
+
function installSkeletonCapture(opts) {
|
|
709
|
+
if (typeof window === "undefined" || typeof document === "undefined") {
|
|
710
|
+
return { onNavigate: () => void 0, teardown: () => void 0 };
|
|
711
|
+
}
|
|
712
|
+
const delay = opts.delayMs ?? SKELETON_DELAY_MS;
|
|
713
|
+
const sent = loadSent();
|
|
714
|
+
let pending = null;
|
|
715
|
+
const capture = (path) => {
|
|
716
|
+
pending = null;
|
|
717
|
+
if (location.pathname !== path) return;
|
|
718
|
+
try {
|
|
719
|
+
const viewport = {
|
|
720
|
+
width: window.innerWidth,
|
|
721
|
+
height: Math.max(document.documentElement.scrollHeight, window.innerHeight),
|
|
722
|
+
scrollY: window.scrollY
|
|
723
|
+
};
|
|
724
|
+
const skeleton = collectSkeleton(document, path, viewport);
|
|
725
|
+
const key = `${path}:${skeleton.hash}`;
|
|
726
|
+
if (sent.has(key)) return;
|
|
727
|
+
sent.add(key);
|
|
728
|
+
saveSent(sent);
|
|
729
|
+
opts.onSkeleton(skeleton);
|
|
730
|
+
} catch {
|
|
731
|
+
return;
|
|
732
|
+
}
|
|
733
|
+
};
|
|
734
|
+
return {
|
|
735
|
+
onNavigate: (path) => {
|
|
736
|
+
if (pending) clearTimeout(pending);
|
|
737
|
+
pending = setTimeout(() => capture(path), delay);
|
|
738
|
+
},
|
|
739
|
+
teardown: () => {
|
|
740
|
+
if (pending) clearTimeout(pending);
|
|
741
|
+
pending = null;
|
|
742
|
+
}
|
|
743
|
+
};
|
|
744
|
+
}
|
|
745
|
+
|
|
237
746
|
// src/web-context.ts
|
|
238
747
|
var UTM_KEYS = [
|
|
239
748
|
"utm_source",
|
|
@@ -276,17 +785,17 @@ function parseUserAgent(ua) {
|
|
|
276
785
|
device
|
|
277
786
|
};
|
|
278
787
|
}
|
|
279
|
-
function readStorage(
|
|
788
|
+
function readStorage(storage2, key) {
|
|
280
789
|
try {
|
|
281
|
-
const s =
|
|
790
|
+
const s = storage2 === "session" ? sessionStorage : localStorage;
|
|
282
791
|
return s.getItem(key);
|
|
283
792
|
} catch {
|
|
284
793
|
return null;
|
|
285
794
|
}
|
|
286
795
|
}
|
|
287
|
-
function writeStorage(
|
|
796
|
+
function writeStorage(storage2, key, value) {
|
|
288
797
|
try {
|
|
289
|
-
const s =
|
|
798
|
+
const s = storage2 === "session" ? sessionStorage : localStorage;
|
|
290
799
|
s.setItem(key, value);
|
|
291
800
|
} catch {
|
|
292
801
|
}
|
|
@@ -351,10 +860,307 @@ function buildWebContext() {
|
|
|
351
860
|
};
|
|
352
861
|
}
|
|
353
862
|
|
|
863
|
+
// src/arm.ts
|
|
864
|
+
function fnv1a2(input) {
|
|
865
|
+
let hash = 2166136261;
|
|
866
|
+
for (let i = 0; i < input.length; i++) {
|
|
867
|
+
hash ^= input.charCodeAt(i);
|
|
868
|
+
hash = Math.imul(hash, 16777619) >>> 0;
|
|
869
|
+
}
|
|
870
|
+
return hash >>> 0;
|
|
871
|
+
}
|
|
872
|
+
function changeBucket(changeId, accountKey) {
|
|
873
|
+
return fnv1a2(`${changeId}:${accountKey}`) % 100;
|
|
874
|
+
}
|
|
875
|
+
function changeArm(changeId, accountKey, holdoutPct) {
|
|
876
|
+
return changeBucket(changeId, accountKey) < holdoutPct ? "holdout" : "change";
|
|
877
|
+
}
|
|
878
|
+
function matchesPathTemplate(template, path) {
|
|
879
|
+
const t = template.split(/[?#]/)[0].split("/").filter(Boolean);
|
|
880
|
+
const p = path.split(/[?#]/)[0].split("/").filter(Boolean);
|
|
881
|
+
if (t.length !== p.length) return false;
|
|
882
|
+
return t.every((segment, i) => segment.startsWith(":") || segment === p[i]);
|
|
883
|
+
}
|
|
884
|
+
|
|
885
|
+
// src/changes.ts
|
|
886
|
+
var REFRESH_MS = 5 * 60 * 1e3;
|
|
887
|
+
var NAV_REFRESH_MS = 60 * 1e3;
|
|
888
|
+
var SCAN_DEBOUNCE_MS = 250;
|
|
889
|
+
var SEEN_KEY = "holdy_changes_seen";
|
|
890
|
+
var DISMISSED_KEY = "holdy_changes_dismissed";
|
|
891
|
+
var TIP_GAP_PX = 8;
|
|
892
|
+
var TIP_CSS = `:host{all:initial}
|
|
893
|
+
.tip{position:fixed;box-sizing:border-box;max-width:280px;padding:10px 12px;border-radius:8px;background:#111;color:#fff;font:13px/1.45 system-ui,-apple-system,sans-serif;box-shadow:0 8px 24px rgba(0,0,0,.18);pointer-events:auto;display:flex;gap:10px;align-items:flex-start}
|
|
894
|
+
.tip p{margin:0;flex:1}
|
|
895
|
+
.tip button{all:unset;cursor:pointer;opacity:.7;font-size:15px;line-height:1}
|
|
896
|
+
.tip button:hover,.tip button:focus-visible{opacity:1}
|
|
897
|
+
@media (prefers-color-scheme:dark){.tip{background:#fff;color:#111}}`;
|
|
898
|
+
function normalizeText(text) {
|
|
899
|
+
return text.replace(/\s+/g, " ").trim();
|
|
900
|
+
}
|
|
901
|
+
function parseChanges(body) {
|
|
902
|
+
const list = body?.changes;
|
|
903
|
+
return Array.isArray(list) ? list.filter(isChange) : [];
|
|
904
|
+
}
|
|
905
|
+
function isAnchor(value) {
|
|
906
|
+
const a = value;
|
|
907
|
+
if (a?.kind === "page") return true;
|
|
908
|
+
return (a?.kind === "selector" || a?.kind === "text") && typeof a.value === "string" && a.value.length > 0;
|
|
909
|
+
}
|
|
910
|
+
function isChange(value) {
|
|
911
|
+
const c = value;
|
|
912
|
+
return typeof c?.id === "string" && (c.kind === "copy" || c.kind === "guidance") && typeof c.path === "string" && isAnchor(c.anchor) && typeof c.content?.text === "string" && typeof c.holdout_pct === "number";
|
|
913
|
+
}
|
|
914
|
+
function changesForPage(changes, path, accountKey) {
|
|
915
|
+
if (!accountKey) return [];
|
|
916
|
+
return changes.filter((c) => matchesPathTemplate(c.path, path)).map((change) => ({ change, arm: changeArm(change.id, accountKey, change.holdout_pct) }));
|
|
917
|
+
}
|
|
918
|
+
function storage(kind) {
|
|
919
|
+
try {
|
|
920
|
+
return kind === "local" ? window.localStorage : window.sessionStorage;
|
|
921
|
+
} catch {
|
|
922
|
+
return void 0;
|
|
923
|
+
}
|
|
924
|
+
}
|
|
925
|
+
function readSet(store, key) {
|
|
926
|
+
try {
|
|
927
|
+
return new Set(JSON.parse(store?.getItem(key) ?? "[]"));
|
|
928
|
+
} catch {
|
|
929
|
+
return /* @__PURE__ */ new Set();
|
|
930
|
+
}
|
|
931
|
+
}
|
|
932
|
+
function addToSet(store, key, value) {
|
|
933
|
+
try {
|
|
934
|
+
const set = readSet(store, key);
|
|
935
|
+
set.add(value);
|
|
936
|
+
store?.setItem(key, JSON.stringify([...set].slice(-200)));
|
|
937
|
+
} catch {
|
|
938
|
+
return;
|
|
939
|
+
}
|
|
940
|
+
}
|
|
941
|
+
function query(selector) {
|
|
942
|
+
try {
|
|
943
|
+
return document.querySelector(selector);
|
|
944
|
+
} catch {
|
|
945
|
+
return null;
|
|
946
|
+
}
|
|
947
|
+
}
|
|
948
|
+
var TEXT_ANCHOR_TAGS = 'button,a,[role="button"],label,summary,h1,h2,h3,h4,li,td,th,p,span,strong';
|
|
949
|
+
var TEXT_ANCHOR_SCAN_LIMIT = 3e3;
|
|
950
|
+
function findByText(value) {
|
|
951
|
+
const wanted = normalizeText(value);
|
|
952
|
+
let best = null;
|
|
953
|
+
let bestLength = Number.POSITIVE_INFINITY;
|
|
954
|
+
const nodes = document.querySelectorAll(TEXT_ANCHOR_TAGS);
|
|
955
|
+
const total = Math.min(nodes.length, TEXT_ANCHOR_SCAN_LIMIT);
|
|
956
|
+
for (let i = 0; i < total; i += 1) {
|
|
957
|
+
const el = nodes[i];
|
|
958
|
+
const raw = el.textContent ?? "";
|
|
959
|
+
if (raw.length >= bestLength || normalizeText(raw) !== wanted) continue;
|
|
960
|
+
best = el;
|
|
961
|
+
bestLength = raw.length;
|
|
962
|
+
}
|
|
963
|
+
return best;
|
|
964
|
+
}
|
|
965
|
+
function findAnchor(anchor) {
|
|
966
|
+
if (anchor.kind === "page") return null;
|
|
967
|
+
return anchor.kind === "selector" ? query(anchor.value) : findByText(anchor.value);
|
|
968
|
+
}
|
|
969
|
+
function findTextNode(el, match) {
|
|
970
|
+
const walker = document.createTreeWalker(el, NodeFilter.SHOW_TEXT);
|
|
971
|
+
for (let node = walker.nextNode(); node; node = walker.nextNode()) {
|
|
972
|
+
if (match(normalizeText(node.textContent ?? ""))) return node;
|
|
973
|
+
}
|
|
974
|
+
return null;
|
|
975
|
+
}
|
|
976
|
+
function installProductChanges(opts) {
|
|
977
|
+
if (typeof window === "undefined" || typeof document === "undefined") {
|
|
978
|
+
return { onNavigate: () => void 0, refresh: async () => void 0, teardown: () => void 0 };
|
|
979
|
+
}
|
|
980
|
+
let changes = [];
|
|
981
|
+
let lastFetch = 0;
|
|
982
|
+
let timer = null;
|
|
983
|
+
let root = null;
|
|
984
|
+
let host = null;
|
|
985
|
+
const copies = /* @__PURE__ */ new Map();
|
|
986
|
+
const tips = /* @__PURE__ */ new Map();
|
|
987
|
+
const log = (message) => {
|
|
988
|
+
if (opts.debug) console.warn(`[holdy] ${message}`);
|
|
989
|
+
};
|
|
990
|
+
const expose = (change, arm) => {
|
|
991
|
+
const key = `${change.id}:${arm}`;
|
|
992
|
+
const session = storage("session");
|
|
993
|
+
if (readSet(session, SEEN_KEY).has(key)) return;
|
|
994
|
+
addToSet(session, SEEN_KEY, key);
|
|
995
|
+
opts.track("$change_exposure", { change_id: change.id, arm, kind: change.kind });
|
|
996
|
+
};
|
|
997
|
+
const eligibleCopyNode = (el, change) => {
|
|
998
|
+
const expected = change.expected_text ? normalizeText(change.expected_text) : null;
|
|
999
|
+
const next = normalizeText(change.content.text);
|
|
1000
|
+
return findTextNode(el, (text) => text.length > 0 && (text === next || expected === null || text === expected));
|
|
1001
|
+
};
|
|
1002
|
+
const applyCopy = (el, change) => {
|
|
1003
|
+
const next = normalizeText(change.content.text);
|
|
1004
|
+
const current = copies.get(change.id);
|
|
1005
|
+
if (current && current.node.isConnected && normalizeText(current.node.textContent ?? "") === next) return true;
|
|
1006
|
+
const node = eligibleCopyNode(el, change);
|
|
1007
|
+
if (!node) return false;
|
|
1008
|
+
const text = node.textContent ?? "";
|
|
1009
|
+
if (normalizeText(text) !== next) {
|
|
1010
|
+
copies.set(change.id, { node, original: text, next: change.content.text });
|
|
1011
|
+
node.textContent = text.replace(text.trim(), change.content.text);
|
|
1012
|
+
}
|
|
1013
|
+
return true;
|
|
1014
|
+
};
|
|
1015
|
+
const restoreCopy = (id) => {
|
|
1016
|
+
const applied = copies.get(id);
|
|
1017
|
+
if (!applied) return;
|
|
1018
|
+
if (applied.node.isConnected && normalizeText(applied.node.textContent ?? "") === normalizeText(applied.next)) {
|
|
1019
|
+
applied.node.textContent = applied.original;
|
|
1020
|
+
}
|
|
1021
|
+
copies.delete(id);
|
|
1022
|
+
};
|
|
1023
|
+
const ensureRoot = () => {
|
|
1024
|
+
if (root) return root;
|
|
1025
|
+
if (!document.body) return null;
|
|
1026
|
+
host = document.createElement("holdy-changes");
|
|
1027
|
+
host.setAttribute("data-holdy-root", "1");
|
|
1028
|
+
host.style.cssText = "position:fixed;top:0;left:0;width:0;height:0;z-index:2147483647;pointer-events:none";
|
|
1029
|
+
root = host.attachShadow({ mode: "closed" });
|
|
1030
|
+
const style = document.createElement("style");
|
|
1031
|
+
style.textContent = TIP_CSS;
|
|
1032
|
+
root.appendChild(style);
|
|
1033
|
+
document.body.appendChild(host);
|
|
1034
|
+
return root;
|
|
1035
|
+
};
|
|
1036
|
+
const showTip = (anchor, change) => {
|
|
1037
|
+
if (tips.has(change.id)) return true;
|
|
1038
|
+
if (readSet(storage("local"), DISMISSED_KEY).has(change.id)) return true;
|
|
1039
|
+
const shadow = ensureRoot();
|
|
1040
|
+
if (!shadow) return false;
|
|
1041
|
+
const card = document.createElement("div");
|
|
1042
|
+
card.className = "tip";
|
|
1043
|
+
card.setAttribute("role", "tooltip");
|
|
1044
|
+
const text = document.createElement("p");
|
|
1045
|
+
text.textContent = change.content.text;
|
|
1046
|
+
const close = document.createElement("button");
|
|
1047
|
+
close.type = "button";
|
|
1048
|
+
close.setAttribute("aria-label", "Fechar");
|
|
1049
|
+
close.textContent = "\xD7";
|
|
1050
|
+
card.append(text, close);
|
|
1051
|
+
card.style.top = "-9999px";
|
|
1052
|
+
card.style.left = "-9999px";
|
|
1053
|
+
shadow.appendChild(card);
|
|
1054
|
+
const remove = () => {
|
|
1055
|
+
card.remove();
|
|
1056
|
+
window.removeEventListener("scroll", position, true);
|
|
1057
|
+
window.removeEventListener("resize", position);
|
|
1058
|
+
tips.delete(change.id);
|
|
1059
|
+
};
|
|
1060
|
+
function position() {
|
|
1061
|
+
if (!anchor) {
|
|
1062
|
+
const corner = card.getBoundingClientRect();
|
|
1063
|
+
card.style.top = `${Math.max(8, window.innerHeight - corner.height - 16)}px`;
|
|
1064
|
+
card.style.left = `${Math.max(8, window.innerWidth - corner.width - 16)}px`;
|
|
1065
|
+
return;
|
|
1066
|
+
}
|
|
1067
|
+
if (!anchor.isConnected) {
|
|
1068
|
+
remove();
|
|
1069
|
+
return;
|
|
1070
|
+
}
|
|
1071
|
+
const a = anchor.getBoundingClientRect();
|
|
1072
|
+
const c = card.getBoundingClientRect();
|
|
1073
|
+
let top = a.bottom + TIP_GAP_PX;
|
|
1074
|
+
if (top + c.height > window.innerHeight - 8) top = a.top - c.height - TIP_GAP_PX;
|
|
1075
|
+
const left = Math.min(Math.max(8, a.left + a.width / 2 - c.width / 2), window.innerWidth - c.width - 8);
|
|
1076
|
+
card.style.top = `${Math.max(8, top)}px`;
|
|
1077
|
+
card.style.left = `${Math.max(8, left)}px`;
|
|
1078
|
+
}
|
|
1079
|
+
close.addEventListener("click", () => {
|
|
1080
|
+
addToSet(storage("local"), DISMISSED_KEY, change.id);
|
|
1081
|
+
opts.track("$change_dismissed", { change_id: change.id, kind: change.kind });
|
|
1082
|
+
remove();
|
|
1083
|
+
});
|
|
1084
|
+
tips.set(change.id, remove);
|
|
1085
|
+
requestAnimationFrame(position);
|
|
1086
|
+
window.addEventListener("scroll", position, true);
|
|
1087
|
+
window.addEventListener("resize", position);
|
|
1088
|
+
return true;
|
|
1089
|
+
};
|
|
1090
|
+
const scan = () => {
|
|
1091
|
+
timer = null;
|
|
1092
|
+
const active = changesForPage(changes, location.pathname, opts.getAccountKey());
|
|
1093
|
+
const activeIds = new Set(active.map((a) => a.change.id));
|
|
1094
|
+
for (const id of [...copies.keys()]) if (!activeIds.has(id)) restoreCopy(id);
|
|
1095
|
+
for (const [id, remove] of [...tips]) if (!activeIds.has(id)) remove();
|
|
1096
|
+
for (const { change, arm } of active) {
|
|
1097
|
+
const el = findAnchor(change.anchor);
|
|
1098
|
+
if (!el && change.anchor.kind !== "page") continue;
|
|
1099
|
+
if (change.kind === "copy") {
|
|
1100
|
+
if (!el) continue;
|
|
1101
|
+
const shown = arm === "change" ? applyCopy(el, change) : eligibleCopyNode(el, change) !== null;
|
|
1102
|
+
if (shown) expose(change, arm);
|
|
1103
|
+
} else if (arm === "holdout" || showTip(el, change)) {
|
|
1104
|
+
expose(change, arm);
|
|
1105
|
+
}
|
|
1106
|
+
}
|
|
1107
|
+
};
|
|
1108
|
+
const schedule = () => {
|
|
1109
|
+
if (timer === null) timer = setTimeout(scan, SCAN_DEBOUNCE_MS);
|
|
1110
|
+
};
|
|
1111
|
+
const refresh = async () => {
|
|
1112
|
+
lastFetch = Date.now();
|
|
1113
|
+
try {
|
|
1114
|
+
const res = await fetch(`${opts.baseUrl}/runtime/changes`, {
|
|
1115
|
+
headers: { Authorization: `Bearer ${opts.apiKey}` }
|
|
1116
|
+
});
|
|
1117
|
+
if (!res.ok) {
|
|
1118
|
+
log(`changes ${res.status}`);
|
|
1119
|
+
return;
|
|
1120
|
+
}
|
|
1121
|
+
changes = parseChanges(await res.json());
|
|
1122
|
+
schedule();
|
|
1123
|
+
} catch (err) {
|
|
1124
|
+
log(`changes network error: ${err.message}`);
|
|
1125
|
+
}
|
|
1126
|
+
};
|
|
1127
|
+
const observer = typeof MutationObserver !== "undefined" ? new MutationObserver(() => {
|
|
1128
|
+
if (changes.length > 0 || copies.size > 0 || tips.size > 0) schedule();
|
|
1129
|
+
}) : null;
|
|
1130
|
+
const observe = () => {
|
|
1131
|
+
if (document.body) observer?.observe(document.body, { childList: true, subtree: true, characterData: true });
|
|
1132
|
+
};
|
|
1133
|
+
if (document.readyState === "loading") document.addEventListener("DOMContentLoaded", observe, { once: true });
|
|
1134
|
+
else observe();
|
|
1135
|
+
const interval = setInterval(() => void refresh(), REFRESH_MS);
|
|
1136
|
+
void refresh();
|
|
1137
|
+
return {
|
|
1138
|
+
onNavigate: () => {
|
|
1139
|
+
if (Date.now() - lastFetch > NAV_REFRESH_MS) void refresh();
|
|
1140
|
+
schedule();
|
|
1141
|
+
},
|
|
1142
|
+
refresh,
|
|
1143
|
+
teardown: () => {
|
|
1144
|
+
clearInterval(interval);
|
|
1145
|
+
if (timer !== null) clearTimeout(timer);
|
|
1146
|
+
observer?.disconnect();
|
|
1147
|
+
for (const id of [...copies.keys()]) restoreCopy(id);
|
|
1148
|
+
for (const remove of [...tips.values()]) remove();
|
|
1149
|
+
host?.remove();
|
|
1150
|
+
host = null;
|
|
1151
|
+
root = null;
|
|
1152
|
+
changes = [];
|
|
1153
|
+
}
|
|
1154
|
+
};
|
|
1155
|
+
}
|
|
1156
|
+
|
|
354
1157
|
// src/web.ts
|
|
355
1158
|
var ANON_STORAGE_KEY = "holdy_anon_id";
|
|
356
1159
|
var HoldyWeb = class extends HoldyCore {
|
|
357
1160
|
teardownAutoCapture = null;
|
|
1161
|
+
friction = null;
|
|
1162
|
+
changes = null;
|
|
1163
|
+
skeleton = null;
|
|
358
1164
|
constructor(opts) {
|
|
359
1165
|
super("sdk_web", opts);
|
|
360
1166
|
this.anonymousId = resolveAnonymousId();
|
|
@@ -364,9 +1170,37 @@ var HoldyWeb = class extends HoldyCore {
|
|
|
364
1170
|
if (document.visibilityState === "hidden") void this.flush();
|
|
365
1171
|
});
|
|
366
1172
|
}
|
|
1173
|
+
if (opts.friction !== false) {
|
|
1174
|
+
const frictionOpts = typeof opts.friction === "object" ? opts.friction : {};
|
|
1175
|
+
this.friction = installFrictionCapture({
|
|
1176
|
+
...frictionOpts,
|
|
1177
|
+
ignoreUrls: [...frictionOpts.ignoreUrls ?? [], this.baseUrl],
|
|
1178
|
+
onFriction: (signal) => this.track(`$${signal.kind}`, signal.properties)
|
|
1179
|
+
});
|
|
1180
|
+
}
|
|
1181
|
+
if (opts.skeleton !== false) {
|
|
1182
|
+
this.skeleton = installSkeletonCapture({
|
|
1183
|
+
onSkeleton: (skeleton) => this.track("$page_skeleton", { ...skeleton })
|
|
1184
|
+
});
|
|
1185
|
+
}
|
|
367
1186
|
if (opts.autoCapture !== false) {
|
|
368
1187
|
this.teardownAutoCapture = installAutoCapture({
|
|
369
|
-
onNavigate: (kind) =>
|
|
1188
|
+
onNavigate: (kind) => {
|
|
1189
|
+
this.page(void 0, { navigation: kind });
|
|
1190
|
+
const path = typeof location !== "undefined" ? location.pathname : "/";
|
|
1191
|
+
this.friction?.onNavigate(path);
|
|
1192
|
+
this.changes?.onNavigate();
|
|
1193
|
+
this.skeleton?.onNavigate(path);
|
|
1194
|
+
}
|
|
1195
|
+
});
|
|
1196
|
+
}
|
|
1197
|
+
if (opts.productChanges !== false && typeof window !== "undefined") {
|
|
1198
|
+
this.changes = installProductChanges({
|
|
1199
|
+
baseUrl: this.baseUrl,
|
|
1200
|
+
apiKey: this.apiKey,
|
|
1201
|
+
getAccountKey: () => this.accountKey,
|
|
1202
|
+
track: (name, properties) => this.track(name, properties),
|
|
1203
|
+
debug: opts.debug
|
|
370
1204
|
});
|
|
371
1205
|
}
|
|
372
1206
|
}
|
|
@@ -384,6 +1218,10 @@ var HoldyWeb = class extends HoldyCore {
|
|
|
384
1218
|
...typeof document !== "undefined" && document.title ? { title: document.title } : {}
|
|
385
1219
|
});
|
|
386
1220
|
}
|
|
1221
|
+
group(accountKey, traits) {
|
|
1222
|
+
super.group(accountKey, traits);
|
|
1223
|
+
this.changes?.onNavigate();
|
|
1224
|
+
}
|
|
387
1225
|
reset() {
|
|
388
1226
|
this.userId = null;
|
|
389
1227
|
this.accountKey = null;
|
|
@@ -392,11 +1230,18 @@ var HoldyWeb = class extends HoldyCore {
|
|
|
392
1230
|
} catch {
|
|
393
1231
|
}
|
|
394
1232
|
this.anonymousId = resolveAnonymousId();
|
|
1233
|
+
this.changes?.onNavigate();
|
|
395
1234
|
}
|
|
396
1235
|
/** Flush final + desinstala o auto-capture (devolve `history` ao original). */
|
|
397
1236
|
async shutdown() {
|
|
398
1237
|
this.teardownAutoCapture?.();
|
|
399
1238
|
this.teardownAutoCapture = null;
|
|
1239
|
+
this.friction?.teardown();
|
|
1240
|
+
this.friction = null;
|
|
1241
|
+
this.changes?.teardown();
|
|
1242
|
+
this.changes = null;
|
|
1243
|
+
this.skeleton?.teardown();
|
|
1244
|
+
this.skeleton = null;
|
|
400
1245
|
await super.shutdown();
|
|
401
1246
|
}
|
|
402
1247
|
environmentContext() {
|