@holdyapp/sdk 1.1.0 → 1.5.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 +52 -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 +748 -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 +748 -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/types.d.ts +2 -0
- package/dist/types.d.ts.map +1 -1
- package/dist/web.d.ts +18 -0
- package/dist/web.d.ts.map +1 -1
- package/dist/web.mjs +701 -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/types.ts +2 -0
- package/src/web.ts +52 -1
package/dist/index.mjs
CHANGED
|
@@ -4,6 +4,7 @@ var DEFAULT_FLUSH_AT = 20;
|
|
|
4
4
|
var DEFAULT_FLUSH_INTERVAL_MS = 5e3;
|
|
5
5
|
var MAX_QUEUE = 1e3;
|
|
6
6
|
var BULK_LIMIT = 500;
|
|
7
|
+
var MAX_BACKOFF_MS = 6e4;
|
|
7
8
|
var HoldyCore = class {
|
|
8
9
|
apiKey;
|
|
9
10
|
baseUrl;
|
|
@@ -13,6 +14,9 @@ var HoldyCore = class {
|
|
|
13
14
|
queue = [];
|
|
14
15
|
timer = null;
|
|
15
16
|
flushing = null;
|
|
17
|
+
retryAt = 0;
|
|
18
|
+
failures = 0;
|
|
19
|
+
warned = /* @__PURE__ */ new Set();
|
|
16
20
|
userId = null;
|
|
17
21
|
anonymousId = null;
|
|
18
22
|
accountKey = null;
|
|
@@ -46,13 +50,14 @@ var HoldyCore = class {
|
|
|
46
50
|
event: name,
|
|
47
51
|
occurred_at: occurredAt,
|
|
48
52
|
source: this.source,
|
|
53
|
+
external_id: newEventId(),
|
|
49
54
|
...userId ? { user_id: userId } : {},
|
|
50
55
|
...this.anonymousId ? { anonymous_id: this.anonymousId } : {},
|
|
51
56
|
properties: properties ?? {},
|
|
52
57
|
...Object.keys(context).length > 0 ? { context } : {}
|
|
53
58
|
};
|
|
54
59
|
this.queue.push(event);
|
|
55
|
-
|
|
60
|
+
this.trimQueue();
|
|
56
61
|
if (this.queue.length >= this.flushAt) void this.flush();
|
|
57
62
|
}
|
|
58
63
|
identify(userId, traits) {
|
|
@@ -65,14 +70,9 @@ var HoldyCore = class {
|
|
|
65
70
|
this.accountKey = accountKey;
|
|
66
71
|
this.track("$group", traits ?? {});
|
|
67
72
|
}
|
|
68
|
-
/** Drena a fila. Nunca lança — falha re-enfileira e
|
|
73
|
+
/** Drena a fila. Nunca lança — falha re-enfileira e espera antes de tentar de novo. */
|
|
69
74
|
async flush() {
|
|
70
|
-
|
|
71
|
-
if (this.queue.length === 0) return;
|
|
72
|
-
this.flushing = this.drain().finally(() => {
|
|
73
|
-
this.flushing = null;
|
|
74
|
-
});
|
|
75
|
-
return this.flushing;
|
|
75
|
+
return this.startFlush(false);
|
|
76
76
|
}
|
|
77
77
|
/** Flush final + para o timer. Chamar em shutdown do server / unload do web. */
|
|
78
78
|
async shutdown() {
|
|
@@ -80,18 +80,43 @@ var HoldyCore = class {
|
|
|
80
80
|
clearInterval(this.timer);
|
|
81
81
|
this.timer = null;
|
|
82
82
|
}
|
|
83
|
-
await this.
|
|
83
|
+
await this.startFlush(true);
|
|
84
|
+
}
|
|
85
|
+
startFlush(force) {
|
|
86
|
+
if (this.flushing) return this.flushing;
|
|
87
|
+
if (this.queue.length === 0) return Promise.resolve();
|
|
88
|
+
if (!force && Date.now() < this.retryAt) return Promise.resolve();
|
|
89
|
+
this.flushing = this.drain().finally(() => {
|
|
90
|
+
this.flushing = null;
|
|
91
|
+
});
|
|
92
|
+
return this.flushing;
|
|
84
93
|
}
|
|
85
94
|
async drain() {
|
|
86
95
|
while (this.queue.length > 0) {
|
|
87
96
|
const batch = this.queue.splice(0, BULK_LIMIT);
|
|
88
|
-
const
|
|
89
|
-
if (
|
|
97
|
+
const outcome = await this.send(batch);
|
|
98
|
+
if (outcome.kind === "retry") {
|
|
90
99
|
this.queue.unshift(...batch);
|
|
100
|
+
this.trimQueue();
|
|
101
|
+
this.scheduleRetry(outcome.afterMs);
|
|
91
102
|
return;
|
|
92
103
|
}
|
|
104
|
+
this.failures = 0;
|
|
105
|
+
this.retryAt = 0;
|
|
93
106
|
}
|
|
94
107
|
}
|
|
108
|
+
scheduleRetry(afterMs) {
|
|
109
|
+
this.failures += 1;
|
|
110
|
+
if (afterMs !== null) {
|
|
111
|
+
this.retryAt = Date.now() + afterMs;
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
const backoff = Math.min(MAX_BACKOFF_MS, 1e3 * 2 ** (this.failures - 1));
|
|
115
|
+
this.retryAt = Date.now() + backoff + Math.floor(Math.random() * backoff * 0.2);
|
|
116
|
+
}
|
|
117
|
+
trimQueue() {
|
|
118
|
+
if (this.queue.length > MAX_QUEUE) this.queue.splice(0, this.queue.length - MAX_QUEUE);
|
|
119
|
+
}
|
|
95
120
|
async send(events) {
|
|
96
121
|
try {
|
|
97
122
|
const res = await fetch(`${this.baseUrl}/events/bulk`, {
|
|
@@ -103,23 +128,38 @@ var HoldyCore = class {
|
|
|
103
128
|
body: JSON.stringify({ events }),
|
|
104
129
|
...this.source === "sdk_web" ? { keepalive: true } : {}
|
|
105
130
|
});
|
|
106
|
-
if (
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
}
|
|
111
|
-
this.log(`bulk failed ${res.status} \xB7 will retry`);
|
|
112
|
-
return false;
|
|
131
|
+
if (res.ok) return { kind: "sent" };
|
|
132
|
+
if (res.status === 429) {
|
|
133
|
+
this.log("rate limited \xB7 will retry after Retry-After");
|
|
134
|
+
return { kind: "retry", afterMs: retryAfterMs(res) };
|
|
113
135
|
}
|
|
114
|
-
|
|
136
|
+
if (res.status >= 500) {
|
|
137
|
+
this.log(`bulk failed ${res.status} \xB7 will retry with backoff`);
|
|
138
|
+
return { kind: "retry", afterMs: null };
|
|
139
|
+
}
|
|
140
|
+
if (res.status === 401) {
|
|
141
|
+
this.warnOnce("401", "API key inv\xE1lida ou revogada \xB7 eventos descartados. Confira a chave em Configura\xE7\xF5es \u203A SDK.");
|
|
142
|
+
return { kind: "dropped" };
|
|
143
|
+
}
|
|
144
|
+
if (res.status === 403) {
|
|
145
|
+
this.warnOnce("403", "Origem n\xE3o permitida ou chave sem escopo events:write \xB7 eventos descartados.");
|
|
146
|
+
return { kind: "dropped" };
|
|
147
|
+
}
|
|
148
|
+
this.log(`bulk rejected ${res.status} \xB7 batch dropped`);
|
|
149
|
+
return { kind: "dropped" };
|
|
115
150
|
} catch (err) {
|
|
116
|
-
this.log(`bulk network error: ${err.message} \xB7 will retry`);
|
|
117
|
-
return
|
|
151
|
+
this.log(`bulk network error: ${err.message} \xB7 will retry with backoff`);
|
|
152
|
+
return { kind: "retry", afterMs: null };
|
|
118
153
|
}
|
|
119
154
|
}
|
|
120
155
|
log(msg) {
|
|
121
156
|
if (this.debug) console.warn(`[holdy] ${msg}`);
|
|
122
157
|
}
|
|
158
|
+
warnOnce(key, msg) {
|
|
159
|
+
if (this.warned.has(key)) return;
|
|
160
|
+
this.warned.add(key);
|
|
161
|
+
console.warn(`[holdy] ${msg}`);
|
|
162
|
+
}
|
|
123
163
|
/**
|
|
124
164
|
* Ambiente que o SDK observa sozinho (url, referrer, browser, utm…).
|
|
125
165
|
* Vazio no core; `HoldyWeb` preenche. O server não infere ambiente — não
|
|
@@ -133,12 +173,72 @@ var HoldyCore = class {
|
|
|
133
173
|
return this.queue.length;
|
|
134
174
|
}
|
|
135
175
|
};
|
|
176
|
+
function retryAfterMs(res) {
|
|
177
|
+
const raw = typeof res.headers?.get === "function" ? res.headers.get("Retry-After") : null;
|
|
178
|
+
if (!raw) return null;
|
|
179
|
+
const seconds = Number(raw);
|
|
180
|
+
if (!Number.isFinite(seconds) || seconds < 0) return null;
|
|
181
|
+
return Math.min(MAX_BACKOFF_MS, seconds * 1e3);
|
|
182
|
+
}
|
|
183
|
+
function newEventId() {
|
|
184
|
+
const c = globalThis.crypto;
|
|
185
|
+
if (typeof c?.randomUUID === "function") return c.randomUUID();
|
|
186
|
+
const rand = () => Math.random().toString(36).slice(2, 12);
|
|
187
|
+
return `${Date.now().toString(36)}-${rand()}-${rand()}`;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// src/run.ts
|
|
191
|
+
function newRunId() {
|
|
192
|
+
const c = globalThis.crypto;
|
|
193
|
+
if (typeof c?.randomUUID === "function") return c.randomUUID();
|
|
194
|
+
return `run_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 10)}`;
|
|
195
|
+
}
|
|
196
|
+
function runEventName(name, suffix) {
|
|
197
|
+
const base = name.trim().replace(/\.$/, "");
|
|
198
|
+
return `${base || "run"}.${suffix}`;
|
|
199
|
+
}
|
|
200
|
+
async function withRun(track, opts, fn) {
|
|
201
|
+
if (!opts || !opts.name) throw new Error("[holdy] run: name is required");
|
|
202
|
+
const id = opts.runId && opts.runId.length > 0 ? opts.runId : newRunId();
|
|
203
|
+
const base = { ...opts.properties ?? {}, ai_run_id: id };
|
|
204
|
+
const overrides = {
|
|
205
|
+
...opts.accountKey ? { accountKey: opts.accountKey } : {},
|
|
206
|
+
...opts.userId ? { userId: opts.userId } : {}
|
|
207
|
+
};
|
|
208
|
+
const emit = (event, properties) => track(event, { ...base, ...properties ?? {}, ai_run_id: id }, overrides);
|
|
209
|
+
const startedAt = Date.now();
|
|
210
|
+
emit(runEventName(opts.name, "started"));
|
|
211
|
+
try {
|
|
212
|
+
const result = await fn({ id, track: emit });
|
|
213
|
+
emit(runEventName(opts.name, "completed"), { duration_ms: Date.now() - startedAt });
|
|
214
|
+
return result;
|
|
215
|
+
} catch (err) {
|
|
216
|
+
emit(runEventName(opts.name, "failed"), {
|
|
217
|
+
duration_ms: Date.now() - startedAt,
|
|
218
|
+
error: err instanceof Error ? err.name : "Error"
|
|
219
|
+
});
|
|
220
|
+
throw err;
|
|
221
|
+
}
|
|
222
|
+
}
|
|
136
223
|
|
|
137
224
|
// src/server.ts
|
|
138
225
|
var Holdy = class extends HoldyCore {
|
|
139
226
|
constructor(opts) {
|
|
140
227
|
super("sdk_server", opts);
|
|
141
228
|
}
|
|
229
|
+
/**
|
|
230
|
+
* Executa `fn` como UMA execução de agente: todo evento lá dentro carrega a
|
|
231
|
+
* mesma `ai_run_id`, e `<name>.started` / `.completed` / `.failed` saem
|
|
232
|
+
* sozinhos.
|
|
233
|
+
*
|
|
234
|
+
* await holdy.run({ name: 'support_agent', accountKey: 'acme' }, async (run) => {
|
|
235
|
+
* run.track('ticket_drafted', { ai_tokens: 1200, ai_cost_usd: 0.02 })
|
|
236
|
+
* return resolveTicket(ticket)
|
|
237
|
+
* })
|
|
238
|
+
*/
|
|
239
|
+
run(opts, fn) {
|
|
240
|
+
return withRun((name, properties, overrides) => this.track(name, properties, overrides), opts, fn);
|
|
241
|
+
}
|
|
142
242
|
};
|
|
143
243
|
|
|
144
244
|
// src/auto-capture.ts
|
|
@@ -204,6 +304,302 @@ function currentPath() {
|
|
|
204
304
|
return `${location.pathname}${location.search}${location.hash}`;
|
|
205
305
|
}
|
|
206
306
|
|
|
307
|
+
// src/friction.ts
|
|
308
|
+
var INSTALLED_FLAG2 = "__holdy_friction__";
|
|
309
|
+
var INTERACTIVE_SELECTOR = 'button, a, input, select, textarea, summary, [role="button"], [role="link"], [role="menuitem"], [role="tab"]';
|
|
310
|
+
var INTERACTIVE_TAGS = /* @__PURE__ */ new Set(["button", "a", "input", "select", "textarea", "summary"]);
|
|
311
|
+
var FORM_CONTROL_TAGS = /* @__PURE__ */ new Set(["input", "select", "textarea"]);
|
|
312
|
+
var RageClickDetector = class {
|
|
313
|
+
constructor(threshold = 3, windowMs = 1e3, radiusPx = 30) {
|
|
314
|
+
this.threshold = threshold;
|
|
315
|
+
this.windowMs = windowMs;
|
|
316
|
+
this.radiusPx = radiusPx;
|
|
317
|
+
}
|
|
318
|
+
clicks = [];
|
|
319
|
+
push(x, y, t) {
|
|
320
|
+
this.clicks = this.clicks.filter(
|
|
321
|
+
(c) => t - c.t <= this.windowMs && Math.hypot(c.x - x, c.y - y) <= this.radiusPx
|
|
322
|
+
);
|
|
323
|
+
this.clicks.push({ x, y, t });
|
|
324
|
+
if (this.clicks.length >= this.threshold) {
|
|
325
|
+
this.clicks = [];
|
|
326
|
+
return true;
|
|
327
|
+
}
|
|
328
|
+
return false;
|
|
329
|
+
}
|
|
330
|
+
};
|
|
331
|
+
var NavLoopDetector = class {
|
|
332
|
+
constructor(windowMs = 6e4) {
|
|
333
|
+
this.windowMs = windowMs;
|
|
334
|
+
}
|
|
335
|
+
visits = [];
|
|
336
|
+
push(path, t) {
|
|
337
|
+
this.visits = [...this.visits, { path, t }].filter((v) => t - v.t <= this.windowMs).slice(-4);
|
|
338
|
+
if (this.visits.length < 4) return null;
|
|
339
|
+
const [a, b, c, d] = this.visits.map((v) => v.path);
|
|
340
|
+
if (a === c && b === d && a !== b) {
|
|
341
|
+
this.visits = [];
|
|
342
|
+
return { from: a, to: b };
|
|
343
|
+
}
|
|
344
|
+
return null;
|
|
345
|
+
}
|
|
346
|
+
};
|
|
347
|
+
function resolveInteractive(el) {
|
|
348
|
+
if (!el) return null;
|
|
349
|
+
const viaClosest = el.closest?.(INTERACTIVE_SELECTOR) ?? null;
|
|
350
|
+
if (viaClosest) return viaClosest;
|
|
351
|
+
const tag = el.tagName.toLowerCase();
|
|
352
|
+
if (INTERACTIVE_TAGS.has(tag)) return el;
|
|
353
|
+
const role = el.getAttribute("role");
|
|
354
|
+
if (role === "button" || role === "link" || role === "menuitem" || role === "tab") return el;
|
|
355
|
+
return null;
|
|
356
|
+
}
|
|
357
|
+
function isDeadClickTarget(el) {
|
|
358
|
+
const target = resolveInteractive(el);
|
|
359
|
+
if (!target) return false;
|
|
360
|
+
if (target.disabled === true) return true;
|
|
361
|
+
if (target.getAttribute("disabled") !== null) return true;
|
|
362
|
+
if (target.getAttribute("aria-disabled") === "true") return true;
|
|
363
|
+
const tag = target.tagName.toLowerCase();
|
|
364
|
+
if (tag === "a") {
|
|
365
|
+
const href = target.getAttribute("href");
|
|
366
|
+
if (!href || href === "#" || href.startsWith("javascript:")) return true;
|
|
367
|
+
}
|
|
368
|
+
return false;
|
|
369
|
+
}
|
|
370
|
+
function describeElement(el) {
|
|
371
|
+
const target = resolveInteractive(el) ?? el;
|
|
372
|
+
if (!target) return {};
|
|
373
|
+
const out = { element: target.tagName.toLowerCase() };
|
|
374
|
+
if (target.id) out.element_id = target.id;
|
|
375
|
+
const role = target.getAttribute("role");
|
|
376
|
+
if (role) out.element_role = role;
|
|
377
|
+
const label = target.getAttribute("aria-label") ?? target.textContent ?? "";
|
|
378
|
+
const text = label.replace(/\s+/g, " ").trim().slice(0, 60);
|
|
379
|
+
if (text) out.element_text = text;
|
|
380
|
+
const name = target.getAttribute("name") ?? target.getAttribute("data-testid");
|
|
381
|
+
if (name) out.element_name = name;
|
|
382
|
+
return out;
|
|
383
|
+
}
|
|
384
|
+
function stripRequestUrl(input, base) {
|
|
385
|
+
try {
|
|
386
|
+
const url = new URL(input, base);
|
|
387
|
+
return `${url.origin}${url.pathname}`;
|
|
388
|
+
} catch {
|
|
389
|
+
return input.split("?")[0].split("#")[0];
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
function errorMessage(reason) {
|
|
393
|
+
if (reason instanceof Error) return reason.message;
|
|
394
|
+
if (typeof reason === "string") return reason;
|
|
395
|
+
try {
|
|
396
|
+
return JSON.stringify(reason);
|
|
397
|
+
} catch {
|
|
398
|
+
return String(reason);
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
function isAbortError(err) {
|
|
402
|
+
return typeof err === "object" && err !== null && err.name === "AbortError";
|
|
403
|
+
}
|
|
404
|
+
function isIgnoredUrl(url, ignore) {
|
|
405
|
+
return ignore.some((prefix) => prefix.length > 0 && url.startsWith(prefix));
|
|
406
|
+
}
|
|
407
|
+
function registrableDomain(host) {
|
|
408
|
+
const labels = host.toLowerCase().split(".").filter(Boolean);
|
|
409
|
+
if (labels.length <= 2) return labels.join(".");
|
|
410
|
+
return labels.slice(-2).join(".");
|
|
411
|
+
}
|
|
412
|
+
function isFirstPartyRequest(url, pageUrl, extraOrigins = []) {
|
|
413
|
+
let target;
|
|
414
|
+
let page;
|
|
415
|
+
try {
|
|
416
|
+
target = new URL(url, pageUrl);
|
|
417
|
+
page = new URL(pageUrl);
|
|
418
|
+
} catch {
|
|
419
|
+
return true;
|
|
420
|
+
}
|
|
421
|
+
if (target.protocol !== "http:" && target.protocol !== "https:") return true;
|
|
422
|
+
if (target.host === page.host) return true;
|
|
423
|
+
if (extraOrigins.some((o) => o.length > 0 && target.origin === o)) return true;
|
|
424
|
+
const targetDomain = registrableDomain(target.hostname);
|
|
425
|
+
return targetDomain.length > 0 && targetDomain === registrableDomain(page.hostname);
|
|
426
|
+
}
|
|
427
|
+
function classifyRequest(outcome) {
|
|
428
|
+
if (outcome.aborted) return null;
|
|
429
|
+
if (!outcome.opaque && (outcome.status === null || outcome.status >= 400)) return "http_error";
|
|
430
|
+
return outcome.durationMs >= outcome.slowMs ? "slow_request" : null;
|
|
431
|
+
}
|
|
432
|
+
function documentStatus(perf) {
|
|
433
|
+
try {
|
|
434
|
+
const entry = perf?.getEntriesByType?.("navigation")?.[0];
|
|
435
|
+
const status = entry?.responseStatus;
|
|
436
|
+
return typeof status === "number" && status > 0 ? status : null;
|
|
437
|
+
} catch {
|
|
438
|
+
return null;
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
function formKey(form) {
|
|
442
|
+
return form.id || form.getAttribute("name") || form.getAttribute("data-testid") || stripRequestUrl(form.getAttribute("action") ?? "", location.href).replace(location.origin, "") || "form";
|
|
443
|
+
}
|
|
444
|
+
function currentPath2() {
|
|
445
|
+
return typeof location === "undefined" ? "/" : location.pathname || "/";
|
|
446
|
+
}
|
|
447
|
+
function installFrictionCapture(opts) {
|
|
448
|
+
const noop = { onNavigate: () => void 0, teardown: () => void 0 };
|
|
449
|
+
if (typeof window === "undefined" || typeof document === "undefined") return noop;
|
|
450
|
+
const w = window;
|
|
451
|
+
if (w[INSTALLED_FLAG2]) return noop;
|
|
452
|
+
w[INSTALLED_FLAG2] = true;
|
|
453
|
+
const emit = (kind, properties) => {
|
|
454
|
+
try {
|
|
455
|
+
opts.onFriction({ kind, properties: { path: currentPath2(), ...properties } });
|
|
456
|
+
} catch {
|
|
457
|
+
return;
|
|
458
|
+
}
|
|
459
|
+
};
|
|
460
|
+
const cleanups = [];
|
|
461
|
+
const rage = new RageClickDetector(opts.rageClicks, opts.rageWindowMs, opts.rageRadiusPx);
|
|
462
|
+
const navLoop = new NavLoopDetector(opts.navLoopWindowMs);
|
|
463
|
+
const slowMs = opts.slowRequestMs ?? 3e3;
|
|
464
|
+
const touched = /* @__PURE__ */ new Map();
|
|
465
|
+
const onClick = (ev) => {
|
|
466
|
+
const el = ev.target instanceof Element ? ev.target : null;
|
|
467
|
+
if (isDeadClickTarget(el)) emit("dead_click", describeElement(el));
|
|
468
|
+
if (rage.push(ev.clientX, ev.clientY, ev.timeStamp || Date.now())) {
|
|
469
|
+
emit("rage_click", describeElement(el));
|
|
470
|
+
}
|
|
471
|
+
};
|
|
472
|
+
document.addEventListener("click", onClick, true);
|
|
473
|
+
cleanups.push(() => document.removeEventListener("click", onClick, true));
|
|
474
|
+
const onError = (ev) => {
|
|
475
|
+
emit("js_error", {
|
|
476
|
+
message: String(ev.message ?? "").slice(0, 200),
|
|
477
|
+
...ev.filename ? { source: stripRequestUrl(ev.filename, location.href) } : {},
|
|
478
|
+
...ev.lineno ? { line: ev.lineno } : {}
|
|
479
|
+
});
|
|
480
|
+
};
|
|
481
|
+
window.addEventListener("error", onError);
|
|
482
|
+
cleanups.push(() => window.removeEventListener("error", onError));
|
|
483
|
+
const onRejection = (ev) => {
|
|
484
|
+
emit("js_error", { message: errorMessage(ev.reason).slice(0, 200), unhandled_rejection: true });
|
|
485
|
+
};
|
|
486
|
+
window.addEventListener("unhandledrejection", onRejection);
|
|
487
|
+
cleanups.push(() => window.removeEventListener("unhandledrejection", onRejection));
|
|
488
|
+
const ignore = (opts.ignoreUrls ?? []).map((url) => stripRequestUrl(url, location.href));
|
|
489
|
+
const apiOrigins = (opts.apiOrigins ?? []).map((o) => {
|
|
490
|
+
try {
|
|
491
|
+
return new URL(o, location.href).origin;
|
|
492
|
+
} catch {
|
|
493
|
+
return "";
|
|
494
|
+
}
|
|
495
|
+
});
|
|
496
|
+
const reportRequest = (url, method, started, status, flags = {}) => {
|
|
497
|
+
const target = stripRequestUrl(url, location.href);
|
|
498
|
+
if (isIgnoredUrl(target, ignore)) return;
|
|
499
|
+
if (!isFirstPartyRequest(target, location.href, apiOrigins)) return;
|
|
500
|
+
const durationMs = performance.now() - started;
|
|
501
|
+
const kind = classifyRequest({ status, durationMs, slowMs, ...flags });
|
|
502
|
+
if (!kind) return;
|
|
503
|
+
emit(kind, {
|
|
504
|
+
url: target,
|
|
505
|
+
method,
|
|
506
|
+
duration_ms: Math.round(durationMs),
|
|
507
|
+
...flags.opaque ? {} : status !== null ? { status } : { failed: true }
|
|
508
|
+
});
|
|
509
|
+
};
|
|
510
|
+
const originalFetch = window.fetch;
|
|
511
|
+
if (typeof originalFetch === "function") {
|
|
512
|
+
const patched = async (input, init) => {
|
|
513
|
+
const started = performance.now();
|
|
514
|
+
const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url;
|
|
515
|
+
const method = (init?.method ?? (input instanceof Request ? input.method : "GET")).toUpperCase();
|
|
516
|
+
try {
|
|
517
|
+
const res = await originalFetch(input, init);
|
|
518
|
+
reportRequest(url, method, started, res.status, {
|
|
519
|
+
opaque: res.type === "opaque" || res.type === "opaqueredirect"
|
|
520
|
+
});
|
|
521
|
+
return res;
|
|
522
|
+
} catch (err) {
|
|
523
|
+
reportRequest(url, method, started, null, { aborted: isAbortError(err) });
|
|
524
|
+
throw err;
|
|
525
|
+
}
|
|
526
|
+
};
|
|
527
|
+
window.fetch = patched;
|
|
528
|
+
cleanups.push(() => {
|
|
529
|
+
window.fetch = originalFetch;
|
|
530
|
+
});
|
|
531
|
+
}
|
|
532
|
+
const xhrProto = typeof XMLHttpRequest === "function" ? XMLHttpRequest.prototype : null;
|
|
533
|
+
if (xhrProto) {
|
|
534
|
+
const originalOpen = xhrProto.open;
|
|
535
|
+
const originalSend = xhrProto.send;
|
|
536
|
+
const requests = /* @__PURE__ */ new WeakMap();
|
|
537
|
+
xhrProto.open = function(method, url, ...rest) {
|
|
538
|
+
requests.set(this, { method: String(method).toUpperCase(), url: String(url) });
|
|
539
|
+
return originalOpen.apply(this, [method, url, ...rest]);
|
|
540
|
+
};
|
|
541
|
+
xhrProto.send = function(body) {
|
|
542
|
+
const request = requests.get(this);
|
|
543
|
+
if (request) {
|
|
544
|
+
const started = performance.now();
|
|
545
|
+
let aborted = false;
|
|
546
|
+
this.addEventListener("abort", () => {
|
|
547
|
+
aborted = true;
|
|
548
|
+
});
|
|
549
|
+
this.addEventListener("loadend", () => {
|
|
550
|
+
reportRequest(request.url, request.method, started, this.status === 0 ? null : this.status, { aborted });
|
|
551
|
+
});
|
|
552
|
+
}
|
|
553
|
+
return originalSend.call(this, body);
|
|
554
|
+
};
|
|
555
|
+
cleanups.push(() => {
|
|
556
|
+
xhrProto.open = originalOpen;
|
|
557
|
+
xhrProto.send = originalSend;
|
|
558
|
+
});
|
|
559
|
+
}
|
|
560
|
+
const loadStatus = documentStatus(typeof performance === "undefined" ? void 0 : performance);
|
|
561
|
+
if (loadStatus !== null && loadStatus >= 400) {
|
|
562
|
+
emit("http_error", { document: true, status: loadStatus, url: stripRequestUrl(location.href) });
|
|
563
|
+
}
|
|
564
|
+
const onFocusIn = (ev) => {
|
|
565
|
+
const el = ev.target;
|
|
566
|
+
if (!el || !FORM_CONTROL_TAGS.has(el.tagName.toLowerCase())) return;
|
|
567
|
+
const form = el.form;
|
|
568
|
+
if (!form) return;
|
|
569
|
+
const entry = touched.get(form) ?? { form: formKey(form), fields: /* @__PURE__ */ new Set() };
|
|
570
|
+
const field = el.name || el.id || el.tagName.toLowerCase();
|
|
571
|
+
entry.fields.add(field);
|
|
572
|
+
touched.set(form, entry);
|
|
573
|
+
};
|
|
574
|
+
document.addEventListener("focusin", onFocusIn, true);
|
|
575
|
+
cleanups.push(() => document.removeEventListener("focusin", onFocusIn, true));
|
|
576
|
+
const onSubmit = (ev) => {
|
|
577
|
+
if (ev.target instanceof HTMLFormElement) touched.delete(ev.target);
|
|
578
|
+
};
|
|
579
|
+
document.addEventListener("submit", onSubmit, true);
|
|
580
|
+
cleanups.push(() => document.removeEventListener("submit", onSubmit, true));
|
|
581
|
+
const flushAbandons = () => {
|
|
582
|
+
for (const entry of touched.values()) {
|
|
583
|
+
emit("form_abandon", { form: entry.form, fields_touched: entry.fields.size });
|
|
584
|
+
}
|
|
585
|
+
touched.clear();
|
|
586
|
+
};
|
|
587
|
+
window.addEventListener("pagehide", flushAbandons);
|
|
588
|
+
cleanups.push(() => window.removeEventListener("pagehide", flushAbandons));
|
|
589
|
+
return {
|
|
590
|
+
onNavigate: (path) => {
|
|
591
|
+
flushAbandons();
|
|
592
|
+
const loop = navLoop.push(path, Date.now());
|
|
593
|
+
if (loop) emit("nav_loop", { from: loop.from, to: loop.to });
|
|
594
|
+
},
|
|
595
|
+
teardown: () => {
|
|
596
|
+
for (const cleanup of cleanups) cleanup();
|
|
597
|
+
touched.clear();
|
|
598
|
+
w[INSTALLED_FLAG2] = false;
|
|
599
|
+
}
|
|
600
|
+
};
|
|
601
|
+
}
|
|
602
|
+
|
|
207
603
|
// src/web-context.ts
|
|
208
604
|
var UTM_KEYS = [
|
|
209
605
|
"utm_source",
|
|
@@ -246,17 +642,17 @@ function parseUserAgent(ua) {
|
|
|
246
642
|
device
|
|
247
643
|
};
|
|
248
644
|
}
|
|
249
|
-
function readStorage(
|
|
645
|
+
function readStorage(storage2, key) {
|
|
250
646
|
try {
|
|
251
|
-
const s =
|
|
647
|
+
const s = storage2 === "session" ? sessionStorage : localStorage;
|
|
252
648
|
return s.getItem(key);
|
|
253
649
|
} catch {
|
|
254
650
|
return null;
|
|
255
651
|
}
|
|
256
652
|
}
|
|
257
|
-
function writeStorage(
|
|
653
|
+
function writeStorage(storage2, key, value) {
|
|
258
654
|
try {
|
|
259
|
-
const s =
|
|
655
|
+
const s = storage2 === "session" ? sessionStorage : localStorage;
|
|
260
656
|
s.setItem(key, value);
|
|
261
657
|
} catch {
|
|
262
658
|
}
|
|
@@ -321,10 +717,306 @@ function buildWebContext() {
|
|
|
321
717
|
};
|
|
322
718
|
}
|
|
323
719
|
|
|
720
|
+
// src/arm.ts
|
|
721
|
+
function fnv1a(input) {
|
|
722
|
+
let hash = 2166136261;
|
|
723
|
+
for (let i = 0; i < input.length; i++) {
|
|
724
|
+
hash ^= input.charCodeAt(i);
|
|
725
|
+
hash = Math.imul(hash, 16777619) >>> 0;
|
|
726
|
+
}
|
|
727
|
+
return hash >>> 0;
|
|
728
|
+
}
|
|
729
|
+
function changeBucket(changeId, accountKey) {
|
|
730
|
+
return fnv1a(`${changeId}:${accountKey}`) % 100;
|
|
731
|
+
}
|
|
732
|
+
function changeArm(changeId, accountKey, holdoutPct) {
|
|
733
|
+
return changeBucket(changeId, accountKey) < holdoutPct ? "holdout" : "change";
|
|
734
|
+
}
|
|
735
|
+
function matchesPathTemplate(template, path) {
|
|
736
|
+
const t = template.split(/[?#]/)[0].split("/").filter(Boolean);
|
|
737
|
+
const p = path.split(/[?#]/)[0].split("/").filter(Boolean);
|
|
738
|
+
if (t.length !== p.length) return false;
|
|
739
|
+
return t.every((segment, i) => segment.startsWith(":") || segment === p[i]);
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
// src/changes.ts
|
|
743
|
+
var REFRESH_MS = 5 * 60 * 1e3;
|
|
744
|
+
var NAV_REFRESH_MS = 60 * 1e3;
|
|
745
|
+
var SCAN_DEBOUNCE_MS = 250;
|
|
746
|
+
var SEEN_KEY = "holdy_changes_seen";
|
|
747
|
+
var DISMISSED_KEY = "holdy_changes_dismissed";
|
|
748
|
+
var TIP_GAP_PX = 8;
|
|
749
|
+
var TIP_CSS = `:host{all:initial}
|
|
750
|
+
.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}
|
|
751
|
+
.tip p{margin:0;flex:1}
|
|
752
|
+
.tip button{all:unset;cursor:pointer;opacity:.7;font-size:15px;line-height:1}
|
|
753
|
+
.tip button:hover,.tip button:focus-visible{opacity:1}
|
|
754
|
+
@media (prefers-color-scheme:dark){.tip{background:#fff;color:#111}}`;
|
|
755
|
+
function normalizeText(text) {
|
|
756
|
+
return text.replace(/\s+/g, " ").trim();
|
|
757
|
+
}
|
|
758
|
+
function parseChanges(body) {
|
|
759
|
+
const list = body?.changes;
|
|
760
|
+
return Array.isArray(list) ? list.filter(isChange) : [];
|
|
761
|
+
}
|
|
762
|
+
function isAnchor(value) {
|
|
763
|
+
const a = value;
|
|
764
|
+
if (a?.kind === "page") return true;
|
|
765
|
+
return (a?.kind === "selector" || a?.kind === "text") && typeof a.value === "string" && a.value.length > 0;
|
|
766
|
+
}
|
|
767
|
+
function isChange(value) {
|
|
768
|
+
const c = value;
|
|
769
|
+
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";
|
|
770
|
+
}
|
|
771
|
+
function changesForPage(changes, path, accountKey) {
|
|
772
|
+
if (!accountKey) return [];
|
|
773
|
+
return changes.filter((c) => matchesPathTemplate(c.path, path)).map((change) => ({ change, arm: changeArm(change.id, accountKey, change.holdout_pct) }));
|
|
774
|
+
}
|
|
775
|
+
function storage(kind) {
|
|
776
|
+
try {
|
|
777
|
+
return kind === "local" ? window.localStorage : window.sessionStorage;
|
|
778
|
+
} catch {
|
|
779
|
+
return void 0;
|
|
780
|
+
}
|
|
781
|
+
}
|
|
782
|
+
function readSet(store, key) {
|
|
783
|
+
try {
|
|
784
|
+
return new Set(JSON.parse(store?.getItem(key) ?? "[]"));
|
|
785
|
+
} catch {
|
|
786
|
+
return /* @__PURE__ */ new Set();
|
|
787
|
+
}
|
|
788
|
+
}
|
|
789
|
+
function addToSet(store, key, value) {
|
|
790
|
+
try {
|
|
791
|
+
const set = readSet(store, key);
|
|
792
|
+
set.add(value);
|
|
793
|
+
store?.setItem(key, JSON.stringify([...set].slice(-200)));
|
|
794
|
+
} catch {
|
|
795
|
+
return;
|
|
796
|
+
}
|
|
797
|
+
}
|
|
798
|
+
function query(selector) {
|
|
799
|
+
try {
|
|
800
|
+
return document.querySelector(selector);
|
|
801
|
+
} catch {
|
|
802
|
+
return null;
|
|
803
|
+
}
|
|
804
|
+
}
|
|
805
|
+
var TEXT_ANCHOR_TAGS = 'button,a,[role="button"],label,summary,h1,h2,h3,h4,li,td,th,p,span,strong';
|
|
806
|
+
var TEXT_ANCHOR_SCAN_LIMIT = 3e3;
|
|
807
|
+
function findByText(value) {
|
|
808
|
+
const wanted = normalizeText(value);
|
|
809
|
+
let best = null;
|
|
810
|
+
let bestLength = Number.POSITIVE_INFINITY;
|
|
811
|
+
const nodes = document.querySelectorAll(TEXT_ANCHOR_TAGS);
|
|
812
|
+
const total = Math.min(nodes.length, TEXT_ANCHOR_SCAN_LIMIT);
|
|
813
|
+
for (let i = 0; i < total; i += 1) {
|
|
814
|
+
const el = nodes[i];
|
|
815
|
+
const raw = el.textContent ?? "";
|
|
816
|
+
if (raw.length >= bestLength || normalizeText(raw) !== wanted) continue;
|
|
817
|
+
best = el;
|
|
818
|
+
bestLength = raw.length;
|
|
819
|
+
}
|
|
820
|
+
return best;
|
|
821
|
+
}
|
|
822
|
+
function findAnchor(anchor) {
|
|
823
|
+
if (anchor.kind === "page") return null;
|
|
824
|
+
return anchor.kind === "selector" ? query(anchor.value) : findByText(anchor.value);
|
|
825
|
+
}
|
|
826
|
+
function findTextNode(el, match) {
|
|
827
|
+
const walker = document.createTreeWalker(el, NodeFilter.SHOW_TEXT);
|
|
828
|
+
for (let node = walker.nextNode(); node; node = walker.nextNode()) {
|
|
829
|
+
if (match(normalizeText(node.textContent ?? ""))) return node;
|
|
830
|
+
}
|
|
831
|
+
return null;
|
|
832
|
+
}
|
|
833
|
+
function installProductChanges(opts) {
|
|
834
|
+
if (typeof window === "undefined" || typeof document === "undefined") {
|
|
835
|
+
return { onNavigate: () => void 0, refresh: async () => void 0, teardown: () => void 0 };
|
|
836
|
+
}
|
|
837
|
+
let changes = [];
|
|
838
|
+
let lastFetch = 0;
|
|
839
|
+
let timer = null;
|
|
840
|
+
let root = null;
|
|
841
|
+
let host = null;
|
|
842
|
+
const copies = /* @__PURE__ */ new Map();
|
|
843
|
+
const tips = /* @__PURE__ */ new Map();
|
|
844
|
+
const log = (message) => {
|
|
845
|
+
if (opts.debug) console.warn(`[holdy] ${message}`);
|
|
846
|
+
};
|
|
847
|
+
const expose = (change, arm) => {
|
|
848
|
+
const key = `${change.id}:${arm}`;
|
|
849
|
+
const session = storage("session");
|
|
850
|
+
if (readSet(session, SEEN_KEY).has(key)) return;
|
|
851
|
+
addToSet(session, SEEN_KEY, key);
|
|
852
|
+
opts.track("$change_exposure", { change_id: change.id, arm, kind: change.kind });
|
|
853
|
+
};
|
|
854
|
+
const eligibleCopyNode = (el, change) => {
|
|
855
|
+
const expected = change.expected_text ? normalizeText(change.expected_text) : null;
|
|
856
|
+
const next = normalizeText(change.content.text);
|
|
857
|
+
return findTextNode(el, (text) => text.length > 0 && (text === next || expected === null || text === expected));
|
|
858
|
+
};
|
|
859
|
+
const applyCopy = (el, change) => {
|
|
860
|
+
const next = normalizeText(change.content.text);
|
|
861
|
+
const current = copies.get(change.id);
|
|
862
|
+
if (current && current.node.isConnected && normalizeText(current.node.textContent ?? "") === next) return true;
|
|
863
|
+
const node = eligibleCopyNode(el, change);
|
|
864
|
+
if (!node) return false;
|
|
865
|
+
const text = node.textContent ?? "";
|
|
866
|
+
if (normalizeText(text) !== next) {
|
|
867
|
+
copies.set(change.id, { node, original: text, next: change.content.text });
|
|
868
|
+
node.textContent = text.replace(text.trim(), change.content.text);
|
|
869
|
+
}
|
|
870
|
+
return true;
|
|
871
|
+
};
|
|
872
|
+
const restoreCopy = (id) => {
|
|
873
|
+
const applied = copies.get(id);
|
|
874
|
+
if (!applied) return;
|
|
875
|
+
if (applied.node.isConnected && normalizeText(applied.node.textContent ?? "") === normalizeText(applied.next)) {
|
|
876
|
+
applied.node.textContent = applied.original;
|
|
877
|
+
}
|
|
878
|
+
copies.delete(id);
|
|
879
|
+
};
|
|
880
|
+
const ensureRoot = () => {
|
|
881
|
+
if (root) return root;
|
|
882
|
+
if (!document.body) return null;
|
|
883
|
+
host = document.createElement("holdy-changes");
|
|
884
|
+
host.setAttribute("data-holdy-root", "1");
|
|
885
|
+
host.style.cssText = "position:fixed;top:0;left:0;width:0;height:0;z-index:2147483647;pointer-events:none";
|
|
886
|
+
root = host.attachShadow({ mode: "closed" });
|
|
887
|
+
const style = document.createElement("style");
|
|
888
|
+
style.textContent = TIP_CSS;
|
|
889
|
+
root.appendChild(style);
|
|
890
|
+
document.body.appendChild(host);
|
|
891
|
+
return root;
|
|
892
|
+
};
|
|
893
|
+
const showTip = (anchor, change) => {
|
|
894
|
+
if (tips.has(change.id)) return true;
|
|
895
|
+
if (readSet(storage("local"), DISMISSED_KEY).has(change.id)) return true;
|
|
896
|
+
const shadow = ensureRoot();
|
|
897
|
+
if (!shadow) return false;
|
|
898
|
+
const card = document.createElement("div");
|
|
899
|
+
card.className = "tip";
|
|
900
|
+
card.setAttribute("role", "tooltip");
|
|
901
|
+
const text = document.createElement("p");
|
|
902
|
+
text.textContent = change.content.text;
|
|
903
|
+
const close = document.createElement("button");
|
|
904
|
+
close.type = "button";
|
|
905
|
+
close.setAttribute("aria-label", "Fechar");
|
|
906
|
+
close.textContent = "\xD7";
|
|
907
|
+
card.append(text, close);
|
|
908
|
+
card.style.top = "-9999px";
|
|
909
|
+
card.style.left = "-9999px";
|
|
910
|
+
shadow.appendChild(card);
|
|
911
|
+
const remove = () => {
|
|
912
|
+
card.remove();
|
|
913
|
+
window.removeEventListener("scroll", position, true);
|
|
914
|
+
window.removeEventListener("resize", position);
|
|
915
|
+
tips.delete(change.id);
|
|
916
|
+
};
|
|
917
|
+
function position() {
|
|
918
|
+
if (!anchor) {
|
|
919
|
+
const corner = card.getBoundingClientRect();
|
|
920
|
+
card.style.top = `${Math.max(8, window.innerHeight - corner.height - 16)}px`;
|
|
921
|
+
card.style.left = `${Math.max(8, window.innerWidth - corner.width - 16)}px`;
|
|
922
|
+
return;
|
|
923
|
+
}
|
|
924
|
+
if (!anchor.isConnected) {
|
|
925
|
+
remove();
|
|
926
|
+
return;
|
|
927
|
+
}
|
|
928
|
+
const a = anchor.getBoundingClientRect();
|
|
929
|
+
const c = card.getBoundingClientRect();
|
|
930
|
+
let top = a.bottom + TIP_GAP_PX;
|
|
931
|
+
if (top + c.height > window.innerHeight - 8) top = a.top - c.height - TIP_GAP_PX;
|
|
932
|
+
const left = Math.min(Math.max(8, a.left + a.width / 2 - c.width / 2), window.innerWidth - c.width - 8);
|
|
933
|
+
card.style.top = `${Math.max(8, top)}px`;
|
|
934
|
+
card.style.left = `${Math.max(8, left)}px`;
|
|
935
|
+
}
|
|
936
|
+
close.addEventListener("click", () => {
|
|
937
|
+
addToSet(storage("local"), DISMISSED_KEY, change.id);
|
|
938
|
+
opts.track("$change_dismissed", { change_id: change.id, kind: change.kind });
|
|
939
|
+
remove();
|
|
940
|
+
});
|
|
941
|
+
tips.set(change.id, remove);
|
|
942
|
+
requestAnimationFrame(position);
|
|
943
|
+
window.addEventListener("scroll", position, true);
|
|
944
|
+
window.addEventListener("resize", position);
|
|
945
|
+
return true;
|
|
946
|
+
};
|
|
947
|
+
const scan = () => {
|
|
948
|
+
timer = null;
|
|
949
|
+
const active = changesForPage(changes, location.pathname, opts.getAccountKey());
|
|
950
|
+
const activeIds = new Set(active.map((a) => a.change.id));
|
|
951
|
+
for (const id of [...copies.keys()]) if (!activeIds.has(id)) restoreCopy(id);
|
|
952
|
+
for (const [id, remove] of [...tips]) if (!activeIds.has(id)) remove();
|
|
953
|
+
for (const { change, arm } of active) {
|
|
954
|
+
const el = findAnchor(change.anchor);
|
|
955
|
+
if (!el && change.anchor.kind !== "page") continue;
|
|
956
|
+
if (change.kind === "copy") {
|
|
957
|
+
if (!el) continue;
|
|
958
|
+
const shown = arm === "change" ? applyCopy(el, change) : eligibleCopyNode(el, change) !== null;
|
|
959
|
+
if (shown) expose(change, arm);
|
|
960
|
+
} else if (arm === "holdout" || showTip(el, change)) {
|
|
961
|
+
expose(change, arm);
|
|
962
|
+
}
|
|
963
|
+
}
|
|
964
|
+
};
|
|
965
|
+
const schedule = () => {
|
|
966
|
+
if (timer === null) timer = setTimeout(scan, SCAN_DEBOUNCE_MS);
|
|
967
|
+
};
|
|
968
|
+
const refresh = async () => {
|
|
969
|
+
lastFetch = Date.now();
|
|
970
|
+
try {
|
|
971
|
+
const res = await fetch(`${opts.baseUrl}/runtime/changes`, {
|
|
972
|
+
headers: { Authorization: `Bearer ${opts.apiKey}` }
|
|
973
|
+
});
|
|
974
|
+
if (!res.ok) {
|
|
975
|
+
log(`changes ${res.status}`);
|
|
976
|
+
return;
|
|
977
|
+
}
|
|
978
|
+
changes = parseChanges(await res.json());
|
|
979
|
+
schedule();
|
|
980
|
+
} catch (err) {
|
|
981
|
+
log(`changes network error: ${err.message}`);
|
|
982
|
+
}
|
|
983
|
+
};
|
|
984
|
+
const observer = typeof MutationObserver !== "undefined" ? new MutationObserver(() => {
|
|
985
|
+
if (changes.length > 0 || copies.size > 0 || tips.size > 0) schedule();
|
|
986
|
+
}) : null;
|
|
987
|
+
const observe = () => {
|
|
988
|
+
if (document.body) observer?.observe(document.body, { childList: true, subtree: true, characterData: true });
|
|
989
|
+
};
|
|
990
|
+
if (document.readyState === "loading") document.addEventListener("DOMContentLoaded", observe, { once: true });
|
|
991
|
+
else observe();
|
|
992
|
+
const interval = setInterval(() => void refresh(), REFRESH_MS);
|
|
993
|
+
void refresh();
|
|
994
|
+
return {
|
|
995
|
+
onNavigate: () => {
|
|
996
|
+
if (Date.now() - lastFetch > NAV_REFRESH_MS) void refresh();
|
|
997
|
+
schedule();
|
|
998
|
+
},
|
|
999
|
+
refresh,
|
|
1000
|
+
teardown: () => {
|
|
1001
|
+
clearInterval(interval);
|
|
1002
|
+
if (timer !== null) clearTimeout(timer);
|
|
1003
|
+
observer?.disconnect();
|
|
1004
|
+
for (const id of [...copies.keys()]) restoreCopy(id);
|
|
1005
|
+
for (const remove of [...tips.values()]) remove();
|
|
1006
|
+
host?.remove();
|
|
1007
|
+
host = null;
|
|
1008
|
+
root = null;
|
|
1009
|
+
changes = [];
|
|
1010
|
+
}
|
|
1011
|
+
};
|
|
1012
|
+
}
|
|
1013
|
+
|
|
324
1014
|
// src/web.ts
|
|
325
1015
|
var ANON_STORAGE_KEY = "holdy_anon_id";
|
|
326
1016
|
var HoldyWeb = class extends HoldyCore {
|
|
327
1017
|
teardownAutoCapture = null;
|
|
1018
|
+
friction = null;
|
|
1019
|
+
changes = null;
|
|
328
1020
|
constructor(opts) {
|
|
329
1021
|
super("sdk_web", opts);
|
|
330
1022
|
this.anonymousId = resolveAnonymousId();
|
|
@@ -334,9 +1026,30 @@ var HoldyWeb = class extends HoldyCore {
|
|
|
334
1026
|
if (document.visibilityState === "hidden") void this.flush();
|
|
335
1027
|
});
|
|
336
1028
|
}
|
|
1029
|
+
if (opts.friction !== false) {
|
|
1030
|
+
const frictionOpts = typeof opts.friction === "object" ? opts.friction : {};
|
|
1031
|
+
this.friction = installFrictionCapture({
|
|
1032
|
+
...frictionOpts,
|
|
1033
|
+
ignoreUrls: [...frictionOpts.ignoreUrls ?? [], this.baseUrl],
|
|
1034
|
+
onFriction: (signal) => this.track(`$${signal.kind}`, signal.properties)
|
|
1035
|
+
});
|
|
1036
|
+
}
|
|
337
1037
|
if (opts.autoCapture !== false) {
|
|
338
1038
|
this.teardownAutoCapture = installAutoCapture({
|
|
339
|
-
onNavigate: (kind) =>
|
|
1039
|
+
onNavigate: (kind) => {
|
|
1040
|
+
this.page(void 0, { navigation: kind });
|
|
1041
|
+
this.friction?.onNavigate(typeof location !== "undefined" ? location.pathname : "/");
|
|
1042
|
+
this.changes?.onNavigate();
|
|
1043
|
+
}
|
|
1044
|
+
});
|
|
1045
|
+
}
|
|
1046
|
+
if (opts.productChanges !== false && typeof window !== "undefined") {
|
|
1047
|
+
this.changes = installProductChanges({
|
|
1048
|
+
baseUrl: this.baseUrl,
|
|
1049
|
+
apiKey: this.apiKey,
|
|
1050
|
+
getAccountKey: () => this.accountKey,
|
|
1051
|
+
track: (name, properties) => this.track(name, properties),
|
|
1052
|
+
debug: opts.debug
|
|
340
1053
|
});
|
|
341
1054
|
}
|
|
342
1055
|
}
|
|
@@ -354,6 +1067,10 @@ var HoldyWeb = class extends HoldyCore {
|
|
|
354
1067
|
...typeof document !== "undefined" && document.title ? { title: document.title } : {}
|
|
355
1068
|
});
|
|
356
1069
|
}
|
|
1070
|
+
group(accountKey, traits) {
|
|
1071
|
+
super.group(accountKey, traits);
|
|
1072
|
+
this.changes?.onNavigate();
|
|
1073
|
+
}
|
|
357
1074
|
reset() {
|
|
358
1075
|
this.userId = null;
|
|
359
1076
|
this.accountKey = null;
|
|
@@ -362,11 +1079,16 @@ var HoldyWeb = class extends HoldyCore {
|
|
|
362
1079
|
} catch {
|
|
363
1080
|
}
|
|
364
1081
|
this.anonymousId = resolveAnonymousId();
|
|
1082
|
+
this.changes?.onNavigate();
|
|
365
1083
|
}
|
|
366
1084
|
/** Flush final + desinstala o auto-capture (devolve `history` ao original). */
|
|
367
1085
|
async shutdown() {
|
|
368
1086
|
this.teardownAutoCapture?.();
|
|
369
1087
|
this.teardownAutoCapture = null;
|
|
1088
|
+
this.friction?.teardown();
|
|
1089
|
+
this.friction = null;
|
|
1090
|
+
this.changes?.teardown();
|
|
1091
|
+
this.changes = null;
|
|
370
1092
|
await super.shutdown();
|
|
371
1093
|
}
|
|
372
1094
|
environmentContext() {
|