@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.js CHANGED
@@ -7706,97 +7706,166 @@ async function safeBodyText(res) {
7706
7706
  return "";
7707
7707
  }
7708
7708
  }
7709
+ var PENDING_MAX = 50;
7710
+ var PENDING_MAX_AGE_MS = 24 * 60 * 60 * 1e3;
7711
+ var POST_TIMEOUT_MS = 2e4;
7712
+ function pendingKey(integration) {
7713
+ return `__deepdots_analytics_pending__:${integration}`;
7714
+ }
7715
+ function generateSessionId() {
7716
+ const c = globalThis.crypto;
7717
+ if (typeof c?.randomUUID === "function") return c.randomUUID();
7718
+ const b = new Uint8Array(16);
7719
+ if (typeof c?.getRandomValues === "function") c.getRandomValues(b);
7720
+ else for (let i = 0; i < 16; i++) b[i] = Math.floor(Math.random() * 256);
7721
+ b[6] = b[6] & 15 | 64;
7722
+ b[8] = b[8] & 63 | 128;
7723
+ const h = Array.from(b, (x) => x.toString(16).padStart(2, "0")).join("");
7724
+ return `${h.slice(0, 8)}-${h.slice(8, 12)}-${h.slice(12, 16)}-${h.slice(16, 20)}-${h.slice(20)}`;
7725
+ }
7709
7726
  function createFeedbackSink(options) {
7710
- let feedbackSessionId = options.initialSessionId || void 0;
7711
- let firstPostInFlight = null;
7712
- let generation = 0;
7713
7727
  const url = `${options.baseUrl}/sdk/feedback`;
7714
- const post = async (body, final, sessionEnd = false) => {
7715
- const postGeneration = generation;
7728
+ const newSessionId = options.newSessionId ?? generateSessionId;
7729
+ const now = options.now ?? (() => Date.now());
7730
+ const storageKey = pendingKey(options.keys.integration);
7731
+ let feedbackSessionId = options.initialSessionId || void 0;
7732
+ let announcedId = feedbackSessionId;
7733
+ const pending2 = loadPending();
7734
+ let draining = null;
7735
+ function loadPending() {
7736
+ if (!options.storage) return [];
7737
+ try {
7738
+ const raw = options.storage.getItem(storageKey);
7739
+ const list = raw ? JSON.parse(raw) : [];
7740
+ return Array.isArray(list) ? list.filter((p) => p?.body && now() - p.at <= PENDING_MAX_AGE_MS) : [];
7741
+ } catch {
7742
+ return [];
7743
+ }
7744
+ }
7745
+ function savePending() {
7746
+ if (!options.storage) return;
7747
+ try {
7748
+ if (pending2.length) options.storage.setItem(storageKey, JSON.stringify(pending2));
7749
+ else options.storage.removeItem(storageKey);
7750
+ } catch {
7751
+ }
7752
+ }
7753
+ function addPending(batch) {
7754
+ pending2.push(batch);
7755
+ if (pending2.length > PENDING_MAX) pending2.splice(0, pending2.length - PENDING_MAX);
7756
+ savePending();
7757
+ }
7758
+ function removePending(batch) {
7759
+ const i = pending2.indexOf(batch);
7760
+ if (i >= 0) pending2.splice(i, 1);
7761
+ savePending();
7762
+ }
7763
+ async function post(body, final) {
7716
7764
  const json = JSON.stringify(body);
7717
7765
  const small = json.length <= KEEPALIVE_MAX_BYTES;
7718
7766
  if (final) options.log?.("[DeepdotsAnalytics] final flush via fetch keepalive");
7719
7767
  const f = options.fetchImpl ?? (typeof fetch !== "undefined" ? fetch : void 0);
7720
7768
  if (!f) {
7721
7769
  options.log?.("[DeepdotsAnalytics] no fetch available; payload not sent", body);
7722
- return;
7770
+ return "drop";
7723
7771
  }
7724
- const res = await f(url, {
7725
- method: "POST",
7726
- headers: { "Content-Type": "application/json" },
7727
- body: json,
7728
- // La API autentica por `publicKey` en el body y responde `Allow-Origin: *`: una
7729
- // petición con credenciales fallaría el CORS. Explícito para no depender del default.
7730
- credentials: "omit",
7731
- // keepalive: el POST sobrevive a la navegación mientras el body sea pequeño.
7732
- ...small ? { keepalive: true } : {}
7733
- });
7734
- if (!res.ok) {
7772
+ let timer;
7773
+ try {
7774
+ const request = f(url, {
7775
+ method: "POST",
7776
+ headers: { "Content-Type": "application/json" },
7777
+ body: json,
7778
+ // La API autentica por `publicKey` en el body y responde `Allow-Origin: *`: una
7779
+ // petición con credenciales fallaría el CORS. Explícito para no depender del default.
7780
+ credentials: "omit",
7781
+ // keepalive: el POST sobrevive a la navegación mientras el body sea pequeño.
7782
+ ...small ? { keepalive: true } : {}
7783
+ });
7784
+ const timeout = new Promise((_, reject) => {
7785
+ timer = setTimeout(() => reject(new Error("timeout")), POST_TIMEOUT_MS);
7786
+ });
7787
+ const res = await Promise.race([request, timeout]);
7788
+ if (res.ok) return "ok";
7735
7789
  const detail = await safeBodyText(res);
7736
7790
  if (isRetryableStatus(res.status)) {
7737
- throw new Error(`POST /sdk/feedback ${res.status} (retryable): ${detail}`);
7791
+ options.log?.(`[DeepdotsAnalytics] POST /sdk/feedback ${res.status} (retryable): ${detail}`);
7792
+ return "retry";
7738
7793
  }
7739
7794
  options.log?.(
7740
7795
  `[DeepdotsAnalytics] POST /sdk/feedback rejected with ${res.status}; batch DISCARDED:`,
7741
7796
  detail
7742
7797
  );
7743
- return;
7744
- }
7745
- if (sessionEnd) {
7746
- let closedId = body.sessionId;
7747
- try {
7748
- const data = await res.json();
7749
- closedId = data?.sessionId ?? closedId;
7750
- } catch {
7751
- }
7752
- try {
7753
- if (closedId) options.onSessionClosed?.(closedId);
7754
- } catch {
7755
- }
7756
- return;
7798
+ return "drop";
7799
+ } catch (error) {
7800
+ options.log?.("[DeepdotsAnalytics] POST /sdk/feedback failed (will retry):", error);
7801
+ return "retry";
7802
+ } finally {
7803
+ clearTimeout(timer);
7757
7804
  }
7805
+ }
7806
+ function delivered(body) {
7807
+ const id = body.sessionId;
7808
+ if (!id) return;
7758
7809
  try {
7759
- const data = await res.json();
7760
- if (postGeneration !== generation) {
7761
- options.log?.("[DeepdotsAnalytics] sessionId of a closed session ignored:", data?.sessionId);
7762
- return;
7763
- }
7764
- if (data?.sessionId && data.sessionId !== feedbackSessionId) {
7765
- feedbackSessionId = data.sessionId;
7766
- options.log?.("[DeepdotsAnalytics] feedbackSessionId cached:", feedbackSessionId);
7767
- options.onSessionId?.(feedbackSessionId);
7810
+ if (body.completed) {
7811
+ options.onSessionClosed?.(id);
7812
+ } else if (id === feedbackSessionId && id !== announcedId) {
7813
+ announcedId = id;
7814
+ options.log?.("[DeepdotsAnalytics] feedback session open:", id);
7815
+ options.onSessionId?.(id);
7768
7816
  }
7769
7817
  } catch {
7770
7818
  }
7771
- };
7819
+ }
7820
+ async function send(batch, final, alreadyPending) {
7821
+ const result = await post(batch.body, final);
7822
+ if (result === "retry") {
7823
+ if (!alreadyPending) addPending(batch);
7824
+ return;
7825
+ }
7826
+ if (alreadyPending) removePending(batch);
7827
+ if (result === "ok") delivered(batch.body);
7828
+ }
7829
+ function drain() {
7830
+ const run = async () => {
7831
+ for (let i = 0; i < pending2.length; ) {
7832
+ const batch = pending2[i];
7833
+ await send(batch, false, true);
7834
+ if (pending2[i] === batch) return;
7835
+ }
7836
+ };
7837
+ const next = draining ? draining.then(run) : run();
7838
+ draining = next;
7839
+ void next.finally(() => {
7840
+ if (draining === next) draining = null;
7841
+ });
7842
+ return next;
7843
+ }
7844
+ if (pending2.length) {
7845
+ savePending();
7846
+ setTimeout(() => void drain(), 0);
7847
+ }
7772
7848
  return async (envelope, meta) => {
7773
7849
  if (meta?.resetSession) {
7774
7850
  feedbackSessionId = void 0;
7775
- firstPostInFlight = null;
7776
- generation++;
7777
7851
  options.onSessionReset?.();
7778
7852
  return;
7779
7853
  }
7780
7854
  const final = meta?.final === true;
7781
7855
  const sessionEnd = meta?.sessionEnd === true;
7782
- if (!feedbackSessionId && firstPostInFlight && !final) {
7783
- await firstPostInFlight;
7784
- }
7856
+ if (!feedbackSessionId) feedbackSessionId = newSessionId();
7785
7857
  const body = buildAnalyticsFeedbackBody(envelope, options.keys, feedbackSessionId, { sessionEnd });
7786
7858
  options.log?.("[DeepdotsAnalytics] POST /sdk/feedback \u2192", body);
7787
- const sent = post(body, final, sessionEnd);
7859
+ const batch = { body, at: now() };
7788
7860
  if (sessionEnd) {
7789
7861
  feedbackSessionId = void 0;
7790
- firstPostInFlight = null;
7791
- generation++;
7792
7862
  options.onSessionReset?.();
7793
- } else if (!feedbackSessionId) {
7794
- firstPostInFlight = sent.then(
7795
- () => void 0,
7796
- () => void 0
7797
- );
7863
+ addPending(batch);
7864
+ await drain();
7865
+ return;
7798
7866
  }
7799
- await sent;
7867
+ const retries = pending2.length ? drain() : null;
7868
+ await Promise.all([retries, send(batch, final, false)]);
7800
7869
  };
7801
7870
  }
7802
7871
 
@@ -8154,12 +8223,12 @@ var EngagementTracker = class {
8154
8223
 
8155
8224
  // src/contact/contact-manager.ts
8156
8225
  init_tracking_manager();
8157
- function stableSerialize(attrs) {
8226
+ function stableSerialize(userId, attrs) {
8158
8227
  const sorted = {};
8159
8228
  for (const key of Object.keys(attrs).sort()) {
8160
8229
  sorted[key] = attrs[key];
8161
8230
  }
8162
- return JSON.stringify(sorted);
8231
+ return JSON.stringify({ userId, attributes: sorted });
8163
8232
  }
8164
8233
  var ContactManager = class {
8165
8234
  constructor(options) {
@@ -8169,18 +8238,23 @@ var ContactManager = class {
8169
8238
  this.post = options.post;
8170
8239
  }
8171
8240
  /**
8172
- * Envía los atributos si cambiaron respecto a lo último enviado.
8173
- * @returns `true` si se envió, `false` si no hubo cambios.
8241
+ * Envía los atributos si cambiaron respecto a lo último enviado. La caché solo se guarda
8242
+ * cuando la API acepta el envío; si falla, la llamada siguiente reintenta. Nunca rechaza.
8243
+ * @returns `true` si se envió, `false` si no hubo cambios o el envío falló.
8174
8244
  */
8175
8245
  async setAttributes(attributes) {
8176
- const serialized = stableSerialize(attributes);
8246
+ const serialized = stableSerialize(this.userId, attributes);
8177
8247
  const last = this.storage.getItem(STORAGE_KEYS.contactAttributes);
8178
8248
  if (last === serialized) return false;
8179
- await this.post({
8180
- publicKey: this.publicKey,
8181
- userId: this.userId,
8182
- userAttributes: attributes
8183
- });
8249
+ try {
8250
+ await this.post({
8251
+ publicKey: this.publicKey,
8252
+ userId: this.userId,
8253
+ userAttributes: attributes
8254
+ });
8255
+ } catch {
8256
+ return false;
8257
+ }
8184
8258
  this.storage.setItem(STORAGE_KEYS.contactAttributes, serialized);
8185
8259
  return true;
8186
8260
  }
@@ -8211,6 +8285,10 @@ function crashRecordToParams(r) {
8211
8285
  }
8212
8286
  var CrashReporter = class {
8213
8287
  constructor(options) {
8288
+ /** Los handlers de `window` ya están puestos: `install()` es idempotente. */
8289
+ this.windowInstalled = false;
8290
+ /** ErrorUtils ya enganchados: engancharlos dos veces capturaría cada error dos veces. */
8291
+ this.hookedErrorUtils = /* @__PURE__ */ new Set();
8214
8292
  this.options = options;
8215
8293
  this.now = options.now ?? (() => Date.now());
8216
8294
  }
@@ -8286,9 +8364,11 @@ var CrashReporter = class {
8286
8364
  }
8287
8365
  return queue;
8288
8366
  }
8289
- /** Instala los handlers globales de errores no capturados (no-op sin `window`). */
8367
+ /** Instala los handlers globales de errores no capturados (no-op sin `window`). Idempotente. */
8290
8368
  install() {
8369
+ if (this.windowInstalled) return;
8291
8370
  if (typeof window === "undefined" || typeof window.addEventListener !== "function") return;
8371
+ this.windowInstalled = true;
8292
8372
  window.addEventListener("error", (e) => {
8293
8373
  if (!this.isEnabled()) return;
8294
8374
  this.captureUnhandled(e.error ?? e.message ?? "error");
@@ -8298,8 +8378,10 @@ var CrashReporter = class {
8298
8378
  this.captureUnhandled(e.reason ?? "unhandledrejection");
8299
8379
  });
8300
8380
  }
8301
- /** Engancha `global.ErrorUtils` de RN (no hay `window`): persiste y encadena al handler previo. */
8381
+ /** Engancha `global.ErrorUtils` de RN (no hay `window`): persiste y encadena al handler previo. Idempotente por instancia de ErrorUtils. */
8302
8382
  installReactNative(errorUtils) {
8383
+ if (this.hookedErrorUtils.has(errorUtils)) return;
8384
+ this.hookedErrorUtils.add(errorUtils);
8303
8385
  const previous = errorUtils.getGlobalHandler?.();
8304
8386
  errorUtils.setGlobalHandler((error, isFatal) => {
8305
8387
  if (this.isEnabled()) this.captureUnhandled(error);
@@ -8515,6 +8597,8 @@ var DeepdotsPopups = class {
8515
8597
  this.analyticsFlushTimer = void 0;
8516
8598
  /** Crash & error reporting (#14–17). Null hasta init(). */
8517
8599
  this.crashReporter = null;
8600
+ /** ErrorUtils de RN recibido del host (o del Provider); se engancha en cuanto haya consentimiento. */
8601
+ this.rnErrorUtils = null;
8518
8602
  /** Protecciones del funnel de Messaging (#18–22). Vigencia de sesión: se reinicia en init(). */
8519
8603
  this.messageGuard = new MessageGuard();
8520
8604
  /** Observador de navegación (Fase 2): emite page_view por el canal de analytics. */
@@ -8615,6 +8699,10 @@ var DeepdotsPopups = class {
8615
8699
  keys: config.analytics,
8616
8700
  log: (...a) => this.log(...a),
8617
8701
  initialSessionId: resumable?.sessionId,
8702
+ // En móvil (RN) la app puede morir con el lote de cierre sin entregar: se
8703
+ // persiste y se reenvía en el arranque siguiente. En web el cierre sale con
8704
+ // `keepalive` y varias pestañas compartirían el mismo storage.
8705
+ storage: (config.platform ?? "web") !== "web" ? storage : void 0,
8618
8706
  onSessionId: (id) => {
8619
8707
  this.analyticsFeedbackSessionId = id;
8620
8708
  this.notifyFeedbackSession(id, "open");
@@ -8650,9 +8738,7 @@ var DeepdotsPopups = class {
8650
8738
  now: () => Date.now(),
8651
8739
  enabled: () => this.tracking?.isTrackingEnabled() ?? false
8652
8740
  });
8653
- if (this.tracking?.isTrackingEnabled()) {
8654
- this.crashReporter.install();
8655
- }
8741
+ this.installCrashHandlers();
8656
8742
  this.geoEnabled = config.geolocation ?? true;
8657
8743
  this.geoWanted = this.geoEnabled && !!config.analytics;
8658
8744
  this.resolveGeo();
@@ -8721,6 +8807,7 @@ var DeepdotsPopups = class {
8721
8807
  if (!enabled) this.closeSession("tracking_disabled");
8722
8808
  this.tracking?.setTrackingEnabled(enabled);
8723
8809
  if (enabled) this.openSession("tracking_enabled");
8810
+ if (enabled) this.installCrashHandlers();
8724
8811
  if (enabled) this.resolveGeo();
8725
8812
  this.log("tracking \xB7 setTrackingEnabled:", enabled, "\xB7 session_id:", this.tracking?.getSessionId() ?? null);
8726
8813
  }
@@ -8751,10 +8838,27 @@ var DeepdotsPopups = class {
8751
8838
  if (!this.tracking?.isTrackingEnabled()) return;
8752
8839
  this.crashReporter?.reportError(error, options);
8753
8840
  }
8754
- /** Engancha `global.ErrorUtils` de RN para capturar errores JS no manejados. No-op si tracking off. */
8841
+ /**
8842
+ * Engancha `global.ErrorUtils` de RN para capturar errores JS no manejados. Con tracking off
8843
+ * se recuerda y se engancha cuando se conceda el consentimiento (`setTrackingEnabled(true)`).
8844
+ */
8755
8845
  installReactNativeCrashHandler(errorUtils) {
8756
- if (!this.tracking?.isTrackingEnabled()) return;
8757
- this.crashReporter?.installReactNative(errorUtils);
8846
+ this.rnErrorUtils = errorUtils;
8847
+ this.installCrashHandlers();
8848
+ }
8849
+ /**
8850
+ * Pone los handlers de errores no capturados: `window` en web y `ErrorUtils` en RN (el que
8851
+ * pasó el host o, si no pasó ninguno, `globalThis.ErrorUtils`, para que un `init()` sin
8852
+ * `setupReactNative` también capture). Solo con consentimiento; idempotente, así que se
8853
+ * llama en `init()` y cada vez que se activa el tracking.
8854
+ */
8855
+ installCrashHandlers() {
8856
+ if (!this.crashReporter || !this.tracking?.isTrackingEnabled()) return;
8857
+ this.crashReporter.install();
8858
+ const errorUtils = this.rnErrorUtils ?? globalThis.ErrorUtils;
8859
+ if (errorUtils && typeof errorUtils.setGlobalHandler === "function") {
8860
+ this.crashReporter.installReactNative(errorUtils);
8861
+ }
8758
8862
  }
8759
8863
  /**
8760
8864
  * Info interna del usuario que solo conoce el host (idioma, edad, plan…), persistida en el
@@ -9577,25 +9681,31 @@ var DeepdotsPopups = class {
9577
9681
  return null;
9578
9682
  }
9579
9683
  }
9580
- /** Envía los atributos de contact a la API (`POST /sdk/popups/contact`). */
9684
+ /**
9685
+ * Envía los atributos de contact a la API (`POST /sdk/popups/contact`). Rechaza si no se
9686
+ * pudo enviar (sin red o respuesta no-2xx), para que `ContactManager` no guarde la caché
9687
+ * y la llamada siguiente reintente.
9688
+ */
9581
9689
  async postContact(body) {
9582
9690
  const baseUrl = this.baseUrl;
9583
- if (!baseUrl) return;
9691
+ if (!baseUrl) throw new Error("no base URL");
9584
9692
  const endpoint = `${baseUrl}/sdk/popups/contact`;
9693
+ let response;
9585
9694
  try {
9586
- const response = await fetch(endpoint, {
9695
+ response = await fetch(endpoint, {
9587
9696
  method: "POST",
9588
9697
  headers: { "Content-Type": "application/json" },
9589
9698
  body: JSON.stringify(body)
9590
9699
  });
9591
- if (!response.ok) {
9592
- this.log("Failed to post contact", response.status, response.statusText);
9593
- return;
9594
- }
9595
- this.log("contact \xB7 sent attributes for user:", body.userId);
9596
9700
  } catch (error) {
9597
9701
  this.log("Error posting contact", error);
9702
+ throw error;
9703
+ }
9704
+ if (!response.ok) {
9705
+ this.log("Failed to post contact", response.status, response.statusText);
9706
+ throw new Error(`HTTP ${response.status}`);
9598
9707
  }
9708
+ this.log("contact \xB7 sent attributes for user:", body.userId);
9599
9709
  }
9600
9710
  /** Notifica eventos de popup a la API */
9601
9711
  async postPopupEvent(status, popupId, userId) {