@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.
- package/dist/widget.js +379 -379
- package/package.json +1 -1
- package/src/chat-widget/index.ts +1 -0
- package/src/chat-widget/styles/chat-widget-messenger.tsx +396 -0
- package/src/custom-element/agent-widget-element.tsx +16 -0
- package/src/renderer/widget-runtime.tsx +15 -1
- package/src/runtime/analytics.ts +119 -21
- package/src/shared/widget-config.ts +18 -0
- package/src/types.ts +5 -0
package/src/runtime/analytics.ts
CHANGED
|
@@ -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.
|
|
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
|
-
|
|
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,
|
|
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
|
|
229
|
+
group.push(entry);
|
|
166
230
|
} else {
|
|
167
|
-
grouped.set(entry.sessionToken, [entry
|
|
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,
|
|
174
|
-
const
|
|
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 (
|
|
181
|
-
|
|
182
|
-
|
|
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
|
-
|
|
189
|
-
|
|
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<
|
|
198
|
-
if (input.events.length === 0) return
|
|
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
|
|
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
|
|
335
|
+
return classifyResponse(response);
|
|
239
336
|
} catch {
|
|
240
|
-
return
|
|
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
|
-
},
|
|
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;
|