@inspira-npm/events 0.0.0-stage → 0.3.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/dist/catalog.d.ts +2 -0
- package/dist/catalog.js +246 -0
- package/dist/catalog.json +665 -0
- package/dist/click-record-B8KyALZZ.d.ts +31 -0
- package/dist/index-BShrVaIU.d.ts +203 -0
- package/dist/index.d.ts +202 -0
- package/dist/index.js +753 -0
- package/dist/server.d.ts +141 -0
- package/dist/server.js +445 -0
- package/dist/shared-D2s2QSYg.js +23 -0
- package/dist/version-DPt-j93d.js +4 -0
- package/package.json +39 -4
- package/README.md +0 -3
package/dist/index.js
ADDED
|
@@ -0,0 +1,753 @@
|
|
|
1
|
+
import { n as SDK_VERSION, t as SDK_NAME } from "./version-DPt-j93d.js";
|
|
2
|
+
// src/query.ts
|
|
3
|
+
/**
|
|
4
|
+
* Reescribe la query de `url` editando sus pares CRUDOS (`clave=valor` tal como están en la URL,
|
|
5
|
+
* sin decodificar). Conserva el hash; las URLs relativas siguen siendo relativas. Si la URL no se
|
|
6
|
+
* puede parsear se devuelve igual.
|
|
7
|
+
*
|
|
8
|
+
* Se edita la query cruda a propósito: `searchParams.set` re-serializa TODOS los params
|
|
9
|
+
* (%20 → +, `flag` → `flag=`, `~` → %7E…) y eso rompe URLs firmadas o de afiliados.
|
|
10
|
+
*/
|
|
11
|
+
function editRawQuery(url, edit) {
|
|
12
|
+
const isAbsolute = /^[a-z][a-z\d+.-]*:/i.test(url) || url.startsWith("//");
|
|
13
|
+
try {
|
|
14
|
+
const parsed = new URL(url, currentHref());
|
|
15
|
+
parsed.search = edit(rawPairs(parsed.search)).join("&");
|
|
16
|
+
return isAbsolute ? parsed.toString() : `${parsed.pathname}${parsed.search}${parsed.hash}`;
|
|
17
|
+
} catch {
|
|
18
|
+
return url;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
/** Pares crudos de una query (`?a=1&b` → `["a=1", "b"]`), sin los vacíos. */
|
|
22
|
+
function rawPairs(search) {
|
|
23
|
+
return search.replace(/^\?/, "").split("&").filter((pair) => pair !== "");
|
|
24
|
+
}
|
|
25
|
+
/** Nombre decodificado de un par crudo (`utm%5Fsource=x` → `utm_source`). */
|
|
26
|
+
function pairKey(pair) {
|
|
27
|
+
const raw = pair.split("=", 1)[0] ?? "";
|
|
28
|
+
try {
|
|
29
|
+
return decodeURIComponent(raw.replace(/\+/g, " "));
|
|
30
|
+
} catch {
|
|
31
|
+
return raw;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
/** Base para resolver URLs relativas: la página actual, o una ficticia en el servidor. */
|
|
35
|
+
function currentHref() {
|
|
36
|
+
return typeof location !== "undefined" ? location.href : "https://placeholder.invalid/";
|
|
37
|
+
}
|
|
38
|
+
// src/click-id.ts
|
|
39
|
+
/** Query param que lleva el click id de un ad hasta el sitio destino. */
|
|
40
|
+
const CLICK_ID_PARAM = "ipc";
|
|
41
|
+
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
|
42
|
+
function isClickId(value) {
|
|
43
|
+
return typeof value === "string" && UUID.test(value);
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Agrega `?ipc=<clickId>` a una URL conservando el resto de la query y el hash.
|
|
47
|
+
* Las URLs relativas siguen siendo relativas. Si la URL no se puede parsear se devuelve igual.
|
|
48
|
+
*/
|
|
49
|
+
function withClickId(url, clickId, param = "ipc") {
|
|
50
|
+
return editRawQuery(url, (pairs) => [...pairs.filter((pair) => pairKey(pair) !== param), `${encodeURIComponent(param)}=${encodeURIComponent(clickId)}`]);
|
|
51
|
+
}
|
|
52
|
+
/** Lee un click id válido de una URL (o null). */
|
|
53
|
+
function readClickIdFromUrl(href, param = "ipc") {
|
|
54
|
+
try {
|
|
55
|
+
const value = new URL(href).searchParams.get(param);
|
|
56
|
+
return isClickId(value) ? value : null;
|
|
57
|
+
} catch {
|
|
58
|
+
return null;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
function isTrackingParam(key) {
|
|
62
|
+
return key === "ipc" || key === "gclid" || key === "fbclid" || key.startsWith("utm_");
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* Copia a `url` los params de atribución de `from` (por defecto, la página actual): `ipc`,
|
|
66
|
+
* `utm_*`, `gclid` y `fbclid`. Los que `url` ya trae no se pisan. Igual que `withClickId`, el
|
|
67
|
+
* resto de la query y el hash quedan intactos (nada se vuelve a codificar) y las URLs relativas
|
|
68
|
+
* siguen siendo relativas. Si no hay nada que copiar, o alguna URL no se puede parsear, devuelve
|
|
69
|
+
* `url` tal cual.
|
|
70
|
+
*
|
|
71
|
+
* Para sitios intermedios (landings) que arman la URL de regreso: sin esto el `?ipc=` y las
|
|
72
|
+
* `utm_*` con las que llegó el visitante se pierden en el siguiente salto.
|
|
73
|
+
*/
|
|
74
|
+
function withTrackingParams(url, from) {
|
|
75
|
+
let search;
|
|
76
|
+
try {
|
|
77
|
+
const href = from ?? (typeof location !== "undefined" ? location.href : "");
|
|
78
|
+
if (!href) return url;
|
|
79
|
+
search = new URL(href, currentHref()).search;
|
|
80
|
+
} catch {
|
|
81
|
+
return url;
|
|
82
|
+
}
|
|
83
|
+
const carried = rawPairs(search).filter((pair) => isTrackingParam(pairKey(pair)));
|
|
84
|
+
if (carried.length === 0) return url;
|
|
85
|
+
let added = false;
|
|
86
|
+
const out = editRawQuery(url, (pairs) => {
|
|
87
|
+
const present = new Set(pairs.map(pairKey));
|
|
88
|
+
const missing = carried.filter((pair) => !present.has(pairKey(pair)));
|
|
89
|
+
added = missing.length > 0;
|
|
90
|
+
return [...pairs, ...missing];
|
|
91
|
+
});
|
|
92
|
+
return added ? out : url;
|
|
93
|
+
}
|
|
94
|
+
// src/consent.ts
|
|
95
|
+
const DEFAULT_CONSENT_COOKIE = "CookieConsent";
|
|
96
|
+
/** Evento que dispara el banner de inspira-frontend-v2 al guardar el consentimiento. */
|
|
97
|
+
const CONSENT_CHANGE_EVENT = "cookie-consent-change";
|
|
98
|
+
const ANALYTICS_CATEGORY = /^(all|statistic|estad[íi]stic|analytic|anal[íi]tic)/;
|
|
99
|
+
/** Interpreta el valor crudo de la cookie de consentimiento (puede venir URL-encoded). */
|
|
100
|
+
function parseConsentValue(raw) {
|
|
101
|
+
if (!raw) return "unknown";
|
|
102
|
+
let decoded = raw;
|
|
103
|
+
try {
|
|
104
|
+
decoded = decodeURIComponent(raw);
|
|
105
|
+
} catch {}
|
|
106
|
+
return decoded.split(",").map((name) => name.trim().toLowerCase()).some((name) => ANALYTICS_CATEGORY.test(name)) ? "granted" : "denied";
|
|
107
|
+
}
|
|
108
|
+
/**
|
|
109
|
+
* Lector de consentimiento basado en la cookie del banner de Inspira. Pásalo como
|
|
110
|
+
* `consent` a `createTracker`. Se evalúa en cada evento, así que un cambio de
|
|
111
|
+
* consentimiento aplica al siguiente evento sin recargar.
|
|
112
|
+
*/
|
|
113
|
+
function cookieConsent(cookieName = DEFAULT_CONSENT_COOKIE) {
|
|
114
|
+
const escaped = cookieName.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
115
|
+
const pattern = new RegExp(`(?:^|;\\s*)${escaped}=([^;]*)`);
|
|
116
|
+
return () => {
|
|
117
|
+
if (typeof document === "undefined") return "unknown";
|
|
118
|
+
return parseConsentValue(pattern.exec(document.cookie)?.[1]);
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
// src/context.ts
|
|
122
|
+
/**
|
|
123
|
+
* Query params que SÍ se conservan en `ctx.url`. El resto se descarta: las URLs pueden
|
|
124
|
+
* traer tokens, emails o ids de reserva.
|
|
125
|
+
*/
|
|
126
|
+
const DEFAULT_URL_PARAM_ALLOWLIST = [
|
|
127
|
+
"utm_source",
|
|
128
|
+
"utm_medium",
|
|
129
|
+
"utm_campaign",
|
|
130
|
+
"utm_term",
|
|
131
|
+
"utm_content",
|
|
132
|
+
"ipc",
|
|
133
|
+
"gclid",
|
|
134
|
+
"fbclid"
|
|
135
|
+
];
|
|
136
|
+
function sanitizeUrl(href, allowlist) {
|
|
137
|
+
try {
|
|
138
|
+
const url = new URL(href);
|
|
139
|
+
const kept = new URLSearchParams();
|
|
140
|
+
for (const [key, value] of url.searchParams) if (allowlist.includes(key)) kept.append(key, value);
|
|
141
|
+
const query = kept.toString();
|
|
142
|
+
return `${url.origin}${url.pathname}${query ? `?${query}` : ""}`;
|
|
143
|
+
} catch {
|
|
144
|
+
return "";
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
function originAndPath(href) {
|
|
148
|
+
if (!href) return null;
|
|
149
|
+
try {
|
|
150
|
+
const url = new URL(href);
|
|
151
|
+
return `${url.origin}${url.pathname}`;
|
|
152
|
+
} catch {
|
|
153
|
+
return null;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
function collectContext(allowlist) {
|
|
157
|
+
let tz = null;
|
|
158
|
+
try {
|
|
159
|
+
tz = Intl.DateTimeFormat().resolvedOptions().timeZone ?? null;
|
|
160
|
+
} catch {
|
|
161
|
+
tz = null;
|
|
162
|
+
}
|
|
163
|
+
return {
|
|
164
|
+
url: sanitizeUrl(location.href, allowlist),
|
|
165
|
+
path: location.pathname,
|
|
166
|
+
referrer: originAndPath(document.referrer),
|
|
167
|
+
locale: document.documentElement.lang || navigator.language || null,
|
|
168
|
+
tz,
|
|
169
|
+
screen: typeof screen !== "undefined" ? {
|
|
170
|
+
w: screen.width,
|
|
171
|
+
h: screen.height
|
|
172
|
+
} : null,
|
|
173
|
+
viewport: {
|
|
174
|
+
w: window.innerWidth,
|
|
175
|
+
h: window.innerHeight
|
|
176
|
+
}
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
// src/click-record.ts
|
|
180
|
+
const MAX_PAGE_URL_LENGTH = 2e3;
|
|
181
|
+
const CONTENT_TYPE$1 = "text/plain;charset=UTF-8";
|
|
182
|
+
/**
|
|
183
|
+
* Normaliza la opción. Devuelve null si no está configurada o no es del mismo origen que la
|
|
184
|
+
* página (el aviso va siempre a consola: un error de configuración perdería registros en
|
|
185
|
+
* silencio).
|
|
186
|
+
*/
|
|
187
|
+
function resolveClickRecord(option) {
|
|
188
|
+
if (!option) return null;
|
|
189
|
+
const { endpoint, requireConsent = false } = typeof option === "string" ? { endpoint: option } : option;
|
|
190
|
+
if (!endpoint) return null;
|
|
191
|
+
try {
|
|
192
|
+
const url = new URL(endpoint, location.href);
|
|
193
|
+
if (url.origin === location.origin) return {
|
|
194
|
+
url: url.href,
|
|
195
|
+
requireConsent
|
|
196
|
+
};
|
|
197
|
+
} catch {}
|
|
198
|
+
console.warn("[inspira-events] clickRecord.endpoint debe ser una ruta del mismo origen; registro de clicks desactivado:", endpoint);
|
|
199
|
+
return null;
|
|
200
|
+
}
|
|
201
|
+
/**
|
|
202
|
+
* Envía el registro sin esperar respuesta: `sendBeacon` (sobrevive a la navegación que dispara
|
|
203
|
+
* el click) o, si el navegador lo rechaza, `fetch` con `keepalive`. Nunca lanza.
|
|
204
|
+
*/
|
|
205
|
+
function sendClickRecord(url, payload) {
|
|
206
|
+
const body = JSON.stringify(payload);
|
|
207
|
+
try {
|
|
208
|
+
if (typeof navigator !== "undefined" && typeof navigator.sendBeacon === "function") {
|
|
209
|
+
if (navigator.sendBeacon(url, new Blob([body], { type: CONTENT_TYPE$1 }))) return;
|
|
210
|
+
}
|
|
211
|
+
} catch {}
|
|
212
|
+
try {
|
|
213
|
+
if (typeof fetch !== "function") return;
|
|
214
|
+
fetch(url, {
|
|
215
|
+
method: "POST",
|
|
216
|
+
body,
|
|
217
|
+
headers: { "Content-Type": CONTENT_TYPE$1 },
|
|
218
|
+
keepalive: true,
|
|
219
|
+
credentials: "same-origin"
|
|
220
|
+
}).catch(() => {});
|
|
221
|
+
} catch {}
|
|
222
|
+
}
|
|
223
|
+
// src/storage.ts
|
|
224
|
+
/**
|
|
225
|
+
* localStorage/sessionStorage que nunca lanza: en Safari privado, con storage bloqueado o
|
|
226
|
+
* cuota llena cae a memoria (los ids duran lo que dure la página).
|
|
227
|
+
*/
|
|
228
|
+
function safeStorage(kind) {
|
|
229
|
+
const memory = /* @__PURE__ */ new Map();
|
|
230
|
+
const backend = () => {
|
|
231
|
+
try {
|
|
232
|
+
return kind === "local" ? window.localStorage : window.sessionStorage;
|
|
233
|
+
} catch {
|
|
234
|
+
return null;
|
|
235
|
+
}
|
|
236
|
+
};
|
|
237
|
+
return {
|
|
238
|
+
get(key) {
|
|
239
|
+
try {
|
|
240
|
+
const value = backend()?.getItem(key);
|
|
241
|
+
if (value !== null && value !== void 0) return value;
|
|
242
|
+
} catch {}
|
|
243
|
+
return memory.get(key) ?? null;
|
|
244
|
+
},
|
|
245
|
+
set(key, value) {
|
|
246
|
+
try {
|
|
247
|
+
const storage = backend();
|
|
248
|
+
if (storage) {
|
|
249
|
+
storage.setItem(key, value);
|
|
250
|
+
memory.delete(key);
|
|
251
|
+
return;
|
|
252
|
+
}
|
|
253
|
+
} catch {}
|
|
254
|
+
memory.set(key, value);
|
|
255
|
+
},
|
|
256
|
+
remove(key) {
|
|
257
|
+
memory.delete(key);
|
|
258
|
+
try {
|
|
259
|
+
backend()?.removeItem(key);
|
|
260
|
+
} catch {}
|
|
261
|
+
}
|
|
262
|
+
};
|
|
263
|
+
}
|
|
264
|
+
// src/uuid.ts
|
|
265
|
+
/** UUID v4. `crypto.randomUUID` solo existe en contextos seguros (https/localhost). */
|
|
266
|
+
function uuid() {
|
|
267
|
+
const cryptoApi = globalThis.crypto;
|
|
268
|
+
if (typeof cryptoApi?.randomUUID === "function") return cryptoApi.randomUUID();
|
|
269
|
+
const bytes = /* @__PURE__ */ new Uint8Array(16);
|
|
270
|
+
if (typeof cryptoApi?.getRandomValues === "function") cryptoApi.getRandomValues(bytes);
|
|
271
|
+
else for (let i = 0; i < bytes.length; i++) bytes[i] = Math.floor(Math.random() * 256);
|
|
272
|
+
bytes[6] = (bytes[6] ?? 0) & 15 | 64;
|
|
273
|
+
bytes[8] = (bytes[8] ?? 0) & 63 | 128;
|
|
274
|
+
const hex = Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("");
|
|
275
|
+
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
|
|
276
|
+
}
|
|
277
|
+
// src/identity.ts
|
|
278
|
+
const ANON_KEY = "ievt_aid";
|
|
279
|
+
const SESSION_KEY = "ievt_sid";
|
|
280
|
+
const SESSION_IDLE_MS = 18e5;
|
|
281
|
+
/**
|
|
282
|
+
* Ids first-party. SOLO se llama con consentimiento `granted`: sin consentimiento el SDK
|
|
283
|
+
* no escribe nada en storage y el collector calcula un hash diario sin cookie.
|
|
284
|
+
* - anon id: persistente por sitio (localStorage).
|
|
285
|
+
* - session id: por pestaña (sessionStorage), rota tras 30 min de inactividad.
|
|
286
|
+
*/
|
|
287
|
+
function createIdentity(now = Date.now) {
|
|
288
|
+
const local = safeStorage("local");
|
|
289
|
+
const session = safeStorage("session");
|
|
290
|
+
return {
|
|
291
|
+
ensure() {
|
|
292
|
+
let anonId = local.get(ANON_KEY);
|
|
293
|
+
if (!anonId) {
|
|
294
|
+
anonId = uuid();
|
|
295
|
+
local.set(ANON_KEY, anonId);
|
|
296
|
+
}
|
|
297
|
+
const t = now();
|
|
298
|
+
let current = null;
|
|
299
|
+
try {
|
|
300
|
+
current = JSON.parse(session.get(SESSION_KEY) ?? "null");
|
|
301
|
+
} catch {
|
|
302
|
+
current = null;
|
|
303
|
+
}
|
|
304
|
+
if (!current || typeof current.id !== "string" || t - current.last > SESSION_IDLE_MS) current = {
|
|
305
|
+
id: uuid(),
|
|
306
|
+
last: t
|
|
307
|
+
};
|
|
308
|
+
else current.last = t;
|
|
309
|
+
session.set(SESSION_KEY, JSON.stringify(current));
|
|
310
|
+
return {
|
|
311
|
+
anonId,
|
|
312
|
+
sessionId: current.id
|
|
313
|
+
};
|
|
314
|
+
},
|
|
315
|
+
/** Borra los ids (consentimiento retirado o `reset()` al cerrar sesión). */
|
|
316
|
+
clear() {
|
|
317
|
+
if (local.get(ANON_KEY) !== null) local.remove(ANON_KEY);
|
|
318
|
+
if (session.get(SESSION_KEY) !== null) session.remove(SESSION_KEY);
|
|
319
|
+
}
|
|
320
|
+
};
|
|
321
|
+
}
|
|
322
|
+
// src/impressions.ts
|
|
323
|
+
const STEPS = Array.from({ length: 11 }, (_, i) => i / 10);
|
|
324
|
+
const EPSILON = .001;
|
|
325
|
+
function isViewable(entry, threshold) {
|
|
326
|
+
if (!entry.isIntersecting) return false;
|
|
327
|
+
if (entry.intersectionRatio >= threshold - EPSILON) return true;
|
|
328
|
+
const root = entry.rootBounds;
|
|
329
|
+
if (!root || root.width === 0 || root.height === 0) return false;
|
|
330
|
+
return entry.intersectionRect.width * entry.intersectionRect.height / (root.width * root.height) >= threshold - EPSILON;
|
|
331
|
+
}
|
|
332
|
+
function createImpressionObserver(log) {
|
|
333
|
+
let observer = null;
|
|
334
|
+
const targets = /* @__PURE__ */ new Map();
|
|
335
|
+
const stop = (target) => {
|
|
336
|
+
if (target.timer !== null) {
|
|
337
|
+
clearTimeout(target.timer);
|
|
338
|
+
target.timer = null;
|
|
339
|
+
}
|
|
340
|
+
};
|
|
341
|
+
const remove = (target) => {
|
|
342
|
+
stop(target);
|
|
343
|
+
const set = targets.get(target.element);
|
|
344
|
+
if (!set?.delete(target)) return;
|
|
345
|
+
if (set.size === 0) {
|
|
346
|
+
targets.delete(target.element);
|
|
347
|
+
observer?.unobserve(target.element);
|
|
348
|
+
}
|
|
349
|
+
};
|
|
350
|
+
const start = (target) => {
|
|
351
|
+
if (target.done || target.timer !== null || document.visibilityState === "hidden") return;
|
|
352
|
+
target.timer = setTimeout(() => {
|
|
353
|
+
target.timer = null;
|
|
354
|
+
if (target.done || !target.inView) return;
|
|
355
|
+
target.done = true;
|
|
356
|
+
if (target.once) remove(target);
|
|
357
|
+
target.fire();
|
|
358
|
+
}, target.minVisibleMs);
|
|
359
|
+
};
|
|
360
|
+
const onEntries = (entries) => {
|
|
361
|
+
for (const entry of entries) {
|
|
362
|
+
const set = targets.get(entry.target);
|
|
363
|
+
if (!set) continue;
|
|
364
|
+
for (const target of set) {
|
|
365
|
+
target.inView = isViewable(entry, target.threshold);
|
|
366
|
+
if (target.inView) start(target);
|
|
367
|
+
else {
|
|
368
|
+
stop(target);
|
|
369
|
+
if (!target.once) target.done = false;
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
};
|
|
374
|
+
const onVisibilityChange = () => {
|
|
375
|
+
const hidden = document.visibilityState === "hidden";
|
|
376
|
+
for (const set of targets.values()) for (const target of set) if (hidden) stop(target);
|
|
377
|
+
else if (target.inView) start(target);
|
|
378
|
+
};
|
|
379
|
+
const ensureObserver = () => {
|
|
380
|
+
if (!observer) {
|
|
381
|
+
observer = new IntersectionObserver(onEntries, { threshold: STEPS });
|
|
382
|
+
document.addEventListener("visibilitychange", onVisibilityChange);
|
|
383
|
+
}
|
|
384
|
+
return observer;
|
|
385
|
+
};
|
|
386
|
+
return {
|
|
387
|
+
/** Observa `element` y llama `fire` cuando cumple la regla de visibilidad. Devuelve el cleanup. */
|
|
388
|
+
observe(element, fire, options = {}) {
|
|
389
|
+
if (typeof IntersectionObserver === "undefined") {
|
|
390
|
+
log("IntersectionObserver no disponible: impresión no medida");
|
|
391
|
+
return () => {};
|
|
392
|
+
}
|
|
393
|
+
const target = {
|
|
394
|
+
element,
|
|
395
|
+
threshold: Math.min(Math.max(options.threshold ?? .5, 0), 1),
|
|
396
|
+
minVisibleMs: Math.max(options.minVisibleMs ?? 1e3, 0),
|
|
397
|
+
once: options.once ?? true,
|
|
398
|
+
fire,
|
|
399
|
+
inView: false,
|
|
400
|
+
timer: null,
|
|
401
|
+
done: false
|
|
402
|
+
};
|
|
403
|
+
const io = ensureObserver();
|
|
404
|
+
let set = targets.get(element);
|
|
405
|
+
if (!set) {
|
|
406
|
+
set = /* @__PURE__ */ new Set();
|
|
407
|
+
targets.set(element, set);
|
|
408
|
+
} else io.unobserve(element);
|
|
409
|
+
set.add(target);
|
|
410
|
+
io.observe(element);
|
|
411
|
+
return () => remove(target);
|
|
412
|
+
},
|
|
413
|
+
disconnect() {
|
|
414
|
+
for (const set of targets.values()) for (const target of set) stop(target);
|
|
415
|
+
targets.clear();
|
|
416
|
+
observer?.disconnect();
|
|
417
|
+
observer = null;
|
|
418
|
+
document.removeEventListener("visibilitychange", onVisibilityChange);
|
|
419
|
+
}
|
|
420
|
+
};
|
|
421
|
+
}
|
|
422
|
+
// src/transport.ts
|
|
423
|
+
const CONTENT_TYPE = "text/plain;charset=UTF-8";
|
|
424
|
+
const fetchTransport = { send(url, body, { beacon, debug }) {
|
|
425
|
+
if (beacon && typeof navigator !== "undefined" && typeof navigator.sendBeacon === "function") try {
|
|
426
|
+
if (navigator.sendBeacon(url, new Blob([body], { type: CONTENT_TYPE }))) return "sent";
|
|
427
|
+
} catch {}
|
|
428
|
+
return fetch(url, {
|
|
429
|
+
method: "POST",
|
|
430
|
+
body,
|
|
431
|
+
headers: { "Content-Type": CONTENT_TYPE },
|
|
432
|
+
keepalive: true,
|
|
433
|
+
credentials: "omit",
|
|
434
|
+
mode: "cors"
|
|
435
|
+
}).then(async (response) => {
|
|
436
|
+
if (response.ok) {
|
|
437
|
+
if (debug) await logRejected(response);
|
|
438
|
+
return "sent";
|
|
439
|
+
}
|
|
440
|
+
if (debug) console.warn("[inspira-events] collector respondió", response.status);
|
|
441
|
+
return response.status === 429 || response.status >= 500 ? "retry" : "drop";
|
|
442
|
+
}, () => "retry");
|
|
443
|
+
} };
|
|
444
|
+
async function logRejected(response) {
|
|
445
|
+
try {
|
|
446
|
+
const data = await response.json();
|
|
447
|
+
if (Array.isArray(data.rejected) && data.rejected.length > 0) console.warn("[inspira-events] eventos rechazados por el collector", data.rejected);
|
|
448
|
+
} catch {}
|
|
449
|
+
}
|
|
450
|
+
// src/tracker.ts
|
|
451
|
+
const MAX_ATTEMPTS = 3;
|
|
452
|
+
const CLICK_ID_STORAGE_KEY = "ievt_ipc";
|
|
453
|
+
const OWN_CLICK_ID_STORAGE_KEY = "ievt_ipc_own";
|
|
454
|
+
const MAX_DESTINATION_URL_LENGTH = 2e3;
|
|
455
|
+
const DAY_MS = 864e5;
|
|
456
|
+
const DEFAULT_MAX_AGE_DAYS = 7;
|
|
457
|
+
function clickProps(clickId, destination) {
|
|
458
|
+
return {
|
|
459
|
+
click_id: clickId,
|
|
460
|
+
destination_url: destination.slice(0, MAX_DESTINATION_URL_LENGTH)
|
|
461
|
+
};
|
|
462
|
+
}
|
|
463
|
+
function createNoopTracker() {
|
|
464
|
+
return {
|
|
465
|
+
track: () => null,
|
|
466
|
+
observeImpression: () => () => {},
|
|
467
|
+
trackAdClick: (_props, url) => url,
|
|
468
|
+
trackOutboundClick: (_props, url) => url,
|
|
469
|
+
captureClickId: () => null,
|
|
470
|
+
getClickId: () => null,
|
|
471
|
+
identify: () => {},
|
|
472
|
+
setWl: () => {},
|
|
473
|
+
reset: () => {},
|
|
474
|
+
flush: () => Promise.resolve(),
|
|
475
|
+
shutdown: () => {}
|
|
476
|
+
};
|
|
477
|
+
}
|
|
478
|
+
/**
|
|
479
|
+
* Crea el tracker. En el servidor (SSR, sin `window`) devuelve un tracker no-op, así que es
|
|
480
|
+
* seguro crearlo a nivel de módulo en un client component de Next.js.
|
|
481
|
+
*/
|
|
482
|
+
function createTracker(config) {
|
|
483
|
+
if (typeof window === "undefined" || typeof document === "undefined") return createNoopTracker();
|
|
484
|
+
const flushIntervalMs = config.flushIntervalMs ?? 5e3;
|
|
485
|
+
const maxBatchSize = Math.max(config.maxBatchSize ?? 20, 1);
|
|
486
|
+
const maxQueueSize = Math.max(config.maxQueueSize ?? 500, maxBatchSize);
|
|
487
|
+
const allowlist = config.urlParamAllowlist ?? DEFAULT_URL_PARAM_ALLOWLIST;
|
|
488
|
+
const transport = config.transport ?? fetchTransport;
|
|
489
|
+
const debug = config.debug ?? false;
|
|
490
|
+
const url = `${config.endpoint.replace(/\/+$/, "")}/e/v1/batch?k=${encodeURIComponent(config.siteKey)}`;
|
|
491
|
+
const identity = createIdentity();
|
|
492
|
+
const clickStore = safeStorage("local");
|
|
493
|
+
const clickRecord = resolveClickRecord(config.clickRecord);
|
|
494
|
+
const log = (...args) => {
|
|
495
|
+
if (debug) console.debug("[inspira-events]", ...args);
|
|
496
|
+
};
|
|
497
|
+
const impressions = createImpressionObserver(log);
|
|
498
|
+
let wl = config.wl ?? null;
|
|
499
|
+
let userId = null;
|
|
500
|
+
let queue = [];
|
|
501
|
+
let timer = null;
|
|
502
|
+
let inflight = null;
|
|
503
|
+
let closed = false;
|
|
504
|
+
const consent = () => {
|
|
505
|
+
try {
|
|
506
|
+
return config.consent?.() ?? "unknown";
|
|
507
|
+
} catch {
|
|
508
|
+
return "unknown";
|
|
509
|
+
}
|
|
510
|
+
};
|
|
511
|
+
const schedule = (delay) => {
|
|
512
|
+
if (timer === null && !closed) timer = setTimeout(() => {
|
|
513
|
+
timer = null;
|
|
514
|
+
flush();
|
|
515
|
+
}, delay);
|
|
516
|
+
};
|
|
517
|
+
const trimQueue = () => {
|
|
518
|
+
const overflow = queue.length - maxQueueSize;
|
|
519
|
+
if (overflow > 0) {
|
|
520
|
+
queue.splice(0, overflow);
|
|
521
|
+
log(`cola llena: ${overflow} eventos descartados`);
|
|
522
|
+
}
|
|
523
|
+
};
|
|
524
|
+
const payload = (batch) => {
|
|
525
|
+
const body = {
|
|
526
|
+
sdk: {
|
|
527
|
+
name: SDK_NAME,
|
|
528
|
+
version: SDK_VERSION
|
|
529
|
+
},
|
|
530
|
+
sent_at: (/* @__PURE__ */ new Date()).toISOString(),
|
|
531
|
+
events: batch.map((q) => q.event)
|
|
532
|
+
};
|
|
533
|
+
return JSON.stringify(body);
|
|
534
|
+
};
|
|
535
|
+
const drain = async () => {
|
|
536
|
+
while (queue.length > 0) {
|
|
537
|
+
const batch = queue.splice(0, maxBatchSize);
|
|
538
|
+
let result;
|
|
539
|
+
try {
|
|
540
|
+
result = await transport.send(url, payload(batch), {
|
|
541
|
+
beacon: false,
|
|
542
|
+
debug
|
|
543
|
+
});
|
|
544
|
+
} catch {
|
|
545
|
+
result = "retry";
|
|
546
|
+
}
|
|
547
|
+
if (result === "sent") continue;
|
|
548
|
+
if (result === "drop") {
|
|
549
|
+
log(`lote de ${batch.length} eventos descartado por el collector`);
|
|
550
|
+
continue;
|
|
551
|
+
}
|
|
552
|
+
queue = [...batch.filter((q) => ++q.attempts < MAX_ATTEMPTS), ...queue];
|
|
553
|
+
trimQueue();
|
|
554
|
+
schedule(flushIntervalMs * 2);
|
|
555
|
+
return;
|
|
556
|
+
}
|
|
557
|
+
};
|
|
558
|
+
const flush = () => {
|
|
559
|
+
if (timer !== null) {
|
|
560
|
+
clearTimeout(timer);
|
|
561
|
+
timer = null;
|
|
562
|
+
}
|
|
563
|
+
if (!inflight) inflight = drain().finally(() => {
|
|
564
|
+
inflight = null;
|
|
565
|
+
if (queue.length > 0) schedule(flushIntervalMs);
|
|
566
|
+
});
|
|
567
|
+
return inflight;
|
|
568
|
+
};
|
|
569
|
+
const flushBeacon = () => {
|
|
570
|
+
if (timer !== null) {
|
|
571
|
+
clearTimeout(timer);
|
|
572
|
+
timer = null;
|
|
573
|
+
}
|
|
574
|
+
while (queue.length > 0) {
|
|
575
|
+
const batch = queue.splice(0, maxBatchSize);
|
|
576
|
+
try {
|
|
577
|
+
Promise.resolve(transport.send(url, payload(batch), {
|
|
578
|
+
beacon: true,
|
|
579
|
+
debug
|
|
580
|
+
})).catch(() => {});
|
|
581
|
+
} catch {}
|
|
582
|
+
}
|
|
583
|
+
};
|
|
584
|
+
const onVisibilityChange = () => {
|
|
585
|
+
if (document.visibilityState === "hidden") flushBeacon();
|
|
586
|
+
};
|
|
587
|
+
document.addEventListener("visibilitychange", onVisibilityChange);
|
|
588
|
+
window.addEventListener("pagehide", flushBeacon);
|
|
589
|
+
const enqueue = (event) => {
|
|
590
|
+
queue.push({
|
|
591
|
+
event,
|
|
592
|
+
attempts: 0
|
|
593
|
+
});
|
|
594
|
+
trimQueue();
|
|
595
|
+
if (queue.length >= maxBatchSize) flush();
|
|
596
|
+
else schedule(flushIntervalMs);
|
|
597
|
+
};
|
|
598
|
+
const forgetStoredIds = () => {
|
|
599
|
+
identity.clear();
|
|
600
|
+
for (const key of [CLICK_ID_STORAGE_KEY, OWN_CLICK_ID_STORAGE_KEY]) if (clickStore.get(key) !== null) clickStore.remove(key);
|
|
601
|
+
};
|
|
602
|
+
const readStoredClickId = (key, maxAgeDays) => {
|
|
603
|
+
try {
|
|
604
|
+
const stored = JSON.parse(clickStore.get(key) ?? "null");
|
|
605
|
+
if (stored && isClickId(stored.id) && typeof stored.ts === "number" && Date.now() - stored.ts <= maxAgeDays * DAY_MS) return {
|
|
606
|
+
id: stored.id,
|
|
607
|
+
ts: stored.ts
|
|
608
|
+
};
|
|
609
|
+
} catch {}
|
|
610
|
+
return null;
|
|
611
|
+
};
|
|
612
|
+
const storeClickId = (key, entry) => {
|
|
613
|
+
clickStore.set(key, JSON.stringify(entry));
|
|
614
|
+
};
|
|
615
|
+
const captureClickId = () => {
|
|
616
|
+
const clickId = readClickIdFromUrl(location.href);
|
|
617
|
+
if (clickId && consent() === "granted") {
|
|
618
|
+
const previous = readStoredClickId(CLICK_ID_STORAGE_KEY, Number.POSITIVE_INFINITY);
|
|
619
|
+
const ts = previous?.id === clickId ? previous.ts : Date.now();
|
|
620
|
+
storeClickId(CLICK_ID_STORAGE_KEY, {
|
|
621
|
+
id: clickId,
|
|
622
|
+
ts
|
|
623
|
+
});
|
|
624
|
+
}
|
|
625
|
+
return clickId;
|
|
626
|
+
};
|
|
627
|
+
const arrivedClickId = () => {
|
|
628
|
+
const fromUrl = readClickIdFromUrl(location.href);
|
|
629
|
+
if (fromUrl) return fromUrl;
|
|
630
|
+
if (consent() !== "granted") return null;
|
|
631
|
+
return readStoredClickId(CLICK_ID_STORAGE_KEY, DEFAULT_MAX_AGE_DAYS)?.id ?? null;
|
|
632
|
+
};
|
|
633
|
+
const rememberOwnClickId = (clickId) => {
|
|
634
|
+
if (consent() === "granted") storeClickId(OWN_CLICK_ID_STORAGE_KEY, {
|
|
635
|
+
id: clickId,
|
|
636
|
+
ts: Date.now()
|
|
637
|
+
});
|
|
638
|
+
};
|
|
639
|
+
const onConsentChange = () => {
|
|
640
|
+
if (consent() === "granted") captureClickId();
|
|
641
|
+
else forgetStoredIds();
|
|
642
|
+
};
|
|
643
|
+
window.addEventListener(CONSENT_CHANGE_EVENT, onConsentChange);
|
|
644
|
+
const emit = (name, props) => {
|
|
645
|
+
if (closed) return null;
|
|
646
|
+
let snapshot;
|
|
647
|
+
try {
|
|
648
|
+
snapshot = JSON.parse(JSON.stringify(props ?? {}));
|
|
649
|
+
} catch (error) {
|
|
650
|
+
log("props no serializables: evento descartado", name, error);
|
|
651
|
+
return null;
|
|
652
|
+
}
|
|
653
|
+
const state = consent();
|
|
654
|
+
const granted = state === "granted";
|
|
655
|
+
if (!granted) forgetStoredIds();
|
|
656
|
+
const ids = granted ? identity.ensure() : null;
|
|
657
|
+
const event = {
|
|
658
|
+
id: uuid(),
|
|
659
|
+
name,
|
|
660
|
+
ts: (/* @__PURE__ */ new Date()).toISOString(),
|
|
661
|
+
props: snapshot,
|
|
662
|
+
ctx: collectContext(allowlist),
|
|
663
|
+
wl,
|
|
664
|
+
consent: state,
|
|
665
|
+
anon_id: ids?.anonId ?? null,
|
|
666
|
+
session_id: ids?.sessionId ?? null,
|
|
667
|
+
user_id: granted ? userId : null
|
|
668
|
+
};
|
|
669
|
+
log("track", event.name, event);
|
|
670
|
+
enqueue(event);
|
|
671
|
+
return event;
|
|
672
|
+
};
|
|
673
|
+
const track = (name, props) => emit(name, props)?.id ?? null;
|
|
674
|
+
const recordAdClick = (event) => {
|
|
675
|
+
if (!clickRecord) return;
|
|
676
|
+
if (clickRecord.requireConsent && event.consent !== "granted") return;
|
|
677
|
+
try {
|
|
678
|
+
const props = event.props;
|
|
679
|
+
const payload = {
|
|
680
|
+
v: 1,
|
|
681
|
+
click_id: props.click_id,
|
|
682
|
+
ad_id: props.ad_id,
|
|
683
|
+
token: props.token,
|
|
684
|
+
placement: props.placement,
|
|
685
|
+
position: props.position,
|
|
686
|
+
page_url: event.ctx.url.slice(0, MAX_PAGE_URL_LENGTH),
|
|
687
|
+
ts: event.ts
|
|
688
|
+
};
|
|
689
|
+
log("click record", payload);
|
|
690
|
+
sendClickRecord(clickRecord.url, payload);
|
|
691
|
+
} catch {}
|
|
692
|
+
};
|
|
693
|
+
return {
|
|
694
|
+
track,
|
|
695
|
+
observeImpression(element, name, props, options) {
|
|
696
|
+
if (closed) return () => {};
|
|
697
|
+
return impressions.observe(element, () => track(name, props), options);
|
|
698
|
+
},
|
|
699
|
+
trackAdClick(props, destination) {
|
|
700
|
+
const clickId = uuid();
|
|
701
|
+
const event = emit("ad_click", {
|
|
702
|
+
...props,
|
|
703
|
+
...clickProps(clickId, destination)
|
|
704
|
+
});
|
|
705
|
+
if (event) {
|
|
706
|
+
rememberOwnClickId(clickId);
|
|
707
|
+
recordAdClick(event);
|
|
708
|
+
}
|
|
709
|
+
return withClickId(destination, clickId);
|
|
710
|
+
},
|
|
711
|
+
trackOutboundClick(props, destination) {
|
|
712
|
+
const clickId = uuid();
|
|
713
|
+
const event = {
|
|
714
|
+
...props,
|
|
715
|
+
...clickProps(clickId, destination),
|
|
716
|
+
via_click_id: arrivedClickId() ?? void 0
|
|
717
|
+
};
|
|
718
|
+
if (track("outbound_click", event)) rememberOwnClickId(clickId);
|
|
719
|
+
return withClickId(destination, clickId);
|
|
720
|
+
},
|
|
721
|
+
captureClickId,
|
|
722
|
+
getClickId({ maxAgeDays = DEFAULT_MAX_AGE_DAYS } = {}) {
|
|
723
|
+
const fromUrl = readClickIdFromUrl(location.href);
|
|
724
|
+
if (fromUrl) return fromUrl;
|
|
725
|
+
if (consent() !== "granted") return null;
|
|
726
|
+
const arrived = readStoredClickId(CLICK_ID_STORAGE_KEY, maxAgeDays);
|
|
727
|
+
const own = readStoredClickId(OWN_CLICK_ID_STORAGE_KEY, maxAgeDays);
|
|
728
|
+
if (arrived && own) return own.ts >= arrived.ts ? own.id : arrived.id;
|
|
729
|
+
return (own ?? arrived)?.id ?? null;
|
|
730
|
+
},
|
|
731
|
+
identify(id) {
|
|
732
|
+
userId = id === null ? null : String(id);
|
|
733
|
+
},
|
|
734
|
+
setWl(next) {
|
|
735
|
+
wl = next;
|
|
736
|
+
},
|
|
737
|
+
reset() {
|
|
738
|
+
userId = null;
|
|
739
|
+
forgetStoredIds();
|
|
740
|
+
},
|
|
741
|
+
flush,
|
|
742
|
+
shutdown() {
|
|
743
|
+
if (closed) return;
|
|
744
|
+
flushBeacon();
|
|
745
|
+
closed = true;
|
|
746
|
+
impressions.disconnect();
|
|
747
|
+
document.removeEventListener("visibilitychange", onVisibilityChange);
|
|
748
|
+
window.removeEventListener("pagehide", flushBeacon);
|
|
749
|
+
window.removeEventListener(CONSENT_CHANGE_EVENT, onConsentChange);
|
|
750
|
+
}
|
|
751
|
+
};
|
|
752
|
+
}
|
|
753
|
+
export { CLICK_ID_PARAM, CONSENT_CHANGE_EVENT, DEFAULT_CONSENT_COOKIE, DEFAULT_URL_PARAM_ALLOWLIST, SDK_NAME, SDK_VERSION, cookieConsent, createNoopTracker, createTracker, fetchTransport, isClickId, uuid as newId, parseConsentValue, readClickIdFromUrl, sanitizeUrl, withClickId, withTrackingParams };
|