@magicfeedback/popup-sdk 1.16.1 → 1.17.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.
@@ -1279,97 +1279,166 @@ async function safeBodyText(res) {
1279
1279
  return "";
1280
1280
  }
1281
1281
  }
1282
+ var PENDING_MAX = 50;
1283
+ var PENDING_MAX_AGE_MS = 24 * 60 * 60 * 1e3;
1284
+ var POST_TIMEOUT_MS = 2e4;
1285
+ function pendingKey(integration) {
1286
+ return `__deepdots_analytics_pending__:${integration}`;
1287
+ }
1288
+ function generateSessionId() {
1289
+ const c = globalThis.crypto;
1290
+ if (typeof c?.randomUUID === "function") return c.randomUUID();
1291
+ const b = new Uint8Array(16);
1292
+ if (typeof c?.getRandomValues === "function") c.getRandomValues(b);
1293
+ else for (let i = 0; i < 16; i++) b[i] = Math.floor(Math.random() * 256);
1294
+ b[6] = b[6] & 15 | 64;
1295
+ b[8] = b[8] & 63 | 128;
1296
+ const h = Array.from(b, (x) => x.toString(16).padStart(2, "0")).join("");
1297
+ return `${h.slice(0, 8)}-${h.slice(8, 12)}-${h.slice(12, 16)}-${h.slice(16, 20)}-${h.slice(20)}`;
1298
+ }
1282
1299
  function createFeedbackSink(options) {
1283
- let feedbackSessionId = options.initialSessionId || void 0;
1284
- let firstPostInFlight = null;
1285
- let generation = 0;
1286
1300
  const url = `${options.baseUrl}/sdk/feedback`;
1287
- const post = async (body, final, sessionEnd = false) => {
1288
- const postGeneration = generation;
1301
+ const newSessionId = options.newSessionId ?? generateSessionId;
1302
+ const now = options.now ?? (() => Date.now());
1303
+ const storageKey = pendingKey(options.keys.integration);
1304
+ let feedbackSessionId = options.initialSessionId || void 0;
1305
+ let announcedId = feedbackSessionId;
1306
+ const pending2 = loadPending();
1307
+ let draining = null;
1308
+ function loadPending() {
1309
+ if (!options.storage) return [];
1310
+ try {
1311
+ const raw = options.storage.getItem(storageKey);
1312
+ const list = raw ? JSON.parse(raw) : [];
1313
+ return Array.isArray(list) ? list.filter((p) => p?.body && now() - p.at <= PENDING_MAX_AGE_MS) : [];
1314
+ } catch {
1315
+ return [];
1316
+ }
1317
+ }
1318
+ function savePending() {
1319
+ if (!options.storage) return;
1320
+ try {
1321
+ if (pending2.length) options.storage.setItem(storageKey, JSON.stringify(pending2));
1322
+ else options.storage.removeItem(storageKey);
1323
+ } catch {
1324
+ }
1325
+ }
1326
+ function addPending(batch) {
1327
+ pending2.push(batch);
1328
+ if (pending2.length > PENDING_MAX) pending2.splice(0, pending2.length - PENDING_MAX);
1329
+ savePending();
1330
+ }
1331
+ function removePending(batch) {
1332
+ const i = pending2.indexOf(batch);
1333
+ if (i >= 0) pending2.splice(i, 1);
1334
+ savePending();
1335
+ }
1336
+ async function post(body, final) {
1289
1337
  const json = JSON.stringify(body);
1290
1338
  const small = json.length <= KEEPALIVE_MAX_BYTES;
1291
1339
  if (final) options.log?.("[DeepdotsAnalytics] final flush via fetch keepalive");
1292
1340
  const f = options.fetchImpl ?? (typeof fetch !== "undefined" ? fetch : void 0);
1293
1341
  if (!f) {
1294
1342
  options.log?.("[DeepdotsAnalytics] no fetch available; payload not sent", body);
1295
- return;
1343
+ return "drop";
1296
1344
  }
1297
- const res = await f(url, {
1298
- method: "POST",
1299
- headers: { "Content-Type": "application/json" },
1300
- body: json,
1301
- // La API autentica por `publicKey` en el body y responde `Allow-Origin: *`: una
1302
- // petición con credenciales fallaría el CORS. Explícito para no depender del default.
1303
- credentials: "omit",
1304
- // keepalive: el POST sobrevive a la navegación mientras el body sea pequeño.
1305
- ...small ? { keepalive: true } : {}
1306
- });
1307
- if (!res.ok) {
1345
+ let timer;
1346
+ try {
1347
+ const request = f(url, {
1348
+ method: "POST",
1349
+ headers: { "Content-Type": "application/json" },
1350
+ body: json,
1351
+ // La API autentica por `publicKey` en el body y responde `Allow-Origin: *`: una
1352
+ // petición con credenciales fallaría el CORS. Explícito para no depender del default.
1353
+ credentials: "omit",
1354
+ // keepalive: el POST sobrevive a la navegación mientras el body sea pequeño.
1355
+ ...small ? { keepalive: true } : {}
1356
+ });
1357
+ const timeout = new Promise((_, reject) => {
1358
+ timer = setTimeout(() => reject(new Error("timeout")), POST_TIMEOUT_MS);
1359
+ });
1360
+ const res = await Promise.race([request, timeout]);
1361
+ if (res.ok) return "ok";
1308
1362
  const detail = await safeBodyText(res);
1309
1363
  if (isRetryableStatus(res.status)) {
1310
- throw new Error(`POST /sdk/feedback ${res.status} (retryable): ${detail}`);
1364
+ options.log?.(`[DeepdotsAnalytics] POST /sdk/feedback ${res.status} (retryable): ${detail}`);
1365
+ return "retry";
1311
1366
  }
1312
1367
  options.log?.(
1313
1368
  `[DeepdotsAnalytics] POST /sdk/feedback rejected with ${res.status}; batch DISCARDED:`,
1314
1369
  detail
1315
1370
  );
1316
- return;
1317
- }
1318
- if (sessionEnd) {
1319
- let closedId = body.sessionId;
1320
- try {
1321
- const data = await res.json();
1322
- closedId = data?.sessionId ?? closedId;
1323
- } catch {
1324
- }
1325
- try {
1326
- if (closedId) options.onSessionClosed?.(closedId);
1327
- } catch {
1328
- }
1329
- return;
1371
+ return "drop";
1372
+ } catch (error) {
1373
+ options.log?.("[DeepdotsAnalytics] POST /sdk/feedback failed (will retry):", error);
1374
+ return "retry";
1375
+ } finally {
1376
+ clearTimeout(timer);
1330
1377
  }
1378
+ }
1379
+ function delivered(body) {
1380
+ const id = body.sessionId;
1381
+ if (!id) return;
1331
1382
  try {
1332
- const data = await res.json();
1333
- if (postGeneration !== generation) {
1334
- options.log?.("[DeepdotsAnalytics] sessionId of a closed session ignored:", data?.sessionId);
1335
- return;
1336
- }
1337
- if (data?.sessionId && data.sessionId !== feedbackSessionId) {
1338
- feedbackSessionId = data.sessionId;
1339
- options.log?.("[DeepdotsAnalytics] feedbackSessionId cached:", feedbackSessionId);
1340
- options.onSessionId?.(feedbackSessionId);
1383
+ if (body.completed) {
1384
+ options.onSessionClosed?.(id);
1385
+ } else if (id === feedbackSessionId && id !== announcedId) {
1386
+ announcedId = id;
1387
+ options.log?.("[DeepdotsAnalytics] feedback session open:", id);
1388
+ options.onSessionId?.(id);
1341
1389
  }
1342
1390
  } catch {
1343
1391
  }
1344
- };
1392
+ }
1393
+ async function send(batch, final, alreadyPending) {
1394
+ const result = await post(batch.body, final);
1395
+ if (result === "retry") {
1396
+ if (!alreadyPending) addPending(batch);
1397
+ return;
1398
+ }
1399
+ if (alreadyPending) removePending(batch);
1400
+ if (result === "ok") delivered(batch.body);
1401
+ }
1402
+ function drain() {
1403
+ const run = async () => {
1404
+ for (let i = 0; i < pending2.length; ) {
1405
+ const batch = pending2[i];
1406
+ await send(batch, false, true);
1407
+ if (pending2[i] === batch) return;
1408
+ }
1409
+ };
1410
+ const next = draining ? draining.then(run) : run();
1411
+ draining = next;
1412
+ void next.finally(() => {
1413
+ if (draining === next) draining = null;
1414
+ });
1415
+ return next;
1416
+ }
1417
+ if (pending2.length) {
1418
+ savePending();
1419
+ setTimeout(() => void drain(), 0);
1420
+ }
1345
1421
  return async (envelope, meta) => {
1346
1422
  if (meta?.resetSession) {
1347
1423
  feedbackSessionId = void 0;
1348
- firstPostInFlight = null;
1349
- generation++;
1350
1424
  options.onSessionReset?.();
1351
1425
  return;
1352
1426
  }
1353
1427
  const final = meta?.final === true;
1354
1428
  const sessionEnd = meta?.sessionEnd === true;
1355
- if (!feedbackSessionId && firstPostInFlight && !final) {
1356
- await firstPostInFlight;
1357
- }
1429
+ if (!feedbackSessionId) feedbackSessionId = newSessionId();
1358
1430
  const body = buildAnalyticsFeedbackBody(envelope, options.keys, feedbackSessionId, { sessionEnd });
1359
1431
  options.log?.("[DeepdotsAnalytics] POST /sdk/feedback \u2192", body);
1360
- const sent = post(body, final, sessionEnd);
1432
+ const batch = { body, at: now() };
1361
1433
  if (sessionEnd) {
1362
1434
  feedbackSessionId = void 0;
1363
- firstPostInFlight = null;
1364
- generation++;
1365
1435
  options.onSessionReset?.();
1366
- } else if (!feedbackSessionId) {
1367
- firstPostInFlight = sent.then(
1368
- () => void 0,
1369
- () => void 0
1370
- );
1436
+ addPending(batch);
1437
+ await drain();
1438
+ return;
1371
1439
  }
1372
- await sent;
1440
+ const retries = pending2.length ? drain() : null;
1441
+ await Promise.all([retries, send(batch, final, false)]);
1373
1442
  };
1374
1443
  }
1375
1444
 
@@ -1788,6 +1857,10 @@ function crashRecordToParams(r) {
1788
1857
  }
1789
1858
  var CrashReporter = class {
1790
1859
  constructor(options) {
1860
+ /** Los handlers de `window` ya están puestos: `install()` es idempotente. */
1861
+ this.windowInstalled = false;
1862
+ /** ErrorUtils ya enganchados: engancharlos dos veces capturaría cada error dos veces. */
1863
+ this.hookedErrorUtils = /* @__PURE__ */ new Set();
1791
1864
  this.options = options;
1792
1865
  this.now = options.now ?? (() => Date.now());
1793
1866
  }
@@ -1863,9 +1936,11 @@ var CrashReporter = class {
1863
1936
  }
1864
1937
  return queue;
1865
1938
  }
1866
- /** Instala los handlers globales de errores no capturados (no-op sin `window`). */
1939
+ /** Instala los handlers globales de errores no capturados (no-op sin `window`). Idempotente. */
1867
1940
  install() {
1941
+ if (this.windowInstalled) return;
1868
1942
  if (typeof window === "undefined" || typeof window.addEventListener !== "function") return;
1943
+ this.windowInstalled = true;
1869
1944
  window.addEventListener("error", (e) => {
1870
1945
  if (!this.isEnabled()) return;
1871
1946
  this.captureUnhandled(e.error ?? e.message ?? "error");
@@ -1875,8 +1950,10 @@ var CrashReporter = class {
1875
1950
  this.captureUnhandled(e.reason ?? "unhandledrejection");
1876
1951
  });
1877
1952
  }
1878
- /** Engancha `global.ErrorUtils` de RN (no hay `window`): persiste y encadena al handler previo. */
1953
+ /** Engancha `global.ErrorUtils` de RN (no hay `window`): persiste y encadena al handler previo. Idempotente por instancia de ErrorUtils. */
1879
1954
  installReactNative(errorUtils) {
1955
+ if (this.hookedErrorUtils.has(errorUtils)) return;
1956
+ this.hookedErrorUtils.add(errorUtils);
1880
1957
  const previous = errorUtils.getGlobalHandler?.();
1881
1958
  errorUtils.setGlobalHandler((error, isFatal) => {
1882
1959
  if (this.isEnabled()) this.captureUnhandled(error);
@@ -2089,6 +2166,8 @@ var DeepdotsPopups = class {
2089
2166
  this.analyticsFlushTimer = void 0;
2090
2167
  /** Crash & error reporting (#14–17). Null hasta init(). */
2091
2168
  this.crashReporter = null;
2169
+ /** ErrorUtils de RN recibido del host (o del Provider); se engancha en cuanto haya consentimiento. */
2170
+ this.rnErrorUtils = null;
2092
2171
  /** Protecciones del funnel de Messaging (#18–22). Vigencia de sesión: se reinicia en init(). */
2093
2172
  this.messageGuard = new MessageGuard();
2094
2173
  /** Observador de navegación (Fase 2): emite page_view por el canal de analytics. */
@@ -2189,6 +2268,10 @@ var DeepdotsPopups = class {
2189
2268
  keys: config.analytics,
2190
2269
  log: (...a) => this.log(...a),
2191
2270
  initialSessionId: resumable?.sessionId,
2271
+ // En móvil (RN) la app puede morir con el lote de cierre sin entregar: se
2272
+ // persiste y se reenvía en el arranque siguiente. En web el cierre sale con
2273
+ // `keepalive` y varias pestañas compartirían el mismo storage.
2274
+ storage: (config.platform ?? "web") !== "web" ? storage : void 0,
2192
2275
  onSessionId: (id) => {
2193
2276
  this.analyticsFeedbackSessionId = id;
2194
2277
  this.notifyFeedbackSession(id, "open");
@@ -2224,9 +2307,7 @@ var DeepdotsPopups = class {
2224
2307
  now: () => Date.now(),
2225
2308
  enabled: () => this.tracking?.isTrackingEnabled() ?? false
2226
2309
  });
2227
- if (this.tracking?.isTrackingEnabled()) {
2228
- this.crashReporter.install();
2229
- }
2310
+ this.installCrashHandlers();
2230
2311
  this.geoEnabled = config.geolocation ?? true;
2231
2312
  this.geoWanted = this.geoEnabled && !!config.analytics;
2232
2313
  this.resolveGeo();
@@ -2295,6 +2376,7 @@ var DeepdotsPopups = class {
2295
2376
  if (!enabled) this.closeSession("tracking_disabled");
2296
2377
  this.tracking?.setTrackingEnabled(enabled);
2297
2378
  if (enabled) this.openSession("tracking_enabled");
2379
+ if (enabled) this.installCrashHandlers();
2298
2380
  if (enabled) this.resolveGeo();
2299
2381
  this.log("tracking \xB7 setTrackingEnabled:", enabled, "\xB7 session_id:", this.tracking?.getSessionId() ?? null);
2300
2382
  }
@@ -2325,10 +2407,27 @@ var DeepdotsPopups = class {
2325
2407
  if (!this.tracking?.isTrackingEnabled()) return;
2326
2408
  this.crashReporter?.reportError(error, options);
2327
2409
  }
2328
- /** Engancha `global.ErrorUtils` de RN para capturar errores JS no manejados. No-op si tracking off. */
2410
+ /**
2411
+ * Engancha `global.ErrorUtils` de RN para capturar errores JS no manejados. Con tracking off
2412
+ * se recuerda y se engancha cuando se conceda el consentimiento (`setTrackingEnabled(true)`).
2413
+ */
2329
2414
  installReactNativeCrashHandler(errorUtils) {
2330
- if (!this.tracking?.isTrackingEnabled()) return;
2331
- this.crashReporter?.installReactNative(errorUtils);
2415
+ this.rnErrorUtils = errorUtils;
2416
+ this.installCrashHandlers();
2417
+ }
2418
+ /**
2419
+ * Pone los handlers de errores no capturados: `window` en web y `ErrorUtils` en RN (el que
2420
+ * pasó el host o, si no pasó ninguno, `globalThis.ErrorUtils`, para que un `init()` sin
2421
+ * `setupReactNative` también capture). Solo con consentimiento; idempotente, así que se
2422
+ * llama en `init()` y cada vez que se activa el tracking.
2423
+ */
2424
+ installCrashHandlers() {
2425
+ if (!this.crashReporter || !this.tracking?.isTrackingEnabled()) return;
2426
+ this.crashReporter.install();
2427
+ const errorUtils = this.rnErrorUtils ?? globalThis.ErrorUtils;
2428
+ if (errorUtils && typeof errorUtils.setGlobalHandler === "function") {
2429
+ this.crashReporter.installReactNative(errorUtils);
2430
+ }
2332
2431
  }
2333
2432
  /**
2334
2433
  * Info interna del usuario que solo conoce el host (idioma, edad, plan…), persistida en el