@usereq/widget 0.2.2 → 0.2.4

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.
@@ -1,431 +0,0 @@
1
- import type { WidgetSessionState } from "../types";
2
- import type {
3
- WidgetAnalyticsBatchEvent,
4
- WidgetAnalyticsBatchRequest,
5
- WidgetClientAnalyticsEventType,
6
- WidgetTriggerRuleType,
7
- } from "../shared/analytics";
8
- import { getWidgetApiBase } from "./api-origin";
9
-
10
- const ANALYTICS_ENABLED = false;
11
-
12
- const ANALYTICS_ENDPOINT = "/api/widget/events/batch";
13
- const DEFAULT_BATCH_SIZE = 10;
14
- const DEFAULT_FLUSH_INTERVAL_MS = 2500;
15
- const MAX_FLUSH_INTERVAL_MS = 120_000;
16
- const MAX_QUEUE_SIZE = 200;
17
- const MAX_EVENT_ATTEMPTS = 5;
18
- const MAX_EVENT_AGE_MS = 10 * 60 * 1000;
19
- const UNIQUE_SCRIPT_LOADED_KEY_PREFIX =
20
- "usereq-widget:unique-script-loaded:";
21
-
22
- type QueuedAnalyticsEvent = {
23
- event: WidgetAnalyticsBatchEvent;
24
- sessionToken: string;
25
- attempts: number;
26
- enqueuedAt: number;
27
- };
28
-
29
- type SendOutcome =
30
- | { outcome: "ok" }
31
- | { outcome: "drop" }
32
- | { outcome: "retry"; retryAfterMs?: number };
33
-
34
- function parseRetryAfter(headerValue: string | null): number | undefined {
35
- if (!headerValue) return undefined;
36
- const trimmed = headerValue.trim();
37
- if (!trimmed) return undefined;
38
- const seconds = Number(trimmed);
39
- if (Number.isFinite(seconds) && seconds >= 0) {
40
- return Math.min(MAX_FLUSH_INTERVAL_MS, seconds * 1000);
41
- }
42
- const dateMs = Date.parse(trimmed);
43
- if (!Number.isNaN(dateMs)) {
44
- const delta = dateMs - Date.now();
45
- if (delta > 0) return Math.min(MAX_FLUSH_INTERVAL_MS, delta);
46
- }
47
- return undefined;
48
- }
49
-
50
- function classifyResponse(response: Response): SendOutcome {
51
- if (response.ok) return { outcome: "ok" };
52
- const status = response.status;
53
- if (status === 408 || status === 425 || status === 429 || status >= 500) {
54
- return {
55
- outcome: "retry",
56
- retryAfterMs: parseRetryAfter(response.headers.get("retry-after")),
57
- };
58
- }
59
- if (status >= 400 && status < 500) return { outcome: "drop" };
60
- return { outcome: "retry" };
61
- }
62
-
63
- export type WidgetAnalyticsTrackerOptions = {
64
- mode: "embed" | "preview";
65
- agentId: string;
66
- getSession: () => WidgetSessionState | null;
67
- getConversationId: () => string | undefined;
68
- batchSize?: number;
69
- flushIntervalMs?: number;
70
- };
71
-
72
- export class WidgetAnalyticsTracker {
73
- private readonly mode: "embed" | "preview";
74
- private readonly agentId: string;
75
- private readonly getSession: () => WidgetSessionState | null;
76
- private readonly getConversationId: () => string | undefined;
77
- private readonly batchSize: number;
78
- private readonly flushIntervalMs: number;
79
- private readonly uniqueScriptSeen = new Set<string>();
80
- private flushTimer: number | null = null;
81
- private queue: QueuedAnalyticsEvent[] = [];
82
- private disposed = false;
83
- private consecutiveFailures = 0;
84
- private nextAllowedFlushAt = 0;
85
-
86
- constructor(options: WidgetAnalyticsTrackerOptions) {
87
- this.mode = options.mode;
88
- this.agentId = options.agentId;
89
- this.getSession = options.getSession;
90
- this.getConversationId = options.getConversationId;
91
- this.batchSize = Math.max(1, options.batchSize ?? DEFAULT_BATCH_SIZE);
92
- this.flushIntervalMs = Math.max(
93
- 200,
94
- options.flushIntervalMs ?? DEFAULT_FLUSH_INTERVAL_MS,
95
- );
96
-
97
- if (ANALYTICS_ENABLED && this.mode === "embed") {
98
- window.addEventListener("pagehide", this.handlePageHide, {
99
- capture: true,
100
- });
101
- document.addEventListener(
102
- "visibilitychange",
103
- this.handleVisibilityChange,
104
- true,
105
- );
106
- }
107
- }
108
-
109
- dispose() {
110
- if (this.disposed) return;
111
- this.disposed = true;
112
- this.clearFlushTimer();
113
- if (!ANALYTICS_ENABLED) return;
114
- window.removeEventListener("pagehide", this.handlePageHide, true);
115
- document.removeEventListener(
116
- "visibilitychange",
117
- this.handleVisibilityChange,
118
- true,
119
- );
120
- void this.flush({ transport: "beacon" });
121
- }
122
-
123
- trackScriptLoaded() {
124
- this.track({ eventType: "SCRIPT_LOADED" });
125
- }
126
-
127
- trackUniqueScriptLoaded() {
128
- if (!ANALYTICS_ENABLED) return;
129
- const sessionToken = this.getSession()?.sessionToken?.trim();
130
- if (!sessionToken) return;
131
- const uniqueKey = `${this.agentId}:${sessionToken}`;
132
-
133
- if (this.uniqueScriptSeen.has(uniqueKey)) return;
134
- if (this.hasSessionUniqueScriptLoaded(uniqueKey)) {
135
- this.uniqueScriptSeen.add(uniqueKey);
136
- return;
137
- }
138
-
139
- this.uniqueScriptSeen.add(uniqueKey);
140
- this.setSessionUniqueScriptLoaded(uniqueKey);
141
- this.track({ eventType: "UNIQUE_SCRIPT_LOADED" });
142
- }
143
-
144
- trackView(triggerRuleType: WidgetTriggerRuleType | null) {
145
- this.track({ eventType: "VIEW", triggerRuleType });
146
- if (triggerRuleType === "element_displayed") {
147
- this.track({
148
- eventType: "DISPLAY_AFTER_ELEMENT_SHOWED",
149
- triggerRuleType,
150
- });
151
- }
152
- }
153
-
154
- trackClick() {
155
- this.track({ eventType: "CLICK" });
156
- }
157
-
158
- trackLinkClicked(linkUrl: string) {
159
- const raw = linkUrl.trim();
160
- if (!raw) return;
161
- const resolvedUrl = this.normalizeLinkUrl(raw);
162
- this.track({ eventType: "LINK_CLICKED", linkUrl: resolvedUrl });
163
- }
164
-
165
- track(event: Omit<WidgetAnalyticsBatchEvent, "occurredAt"> & {
166
- occurredAt?: string;
167
- }) {
168
- if (!ANALYTICS_ENABLED) return;
169
- if (this.disposed || this.mode !== "embed") return;
170
-
171
- const sessionToken = this.getSession()?.sessionToken?.trim();
172
- if (!sessionToken) return;
173
-
174
- const { occurredAt, conversationId, triggerRuleType, linkUrl, ...rest } = event;
175
- const resolvedConversationId = conversationId ?? this.getConversationId();
176
- const queuedEvent: WidgetAnalyticsBatchEvent = {
177
- ...rest,
178
- occurredAt: occurredAt ?? new Date().toISOString(),
179
- ...(resolvedConversationId ? { conversationId: resolvedConversationId } : {}),
180
- ...(triggerRuleType ? { triggerRuleType } : {}),
181
- ...(linkUrl ? { linkUrl } : {}),
182
- };
183
-
184
- this.enqueue({
185
- event: queuedEvent,
186
- sessionToken,
187
- attempts: 0,
188
- enqueuedAt: Date.now(),
189
- });
190
-
191
- if (this.queue.length >= this.batchSize) {
192
- void this.flush();
193
- return;
194
- }
195
-
196
- this.scheduleFlush();
197
- }
198
-
199
- private enqueue(entry: QueuedAnalyticsEvent) {
200
- this.queue.push(entry);
201
- if (this.queue.length > MAX_QUEUE_SIZE) {
202
- this.queue.splice(0, this.queue.length - MAX_QUEUE_SIZE);
203
- }
204
- }
205
-
206
- private pruneStale(now: number) {
207
- if (this.queue.length === 0) return;
208
- this.queue = this.queue.filter(
209
- (entry) => now - entry.enqueuedAt < MAX_EVENT_AGE_MS,
210
- );
211
- }
212
-
213
- async flush(options?: {
214
- transport?: "default" | "beacon";
215
- }): Promise<void> {
216
- if (!ANALYTICS_ENABLED) return;
217
- if (this.disposed) return;
218
-
219
- const now = Date.now();
220
- this.pruneStale(now);
221
- if (this.queue.length === 0) {
222
- this.clearFlushTimer();
223
- return;
224
- }
225
-
226
- this.clearFlushTimer();
227
- const transport = options?.transport ?? "default";
228
- const snapshot = this.queue;
229
- this.queue = [];
230
-
231
- const grouped = new Map<string, QueuedAnalyticsEvent[]>();
232
- for (const entry of snapshot) {
233
- const group = grouped.get(entry.sessionToken);
234
- if (group) {
235
- group.push(entry);
236
- } else {
237
- grouped.set(entry.sessionToken, [entry]);
238
- }
239
- }
240
-
241
- const failed: QueuedAnalyticsEvent[] = [];
242
- let maxRetryAfterMs = 0;
243
- let anyRetry = false;
244
- let anySuccess = false;
245
-
246
- for (const [sessionToken, entries] of grouped.entries()) {
247
- const result = await this.sendBatch({
248
- events: entries.map((entry) => entry.event),
249
- sessionToken,
250
- transport,
251
- });
252
-
253
- if (result.outcome === "ok") {
254
- anySuccess = true;
255
- continue;
256
- }
257
- if (result.outcome === "drop") {
258
- continue;
259
- }
260
- anyRetry = true;
261
- if (result.retryAfterMs && result.retryAfterMs > maxRetryAfterMs) {
262
- maxRetryAfterMs = result.retryAfterMs;
263
- }
264
- for (const entry of entries) {
265
- const attempts = entry.attempts + 1;
266
- if (attempts >= MAX_EVENT_ATTEMPTS) continue;
267
- failed.push({ ...entry, attempts });
268
- }
269
- }
270
-
271
- if (anySuccess && !anyRetry) {
272
- this.consecutiveFailures = 0;
273
- this.nextAllowedFlushAt = 0;
274
- } else if (anyRetry) {
275
- this.consecutiveFailures += 1;
276
- }
277
-
278
- if (failed.length > 0) {
279
- for (const entry of failed) {
280
- this.enqueue(entry);
281
- }
282
- const backoff = this.computeBackoffDelay();
283
- const delay = Math.max(backoff, maxRetryAfterMs);
284
- this.nextAllowedFlushAt = Date.now() + delay;
285
- this.scheduleFlush(delay);
286
- }
287
- }
288
-
289
- private computeBackoffDelay(): number {
290
- const exponent = Math.max(0, this.consecutiveFailures - 1);
291
- const base = this.flushIntervalMs * Math.pow(2, exponent);
292
- const jitter = base * 0.2 * Math.random();
293
- return Math.min(MAX_FLUSH_INTERVAL_MS, base + jitter);
294
- }
295
-
296
- private async sendBatch(input: {
297
- events: WidgetAnalyticsBatchEvent[];
298
- sessionToken: string;
299
- transport: "default" | "beacon";
300
- }): Promise<SendOutcome> {
301
- if (input.events.length === 0) return { outcome: "ok" };
302
-
303
- const url = new URL(ANALYTICS_ENDPOINT, `${getWidgetApiBase()}/`).toString();
304
- const sanitizedEvents = input.events.map((event) => {
305
- const { conversationId, triggerRuleType, linkUrl, ...rest } = event;
306
- return {
307
- ...rest,
308
- ...(conversationId ? { conversationId } : {}),
309
- ...(triggerRuleType ? { triggerRuleType } : {}),
310
- ...(linkUrl ? { linkUrl } : {}),
311
- };
312
- });
313
- const payload: WidgetAnalyticsBatchRequest = {
314
- events: sanitizedEvents,
315
- pageUrl: window.location.href,
316
- referrer: document.referrer || undefined,
317
- // sendBeacon cannot set Authorization header, so include token in body as fallback.
318
- sessionToken: input.sessionToken,
319
- };
320
-
321
- if (input.transport === "beacon" && typeof navigator.sendBeacon === "function") {
322
- const body = new Blob([JSON.stringify(payload)], {
323
- type: "application/json",
324
- });
325
- if (navigator.sendBeacon(url, body)) {
326
- return { outcome: "ok" };
327
- }
328
- }
329
-
330
- try {
331
- const response = await fetch(url, {
332
- method: "POST",
333
- headers: {
334
- Authorization: `Bearer ${input.sessionToken}`,
335
- "Content-Type": "application/json",
336
- },
337
- body: JSON.stringify(payload),
338
- credentials: "omit",
339
- keepalive: input.transport === "beacon",
340
- });
341
- return classifyResponse(response);
342
- } catch {
343
- return { outcome: "retry" };
344
- }
345
- }
346
-
347
- private scheduleFlush(delayMs?: number) {
348
- if (this.flushTimer != null) return;
349
- const delay = Math.max(0, delayMs ?? this.flushIntervalMs);
350
- this.flushTimer = window.setTimeout(() => {
351
- this.flushTimer = null;
352
- void this.flush();
353
- }, delay);
354
- }
355
-
356
- private clearFlushTimer() {
357
- if (this.flushTimer == null) return;
358
- window.clearTimeout(this.flushTimer);
359
- this.flushTimer = null;
360
- }
361
-
362
- private hasSessionUniqueScriptLoaded(uniqueKey: string): boolean {
363
- try {
364
- return (
365
- sessionStorage.getItem(`${UNIQUE_SCRIPT_LOADED_KEY_PREFIX}${uniqueKey}`) ===
366
- "1"
367
- );
368
- } catch {
369
- return false;
370
- }
371
- }
372
-
373
- private setSessionUniqueScriptLoaded(uniqueKey: string) {
374
- try {
375
- sessionStorage.setItem(
376
- `${UNIQUE_SCRIPT_LOADED_KEY_PREFIX}${uniqueKey}`,
377
- "1",
378
- );
379
- } catch {
380
- // no-op
381
- }
382
- }
383
-
384
- private readonly handlePageHide = () => {
385
- void this.flush({ transport: "beacon" });
386
- };
387
-
388
- private readonly handleVisibilityChange = () => {
389
- if (document.visibilityState === "hidden") {
390
- void this.flush({ transport: "beacon" });
391
- }
392
- };
393
-
394
- private normalizeLinkUrl(value: string): string {
395
- try {
396
- return new URL(value, window.location.href).toString();
397
- } catch {
398
- return value;
399
- }
400
- }
401
- }
402
-
403
- export function createWidgetAnalyticsTracker(
404
- options: WidgetAnalyticsTrackerOptions,
405
- ): WidgetAnalyticsTracker {
406
- return new WidgetAnalyticsTracker(options);
407
- }
408
-
409
- export function isWidgetTriggerRuleType(
410
- value: string | null | undefined,
411
- ): value is WidgetTriggerRuleType {
412
- return (
413
- value === "time_delay" ||
414
- value === "scroll_to_bottom" ||
415
- value === "element_clicked" ||
416
- value === "element_displayed"
417
- );
418
- }
419
-
420
- export function isWidgetClientAnalyticsEventType(
421
- value: string | null | undefined,
422
- ): value is WidgetClientAnalyticsEventType {
423
- return (
424
- value === "SCRIPT_LOADED" ||
425
- value === "UNIQUE_SCRIPT_LOADED" ||
426
- value === "VIEW" ||
427
- value === "CLICK" ||
428
- value === "DISPLAY_AFTER_ELEMENT_SHOWED" ||
429
- value === "LINK_CLICKED"
430
- );
431
- }
@@ -1,13 +0,0 @@
1
- export function scheduleRenewal(
2
- expiresAt: string,
3
- onRenew: () => Promise<void> | void
4
- ): () => void {
5
- const expiryMs = new Date(expiresAt).getTime();
6
- const delay = Math.max(5_000, expiryMs - Date.now() - 60_000);
7
- const timer = window.setTimeout(() => {
8
- void onRenew();
9
- }, delay);
10
-
11
- return () => window.clearTimeout(timer);
12
- }
13
-
@@ -1,54 +0,0 @@
1
- export const WIDGET_ANALYTICS_EVENT_TYPES = [
2
- "SCRIPT_LOADED",
3
- "UNIQUE_SCRIPT_LOADED",
4
- "VIEW",
5
- "CLICK",
6
- "SESSION_START",
7
- "DISPLAY_AFTER_ELEMENT_SHOWED",
8
- "WIDGET_DISPLAY_AFTER_VSL_SHOWED",
9
- "LINK_CLICKED",
10
- ] as const;
11
-
12
- export type WidgetAnalyticsEventType =
13
- (typeof WIDGET_ANALYTICS_EVENT_TYPES)[number];
14
-
15
- export const WIDGET_CLIENT_ANALYTICS_EVENT_TYPES = [
16
- "SCRIPT_LOADED",
17
- "UNIQUE_SCRIPT_LOADED",
18
- "VIEW",
19
- "CLICK",
20
- "DISPLAY_AFTER_ELEMENT_SHOWED",
21
- "LINK_CLICKED",
22
- ] as const;
23
-
24
- export type WidgetClientAnalyticsEventType =
25
- (typeof WIDGET_CLIENT_ANALYTICS_EVENT_TYPES)[number];
26
-
27
- export const WIDGET_TRIGGER_RULE_TYPES = [
28
- "time_delay",
29
- "scroll_to_bottom",
30
- "element_clicked",
31
- "element_displayed",
32
- ] as const;
33
-
34
- export type WidgetTriggerRuleType = (typeof WIDGET_TRIGGER_RULE_TYPES)[number];
35
-
36
- export type WidgetAnalyticsBatchEvent = {
37
- eventType: WidgetClientAnalyticsEventType;
38
- occurredAt?: string;
39
- conversationId?: string | null;
40
- triggerRuleType?: WidgetTriggerRuleType | null;
41
- linkUrl?: string | null;
42
- };
43
-
44
- export type WidgetAnalyticsBatchRequest = {
45
- events: WidgetAnalyticsBatchEvent[];
46
- pageUrl?: string;
47
- referrer?: string;
48
- sessionToken?: string;
49
- };
50
-
51
- export type WidgetAnalyticsBatchResponse = {
52
- accepted: number;
53
- };
54
-