@guidelytech/react-native 0.8.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
@@ -156,7 +156,7 @@ function FlagsProvider({ children, client: external, payload, ...options }) {
156
156
  return /* @__PURE__ */ (0, import_jsx_runtime.jsx)(FlagsContext.Provider, { value, children });
157
157
  }
158
158
 
159
- // ../shared/dist/chunk-OLWGMDRX.js
159
+ // ../shared/dist/chunk-UW6NRYRY.js
160
160
  function fnv1a(input) {
161
161
  let h = 2166136261;
162
162
  for (let i = 0; i < input.length; i++) {
@@ -298,6 +298,8 @@ function resolveField(rule, ctx) {
298
298
  return ctx.userAttributes.role;
299
299
  case "user.plan":
300
300
  return ctx.userAttributes.plan ?? ctx.accountAttributes.plan;
301
+ case "user.identified":
302
+ return !!ctx.userId;
301
303
  case "url.path":
302
304
  return ctx.path;
303
305
  case "url.full":
@@ -460,6 +462,7 @@ function sanitizeProperties(props) {
460
462
  return out;
461
463
  }
462
464
  var SOURCEMAP_MAX_FILE_BYTES = 25 * 1024 * 1024;
465
+ var MAX_COMPLAINT_ATTACHMENT_BYTES = 2 * 1024 * 1024;
463
466
  var MEDIA_IMAGE_MAX_BYTES = 10 * 1024 * 1024;
464
467
  var MEDIA_VIDEO_MAX_BYTES = 200 * 1024 * 1024;
465
468
  var MEDIA_FILE_MAX_BYTES = 50 * 1024 * 1024;
@@ -611,51 +614,108 @@ function isArticleListed(collections, article, member) {
611
614
  }
612
615
  var CAPTURE_SCREENSHOT_MAX_BYTES = 8 * 1024 * 1024;
613
616
 
614
- // ../sdk-core/dist/chunk-CYAG6ZYX.js
615
- function readJson(store, key, fallback) {
616
- const raw = store.get(key);
617
- if (raw == null) return fallback;
618
- try {
619
- return JSON.parse(raw);
620
- } catch {
621
- return fallback;
622
- }
623
- }
624
- function writeJson(store, key, value) {
625
- try {
626
- store.set(key, JSON.stringify(value));
627
- } catch {
628
- }
629
- }
630
- var KEYS = {
631
- anon: "guidely:anon",
632
- seen: "guidely:seen",
633
- config: (key) => `guidely:config:${key}`,
634
- etag: (key) => `guidely:etag:${key}`,
635
- state: (key) => `guidely:state:${key}`,
636
- user: (key) => `guidely:user:${key}`,
637
- queue: (key) => `guidely:queue:${key}`,
638
- session: "guidely:session",
639
- active: "guidely:active",
640
- authoring: "guidely:authoring",
641
- /** How-to capture session marker ({ token, … }) and its not-yet-uploaded steps. */
642
- capture: "guidely:capture",
643
- capturePending: "guidely:capture:pending",
644
- /** Messenger conversations this browser can read: [{ id, token }]. */
645
- conversations: (key) => `guidely:conversations:${key}`,
646
- /** Announcement ids opened in the messenger's News tab. */
647
- newsSeen: (key) => `guidely:news:${key}`
648
- };
649
-
650
- // ../sdk-core/dist/chunk-AKNE7D5M.js
617
+ // ../sdk-core/dist/chunk-C4WWSF67.js
651
618
  var __defProp2 = Object.defineProperty;
652
619
  var __defNormalProp2 = (obj, key, value) => key in obj ? __defProp2(obj, key, { enumerable: true, configurable: true, writable: true, value }) : obj[key] = value;
653
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;
654
706
  var Api = class {
655
- constructor(baseUrl, key, fetchImpl) {
707
+ constructor(baseUrl, key, fetchImpl, health) {
656
708
  __publicField2(this, "baseUrl", baseUrl);
657
709
  __publicField2(this, "key", key);
658
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();
659
719
  }
660
720
  url(path, params = {}) {
661
721
  const query = Object.entries({ key: this.key, ...params }).filter((e) => e[1] != null).map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(v)}`).join("&");
@@ -675,15 +735,22 @@ var Api = class {
675
735
  const ac = typeof AbortController !== "undefined" ? new AbortController() : null;
676
736
  const timer = ac ? setTimeout(() => ac.abort(), options.timeoutMs ?? 1e4) : null;
677
737
  try {
678
- const res = await f(this.url(path, options.params), {
679
- method,
680
- headers,
681
- body: options.body === void 0 ? void 0 : form ? options.body : JSON.stringify(options.body),
682
- keepalive: options.keepalive,
683
- signal: ac?.signal,
684
- credentials: "omit",
685
- mode: "cors"
686
- });
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);
687
754
  let data = null;
688
755
  if (res.status !== 304 && res.status !== 204) {
689
756
  try {
@@ -709,30 +776,53 @@ var Api = class {
709
776
  }
710
777
  /**
711
778
  * POST that may answer with server-sent events. Each event is passed to `onEvent`; a JSON answer is
712
- * 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.
713
782
  */
714
783
  async stream(path, body, onEvent, headers = {}) {
715
784
  const f = this.fetcher;
716
785
  if (!f) throw new Error("fetch is not available");
717
- const res = await f(this.url(path), {
718
- method: "POST",
719
- headers: { ...headers, "X-Guidely-Key": this.key, Accept: "text/event-stream, application/json", "Content-Type": "application/json" },
720
- body: JSON.stringify(body),
721
- credentials: "omit",
722
- mode: "cors"
723
- });
724
- const type = res.headers?.get?.("content-type") ?? "";
725
- if (!type.includes("text/event-stream") || !res.body) {
726
- 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;
727
796
  try {
728
- data = await res.json();
729
- } catch {
730
- 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;
808
+ }
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 };
731
819
  }
732
- return { status: res.status, ok: res.ok, data, etag: null };
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);
733
825
  }
734
- await readEventStream(res.body, onEvent);
735
- return { status: res.status, ok: res.ok, data: null, etag: null };
736
826
  }
737
827
  /** `navigator.sendBeacon` when available (page unload); returns false when it could not be queued. */
738
828
  beacon(path, body) {
@@ -763,7 +853,7 @@ async function withRetry(send, attempts = 3, baseMs = 500) {
763
853
  function postWithRetry(api, path, body, attempts = 3, baseMs = 500) {
764
854
  return withRetry(() => api.post(path, body), attempts, baseMs);
765
855
  }
766
- async function readEventStream(body, onEvent) {
856
+ async function readEventStream(body, onEvent, onChunk) {
767
857
  const reader = body.getReader();
768
858
  const decoder = new TextDecoder();
769
859
  let buffer = "";
@@ -786,6 +876,7 @@ async function readEventStream(body, onEvent) {
786
876
  };
787
877
  for (; ; ) {
788
878
  const { done, value } = await reader.read();
879
+ if (value) onChunk?.();
789
880
  if (value) buffer += decoder.decode(value, { stream: true }).replace(/\r\n?/g, "\n");
790
881
  let sep;
791
882
  while ((sep = buffer.indexOf("\n\n")) >= 0) {
@@ -796,8 +887,44 @@ async function readEventStream(body, onEvent) {
796
887
  }
797
888
  if (buffer.trim()) dispatch(buffer);
798
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
+ };
799
926
 
800
- // ../sdk-core/dist/chunk-GDF6RCHQ.js
927
+ // ../sdk-core/dist/chunk-GDJB466J.js
801
928
  var EventQueue = class {
802
929
  constructor(opts) {
803
930
  __publicField2(this, "opts", opts);
@@ -808,17 +935,34 @@ var EventQueue = class {
808
935
  __publicField2(this, "inflightIds", /* @__PURE__ */ new Set());
809
936
  __publicField2(this, "attempt", 0);
810
937
  __publicField2(this, "destroyed", false);
938
+ __publicField2(this, "offRecover", null);
811
939
  __publicField2(this, "flushIntervalMs");
812
940
  __publicField2(this, "maxQueue");
813
941
  __publicField2(this, "batchSize");
814
942
  __publicField2(this, "maxAttempts");
815
943
  this.flushIntervalMs = opts.flushIntervalMs ?? 2e3;
816
- this.maxQueue = opts.maxQueue ?? 200;
944
+ this.maxQueue = opts.maxQueue ?? 500;
817
945
  this.batchSize = opts.batchSize ?? 50;
818
946
  this.maxAttempts = opts.maxAttempts ?? 5;
819
947
  const persisted = readJson(opts.store, opts.storageKey, []);
820
948
  if (Array.isArray(persisted)) this.events = persisted.filter((e) => e && typeof e.eventId === "string").slice(-this.maxQueue);
821
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);
822
966
  }
823
967
  get size() {
824
968
  return this.events.length;
@@ -852,6 +996,8 @@ var EventQueue = class {
852
996
  clearTimeout(this.timer);
853
997
  this.timer = null;
854
998
  }
999
+ const wait = this.opts.health?.waitMs() ?? 0;
1000
+ if (wait > 0) return this.retryIn(wait);
855
1001
  this.inflight = true;
856
1002
  const batch = this.events.slice(0, this.batchSize);
857
1003
  this.inflightIds = new Set(batch.map((e) => e.eventId));
@@ -875,26 +1021,20 @@ var EventQueue = class {
875
1021
  if (this.events.length) this.schedule();
876
1022
  return;
877
1023
  }
878
- this.attempt += 1;
879
- if (this.attempt >= this.maxAttempts) {
880
- this.attempt = 0;
881
- return;
882
- }
883
- this.retryTimer = setTimeout(() => {
884
- this.retryTimer = null;
885
- void this.flush();
886
- }, 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));
887
1027
  }
888
1028
  /** Best-effort synchronous flush on unload using `sendBeacon`. */
889
1029
  flushSync() {
890
1030
  if (this.destroyed || !this.opts.enabled() || !this.opts.beacon) return;
891
1031
  const batch = this.events.filter((e) => !this.inflightIds.has(e.eventId)).slice(0, this.batchSize);
892
1032
  if (!batch.length) return;
893
- if (this.opts.beacon(batch)) {
1033
+ if (this.opts.beacon(batch) && (this.opts.health?.reachable ?? true)) {
894
1034
  const ids = new Set(batch.map((e) => e.eventId));
895
1035
  this.events = this.events.filter((e) => !ids.has(e.eventId));
896
- this.persist();
897
1036
  }
1037
+ this.persist();
898
1038
  }
899
1039
  clear() {
900
1040
  this.events = [];
@@ -902,6 +1042,7 @@ var EventQueue = class {
902
1042
  }
903
1043
  destroy() {
904
1044
  this.destroyed = true;
1045
+ this.offRecover?.();
905
1046
  if (this.timer) clearTimeout(this.timer);
906
1047
  if (this.retryTimer) clearTimeout(this.retryTimer);
907
1048
  this.timer = null;
@@ -909,6 +1050,88 @@ var EventQueue = class {
909
1050
  this.persist();
910
1051
  }
911
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
+ };
912
1135
  var Emitter = class {
913
1136
  constructor(onError) {
914
1137
  __publicField2(this, "onError", onError);
@@ -983,6 +1206,10 @@ function isWithinSchedule(guide, now = Date.now()) {
983
1206
  if (s.endAt && now > Date.parse(s.endAt)) return false;
984
1207
  return true;
985
1208
  }
1209
+ function dismissedAfterMerge(local, server) {
1210
+ if (server.dismissResetAt && server.dismissResetAt !== local?.dismissResetAt) return server.dismissedAt ?? null;
1211
+ return local?.dismissedAt ?? server.dismissedAt ?? null;
1212
+ }
986
1213
 
987
1214
  // src/core/client.ts
988
1215
  var import_flags2 = require("@guidelytech/flags");
@@ -1460,12 +1687,29 @@ function createSseParser(onEvent) {
1460
1687
  }
1461
1688
  };
1462
1689
  }
1690
+ var STREAM_RESPONSE_TIMEOUT_MS2 = 3e4;
1691
+ var STREAM_IDLE_TIMEOUT_MS2 = 6e4;
1463
1692
  function xhrStreamPost() {
1464
1693
  if (typeof XMLHttpRequest === "undefined") return null;
1465
1694
  return (url, headers, body, onChunk) => new Promise((resolve, reject) => {
1466
1695
  const xhr = new XMLHttpRequest();
1467
1696
  let seen = 0;
1468
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
+ };
1469
1713
  const pump = () => {
1470
1714
  const type = xhr.getResponseHeader("content-type") || "";
1471
1715
  if (!type.includes("text/event-stream")) return;
@@ -1478,8 +1722,15 @@ function xhrStreamPost() {
1478
1722
  };
1479
1723
  xhr.open("POST", url);
1480
1724
  for (const [k, v] of Object.entries(headers)) xhr.setRequestHeader(k, v);
1481
- xhr.onprogress = pump;
1482
- 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(() => {
1483
1734
  pump();
1484
1735
  let json = null;
1485
1736
  if (!streamed) {
@@ -1490,27 +1741,74 @@ function xhrStreamPost() {
1490
1741
  }
1491
1742
  }
1492
1743
  resolve({ status: xhr.status, json, streamed });
1493
- };
1494
- 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");
1495
1747
  xhr.send(JSON.stringify(body));
1496
1748
  });
1497
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
+ }
1498
1755
  function fetchStreamPost(fetchImpl) {
1499
1756
  return async (url, headers, body, onChunk) => {
1500
- const res = await fetchImpl(url, { method: "POST", headers, body: JSON.stringify(body) });
1501
- const type = res.headers.get("content-type") || "";
1502
- const text = await res.text();
1503
- if (type.includes("text/event-stream")) {
1504
- onChunk(text);
1505
- return { status: res.status, json: null, streamed: true };
1506
- }
1507
- 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");
1508
1775
  try {
1509
- json = JSON.parse(text || "null");
1510
- } catch {
1511
- 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);
1512
1811
  }
1513
- return { status: res.status, json, streamed: false };
1514
1812
  };
1515
1813
  }
1516
1814
  var byNewest = (a, b) => Date.parse(b.lastMessageAt) - Date.parse(a.lastMessageAt);
@@ -1695,7 +1993,15 @@ var Messenger = class {
1695
1993
  }
1696
1994
  });
1697
1995
  try {
1698
- 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();
1699
2005
  parser.end();
1700
2006
  if (res.status < 200 || res.status >= 300) throw new Error(`Request failed (${res.status})`);
1701
2007
  if (!res.streamed && res.json) {
@@ -1882,9 +2188,20 @@ var PersistentStore = class {
1882
2188
 
1883
2189
  // src/core/client.ts
1884
2190
  var SDK_NAME = "@guidelytech/react-native";
1885
- var SDK_VERSION = "0.8.0";
2191
+ var SDK_VERSION = "0.10.0";
1886
2192
  var DEFAULT_API_URL = "https://guidelytech.com";
1887
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
+ }
1888
2205
  var nowIso = () => (/* @__PURE__ */ new Date()).toISOString();
1889
2206
  var emptyState = () => ({ guides: {}, checklists: {}, surveysSubmitted: [], audienceIds: [] });
1890
2207
  var newSession = () => ({ id: `s_${randomId(16)}`, startedAt: nowIso(), lastActiveAt: nowIso(), shownGuides: [], events: [] });
@@ -1897,6 +2214,8 @@ var GuidelyClient = class {
1897
2214
  __publicField(this, "store");
1898
2215
  __publicField(this, "emitter");
1899
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);
1900
2219
  __publicField(this, "config", null);
1901
2220
  __publicField(this, "configFetchedAt", 0);
1902
2221
  __publicField(this, "etag", null);
@@ -1959,7 +2278,7 @@ var GuidelyClient = class {
1959
2278
  this.emitter = new Emitter((err) => this.reportError(err));
1960
2279
  this.api = new Api(this.options.apiUrl, this.options.projectId, options.fetch);
1961
2280
  this.store = new PersistentStore(options.storage ?? null, (err) => this.log("storage error", err));
1962
- 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.");
1963
2282
  this.cleanups.push(this.targets.onTap((id) => this.handleTap(id)));
1964
2283
  if (options.user) this.user = { ...options.user };
1965
2284
  this.flags = this.createFlags(options.flags);
@@ -2010,7 +2329,7 @@ var GuidelyClient = class {
2010
2329
  async bootstrap() {
2011
2330
  const key = this.options.projectId;
2012
2331
  try {
2013
- 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)]);
2014
2333
  if (this.destroyed) return;
2015
2334
  this.anonymousId = this.store.get(KEYS.anon) || "";
2016
2335
  if (!this.anonymousId) {
@@ -2030,22 +2349,27 @@ var GuidelyClient = class {
2030
2349
  store: this.store,
2031
2350
  storageKey: KEYS.queue(key),
2032
2351
  enabled: () => this.analyticsEnabled(),
2352
+ health: this.api.health,
2033
2353
  send: async (events) => {
2034
2354
  const res = await this.api.post("/events", { events, sentAt: nowIso(), sdk: { name: SDK_NAME, version: SDK_VERSION } });
2035
2355
  return { ok: res.ok, retry: !(res.status >= 400 && res.status < 500 && res.status !== 429) };
2036
2356
  },
2037
2357
  onError: (err) => this.reportError(err)
2038
2358
  });
2359
+ this.outbox = new Outbox({ api: this.api, store: this.store, storageKey: KEYS.outbox(key), health: this.api.health });
2039
2360
  this.hydrated = true;
2040
2361
  const cached = readJson(this.store, KEYS.config(key), null);
2041
2362
  this.etag = this.store.get(KEYS.etag(key));
2042
2363
  if (cached && Array.isArray(cached.guides)) this.config = forPlatform(cached, this.bridge.device.platform);
2043
2364
  this.syncMessenger();
2044
2365
  this.pending.splice(0).forEach((fn) => fn());
2045
- 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));
2046
2370
  if (this.destroyed) return;
2047
2371
  this.wireTimers();
2048
- 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);
2049
2373
  if (this.destroyed) return;
2050
2374
  this.isReady = true;
2051
2375
  if (this.config) this.emitter.emit("ready", this.config);
@@ -2085,7 +2409,9 @@ var GuidelyClient = class {
2085
2409
  }
2086
2410
  }
2087
2411
  wireTimers() {
2088
- 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);
2089
2415
  this.cleanups.push(() => clearInterval(refresh));
2090
2416
  const poll = setInterval(() => {
2091
2417
  if (!this.isReady || !this.config || this.backgroundedAt !== null) return;
@@ -2101,6 +2427,11 @@ var GuidelyClient = class {
2101
2427
  if (this.destroyed) return;
2102
2428
  const before = this.config?.configVersion;
2103
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;
2104
2435
  if (this.config && this.config.configVersion !== before) {
2105
2436
  this.notify();
2106
2437
  this.evaluate("ready");
@@ -2114,6 +2445,7 @@ var GuidelyClient = class {
2114
2445
  this.session.lastActiveAt = nowIso();
2115
2446
  this.persistSession();
2116
2447
  void this.queue?.flush();
2448
+ void this.outbox?.flush();
2117
2449
  void this.store.flush();
2118
2450
  void this.flags.flush();
2119
2451
  this.messenger?.setForeground(false);
@@ -2127,7 +2459,7 @@ var GuidelyClient = class {
2127
2459
  this.firedDelays.clear();
2128
2460
  }
2129
2461
  void this.flags.refresh();
2130
- 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();
2131
2463
  else if (this.isReady) this.evaluate("ready");
2132
2464
  }
2133
2465
  }
@@ -2153,6 +2485,7 @@ var GuidelyClient = class {
2153
2485
  this.cleanups.splice(0).forEach((fn) => fn());
2154
2486
  void this.queue?.flush();
2155
2487
  this.queue?.destroy();
2488
+ this.outbox?.destroy();
2156
2489
  this.flags.destroy();
2157
2490
  this.emitter.clear();
2158
2491
  this.uiListeners.clear();
@@ -2200,7 +2533,8 @@ var GuidelyClient = class {
2200
2533
  checklists[id] = {
2201
2534
  completedTaskIds: Array.from(/* @__PURE__ */ new Set([...local?.completedTaskIds ?? [], ...p.completedTaskIds ?? []])),
2202
2535
  completedAt: local?.completedAt ?? p.completedAt ?? null,
2203
- dismissedAt: local?.dismissedAt ?? p.dismissedAt ?? null
2536
+ dismissedAt: dismissedAfterMerge(local, p),
2537
+ dismissResetAt: p.dismissResetAt ?? local?.dismissResetAt ?? null
2204
2538
  };
2205
2539
  }
2206
2540
  this.state = {
@@ -2565,7 +2899,13 @@ var GuidelyClient = class {
2565
2899
  if (reason === "completed") this.completeChecklistTasksBy("guide_completed", guide);
2566
2900
  }
2567
2901
  syncGuideState(guideId, update) {
2568
- 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));
2569
2909
  }
2570
2910
  show(guide, manual, stepId) {
2571
2911
  try {
@@ -2773,7 +3113,7 @@ var GuidelyClient = class {
2773
3113
  progress.dismissedAt = nowIso();
2774
3114
  this.persistState();
2775
3115
  this.enqueue("checklist_dismissed", { guideId: guide.id, guideVersion: guide.version });
2776
- 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 });
2777
3117
  this.checklists.delete(guide.id);
2778
3118
  this.notify();
2779
3119
  }
@@ -2786,7 +3126,7 @@ var GuidelyClient = class {
2786
3126
  this.persistState();
2787
3127
  this.recordEnd(guide, "completed");
2788
3128
  this.enqueue("survey_submitted", { guideId: guide.id, guideVersion: guide.version, properties: { responseId } });
2789
- 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 });
2790
3130
  this.emitter.emit("survey:submit", { guide, answers, responseId });
2791
3131
  this.emitter.emit("guide:complete", guide);
2792
3132
  this.surveyView = { ...this.surveyView, submitted: true };
@@ -2843,7 +3183,7 @@ var GuidelyClient = class {
2843
3183
  this.enqueue("checklist_completed", { guideId: guide.id, guideVersion: guide.version });
2844
3184
  }
2845
3185
  this.persistState();
2846
- 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 });
2847
3187
  this.emitter.emit("checklist:task", { guide, task, progress: this.getChecklistProgress(guide.id) });
2848
3188
  if (complete) {
2849
3189
  this.emitter.emit("checklist:complete", guide);
@@ -2980,7 +3320,8 @@ var GuidelyClient = class {
2980
3320
  },
2981
3321
  onMessage: (message, conversation) => this.emitter.emit("conversation:message", { message, conversation }),
2982
3322
  onStartGuide: (key) => this.setTimer(() => this.startGuide(key), 0),
2983
- 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
2984
3325
  });
2985
3326
  this.resourceCenterOpen = false;
2986
3327
  }
@@ -3085,6 +3426,7 @@ var GuidelyClient = class {
3085
3426
  audienceIds: this.state.audienceIds,
3086
3427
  session: this.session,
3087
3428
  pendingEvents: this.queue?.size ?? 0,
3429
+ pendingWrites: this.outbox?.size ?? 0,
3088
3430
  targets: this.targets.ids(),
3089
3431
  flagsReady: this.flags.isReady()
3090
3432
  };