@usereq/widget 0.1.2 → 0.1.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.
@@ -10,14 +10,54 @@ import { getWidgetApiBase } from "./api-origin";
10
10
  const ANALYTICS_ENDPOINT = "/api/widget/events/batch";
11
11
  const DEFAULT_BATCH_SIZE = 10;
12
12
  const DEFAULT_FLUSH_INTERVAL_MS = 2500;
13
+ const MAX_FLUSH_INTERVAL_MS = 120_000;
14
+ const MAX_QUEUE_SIZE = 200;
15
+ const MAX_EVENT_ATTEMPTS = 5;
16
+ const MAX_EVENT_AGE_MS = 10 * 60 * 1000;
13
17
  const UNIQUE_SCRIPT_LOADED_KEY_PREFIX =
14
18
  "usereq-widget:unique-script-loaded:";
15
19
 
16
20
  type QueuedAnalyticsEvent = {
17
21
  event: WidgetAnalyticsBatchEvent;
18
22
  sessionToken: string;
23
+ attempts: number;
24
+ enqueuedAt: number;
19
25
  };
20
26
 
27
+ type SendOutcome =
28
+ | { outcome: "ok" }
29
+ | { outcome: "drop" }
30
+ | { outcome: "retry"; retryAfterMs?: number };
31
+
32
+ function parseRetryAfter(headerValue: string | null): number | undefined {
33
+ if (!headerValue) return undefined;
34
+ const trimmed = headerValue.trim();
35
+ if (!trimmed) return undefined;
36
+ const seconds = Number(trimmed);
37
+ if (Number.isFinite(seconds) && seconds >= 0) {
38
+ return Math.min(MAX_FLUSH_INTERVAL_MS, seconds * 1000);
39
+ }
40
+ const dateMs = Date.parse(trimmed);
41
+ if (!Number.isNaN(dateMs)) {
42
+ const delta = dateMs - Date.now();
43
+ if (delta > 0) return Math.min(MAX_FLUSH_INTERVAL_MS, delta);
44
+ }
45
+ return undefined;
46
+ }
47
+
48
+ function classifyResponse(response: Response): SendOutcome {
49
+ if (response.ok) return { outcome: "ok" };
50
+ const status = response.status;
51
+ if (status === 408 || status === 425 || status === 429 || status >= 500) {
52
+ return {
53
+ outcome: "retry",
54
+ retryAfterMs: parseRetryAfter(response.headers.get("retry-after")),
55
+ };
56
+ }
57
+ if (status >= 400 && status < 500) return { outcome: "drop" };
58
+ return { outcome: "retry" };
59
+ }
60
+
21
61
  export type WidgetAnalyticsTrackerOptions = {
22
62
  mode: "embed" | "preview";
23
63
  agentId: string;
@@ -38,6 +78,8 @@ export class WidgetAnalyticsTracker {
38
78
  private flushTimer: number | null = null;
39
79
  private queue: QueuedAnalyticsEvent[] = [];
40
80
  private disposed = false;
81
+ private consecutiveFailures = 0;
82
+ private nextAllowedFlushAt = 0;
41
83
 
42
84
  constructor(options: WidgetAnalyticsTrackerOptions) {
43
85
  this.mode = options.mode;
@@ -134,9 +176,11 @@ export class WidgetAnalyticsTracker {
134
176
  ...(linkUrl ? { linkUrl } : {}),
135
177
  };
136
178
 
137
- this.queue.push({
179
+ this.enqueue({
138
180
  event: queuedEvent,
139
181
  sessionToken,
182
+ attempts: 0,
183
+ enqueuedAt: Date.now(),
140
184
  });
141
185
 
142
186
  if (this.queue.length >= this.batchSize) {
@@ -147,55 +191,108 @@ export class WidgetAnalyticsTracker {
147
191
  this.scheduleFlush();
148
192
  }
149
193
 
194
+ private enqueue(entry: QueuedAnalyticsEvent) {
195
+ this.queue.push(entry);
196
+ if (this.queue.length > MAX_QUEUE_SIZE) {
197
+ this.queue.splice(0, this.queue.length - MAX_QUEUE_SIZE);
198
+ }
199
+ }
200
+
201
+ private pruneStale(now: number) {
202
+ if (this.queue.length === 0) return;
203
+ this.queue = this.queue.filter(
204
+ (entry) => now - entry.enqueuedAt < MAX_EVENT_AGE_MS,
205
+ );
206
+ }
207
+
150
208
  async flush(options?: {
151
209
  transport?: "default" | "beacon";
152
210
  }): Promise<void> {
153
211
  if (this.disposed) return;
154
- if (this.queue.length === 0) return;
212
+
213
+ const now = Date.now();
214
+ this.pruneStale(now);
215
+ if (this.queue.length === 0) {
216
+ this.clearFlushTimer();
217
+ return;
218
+ }
155
219
 
156
220
  this.clearFlushTimer();
157
221
  const transport = options?.transport ?? "default";
158
222
  const snapshot = this.queue;
159
223
  this.queue = [];
160
224
 
161
- const grouped = new Map<string, WidgetAnalyticsBatchEvent[]>();
225
+ const grouped = new Map<string, QueuedAnalyticsEvent[]>();
162
226
  for (const entry of snapshot) {
163
227
  const group = grouped.get(entry.sessionToken);
164
228
  if (group) {
165
- group.push(entry.event);
229
+ group.push(entry);
166
230
  } else {
167
- grouped.set(entry.sessionToken, [entry.event]);
231
+ grouped.set(entry.sessionToken, [entry]);
168
232
  }
169
233
  }
170
234
 
171
235
  const failed: QueuedAnalyticsEvent[] = [];
236
+ let maxRetryAfterMs = 0;
237
+ let anyRetry = false;
238
+ let anySuccess = false;
172
239
 
173
- for (const [sessionToken, events] of grouped.entries()) {
174
- const sent = await this.sendBatch({
175
- events,
240
+ for (const [sessionToken, entries] of grouped.entries()) {
241
+ const result = await this.sendBatch({
242
+ events: entries.map((entry) => entry.event),
176
243
  sessionToken,
177
244
  transport,
178
245
  });
179
246
 
180
- if (!sent) {
181
- for (const event of events) {
182
- failed.push({ event, sessionToken });
183
- }
247
+ if (result.outcome === "ok") {
248
+ anySuccess = true;
249
+ continue;
250
+ }
251
+ if (result.outcome === "drop") {
252
+ continue;
253
+ }
254
+ anyRetry = true;
255
+ if (result.retryAfterMs && result.retryAfterMs > maxRetryAfterMs) {
256
+ maxRetryAfterMs = result.retryAfterMs;
184
257
  }
258
+ for (const entry of entries) {
259
+ const attempts = entry.attempts + 1;
260
+ if (attempts >= MAX_EVENT_ATTEMPTS) continue;
261
+ failed.push({ ...entry, attempts });
262
+ }
263
+ }
264
+
265
+ if (anySuccess && !anyRetry) {
266
+ this.consecutiveFailures = 0;
267
+ this.nextAllowedFlushAt = 0;
268
+ } else if (anyRetry) {
269
+ this.consecutiveFailures += 1;
185
270
  }
186
271
 
187
272
  if (failed.length > 0) {
188
- this.queue = [...failed, ...this.queue];
189
- this.scheduleFlush();
273
+ for (const entry of failed) {
274
+ this.enqueue(entry);
275
+ }
276
+ const backoff = this.computeBackoffDelay();
277
+ const delay = Math.max(backoff, maxRetryAfterMs);
278
+ this.nextAllowedFlushAt = Date.now() + delay;
279
+ this.scheduleFlush(delay);
190
280
  }
191
281
  }
192
282
 
283
+ private computeBackoffDelay(): number {
284
+ const exponent = Math.max(0, this.consecutiveFailures - 1);
285
+ const base = this.flushIntervalMs * Math.pow(2, exponent);
286
+ const jitter = base * 0.2 * Math.random();
287
+ return Math.min(MAX_FLUSH_INTERVAL_MS, base + jitter);
288
+ }
289
+
193
290
  private async sendBatch(input: {
194
291
  events: WidgetAnalyticsBatchEvent[];
195
292
  sessionToken: string;
196
293
  transport: "default" | "beacon";
197
- }): Promise<boolean> {
198
- if (input.events.length === 0) return true;
294
+ }): Promise<SendOutcome> {
295
+ if (input.events.length === 0) return { outcome: "ok" };
199
296
 
200
297
  const url = new URL(ANALYTICS_ENDPOINT, `${getWidgetApiBase()}/`).toString();
201
298
  const sanitizedEvents = input.events.map((event) => {
@@ -220,7 +317,7 @@ export class WidgetAnalyticsTracker {
220
317
  type: "application/json",
221
318
  });
222
319
  if (navigator.sendBeacon(url, body)) {
223
- return true;
320
+ return { outcome: "ok" };
224
321
  }
225
322
  }
226
323
 
@@ -235,18 +332,19 @@ export class WidgetAnalyticsTracker {
235
332
  credentials: "omit",
236
333
  keepalive: input.transport === "beacon",
237
334
  });
238
- return response.ok;
335
+ return classifyResponse(response);
239
336
  } catch {
240
- return false;
337
+ return { outcome: "retry" };
241
338
  }
242
339
  }
243
340
 
244
- private scheduleFlush() {
341
+ private scheduleFlush(delayMs?: number) {
245
342
  if (this.flushTimer != null) return;
343
+ const delay = Math.max(0, delayMs ?? this.flushIntervalMs);
246
344
  this.flushTimer = window.setTimeout(() => {
247
345
  this.flushTimer = null;
248
346
  void this.flush();
249
- }, this.flushIntervalMs);
347
+ }, delay);
250
348
  }
251
349
 
252
350
  private clearFlushTimer() {
@@ -14,6 +14,10 @@ export type WidgetPlacement =
14
14
 
15
15
  export type WidgetVariant = "default" | "chatbar" | "box";
16
16
 
17
+ export type WidgetPreset = "default" | "facebook_messenger";
18
+
19
+ export const DEFAULT_WIDGET_PRESET: WidgetPreset = "default";
20
+
17
21
  export type WidgetAppearance = ResolvedChatWidgetAppearance;
18
22
 
19
23
  export const DEFAULT_WIDGET_APPEARANCE: WidgetAppearance =
@@ -64,6 +68,20 @@ export function widgetAppearanceToAttributes(
64
68
  ];
65
69
  }
66
70
 
71
+ export function normalizeWidgetPreset(
72
+ value: string | null | undefined,
73
+ ): WidgetPreset {
74
+ switch (value) {
75
+ case "facebook_messenger":
76
+ case "facebook-messenger":
77
+ return "facebook_messenger";
78
+ case "default":
79
+ return "default";
80
+ default:
81
+ return DEFAULT_WIDGET_PRESET;
82
+ }
83
+ }
84
+
67
85
  export function normalizeWidgetVariant(
68
86
  value: string | null | undefined,
69
87
  ): WidgetVariant {
package/src/types.ts CHANGED
@@ -1,17 +1,21 @@
1
1
  import type {
2
2
  WidgetAppearance,
3
3
  WidgetPlacement,
4
+ WidgetPreset,
4
5
  WidgetVariant,
5
6
  WidgetTriggerRule,
6
7
  } from "./shared/widget-config";
7
8
 
8
9
  export {
9
10
  DEFAULT_WIDGET_APPEARANCE,
11
+ DEFAULT_WIDGET_PRESET,
10
12
  type WidgetAppearance,
11
13
  type WidgetPlacement,
14
+ type WidgetPreset,
12
15
  type WidgetVariant,
13
16
  normalizeWidgetAppearance,
14
17
  normalizeWidgetPlacement,
18
+ normalizeWidgetPreset,
15
19
  normalizeWidgetVariant,
16
20
  splitWidgetPlacement,
17
21
  widgetAppearanceToAttributes,
@@ -37,6 +41,7 @@ export type WidgetSessionConfig = {
37
41
  spotlightMessages: string[];
38
42
  widgetAppearance: WidgetAppearance;
39
43
  widgetVariant: WidgetVariant;
44
+ widgetPreset: WidgetPreset;
40
45
  widgetPlacement: WidgetPlacement;
41
46
  allowedDomains: string[];
42
47
  triggerRule: WidgetTriggerRule | null;