@magicfeedback/popup-sdk 1.16.0 → 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
 
@@ -1724,12 +1793,12 @@ var EngagementTracker = class {
1724
1793
  };
1725
1794
 
1726
1795
  // src/contact/contact-manager.ts
1727
- function stableSerialize(attrs) {
1796
+ function stableSerialize(userId, attrs) {
1728
1797
  const sorted = {};
1729
1798
  for (const key of Object.keys(attrs).sort()) {
1730
1799
  sorted[key] = attrs[key];
1731
1800
  }
1732
- return JSON.stringify(sorted);
1801
+ return JSON.stringify({ userId, attributes: sorted });
1733
1802
  }
1734
1803
  var ContactManager = class {
1735
1804
  constructor(options) {
@@ -1739,18 +1808,23 @@ var ContactManager = class {
1739
1808
  this.post = options.post;
1740
1809
  }
1741
1810
  /**
1742
- * Envía los atributos si cambiaron respecto a lo último enviado.
1743
- * @returns `true` si se envió, `false` si no hubo cambios.
1811
+ * Envía los atributos si cambiaron respecto a lo último enviado. La caché solo se guarda
1812
+ * cuando la API acepta el envío; si falla, la llamada siguiente reintenta. Nunca rechaza.
1813
+ * @returns `true` si se envió, `false` si no hubo cambios o el envío falló.
1744
1814
  */
1745
1815
  async setAttributes(attributes) {
1746
- const serialized = stableSerialize(attributes);
1816
+ const serialized = stableSerialize(this.userId, attributes);
1747
1817
  const last = this.storage.getItem(STORAGE_KEYS.contactAttributes);
1748
1818
  if (last === serialized) return false;
1749
- await this.post({
1750
- publicKey: this.publicKey,
1751
- userId: this.userId,
1752
- userAttributes: attributes
1753
- });
1819
+ try {
1820
+ await this.post({
1821
+ publicKey: this.publicKey,
1822
+ userId: this.userId,
1823
+ userAttributes: attributes
1824
+ });
1825
+ } catch {
1826
+ return false;
1827
+ }
1754
1828
  this.storage.setItem(STORAGE_KEYS.contactAttributes, serialized);
1755
1829
  return true;
1756
1830
  }
@@ -1781,6 +1855,10 @@ function crashRecordToParams(r) {
1781
1855
  }
1782
1856
  var CrashReporter = class {
1783
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();
1784
1862
  this.options = options;
1785
1863
  this.now = options.now ?? (() => Date.now());
1786
1864
  }
@@ -1856,9 +1934,11 @@ var CrashReporter = class {
1856
1934
  }
1857
1935
  return queue;
1858
1936
  }
1859
- /** 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. */
1860
1938
  install() {
1939
+ if (this.windowInstalled) return;
1861
1940
  if (typeof window === "undefined" || typeof window.addEventListener !== "function") return;
1941
+ this.windowInstalled = true;
1862
1942
  window.addEventListener("error", (e) => {
1863
1943
  if (!this.isEnabled()) return;
1864
1944
  this.captureUnhandled(e.error ?? e.message ?? "error");
@@ -1868,8 +1948,10 @@ var CrashReporter = class {
1868
1948
  this.captureUnhandled(e.reason ?? "unhandledrejection");
1869
1949
  });
1870
1950
  }
1871
- /** 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. */
1872
1952
  installReactNative(errorUtils) {
1953
+ if (this.hookedErrorUtils.has(errorUtils)) return;
1954
+ this.hookedErrorUtils.add(errorUtils);
1873
1955
  const previous = errorUtils.getGlobalHandler?.();
1874
1956
  errorUtils.setGlobalHandler((error, isFatal) => {
1875
1957
  if (this.isEnabled()) this.captureUnhandled(error);
@@ -2082,6 +2164,8 @@ var DeepdotsPopups = class {
2082
2164
  this.analyticsFlushTimer = void 0;
2083
2165
  /** Crash & error reporting (#14–17). Null hasta init(). */
2084
2166
  this.crashReporter = null;
2167
+ /** ErrorUtils de RN recibido del host (o del Provider); se engancha en cuanto haya consentimiento. */
2168
+ this.rnErrorUtils = null;
2085
2169
  /** Protecciones del funnel de Messaging (#18–22). Vigencia de sesión: se reinicia en init(). */
2086
2170
  this.messageGuard = new MessageGuard();
2087
2171
  /** Observador de navegación (Fase 2): emite page_view por el canal de analytics. */
@@ -2182,6 +2266,10 @@ var DeepdotsPopups = class {
2182
2266
  keys: config.analytics,
2183
2267
  log: (...a) => this.log(...a),
2184
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,
2185
2273
  onSessionId: (id) => {
2186
2274
  this.analyticsFeedbackSessionId = id;
2187
2275
  this.notifyFeedbackSession(id, "open");
@@ -2217,9 +2305,7 @@ var DeepdotsPopups = class {
2217
2305
  now: () => Date.now(),
2218
2306
  enabled: () => this.tracking?.isTrackingEnabled() ?? false
2219
2307
  });
2220
- if (this.tracking?.isTrackingEnabled()) {
2221
- this.crashReporter.install();
2222
- }
2308
+ this.installCrashHandlers();
2223
2309
  this.geoEnabled = config.geolocation ?? true;
2224
2310
  this.geoWanted = this.geoEnabled && !!config.analytics;
2225
2311
  this.resolveGeo();
@@ -2288,6 +2374,7 @@ var DeepdotsPopups = class {
2288
2374
  if (!enabled) this.closeSession("tracking_disabled");
2289
2375
  this.tracking?.setTrackingEnabled(enabled);
2290
2376
  if (enabled) this.openSession("tracking_enabled");
2377
+ if (enabled) this.installCrashHandlers();
2291
2378
  if (enabled) this.resolveGeo();
2292
2379
  this.log("tracking \xB7 setTrackingEnabled:", enabled, "\xB7 session_id:", this.tracking?.getSessionId() ?? null);
2293
2380
  }
@@ -2318,10 +2405,27 @@ var DeepdotsPopups = class {
2318
2405
  if (!this.tracking?.isTrackingEnabled()) return;
2319
2406
  this.crashReporter?.reportError(error, options);
2320
2407
  }
2321
- /** 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
+ */
2322
2412
  installReactNativeCrashHandler(errorUtils) {
2323
- if (!this.tracking?.isTrackingEnabled()) return;
2324
- 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
+ }
2325
2429
  }
2326
2430
  /**
2327
2431
  * Info interna del usuario que solo conoce el host (idioma, edad, plan…), persistida en el
@@ -3144,25 +3248,31 @@ var DeepdotsPopups = class {
3144
3248
  return null;
3145
3249
  }
3146
3250
  }
3147
- /** Envía los atributos de contact a la API (`POST /sdk/popups/contact`). */
3251
+ /**
3252
+ * Envía los atributos de contact a la API (`POST /sdk/popups/contact`). Rechaza si no se
3253
+ * pudo enviar (sin red o respuesta no-2xx), para que `ContactManager` no guarde la caché
3254
+ * y la llamada siguiente reintente.
3255
+ */
3148
3256
  async postContact(body) {
3149
3257
  const baseUrl = this.baseUrl;
3150
- if (!baseUrl) return;
3258
+ if (!baseUrl) throw new Error("no base URL");
3151
3259
  const endpoint = `${baseUrl}/sdk/popups/contact`;
3260
+ let response;
3152
3261
  try {
3153
- const response = await fetch(endpoint, {
3262
+ response = await fetch(endpoint, {
3154
3263
  method: "POST",
3155
3264
  headers: { "Content-Type": "application/json" },
3156
3265
  body: JSON.stringify(body)
3157
3266
  });
3158
- if (!response.ok) {
3159
- this.log("Failed to post contact", response.status, response.statusText);
3160
- return;
3161
- }
3162
- this.log("contact \xB7 sent attributes for user:", body.userId);
3163
3267
  } catch (error) {
3164
3268
  this.log("Error posting contact", error);
3269
+ throw error;
3270
+ }
3271
+ if (!response.ok) {
3272
+ this.log("Failed to post contact", response.status, response.statusText);
3273
+ throw new Error(`HTTP ${response.status}`);
3165
3274
  }
3275
+ this.log("contact \xB7 sent attributes for user:", body.userId);
3166
3276
  }
3167
3277
  /** Notifica eventos de popup a la API */
3168
3278
  async postPopupEvent(status, popupId, userId) {