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