@guidelytech/sdk-core 0.2.1 → 0.4.1

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/README.md CHANGED
@@ -41,7 +41,11 @@ The module is safe to import during SSR (nothing touches `window` at module scop
41
41
  | `storage` | `"local"` | `"local"` \| `"session"` \| `"memory"` for identity, cached config, state and queue. |
42
42
  | `pollIntervalMs` | `1000` | Idle re-check interval (min 250ms): re-arms `element_visible` triggers whose target appears without navigation and drains the guide queue. |
43
43
  | `configRefreshMs` | `300000` | Periodic config refresh (`If-None-Match` → 304 when unchanged). |
44
- | `maskSelectors` | `[]` | Elements whose text is never captured in authoring picks (merged with the project setting). |
44
+ | `maskSelectors` | `[]` | Elements whose text is never captured: authoring picks, click text, session replays (merged with the project setting, `[data-guidely-mask]` and `.guidely-mask`). |
45
+ | `autocapture` | project setting | `false` or `{ pageviews?, pageLeave?, clicks?, rageClicks?, heatmaps?, mouseMovement? }`: switch product-analytics autocapture off for this installation (options can only turn things off). |
46
+ | `release` | — | App version or commit SHA (≤100 chars) sent with every `exception`; error stacks are resolved with the source maps uploaded for that release (`@guidelytech/sourcemaps`). |
47
+ | `captureErrors` | project setting | `false` stops reporting uncaught errors / unhandled rejections (`captureException` still works while the project has error tracking on). |
48
+ | `sessionReplay` | project setting | `false` never records; `{ maskAllText?, blockSelectors?, recordNetwork?: false, recordNetworkPayloads?: false, maskNetworkRequest? }` makes recording stricter (see "Network recording"). |
45
49
  | `onError` | — | Receives every error the SDK swallows (network, rendering). |
46
50
  | `theme` | — | Overrides for the project theme (`primaryColor`, `backgroundColor`, `textColor`, `borderRadius`, `fontFamily`). |
47
51
  | `user` | — | `{ id, traits?, account? }` to identify immediately. |
@@ -62,7 +66,10 @@ event, never thrown into the host application.
62
66
  | `reset()` | New anonymous id, cleared user and local state, new session; triggers re-evaluated. |
63
67
  | `track(name, properties?)` | Custom event. Drives `event` triggers, `advanceOn: "event"` steps and checklist tasks (`completion: "event"`). Keys matching `SENSITIVE_PROPERTY_PATTERN` are stripped. |
64
68
  | `page(url?)` | Records a `page_view` and re-evaluates triggers. Called automatically on SPA navigation (History API patch + `popstate` + `hashchange`); adapters call it explicitly too. |
65
- | `setConsent("granted" \| "denied" \| "pending")` | Runtime consent changes. |
69
+ | `setConsent("granted" \| "denied" \| "pending")` | Runtime consent changes (anything but `granted` stops and discards a session recording). |
70
+ | `captureException(error, properties?)` | Reports a caught error as an `exception` event (`source: "manual"`, `handled: true`). A no-op when the project has error tracking off; `captureErrors: false` does not affect it. |
71
+ | `startSessionRecording()` / `stopSessionRecording()` | Force recording on for this session (ignores sampling; needs replay enabled on the project) / stop it for the rest of the session. |
72
+ | `isRecording()` / `getSessionId()` | Whether the session is being recorded; the current analytics session id (also the replay id). |
66
73
  | `startGuide(idOrKey, { stepId? })` | Starts a guide regardless of targeting/frequency, replacing any active guide. Checklists open their panel. Returns `false` for unknown guides. |
67
74
  | `stopGuide()` | Removes the active guide silently (no dismiss event). |
68
75
  | `dismissGuide()` | Dismisses the active guide (records `guide_dismissed`). |
@@ -143,16 +150,42 @@ Messages posted to the opener: `guidely:authoring-ready { sessionId, url }`, `gu
143
150
  `guidely:user:<key>`, `guidely:queue:<key>` (local/session storage per `storage` option);
144
151
  `guidely:session`, `guidely:active`, `guidely:authoring` (sessionStorage).
145
152
 
153
+ ## Network recording (session replay)
154
+
155
+ When the project enables replay with **Record network** (`replay.recordNetwork`), recordings include
156
+ the page's `fetch`/`XMLHttpRequest` calls, WebSocket connections and frames, and resource timing
157
+ (scripts, stylesheets, images, fonts, media, the document) as rrweb plugin events
158
+ (`guidely/network@1`, payload `NetworkPluginPayload` from `@guidelytech/shared`). It lives in the
159
+ lazily loaded recorder chunk; the base bundle does not grow.
160
+
161
+ - `fetch`, `XMLHttpRequest` and `WebSocket` are wrapped only while recording and restored on stop
162
+ (left as a passthrough if another library wrapped them afterwards). Callers get the original
163
+ promise / XHR / socket; bodies are read from `response.clone()` (≤ 2s, never the app's stream).
164
+ `instanceof WebSocket`, constants, `onmessage` and `addEventListener` behave as before; sockets
165
+ created while recording keep working after it stops.
166
+ - Requests to Guidely's SDK API, `data:`/`blob:` URLs and the recorder script are never recorded.
167
+ URLs are absolute, with sensitive-looking query parameter values replaced by `[redacted]`.
168
+ - Headers and bodies only with **Record payloads** (`replay.recordNetworkPayloads`, off by default):
169
+ header names lower-cased, credentials/cookies `[redacted]`; bodies only for text, JSON and
170
+ form-urlencoded content, ≤ 10KB (`[truncated]`), `[binary]` otherwise; sensitive JSON/form keys
171
+ masked. WebSocket frame data ≤ 2KB (`[binary <n> B]` for binary frames). Frame sizes and totals are
172
+ always recorded; at most 200 frames per socket and 500 requests per page load.
173
+ - Batched every second or 20 items, and flushed when the page is hidden.
174
+ - `sessionReplay: { recordNetwork: false }` / `{ recordNetworkPayloads: false }` turn this off for one
175
+ installation. `maskNetworkRequest(request)` gets each request after redaction: return a modified
176
+ copy, or `null` to drop it (and, for a WebSocket, all its frames); a throwing hook drops it.
177
+
146
178
  ## Bundle size
147
179
 
148
180
  `pnpm --filter @guidelytech/sdk-core size` builds a minified ESM bundle and prints gzip sizes:
149
181
 
150
182
  | Bundle | minified | min+gz |
151
183
  |---|---|---|
152
- | sdk-core + guide-runtime + floating-ui + `@guidelytech/shared/runtime` (everything an app ships) | 127 kB | **39.4 kB** |
184
+ | sdk-core + guide-runtime + floating-ui + `@guidelytech/shared/runtime` (everything an app ships) | 142 kB | **44.4 kB** |
153
185
  | Scout messenger chunk (`@guidelytech/guide-runtime/messenger`, loaded on demand) | 26 kB | 8.8 kB |
186
+ | Session-replay chunk (`./recorder`: rrweb + replay controller + network recording, loaded only when a session is recorded) | 94 kB | 30 kB |
154
187
  | script-tag build `guidely.js` (authoring schemas and messenger inlined) | 244 kB | ~71 kB |
155
188
 
156
189
  The SDK imports only the zod-free `@guidelytech/shared/runtime` entry; the Zod schemas are loaded
157
190
  lazily and only when the page enters authoring mode. Budget: 45 kB min+gz for the npm runtime and
158
- 15 kB for the messenger chunk.
191
+ 15 kB for the messenger chunk, 80 kB for the session-replay chunk.
@@ -0,0 +1,151 @@
1
+ var __defProp = Object.defineProperty;
2
+ var __defNormalProp = (obj, key, value) => key in obj ? __defProp(obj, key, { enumerable: true, configurable: true, writable: true, value }) : obj[key] = value;
3
+ var __publicField = (obj, key, value) => __defNormalProp(obj, typeof key !== "symbol" ? key + "" : key, value);
4
+
5
+ // src/http.ts
6
+ var Api = class {
7
+ constructor(baseUrl, key, fetchImpl) {
8
+ __publicField(this, "baseUrl", baseUrl);
9
+ __publicField(this, "key", key);
10
+ __publicField(this, "fetchImpl", fetchImpl);
11
+ }
12
+ url(path, params = {}) {
13
+ const query = Object.entries({ key: this.key, ...params }).filter((e) => e[1] != null).map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(v)}`).join("&");
14
+ return `${this.baseUrl.replace(/\/$/, "")}/api/v1/sdk${path}?${query}`;
15
+ }
16
+ get fetcher() {
17
+ if (this.fetchImpl) return this.fetchImpl;
18
+ return typeof fetch === "function" ? fetch.bind(globalThis) : null;
19
+ }
20
+ async request(method, path, options = {}) {
21
+ const f = this.fetcher;
22
+ if (!f) throw new Error("fetch is not available");
23
+ const headers = { ...options.headers, "X-Guidely-Key": this.key, Accept: "application/json" };
24
+ if (options.body !== void 0) headers["Content-Type"] = "application/json";
25
+ if (options.etag) headers["If-None-Match"] = options.etag;
26
+ const ac = typeof AbortController !== "undefined" ? new AbortController() : null;
27
+ const timer = ac ? setTimeout(() => ac.abort(), options.timeoutMs ?? 1e4) : null;
28
+ try {
29
+ const res = await f(this.url(path, options.params), {
30
+ method,
31
+ headers,
32
+ body: options.body === void 0 ? void 0 : JSON.stringify(options.body),
33
+ keepalive: options.keepalive,
34
+ signal: ac?.signal,
35
+ credentials: "omit",
36
+ mode: "cors"
37
+ });
38
+ let data = null;
39
+ if (res.status !== 304 && res.status !== 204) {
40
+ try {
41
+ data = await res.json();
42
+ } catch {
43
+ data = null;
44
+ }
45
+ }
46
+ return { status: res.status, ok: res.ok, data, etag: res.headers?.get?.("etag") ?? null };
47
+ } finally {
48
+ if (timer) clearTimeout(timer);
49
+ }
50
+ }
51
+ get(path, options) {
52
+ return this.request("GET", path, options);
53
+ }
54
+ post(path, body, options) {
55
+ return this.request("POST", path, { body, ...options });
56
+ }
57
+ /**
58
+ * POST that may answer with server-sent events. Each event is passed to `onEvent`; a JSON answer is
59
+ * returned instead. No timeout: Scout replies can take a while and the server keeps the stream alive.
60
+ */
61
+ async stream(path, body, onEvent, headers = {}) {
62
+ const f = this.fetcher;
63
+ if (!f) throw new Error("fetch is not available");
64
+ const res = await f(this.url(path), {
65
+ method: "POST",
66
+ headers: { ...headers, "X-Guidely-Key": this.key, Accept: "text/event-stream, application/json", "Content-Type": "application/json" },
67
+ body: JSON.stringify(body),
68
+ credentials: "omit",
69
+ mode: "cors"
70
+ });
71
+ const type = res.headers?.get?.("content-type") ?? "";
72
+ if (!type.includes("text/event-stream") || !res.body) {
73
+ let data = null;
74
+ try {
75
+ data = await res.json();
76
+ } catch {
77
+ data = null;
78
+ }
79
+ return { status: res.status, ok: res.ok, data, etag: null };
80
+ }
81
+ await readEventStream(res.body, onEvent);
82
+ return { status: res.status, ok: res.ok, data: null, etag: null };
83
+ }
84
+ /** `navigator.sendBeacon` when available (page unload); returns false when it could not be queued. */
85
+ beacon(path, body) {
86
+ try {
87
+ if (typeof navigator === "undefined" || typeof navigator.sendBeacon !== "function") return false;
88
+ const blob = new Blob([JSON.stringify(body)], { type: "text/plain;charset=UTF-8" });
89
+ return navigator.sendBeacon(this.url(path), blob);
90
+ } catch {
91
+ return false;
92
+ }
93
+ }
94
+ };
95
+ var sleep = (ms) => new Promise((r) => setTimeout(r, ms));
96
+ function backoffMs(attempt, baseMs = 1e3, maxMs = 3e4) {
97
+ return Math.min(maxMs, baseMs * 2 ** Math.max(0, attempt));
98
+ }
99
+ async function postWithRetry(api, path, body, attempts = 3, baseMs = 500) {
100
+ for (let i = 0; i < attempts; i++) {
101
+ try {
102
+ const res = await api.post(path, body);
103
+ if (res.ok || res.status >= 400 && res.status < 500 && res.status !== 429) return res;
104
+ } catch {
105
+ }
106
+ if (i < attempts - 1) await sleep(backoffMs(i, baseMs, 8e3));
107
+ }
108
+ return null;
109
+ }
110
+ async function readEventStream(body, onEvent) {
111
+ const reader = body.getReader();
112
+ const decoder = new TextDecoder();
113
+ let buffer = "";
114
+ const dispatch = (raw) => {
115
+ let event = "message";
116
+ const data = [];
117
+ for (const line of raw.split("\n")) {
118
+ if (!line || line.startsWith(":")) continue;
119
+ const i = line.indexOf(":");
120
+ const field = i < 0 ? line : line.slice(0, i);
121
+ const value = i < 0 ? "" : line.slice(i + 1).replace(/^ /, "");
122
+ if (field === "event") event = value;
123
+ else if (field === "data") data.push(value);
124
+ }
125
+ if (!data.length) return;
126
+ try {
127
+ onEvent(event, JSON.parse(data.join("\n")));
128
+ } catch {
129
+ }
130
+ };
131
+ for (; ; ) {
132
+ const { done, value } = await reader.read();
133
+ if (value) buffer += decoder.decode(value, { stream: true }).replace(/\r\n?/g, "\n");
134
+ let sep;
135
+ while ((sep = buffer.indexOf("\n\n")) >= 0) {
136
+ dispatch(buffer.slice(0, sep));
137
+ buffer = buffer.slice(sep + 2);
138
+ }
139
+ if (done) break;
140
+ }
141
+ if (buffer.trim()) dispatch(buffer);
142
+ }
143
+
144
+ export {
145
+ __publicField,
146
+ Api,
147
+ sleep,
148
+ backoffMs,
149
+ postWithRetry
150
+ };
151
+ //# sourceMappingURL=chunk-GJT7CFAC.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/http.ts"],"sourcesContent":["export interface ApiResponse<T> {\n status: number;\n ok: boolean;\n data: T | null;\n etag: string | null;\n}\n\nexport type FetchLike = typeof fetch;\n\n/** Thin HTTP client for the public SDK API. Adds the public key as query + header. */\nexport class Api {\n constructor(\n private readonly baseUrl: string,\n private readonly key: string,\n private readonly fetchImpl?: FetchLike,\n ) {}\n\n url(path: string, params: Record<string, string | undefined> = {}): string {\n // Built by hand rather than with URL/searchParams, which React Native does not implement.\n const query = Object.entries({ key: this.key, ...params })\n .filter((e): e is [string, string] => e[1] != null)\n .map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(v)}`)\n .join(\"&\");\n return `${this.baseUrl.replace(/\\/$/, \"\")}/api/v1/sdk${path}?${query}`;\n }\n\n private get fetcher(): FetchLike | null {\n if (this.fetchImpl) return this.fetchImpl;\n return typeof fetch === \"function\" ? fetch.bind(globalThis) : null;\n }\n\n async request<T>(method: \"GET\" | \"POST\", path: string, options: { body?: unknown; etag?: string | null; params?: Record<string, string | undefined>; keepalive?: boolean; timeoutMs?: number; headers?: Record<string, string> } = {}): Promise<ApiResponse<T>> {\n const f = this.fetcher;\n if (!f) throw new Error(\"fetch is not available\");\n const headers: Record<string, string> = { ...options.headers, \"X-Guidely-Key\": this.key, Accept: \"application/json\" };\n if (options.body !== undefined) headers[\"Content-Type\"] = \"application/json\";\n if (options.etag) headers[\"If-None-Match\"] = options.etag;\n const ac = typeof AbortController !== \"undefined\" ? new AbortController() : null;\n const timer = ac ? setTimeout(() => ac.abort(), options.timeoutMs ?? 10000) : null;\n try {\n const res = await f(this.url(path, options.params), {\n method,\n headers,\n body: options.body === undefined ? undefined : JSON.stringify(options.body),\n keepalive: options.keepalive,\n signal: ac?.signal,\n credentials: \"omit\",\n mode: \"cors\",\n });\n let data: T | null = null;\n if (res.status !== 304 && res.status !== 204) {\n try {\n data = (await res.json()) as T;\n } catch {\n data = null;\n }\n }\n return { status: res.status, ok: res.ok, data, etag: res.headers?.get?.(\"etag\") ?? null };\n } finally {\n if (timer) clearTimeout(timer);\n }\n }\n\n get<T>(path: string, options?: { etag?: string | null; params?: Record<string, string | undefined>; headers?: Record<string, string> }): Promise<ApiResponse<T>> {\n return this.request<T>(\"GET\", path, options);\n }\n\n post<T>(path: string, body: unknown, options?: { params?: Record<string, string | undefined>; keepalive?: boolean; headers?: Record<string, string> }): Promise<ApiResponse<T>> {\n return this.request<T>(\"POST\", path, { body, ...options });\n }\n\n /**\n * POST that may answer with server-sent events. Each event is passed to `onEvent`; a JSON answer is\n * returned instead. No timeout: Scout replies can take a while and the server keeps the stream alive.\n */\n async stream<T>(path: string, body: unknown, onEvent: (event: string, data: unknown) => void, headers: Record<string, string> = {}): Promise<ApiResponse<T>> {\n const f = this.fetcher;\n if (!f) throw new Error(\"fetch is not available\");\n const res = await f(this.url(path), {\n method: \"POST\",\n headers: { ...headers, \"X-Guidely-Key\": this.key, Accept: \"text/event-stream, application/json\", \"Content-Type\": \"application/json\" },\n body: JSON.stringify(body),\n credentials: \"omit\",\n mode: \"cors\",\n });\n const type = res.headers?.get?.(\"content-type\") ?? \"\";\n if (!type.includes(\"text/event-stream\") || !res.body) {\n let data: T | null = null;\n try {\n data = (await res.json()) as T;\n } catch {\n data = null;\n }\n return { status: res.status, ok: res.ok, data, etag: null };\n }\n await readEventStream(res.body, onEvent);\n return { status: res.status, ok: res.ok, data: null, etag: null };\n }\n\n /** `navigator.sendBeacon` when available (page unload); returns false when it could not be queued. */\n beacon(path: string, body: unknown): boolean {\n try {\n if (typeof navigator === \"undefined\" || typeof navigator.sendBeacon !== \"function\") return false;\n // text/plain is a CORS-safelisted type: cross-origin beacons need no preflight (which\n // browsers drop during unload). The API parses the body regardless of content type.\n const blob = new Blob([JSON.stringify(body)], { type: \"text/plain;charset=UTF-8\" });\n return navigator.sendBeacon(this.url(path), blob);\n } catch {\n return false;\n }\n }\n}\n\nexport const sleep = (ms: number) => new Promise<void>((r) => setTimeout(r, ms));\n\n/** Exponential backoff helper: 1s → 2s → 4s … capped. */\nexport function backoffMs(attempt: number, baseMs = 1000, maxMs = 30000): number {\n return Math.min(maxMs, baseMs * 2 ** Math.max(0, attempt));\n}\n\n/** Fire-and-forget POST with bounded retries (used for state sync; never blocks UI). */\nexport async function postWithRetry<T>(api: Api, path: string, body: unknown, attempts = 3, baseMs = 500): Promise<ApiResponse<T> | null> {\n for (let i = 0; i < attempts; i++) {\n try {\n const res = await api.post<T>(path, body);\n if (res.ok || (res.status >= 400 && res.status < 500 && res.status !== 429)) return res;\n } catch {\n /* network error → retry */\n }\n if (i < attempts - 1) await sleep(backoffMs(i, baseMs, 8000));\n }\n return null;\n}\n\n/** Parses a `text/event-stream` body (`event:` + single-line JSON `data:`; comment lines ignored). */\nexport async function readEventStream(body: ReadableStream<Uint8Array>, onEvent: (event: string, data: unknown) => void): Promise<void> {\n const reader = body.getReader();\n const decoder = new TextDecoder();\n let buffer = \"\";\n const dispatch = (raw: string) => {\n let event = \"message\";\n const data: string[] = [];\n for (const line of raw.split(\"\\n\")) {\n if (!line || line.startsWith(\":\")) continue;\n const i = line.indexOf(\":\");\n const field = i < 0 ? line : line.slice(0, i);\n const value = i < 0 ? \"\" : line.slice(i + 1).replace(/^ /, \"\");\n if (field === \"event\") event = value;\n else if (field === \"data\") data.push(value);\n }\n if (!data.length) return;\n try {\n onEvent(event, JSON.parse(data.join(\"\\n\")));\n } catch {\n /* ignore malformed events */\n }\n };\n for (;;) {\n const { done, value } = await reader.read();\n if (value) buffer += decoder.decode(value, { stream: true }).replace(/\\r\\n?/g, \"\\n\");\n let sep: number;\n while ((sep = buffer.indexOf(\"\\n\\n\")) >= 0) {\n dispatch(buffer.slice(0, sep));\n buffer = buffer.slice(sep + 2);\n }\n if (done) break;\n }\n if (buffer.trim()) dispatch(buffer);\n}\n"],"mappings":"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","names":[]}
@@ -0,0 +1,282 @@
1
+ import {
2
+ __publicField,
3
+ backoffMs
4
+ } from "./chunk-GJT7CFAC.js";
5
+
6
+ // src/storage.ts
7
+ function memoryStore() {
8
+ const map = /* @__PURE__ */ new Map();
9
+ return { get: (k) => map.get(k) ?? null, set: (k, v) => void map.set(k, v), remove: (k) => void map.delete(k) };
10
+ }
11
+ function webStore(area) {
12
+ try {
13
+ if (typeof window === "undefined") return null;
14
+ const s = window[area];
15
+ if (!s) return null;
16
+ const probe = "__guidely_probe__";
17
+ s.setItem(probe, "1");
18
+ s.removeItem(probe);
19
+ return {
20
+ get: (k) => {
21
+ try {
22
+ return s.getItem(k);
23
+ } catch {
24
+ return null;
25
+ }
26
+ },
27
+ set: (k, v) => {
28
+ try {
29
+ s.setItem(k, v);
30
+ } catch {
31
+ }
32
+ },
33
+ remove: (k) => {
34
+ try {
35
+ s.removeItem(k);
36
+ } catch {
37
+ }
38
+ }
39
+ };
40
+ } catch {
41
+ return null;
42
+ }
43
+ }
44
+ function createStorage(kind = "local") {
45
+ if (kind === "memory") return memoryStore();
46
+ return webStore(kind === "local" ? "localStorage" : "sessionStorage") ?? memoryStore();
47
+ }
48
+ function readJson(store, key, fallback) {
49
+ const raw = store.get(key);
50
+ if (raw == null) return fallback;
51
+ try {
52
+ return JSON.parse(raw);
53
+ } catch {
54
+ return fallback;
55
+ }
56
+ }
57
+ function writeJson(store, key, value) {
58
+ try {
59
+ store.set(key, JSON.stringify(value));
60
+ } catch {
61
+ }
62
+ }
63
+ var KEYS = {
64
+ anon: "guidely:anon",
65
+ seen: "guidely:seen",
66
+ config: (key) => `guidely:config:${key}`,
67
+ etag: (key) => `guidely:etag:${key}`,
68
+ state: (key) => `guidely:state:${key}`,
69
+ user: (key) => `guidely:user:${key}`,
70
+ queue: (key) => `guidely:queue:${key}`,
71
+ session: "guidely:session",
72
+ active: "guidely:active",
73
+ authoring: "guidely:authoring",
74
+ /** Messenger conversations this browser can read: [{ id, token }]. */
75
+ conversations: (key) => `guidely:conversations:${key}`
76
+ };
77
+
78
+ // src/queue.ts
79
+ var EventQueue = class {
80
+ constructor(opts) {
81
+ __publicField(this, "opts", opts);
82
+ __publicField(this, "events", []);
83
+ __publicField(this, "timer", null);
84
+ __publicField(this, "retryTimer", null);
85
+ __publicField(this, "inflight", false);
86
+ __publicField(this, "inflightIds", /* @__PURE__ */ new Set());
87
+ __publicField(this, "attempt", 0);
88
+ __publicField(this, "destroyed", false);
89
+ __publicField(this, "flushIntervalMs");
90
+ __publicField(this, "maxQueue");
91
+ __publicField(this, "batchSize");
92
+ __publicField(this, "maxAttempts");
93
+ this.flushIntervalMs = opts.flushIntervalMs ?? 2e3;
94
+ this.maxQueue = opts.maxQueue ?? 200;
95
+ this.batchSize = opts.batchSize ?? 50;
96
+ this.maxAttempts = opts.maxAttempts ?? 5;
97
+ const persisted = readJson(opts.store, opts.storageKey, []);
98
+ if (Array.isArray(persisted)) this.events = persisted.filter((e) => e && typeof e.eventId === "string").slice(-this.maxQueue);
99
+ if (this.events.length) this.schedule();
100
+ }
101
+ get size() {
102
+ return this.events.length;
103
+ }
104
+ /** Pending events (read-only copy, for debugging). */
105
+ peek() {
106
+ return this.events.slice();
107
+ }
108
+ enqueue(event) {
109
+ if (this.destroyed) return;
110
+ if (this.events.some((e) => e.eventId === event.eventId)) return;
111
+ this.events.push(event);
112
+ if (this.events.length > this.maxQueue) this.events.splice(0, this.events.length - this.maxQueue);
113
+ this.persist();
114
+ this.schedule();
115
+ }
116
+ persist() {
117
+ writeJson(this.opts.store, this.opts.storageKey, this.events);
118
+ }
119
+ schedule() {
120
+ if (this.timer || this.retryTimer || this.destroyed) return;
121
+ this.timer = setTimeout(() => {
122
+ this.timer = null;
123
+ void this.flush();
124
+ }, this.flushIntervalMs);
125
+ }
126
+ /** Sends queued events now (one batch at a time). Safe to call at any time. */
127
+ async flush() {
128
+ if (this.destroyed || this.inflight || !this.events.length || !this.opts.enabled()) return;
129
+ if (this.timer) {
130
+ clearTimeout(this.timer);
131
+ this.timer = null;
132
+ }
133
+ this.inflight = true;
134
+ const batch = this.events.slice(0, this.batchSize);
135
+ this.inflightIds = new Set(batch.map((e) => e.eventId));
136
+ let ok = false;
137
+ let retry = true;
138
+ try {
139
+ const res = await this.opts.send(batch);
140
+ ok = res.ok;
141
+ retry = res.retry ?? !res.ok;
142
+ } catch (err) {
143
+ this.opts.onError?.(err);
144
+ }
145
+ this.inflight = false;
146
+ this.inflightIds.clear();
147
+ if (this.destroyed) return;
148
+ if (ok || !retry) {
149
+ const ids = new Set(batch.map((e) => e.eventId));
150
+ this.events = this.events.filter((e) => !ids.has(e.eventId));
151
+ this.attempt = 0;
152
+ this.persist();
153
+ if (this.events.length) this.schedule();
154
+ return;
155
+ }
156
+ this.attempt += 1;
157
+ if (this.attempt >= this.maxAttempts) {
158
+ this.attempt = 0;
159
+ return;
160
+ }
161
+ this.retryTimer = setTimeout(() => {
162
+ this.retryTimer = null;
163
+ void this.flush();
164
+ }, backoffMs(this.attempt - 1));
165
+ }
166
+ /** Best-effort synchronous flush on unload using `sendBeacon`. */
167
+ flushSync() {
168
+ if (this.destroyed || !this.opts.enabled() || !this.opts.beacon) return;
169
+ const batch = this.events.filter((e) => !this.inflightIds.has(e.eventId)).slice(0, this.batchSize);
170
+ if (!batch.length) return;
171
+ if (this.opts.beacon(batch)) {
172
+ const ids = new Set(batch.map((e) => e.eventId));
173
+ this.events = this.events.filter((e) => !ids.has(e.eventId));
174
+ this.persist();
175
+ }
176
+ }
177
+ clear() {
178
+ this.events = [];
179
+ this.persist();
180
+ }
181
+ destroy() {
182
+ this.destroyed = true;
183
+ if (this.timer) clearTimeout(this.timer);
184
+ if (this.retryTimer) clearTimeout(this.retryTimer);
185
+ this.timer = null;
186
+ this.retryTimer = null;
187
+ this.persist();
188
+ }
189
+ };
190
+
191
+ // src/emitter.ts
192
+ var Emitter = class {
193
+ constructor(onError) {
194
+ __publicField(this, "onError", onError);
195
+ __publicField(this, "listeners", /* @__PURE__ */ new Map());
196
+ }
197
+ on(event, cb) {
198
+ let set = this.listeners.get(event);
199
+ if (!set) {
200
+ set = /* @__PURE__ */ new Set();
201
+ this.listeners.set(event, set);
202
+ }
203
+ set.add(cb);
204
+ return () => this.off(event, cb);
205
+ }
206
+ off(event, cb) {
207
+ this.listeners.get(event)?.delete(cb);
208
+ }
209
+ emit(event, ...args) {
210
+ const set = this.listeners.get(event);
211
+ if (!set) return;
212
+ for (const cb of Array.from(set)) {
213
+ try {
214
+ cb(...args);
215
+ } catch (err) {
216
+ this.onError?.(err);
217
+ }
218
+ }
219
+ }
220
+ clear() {
221
+ this.listeners.clear();
222
+ }
223
+ };
224
+
225
+ // src/frequency.ts
226
+ var HOUR = 3600 * 1e3;
227
+ function isFrequencyEligible(freq, state, shownThisSession, now = Date.now()) {
228
+ const s = state ?? { impressions: 0 };
229
+ if (freq.maxImpressions != null && s.impressions >= freq.maxImpressions) return false;
230
+ if (s.completedAt && freq.mode !== "every_time") return false;
231
+ if (s.dismissedAt) {
232
+ if (!freq.reshowAfterDismiss) return false;
233
+ if (freq.cooldownHours > 0 && now - Date.parse(s.dismissedAt) < freq.cooldownHours * HOUR) return false;
234
+ }
235
+ switch (freq.mode) {
236
+ case "once":
237
+ return s.impressions === 0 || !!s.dismissedAt && freq.reshowAfterDismiss;
238
+ case "once_per_session":
239
+ return !shownThisSession;
240
+ case "interval": {
241
+ if (!s.lastShownAt) return true;
242
+ const hours = freq.intervalHours ?? 24;
243
+ return now - Date.parse(s.lastShownAt) >= hours * HOUR;
244
+ }
245
+ case "every_time":
246
+ default:
247
+ return true;
248
+ }
249
+ }
250
+ function isExclusiveGuide(guide) {
251
+ if (guide.kind === "banner" || guide.kind === "checklist") return false;
252
+ const steps = guide.config.steps;
253
+ if (steps.length > 0 && steps.every((s) => s.type === "hotspot" || s.type === "banner")) return false;
254
+ return true;
255
+ }
256
+ function areMutuallyExclusive(a, b) {
257
+ const la = a.config.frequency.mutuallyExclusiveWith;
258
+ const lb = b.config.frequency.mutuallyExclusiveWith;
259
+ return la.includes(b.id) || la.includes(b.key) || lb.includes(a.id) || lb.includes(a.key);
260
+ }
261
+ function isWithinSchedule(guide, now = Date.now()) {
262
+ const s = guide.config.survey;
263
+ if (!s) return true;
264
+ if (s.startAt && now < Date.parse(s.startAt)) return false;
265
+ if (s.endAt && now > Date.parse(s.endAt)) return false;
266
+ return true;
267
+ }
268
+
269
+ export {
270
+ memoryStore,
271
+ createStorage,
272
+ readJson,
273
+ writeJson,
274
+ KEYS,
275
+ EventQueue,
276
+ Emitter,
277
+ isFrequencyEligible,
278
+ isExclusiveGuide,
279
+ areMutuallyExclusive,
280
+ isWithinSchedule
281
+ };
282
+ //# sourceMappingURL=chunk-HLR3W63Z.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/storage.ts","../src/queue.ts","../src/emitter.ts","../src/frequency.ts"],"sourcesContent":["export type StorageKind = \"local\" | \"session\" | \"memory\";\n\n/** Minimal synchronous key/value store; never throws (private mode, quota, SSR). */\nexport interface KeyValueStore {\n get(key: string): string | null;\n set(key: string, value: string): void;\n remove(key: string): void;\n}\n\nexport function memoryStore(): KeyValueStore {\n const map = new Map<string, string>();\n return { get: (k) => map.get(k) ?? null, set: (k, v) => void map.set(k, v), remove: (k) => void map.delete(k) };\n}\n\nfunction webStore(area: \"localStorage\" | \"sessionStorage\"): KeyValueStore | null {\n try {\n if (typeof window === \"undefined\") return null;\n const s = window[area];\n if (!s) return null;\n const probe = \"__guidely_probe__\";\n s.setItem(probe, \"1\");\n s.removeItem(probe);\n return {\n get: (k) => {\n try {\n return s.getItem(k);\n } catch {\n return null;\n }\n },\n set: (k, v) => {\n try {\n s.setItem(k, v);\n } catch {\n /* quota / private mode */\n }\n },\n remove: (k) => {\n try {\n s.removeItem(k);\n } catch {\n /* ignore */\n }\n },\n };\n } catch {\n return null;\n }\n}\n\n/** Creates a store of the requested kind, degrading to memory when unavailable. */\nexport function createStorage(kind: StorageKind = \"local\"): KeyValueStore {\n if (kind === \"memory\") return memoryStore();\n return webStore(kind === \"local\" ? \"localStorage\" : \"sessionStorage\") ?? memoryStore();\n}\n\nexport function readJson<T>(store: KeyValueStore, key: string, fallback: T): T {\n const raw = store.get(key);\n if (raw == null) return fallback;\n try {\n return JSON.parse(raw) as T;\n } catch {\n return fallback;\n }\n}\n\nexport function writeJson(store: KeyValueStore, key: string, value: unknown): void {\n try {\n store.set(key, JSON.stringify(value));\n } catch {\n /* ignore */\n }\n}\n\n/** Storage keys used by the SDK (documented for integrators). */\nexport const KEYS = {\n anon: \"guidely:anon\",\n seen: \"guidely:seen\",\n config: (key: string) => `guidely:config:${key}`,\n etag: (key: string) => `guidely:etag:${key}`,\n state: (key: string) => `guidely:state:${key}`,\n user: (key: string) => `guidely:user:${key}`,\n queue: (key: string) => `guidely:queue:${key}`,\n session: \"guidely:session\",\n active: \"guidely:active\",\n authoring: \"guidely:authoring\",\n /** Messenger conversations this browser can read: [{ id, token }]. */\n conversations: (key: string) => `guidely:conversations:${key}`,\n} as const;\n","import type { SdkEvent } from \"@guidelytech/shared\";\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 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`, retries with exponential backoff (1s→30s, 5 attempts) and keeps failed events for\n * the next flush. Drops the oldest events once the queue exceeds 200.\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 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 ?? 200;\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 }\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 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 this.attempt += 1;\n if (this.attempt >= this.maxAttempts) {\n // Give up for now; events stay queued until the next flush trigger (navigation, timer, unload).\n this.attempt = 0;\n return;\n }\n this.retryTimer = setTimeout(() => {\n this.retryTimer = null;\n void this.flush();\n }, backoffMs(this.attempt - 1));\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 if (this.opts.beacon(batch)) {\n const ids = new Set(batch.map((e) => e.eventId));\n this.events = this.events.filter((e) => !ids.has(e.eventId));\n this.persist();\n }\n }\n\n clear(): void {\n this.events = [];\n this.persist();\n }\n\n destroy(): void {\n this.destroyed = true;\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","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}\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"],"mappings":"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","names":[]}