@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.js CHANGED
@@ -96,7 +96,7 @@ function FlagsProvider({ children, client: external, payload, ...options }) {
96
96
  return /* @__PURE__ */ jsx(FlagsContext.Provider, { value, children });
97
97
  }
98
98
 
99
- // ../shared/dist/chunk-OLWGMDRX.js
99
+ // ../shared/dist/chunk-UW6NRYRY.js
100
100
  function fnv1a(input) {
101
101
  let h = 2166136261;
102
102
  for (let i = 0; i < input.length; i++) {
@@ -238,6 +238,8 @@ function resolveField(rule, ctx) {
238
238
  return ctx.userAttributes.role;
239
239
  case "user.plan":
240
240
  return ctx.userAttributes.plan ?? ctx.accountAttributes.plan;
241
+ case "user.identified":
242
+ return !!ctx.userId;
241
243
  case "url.path":
242
244
  return ctx.path;
243
245
  case "url.full":
@@ -400,6 +402,7 @@ function sanitizeProperties(props) {
400
402
  return out;
401
403
  }
402
404
  var SOURCEMAP_MAX_FILE_BYTES = 25 * 1024 * 1024;
405
+ var MAX_COMPLAINT_ATTACHMENT_BYTES = 2 * 1024 * 1024;
403
406
  var MEDIA_IMAGE_MAX_BYTES = 10 * 1024 * 1024;
404
407
  var MEDIA_VIDEO_MAX_BYTES = 200 * 1024 * 1024;
405
408
  var MEDIA_FILE_MAX_BYTES = 50 * 1024 * 1024;
@@ -551,51 +554,108 @@ function isArticleListed(collections, article, member) {
551
554
  }
552
555
  var CAPTURE_SCREENSHOT_MAX_BYTES = 8 * 1024 * 1024;
553
556
 
554
- // ../sdk-core/dist/chunk-CYAG6ZYX.js
555
- function readJson(store, key, fallback) {
556
- const raw = store.get(key);
557
- if (raw == null) return fallback;
558
- try {
559
- return JSON.parse(raw);
560
- } catch {
561
- return fallback;
562
- }
563
- }
564
- function writeJson(store, key, value) {
565
- try {
566
- store.set(key, JSON.stringify(value));
567
- } catch {
568
- }
569
- }
570
- var KEYS = {
571
- anon: "guidely:anon",
572
- seen: "guidely:seen",
573
- config: (key) => `guidely:config:${key}`,
574
- etag: (key) => `guidely:etag:${key}`,
575
- state: (key) => `guidely:state:${key}`,
576
- user: (key) => `guidely:user:${key}`,
577
- queue: (key) => `guidely:queue:${key}`,
578
- session: "guidely:session",
579
- active: "guidely:active",
580
- authoring: "guidely:authoring",
581
- /** How-to capture session marker ({ token, … }) and its not-yet-uploaded steps. */
582
- capture: "guidely:capture",
583
- capturePending: "guidely:capture:pending",
584
- /** Messenger conversations this browser can read: [{ id, token }]. */
585
- conversations: (key) => `guidely:conversations:${key}`,
586
- /** Announcement ids opened in the messenger's News tab. */
587
- newsSeen: (key) => `guidely:news:${key}`
588
- };
589
-
590
- // ../sdk-core/dist/chunk-AKNE7D5M.js
557
+ // ../sdk-core/dist/chunk-C4WWSF67.js
591
558
  var __defProp2 = Object.defineProperty;
592
559
  var __defNormalProp2 = (obj, key, value) => key in obj ? __defProp2(obj, key, { enumerable: true, configurable: true, writable: true, value }) : obj[key] = value;
593
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;
594
646
  var Api = class {
595
- constructor(baseUrl, key, fetchImpl) {
647
+ constructor(baseUrl, key, fetchImpl, health) {
596
648
  __publicField2(this, "baseUrl", baseUrl);
597
649
  __publicField2(this, "key", key);
598
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();
599
659
  }
600
660
  url(path, params = {}) {
601
661
  const query = Object.entries({ key: this.key, ...params }).filter((e) => e[1] != null).map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(v)}`).join("&");
@@ -615,15 +675,22 @@ var Api = class {
615
675
  const ac = typeof AbortController !== "undefined" ? new AbortController() : null;
616
676
  const timer = ac ? setTimeout(() => ac.abort(), options.timeoutMs ?? 1e4) : null;
617
677
  try {
618
- const res = await f(this.url(path, options.params), {
619
- method,
620
- headers,
621
- body: options.body === void 0 ? void 0 : form ? options.body : JSON.stringify(options.body),
622
- keepalive: options.keepalive,
623
- signal: ac?.signal,
624
- credentials: "omit",
625
- mode: "cors"
626
- });
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);
627
694
  let data = null;
628
695
  if (res.status !== 304 && res.status !== 204) {
629
696
  try {
@@ -649,30 +716,53 @@ var Api = class {
649
716
  }
650
717
  /**
651
718
  * POST that may answer with server-sent events. Each event is passed to `onEvent`; a JSON answer is
652
- * 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.
653
722
  */
654
723
  async stream(path, body, onEvent, headers = {}) {
655
724
  const f = this.fetcher;
656
725
  if (!f) throw new Error("fetch is not available");
657
- const res = await f(this.url(path), {
658
- method: "POST",
659
- headers: { ...headers, "X-Guidely-Key": this.key, Accept: "text/event-stream, application/json", "Content-Type": "application/json" },
660
- body: JSON.stringify(body),
661
- credentials: "omit",
662
- mode: "cors"
663
- });
664
- const type = res.headers?.get?.("content-type") ?? "";
665
- if (!type.includes("text/event-stream") || !res.body) {
666
- 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;
667
736
  try {
668
- data = await res.json();
669
- } catch {
670
- 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;
748
+ }
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 };
671
759
  }
672
- return { status: res.status, ok: res.ok, data, etag: null };
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);
673
765
  }
674
- await readEventStream(res.body, onEvent);
675
- return { status: res.status, ok: res.ok, data: null, etag: null };
676
766
  }
677
767
  /** `navigator.sendBeacon` when available (page unload); returns false when it could not be queued. */
678
768
  beacon(path, body) {
@@ -703,7 +793,7 @@ async function withRetry(send, attempts = 3, baseMs = 500) {
703
793
  function postWithRetry(api, path, body, attempts = 3, baseMs = 500) {
704
794
  return withRetry(() => api.post(path, body), attempts, baseMs);
705
795
  }
706
- async function readEventStream(body, onEvent) {
796
+ async function readEventStream(body, onEvent, onChunk) {
707
797
  const reader = body.getReader();
708
798
  const decoder = new TextDecoder();
709
799
  let buffer = "";
@@ -726,6 +816,7 @@ async function readEventStream(body, onEvent) {
726
816
  };
727
817
  for (; ; ) {
728
818
  const { done, value } = await reader.read();
819
+ if (value) onChunk?.();
729
820
  if (value) buffer += decoder.decode(value, { stream: true }).replace(/\r\n?/g, "\n");
730
821
  let sep;
731
822
  while ((sep = buffer.indexOf("\n\n")) >= 0) {
@@ -736,8 +827,44 @@ async function readEventStream(body, onEvent) {
736
827
  }
737
828
  if (buffer.trim()) dispatch(buffer);
738
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
+ };
739
866
 
740
- // ../sdk-core/dist/chunk-GDF6RCHQ.js
867
+ // ../sdk-core/dist/chunk-GDJB466J.js
741
868
  var EventQueue = class {
742
869
  constructor(opts) {
743
870
  __publicField2(this, "opts", opts);
@@ -748,17 +875,34 @@ var EventQueue = class {
748
875
  __publicField2(this, "inflightIds", /* @__PURE__ */ new Set());
749
876
  __publicField2(this, "attempt", 0);
750
877
  __publicField2(this, "destroyed", false);
878
+ __publicField2(this, "offRecover", null);
751
879
  __publicField2(this, "flushIntervalMs");
752
880
  __publicField2(this, "maxQueue");
753
881
  __publicField2(this, "batchSize");
754
882
  __publicField2(this, "maxAttempts");
755
883
  this.flushIntervalMs = opts.flushIntervalMs ?? 2e3;
756
- this.maxQueue = opts.maxQueue ?? 200;
884
+ this.maxQueue = opts.maxQueue ?? 500;
757
885
  this.batchSize = opts.batchSize ?? 50;
758
886
  this.maxAttempts = opts.maxAttempts ?? 5;
759
887
  const persisted = readJson(opts.store, opts.storageKey, []);
760
888
  if (Array.isArray(persisted)) this.events = persisted.filter((e) => e && typeof e.eventId === "string").slice(-this.maxQueue);
761
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);
762
906
  }
763
907
  get size() {
764
908
  return this.events.length;
@@ -792,6 +936,8 @@ var EventQueue = class {
792
936
  clearTimeout(this.timer);
793
937
  this.timer = null;
794
938
  }
939
+ const wait = this.opts.health?.waitMs() ?? 0;
940
+ if (wait > 0) return this.retryIn(wait);
795
941
  this.inflight = true;
796
942
  const batch = this.events.slice(0, this.batchSize);
797
943
  this.inflightIds = new Set(batch.map((e) => e.eventId));
@@ -815,26 +961,20 @@ var EventQueue = class {
815
961
  if (this.events.length) this.schedule();
816
962
  return;
817
963
  }
818
- this.attempt += 1;
819
- if (this.attempt >= this.maxAttempts) {
820
- this.attempt = 0;
821
- return;
822
- }
823
- this.retryTimer = setTimeout(() => {
824
- this.retryTimer = null;
825
- void this.flush();
826
- }, 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));
827
967
  }
828
968
  /** Best-effort synchronous flush on unload using `sendBeacon`. */
829
969
  flushSync() {
830
970
  if (this.destroyed || !this.opts.enabled() || !this.opts.beacon) return;
831
971
  const batch = this.events.filter((e) => !this.inflightIds.has(e.eventId)).slice(0, this.batchSize);
832
972
  if (!batch.length) return;
833
- if (this.opts.beacon(batch)) {
973
+ if (this.opts.beacon(batch) && (this.opts.health?.reachable ?? true)) {
834
974
  const ids = new Set(batch.map((e) => e.eventId));
835
975
  this.events = this.events.filter((e) => !ids.has(e.eventId));
836
- this.persist();
837
976
  }
977
+ this.persist();
838
978
  }
839
979
  clear() {
840
980
  this.events = [];
@@ -842,6 +982,7 @@ var EventQueue = class {
842
982
  }
843
983
  destroy() {
844
984
  this.destroyed = true;
985
+ this.offRecover?.();
845
986
  if (this.timer) clearTimeout(this.timer);
846
987
  if (this.retryTimer) clearTimeout(this.retryTimer);
847
988
  this.timer = null;
@@ -849,6 +990,88 @@ var EventQueue = class {
849
990
  this.persist();
850
991
  }
851
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
+ };
852
1075
  var Emitter = class {
853
1076
  constructor(onError) {
854
1077
  __publicField2(this, "onError", onError);
@@ -923,6 +1146,10 @@ function isWithinSchedule(guide, now = Date.now()) {
923
1146
  if (s.endAt && now > Date.parse(s.endAt)) return false;
924
1147
  return true;
925
1148
  }
1149
+ function dismissedAfterMerge(local, server) {
1150
+ if (server.dismissResetAt && server.dismissResetAt !== local?.dismissResetAt) return server.dismissedAt ?? null;
1151
+ return local?.dismissedAt ?? server.dismissedAt ?? null;
1152
+ }
926
1153
 
927
1154
  // src/core/client.ts
928
1155
  import { createFlagsClient as createFlagsClient2 } from "@guidelytech/flags";
@@ -1400,12 +1627,29 @@ function createSseParser(onEvent) {
1400
1627
  }
1401
1628
  };
1402
1629
  }
1630
+ var STREAM_RESPONSE_TIMEOUT_MS2 = 3e4;
1631
+ var STREAM_IDLE_TIMEOUT_MS2 = 6e4;
1403
1632
  function xhrStreamPost() {
1404
1633
  if (typeof XMLHttpRequest === "undefined") return null;
1405
1634
  return (url, headers, body, onChunk) => new Promise((resolve, reject) => {
1406
1635
  const xhr = new XMLHttpRequest();
1407
1636
  let seen = 0;
1408
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
+ };
1409
1653
  const pump = () => {
1410
1654
  const type = xhr.getResponseHeader("content-type") || "";
1411
1655
  if (!type.includes("text/event-stream")) return;
@@ -1418,8 +1662,15 @@ function xhrStreamPost() {
1418
1662
  };
1419
1663
  xhr.open("POST", url);
1420
1664
  for (const [k, v] of Object.entries(headers)) xhr.setRequestHeader(k, v);
1421
- xhr.onprogress = pump;
1422
- 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(() => {
1423
1674
  pump();
1424
1675
  let json = null;
1425
1676
  if (!streamed) {
@@ -1430,27 +1681,74 @@ function xhrStreamPost() {
1430
1681
  }
1431
1682
  }
1432
1683
  resolve({ status: xhr.status, json, streamed });
1433
- };
1434
- 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");
1435
1687
  xhr.send(JSON.stringify(body));
1436
1688
  });
1437
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
+ }
1438
1695
  function fetchStreamPost(fetchImpl) {
1439
1696
  return async (url, headers, body, onChunk) => {
1440
- const res = await fetchImpl(url, { method: "POST", headers, body: JSON.stringify(body) });
1441
- const type = res.headers.get("content-type") || "";
1442
- const text = await res.text();
1443
- if (type.includes("text/event-stream")) {
1444
- onChunk(text);
1445
- return { status: res.status, json: null, streamed: true };
1446
- }
1447
- 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");
1448
1715
  try {
1449
- json = JSON.parse(text || "null");
1450
- } catch {
1451
- 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);
1452
1751
  }
1453
- return { status: res.status, json, streamed: false };
1454
1752
  };
1455
1753
  }
1456
1754
  var byNewest = (a, b) => Date.parse(b.lastMessageAt) - Date.parse(a.lastMessageAt);
@@ -1635,7 +1933,15 @@ var Messenger = class {
1635
1933
  }
1636
1934
  });
1637
1935
  try {
1638
- 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();
1639
1945
  parser.end();
1640
1946
  if (res.status < 200 || res.status >= 300) throw new Error(`Request failed (${res.status})`);
1641
1947
  if (!res.streamed && res.json) {
@@ -1822,9 +2128,20 @@ var PersistentStore = class {
1822
2128
 
1823
2129
  // src/core/client.ts
1824
2130
  var SDK_NAME = "@guidelytech/react-native";
1825
- var SDK_VERSION = "0.8.0";
2131
+ var SDK_VERSION = "0.10.0";
1826
2132
  var DEFAULT_API_URL = "https://guidelytech.com";
1827
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
+ }
1828
2145
  var nowIso = () => (/* @__PURE__ */ new Date()).toISOString();
1829
2146
  var emptyState = () => ({ guides: {}, checklists: {}, surveysSubmitted: [], audienceIds: [] });
1830
2147
  var newSession = () => ({ id: `s_${randomId(16)}`, startedAt: nowIso(), lastActiveAt: nowIso(), shownGuides: [], events: [] });
@@ -1837,6 +2154,8 @@ var GuidelyClient = class {
1837
2154
  __publicField(this, "store");
1838
2155
  __publicField(this, "emitter");
1839
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);
1840
2159
  __publicField(this, "config", null);
1841
2160
  __publicField(this, "configFetchedAt", 0);
1842
2161
  __publicField(this, "etag", null);
@@ -1899,7 +2218,7 @@ var GuidelyClient = class {
1899
2218
  this.emitter = new Emitter((err) => this.reportError(err));
1900
2219
  this.api = new Api(this.options.apiUrl, this.options.projectId, options.fetch);
1901
2220
  this.store = new PersistentStore(options.storage ?? null, (err) => this.log("storage error", err));
1902
- 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.");
1903
2222
  this.cleanups.push(this.targets.onTap((id) => this.handleTap(id)));
1904
2223
  if (options.user) this.user = { ...options.user };
1905
2224
  this.flags = this.createFlags(options.flags);
@@ -1950,7 +2269,7 @@ var GuidelyClient = class {
1950
2269
  async bootstrap() {
1951
2270
  const key = this.options.projectId;
1952
2271
  try {
1953
- 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)]);
1954
2273
  if (this.destroyed) return;
1955
2274
  this.anonymousId = this.store.get(KEYS.anon) || "";
1956
2275
  if (!this.anonymousId) {
@@ -1970,22 +2289,27 @@ var GuidelyClient = class {
1970
2289
  store: this.store,
1971
2290
  storageKey: KEYS.queue(key),
1972
2291
  enabled: () => this.analyticsEnabled(),
2292
+ health: this.api.health,
1973
2293
  send: async (events) => {
1974
2294
  const res = await this.api.post("/events", { events, sentAt: nowIso(), sdk: { name: SDK_NAME, version: SDK_VERSION } });
1975
2295
  return { ok: res.ok, retry: !(res.status >= 400 && res.status < 500 && res.status !== 429) };
1976
2296
  },
1977
2297
  onError: (err) => this.reportError(err)
1978
2298
  });
2299
+ this.outbox = new Outbox({ api: this.api, store: this.store, storageKey: KEYS.outbox(key), health: this.api.health });
1979
2300
  this.hydrated = true;
1980
2301
  const cached = readJson(this.store, KEYS.config(key), null);
1981
2302
  this.etag = this.store.get(KEYS.etag(key));
1982
2303
  if (cached && Array.isArray(cached.guides)) this.config = forPlatform(cached, this.bridge.device.platform);
1983
2304
  this.syncMessenger();
1984
2305
  this.pending.splice(0).forEach((fn) => fn());
1985
- 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));
1986
2310
  if (this.destroyed) return;
1987
2311
  this.wireTimers();
1988
- 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);
1989
2313
  if (this.destroyed) return;
1990
2314
  this.isReady = true;
1991
2315
  if (this.config) this.emitter.emit("ready", this.config);
@@ -2025,7 +2349,9 @@ var GuidelyClient = class {
2025
2349
  }
2026
2350
  }
2027
2351
  wireTimers() {
2028
- 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);
2029
2355
  this.cleanups.push(() => clearInterval(refresh));
2030
2356
  const poll = setInterval(() => {
2031
2357
  if (!this.isReady || !this.config || this.backgroundedAt !== null) return;
@@ -2041,6 +2367,11 @@ var GuidelyClient = class {
2041
2367
  if (this.destroyed) return;
2042
2368
  const before = this.config?.configVersion;
2043
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;
2044
2375
  if (this.config && this.config.configVersion !== before) {
2045
2376
  this.notify();
2046
2377
  this.evaluate("ready");
@@ -2054,6 +2385,7 @@ var GuidelyClient = class {
2054
2385
  this.session.lastActiveAt = nowIso();
2055
2386
  this.persistSession();
2056
2387
  void this.queue?.flush();
2388
+ void this.outbox?.flush();
2057
2389
  void this.store.flush();
2058
2390
  void this.flags.flush();
2059
2391
  this.messenger?.setForeground(false);
@@ -2067,7 +2399,7 @@ var GuidelyClient = class {
2067
2399
  this.firedDelays.clear();
2068
2400
  }
2069
2401
  void this.flags.refresh();
2070
- 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();
2071
2403
  else if (this.isReady) this.evaluate("ready");
2072
2404
  }
2073
2405
  }
@@ -2093,6 +2425,7 @@ var GuidelyClient = class {
2093
2425
  this.cleanups.splice(0).forEach((fn) => fn());
2094
2426
  void this.queue?.flush();
2095
2427
  this.queue?.destroy();
2428
+ this.outbox?.destroy();
2096
2429
  this.flags.destroy();
2097
2430
  this.emitter.clear();
2098
2431
  this.uiListeners.clear();
@@ -2140,7 +2473,8 @@ var GuidelyClient = class {
2140
2473
  checklists[id] = {
2141
2474
  completedTaskIds: Array.from(/* @__PURE__ */ new Set([...local?.completedTaskIds ?? [], ...p.completedTaskIds ?? []])),
2142
2475
  completedAt: local?.completedAt ?? p.completedAt ?? null,
2143
- dismissedAt: local?.dismissedAt ?? p.dismissedAt ?? null
2476
+ dismissedAt: dismissedAfterMerge(local, p),
2477
+ dismissResetAt: p.dismissResetAt ?? local?.dismissResetAt ?? null
2144
2478
  };
2145
2479
  }
2146
2480
  this.state = {
@@ -2505,7 +2839,13 @@ var GuidelyClient = class {
2505
2839
  if (reason === "completed") this.completeChecklistTasksBy("guide_completed", guide);
2506
2840
  }
2507
2841
  syncGuideState(guideId, update) {
2508
- 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));
2509
2849
  }
2510
2850
  show(guide, manual, stepId) {
2511
2851
  try {
@@ -2713,7 +3053,7 @@ var GuidelyClient = class {
2713
3053
  progress.dismissedAt = nowIso();
2714
3054
  this.persistState();
2715
3055
  this.enqueue("checklist_dismissed", { guideId: guide.id, guideVersion: guide.version });
2716
- 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 });
2717
3057
  this.checklists.delete(guide.id);
2718
3058
  this.notify();
2719
3059
  }
@@ -2726,7 +3066,7 @@ var GuidelyClient = class {
2726
3066
  this.persistState();
2727
3067
  this.recordEnd(guide, "completed");
2728
3068
  this.enqueue("survey_submitted", { guideId: guide.id, guideVersion: guide.version, properties: { responseId } });
2729
- 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 });
2730
3070
  this.emitter.emit("survey:submit", { guide, answers, responseId });
2731
3071
  this.emitter.emit("guide:complete", guide);
2732
3072
  this.surveyView = { ...this.surveyView, submitted: true };
@@ -2783,7 +3123,7 @@ var GuidelyClient = class {
2783
3123
  this.enqueue("checklist_completed", { guideId: guide.id, guideVersion: guide.version });
2784
3124
  }
2785
3125
  this.persistState();
2786
- 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 });
2787
3127
  this.emitter.emit("checklist:task", { guide, task, progress: this.getChecklistProgress(guide.id) });
2788
3128
  if (complete) {
2789
3129
  this.emitter.emit("checklist:complete", guide);
@@ -2920,7 +3260,8 @@ var GuidelyClient = class {
2920
3260
  },
2921
3261
  onMessage: (message, conversation) => this.emitter.emit("conversation:message", { message, conversation }),
2922
3262
  onStartGuide: (key) => this.setTimer(() => this.startGuide(key), 0),
2923
- 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
2924
3265
  });
2925
3266
  this.resourceCenterOpen = false;
2926
3267
  }
@@ -3025,6 +3366,7 @@ var GuidelyClient = class {
3025
3366
  audienceIds: this.state.audienceIds,
3026
3367
  session: this.session,
3027
3368
  pendingEvents: this.queue?.size ?? 0,
3369
+ pendingWrites: this.outbox?.size ?? 0,
3028
3370
  targets: this.targets.ids(),
3029
3371
  flagsReady: this.flags.isReady()
3030
3372
  };