@guidelytech/react-native 0.9.0 → 0.10.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
@@ -554,51 +554,108 @@ function isArticleListed(collections, article, member) {
554
554
  }
555
555
  var CAPTURE_SCREENSHOT_MAX_BYTES = 8 * 1024 * 1024;
556
556
 
557
- // ../sdk-core/dist/chunk-CYAG6ZYX.js
558
- function readJson(store, key, fallback) {
559
- const raw = store.get(key);
560
- if (raw == null) return fallback;
561
- try {
562
- return JSON.parse(raw);
563
- } catch {
564
- return fallback;
565
- }
566
- }
567
- function writeJson(store, key, value) {
568
- try {
569
- store.set(key, JSON.stringify(value));
570
- } catch {
571
- }
572
- }
573
- var KEYS = {
574
- anon: "guidely:anon",
575
- seen: "guidely:seen",
576
- config: (key) => `guidely:config:${key}`,
577
- etag: (key) => `guidely:etag:${key}`,
578
- state: (key) => `guidely:state:${key}`,
579
- user: (key) => `guidely:user:${key}`,
580
- queue: (key) => `guidely:queue:${key}`,
581
- session: "guidely:session",
582
- active: "guidely:active",
583
- authoring: "guidely:authoring",
584
- /** How-to capture session marker ({ token, … }) and its not-yet-uploaded steps. */
585
- capture: "guidely:capture",
586
- capturePending: "guidely:capture:pending",
587
- /** Messenger conversations this browser can read: [{ id, token }]. */
588
- conversations: (key) => `guidely:conversations:${key}`,
589
- /** Announcement ids opened in the messenger's News tab. */
590
- newsSeen: (key) => `guidely:news:${key}`
591
- };
592
-
593
- // ../sdk-core/dist/chunk-AKNE7D5M.js
557
+ // ../sdk-core/dist/chunk-C4WWSF67.js
594
558
  var __defProp2 = Object.defineProperty;
595
559
  var __defNormalProp2 = (obj, key, value) => key in obj ? __defProp2(obj, key, { enumerable: true, configurable: true, writable: true, value }) : obj[key] = value;
596
560
  var __publicField2 = (obj, key, value) => __defNormalProp2(obj, typeof key !== "symbol" ? key + "" : key, value);
561
+ var MAX_RETRY_AFTER_MS = 60 * 60 * 1e3;
562
+ var OFFLINE_WAIT_MS = 3e4;
563
+ var ServerHealth = class {
564
+ constructor(opts = {}) {
565
+ __publicField2(this, "failures", 0);
566
+ __publicField2(this, "openUntil", 0);
567
+ __publicField2(this, "lastOkAt", 0);
568
+ __publicField2(this, "listeners", /* @__PURE__ */ new Set());
569
+ __publicField2(this, "threshold");
570
+ __publicField2(this, "baseMs");
571
+ __publicField2(this, "maxMs");
572
+ __publicField2(this, "now");
573
+ __publicField2(this, "random");
574
+ this.threshold = opts.threshold ?? 3;
575
+ this.baseMs = opts.baseMs ?? 5e3;
576
+ this.maxMs = opts.maxMs ?? 5 * 60 * 1e3;
577
+ this.now = opts.now ?? Date.now;
578
+ this.random = opts.random ?? Math.random;
579
+ }
580
+ /** The server answered (2xx, 3xx, or a 4xx other than 429): it is up. */
581
+ success() {
582
+ const wasDown = this.failures > 0 || this.openUntil > 0;
583
+ this.failures = 0;
584
+ this.openUntil = 0;
585
+ this.lastOkAt = this.now();
586
+ if (wasDown) this.notify();
587
+ }
588
+ /** The request failed or the server is overloaded. `retryAfterMs` comes from a `Retry-After` header. */
589
+ failure(retryAfterMs) {
590
+ this.failures += 1;
591
+ const now = this.now();
592
+ if (retryAfterMs != null && retryAfterMs > 0) {
593
+ this.openUntil = Math.max(this.openUntil, now + Math.min(retryAfterMs, MAX_RETRY_AFTER_MS));
594
+ return;
595
+ }
596
+ if (this.failures < this.threshold) return;
597
+ const cooldown = Math.min(this.maxMs, this.baseMs * 2 ** (this.failures - this.threshold));
598
+ this.openUntil = Math.max(this.openUntil, now + cooldown * (0.8 + this.random() * 0.4));
599
+ }
600
+ /** Milliseconds background traffic should wait before trying again (0: send now). */
601
+ waitMs() {
602
+ if (typeof navigator !== "undefined" && navigator.onLine === false) return OFFLINE_WAIT_MS;
603
+ return Math.max(0, this.openUntil - this.now());
604
+ }
605
+ canSend() {
606
+ return this.waitMs() === 0;
607
+ }
608
+ /**
609
+ * The last request got an answer. Unload beacons can't report back, so what they carry is only
610
+ * forgotten while this holds; otherwise it stays queued and is sent again (the server drops duplicates).
611
+ */
612
+ get reachable() {
613
+ return this.failures === 0 && this.lastOkAt > 0;
614
+ }
615
+ /** The browser came back online: end the cooldown and let queued traffic try straight away. */
616
+ online() {
617
+ this.openUntil = 0;
618
+ this.notify();
619
+ }
620
+ /** Called when the server answers again after failures, or the browser comes back online. */
621
+ onRecover(cb) {
622
+ this.listeners.add(cb);
623
+ return () => this.listeners.delete(cb);
624
+ }
625
+ notify() {
626
+ for (const cb of Array.from(this.listeners)) {
627
+ try {
628
+ cb();
629
+ } catch {
630
+ }
631
+ }
632
+ }
633
+ };
634
+ function parseRetryAfter(value, now = Date.now()) {
635
+ if (!value) return null;
636
+ const trimmed = value.trim();
637
+ if (/^\d+$/.test(trimmed)) return Number(trimmed) * 1e3;
638
+ const at = Date.parse(trimmed);
639
+ return Number.isNaN(at) ? null : Math.max(0, at - now);
640
+ }
641
+ function isServerFailure(status) {
642
+ return status === 429 || status >= 500;
643
+ }
644
+ var STREAM_RESPONSE_TIMEOUT_MS = 3e4;
645
+ var STREAM_IDLE_TIMEOUT_MS = 6e4;
597
646
  var Api = class {
598
- constructor(baseUrl, key, fetchImpl) {
647
+ constructor(baseUrl, key, fetchImpl, health) {
599
648
  __publicField2(this, "baseUrl", baseUrl);
600
649
  __publicField2(this, "key", key);
601
650
  __publicField2(this, "fetchImpl", fetchImpl);
651
+ __publicField2(this, "health");
652
+ this.health = health ?? new ServerHealth();
653
+ }
654
+ /** Feeds an answer (or a failed request, `null`) into {@link health}. */
655
+ observe(res) {
656
+ if (!res) this.health.failure();
657
+ else if (isServerFailure(res.status)) this.health.failure(parseRetryAfter(res.headers?.get?.("retry-after")));
658
+ else this.health.success();
602
659
  }
603
660
  url(path, params = {}) {
604
661
  const query = Object.entries({ key: this.key, ...params }).filter((e) => e[1] != null).map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(v)}`).join("&");
@@ -618,15 +675,22 @@ var Api = class {
618
675
  const ac = typeof AbortController !== "undefined" ? new AbortController() : null;
619
676
  const timer = ac ? setTimeout(() => ac.abort(), options.timeoutMs ?? 1e4) : null;
620
677
  try {
621
- const res = await f(this.url(path, options.params), {
622
- method,
623
- headers,
624
- body: options.body === void 0 ? void 0 : form ? options.body : JSON.stringify(options.body),
625
- keepalive: options.keepalive,
626
- signal: ac?.signal,
627
- credentials: "omit",
628
- mode: "cors"
629
- });
678
+ let res;
679
+ try {
680
+ res = await f(this.url(path, options.params), {
681
+ method,
682
+ headers,
683
+ body: options.body === void 0 ? void 0 : form ? options.body : JSON.stringify(options.body),
684
+ keepalive: options.keepalive,
685
+ signal: ac?.signal,
686
+ credentials: "omit",
687
+ mode: "cors"
688
+ });
689
+ } catch (err) {
690
+ this.observe(null);
691
+ throw err;
692
+ }
693
+ this.observe(res);
630
694
  let data = null;
631
695
  if (res.status !== 304 && res.status !== 204) {
632
696
  try {
@@ -652,30 +716,53 @@ var Api = class {
652
716
  }
653
717
  /**
654
718
  * POST that may answer with server-sent events. Each event is passed to `onEvent`; a JSON answer is
655
- * returned instead. No timeout: Scout replies can take a while and the server keeps the stream alive.
719
+ * returned instead. Scout replies can take a while, so there is no overall timeout: the response
720
+ * must start within 30 s and then never go quiet for more than 60 s (the server sends keepalives),
721
+ * otherwise the request is aborted and rejects.
656
722
  */
657
723
  async stream(path, body, onEvent, headers = {}) {
658
724
  const f = this.fetcher;
659
725
  if (!f) throw new Error("fetch is not available");
660
- const res = await f(this.url(path), {
661
- method: "POST",
662
- headers: { ...headers, "X-Guidely-Key": this.key, Accept: "text/event-stream, application/json", "Content-Type": "application/json" },
663
- body: JSON.stringify(body),
664
- credentials: "omit",
665
- mode: "cors"
666
- });
667
- const type = res.headers?.get?.("content-type") ?? "";
668
- if (!type.includes("text/event-stream") || !res.body) {
669
- let data = null;
726
+ const ac = typeof AbortController !== "undefined" ? new AbortController() : null;
727
+ let timer = null;
728
+ const arm = (ms) => {
729
+ if (!ac) return;
730
+ if (timer) clearTimeout(timer);
731
+ timer = setTimeout(() => ac.abort(), ms);
732
+ };
733
+ arm(STREAM_RESPONSE_TIMEOUT_MS);
734
+ try {
735
+ let res;
670
736
  try {
671
- data = await res.json();
672
- } catch {
673
- data = null;
737
+ res = await f(this.url(path), {
738
+ method: "POST",
739
+ headers: { ...headers, "X-Guidely-Key": this.key, Accept: "text/event-stream, application/json", "Content-Type": "application/json" },
740
+ body: JSON.stringify(body),
741
+ signal: ac?.signal,
742
+ credentials: "omit",
743
+ mode: "cors"
744
+ });
745
+ } catch (err) {
746
+ this.observe(null);
747
+ throw err;
674
748
  }
675
- return { status: res.status, ok: res.ok, data, etag: null };
749
+ this.observe(res);
750
+ const type = res.headers?.get?.("content-type") ?? "";
751
+ if (!type.includes("text/event-stream") || !res.body) {
752
+ let data = null;
753
+ try {
754
+ data = await res.json();
755
+ } catch {
756
+ data = null;
757
+ }
758
+ return { status: res.status, ok: res.ok, data, etag: null };
759
+ }
760
+ arm(STREAM_IDLE_TIMEOUT_MS);
761
+ await readEventStream(res.body, onEvent, () => arm(STREAM_IDLE_TIMEOUT_MS));
762
+ return { status: res.status, ok: res.ok, data: null, etag: null };
763
+ } finally {
764
+ if (timer) clearTimeout(timer);
676
765
  }
677
- await readEventStream(res.body, onEvent);
678
- return { status: res.status, ok: res.ok, data: null, etag: null };
679
766
  }
680
767
  /** `navigator.sendBeacon` when available (page unload); returns false when it could not be queued. */
681
768
  beacon(path, body) {
@@ -706,7 +793,7 @@ async function withRetry(send, attempts = 3, baseMs = 500) {
706
793
  function postWithRetry(api, path, body, attempts = 3, baseMs = 500) {
707
794
  return withRetry(() => api.post(path, body), attempts, baseMs);
708
795
  }
709
- async function readEventStream(body, onEvent) {
796
+ async function readEventStream(body, onEvent, onChunk) {
710
797
  const reader = body.getReader();
711
798
  const decoder = new TextDecoder();
712
799
  let buffer = "";
@@ -729,6 +816,7 @@ async function readEventStream(body, onEvent) {
729
816
  };
730
817
  for (; ; ) {
731
818
  const { done, value } = await reader.read();
819
+ if (value) onChunk?.();
732
820
  if (value) buffer += decoder.decode(value, { stream: true }).replace(/\r\n?/g, "\n");
733
821
  let sep;
734
822
  while ((sep = buffer.indexOf("\n\n")) >= 0) {
@@ -739,8 +827,44 @@ async function readEventStream(body, onEvent) {
739
827
  }
740
828
  if (buffer.trim()) dispatch(buffer);
741
829
  }
830
+ function readJson(store, key, fallback) {
831
+ const raw = store.get(key);
832
+ if (raw == null) return fallback;
833
+ try {
834
+ return JSON.parse(raw);
835
+ } catch {
836
+ return fallback;
837
+ }
838
+ }
839
+ function writeJson(store, key, value) {
840
+ try {
841
+ store.set(key, JSON.stringify(value));
842
+ } catch {
843
+ }
844
+ }
845
+ var KEYS = {
846
+ anon: "guidely:anon",
847
+ seen: "guidely:seen",
848
+ config: (key) => `guidely:config:${key}`,
849
+ etag: (key) => `guidely:etag:${key}`,
850
+ state: (key) => `guidely:state:${key}`,
851
+ user: (key) => `guidely:user:${key}`,
852
+ queue: (key) => `guidely:queue:${key}`,
853
+ /** Survey answers and guide/checklist progress not yet accepted by the server. */
854
+ outbox: (key) => `guidely:outbox:${key}`,
855
+ session: "guidely:session",
856
+ active: "guidely:active",
857
+ authoring: "guidely:authoring",
858
+ /** How-to capture session marker ({ token, … }) and its not-yet-uploaded steps. */
859
+ capture: "guidely:capture",
860
+ capturePending: "guidely:capture:pending",
861
+ /** Messenger conversations this browser can read: [{ id, token }]. */
862
+ conversations: (key) => `guidely:conversations:${key}`,
863
+ /** Announcement ids opened in the messenger's News tab. */
864
+ newsSeen: (key) => `guidely:news:${key}`
865
+ };
742
866
 
743
- // ../sdk-core/dist/chunk-544KSOC3.js
867
+ // ../sdk-core/dist/chunk-GDJB466J.js
744
868
  var EventQueue = class {
745
869
  constructor(opts) {
746
870
  __publicField2(this, "opts", opts);
@@ -751,17 +875,34 @@ var EventQueue = class {
751
875
  __publicField2(this, "inflightIds", /* @__PURE__ */ new Set());
752
876
  __publicField2(this, "attempt", 0);
753
877
  __publicField2(this, "destroyed", false);
878
+ __publicField2(this, "offRecover", null);
754
879
  __publicField2(this, "flushIntervalMs");
755
880
  __publicField2(this, "maxQueue");
756
881
  __publicField2(this, "batchSize");
757
882
  __publicField2(this, "maxAttempts");
758
883
  this.flushIntervalMs = opts.flushIntervalMs ?? 2e3;
759
- this.maxQueue = opts.maxQueue ?? 200;
884
+ this.maxQueue = opts.maxQueue ?? 500;
760
885
  this.batchSize = opts.batchSize ?? 50;
761
886
  this.maxAttempts = opts.maxAttempts ?? 5;
762
887
  const persisted = readJson(opts.store, opts.storageKey, []);
763
888
  if (Array.isArray(persisted)) this.events = persisted.filter((e) => e && typeof e.eventId === "string").slice(-this.maxQueue);
764
889
  if (this.events.length) this.schedule();
890
+ this.offRecover = opts.health?.onRecover(() => this.retryNow()) ?? null;
891
+ }
892
+ /** The API is back: drop any pending backoff and send what is queued. */
893
+ retryNow() {
894
+ if (this.destroyed) return;
895
+ if (this.retryTimer) clearTimeout(this.retryTimer);
896
+ this.retryTimer = null;
897
+ this.attempt = 0;
898
+ void this.flush();
899
+ }
900
+ retryIn(ms) {
901
+ if (this.retryTimer || this.destroyed) return;
902
+ this.retryTimer = setTimeout(() => {
903
+ this.retryTimer = null;
904
+ void this.flush();
905
+ }, ms);
765
906
  }
766
907
  get size() {
767
908
  return this.events.length;
@@ -795,6 +936,8 @@ var EventQueue = class {
795
936
  clearTimeout(this.timer);
796
937
  this.timer = null;
797
938
  }
939
+ const wait = this.opts.health?.waitMs() ?? 0;
940
+ if (wait > 0) return this.retryIn(wait);
798
941
  this.inflight = true;
799
942
  const batch = this.events.slice(0, this.batchSize);
800
943
  this.inflightIds = new Set(batch.map((e) => e.eventId));
@@ -818,26 +961,20 @@ var EventQueue = class {
818
961
  if (this.events.length) this.schedule();
819
962
  return;
820
963
  }
821
- this.attempt += 1;
822
- if (this.attempt >= this.maxAttempts) {
823
- this.attempt = 0;
824
- return;
825
- }
826
- this.retryTimer = setTimeout(() => {
827
- this.retryTimer = null;
828
- void this.flush();
829
- }, backoffMs(this.attempt - 1));
964
+ this.attempt = Math.min(this.attempt + 1, this.maxAttempts);
965
+ const delay = this.attempt >= this.maxAttempts ? 6e4 : backoffMs(this.attempt - 1);
966
+ this.retryIn(Math.max(delay, this.opts.health?.waitMs() ?? 0));
830
967
  }
831
968
  /** Best-effort synchronous flush on unload using `sendBeacon`. */
832
969
  flushSync() {
833
970
  if (this.destroyed || !this.opts.enabled() || !this.opts.beacon) return;
834
971
  const batch = this.events.filter((e) => !this.inflightIds.has(e.eventId)).slice(0, this.batchSize);
835
972
  if (!batch.length) return;
836
- if (this.opts.beacon(batch)) {
973
+ if (this.opts.beacon(batch) && (this.opts.health?.reachable ?? true)) {
837
974
  const ids = new Set(batch.map((e) => e.eventId));
838
975
  this.events = this.events.filter((e) => !ids.has(e.eventId));
839
- this.persist();
840
976
  }
977
+ this.persist();
841
978
  }
842
979
  clear() {
843
980
  this.events = [];
@@ -845,6 +982,7 @@ var EventQueue = class {
845
982
  }
846
983
  destroy() {
847
984
  this.destroyed = true;
985
+ this.offRecover?.();
848
986
  if (this.timer) clearTimeout(this.timer);
849
987
  if (this.retryTimer) clearTimeout(this.retryTimer);
850
988
  this.timer = null;
@@ -852,6 +990,88 @@ var EventQueue = class {
852
990
  this.persist();
853
991
  }
854
992
  };
993
+ var Outbox = class {
994
+ constructor(opts) {
995
+ __publicField2(this, "opts", opts);
996
+ __publicField2(this, "items");
997
+ __publicField2(this, "sending", false);
998
+ __publicField2(this, "attempt", 0);
999
+ __publicField2(this, "timer", null);
1000
+ __publicField2(this, "destroyed", false);
1001
+ __publicField2(this, "offRecover");
1002
+ __publicField2(this, "maxItems");
1003
+ __publicField2(this, "maxAgeMs");
1004
+ this.maxItems = opts.maxItems ?? 50;
1005
+ this.maxAgeMs = opts.maxAgeMs ?? 7 * 24 * 60 * 60 * 1e3;
1006
+ const stored = readJson(opts.store, opts.storageKey, []);
1007
+ const cutoff = Date.now() - this.maxAgeMs;
1008
+ this.items = Array.isArray(stored) ? stored.filter((i) => i && typeof i.path === "string" && typeof i.key === "string" && i.createdAt > cutoff).slice(-this.maxItems) : [];
1009
+ this.offRecover = opts.health?.onRecover(() => {
1010
+ this.attempt = 0;
1011
+ this.retryIn(0);
1012
+ }) ?? null;
1013
+ if (this.items.length) this.retryIn(0);
1014
+ }
1015
+ get size() {
1016
+ return this.items.length;
1017
+ }
1018
+ /** Queues `POST path` and sends it now (or as soon as the API is reachable). */
1019
+ add(key, path, body) {
1020
+ if (this.destroyed) return;
1021
+ this.items = this.items.filter((i) => i.key !== key);
1022
+ this.items.push({ key, path, body, createdAt: Date.now() });
1023
+ if (this.items.length > this.maxItems) this.items.splice(0, this.items.length - this.maxItems);
1024
+ this.persist();
1025
+ void this.flush();
1026
+ }
1027
+ persist() {
1028
+ writeJson(this.opts.store, this.opts.storageKey, this.items);
1029
+ }
1030
+ retryIn(ms) {
1031
+ if (this.destroyed) return;
1032
+ if (this.timer) clearTimeout(this.timer);
1033
+ this.timer = setTimeout(() => {
1034
+ this.timer = null;
1035
+ void this.flush();
1036
+ }, ms);
1037
+ }
1038
+ /** Sends queued requests in order, one at a time. */
1039
+ async flush() {
1040
+ if (this.destroyed || this.sending || !this.items.length) return;
1041
+ const wait = this.opts.health?.waitMs() ?? 0;
1042
+ if (wait > 0) return this.retryIn(wait);
1043
+ this.sending = true;
1044
+ try {
1045
+ while (this.items.length && !this.destroyed) {
1046
+ const item = this.items[0];
1047
+ let retry = false;
1048
+ try {
1049
+ const res = await this.opts.api.post(item.path, item.body);
1050
+ retry = !res.ok && (res.status === 429 || res.status >= 500);
1051
+ } catch (err) {
1052
+ retry = true;
1053
+ this.opts.onError?.(err);
1054
+ }
1055
+ if (retry) {
1056
+ this.attempt += 1;
1057
+ this.retryIn(Math.max(backoffMs(this.attempt - 1, 1e3, 6e4), this.opts.health?.waitMs() ?? 0));
1058
+ return;
1059
+ }
1060
+ this.attempt = 0;
1061
+ this.items = this.items.filter((i) => i !== item);
1062
+ this.persist();
1063
+ }
1064
+ } finally {
1065
+ this.sending = false;
1066
+ }
1067
+ }
1068
+ destroy() {
1069
+ this.destroyed = true;
1070
+ this.offRecover?.();
1071
+ if (this.timer) clearTimeout(this.timer);
1072
+ this.timer = null;
1073
+ }
1074
+ };
855
1075
  var Emitter = class {
856
1076
  constructor(onError) {
857
1077
  __publicField2(this, "onError", onError);
@@ -1407,12 +1627,29 @@ function createSseParser(onEvent) {
1407
1627
  }
1408
1628
  };
1409
1629
  }
1630
+ var STREAM_RESPONSE_TIMEOUT_MS2 = 3e4;
1631
+ var STREAM_IDLE_TIMEOUT_MS2 = 6e4;
1410
1632
  function xhrStreamPost() {
1411
1633
  if (typeof XMLHttpRequest === "undefined") return null;
1412
1634
  return (url, headers, body, onChunk) => new Promise((resolve, reject) => {
1413
1635
  const xhr = new XMLHttpRequest();
1414
1636
  let seen = 0;
1415
1637
  let streamed = false;
1638
+ let timer = null;
1639
+ let settled = false;
1640
+ const settle = (fn) => {
1641
+ if (settled) return;
1642
+ settled = true;
1643
+ if (timer) clearTimeout(timer);
1644
+ fn();
1645
+ };
1646
+ const arm = (ms, what) => {
1647
+ if (timer) clearTimeout(timer);
1648
+ timer = setTimeout(() => {
1649
+ settle(() => reject(new Error(`Scout ${what} timed out`)));
1650
+ xhr.abort();
1651
+ }, ms);
1652
+ };
1416
1653
  const pump = () => {
1417
1654
  const type = xhr.getResponseHeader("content-type") || "";
1418
1655
  if (!type.includes("text/event-stream")) return;
@@ -1425,8 +1662,15 @@ function xhrStreamPost() {
1425
1662
  };
1426
1663
  xhr.open("POST", url);
1427
1664
  for (const [k, v] of Object.entries(headers)) xhr.setRequestHeader(k, v);
1428
- xhr.onprogress = pump;
1429
- xhr.onload = () => {
1665
+ xhr.onreadystatechange = () => {
1666
+ if (!settled && xhr.readyState >= 2 && xhr.readyState < 4) arm(STREAM_IDLE_TIMEOUT_MS2, "reply");
1667
+ };
1668
+ xhr.onprogress = () => {
1669
+ if (settled) return;
1670
+ arm(STREAM_IDLE_TIMEOUT_MS2, "reply");
1671
+ pump();
1672
+ };
1673
+ xhr.onload = () => settle(() => {
1430
1674
  pump();
1431
1675
  let json = null;
1432
1676
  if (!streamed) {
@@ -1437,27 +1681,74 @@ function xhrStreamPost() {
1437
1681
  }
1438
1682
  }
1439
1683
  resolve({ status: xhr.status, json, streamed });
1440
- };
1441
- xhr.onerror = () => reject(new Error("Network request failed"));
1684
+ });
1685
+ xhr.onerror = () => settle(() => reject(new Error("Network request failed")));
1686
+ arm(STREAM_RESPONSE_TIMEOUT_MS2, "request");
1442
1687
  xhr.send(JSON.stringify(body));
1443
1688
  });
1444
1689
  }
1690
+ function bodyReader(res) {
1691
+ const body = res.body;
1692
+ const Decoder = globalThis.TextDecoder;
1693
+ return body && typeof body.getReader === "function" && Decoder ? { reader: body.getReader(), decoder: new Decoder() } : null;
1694
+ }
1445
1695
  function fetchStreamPost(fetchImpl) {
1446
1696
  return async (url, headers, body, onChunk) => {
1447
- const res = await fetchImpl(url, { method: "POST", headers, body: JSON.stringify(body) });
1448
- const type = res.headers.get("content-type") || "";
1449
- const text = await res.text();
1450
- if (type.includes("text/event-stream")) {
1451
- onChunk(text);
1452
- return { status: res.status, json: null, streamed: true };
1453
- }
1454
- let json = null;
1697
+ const ac = typeof AbortController !== "undefined" ? new AbortController() : null;
1698
+ let timer = null;
1699
+ let timedOut = "";
1700
+ let fail = () => {
1701
+ };
1702
+ const expired = new Promise((_, reject) => fail = reject);
1703
+ expired.catch(() => {
1704
+ });
1705
+ const arm = (ms, what) => {
1706
+ if (timer) clearTimeout(timer);
1707
+ timer = setTimeout(() => {
1708
+ timedOut = what;
1709
+ ac?.abort();
1710
+ fail(new Error(`Scout ${what} timed out`));
1711
+ }, ms);
1712
+ };
1713
+ const race = (p) => Promise.race([p, expired]);
1714
+ arm(STREAM_RESPONSE_TIMEOUT_MS2, "request");
1455
1715
  try {
1456
- json = JSON.parse(text || "null");
1457
- } catch {
1458
- json = null;
1716
+ const res = await race(fetchImpl(url, { method: "POST", headers, body: JSON.stringify(body), signal: ac?.signal }));
1717
+ arm(STREAM_IDLE_TIMEOUT_MS2, "reply");
1718
+ const type = res.headers.get("content-type") || "";
1719
+ const sse = type.includes("text/event-stream");
1720
+ const streaming = sse ? bodyReader(res) : null;
1721
+ if (streaming) {
1722
+ const { reader, decoder } = streaming;
1723
+ for (; ; ) {
1724
+ const { done, value } = await race(reader.read());
1725
+ if (value) {
1726
+ arm(STREAM_IDLE_TIMEOUT_MS2, "reply");
1727
+ onChunk(decoder.decode(value, { stream: true }));
1728
+ }
1729
+ if (done) break;
1730
+ }
1731
+ const rest = decoder.decode();
1732
+ if (rest) onChunk(rest);
1733
+ return { status: res.status, json: null, streamed: true };
1734
+ }
1735
+ const text = await race(res.text());
1736
+ if (sse) {
1737
+ onChunk(text);
1738
+ return { status: res.status, json: null, streamed: true };
1739
+ }
1740
+ let json = null;
1741
+ try {
1742
+ json = JSON.parse(text || "null");
1743
+ } catch {
1744
+ json = null;
1745
+ }
1746
+ return { status: res.status, json, streamed: false };
1747
+ } catch (err) {
1748
+ throw timedOut ? new Error(`Scout ${timedOut} timed out`) : err;
1749
+ } finally {
1750
+ if (timer) clearTimeout(timer);
1459
1751
  }
1460
- return { status: res.status, json, streamed: false };
1461
1752
  };
1462
1753
  }
1463
1754
  var byNewest = (a, b) => Date.parse(b.lastMessageAt) - Date.parse(a.lastMessageAt);
@@ -1642,7 +1933,15 @@ var Messenger = class {
1642
1933
  }
1643
1934
  });
1644
1935
  try {
1645
- const res = await this.deps.streamPost(this.url(path), headers, payload, (chunk) => parser.feed(chunk));
1936
+ let res;
1937
+ try {
1938
+ res = await this.deps.streamPost(this.url(path), headers, payload, (chunk) => parser.feed(chunk));
1939
+ } catch (err) {
1940
+ this.deps.health?.failure();
1941
+ throw err;
1942
+ }
1943
+ if (isServerFailure(res.status)) this.deps.health?.failure();
1944
+ else this.deps.health?.success();
1646
1945
  parser.end();
1647
1946
  if (res.status < 200 || res.status >= 300) throw new Error(`Request failed (${res.status})`);
1648
1947
  if (!res.streamed && res.json) {
@@ -1829,9 +2128,20 @@ var PersistentStore = class {
1829
2128
 
1830
2129
  // src/core/client.ts
1831
2130
  var SDK_NAME = "@guidelytech/react-native";
1832
- var SDK_VERSION = "0.9.0";
2131
+ var SDK_VERSION = "0.10.0";
1833
2132
  var DEFAULT_API_URL = "https://guidelytech.com";
1834
2133
  var PAGE_SCOPED = /* @__PURE__ */ new Set(["page_load", "url_match", "element_click", "element_visible", "delay"]);
2134
+ var CACHED_START_MS = 1500;
2135
+ function settlesWithin(promise, ms) {
2136
+ return new Promise((resolve) => {
2137
+ const timer = setTimeout(() => resolve(false), ms);
2138
+ const done = () => {
2139
+ clearTimeout(timer);
2140
+ resolve(true);
2141
+ };
2142
+ promise.then(done, done);
2143
+ });
2144
+ }
1835
2145
  var nowIso = () => (/* @__PURE__ */ new Date()).toISOString();
1836
2146
  var emptyState = () => ({ guides: {}, checklists: {}, surveysSubmitted: [], audienceIds: [] });
1837
2147
  var newSession = () => ({ id: `s_${randomId(16)}`, startedAt: nowIso(), lastActiveAt: nowIso(), shownGuides: [], events: [] });
@@ -1844,6 +2154,8 @@ var GuidelyClient = class {
1844
2154
  __publicField(this, "store");
1845
2155
  __publicField(this, "emitter");
1846
2156
  __publicField(this, "queue", null);
2157
+ /** Survey answers, guide completions/dismissals and checklist progress, kept until the server has them. */
2158
+ __publicField(this, "outbox", null);
1847
2159
  __publicField(this, "config", null);
1848
2160
  __publicField(this, "configFetchedAt", 0);
1849
2161
  __publicField(this, "etag", null);
@@ -1906,7 +2218,7 @@ var GuidelyClient = class {
1906
2218
  this.emitter = new Emitter((err) => this.reportError(err));
1907
2219
  this.api = new Api(this.options.apiUrl, this.options.projectId, options.fetch);
1908
2220
  this.store = new PersistentStore(options.storage ?? null, (err) => this.log("storage error", err));
1909
- if (!options.storage) this.log("no `storage` passed: identity and guide history last only for this app session. Pass AsyncStorage to persist them.");
2221
+ if (!options.storage) this.log("no `storage` passed: identity, guide history, queued events and survey answers last only for this app session, so whatever could not be sent during a Guidely outage is lost when the app restarts. Pass AsyncStorage to persist them.");
1910
2222
  this.cleanups.push(this.targets.onTap((id) => this.handleTap(id)));
1911
2223
  if (options.user) this.user = { ...options.user };
1912
2224
  this.flags = this.createFlags(options.flags);
@@ -1957,7 +2269,7 @@ var GuidelyClient = class {
1957
2269
  async bootstrap() {
1958
2270
  const key = this.options.projectId;
1959
2271
  try {
1960
- await this.store.hydrate([KEYS.anon, KEYS.seen, KEYS.session, KEYS.config(key), KEYS.etag(key), KEYS.state(key), KEYS.user(key), KEYS.queue(key), KEYS.conversations(key)]);
2272
+ await this.store.hydrate([KEYS.anon, KEYS.seen, KEYS.session, KEYS.config(key), KEYS.etag(key), KEYS.state(key), KEYS.user(key), KEYS.queue(key), KEYS.outbox(key), KEYS.conversations(key)]);
1961
2273
  if (this.destroyed) return;
1962
2274
  this.anonymousId = this.store.get(KEYS.anon) || "";
1963
2275
  if (!this.anonymousId) {
@@ -1977,22 +2289,27 @@ var GuidelyClient = class {
1977
2289
  store: this.store,
1978
2290
  storageKey: KEYS.queue(key),
1979
2291
  enabled: () => this.analyticsEnabled(),
2292
+ health: this.api.health,
1980
2293
  send: async (events) => {
1981
2294
  const res = await this.api.post("/events", { events, sentAt: nowIso(), sdk: { name: SDK_NAME, version: SDK_VERSION } });
1982
2295
  return { ok: res.ok, retry: !(res.status >= 400 && res.status < 500 && res.status !== 429) };
1983
2296
  },
1984
2297
  onError: (err) => this.reportError(err)
1985
2298
  });
2299
+ this.outbox = new Outbox({ api: this.api, store: this.store, storageKey: KEYS.outbox(key), health: this.api.health });
1986
2300
  this.hydrated = true;
1987
2301
  const cached = readJson(this.store, KEYS.config(key), null);
1988
2302
  this.etag = this.store.get(KEYS.etag(key));
1989
2303
  if (cached && Array.isArray(cached.guides)) this.config = forPlatform(cached, this.bridge.device.platform);
1990
2304
  this.syncMessenger();
1991
2305
  this.pending.splice(0).forEach((fn) => fn());
1992
- await this.fetchConfig();
2306
+ const before = this.config?.configVersion;
2307
+ const fresh = this.fetchConfig();
2308
+ if (!this.config) await fresh;
2309
+ else if (!await settlesWithin(fresh, CACHED_START_MS)) void fresh.then(() => this.applyConfigChange(before));
1993
2310
  if (this.destroyed) return;
1994
2311
  this.wireTimers();
1995
- if (this.user?.id) await this.identify(this.user.id, this.user.traits, this.user.account);
2312
+ if (this.user?.id) await settlesWithin(this.identify(this.user.id, this.user.traits, this.user.account), CACHED_START_MS);
1996
2313
  if (this.destroyed) return;
1997
2314
  this.isReady = true;
1998
2315
  if (this.config) this.emitter.emit("ready", this.config);
@@ -2032,7 +2349,9 @@ var GuidelyClient = class {
2032
2349
  }
2033
2350
  }
2034
2351
  wireTimers() {
2035
- const refresh = setInterval(() => void this.refresh(), this.options.configRefreshMs);
2352
+ const refresh = setInterval(() => {
2353
+ if (this.api.health.canSend()) void this.refresh();
2354
+ }, this.options.configRefreshMs);
2036
2355
  this.cleanups.push(() => clearInterval(refresh));
2037
2356
  const poll = setInterval(() => {
2038
2357
  if (!this.isReady || !this.config || this.backgroundedAt !== null) return;
@@ -2048,6 +2367,11 @@ var GuidelyClient = class {
2048
2367
  if (this.destroyed) return;
2049
2368
  const before = this.config?.configVersion;
2050
2369
  await this.fetchConfig();
2370
+ this.applyConfigChange(before);
2371
+ }
2372
+ /** Re-renders after a config change (refresh, or a config that arrived after start). */
2373
+ applyConfigChange(before) {
2374
+ if (this.destroyed || !this.isReady) return;
2051
2375
  if (this.config && this.config.configVersion !== before) {
2052
2376
  this.notify();
2053
2377
  this.evaluate("ready");
@@ -2061,6 +2385,7 @@ var GuidelyClient = class {
2061
2385
  this.session.lastActiveAt = nowIso();
2062
2386
  this.persistSession();
2063
2387
  void this.queue?.flush();
2388
+ void this.outbox?.flush();
2064
2389
  void this.store.flush();
2065
2390
  void this.flags.flush();
2066
2391
  this.messenger?.setForeground(false);
@@ -2074,7 +2399,7 @@ var GuidelyClient = class {
2074
2399
  this.firedDelays.clear();
2075
2400
  }
2076
2401
  void this.flags.refresh();
2077
- if (Date.now() - this.configFetchedAt > this.options.configRefreshMs) void this.refresh();
2402
+ if (Date.now() - this.configFetchedAt > this.options.configRefreshMs && this.api.health.canSend()) void this.refresh();
2078
2403
  else if (this.isReady) this.evaluate("ready");
2079
2404
  }
2080
2405
  }
@@ -2100,6 +2425,7 @@ var GuidelyClient = class {
2100
2425
  this.cleanups.splice(0).forEach((fn) => fn());
2101
2426
  void this.queue?.flush();
2102
2427
  this.queue?.destroy();
2428
+ this.outbox?.destroy();
2103
2429
  this.flags.destroy();
2104
2430
  this.emitter.clear();
2105
2431
  this.uiListeners.clear();
@@ -2513,7 +2839,13 @@ var GuidelyClient = class {
2513
2839
  if (reason === "completed") this.completeChecklistTasksBy("guide_completed", guide);
2514
2840
  }
2515
2841
  syncGuideState(guideId, update) {
2516
- void postWithRetry(this.api, "/guide-state", { anonymousId: this.anonymousId, userId: this.user?.id || null, updates: [{ guideId, ...update }] });
2842
+ const body = { anonymousId: this.anonymousId, userId: this.user?.id || null, updates: [{ guideId, ...update }] };
2843
+ if (update.impression) void postWithRetry(this.api, "/guide-state", body);
2844
+ else this.sendDurably(`guide:${guideId}:${update.completed ? "completed" : "dismissed"}:${this.anonymousId}`, "/guide-state", body);
2845
+ }
2846
+ /** Queues a write in the persisted outbox (sent now, or once the API is reachable again). */
2847
+ sendDurably(key, path, body) {
2848
+ this.whenHydrated(() => this.outbox?.add(key, path, body));
2517
2849
  }
2518
2850
  show(guide, manual, stepId) {
2519
2851
  try {
@@ -2721,7 +3053,7 @@ var GuidelyClient = class {
2721
3053
  progress.dismissedAt = nowIso();
2722
3054
  this.persistState();
2723
3055
  this.enqueue("checklist_dismissed", { guideId: guide.id, guideVersion: guide.version });
2724
- void postWithRetry(this.api, `/checklists/${encodeURIComponent(guide.id)}/progress`, { anonymousId: this.anonymousId, userId: this.user?.id || null, guideVersion: guide.version, completedTaskIds: progress.completedTaskIds, dismissed: true });
3056
+ this.sendDurably(`checklist:${guide.id}:dismissed:${this.anonymousId}`, `/checklists/${encodeURIComponent(guide.id)}/progress`, { anonymousId: this.anonymousId, userId: this.user?.id || null, guideVersion: guide.version, completedTaskIds: progress.completedTaskIds, dismissed: true });
2725
3057
  this.checklists.delete(guide.id);
2726
3058
  this.notify();
2727
3059
  }
@@ -2734,7 +3066,7 @@ var GuidelyClient = class {
2734
3066
  this.persistState();
2735
3067
  this.recordEnd(guide, "completed");
2736
3068
  this.enqueue("survey_submitted", { guideId: guide.id, guideVersion: guide.version, properties: { responseId } });
2737
- void postWithRetry(this.api, `/surveys/${encodeURIComponent(guide.id)}/responses`, { anonymousId: this.anonymousId, userId: this.user?.id || null, accountId: this.user?.account?.id || null, guideVersion: guide.version, answers, responseId });
3069
+ this.sendDurably(`survey:${responseId}`, `/surveys/${encodeURIComponent(guide.id)}/responses`, { anonymousId: this.anonymousId, userId: this.user?.id || null, accountId: this.user?.account?.id || null, guideVersion: guide.version, answers, responseId });
2738
3070
  this.emitter.emit("survey:submit", { guide, answers, responseId });
2739
3071
  this.emitter.emit("guide:complete", guide);
2740
3072
  this.surveyView = { ...this.surveyView, submitted: true };
@@ -2791,7 +3123,7 @@ var GuidelyClient = class {
2791
3123
  this.enqueue("checklist_completed", { guideId: guide.id, guideVersion: guide.version });
2792
3124
  }
2793
3125
  this.persistState();
2794
- void postWithRetry(this.api, `/checklists/${encodeURIComponent(guide.id)}/progress`, { anonymousId: this.anonymousId, userId: this.user?.id || null, guideVersion: guide.version, completedTaskIds: progress.completedTaskIds, completed: !!progress.completedAt });
3126
+ this.sendDurably(`checklist:${guide.id}:progress:${this.anonymousId}`, `/checklists/${encodeURIComponent(guide.id)}/progress`, { anonymousId: this.anonymousId, userId: this.user?.id || null, guideVersion: guide.version, completedTaskIds: progress.completedTaskIds, completed: !!progress.completedAt });
2795
3127
  this.emitter.emit("checklist:task", { guide, task, progress: this.getChecklistProgress(guide.id) });
2796
3128
  if (complete) {
2797
3129
  this.emitter.emit("checklist:complete", guide);
@@ -2928,7 +3260,8 @@ var GuidelyClient = class {
2928
3260
  },
2929
3261
  onMessage: (message, conversation) => this.emitter.emit("conversation:message", { message, conversation }),
2930
3262
  onStartGuide: (key) => this.setTimer(() => this.startGuide(key), 0),
2931
- onError: (err) => this.log("messenger", err instanceof Error ? err.message : err)
3263
+ onError: (err) => this.log("messenger", err instanceof Error ? err.message : err),
3264
+ health: this.api.health
2932
3265
  });
2933
3266
  this.resourceCenterOpen = false;
2934
3267
  }
@@ -3033,6 +3366,7 @@ var GuidelyClient = class {
3033
3366
  audienceIds: this.state.audienceIds,
3034
3367
  session: this.session,
3035
3368
  pendingEvents: this.queue?.size ?? 0,
3369
+ pendingWrites: this.outbox?.size ?? 0,
3036
3370
  targets: this.targets.ids(),
3037
3371
  flagsReady: this.flags.isReady()
3038
3372
  };