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