@guidelytech/sdk-core 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.
Files changed (48) hide show
  1. package/README.md +15 -4
  2. package/dist/{capture-C3faAYb7.d.cts → capture-369s4YYT.d.ts} +2 -1
  3. package/dist/{capture-Dqd7up2n.d.ts → capture-DVJyQ2JZ.d.cts} +2 -1
  4. package/dist/capture.cjs +2 -0
  5. package/dist/capture.cjs.map +1 -1
  6. package/dist/capture.d.cts +3 -2
  7. package/dist/capture.d.ts +3 -2
  8. package/dist/capture.js +4 -6
  9. package/dist/capture.js.map +1 -1
  10. package/dist/chunk-C4WWSF67.js +374 -0
  11. package/dist/chunk-C4WWSF67.js.map +1 -0
  12. package/dist/{chunk-544KSOC3.js → chunk-GDJB466J.js} +115 -18
  13. package/dist/chunk-GDJB466J.js.map +1 -0
  14. package/dist/headless.cjs +275 -40
  15. package/dist/headless.cjs.map +1 -1
  16. package/dist/headless.d.cts +43 -3
  17. package/dist/headless.d.ts +43 -3
  18. package/dist/headless.js +14 -8
  19. package/dist/health-DFHUstWd.d.cts +88 -0
  20. package/dist/health-DFHUstWd.d.ts +88 -0
  21. package/dist/{http-47tiAlOM.d.ts → http-D0dCcpLN.d.cts} +10 -35
  22. package/dist/{http-47tiAlOM.d.cts → http-DJd8jRhK.d.ts} +10 -35
  23. package/dist/index.cjs +342 -77
  24. package/dist/index.cjs.map +1 -1
  25. package/dist/index.d.cts +10 -5
  26. package/dist/index.d.ts +10 -5
  27. package/dist/index.js +71 -32
  28. package/dist/index.js.map +1 -1
  29. package/dist/recorder.cjs +70 -9
  30. package/dist/recorder.cjs.map +1 -1
  31. package/dist/recorder.d.cts +9 -0
  32. package/dist/recorder.d.ts +9 -0
  33. package/dist/recorder.js +57 -11
  34. package/dist/recorder.js.map +1 -1
  35. package/dist/{types-DnJW_4ck.d.ts → types-B5ecgHUV.d.cts} +10 -3
  36. package/dist/{types-zM5e3loY.d.cts → types-BtPovimB.d.ts} +10 -3
  37. package/package.json +4 -4
  38. package/dist/chunk-544KSOC3.js.map +0 -1
  39. package/dist/chunk-AKNE7D5M.js +0 -160
  40. package/dist/chunk-AKNE7D5M.js.map +0 -1
  41. package/dist/chunk-CYAG6ZYX.js +0 -85
  42. package/dist/chunk-CYAG6ZYX.js.map +0 -1
  43. package/dist/chunk-GDF6RCHQ.js +0 -209
  44. package/dist/chunk-GDF6RCHQ.js.map +0 -1
  45. package/dist/types-DbKQPvFt.d.cts +0 -238
  46. package/dist/types-DqhNl5Am.d.ts +0 -238
  47. package/dist/types-RyeRQd9f.d.ts +0 -226
  48. package/dist/types-d4eOerxi.d.cts +0 -226
package/dist/index.cjs CHANGED
@@ -57,7 +57,7 @@ __export(index_exports, {
57
57
  module.exports = __toCommonJS(index_exports);
58
58
 
59
59
  // src/client.ts
60
- var import_runtime13 = require("@guidelytech/shared/runtime");
60
+ var import_runtime15 = require("@guidelytech/shared/runtime");
61
61
  var import_guide_runtime4 = require("@guidelytech/guide-runtime");
62
62
  var import_guide_runtime5 = require("@guidelytech/guide-runtime");
63
63
  var import_flags = require("@guidelytech/flags");
@@ -206,6 +206,8 @@ var KEYS = {
206
206
  state: (key) => `guidely:state:${key}`,
207
207
  user: (key) => `guidely:user:${key}`,
208
208
  queue: (key) => `guidely:queue:${key}`,
209
+ /** Survey answers and guide/checklist progress not yet accepted by the server. */
210
+ outbox: (key) => `guidely:outbox:${key}`,
209
211
  session: "guidely:session",
210
212
  active: "guidely:active",
211
213
  authoring: "guidely:authoring",
@@ -304,7 +306,7 @@ var AuthoringBridge = class {
304
306
  masked(el) {
305
307
  return this.opts.maskSelectors.some((sel) => {
306
308
  try {
307
- return !!el.closest(sel);
309
+ return !!(0, import_guide_runtime.closestComposed)(el, sel);
308
310
  } catch {
309
311
  return false;
310
312
  }
@@ -505,11 +507,11 @@ function validSelectors(list) {
505
507
  return out;
506
508
  }
507
509
  function isIgnoredElement(el) {
508
- return (0, import_guide_runtime2.isGuidelyElement)(el) || !!el.closest("[data-guidely-ignore]");
510
+ return (0, import_guide_runtime2.isGuidelyElement)(el) || !!(0, import_guide_runtime2.closestComposed)(el, "[data-guidely-ignore]");
509
511
  }
510
512
  function interactiveElement(target) {
511
513
  try {
512
- return target.closest(INTERACTIVE_SELECTOR);
514
+ return (0, import_guide_runtime2.closestComposed)(target, INTERACTIVE_SELECTOR);
513
515
  } catch {
514
516
  return null;
515
517
  }
@@ -520,14 +522,19 @@ function esc(value) {
520
522
  }
521
523
  var attr = (tag, name, value) => value ? `${tag}[${name}="${value.replace(/["\\]/g, "\\$&")}"]` : `${tag}[${name}]`;
522
524
  function elementSelector(el) {
525
+ const own = ownSelector(el);
526
+ const chain = (0, import_guide_runtime2.shadowHostChain)(el);
527
+ return (chain ? chain + import_guide_runtime2.SHADOW_SEPARATOR + own : own).slice(0, 300);
528
+ }
529
+ function ownSelector(el) {
523
530
  const tag = el.tagName.toLowerCase();
524
531
  if (el.id && (0, import_guide_runtime2.isStableId)(el.id)) return `#${esc(el.id)}`;
525
- for (const name of PREFERRED_ATTRS) if (el.hasAttribute(name)) return attr(tag, name, el.getAttribute(name) || "").slice(0, 300);
532
+ for (const name of PREFERRED_ATTRS) if (el.hasAttribute(name)) return attr(tag, name, el.getAttribute(name) || "");
526
533
  for (const a of Array.from(el.attributes)) {
527
- if (a.name.startsWith("data-") && !NOISY_DATA_ATTR.test(a.name) && a.value && a.value.length <= 60) return attr(tag, a.name, a.value).slice(0, 300);
534
+ if (a.name.startsWith("data-") && !NOISY_DATA_ATTR.test(a.name) && a.value && a.value.length <= 60) return attr(tag, a.name, a.value);
528
535
  }
529
536
  const label = el.getAttribute("aria-label");
530
- if (label && label.length <= 80) return attr(tag, "aria-label", label).slice(0, 300);
537
+ if (label && label.length <= 80) return attr(tag, "aria-label", label);
531
538
  const parts = [];
532
539
  let cur = el;
533
540
  while (cur && parts.length < 4) {
@@ -543,12 +550,12 @@ function elementSelector(el) {
543
550
  parts.unshift(same.length > 1 ? `${t}:nth-of-type(${same.indexOf(node) + 1})` : t);
544
551
  cur = parent;
545
552
  }
546
- return (parts.join(" > ") || tag).slice(0, 300);
553
+ return parts.join(" > ") || tag;
547
554
  }
548
555
  function matches(el, selector, deep) {
549
556
  if (!selector) return false;
550
557
  try {
551
- return !!el.closest(selector) || deep && !!el.querySelector(selector);
558
+ return !!(0, import_guide_runtime2.closestComposed)(el, selector) || deep && !!el.querySelector(selector);
552
559
  } catch {
553
560
  return false;
554
561
  }
@@ -566,7 +573,7 @@ function elementText(el, mask, maskAll) {
566
573
  return text ? text.slice(0, 100) : void 0;
567
574
  }
568
575
  function linkHref(el) {
569
- const a = el.closest("a[href]");
576
+ const a = (0, import_guide_runtime2.closestComposed)(el, "a[href]");
570
577
  if (!a) return void 0;
571
578
  try {
572
579
  const u = new URL(a.getAttribute("href") || "", location.href);
@@ -898,12 +905,108 @@ function dismissedAfterMerge(local, server) {
898
905
  return local?.dismissedAt ?? server.dismissedAt ?? null;
899
906
  }
900
907
 
908
+ // src/health.ts
909
+ var MAX_RETRY_AFTER_MS = 60 * 60 * 1e3;
910
+ var OFFLINE_WAIT_MS = 3e4;
911
+ var ServerHealth = class {
912
+ constructor(opts = {}) {
913
+ __publicField(this, "failures", 0);
914
+ __publicField(this, "openUntil", 0);
915
+ __publicField(this, "lastOkAt", 0);
916
+ __publicField(this, "listeners", /* @__PURE__ */ new Set());
917
+ __publicField(this, "threshold");
918
+ __publicField(this, "baseMs");
919
+ __publicField(this, "maxMs");
920
+ __publicField(this, "now");
921
+ __publicField(this, "random");
922
+ this.threshold = opts.threshold ?? 3;
923
+ this.baseMs = opts.baseMs ?? 5e3;
924
+ this.maxMs = opts.maxMs ?? 5 * 60 * 1e3;
925
+ this.now = opts.now ?? Date.now;
926
+ this.random = opts.random ?? Math.random;
927
+ }
928
+ /** The server answered (2xx, 3xx, or a 4xx other than 429): it is up. */
929
+ success() {
930
+ const wasDown = this.failures > 0 || this.openUntil > 0;
931
+ this.failures = 0;
932
+ this.openUntil = 0;
933
+ this.lastOkAt = this.now();
934
+ if (wasDown) this.notify();
935
+ }
936
+ /** The request failed or the server is overloaded. `retryAfterMs` comes from a `Retry-After` header. */
937
+ failure(retryAfterMs) {
938
+ this.failures += 1;
939
+ const now = this.now();
940
+ if (retryAfterMs != null && retryAfterMs > 0) {
941
+ this.openUntil = Math.max(this.openUntil, now + Math.min(retryAfterMs, MAX_RETRY_AFTER_MS));
942
+ return;
943
+ }
944
+ if (this.failures < this.threshold) return;
945
+ const cooldown = Math.min(this.maxMs, this.baseMs * 2 ** (this.failures - this.threshold));
946
+ this.openUntil = Math.max(this.openUntil, now + cooldown * (0.8 + this.random() * 0.4));
947
+ }
948
+ /** Milliseconds background traffic should wait before trying again (0: send now). */
949
+ waitMs() {
950
+ if (typeof navigator !== "undefined" && navigator.onLine === false) return OFFLINE_WAIT_MS;
951
+ return Math.max(0, this.openUntil - this.now());
952
+ }
953
+ canSend() {
954
+ return this.waitMs() === 0;
955
+ }
956
+ /**
957
+ * The last request got an answer. Unload beacons can't report back, so what they carry is only
958
+ * forgotten while this holds; otherwise it stays queued and is sent again (the server drops duplicates).
959
+ */
960
+ get reachable() {
961
+ return this.failures === 0 && this.lastOkAt > 0;
962
+ }
963
+ /** The browser came back online: end the cooldown and let queued traffic try straight away. */
964
+ online() {
965
+ this.openUntil = 0;
966
+ this.notify();
967
+ }
968
+ /** Called when the server answers again after failures, or the browser comes back online. */
969
+ onRecover(cb) {
970
+ this.listeners.add(cb);
971
+ return () => this.listeners.delete(cb);
972
+ }
973
+ notify() {
974
+ for (const cb of Array.from(this.listeners)) {
975
+ try {
976
+ cb();
977
+ } catch {
978
+ }
979
+ }
980
+ }
981
+ };
982
+ function parseRetryAfter(value, now = Date.now()) {
983
+ if (!value) return null;
984
+ const trimmed = value.trim();
985
+ if (/^\d+$/.test(trimmed)) return Number(trimmed) * 1e3;
986
+ const at = Date.parse(trimmed);
987
+ return Number.isNaN(at) ? null : Math.max(0, at - now);
988
+ }
989
+ function isServerFailure(status) {
990
+ return status === 429 || status >= 500;
991
+ }
992
+
901
993
  // src/http.ts
994
+ var STREAM_RESPONSE_TIMEOUT_MS = 3e4;
995
+ var STREAM_IDLE_TIMEOUT_MS = 6e4;
902
996
  var Api = class {
903
- constructor(baseUrl, key, fetchImpl) {
997
+ constructor(baseUrl, key, fetchImpl, health) {
904
998
  __publicField(this, "baseUrl", baseUrl);
905
999
  __publicField(this, "key", key);
906
1000
  __publicField(this, "fetchImpl", fetchImpl);
1001
+ /** Reachability of the API, fed by every answer (see {@link ServerHealth}). */
1002
+ __publicField(this, "health");
1003
+ this.health = health ?? new ServerHealth();
1004
+ }
1005
+ /** Feeds an answer (or a failed request, `null`) into {@link health}. */
1006
+ observe(res) {
1007
+ if (!res) this.health.failure();
1008
+ else if (isServerFailure(res.status)) this.health.failure(parseRetryAfter(res.headers?.get?.("retry-after")));
1009
+ else this.health.success();
907
1010
  }
908
1011
  url(path, params = {}) {
909
1012
  const query = Object.entries({ key: this.key, ...params }).filter((e) => e[1] != null).map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(v)}`).join("&");
@@ -923,15 +1026,22 @@ var Api = class {
923
1026
  const ac = typeof AbortController !== "undefined" ? new AbortController() : null;
924
1027
  const timer = ac ? setTimeout(() => ac.abort(), options.timeoutMs ?? 1e4) : null;
925
1028
  try {
926
- const res = await f(this.url(path, options.params), {
927
- method,
928
- headers,
929
- body: options.body === void 0 ? void 0 : form ? options.body : JSON.stringify(options.body),
930
- keepalive: options.keepalive,
931
- signal: ac?.signal,
932
- credentials: "omit",
933
- mode: "cors"
934
- });
1029
+ let res;
1030
+ try {
1031
+ res = await f(this.url(path, options.params), {
1032
+ method,
1033
+ headers,
1034
+ body: options.body === void 0 ? void 0 : form ? options.body : JSON.stringify(options.body),
1035
+ keepalive: options.keepalive,
1036
+ signal: ac?.signal,
1037
+ credentials: "omit",
1038
+ mode: "cors"
1039
+ });
1040
+ } catch (err) {
1041
+ this.observe(null);
1042
+ throw err;
1043
+ }
1044
+ this.observe(res);
935
1045
  let data = null;
936
1046
  if (res.status !== 304 && res.status !== 204) {
937
1047
  try {
@@ -957,30 +1067,53 @@ var Api = class {
957
1067
  }
958
1068
  /**
959
1069
  * POST that may answer with server-sent events. Each event is passed to `onEvent`; a JSON answer is
960
- * returned instead. No timeout: Scout replies can take a while and the server keeps the stream alive.
1070
+ * returned instead. Scout replies can take a while, so there is no overall timeout: the response
1071
+ * must start within 30 s and then never go quiet for more than 60 s (the server sends keepalives),
1072
+ * otherwise the request is aborted and rejects.
961
1073
  */
962
1074
  async stream(path, body, onEvent, headers = {}) {
963
1075
  const f = this.fetcher;
964
1076
  if (!f) throw new Error("fetch is not available");
965
- const res = await f(this.url(path), {
966
- method: "POST",
967
- headers: { ...headers, "X-Guidely-Key": this.key, Accept: "text/event-stream, application/json", "Content-Type": "application/json" },
968
- body: JSON.stringify(body),
969
- credentials: "omit",
970
- mode: "cors"
971
- });
972
- const type = res.headers?.get?.("content-type") ?? "";
973
- if (!type.includes("text/event-stream") || !res.body) {
974
- let data = null;
1077
+ const ac = typeof AbortController !== "undefined" ? new AbortController() : null;
1078
+ let timer = null;
1079
+ const arm = (ms) => {
1080
+ if (!ac) return;
1081
+ if (timer) clearTimeout(timer);
1082
+ timer = setTimeout(() => ac.abort(), ms);
1083
+ };
1084
+ arm(STREAM_RESPONSE_TIMEOUT_MS);
1085
+ try {
1086
+ let res;
975
1087
  try {
976
- data = await res.json();
977
- } catch {
978
- data = null;
1088
+ res = await f(this.url(path), {
1089
+ method: "POST",
1090
+ headers: { ...headers, "X-Guidely-Key": this.key, Accept: "text/event-stream, application/json", "Content-Type": "application/json" },
1091
+ body: JSON.stringify(body),
1092
+ signal: ac?.signal,
1093
+ credentials: "omit",
1094
+ mode: "cors"
1095
+ });
1096
+ } catch (err) {
1097
+ this.observe(null);
1098
+ throw err;
979
1099
  }
980
- return { status: res.status, ok: res.ok, data, etag: null };
1100
+ this.observe(res);
1101
+ const type = res.headers?.get?.("content-type") ?? "";
1102
+ if (!type.includes("text/event-stream") || !res.body) {
1103
+ let data = null;
1104
+ try {
1105
+ data = await res.json();
1106
+ } catch {
1107
+ data = null;
1108
+ }
1109
+ return { status: res.status, ok: res.ok, data, etag: null };
1110
+ }
1111
+ arm(STREAM_IDLE_TIMEOUT_MS);
1112
+ await readEventStream(res.body, onEvent, () => arm(STREAM_IDLE_TIMEOUT_MS));
1113
+ return { status: res.status, ok: res.ok, data: null, etag: null };
1114
+ } finally {
1115
+ if (timer) clearTimeout(timer);
981
1116
  }
982
- await readEventStream(res.body, onEvent);
983
- return { status: res.status, ok: res.ok, data: null, etag: null };
984
1117
  }
985
1118
  /** `navigator.sendBeacon` when available (page unload); returns false when it could not be queued. */
986
1119
  beacon(path, body) {
@@ -1011,7 +1144,7 @@ async function withRetry(send, attempts = 3, baseMs = 500) {
1011
1144
  function postWithRetry(api, path, body, attempts = 3, baseMs = 500) {
1012
1145
  return withRetry(() => api.post(path, body), attempts, baseMs);
1013
1146
  }
1014
- async function readEventStream(body, onEvent) {
1147
+ async function readEventStream(body, onEvent, onChunk) {
1015
1148
  const reader = body.getReader();
1016
1149
  const decoder = new TextDecoder();
1017
1150
  let buffer = "";
@@ -1034,6 +1167,7 @@ async function readEventStream(body, onEvent) {
1034
1167
  };
1035
1168
  for (; ; ) {
1036
1169
  const { done, value } = await reader.read();
1170
+ if (value) onChunk?.();
1037
1171
  if (value) buffer += decoder.decode(value, { stream: true }).replace(/\r\n?/g, "\n");
1038
1172
  let sep;
1039
1173
  while ((sep = buffer.indexOf("\n\n")) >= 0) {
@@ -1187,7 +1321,7 @@ function createMessengerTransport(opts) {
1187
1321
  }
1188
1322
 
1189
1323
  // src/messenger-visitor.ts
1190
- var import_runtime10 = require("@guidelytech/shared/runtime");
1324
+ var import_runtime11 = require("@guidelytech/shared/runtime");
1191
1325
  var import_guide_runtime3 = require("@guidelytech/guide-runtime");
1192
1326
  var PAGE_GUIDE_KINDS = /* @__PURE__ */ new Set(["flow", "tooltip", "modal"]);
1193
1327
  var CATCH_ALL = /* @__PURE__ */ new Set(["", "*", "**", "/*", "/**", "/"]);
@@ -1254,7 +1388,7 @@ function buildMessengerVisitor(input) {
1254
1388
  unread: !seen.has(g.id) && !(state.guides[g.id]?.impressions ?? 0)
1255
1389
  };
1256
1390
  });
1257
- return { firstName: (0, import_runtime10.firstNameFromTraits)(input.traits), path: pathOf(input.url), pageGuides, resumeGuide, checklist, news };
1391
+ return { firstName: (0, import_runtime11.firstNameFromTraits)(input.traits), path: pathOf(input.url), pageGuides, resumeGuide, checklist, news };
1258
1392
  }
1259
1393
 
1260
1394
  // src/navigation.ts
@@ -1357,6 +1491,90 @@ var PageTracker = class {
1357
1491
  }
1358
1492
  };
1359
1493
 
1494
+ // src/outbox.ts
1495
+ var Outbox = class {
1496
+ constructor(opts) {
1497
+ __publicField(this, "opts", opts);
1498
+ __publicField(this, "items");
1499
+ __publicField(this, "sending", false);
1500
+ __publicField(this, "attempt", 0);
1501
+ __publicField(this, "timer", null);
1502
+ __publicField(this, "destroyed", false);
1503
+ __publicField(this, "offRecover");
1504
+ __publicField(this, "maxItems");
1505
+ __publicField(this, "maxAgeMs");
1506
+ this.maxItems = opts.maxItems ?? 50;
1507
+ this.maxAgeMs = opts.maxAgeMs ?? 7 * 24 * 60 * 60 * 1e3;
1508
+ const stored = readJson(opts.store, opts.storageKey, []);
1509
+ const cutoff = Date.now() - this.maxAgeMs;
1510
+ this.items = Array.isArray(stored) ? stored.filter((i) => i && typeof i.path === "string" && typeof i.key === "string" && i.createdAt > cutoff).slice(-this.maxItems) : [];
1511
+ this.offRecover = opts.health?.onRecover(() => {
1512
+ this.attempt = 0;
1513
+ this.retryIn(0);
1514
+ }) ?? null;
1515
+ if (this.items.length) this.retryIn(0);
1516
+ }
1517
+ get size() {
1518
+ return this.items.length;
1519
+ }
1520
+ /** Queues `POST path` and sends it now (or as soon as the API is reachable). */
1521
+ add(key, path, body) {
1522
+ if (this.destroyed) return;
1523
+ this.items = this.items.filter((i) => i.key !== key);
1524
+ this.items.push({ key, path, body, createdAt: Date.now() });
1525
+ if (this.items.length > this.maxItems) this.items.splice(0, this.items.length - this.maxItems);
1526
+ this.persist();
1527
+ void this.flush();
1528
+ }
1529
+ persist() {
1530
+ writeJson(this.opts.store, this.opts.storageKey, this.items);
1531
+ }
1532
+ retryIn(ms) {
1533
+ if (this.destroyed) return;
1534
+ if (this.timer) clearTimeout(this.timer);
1535
+ this.timer = setTimeout(() => {
1536
+ this.timer = null;
1537
+ void this.flush();
1538
+ }, ms);
1539
+ }
1540
+ /** Sends queued requests in order, one at a time. */
1541
+ async flush() {
1542
+ if (this.destroyed || this.sending || !this.items.length) return;
1543
+ const wait = this.opts.health?.waitMs() ?? 0;
1544
+ if (wait > 0) return this.retryIn(wait);
1545
+ this.sending = true;
1546
+ try {
1547
+ while (this.items.length && !this.destroyed) {
1548
+ const item = this.items[0];
1549
+ let retry = false;
1550
+ try {
1551
+ const res = await this.opts.api.post(item.path, item.body);
1552
+ retry = !res.ok && (res.status === 429 || res.status >= 500);
1553
+ } catch (err) {
1554
+ retry = true;
1555
+ this.opts.onError?.(err);
1556
+ }
1557
+ if (retry) {
1558
+ this.attempt += 1;
1559
+ this.retryIn(Math.max(backoffMs(this.attempt - 1, 1e3, 6e4), this.opts.health?.waitMs() ?? 0));
1560
+ return;
1561
+ }
1562
+ this.attempt = 0;
1563
+ this.items = this.items.filter((i) => i !== item);
1564
+ this.persist();
1565
+ }
1566
+ } finally {
1567
+ this.sending = false;
1568
+ }
1569
+ }
1570
+ destroy() {
1571
+ this.destroyed = true;
1572
+ this.offRecover?.();
1573
+ if (this.timer) clearTimeout(this.timer);
1574
+ this.timer = null;
1575
+ }
1576
+ };
1577
+
1360
1578
  // src/queue.ts
1361
1579
  var EventQueue = class {
1362
1580
  constructor(opts) {
@@ -1368,17 +1586,34 @@ var EventQueue = class {
1368
1586
  __publicField(this, "inflightIds", /* @__PURE__ */ new Set());
1369
1587
  __publicField(this, "attempt", 0);
1370
1588
  __publicField(this, "destroyed", false);
1589
+ __publicField(this, "offRecover", null);
1371
1590
  __publicField(this, "flushIntervalMs");
1372
1591
  __publicField(this, "maxQueue");
1373
1592
  __publicField(this, "batchSize");
1374
1593
  __publicField(this, "maxAttempts");
1375
1594
  this.flushIntervalMs = opts.flushIntervalMs ?? 2e3;
1376
- this.maxQueue = opts.maxQueue ?? 200;
1595
+ this.maxQueue = opts.maxQueue ?? 500;
1377
1596
  this.batchSize = opts.batchSize ?? 50;
1378
1597
  this.maxAttempts = opts.maxAttempts ?? 5;
1379
1598
  const persisted = readJson(opts.store, opts.storageKey, []);
1380
1599
  if (Array.isArray(persisted)) this.events = persisted.filter((e) => e && typeof e.eventId === "string").slice(-this.maxQueue);
1381
1600
  if (this.events.length) this.schedule();
1601
+ this.offRecover = opts.health?.onRecover(() => this.retryNow()) ?? null;
1602
+ }
1603
+ /** The API is back: drop any pending backoff and send what is queued. */
1604
+ retryNow() {
1605
+ if (this.destroyed) return;
1606
+ if (this.retryTimer) clearTimeout(this.retryTimer);
1607
+ this.retryTimer = null;
1608
+ this.attempt = 0;
1609
+ void this.flush();
1610
+ }
1611
+ retryIn(ms) {
1612
+ if (this.retryTimer || this.destroyed) return;
1613
+ this.retryTimer = setTimeout(() => {
1614
+ this.retryTimer = null;
1615
+ void this.flush();
1616
+ }, ms);
1382
1617
  }
1383
1618
  get size() {
1384
1619
  return this.events.length;
@@ -1412,6 +1647,8 @@ var EventQueue = class {
1412
1647
  clearTimeout(this.timer);
1413
1648
  this.timer = null;
1414
1649
  }
1650
+ const wait = this.opts.health?.waitMs() ?? 0;
1651
+ if (wait > 0) return this.retryIn(wait);
1415
1652
  this.inflight = true;
1416
1653
  const batch = this.events.slice(0, this.batchSize);
1417
1654
  this.inflightIds = new Set(batch.map((e) => e.eventId));
@@ -1435,26 +1672,20 @@ var EventQueue = class {
1435
1672
  if (this.events.length) this.schedule();
1436
1673
  return;
1437
1674
  }
1438
- this.attempt += 1;
1439
- if (this.attempt >= this.maxAttempts) {
1440
- this.attempt = 0;
1441
- return;
1442
- }
1443
- this.retryTimer = setTimeout(() => {
1444
- this.retryTimer = null;
1445
- void this.flush();
1446
- }, backoffMs(this.attempt - 1));
1675
+ this.attempt = Math.min(this.attempt + 1, this.maxAttempts);
1676
+ const delay = this.attempt >= this.maxAttempts ? 6e4 : backoffMs(this.attempt - 1);
1677
+ this.retryIn(Math.max(delay, this.opts.health?.waitMs() ?? 0));
1447
1678
  }
1448
1679
  /** Best-effort synchronous flush on unload using `sendBeacon`. */
1449
1680
  flushSync() {
1450
1681
  if (this.destroyed || !this.opts.enabled() || !this.opts.beacon) return;
1451
1682
  const batch = this.events.filter((e) => !this.inflightIds.has(e.eventId)).slice(0, this.batchSize);
1452
1683
  if (!batch.length) return;
1453
- if (this.opts.beacon(batch)) {
1684
+ if (this.opts.beacon(batch) && (this.opts.health?.reachable ?? true)) {
1454
1685
  const ids = new Set(batch.map((e) => e.eventId));
1455
1686
  this.events = this.events.filter((e) => !ids.has(e.eventId));
1456
- this.persist();
1457
1687
  }
1688
+ this.persist();
1458
1689
  }
1459
1690
  clear() {
1460
1691
  this.events = [];
@@ -1462,6 +1693,7 @@ var EventQueue = class {
1462
1693
  }
1463
1694
  destroy() {
1464
1695
  this.destroyed = true;
1696
+ this.offRecover?.();
1465
1697
  if (this.timer) clearTimeout(this.timer);
1466
1698
  if (this.retryTimer) clearTimeout(this.retryTimer);
1467
1699
  this.timer = null;
@@ -1475,11 +1707,22 @@ var loadRecorder = async () => (await import("./recorder.cjs")).recorder;
1475
1707
 
1476
1708
  // src/client.ts
1477
1709
  var SDK_NAME = "@guidelytech/sdk-core";
1478
- var SDK_VERSION = "0.9.0";
1710
+ var SDK_VERSION = "0.10.0";
1479
1711
  var DEFAULT_API_URL = "https://api.guidely.app";
1480
1712
  var PAGE_SCOPED = /* @__PURE__ */ new Set(["page_load", "url_match", "element_click", "element_visible", "delay"]);
1481
1713
  var DAY_MS = 24 * 60 * 60 * 1e3;
1482
1714
  var MAX_SESSION_EXCEPTIONS = 100;
1715
+ var CACHED_START_MS = 1500;
1716
+ function settlesWithin(promise, ms) {
1717
+ return new Promise((resolve) => {
1718
+ const timer = setTimeout(() => resolve(false), ms);
1719
+ const done = () => {
1720
+ clearTimeout(timer);
1721
+ resolve(true);
1722
+ };
1723
+ promise.then(done, done);
1724
+ });
1725
+ }
1483
1726
  function isBrowser() {
1484
1727
  return typeof window !== "undefined" && typeof document !== "undefined";
1485
1728
  }
@@ -1490,7 +1733,7 @@ function emptyState() {
1490
1733
  return { guides: {}, checklists: {}, surveysSubmitted: [], audienceIds: [] };
1491
1734
  }
1492
1735
  function newSession(now = Date.now()) {
1493
- return { id: `s_${(0, import_runtime13.randomId)(16)}`, startedAt: new Date(now).toISOString(), shownGuides: [], events: [], lastActivityAt: now };
1736
+ return { id: `s_${(0, import_runtime15.randomId)(16)}`, startedAt: new Date(now).toISOString(), shownGuides: [], events: [], lastActivityAt: now };
1494
1737
  }
1495
1738
  var GuidelyClient = class _GuidelyClient {
1496
1739
  constructor(options) {
@@ -1500,6 +1743,8 @@ var GuidelyClient = class _GuidelyClient {
1500
1743
  __publicField(this, "sessionStore");
1501
1744
  __publicField(this, "emitter");
1502
1745
  __publicField(this, "queue");
1746
+ /** Survey answers and guide/checklist progress, kept until the server accepts them. */
1747
+ __publicField(this, "outbox");
1503
1748
  __publicField(this, "config", null);
1504
1749
  __publicField(this, "etag", null);
1505
1750
  __publicField(this, "anonymousId");
@@ -1583,7 +1828,7 @@ var GuidelyClient = class _GuidelyClient {
1583
1828
  this.sessionStore = this.options.storage === "memory" ? this.store : createStorage("session");
1584
1829
  this.anonymousId = this.store.get(KEYS.anon) || "";
1585
1830
  if (!this.anonymousId) {
1586
- this.anonymousId = `anon_${(0, import_runtime13.randomId)(20)}`;
1831
+ this.anonymousId = `anon_${(0, import_runtime15.randomId)(20)}`;
1587
1832
  this.store.set(KEYS.anon, this.anonymousId);
1588
1833
  }
1589
1834
  this.firstVisit = !this.store.get(KEYS.seen);
@@ -1599,6 +1844,7 @@ var GuidelyClient = class _GuidelyClient {
1599
1844
  store: this.store,
1600
1845
  storageKey: KEYS.queue(this.options.projectId),
1601
1846
  enabled: () => this.analyticsEnabled(),
1847
+ health: this.api.health,
1602
1848
  send: async (events) => {
1603
1849
  const res = await this.api.post("/events", { events, sentAt: nowIso(), sdk: { name: SDK_NAME, version: SDK_VERSION } }, { keepalive: true });
1604
1850
  return { ok: res.ok, retry: !(res.status >= 400 && res.status < 500 && res.status !== 429) };
@@ -1606,6 +1852,7 @@ var GuidelyClient = class _GuidelyClient {
1606
1852
  beacon: (events) => this.api.beacon("/events", { events, sentAt: nowIso(), sdk: { name: SDK_NAME, version: SDK_VERSION } }),
1607
1853
  onError: (err) => this.reportError(err)
1608
1854
  });
1855
+ this.outbox = new Outbox({ api: this.api, store: this.store, storageKey: KEYS.outbox(this.options.projectId), health: this.api.health });
1609
1856
  if (options.user) this.user = { ...options.user };
1610
1857
  this.flags = this.createFlags(options.flags);
1611
1858
  }
@@ -1663,12 +1910,15 @@ var GuidelyClient = class _GuidelyClient {
1663
1910
  const cached = readJson(this.store, KEYS.config(this.options.projectId), null);
1664
1911
  this.etag = this.store.get(KEYS.etag(this.options.projectId));
1665
1912
  if (cached && cached.guides) this.config = webOnly(cached);
1666
- await this.fetchConfig();
1913
+ const before = this.config?.configVersion;
1914
+ const fresh = this.fetchConfig();
1915
+ if (!this.config) await fresh;
1916
+ else if (!await settlesWithin(fresh, CACHED_START_MS)) void fresh.then(() => this.applyConfigChange(before));
1667
1917
  if (this.destroyed) return;
1668
1918
  this.wireListeners();
1669
1919
  if (!this.identitySettled) {
1670
- if (this.options.user) await this.identify(this.options.user.id, this.options.user.traits, this.options.user.account);
1671
- else if (this.user?.id) await this.identify(this.user.id, this.user.traits, this.user.account);
1920
+ const known = this.options.user ?? (this.user?.id ? this.user : null);
1921
+ if (known) await settlesWithin(this.identify(known.id, known.traits, known.account), CACHED_START_MS);
1672
1922
  }
1673
1923
  if (this.destroyed) return;
1674
1924
  this.isReady = true;
@@ -1773,7 +2023,12 @@ var GuidelyClient = class _GuidelyClient {
1773
2023
  win.removeEventListener("scroll", onScroll);
1774
2024
  doc.removeEventListener("keydown", onKey, true);
1775
2025
  });
1776
- const refresh = setInterval(() => void this.refresh(), this.options.configRefreshMs);
2026
+ const refresh = setInterval(() => {
2027
+ if (this.api.health.canSend()) void this.refresh();
2028
+ }, this.options.configRefreshMs);
2029
+ const onOnline = () => this.api.health.online();
2030
+ win.addEventListener("online", onOnline);
2031
+ this.cleanups.push(() => win.removeEventListener("online", onOnline));
1777
2032
  this.cleanups.push(() => clearInterval(refresh));
1778
2033
  const poll = setInterval(() => {
1779
2034
  if (!this.isReady || !this.config || this.editing() || doc.visibilityState === "hidden") return;
@@ -1806,6 +2061,11 @@ var GuidelyClient = class _GuidelyClient {
1806
2061
  if (this.destroyed) return;
1807
2062
  const before = this.config?.configVersion;
1808
2063
  await this.fetchConfig();
2064
+ this.applyConfigChange(before);
2065
+ }
2066
+ /** Re-renders after a config change (refresh, or a config that arrived after start). */
2067
+ applyConfigChange(before) {
2068
+ if (this.destroyed || !this.isReady) return;
1809
2069
  if (this.config && this.config.configVersion !== before) {
1810
2070
  this.renderPersistentWidgets();
1811
2071
  this.evaluate("ready");
@@ -1840,6 +2100,7 @@ var GuidelyClient = class _GuidelyClient {
1840
2100
  this.replay = null;
1841
2101
  this.queue.flushSync();
1842
2102
  this.queue.destroy();
2103
+ this.outbox.destroy();
1843
2104
  this.flags.destroy();
1844
2105
  this.emitter.clear();
1845
2106
  (0, import_guide_runtime4.destroyHost)();
@@ -1855,7 +2116,7 @@ var GuidelyClient = class _GuidelyClient {
1855
2116
  this.user = { id: String(userId), traits: { ...this.user?.id === String(userId) ? this.user.traits : {}, ...traits || {} }, account: account ?? (this.user?.id === String(userId) ? this.user.account : void 0) };
1856
2117
  writeJson(this.store, KEYS.user(this.options.projectId), this.user);
1857
2118
  this.syncFlagAttributes();
1858
- this.enqueue("identify", { properties: { traits: (0, import_runtime13.sanitizeProperties)(this.user.traits) } });
2119
+ this.enqueue("identify", { properties: { traits: (0, import_runtime15.sanitizeProperties)(this.user.traits) } });
1859
2120
  await this.syncIdentify();
1860
2121
  this.emitter.emit("identify", { userId: this.user.id, anonymousId: this.anonymousId });
1861
2122
  if (this.isReady) this.evaluate("identify");
@@ -1928,7 +2189,7 @@ var GuidelyClient = class _GuidelyClient {
1928
2189
  this.stopGuide();
1929
2190
  this.user = null;
1930
2191
  this.store.remove(KEYS.user(this.options.projectId));
1931
- this.anonymousId = `anon_${(0, import_runtime13.randomId)(20)}`;
2192
+ this.anonymousId = `anon_${(0, import_runtime15.randomId)(20)}`;
1932
2193
  this.store.set(KEYS.anon, this.anonymousId);
1933
2194
  this.state = emptyState();
1934
2195
  this.persistState();
@@ -1972,7 +2233,7 @@ var GuidelyClient = class _GuidelyClient {
1972
2233
  if (this.consent === "denied" || !this.options.analytics) return null;
1973
2234
  if (name !== "page_leave" && name !== "heatmap") this.touchSession();
1974
2235
  const event = {
1975
- eventId: (0, import_runtime13.randomId)(24),
2236
+ eventId: (0, import_runtime15.randomId)(24),
1976
2237
  name,
1977
2238
  occurredAt: nowIso(),
1978
2239
  anonymousId: this.anonymousId,
@@ -1982,7 +2243,7 @@ var GuidelyClient = class _GuidelyClient {
1982
2243
  url: this.collectUrls() ? location.href : void 0,
1983
2244
  ...extra
1984
2245
  };
1985
- if (extra.properties) event.properties = (0, import_runtime13.sanitizeProperties)(extra.properties);
2246
+ if (extra.properties) event.properties = (0, import_runtime15.sanitizeProperties)(extra.properties);
1986
2247
  this.queue.enqueue(event);
1987
2248
  this.replay?.addCustomEvent("guidely", { name, customName: event.customName, guideId: event.guideId });
1988
2249
  this.emitter.emit("track", event);
@@ -2042,7 +2303,7 @@ var GuidelyClient = class _GuidelyClient {
2042
2303
  if (changed) void this.queue.flush();
2043
2304
  }
2044
2305
  handleDocumentClick(e) {
2045
- const target = e.target;
2306
+ const target = (0, import_guide_runtime4.composedTarget)(e);
2046
2307
  if (!target || typeof target.closest !== "function") return;
2047
2308
  this.autocaptureClick(e, target);
2048
2309
  this.completeChecklistTasksBy("click", target);
@@ -2137,7 +2398,7 @@ var GuidelyClient = class _GuidelyClient {
2137
2398
  }
2138
2399
  /** Bins one pointer sample into the heatmap buffer. Not session activity. */
2139
2400
  sampleMove(e) {
2140
- const target = e.target;
2401
+ const target = (0, import_guide_runtime4.composedTarget)(e);
2141
2402
  if (document.visibilityState === "hidden" || !target?.closest || !this.autocaptureOn("mouseMovement") || isIgnoredElement(target)) return;
2142
2403
  const url = location.href;
2143
2404
  if (this.heatmap.url !== url) this.flushHeatmap();
@@ -2228,7 +2489,7 @@ var GuidelyClient = class _GuidelyClient {
2228
2489
  syncReplay() {
2229
2490
  const cfg = this.isReady && this.captureAllowed() ? this.replayConfig() : null;
2230
2491
  if (cfg && this.session.replay === void 0) {
2231
- this.session.replay = (0, import_runtime13.rolloutBucket)(this.session.id, "replay") < cfg.sampleRate;
2492
+ this.session.replay = (0, import_runtime15.rolloutBucket)(this.session.id, "replay") < cfg.sampleRate;
2232
2493
  this.persistSession();
2233
2494
  }
2234
2495
  if (!cfg || !this.session.replay) {
@@ -2262,7 +2523,9 @@ var GuidelyClient = class _GuidelyClient {
2262
2523
  this.persistSession();
2263
2524
  this.replay?.stop({ discard: true });
2264
2525
  },
2265
- onError: (err) => this.reportError(err)
2526
+ onError: (err) => this.reportError(err),
2527
+ health: this.api.health,
2528
+ stash: this.sessionStore
2266
2529
  });
2267
2530
  this.syncReplay();
2268
2531
  },
@@ -2328,7 +2591,7 @@ var GuidelyClient = class _GuidelyClient {
2328
2591
  if (!isWithinSchedule(guide)) return false;
2329
2592
  if (guide.kind === "survey" && (this.state.surveysSubmitted.includes(guide.id) || this.state.surveysSubmitted.includes(guide.key))) return false;
2330
2593
  ctx.rolloutSalt = guide.id;
2331
- return (0, import_runtime13.evaluateRules)(c.targeting, ctx);
2594
+ return (0, import_runtime15.evaluateRules)(c.targeting, ctx);
2332
2595
  }
2333
2596
  isEligible(guide, ctx, url) {
2334
2597
  if (!this.isTargeted(guide, ctx, url)) return false;
@@ -2352,7 +2615,7 @@ var GuidelyClient = class _GuidelyClient {
2352
2615
  case "element_click":
2353
2616
  if (reason !== "click" || !t.value || !detail.target) return false;
2354
2617
  try {
2355
- return !!detail.target.closest(t.value);
2618
+ return !!(0, import_guide_runtime4.closestComposed)(detail.target, t.value);
2356
2619
  } catch {
2357
2620
  return false;
2358
2621
  }
@@ -2503,7 +2766,9 @@ var GuidelyClient = class _GuidelyClient {
2503
2766
  if (reason === "completed") this.completeChecklistTasksBy("guide_completed", guide);
2504
2767
  }
2505
2768
  syncGuideState(guideId, update) {
2506
- void postWithRetry(this.api, "/guide-state", { anonymousId: this.anonymousId, userId: this.user?.id || null, updates: [{ guideId, ...update }] });
2769
+ const body = { anonymousId: this.anonymousId, userId: this.user?.id || null, updates: [{ guideId, ...update }] };
2770
+ if (update.impression) void postWithRetry(this.api, "/guide-state", body);
2771
+ else this.outbox.add(`guide:${guideId}:${update.completed ? "completed" : "dismissed"}:${this.anonymousId}`, "/guide-state", body);
2507
2772
  }
2508
2773
  show(guide, manual, stepId) {
2509
2774
  try {
@@ -2626,12 +2891,12 @@ var GuidelyClient = class _GuidelyClient {
2626
2891
  title: guide.name,
2627
2892
  onDismiss: () => finish(this.state.surveysSubmitted.includes(guide.id) ? "submitted" : "dismissed"),
2628
2893
  onSubmit: (answers) => {
2629
- const responseId = (0, import_runtime13.randomId)(24);
2894
+ const responseId = (0, import_runtime15.randomId)(24);
2630
2895
  this.state.surveysSubmitted.push(guide.id);
2631
2896
  this.persistState();
2632
2897
  this.recordEnd(guide, "completed");
2633
2898
  this.enqueue("survey_submitted", { guideId: guide.id, guideVersion: guide.version, properties: { responseId } });
2634
- 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 });
2899
+ this.outbox.add(`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 });
2635
2900
  this.emitter.emit("survey:submit", { guide, answers, responseId });
2636
2901
  this.emitter.emit("guide:complete", guide);
2637
2902
  widget.showThankYou();
@@ -2761,7 +3026,7 @@ var GuidelyClient = class _GuidelyClient {
2761
3026
  isAudienceTargeted: (g) => {
2762
3027
  if (!isWithinSchedule(g)) return false;
2763
3028
  ctx.rolloutSalt = g.id;
2764
- return (0, import_runtime13.evaluateRules)(g.config.targeting, ctx);
3029
+ return (0, import_runtime15.evaluateRules)(g.config.targeting, ctx);
2765
3030
  },
2766
3031
  newsSeen: Array.isArray(seen) ? seen : []
2767
3032
  });
@@ -2820,7 +3085,7 @@ var GuidelyClient = class _GuidelyClient {
2820
3085
  }
2821
3086
  this.persistState();
2822
3087
  this.widgets.get(guide.id)?.checklist?.setProgress(this.getChecklistProgress(guide.id));
2823
- 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 });
3088
+ this.outbox.add(`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 });
2824
3089
  this.emitter.emit("checklist:task", { guide, task, progress: this.getChecklistProgress(guide.id) });
2825
3090
  if (complete) {
2826
3091
  this.emitter.emit("checklist:complete", guide);
@@ -2840,7 +3105,7 @@ var GuidelyClient = class _GuidelyClient {
2840
3105
  else if (kind === "guide_completed") hit = value.id === task.completionValue || value.key === task.completionValue;
2841
3106
  else if (kind === "click") {
2842
3107
  try {
2843
- hit = !!value.closest(task.completionValue);
3108
+ hit = !!(0, import_guide_runtime4.closestComposed)(value, task.completionValue);
2844
3109
  } catch {
2845
3110
  hit = false;
2846
3111
  }
@@ -2860,7 +3125,7 @@ var GuidelyClient = class _GuidelyClient {
2860
3125
  progress.dismissedAt = nowIso();
2861
3126
  this.persistState();
2862
3127
  this.enqueue("checklist_dismissed", { guideId: guide.id, guideVersion: guide.version });
2863
- 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 });
3128
+ this.outbox.add(`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 });
2864
3129
  this.widgets.get(guide.id)?.destroy();
2865
3130
  this.widgets.delete(guide.id);
2866
3131
  }
@@ -3168,7 +3433,7 @@ var GuidelyClient = class _GuidelyClient {
3168
3433
  };
3169
3434
  var current = null;
3170
3435
  function webOnly(config) {
3171
- return config.guides.every((g) => (0, import_runtime13.supportsPlatform)(g.config, "web")) ? config : { ...config, guides: config.guides.filter((g) => (0, import_runtime13.supportsPlatform)(g.config, "web")) };
3436
+ return config.guides.every((g) => (0, import_runtime15.supportsPlatform)(g.config, "web")) ? config : { ...config, guides: config.guides.filter((g) => (0, import_runtime15.supportsPlatform)(g.config, "web")) };
3172
3437
  }
3173
3438
  function pathOf2(url) {
3174
3439
  try {