@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.cjs CHANGED
@@ -614,51 +614,108 @@ function isArticleListed(collections, article, member) {
614
614
  }
615
615
  var CAPTURE_SCREENSHOT_MAX_BYTES = 8 * 1024 * 1024;
616
616
 
617
- // ../sdk-core/dist/chunk-CYAG6ZYX.js
618
- function readJson(store, key, fallback) {
619
- const raw = store.get(key);
620
- if (raw == null) return fallback;
621
- try {
622
- return JSON.parse(raw);
623
- } catch {
624
- return fallback;
625
- }
626
- }
627
- function writeJson(store, key, value) {
628
- try {
629
- store.set(key, JSON.stringify(value));
630
- } catch {
631
- }
632
- }
633
- var KEYS = {
634
- anon: "guidely:anon",
635
- seen: "guidely:seen",
636
- config: (key) => `guidely:config:${key}`,
637
- etag: (key) => `guidely:etag:${key}`,
638
- state: (key) => `guidely:state:${key}`,
639
- user: (key) => `guidely:user:${key}`,
640
- queue: (key) => `guidely:queue:${key}`,
641
- session: "guidely:session",
642
- active: "guidely:active",
643
- authoring: "guidely:authoring",
644
- /** How-to capture session marker ({ token, … }) and its not-yet-uploaded steps. */
645
- capture: "guidely:capture",
646
- capturePending: "guidely:capture:pending",
647
- /** Messenger conversations this browser can read: [{ id, token }]. */
648
- conversations: (key) => `guidely:conversations:${key}`,
649
- /** Announcement ids opened in the messenger's News tab. */
650
- newsSeen: (key) => `guidely:news:${key}`
651
- };
652
-
653
- // ../sdk-core/dist/chunk-AKNE7D5M.js
617
+ // ../sdk-core/dist/chunk-C4WWSF67.js
654
618
  var __defProp2 = Object.defineProperty;
655
619
  var __defNormalProp2 = (obj, key, value) => key in obj ? __defProp2(obj, key, { enumerable: true, configurable: true, writable: true, value }) : obj[key] = value;
656
620
  var __publicField2 = (obj, key, value) => __defNormalProp2(obj, typeof key !== "symbol" ? key + "" : key, value);
621
+ var MAX_RETRY_AFTER_MS = 60 * 60 * 1e3;
622
+ var OFFLINE_WAIT_MS = 3e4;
623
+ var ServerHealth = class {
624
+ constructor(opts = {}) {
625
+ __publicField2(this, "failures", 0);
626
+ __publicField2(this, "openUntil", 0);
627
+ __publicField2(this, "lastOkAt", 0);
628
+ __publicField2(this, "listeners", /* @__PURE__ */ new Set());
629
+ __publicField2(this, "threshold");
630
+ __publicField2(this, "baseMs");
631
+ __publicField2(this, "maxMs");
632
+ __publicField2(this, "now");
633
+ __publicField2(this, "random");
634
+ this.threshold = opts.threshold ?? 3;
635
+ this.baseMs = opts.baseMs ?? 5e3;
636
+ this.maxMs = opts.maxMs ?? 5 * 60 * 1e3;
637
+ this.now = opts.now ?? Date.now;
638
+ this.random = opts.random ?? Math.random;
639
+ }
640
+ /** The server answered (2xx, 3xx, or a 4xx other than 429): it is up. */
641
+ success() {
642
+ const wasDown = this.failures > 0 || this.openUntil > 0;
643
+ this.failures = 0;
644
+ this.openUntil = 0;
645
+ this.lastOkAt = this.now();
646
+ if (wasDown) this.notify();
647
+ }
648
+ /** The request failed or the server is overloaded. `retryAfterMs` comes from a `Retry-After` header. */
649
+ failure(retryAfterMs) {
650
+ this.failures += 1;
651
+ const now = this.now();
652
+ if (retryAfterMs != null && retryAfterMs > 0) {
653
+ this.openUntil = Math.max(this.openUntil, now + Math.min(retryAfterMs, MAX_RETRY_AFTER_MS));
654
+ return;
655
+ }
656
+ if (this.failures < this.threshold) return;
657
+ const cooldown = Math.min(this.maxMs, this.baseMs * 2 ** (this.failures - this.threshold));
658
+ this.openUntil = Math.max(this.openUntil, now + cooldown * (0.8 + this.random() * 0.4));
659
+ }
660
+ /** Milliseconds background traffic should wait before trying again (0: send now). */
661
+ waitMs() {
662
+ if (typeof navigator !== "undefined" && navigator.onLine === false) return OFFLINE_WAIT_MS;
663
+ return Math.max(0, this.openUntil - this.now());
664
+ }
665
+ canSend() {
666
+ return this.waitMs() === 0;
667
+ }
668
+ /**
669
+ * The last request got an answer. Unload beacons can't report back, so what they carry is only
670
+ * forgotten while this holds; otherwise it stays queued and is sent again (the server drops duplicates).
671
+ */
672
+ get reachable() {
673
+ return this.failures === 0 && this.lastOkAt > 0;
674
+ }
675
+ /** The browser came back online: end the cooldown and let queued traffic try straight away. */
676
+ online() {
677
+ this.openUntil = 0;
678
+ this.notify();
679
+ }
680
+ /** Called when the server answers again after failures, or the browser comes back online. */
681
+ onRecover(cb) {
682
+ this.listeners.add(cb);
683
+ return () => this.listeners.delete(cb);
684
+ }
685
+ notify() {
686
+ for (const cb of Array.from(this.listeners)) {
687
+ try {
688
+ cb();
689
+ } catch {
690
+ }
691
+ }
692
+ }
693
+ };
694
+ function parseRetryAfter(value, now = Date.now()) {
695
+ if (!value) return null;
696
+ const trimmed = value.trim();
697
+ if (/^\d+$/.test(trimmed)) return Number(trimmed) * 1e3;
698
+ const at = Date.parse(trimmed);
699
+ return Number.isNaN(at) ? null : Math.max(0, at - now);
700
+ }
701
+ function isServerFailure(status) {
702
+ return status === 429 || status >= 500;
703
+ }
704
+ var STREAM_RESPONSE_TIMEOUT_MS = 3e4;
705
+ var STREAM_IDLE_TIMEOUT_MS = 6e4;
657
706
  var Api = class {
658
- constructor(baseUrl, key, fetchImpl) {
707
+ constructor(baseUrl, key, fetchImpl, health) {
659
708
  __publicField2(this, "baseUrl", baseUrl);
660
709
  __publicField2(this, "key", key);
661
710
  __publicField2(this, "fetchImpl", fetchImpl);
711
+ __publicField2(this, "health");
712
+ this.health = health ?? new ServerHealth();
713
+ }
714
+ /** Feeds an answer (or a failed request, `null`) into {@link health}. */
715
+ observe(res) {
716
+ if (!res) this.health.failure();
717
+ else if (isServerFailure(res.status)) this.health.failure(parseRetryAfter(res.headers?.get?.("retry-after")));
718
+ else this.health.success();
662
719
  }
663
720
  url(path, params = {}) {
664
721
  const query = Object.entries({ key: this.key, ...params }).filter((e) => e[1] != null).map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(v)}`).join("&");
@@ -678,15 +735,22 @@ var Api = class {
678
735
  const ac = typeof AbortController !== "undefined" ? new AbortController() : null;
679
736
  const timer = ac ? setTimeout(() => ac.abort(), options.timeoutMs ?? 1e4) : null;
680
737
  try {
681
- const res = await f(this.url(path, options.params), {
682
- method,
683
- headers,
684
- body: options.body === void 0 ? void 0 : form ? options.body : JSON.stringify(options.body),
685
- keepalive: options.keepalive,
686
- signal: ac?.signal,
687
- credentials: "omit",
688
- mode: "cors"
689
- });
738
+ let res;
739
+ try {
740
+ res = await f(this.url(path, options.params), {
741
+ method,
742
+ headers,
743
+ body: options.body === void 0 ? void 0 : form ? options.body : JSON.stringify(options.body),
744
+ keepalive: options.keepalive,
745
+ signal: ac?.signal,
746
+ credentials: "omit",
747
+ mode: "cors"
748
+ });
749
+ } catch (err) {
750
+ this.observe(null);
751
+ throw err;
752
+ }
753
+ this.observe(res);
690
754
  let data = null;
691
755
  if (res.status !== 304 && res.status !== 204) {
692
756
  try {
@@ -712,30 +776,53 @@ var Api = class {
712
776
  }
713
777
  /**
714
778
  * POST that may answer with server-sent events. Each event is passed to `onEvent`; a JSON answer is
715
- * returned instead. No timeout: Scout replies can take a while and the server keeps the stream alive.
779
+ * returned instead. Scout replies can take a while, so there is no overall timeout: the response
780
+ * must start within 30 s and then never go quiet for more than 60 s (the server sends keepalives),
781
+ * otherwise the request is aborted and rejects.
716
782
  */
717
783
  async stream(path, body, onEvent, headers = {}) {
718
784
  const f = this.fetcher;
719
785
  if (!f) throw new Error("fetch is not available");
720
- const res = await f(this.url(path), {
721
- method: "POST",
722
- headers: { ...headers, "X-Guidely-Key": this.key, Accept: "text/event-stream, application/json", "Content-Type": "application/json" },
723
- body: JSON.stringify(body),
724
- credentials: "omit",
725
- mode: "cors"
726
- });
727
- const type = res.headers?.get?.("content-type") ?? "";
728
- if (!type.includes("text/event-stream") || !res.body) {
729
- let data = null;
786
+ const ac = typeof AbortController !== "undefined" ? new AbortController() : null;
787
+ let timer = null;
788
+ const arm = (ms) => {
789
+ if (!ac) return;
790
+ if (timer) clearTimeout(timer);
791
+ timer = setTimeout(() => ac.abort(), ms);
792
+ };
793
+ arm(STREAM_RESPONSE_TIMEOUT_MS);
794
+ try {
795
+ let res;
730
796
  try {
731
- data = await res.json();
732
- } catch {
733
- data = null;
797
+ res = await f(this.url(path), {
798
+ method: "POST",
799
+ headers: { ...headers, "X-Guidely-Key": this.key, Accept: "text/event-stream, application/json", "Content-Type": "application/json" },
800
+ body: JSON.stringify(body),
801
+ signal: ac?.signal,
802
+ credentials: "omit",
803
+ mode: "cors"
804
+ });
805
+ } catch (err) {
806
+ this.observe(null);
807
+ throw err;
734
808
  }
735
- return { status: res.status, ok: res.ok, data, etag: null };
809
+ this.observe(res);
810
+ const type = res.headers?.get?.("content-type") ?? "";
811
+ if (!type.includes("text/event-stream") || !res.body) {
812
+ let data = null;
813
+ try {
814
+ data = await res.json();
815
+ } catch {
816
+ data = null;
817
+ }
818
+ return { status: res.status, ok: res.ok, data, etag: null };
819
+ }
820
+ arm(STREAM_IDLE_TIMEOUT_MS);
821
+ await readEventStream(res.body, onEvent, () => arm(STREAM_IDLE_TIMEOUT_MS));
822
+ return { status: res.status, ok: res.ok, data: null, etag: null };
823
+ } finally {
824
+ if (timer) clearTimeout(timer);
736
825
  }
737
- await readEventStream(res.body, onEvent);
738
- return { status: res.status, ok: res.ok, data: null, etag: null };
739
826
  }
740
827
  /** `navigator.sendBeacon` when available (page unload); returns false when it could not be queued. */
741
828
  beacon(path, body) {
@@ -766,7 +853,7 @@ async function withRetry(send, attempts = 3, baseMs = 500) {
766
853
  function postWithRetry(api, path, body, attempts = 3, baseMs = 500) {
767
854
  return withRetry(() => api.post(path, body), attempts, baseMs);
768
855
  }
769
- async function readEventStream(body, onEvent) {
856
+ async function readEventStream(body, onEvent, onChunk) {
770
857
  const reader = body.getReader();
771
858
  const decoder = new TextDecoder();
772
859
  let buffer = "";
@@ -789,6 +876,7 @@ async function readEventStream(body, onEvent) {
789
876
  };
790
877
  for (; ; ) {
791
878
  const { done, value } = await reader.read();
879
+ if (value) onChunk?.();
792
880
  if (value) buffer += decoder.decode(value, { stream: true }).replace(/\r\n?/g, "\n");
793
881
  let sep;
794
882
  while ((sep = buffer.indexOf("\n\n")) >= 0) {
@@ -799,8 +887,44 @@ async function readEventStream(body, onEvent) {
799
887
  }
800
888
  if (buffer.trim()) dispatch(buffer);
801
889
  }
890
+ function readJson(store, key, fallback) {
891
+ const raw = store.get(key);
892
+ if (raw == null) return fallback;
893
+ try {
894
+ return JSON.parse(raw);
895
+ } catch {
896
+ return fallback;
897
+ }
898
+ }
899
+ function writeJson(store, key, value) {
900
+ try {
901
+ store.set(key, JSON.stringify(value));
902
+ } catch {
903
+ }
904
+ }
905
+ var KEYS = {
906
+ anon: "guidely:anon",
907
+ seen: "guidely:seen",
908
+ config: (key) => `guidely:config:${key}`,
909
+ etag: (key) => `guidely:etag:${key}`,
910
+ state: (key) => `guidely:state:${key}`,
911
+ user: (key) => `guidely:user:${key}`,
912
+ queue: (key) => `guidely:queue:${key}`,
913
+ /** Survey answers and guide/checklist progress not yet accepted by the server. */
914
+ outbox: (key) => `guidely:outbox:${key}`,
915
+ session: "guidely:session",
916
+ active: "guidely:active",
917
+ authoring: "guidely:authoring",
918
+ /** How-to capture session marker ({ token, … }) and its not-yet-uploaded steps. */
919
+ capture: "guidely:capture",
920
+ capturePending: "guidely:capture:pending",
921
+ /** Messenger conversations this browser can read: [{ id, token }]. */
922
+ conversations: (key) => `guidely:conversations:${key}`,
923
+ /** Announcement ids opened in the messenger's News tab. */
924
+ newsSeen: (key) => `guidely:news:${key}`
925
+ };
802
926
 
803
- // ../sdk-core/dist/chunk-544KSOC3.js
927
+ // ../sdk-core/dist/chunk-GDJB466J.js
804
928
  var EventQueue = class {
805
929
  constructor(opts) {
806
930
  __publicField2(this, "opts", opts);
@@ -811,17 +935,34 @@ var EventQueue = class {
811
935
  __publicField2(this, "inflightIds", /* @__PURE__ */ new Set());
812
936
  __publicField2(this, "attempt", 0);
813
937
  __publicField2(this, "destroyed", false);
938
+ __publicField2(this, "offRecover", null);
814
939
  __publicField2(this, "flushIntervalMs");
815
940
  __publicField2(this, "maxQueue");
816
941
  __publicField2(this, "batchSize");
817
942
  __publicField2(this, "maxAttempts");
818
943
  this.flushIntervalMs = opts.flushIntervalMs ?? 2e3;
819
- this.maxQueue = opts.maxQueue ?? 200;
944
+ this.maxQueue = opts.maxQueue ?? 500;
820
945
  this.batchSize = opts.batchSize ?? 50;
821
946
  this.maxAttempts = opts.maxAttempts ?? 5;
822
947
  const persisted = readJson(opts.store, opts.storageKey, []);
823
948
  if (Array.isArray(persisted)) this.events = persisted.filter((e) => e && typeof e.eventId === "string").slice(-this.maxQueue);
824
949
  if (this.events.length) this.schedule();
950
+ this.offRecover = opts.health?.onRecover(() => this.retryNow()) ?? null;
951
+ }
952
+ /** The API is back: drop any pending backoff and send what is queued. */
953
+ retryNow() {
954
+ if (this.destroyed) return;
955
+ if (this.retryTimer) clearTimeout(this.retryTimer);
956
+ this.retryTimer = null;
957
+ this.attempt = 0;
958
+ void this.flush();
959
+ }
960
+ retryIn(ms) {
961
+ if (this.retryTimer || this.destroyed) return;
962
+ this.retryTimer = setTimeout(() => {
963
+ this.retryTimer = null;
964
+ void this.flush();
965
+ }, ms);
825
966
  }
826
967
  get size() {
827
968
  return this.events.length;
@@ -855,6 +996,8 @@ var EventQueue = class {
855
996
  clearTimeout(this.timer);
856
997
  this.timer = null;
857
998
  }
999
+ const wait = this.opts.health?.waitMs() ?? 0;
1000
+ if (wait > 0) return this.retryIn(wait);
858
1001
  this.inflight = true;
859
1002
  const batch = this.events.slice(0, this.batchSize);
860
1003
  this.inflightIds = new Set(batch.map((e) => e.eventId));
@@ -878,26 +1021,20 @@ var EventQueue = class {
878
1021
  if (this.events.length) this.schedule();
879
1022
  return;
880
1023
  }
881
- this.attempt += 1;
882
- if (this.attempt >= this.maxAttempts) {
883
- this.attempt = 0;
884
- return;
885
- }
886
- this.retryTimer = setTimeout(() => {
887
- this.retryTimer = null;
888
- void this.flush();
889
- }, backoffMs(this.attempt - 1));
1024
+ this.attempt = Math.min(this.attempt + 1, this.maxAttempts);
1025
+ const delay = this.attempt >= this.maxAttempts ? 6e4 : backoffMs(this.attempt - 1);
1026
+ this.retryIn(Math.max(delay, this.opts.health?.waitMs() ?? 0));
890
1027
  }
891
1028
  /** Best-effort synchronous flush on unload using `sendBeacon`. */
892
1029
  flushSync() {
893
1030
  if (this.destroyed || !this.opts.enabled() || !this.opts.beacon) return;
894
1031
  const batch = this.events.filter((e) => !this.inflightIds.has(e.eventId)).slice(0, this.batchSize);
895
1032
  if (!batch.length) return;
896
- if (this.opts.beacon(batch)) {
1033
+ if (this.opts.beacon(batch) && (this.opts.health?.reachable ?? true)) {
897
1034
  const ids = new Set(batch.map((e) => e.eventId));
898
1035
  this.events = this.events.filter((e) => !ids.has(e.eventId));
899
- this.persist();
900
1036
  }
1037
+ this.persist();
901
1038
  }
902
1039
  clear() {
903
1040
  this.events = [];
@@ -905,6 +1042,7 @@ var EventQueue = class {
905
1042
  }
906
1043
  destroy() {
907
1044
  this.destroyed = true;
1045
+ this.offRecover?.();
908
1046
  if (this.timer) clearTimeout(this.timer);
909
1047
  if (this.retryTimer) clearTimeout(this.retryTimer);
910
1048
  this.timer = null;
@@ -912,6 +1050,88 @@ var EventQueue = class {
912
1050
  this.persist();
913
1051
  }
914
1052
  };
1053
+ var Outbox = class {
1054
+ constructor(opts) {
1055
+ __publicField2(this, "opts", opts);
1056
+ __publicField2(this, "items");
1057
+ __publicField2(this, "sending", false);
1058
+ __publicField2(this, "attempt", 0);
1059
+ __publicField2(this, "timer", null);
1060
+ __publicField2(this, "destroyed", false);
1061
+ __publicField2(this, "offRecover");
1062
+ __publicField2(this, "maxItems");
1063
+ __publicField2(this, "maxAgeMs");
1064
+ this.maxItems = opts.maxItems ?? 50;
1065
+ this.maxAgeMs = opts.maxAgeMs ?? 7 * 24 * 60 * 60 * 1e3;
1066
+ const stored = readJson(opts.store, opts.storageKey, []);
1067
+ const cutoff = Date.now() - this.maxAgeMs;
1068
+ this.items = Array.isArray(stored) ? stored.filter((i) => i && typeof i.path === "string" && typeof i.key === "string" && i.createdAt > cutoff).slice(-this.maxItems) : [];
1069
+ this.offRecover = opts.health?.onRecover(() => {
1070
+ this.attempt = 0;
1071
+ this.retryIn(0);
1072
+ }) ?? null;
1073
+ if (this.items.length) this.retryIn(0);
1074
+ }
1075
+ get size() {
1076
+ return this.items.length;
1077
+ }
1078
+ /** Queues `POST path` and sends it now (or as soon as the API is reachable). */
1079
+ add(key, path, body) {
1080
+ if (this.destroyed) return;
1081
+ this.items = this.items.filter((i) => i.key !== key);
1082
+ this.items.push({ key, path, body, createdAt: Date.now() });
1083
+ if (this.items.length > this.maxItems) this.items.splice(0, this.items.length - this.maxItems);
1084
+ this.persist();
1085
+ void this.flush();
1086
+ }
1087
+ persist() {
1088
+ writeJson(this.opts.store, this.opts.storageKey, this.items);
1089
+ }
1090
+ retryIn(ms) {
1091
+ if (this.destroyed) return;
1092
+ if (this.timer) clearTimeout(this.timer);
1093
+ this.timer = setTimeout(() => {
1094
+ this.timer = null;
1095
+ void this.flush();
1096
+ }, ms);
1097
+ }
1098
+ /** Sends queued requests in order, one at a time. */
1099
+ async flush() {
1100
+ if (this.destroyed || this.sending || !this.items.length) return;
1101
+ const wait = this.opts.health?.waitMs() ?? 0;
1102
+ if (wait > 0) return this.retryIn(wait);
1103
+ this.sending = true;
1104
+ try {
1105
+ while (this.items.length && !this.destroyed) {
1106
+ const item = this.items[0];
1107
+ let retry = false;
1108
+ try {
1109
+ const res = await this.opts.api.post(item.path, item.body);
1110
+ retry = !res.ok && (res.status === 429 || res.status >= 500);
1111
+ } catch (err) {
1112
+ retry = true;
1113
+ this.opts.onError?.(err);
1114
+ }
1115
+ if (retry) {
1116
+ this.attempt += 1;
1117
+ this.retryIn(Math.max(backoffMs(this.attempt - 1, 1e3, 6e4), this.opts.health?.waitMs() ?? 0));
1118
+ return;
1119
+ }
1120
+ this.attempt = 0;
1121
+ this.items = this.items.filter((i) => i !== item);
1122
+ this.persist();
1123
+ }
1124
+ } finally {
1125
+ this.sending = false;
1126
+ }
1127
+ }
1128
+ destroy() {
1129
+ this.destroyed = true;
1130
+ this.offRecover?.();
1131
+ if (this.timer) clearTimeout(this.timer);
1132
+ this.timer = null;
1133
+ }
1134
+ };
915
1135
  var Emitter = class {
916
1136
  constructor(onError) {
917
1137
  __publicField2(this, "onError", onError);
@@ -1467,12 +1687,29 @@ function createSseParser(onEvent) {
1467
1687
  }
1468
1688
  };
1469
1689
  }
1690
+ var STREAM_RESPONSE_TIMEOUT_MS2 = 3e4;
1691
+ var STREAM_IDLE_TIMEOUT_MS2 = 6e4;
1470
1692
  function xhrStreamPost() {
1471
1693
  if (typeof XMLHttpRequest === "undefined") return null;
1472
1694
  return (url, headers, body, onChunk) => new Promise((resolve, reject) => {
1473
1695
  const xhr = new XMLHttpRequest();
1474
1696
  let seen = 0;
1475
1697
  let streamed = false;
1698
+ let timer = null;
1699
+ let settled = false;
1700
+ const settle = (fn) => {
1701
+ if (settled) return;
1702
+ settled = true;
1703
+ if (timer) clearTimeout(timer);
1704
+ fn();
1705
+ };
1706
+ const arm = (ms, what) => {
1707
+ if (timer) clearTimeout(timer);
1708
+ timer = setTimeout(() => {
1709
+ settle(() => reject(new Error(`Scout ${what} timed out`)));
1710
+ xhr.abort();
1711
+ }, ms);
1712
+ };
1476
1713
  const pump = () => {
1477
1714
  const type = xhr.getResponseHeader("content-type") || "";
1478
1715
  if (!type.includes("text/event-stream")) return;
@@ -1485,8 +1722,15 @@ function xhrStreamPost() {
1485
1722
  };
1486
1723
  xhr.open("POST", url);
1487
1724
  for (const [k, v] of Object.entries(headers)) xhr.setRequestHeader(k, v);
1488
- xhr.onprogress = pump;
1489
- xhr.onload = () => {
1725
+ xhr.onreadystatechange = () => {
1726
+ if (!settled && xhr.readyState >= 2 && xhr.readyState < 4) arm(STREAM_IDLE_TIMEOUT_MS2, "reply");
1727
+ };
1728
+ xhr.onprogress = () => {
1729
+ if (settled) return;
1730
+ arm(STREAM_IDLE_TIMEOUT_MS2, "reply");
1731
+ pump();
1732
+ };
1733
+ xhr.onload = () => settle(() => {
1490
1734
  pump();
1491
1735
  let json = null;
1492
1736
  if (!streamed) {
@@ -1497,27 +1741,74 @@ function xhrStreamPost() {
1497
1741
  }
1498
1742
  }
1499
1743
  resolve({ status: xhr.status, json, streamed });
1500
- };
1501
- xhr.onerror = () => reject(new Error("Network request failed"));
1744
+ });
1745
+ xhr.onerror = () => settle(() => reject(new Error("Network request failed")));
1746
+ arm(STREAM_RESPONSE_TIMEOUT_MS2, "request");
1502
1747
  xhr.send(JSON.stringify(body));
1503
1748
  });
1504
1749
  }
1750
+ function bodyReader(res) {
1751
+ const body = res.body;
1752
+ const Decoder = globalThis.TextDecoder;
1753
+ return body && typeof body.getReader === "function" && Decoder ? { reader: body.getReader(), decoder: new Decoder() } : null;
1754
+ }
1505
1755
  function fetchStreamPost(fetchImpl) {
1506
1756
  return async (url, headers, body, onChunk) => {
1507
- const res = await fetchImpl(url, { method: "POST", headers, body: JSON.stringify(body) });
1508
- const type = res.headers.get("content-type") || "";
1509
- const text = await res.text();
1510
- if (type.includes("text/event-stream")) {
1511
- onChunk(text);
1512
- return { status: res.status, json: null, streamed: true };
1513
- }
1514
- let json = null;
1757
+ const ac = typeof AbortController !== "undefined" ? new AbortController() : null;
1758
+ let timer = null;
1759
+ let timedOut = "";
1760
+ let fail = () => {
1761
+ };
1762
+ const expired = new Promise((_, reject) => fail = reject);
1763
+ expired.catch(() => {
1764
+ });
1765
+ const arm = (ms, what) => {
1766
+ if (timer) clearTimeout(timer);
1767
+ timer = setTimeout(() => {
1768
+ timedOut = what;
1769
+ ac?.abort();
1770
+ fail(new Error(`Scout ${what} timed out`));
1771
+ }, ms);
1772
+ };
1773
+ const race = (p) => Promise.race([p, expired]);
1774
+ arm(STREAM_RESPONSE_TIMEOUT_MS2, "request");
1515
1775
  try {
1516
- json = JSON.parse(text || "null");
1517
- } catch {
1518
- json = null;
1776
+ const res = await race(fetchImpl(url, { method: "POST", headers, body: JSON.stringify(body), signal: ac?.signal }));
1777
+ arm(STREAM_IDLE_TIMEOUT_MS2, "reply");
1778
+ const type = res.headers.get("content-type") || "";
1779
+ const sse = type.includes("text/event-stream");
1780
+ const streaming = sse ? bodyReader(res) : null;
1781
+ if (streaming) {
1782
+ const { reader, decoder } = streaming;
1783
+ for (; ; ) {
1784
+ const { done, value } = await race(reader.read());
1785
+ if (value) {
1786
+ arm(STREAM_IDLE_TIMEOUT_MS2, "reply");
1787
+ onChunk(decoder.decode(value, { stream: true }));
1788
+ }
1789
+ if (done) break;
1790
+ }
1791
+ const rest = decoder.decode();
1792
+ if (rest) onChunk(rest);
1793
+ return { status: res.status, json: null, streamed: true };
1794
+ }
1795
+ const text = await race(res.text());
1796
+ if (sse) {
1797
+ onChunk(text);
1798
+ return { status: res.status, json: null, streamed: true };
1799
+ }
1800
+ let json = null;
1801
+ try {
1802
+ json = JSON.parse(text || "null");
1803
+ } catch {
1804
+ json = null;
1805
+ }
1806
+ return { status: res.status, json, streamed: false };
1807
+ } catch (err) {
1808
+ throw timedOut ? new Error(`Scout ${timedOut} timed out`) : err;
1809
+ } finally {
1810
+ if (timer) clearTimeout(timer);
1519
1811
  }
1520
- return { status: res.status, json, streamed: false };
1521
1812
  };
1522
1813
  }
1523
1814
  var byNewest = (a, b) => Date.parse(b.lastMessageAt) - Date.parse(a.lastMessageAt);
@@ -1702,7 +1993,15 @@ var Messenger = class {
1702
1993
  }
1703
1994
  });
1704
1995
  try {
1705
- const res = await this.deps.streamPost(this.url(path), headers, payload, (chunk) => parser.feed(chunk));
1996
+ let res;
1997
+ try {
1998
+ res = await this.deps.streamPost(this.url(path), headers, payload, (chunk) => parser.feed(chunk));
1999
+ } catch (err) {
2000
+ this.deps.health?.failure();
2001
+ throw err;
2002
+ }
2003
+ if (isServerFailure(res.status)) this.deps.health?.failure();
2004
+ else this.deps.health?.success();
1706
2005
  parser.end();
1707
2006
  if (res.status < 200 || res.status >= 300) throw new Error(`Request failed (${res.status})`);
1708
2007
  if (!res.streamed && res.json) {
@@ -1889,9 +2188,20 @@ var PersistentStore = class {
1889
2188
 
1890
2189
  // src/core/client.ts
1891
2190
  var SDK_NAME = "@guidelytech/react-native";
1892
- var SDK_VERSION = "0.9.0";
2191
+ var SDK_VERSION = "0.10.0";
1893
2192
  var DEFAULT_API_URL = "https://guidelytech.com";
1894
2193
  var PAGE_SCOPED = /* @__PURE__ */ new Set(["page_load", "url_match", "element_click", "element_visible", "delay"]);
2194
+ var CACHED_START_MS = 1500;
2195
+ function settlesWithin(promise, ms) {
2196
+ return new Promise((resolve) => {
2197
+ const timer = setTimeout(() => resolve(false), ms);
2198
+ const done = () => {
2199
+ clearTimeout(timer);
2200
+ resolve(true);
2201
+ };
2202
+ promise.then(done, done);
2203
+ });
2204
+ }
1895
2205
  var nowIso = () => (/* @__PURE__ */ new Date()).toISOString();
1896
2206
  var emptyState = () => ({ guides: {}, checklists: {}, surveysSubmitted: [], audienceIds: [] });
1897
2207
  var newSession = () => ({ id: `s_${randomId(16)}`, startedAt: nowIso(), lastActiveAt: nowIso(), shownGuides: [], events: [] });
@@ -1904,6 +2214,8 @@ var GuidelyClient = class {
1904
2214
  __publicField(this, "store");
1905
2215
  __publicField(this, "emitter");
1906
2216
  __publicField(this, "queue", null);
2217
+ /** Survey answers, guide completions/dismissals and checklist progress, kept until the server has them. */
2218
+ __publicField(this, "outbox", null);
1907
2219
  __publicField(this, "config", null);
1908
2220
  __publicField(this, "configFetchedAt", 0);
1909
2221
  __publicField(this, "etag", null);
@@ -1966,7 +2278,7 @@ var GuidelyClient = class {
1966
2278
  this.emitter = new Emitter((err) => this.reportError(err));
1967
2279
  this.api = new Api(this.options.apiUrl, this.options.projectId, options.fetch);
1968
2280
  this.store = new PersistentStore(options.storage ?? null, (err) => this.log("storage error", err));
1969
- if (!options.storage) this.log("no `storage` passed: identity and guide history last only for this app session. Pass AsyncStorage to persist them.");
2281
+ 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.");
1970
2282
  this.cleanups.push(this.targets.onTap((id) => this.handleTap(id)));
1971
2283
  if (options.user) this.user = { ...options.user };
1972
2284
  this.flags = this.createFlags(options.flags);
@@ -2017,7 +2329,7 @@ var GuidelyClient = class {
2017
2329
  async bootstrap() {
2018
2330
  const key = this.options.projectId;
2019
2331
  try {
2020
- 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)]);
2332
+ 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)]);
2021
2333
  if (this.destroyed) return;
2022
2334
  this.anonymousId = this.store.get(KEYS.anon) || "";
2023
2335
  if (!this.anonymousId) {
@@ -2037,22 +2349,27 @@ var GuidelyClient = class {
2037
2349
  store: this.store,
2038
2350
  storageKey: KEYS.queue(key),
2039
2351
  enabled: () => this.analyticsEnabled(),
2352
+ health: this.api.health,
2040
2353
  send: async (events) => {
2041
2354
  const res = await this.api.post("/events", { events, sentAt: nowIso(), sdk: { name: SDK_NAME, version: SDK_VERSION } });
2042
2355
  return { ok: res.ok, retry: !(res.status >= 400 && res.status < 500 && res.status !== 429) };
2043
2356
  },
2044
2357
  onError: (err) => this.reportError(err)
2045
2358
  });
2359
+ this.outbox = new Outbox({ api: this.api, store: this.store, storageKey: KEYS.outbox(key), health: this.api.health });
2046
2360
  this.hydrated = true;
2047
2361
  const cached = readJson(this.store, KEYS.config(key), null);
2048
2362
  this.etag = this.store.get(KEYS.etag(key));
2049
2363
  if (cached && Array.isArray(cached.guides)) this.config = forPlatform(cached, this.bridge.device.platform);
2050
2364
  this.syncMessenger();
2051
2365
  this.pending.splice(0).forEach((fn) => fn());
2052
- await this.fetchConfig();
2366
+ const before = this.config?.configVersion;
2367
+ const fresh = this.fetchConfig();
2368
+ if (!this.config) await fresh;
2369
+ else if (!await settlesWithin(fresh, CACHED_START_MS)) void fresh.then(() => this.applyConfigChange(before));
2053
2370
  if (this.destroyed) return;
2054
2371
  this.wireTimers();
2055
- if (this.user?.id) await this.identify(this.user.id, this.user.traits, this.user.account);
2372
+ if (this.user?.id) await settlesWithin(this.identify(this.user.id, this.user.traits, this.user.account), CACHED_START_MS);
2056
2373
  if (this.destroyed) return;
2057
2374
  this.isReady = true;
2058
2375
  if (this.config) this.emitter.emit("ready", this.config);
@@ -2092,7 +2409,9 @@ var GuidelyClient = class {
2092
2409
  }
2093
2410
  }
2094
2411
  wireTimers() {
2095
- const refresh = setInterval(() => void this.refresh(), this.options.configRefreshMs);
2412
+ const refresh = setInterval(() => {
2413
+ if (this.api.health.canSend()) void this.refresh();
2414
+ }, this.options.configRefreshMs);
2096
2415
  this.cleanups.push(() => clearInterval(refresh));
2097
2416
  const poll = setInterval(() => {
2098
2417
  if (!this.isReady || !this.config || this.backgroundedAt !== null) return;
@@ -2108,6 +2427,11 @@ var GuidelyClient = class {
2108
2427
  if (this.destroyed) return;
2109
2428
  const before = this.config?.configVersion;
2110
2429
  await this.fetchConfig();
2430
+ this.applyConfigChange(before);
2431
+ }
2432
+ /** Re-renders after a config change (refresh, or a config that arrived after start). */
2433
+ applyConfigChange(before) {
2434
+ if (this.destroyed || !this.isReady) return;
2111
2435
  if (this.config && this.config.configVersion !== before) {
2112
2436
  this.notify();
2113
2437
  this.evaluate("ready");
@@ -2121,6 +2445,7 @@ var GuidelyClient = class {
2121
2445
  this.session.lastActiveAt = nowIso();
2122
2446
  this.persistSession();
2123
2447
  void this.queue?.flush();
2448
+ void this.outbox?.flush();
2124
2449
  void this.store.flush();
2125
2450
  void this.flags.flush();
2126
2451
  this.messenger?.setForeground(false);
@@ -2134,7 +2459,7 @@ var GuidelyClient = class {
2134
2459
  this.firedDelays.clear();
2135
2460
  }
2136
2461
  void this.flags.refresh();
2137
- if (Date.now() - this.configFetchedAt > this.options.configRefreshMs) void this.refresh();
2462
+ if (Date.now() - this.configFetchedAt > this.options.configRefreshMs && this.api.health.canSend()) void this.refresh();
2138
2463
  else if (this.isReady) this.evaluate("ready");
2139
2464
  }
2140
2465
  }
@@ -2160,6 +2485,7 @@ var GuidelyClient = class {
2160
2485
  this.cleanups.splice(0).forEach((fn) => fn());
2161
2486
  void this.queue?.flush();
2162
2487
  this.queue?.destroy();
2488
+ this.outbox?.destroy();
2163
2489
  this.flags.destroy();
2164
2490
  this.emitter.clear();
2165
2491
  this.uiListeners.clear();
@@ -2573,7 +2899,13 @@ var GuidelyClient = class {
2573
2899
  if (reason === "completed") this.completeChecklistTasksBy("guide_completed", guide);
2574
2900
  }
2575
2901
  syncGuideState(guideId, update) {
2576
- void postWithRetry(this.api, "/guide-state", { anonymousId: this.anonymousId, userId: this.user?.id || null, updates: [{ guideId, ...update }] });
2902
+ const body = { anonymousId: this.anonymousId, userId: this.user?.id || null, updates: [{ guideId, ...update }] };
2903
+ if (update.impression) void postWithRetry(this.api, "/guide-state", body);
2904
+ else this.sendDurably(`guide:${guideId}:${update.completed ? "completed" : "dismissed"}:${this.anonymousId}`, "/guide-state", body);
2905
+ }
2906
+ /** Queues a write in the persisted outbox (sent now, or once the API is reachable again). */
2907
+ sendDurably(key, path, body) {
2908
+ this.whenHydrated(() => this.outbox?.add(key, path, body));
2577
2909
  }
2578
2910
  show(guide, manual, stepId) {
2579
2911
  try {
@@ -2781,7 +3113,7 @@ var GuidelyClient = class {
2781
3113
  progress.dismissedAt = nowIso();
2782
3114
  this.persistState();
2783
3115
  this.enqueue("checklist_dismissed", { guideId: guide.id, guideVersion: guide.version });
2784
- 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 });
3116
+ 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 });
2785
3117
  this.checklists.delete(guide.id);
2786
3118
  this.notify();
2787
3119
  }
@@ -2794,7 +3126,7 @@ var GuidelyClient = class {
2794
3126
  this.persistState();
2795
3127
  this.recordEnd(guide, "completed");
2796
3128
  this.enqueue("survey_submitted", { guideId: guide.id, guideVersion: guide.version, properties: { responseId } });
2797
- 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 });
3129
+ 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 });
2798
3130
  this.emitter.emit("survey:submit", { guide, answers, responseId });
2799
3131
  this.emitter.emit("guide:complete", guide);
2800
3132
  this.surveyView = { ...this.surveyView, submitted: true };
@@ -2851,7 +3183,7 @@ var GuidelyClient = class {
2851
3183
  this.enqueue("checklist_completed", { guideId: guide.id, guideVersion: guide.version });
2852
3184
  }
2853
3185
  this.persistState();
2854
- 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 });
3186
+ 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 });
2855
3187
  this.emitter.emit("checklist:task", { guide, task, progress: this.getChecklistProgress(guide.id) });
2856
3188
  if (complete) {
2857
3189
  this.emitter.emit("checklist:complete", guide);
@@ -2988,7 +3320,8 @@ var GuidelyClient = class {
2988
3320
  },
2989
3321
  onMessage: (message, conversation) => this.emitter.emit("conversation:message", { message, conversation }),
2990
3322
  onStartGuide: (key) => this.setTimer(() => this.startGuide(key), 0),
2991
- onError: (err) => this.log("messenger", err instanceof Error ? err.message : err)
3323
+ onError: (err) => this.log("messenger", err instanceof Error ? err.message : err),
3324
+ health: this.api.health
2992
3325
  });
2993
3326
  this.resourceCenterOpen = false;
2994
3327
  }
@@ -3093,6 +3426,7 @@ var GuidelyClient = class {
3093
3426
  audienceIds: this.state.audienceIds,
3094
3427
  session: this.session,
3095
3428
  pendingEvents: this.queue?.size ?? 0,
3429
+ pendingWrites: this.outbox?.size ?? 0,
3096
3430
  targets: this.targets.ids(),
3097
3431
  flagsReady: this.flags.isReady()
3098
3432
  };