@guidelytech/sdk-core 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.
Files changed (44) 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-GDF6RCHQ.js → chunk-GDJB466J.js} +121 -19
  13. package/dist/chunk-GDJB466J.js.map +1 -0
  14. package/dist/headless.cjs +281 -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 +16 -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 +380 -81
  24. package/dist/index.cjs.map +1 -1
  25. package/dist/index.d.cts +15 -5
  26. package/dist/index.d.ts +15 -5
  27. package/dist/index.js +106 -36
  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-DqhNl5Am.d.ts → types-B5ecgHUV.d.cts} +26 -4
  36. package/dist/{types-DbKQPvFt.d.cts → types-BtPovimB.d.ts} +26 -4
  37. package/package.json +7 -7
  38. package/dist/chunk-AKNE7D5M.js +0 -160
  39. package/dist/chunk-AKNE7D5M.js.map +0 -1
  40. package/dist/chunk-CYAG6ZYX.js +0 -85
  41. package/dist/chunk-CYAG6ZYX.js.map +0 -1
  42. package/dist/chunk-GDF6RCHQ.js.map +0 -1
  43. package/dist/types-RyeRQd9f.d.ts +0 -226
  44. package/dist/types-d4eOerxi.d.cts +0 -226
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/queue.ts","../src/outbox.ts","../src/emitter.ts","../src/frequency.ts"],"sourcesContent":["import type { SdkEvent } from \"@guidelytech/shared\";\nimport type { ServerHealth } from \"./health\";\nimport { backoffMs } from \"./http\";\nimport { readJson, writeJson, type KeyValueStore } from \"./storage\";\n\nexport interface EventQueueOptions {\n store: KeyValueStore;\n storageKey: string;\n /** Sends one batch. Resolve `{ ok: true }` on success, `{ ok: false, retry }` otherwise. */\n send: (events: SdkEvent[]) => Promise<{ ok: boolean; retry?: boolean }>;\n /** Synchronous best-effort send used on page hide. */\n beacon?: (events: SdkEvent[]) => boolean;\n /** Should events be flushed right now? (analytics enabled + consent granted). */\n enabled: () => boolean;\n /** API reachability: sends wait while its circuit is open, and beaconed events are only forgotten while it is reachable. */\n health?: ServerHealth;\n flushIntervalMs?: number;\n maxQueue?: number;\n batchSize?: number;\n maxAttempts?: number;\n onError?: (err: unknown) => void;\n}\n\n/**\n * Bounded, persisted event queue. Batches ≤50 events every 2s (or on demand), dedupes by\n * `eventId`, and keeps failed events (also across page loads) until the server takes them:\n * retries back off 1s→60s, wait while the API's circuit is open, and start again as soon as it\n * recovers. Drops the oldest events once the queue exceeds 500.\n */\nexport class EventQueue {\n private events: SdkEvent[] = [];\n private timer: ReturnType<typeof setTimeout> | null = null;\n private retryTimer: ReturnType<typeof setTimeout> | null = null;\n private inflight = false;\n private inflightIds = new Set<string>();\n private attempt = 0;\n private destroyed = false;\n private offRecover: (() => void) | null = null;\n private readonly flushIntervalMs: number;\n private readonly maxQueue: number;\n private readonly batchSize: number;\n private readonly maxAttempts: number;\n\n constructor(private readonly opts: EventQueueOptions) {\n this.flushIntervalMs = opts.flushIntervalMs ?? 2000;\n this.maxQueue = opts.maxQueue ?? 500;\n this.batchSize = opts.batchSize ?? 50;\n this.maxAttempts = opts.maxAttempts ?? 5;\n const persisted = readJson<SdkEvent[]>(opts.store, opts.storageKey, []);\n if (Array.isArray(persisted)) this.events = persisted.filter((e) => e && typeof e.eventId === \"string\").slice(-this.maxQueue);\n // Events left over from a previous page load are flushed on the next tick.\n if (this.events.length) this.schedule();\n this.offRecover = opts.health?.onRecover(() => this.retryNow()) ?? null;\n }\n\n /** The API is back: drop any pending backoff and send what is queued. */\n private retryNow(): void {\n if (this.destroyed) return;\n if (this.retryTimer) clearTimeout(this.retryTimer);\n this.retryTimer = null;\n this.attempt = 0;\n void this.flush();\n }\n\n private retryIn(ms: number): void {\n if (this.retryTimer || this.destroyed) return;\n this.retryTimer = setTimeout(() => {\n this.retryTimer = null;\n void this.flush();\n }, ms);\n }\n\n get size(): number {\n return this.events.length;\n }\n\n /** Pending events (read-only copy, for debugging). */\n peek(): SdkEvent[] {\n return this.events.slice();\n }\n\n enqueue(event: SdkEvent): void {\n if (this.destroyed) return;\n if (this.events.some((e) => e.eventId === event.eventId)) return;\n this.events.push(event);\n if (this.events.length > this.maxQueue) this.events.splice(0, this.events.length - this.maxQueue);\n this.persist();\n this.schedule();\n }\n\n private persist(): void {\n writeJson(this.opts.store, this.opts.storageKey, this.events);\n }\n\n private schedule(): void {\n if (this.timer || this.retryTimer || this.destroyed) return;\n this.timer = setTimeout(() => {\n this.timer = null;\n void this.flush();\n }, this.flushIntervalMs);\n }\n\n /** Sends queued events now (one batch at a time). Safe to call at any time. */\n async flush(): Promise<void> {\n if (this.destroyed || this.inflight || !this.events.length || !this.opts.enabled()) return;\n if (this.timer) {\n clearTimeout(this.timer);\n this.timer = null;\n }\n const wait = this.opts.health?.waitMs() ?? 0;\n if (wait > 0) return this.retryIn(wait);\n this.inflight = true;\n const batch = this.events.slice(0, this.batchSize);\n this.inflightIds = new Set(batch.map((e) => e.eventId));\n let ok = false;\n let retry = true;\n try {\n const res = await this.opts.send(batch);\n ok = res.ok;\n retry = res.retry ?? !res.ok;\n } catch (err) {\n this.opts.onError?.(err);\n }\n this.inflight = false;\n this.inflightIds.clear();\n if (this.destroyed) return;\n if (ok || !retry) {\n const ids = new Set(batch.map((e) => e.eventId));\n this.events = this.events.filter((e) => !ids.has(e.eventId));\n this.attempt = 0;\n this.persist();\n if (this.events.length) this.schedule();\n return;\n }\n // Fast retries first, then about once a minute until the server takes the events (or the\n // API's circuit says to wait longer); a recovery or the next page load sends them sooner.\n this.attempt = Math.min(this.attempt + 1, this.maxAttempts);\n const delay = this.attempt >= this.maxAttempts ? 60_000 : backoffMs(this.attempt - 1);\n this.retryIn(Math.max(delay, this.opts.health?.waitMs() ?? 0));\n }\n\n /** Best-effort synchronous flush on unload using `sendBeacon`. */\n flushSync(): void {\n if (this.destroyed || !this.opts.enabled() || !this.opts.beacon) return;\n // Events already in flight (fetch with keepalive) survive unload; only beacon the rest to avoid duplicate batches.\n const batch = this.events.filter((e) => !this.inflightIds.has(e.eventId)).slice(0, this.batchSize);\n if (!batch.length) return;\n // A beacon that was queued may still never arrive (the server is down), and nothing reports\n // back. Only forget its events while the API is answering; otherwise they stay persisted and the\n // next page load sends them again (the server drops events it already has, by eventId).\n if (this.opts.beacon(batch) && (this.opts.health?.reachable ?? true)) {\n const ids = new Set(batch.map((e) => e.eventId));\n this.events = this.events.filter((e) => !ids.has(e.eventId));\n }\n this.persist();\n }\n\n clear(): void {\n this.events = [];\n this.persist();\n }\n\n destroy(): void {\n this.destroyed = true;\n this.offRecover?.();\n if (this.timer) clearTimeout(this.timer);\n if (this.retryTimer) clearTimeout(this.retryTimer);\n this.timer = null;\n this.retryTimer = null;\n this.persist();\n }\n}\n","import type { ServerHealth } from \"./health\";\nimport { backoffMs, type Api } from \"./http\";\nimport { readJson, writeJson, type KeyValueStore } from \"./storage\";\n\n/** One request waiting to reach the server. */\ninterface OutboxItem {\n /** Same key → the newer request replaces the older one (e.g. a checklist's latest progress). */\n key: string;\n path: string;\n body: unknown;\n createdAt: number;\n}\n\nexport interface OutboxOptions {\n api: Api;\n store: KeyValueStore;\n storageKey: string;\n health?: ServerHealth;\n maxItems?: number;\n maxAgeMs?: number;\n onError?: (err: unknown) => void;\n}\n\n/**\n * Persisted queue for writes that must not be lost when the API is down: survey answers, guide\n * completions and checklist progress. Each request is kept (also across page loads) until the\n * server answers it; 5xx, 429 and network errors are retried with backoff, waiting while the API's\n * circuit is open. Every request is idempotent on the server (survey answers dedupe by\n * `responseId`, progress merges), so sending one twice is harmless. Bounded to 50 requests and 7 days.\n */\nexport class Outbox {\n private items: OutboxItem[];\n private sending = false;\n private attempt = 0;\n private timer: ReturnType<typeof setTimeout> | null = null;\n private destroyed = false;\n private readonly offRecover: (() => void) | null;\n private readonly maxItems: number;\n private readonly maxAgeMs: number;\n\n constructor(private readonly opts: OutboxOptions) {\n this.maxItems = opts.maxItems ?? 50;\n this.maxAgeMs = opts.maxAgeMs ?? 7 * 24 * 60 * 60 * 1000;\n const stored = readJson<OutboxItem[]>(opts.store, opts.storageKey, []);\n const cutoff = Date.now() - this.maxAgeMs;\n this.items = Array.isArray(stored) ? stored.filter((i) => i && typeof i.path === \"string\" && typeof i.key === \"string\" && i.createdAt > cutoff).slice(-this.maxItems) : [];\n this.offRecover = opts.health?.onRecover(() => {\n this.attempt = 0;\n this.retryIn(0);\n }) ?? null;\n if (this.items.length) this.retryIn(0);\n }\n\n get size(): number {\n return this.items.length;\n }\n\n /** Queues `POST path` and sends it now (or as soon as the API is reachable). */\n add(key: string, path: string, body: unknown): void {\n if (this.destroyed) return;\n this.items = this.items.filter((i) => i.key !== key);\n this.items.push({ key, path, body, createdAt: Date.now() });\n if (this.items.length > this.maxItems) this.items.splice(0, this.items.length - this.maxItems);\n this.persist();\n void this.flush();\n }\n\n private persist(): void {\n writeJson(this.opts.store, this.opts.storageKey, this.items);\n }\n\n private retryIn(ms: number): void {\n if (this.destroyed) return;\n if (this.timer) clearTimeout(this.timer);\n this.timer = setTimeout(() => {\n this.timer = null;\n void this.flush();\n }, ms);\n }\n\n /** Sends queued requests in order, one at a time. */\n async flush(): Promise<void> {\n if (this.destroyed || this.sending || !this.items.length) return;\n const wait = this.opts.health?.waitMs() ?? 0;\n if (wait > 0) return this.retryIn(wait);\n this.sending = true;\n try {\n while (this.items.length && !this.destroyed) {\n const item = this.items[0] as OutboxItem;\n let retry = false;\n try {\n const res = await this.opts.api.post(item.path, item.body);\n retry = !res.ok && (res.status === 429 || res.status >= 500);\n } catch (err) {\n retry = true;\n this.opts.onError?.(err);\n }\n if (retry) {\n this.attempt += 1;\n this.retryIn(Math.max(backoffMs(this.attempt - 1, 1000, 60_000), this.opts.health?.waitMs() ?? 0));\n return;\n }\n // Answered (or refused for good, e.g. the survey was deleted): done with it. A newer request\n // under the same key may have replaced it while this one was in flight; keep that one.\n this.attempt = 0;\n this.items = this.items.filter((i) => i !== item);\n this.persist();\n }\n } finally {\n this.sending = false;\n }\n }\n\n destroy(): void {\n this.destroyed = true;\n this.offRecover?.();\n if (this.timer) clearTimeout(this.timer);\n this.timer = null;\n }\n}\n","type AnyFn = (...args: any[]) => void;\n\n/** Tiny typed event emitter. Listener errors are reported, never thrown into the caller. */\nexport class Emitter<Events extends { [K in keyof Events]: AnyFn }> {\n private listeners = new Map<keyof Events, Set<AnyFn>>();\n constructor(private readonly onError?: (err: unknown) => void) {}\n\n on<K extends keyof Events>(event: K, cb: Events[K]): () => void {\n let set = this.listeners.get(event);\n if (!set) {\n set = new Set();\n this.listeners.set(event, set);\n }\n set.add(cb);\n return () => this.off(event, cb);\n }\n\n off<K extends keyof Events>(event: K, cb: Events[K]): void {\n this.listeners.get(event)?.delete(cb);\n }\n\n emit<K extends keyof Events>(event: K, ...args: Parameters<Events[K]>): void {\n const set = this.listeners.get(event);\n if (!set) return;\n for (const cb of Array.from(set)) {\n try {\n cb(...args);\n } catch (err) {\n this.onError?.(err);\n }\n }\n }\n\n clear(): void {\n this.listeners.clear();\n }\n}\n","import type { FrequencyConfig, PublishedGuide } from \"@guidelytech/shared\";\n\n/** Per-guide state kept locally and merged with the server's `IdentifyResponse.guideState`. */\nexport interface GuideLocalState {\n impressions: number;\n completedAt?: string | null;\n dismissedAt?: string | null;\n lastShownAt?: string | null;\n /** Last step reached in a multi-step flow (local only), so the messenger can offer to resume it. */\n lastStepId?: string | null;\n}\n\nconst HOUR = 3600 * 1000;\n\n/**\n * Pure frequency evaluation. `shownThisSession` comes from session storage.\n * - `once`: a single impression ever (unless `reshowAfterDismiss` and it was only dismissed).\n * - `once_per_session`: at most once per browser session.\n * - `every_time`: on every trigger (still blocked by completion/dismissal rules below).\n * - `interval`: at most once every `intervalHours`.\n * `maxImpressions` caps total impressions; dismissal blocks re-showing unless `reshowAfterDismiss`\n * (then `cooldownHours` must have elapsed); completion blocks unless mode is `every_time`.\n */\nexport function isFrequencyEligible(freq: FrequencyConfig, state: GuideLocalState | undefined, shownThisSession: boolean, now: number = Date.now()): boolean {\n const s = state ?? { impressions: 0 };\n if (freq.maxImpressions != null && s.impressions >= freq.maxImpressions) return false;\n if (s.completedAt && freq.mode !== \"every_time\") return false;\n if (s.dismissedAt) {\n if (!freq.reshowAfterDismiss) return false;\n if (freq.cooldownHours > 0 && now - Date.parse(s.dismissedAt) < freq.cooldownHours * HOUR) return false;\n }\n switch (freq.mode) {\n case \"once\":\n return s.impressions === 0 || (!!s.dismissedAt && freq.reshowAfterDismiss);\n case \"once_per_session\":\n return !shownThisSession;\n case \"interval\": {\n if (!s.lastShownAt) return true;\n const hours = freq.intervalHours ?? 24;\n return now - Date.parse(s.lastShownAt) >= hours * HOUR;\n }\n case \"every_time\":\n default:\n return true;\n }\n}\n\n/**\n * Exclusive guides (flows, modals, tooltips, announcements, surveys) run one at a time.\n * Banners, checklists and hotspot-only tooltips can coexist with an active flow.\n */\nexport function isExclusiveGuide(guide: PublishedGuide): boolean {\n if (guide.kind === \"banner\" || guide.kind === \"checklist\") return false;\n const steps = guide.config.steps;\n if (steps.length > 0 && steps.every((s) => s.type === \"hotspot\" || s.type === \"banner\")) return false;\n return true;\n}\n\n/** Is `a` mutually exclusive with `b` (by id or key, in either direction)? */\nexport function areMutuallyExclusive(a: PublishedGuide, b: PublishedGuide): boolean {\n const la = a.config.frequency.mutuallyExclusiveWith;\n const lb = b.config.frequency.mutuallyExclusiveWith;\n return la.includes(b.id) || la.includes(b.key) || lb.includes(a.id) || lb.includes(a.key);\n}\n\n/** Survey scheduling window. */\nexport function isWithinSchedule(guide: PublishedGuide, now: number = Date.now()): boolean {\n const s = guide.config.survey;\n if (!s) return true;\n if (s.startAt && now < Date.parse(s.startAt)) return false;\n if (s.endAt && now > Date.parse(s.endAt)) return false;\n return true;\n}\n\n/**\n * A checklist's dismissal after merging server state. A local dismissal normally wins (it may not have\n * synced yet), but when the server reports a reset this device hasn't applied, the server's value wins:\n * null, or a dismissal made on another device after the reset. Compared as tokens, not times, so a\n * skewed device clock can't keep a reset dismissal.\n */\nexport function dismissedAfterMerge(local: { dismissedAt?: string | null; dismissResetAt?: string | null } | undefined, server: { dismissedAt?: string | null; dismissResetAt?: string | null }): string | null {\n if (server.dismissResetAt && server.dismissResetAt !== local?.dismissResetAt) return server.dismissedAt ?? null;\n return local?.dismissedAt ?? server.dismissedAt ?? null;\n}\n"],"mappings":"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","names":[]}
package/dist/headless.cjs CHANGED
@@ -26,12 +26,17 @@ __export(headless_exports, {
26
26
  Emitter: () => Emitter,
27
27
  EventQueue: () => EventQueue,
28
28
  KEYS: () => KEYS,
29
+ Outbox: () => Outbox,
30
+ ServerHealth: () => ServerHealth,
29
31
  areMutuallyExclusive: () => areMutuallyExclusive,
30
32
  backoffMs: () => backoffMs,
33
+ dismissedAfterMerge: () => dismissedAfterMerge,
31
34
  isExclusiveGuide: () => isExclusiveGuide,
32
35
  isFrequencyEligible: () => isFrequencyEligible,
36
+ isServerFailure: () => isServerFailure,
33
37
  isWithinSchedule: () => isWithinSchedule,
34
38
  memoryStore: () => memoryStore,
39
+ parseRetryAfter: () => parseRetryAfter,
35
40
  postWithRetry: () => postWithRetry,
36
41
  readJson: () => readJson,
37
42
  sleep: () => sleep,
@@ -39,12 +44,108 @@ __export(headless_exports, {
39
44
  });
40
45
  module.exports = __toCommonJS(headless_exports);
41
46
 
47
+ // src/health.ts
48
+ var MAX_RETRY_AFTER_MS = 60 * 60 * 1e3;
49
+ var OFFLINE_WAIT_MS = 3e4;
50
+ var ServerHealth = class {
51
+ constructor(opts = {}) {
52
+ __publicField(this, "failures", 0);
53
+ __publicField(this, "openUntil", 0);
54
+ __publicField(this, "lastOkAt", 0);
55
+ __publicField(this, "listeners", /* @__PURE__ */ new Set());
56
+ __publicField(this, "threshold");
57
+ __publicField(this, "baseMs");
58
+ __publicField(this, "maxMs");
59
+ __publicField(this, "now");
60
+ __publicField(this, "random");
61
+ this.threshold = opts.threshold ?? 3;
62
+ this.baseMs = opts.baseMs ?? 5e3;
63
+ this.maxMs = opts.maxMs ?? 5 * 60 * 1e3;
64
+ this.now = opts.now ?? Date.now;
65
+ this.random = opts.random ?? Math.random;
66
+ }
67
+ /** The server answered (2xx, 3xx, or a 4xx other than 429): it is up. */
68
+ success() {
69
+ const wasDown = this.failures > 0 || this.openUntil > 0;
70
+ this.failures = 0;
71
+ this.openUntil = 0;
72
+ this.lastOkAt = this.now();
73
+ if (wasDown) this.notify();
74
+ }
75
+ /** The request failed or the server is overloaded. `retryAfterMs` comes from a `Retry-After` header. */
76
+ failure(retryAfterMs) {
77
+ this.failures += 1;
78
+ const now = this.now();
79
+ if (retryAfterMs != null && retryAfterMs > 0) {
80
+ this.openUntil = Math.max(this.openUntil, now + Math.min(retryAfterMs, MAX_RETRY_AFTER_MS));
81
+ return;
82
+ }
83
+ if (this.failures < this.threshold) return;
84
+ const cooldown = Math.min(this.maxMs, this.baseMs * 2 ** (this.failures - this.threshold));
85
+ this.openUntil = Math.max(this.openUntil, now + cooldown * (0.8 + this.random() * 0.4));
86
+ }
87
+ /** Milliseconds background traffic should wait before trying again (0: send now). */
88
+ waitMs() {
89
+ if (typeof navigator !== "undefined" && navigator.onLine === false) return OFFLINE_WAIT_MS;
90
+ return Math.max(0, this.openUntil - this.now());
91
+ }
92
+ canSend() {
93
+ return this.waitMs() === 0;
94
+ }
95
+ /**
96
+ * The last request got an answer. Unload beacons can't report back, so what they carry is only
97
+ * forgotten while this holds; otherwise it stays queued and is sent again (the server drops duplicates).
98
+ */
99
+ get reachable() {
100
+ return this.failures === 0 && this.lastOkAt > 0;
101
+ }
102
+ /** The browser came back online: end the cooldown and let queued traffic try straight away. */
103
+ online() {
104
+ this.openUntil = 0;
105
+ this.notify();
106
+ }
107
+ /** Called when the server answers again after failures, or the browser comes back online. */
108
+ onRecover(cb) {
109
+ this.listeners.add(cb);
110
+ return () => this.listeners.delete(cb);
111
+ }
112
+ notify() {
113
+ for (const cb of Array.from(this.listeners)) {
114
+ try {
115
+ cb();
116
+ } catch {
117
+ }
118
+ }
119
+ }
120
+ };
121
+ function parseRetryAfter(value, now = Date.now()) {
122
+ if (!value) return null;
123
+ const trimmed = value.trim();
124
+ if (/^\d+$/.test(trimmed)) return Number(trimmed) * 1e3;
125
+ const at = Date.parse(trimmed);
126
+ return Number.isNaN(at) ? null : Math.max(0, at - now);
127
+ }
128
+ function isServerFailure(status) {
129
+ return status === 429 || status >= 500;
130
+ }
131
+
42
132
  // src/http.ts
133
+ var STREAM_RESPONSE_TIMEOUT_MS = 3e4;
134
+ var STREAM_IDLE_TIMEOUT_MS = 6e4;
43
135
  var Api = class {
44
- constructor(baseUrl, key, fetchImpl) {
136
+ constructor(baseUrl, key, fetchImpl, health) {
45
137
  __publicField(this, "baseUrl", baseUrl);
46
138
  __publicField(this, "key", key);
47
139
  __publicField(this, "fetchImpl", fetchImpl);
140
+ /** Reachability of the API, fed by every answer (see {@link ServerHealth}). */
141
+ __publicField(this, "health");
142
+ this.health = health ?? new ServerHealth();
143
+ }
144
+ /** Feeds an answer (or a failed request, `null`) into {@link health}. */
145
+ observe(res) {
146
+ if (!res) this.health.failure();
147
+ else if (isServerFailure(res.status)) this.health.failure(parseRetryAfter(res.headers?.get?.("retry-after")));
148
+ else this.health.success();
48
149
  }
49
150
  url(path, params = {}) {
50
151
  const query = Object.entries({ key: this.key, ...params }).filter((e) => e[1] != null).map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(v)}`).join("&");
@@ -64,15 +165,22 @@ var Api = class {
64
165
  const ac = typeof AbortController !== "undefined" ? new AbortController() : null;
65
166
  const timer = ac ? setTimeout(() => ac.abort(), options.timeoutMs ?? 1e4) : null;
66
167
  try {
67
- const res = await f(this.url(path, options.params), {
68
- method,
69
- headers,
70
- body: options.body === void 0 ? void 0 : form ? options.body : JSON.stringify(options.body),
71
- keepalive: options.keepalive,
72
- signal: ac?.signal,
73
- credentials: "omit",
74
- mode: "cors"
75
- });
168
+ let res;
169
+ try {
170
+ res = await f(this.url(path, options.params), {
171
+ method,
172
+ headers,
173
+ body: options.body === void 0 ? void 0 : form ? options.body : JSON.stringify(options.body),
174
+ keepalive: options.keepalive,
175
+ signal: ac?.signal,
176
+ credentials: "omit",
177
+ mode: "cors"
178
+ });
179
+ } catch (err) {
180
+ this.observe(null);
181
+ throw err;
182
+ }
183
+ this.observe(res);
76
184
  let data = null;
77
185
  if (res.status !== 304 && res.status !== 204) {
78
186
  try {
@@ -98,30 +206,53 @@ var Api = class {
98
206
  }
99
207
  /**
100
208
  * POST that may answer with server-sent events. Each event is passed to `onEvent`; a JSON answer is
101
- * returned instead. No timeout: Scout replies can take a while and the server keeps the stream alive.
209
+ * returned instead. Scout replies can take a while, so there is no overall timeout: the response
210
+ * must start within 30 s and then never go quiet for more than 60 s (the server sends keepalives),
211
+ * otherwise the request is aborted and rejects.
102
212
  */
103
213
  async stream(path, body, onEvent, headers = {}) {
104
214
  const f = this.fetcher;
105
215
  if (!f) throw new Error("fetch is not available");
106
- const res = await f(this.url(path), {
107
- method: "POST",
108
- headers: { ...headers, "X-Guidely-Key": this.key, Accept: "text/event-stream, application/json", "Content-Type": "application/json" },
109
- body: JSON.stringify(body),
110
- credentials: "omit",
111
- mode: "cors"
112
- });
113
- const type = res.headers?.get?.("content-type") ?? "";
114
- if (!type.includes("text/event-stream") || !res.body) {
115
- let data = null;
216
+ const ac = typeof AbortController !== "undefined" ? new AbortController() : null;
217
+ let timer = null;
218
+ const arm = (ms) => {
219
+ if (!ac) return;
220
+ if (timer) clearTimeout(timer);
221
+ timer = setTimeout(() => ac.abort(), ms);
222
+ };
223
+ arm(STREAM_RESPONSE_TIMEOUT_MS);
224
+ try {
225
+ let res;
116
226
  try {
117
- data = await res.json();
118
- } catch {
119
- data = null;
227
+ res = await f(this.url(path), {
228
+ method: "POST",
229
+ headers: { ...headers, "X-Guidely-Key": this.key, Accept: "text/event-stream, application/json", "Content-Type": "application/json" },
230
+ body: JSON.stringify(body),
231
+ signal: ac?.signal,
232
+ credentials: "omit",
233
+ mode: "cors"
234
+ });
235
+ } catch (err) {
236
+ this.observe(null);
237
+ throw err;
120
238
  }
121
- return { status: res.status, ok: res.ok, data, etag: null };
239
+ this.observe(res);
240
+ const type = res.headers?.get?.("content-type") ?? "";
241
+ if (!type.includes("text/event-stream") || !res.body) {
242
+ let data = null;
243
+ try {
244
+ data = await res.json();
245
+ } catch {
246
+ data = null;
247
+ }
248
+ return { status: res.status, ok: res.ok, data, etag: null };
249
+ }
250
+ arm(STREAM_IDLE_TIMEOUT_MS);
251
+ await readEventStream(res.body, onEvent, () => arm(STREAM_IDLE_TIMEOUT_MS));
252
+ return { status: res.status, ok: res.ok, data: null, etag: null };
253
+ } finally {
254
+ if (timer) clearTimeout(timer);
122
255
  }
123
- await readEventStream(res.body, onEvent);
124
- return { status: res.status, ok: res.ok, data: null, etag: null };
125
256
  }
126
257
  /** `navigator.sendBeacon` when available (page unload); returns false when it could not be queued. */
127
258
  beacon(path, body) {
@@ -152,7 +283,7 @@ async function withRetry(send, attempts = 3, baseMs = 500) {
152
283
  function postWithRetry(api, path, body, attempts = 3, baseMs = 500) {
153
284
  return withRetry(() => api.post(path, body), attempts, baseMs);
154
285
  }
155
- async function readEventStream(body, onEvent) {
286
+ async function readEventStream(body, onEvent, onChunk) {
156
287
  const reader = body.getReader();
157
288
  const decoder = new TextDecoder();
158
289
  let buffer = "";
@@ -175,6 +306,7 @@ async function readEventStream(body, onEvent) {
175
306
  };
176
307
  for (; ; ) {
177
308
  const { done, value } = await reader.read();
309
+ if (value) onChunk?.();
178
310
  if (value) buffer += decoder.decode(value, { stream: true }).replace(/\r\n?/g, "\n");
179
311
  let sep;
180
312
  while ((sep = buffer.indexOf("\n\n")) >= 0) {
@@ -214,6 +346,8 @@ var KEYS = {
214
346
  state: (key) => `guidely:state:${key}`,
215
347
  user: (key) => `guidely:user:${key}`,
216
348
  queue: (key) => `guidely:queue:${key}`,
349
+ /** Survey answers and guide/checklist progress not yet accepted by the server. */
350
+ outbox: (key) => `guidely:outbox:${key}`,
217
351
  session: "guidely:session",
218
352
  active: "guidely:active",
219
353
  authoring: "guidely:authoring",
@@ -237,17 +371,34 @@ var EventQueue = class {
237
371
  __publicField(this, "inflightIds", /* @__PURE__ */ new Set());
238
372
  __publicField(this, "attempt", 0);
239
373
  __publicField(this, "destroyed", false);
374
+ __publicField(this, "offRecover", null);
240
375
  __publicField(this, "flushIntervalMs");
241
376
  __publicField(this, "maxQueue");
242
377
  __publicField(this, "batchSize");
243
378
  __publicField(this, "maxAttempts");
244
379
  this.flushIntervalMs = opts.flushIntervalMs ?? 2e3;
245
- this.maxQueue = opts.maxQueue ?? 200;
380
+ this.maxQueue = opts.maxQueue ?? 500;
246
381
  this.batchSize = opts.batchSize ?? 50;
247
382
  this.maxAttempts = opts.maxAttempts ?? 5;
248
383
  const persisted = readJson(opts.store, opts.storageKey, []);
249
384
  if (Array.isArray(persisted)) this.events = persisted.filter((e) => e && typeof e.eventId === "string").slice(-this.maxQueue);
250
385
  if (this.events.length) this.schedule();
386
+ this.offRecover = opts.health?.onRecover(() => this.retryNow()) ?? null;
387
+ }
388
+ /** The API is back: drop any pending backoff and send what is queued. */
389
+ retryNow() {
390
+ if (this.destroyed) return;
391
+ if (this.retryTimer) clearTimeout(this.retryTimer);
392
+ this.retryTimer = null;
393
+ this.attempt = 0;
394
+ void this.flush();
395
+ }
396
+ retryIn(ms) {
397
+ if (this.retryTimer || this.destroyed) return;
398
+ this.retryTimer = setTimeout(() => {
399
+ this.retryTimer = null;
400
+ void this.flush();
401
+ }, ms);
251
402
  }
252
403
  get size() {
253
404
  return this.events.length;
@@ -281,6 +432,8 @@ var EventQueue = class {
281
432
  clearTimeout(this.timer);
282
433
  this.timer = null;
283
434
  }
435
+ const wait = this.opts.health?.waitMs() ?? 0;
436
+ if (wait > 0) return this.retryIn(wait);
284
437
  this.inflight = true;
285
438
  const batch = this.events.slice(0, this.batchSize);
286
439
  this.inflightIds = new Set(batch.map((e) => e.eventId));
@@ -304,26 +457,20 @@ var EventQueue = class {
304
457
  if (this.events.length) this.schedule();
305
458
  return;
306
459
  }
307
- this.attempt += 1;
308
- if (this.attempt >= this.maxAttempts) {
309
- this.attempt = 0;
310
- return;
311
- }
312
- this.retryTimer = setTimeout(() => {
313
- this.retryTimer = null;
314
- void this.flush();
315
- }, backoffMs(this.attempt - 1));
460
+ this.attempt = Math.min(this.attempt + 1, this.maxAttempts);
461
+ const delay = this.attempt >= this.maxAttempts ? 6e4 : backoffMs(this.attempt - 1);
462
+ this.retryIn(Math.max(delay, this.opts.health?.waitMs() ?? 0));
316
463
  }
317
464
  /** Best-effort synchronous flush on unload using `sendBeacon`. */
318
465
  flushSync() {
319
466
  if (this.destroyed || !this.opts.enabled() || !this.opts.beacon) return;
320
467
  const batch = this.events.filter((e) => !this.inflightIds.has(e.eventId)).slice(0, this.batchSize);
321
468
  if (!batch.length) return;
322
- if (this.opts.beacon(batch)) {
469
+ if (this.opts.beacon(batch) && (this.opts.health?.reachable ?? true)) {
323
470
  const ids = new Set(batch.map((e) => e.eventId));
324
471
  this.events = this.events.filter((e) => !ids.has(e.eventId));
325
- this.persist();
326
472
  }
473
+ this.persist();
327
474
  }
328
475
  clear() {
329
476
  this.events = [];
@@ -331,6 +478,7 @@ var EventQueue = class {
331
478
  }
332
479
  destroy() {
333
480
  this.destroyed = true;
481
+ this.offRecover?.();
334
482
  if (this.timer) clearTimeout(this.timer);
335
483
  if (this.retryTimer) clearTimeout(this.retryTimer);
336
484
  this.timer = null;
@@ -339,6 +487,90 @@ var EventQueue = class {
339
487
  }
340
488
  };
341
489
 
490
+ // src/outbox.ts
491
+ var Outbox = class {
492
+ constructor(opts) {
493
+ __publicField(this, "opts", opts);
494
+ __publicField(this, "items");
495
+ __publicField(this, "sending", false);
496
+ __publicField(this, "attempt", 0);
497
+ __publicField(this, "timer", null);
498
+ __publicField(this, "destroyed", false);
499
+ __publicField(this, "offRecover");
500
+ __publicField(this, "maxItems");
501
+ __publicField(this, "maxAgeMs");
502
+ this.maxItems = opts.maxItems ?? 50;
503
+ this.maxAgeMs = opts.maxAgeMs ?? 7 * 24 * 60 * 60 * 1e3;
504
+ const stored = readJson(opts.store, opts.storageKey, []);
505
+ const cutoff = Date.now() - this.maxAgeMs;
506
+ this.items = Array.isArray(stored) ? stored.filter((i) => i && typeof i.path === "string" && typeof i.key === "string" && i.createdAt > cutoff).slice(-this.maxItems) : [];
507
+ this.offRecover = opts.health?.onRecover(() => {
508
+ this.attempt = 0;
509
+ this.retryIn(0);
510
+ }) ?? null;
511
+ if (this.items.length) this.retryIn(0);
512
+ }
513
+ get size() {
514
+ return this.items.length;
515
+ }
516
+ /** Queues `POST path` and sends it now (or as soon as the API is reachable). */
517
+ add(key, path, body) {
518
+ if (this.destroyed) return;
519
+ this.items = this.items.filter((i) => i.key !== key);
520
+ this.items.push({ key, path, body, createdAt: Date.now() });
521
+ if (this.items.length > this.maxItems) this.items.splice(0, this.items.length - this.maxItems);
522
+ this.persist();
523
+ void this.flush();
524
+ }
525
+ persist() {
526
+ writeJson(this.opts.store, this.opts.storageKey, this.items);
527
+ }
528
+ retryIn(ms) {
529
+ if (this.destroyed) return;
530
+ if (this.timer) clearTimeout(this.timer);
531
+ this.timer = setTimeout(() => {
532
+ this.timer = null;
533
+ void this.flush();
534
+ }, ms);
535
+ }
536
+ /** Sends queued requests in order, one at a time. */
537
+ async flush() {
538
+ if (this.destroyed || this.sending || !this.items.length) return;
539
+ const wait = this.opts.health?.waitMs() ?? 0;
540
+ if (wait > 0) return this.retryIn(wait);
541
+ this.sending = true;
542
+ try {
543
+ while (this.items.length && !this.destroyed) {
544
+ const item = this.items[0];
545
+ let retry = false;
546
+ try {
547
+ const res = await this.opts.api.post(item.path, item.body);
548
+ retry = !res.ok && (res.status === 429 || res.status >= 500);
549
+ } catch (err) {
550
+ retry = true;
551
+ this.opts.onError?.(err);
552
+ }
553
+ if (retry) {
554
+ this.attempt += 1;
555
+ this.retryIn(Math.max(backoffMs(this.attempt - 1, 1e3, 6e4), this.opts.health?.waitMs() ?? 0));
556
+ return;
557
+ }
558
+ this.attempt = 0;
559
+ this.items = this.items.filter((i) => i !== item);
560
+ this.persist();
561
+ }
562
+ } finally {
563
+ this.sending = false;
564
+ }
565
+ }
566
+ destroy() {
567
+ this.destroyed = true;
568
+ this.offRecover?.();
569
+ if (this.timer) clearTimeout(this.timer);
570
+ this.timer = null;
571
+ }
572
+ };
573
+
342
574
  // src/emitter.ts
343
575
  var Emitter = class {
344
576
  constructor(onError) {
@@ -416,18 +648,27 @@ function isWithinSchedule(guide, now = Date.now()) {
416
648
  if (s.endAt && now > Date.parse(s.endAt)) return false;
417
649
  return true;
418
650
  }
651
+ function dismissedAfterMerge(local, server) {
652
+ if (server.dismissResetAt && server.dismissResetAt !== local?.dismissResetAt) return server.dismissedAt ?? null;
653
+ return local?.dismissedAt ?? server.dismissedAt ?? null;
654
+ }
419
655
  // Annotate the CommonJS export names for ESM import in node:
420
656
  0 && (module.exports = {
421
657
  Api,
422
658
  Emitter,
423
659
  EventQueue,
424
660
  KEYS,
661
+ Outbox,
662
+ ServerHealth,
425
663
  areMutuallyExclusive,
426
664
  backoffMs,
665
+ dismissedAfterMerge,
427
666
  isExclusiveGuide,
428
667
  isFrequencyEligible,
668
+ isServerFailure,
429
669
  isWithinSchedule,
430
670
  memoryStore,
671
+ parseRetryAfter,
431
672
  postWithRetry,
432
673
  readJson,
433
674
  sleep,