@product7/product7-js 0.6.4 → 0.6.7
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/product7-js.js +124 -14
- package/dist/product7-js.js.map +1 -1
- package/dist/product7-js.min.js +1 -1
- package/dist/product7-js.min.js.map +1 -1
- package/package.json +1 -1
- package/src/core/WebSocketService.js +69 -10
- package/src/widgets/LiveChatWidget.js +53 -1
- package/src/widgets/liveChat/views/HomeView.js +2 -3
package/package.json
CHANGED
|
@@ -11,11 +11,28 @@ export class WebSocketService {
|
|
|
11
11
|
|
|
12
12
|
this.ws = null;
|
|
13
13
|
this.reconnectAttempts = 0;
|
|
14
|
-
|
|
15
|
-
|
|
14
|
+
// Reconnect indefinitely with capped exponential backoff. The
|
|
15
|
+
// previous 5-attempt hard cap meant ~31s of churn on a flaky network
|
|
16
|
+
// permanently killed the live-chat connection until page refresh —
|
|
17
|
+
// the dominant "messages don't arrive" symptom in production.
|
|
18
|
+
this.reconnectBaseDelay = 1000;
|
|
19
|
+
this.reconnectMaxDelay = 30_000;
|
|
16
20
|
this.pingInterval = null;
|
|
17
21
|
this.isConnected = false;
|
|
18
22
|
|
|
23
|
+
// Heartbeat watchdog. The browser's onclose/onerror only fires when
|
|
24
|
+
// it notices the TCP socket is dead — on stalled-but-not-closed
|
|
25
|
+
// connections (NAT timeouts, sleeping mobile, some intermediaries)
|
|
26
|
+
// that can take many minutes. We track the wall-clock time of the
|
|
27
|
+
// last received frame and force a reconnect if nothing arrives
|
|
28
|
+
// within the timeout. Server's protocol-level pings come every 54s
|
|
29
|
+
// (pkg/websocket/client.go pingPeriod) and our app-level pings/pongs
|
|
30
|
+
// every 30s, so 90s catches a dead pipe with one cycle of headroom.
|
|
31
|
+
this.lastFrameAt = 0;
|
|
32
|
+
this.heartbeatTimeoutMs = 90_000;
|
|
33
|
+
this.heartbeatCheckIntervalMs = 15_000;
|
|
34
|
+
this.heartbeatInterval = null;
|
|
35
|
+
|
|
19
36
|
// Event listeners
|
|
20
37
|
this._listeners = new Map();
|
|
21
38
|
|
|
@@ -39,6 +56,8 @@ export class WebSocketService {
|
|
|
39
56
|
return;
|
|
40
57
|
}
|
|
41
58
|
|
|
59
|
+
this._intentionallyClosed = false;
|
|
60
|
+
|
|
42
61
|
// Mock mode - simulate connection
|
|
43
62
|
if (this.mock) {
|
|
44
63
|
this.isConnected = true;
|
|
@@ -70,13 +89,15 @@ export class WebSocketService {
|
|
|
70
89
|
*/
|
|
71
90
|
disconnect() {
|
|
72
91
|
this.isConnected = false;
|
|
73
|
-
this.
|
|
92
|
+
this._intentionallyClosed = true; // Prevent reconnection
|
|
74
93
|
|
|
75
94
|
if (this.pingInterval) {
|
|
76
95
|
clearInterval(this.pingInterval);
|
|
77
96
|
this.pingInterval = null;
|
|
78
97
|
}
|
|
79
98
|
|
|
99
|
+
this._stopHeartbeat();
|
|
100
|
+
|
|
80
101
|
if (this.ws) {
|
|
81
102
|
this.ws.close();
|
|
82
103
|
this.ws = null;
|
|
@@ -88,6 +109,35 @@ export class WebSocketService {
|
|
|
88
109
|
}
|
|
89
110
|
}
|
|
90
111
|
|
|
112
|
+
_startHeartbeat() {
|
|
113
|
+
this._stopHeartbeat();
|
|
114
|
+
this.lastFrameAt = Date.now();
|
|
115
|
+
this.heartbeatInterval = setInterval(() => {
|
|
116
|
+
if (Date.now() - this.lastFrameAt > this.heartbeatTimeoutMs) {
|
|
117
|
+
console.warn(
|
|
118
|
+
`[WebSocket] No frames in ${this.heartbeatTimeoutMs}ms, forcing reconnect`
|
|
119
|
+
);
|
|
120
|
+
// Closing the socket fires onclose, which schedules a
|
|
121
|
+
// reconnect through the normal path.
|
|
122
|
+
this._stopHeartbeat();
|
|
123
|
+
if (this.ws) {
|
|
124
|
+
try {
|
|
125
|
+
this.ws.close();
|
|
126
|
+
} catch (_) {
|
|
127
|
+
// ignore
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
}, this.heartbeatCheckIntervalMs);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
_stopHeartbeat() {
|
|
135
|
+
if (this.heartbeatInterval) {
|
|
136
|
+
clearInterval(this.heartbeatInterval);
|
|
137
|
+
this.heartbeatInterval = null;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
91
141
|
/**
|
|
92
142
|
* Subscribe to events
|
|
93
143
|
* @param {string} event - Event name
|
|
@@ -137,6 +187,8 @@ export class WebSocketService {
|
|
|
137
187
|
console.log('[WebSocket] Connected');
|
|
138
188
|
this.isConnected = true;
|
|
139
189
|
this.reconnectAttempts = 0;
|
|
190
|
+
this._intentionallyClosed = false;
|
|
191
|
+
this._startHeartbeat();
|
|
140
192
|
this._emit('connected', {});
|
|
141
193
|
|
|
142
194
|
// Start ping interval to keep connection alive
|
|
@@ -146,6 +198,9 @@ export class WebSocketService {
|
|
|
146
198
|
}
|
|
147
199
|
|
|
148
200
|
_onMessage(event) {
|
|
201
|
+
// Any inbound frame counts as a sign of life for the watchdog —
|
|
202
|
+
// including pongs, message:new, typing, etc.
|
|
203
|
+
this.lastFrameAt = Date.now();
|
|
149
204
|
try {
|
|
150
205
|
const data = JSON.parse(event.data);
|
|
151
206
|
const { type, payload } = data;
|
|
@@ -190,8 +245,13 @@ export class WebSocketService {
|
|
|
190
245
|
this.pingInterval = null;
|
|
191
246
|
}
|
|
192
247
|
|
|
248
|
+
this._stopHeartbeat();
|
|
249
|
+
|
|
193
250
|
this._emit('disconnected', { code: event.code, reason: event.reason });
|
|
194
|
-
|
|
251
|
+
// Skip reconnect if disconnect() was called intentionally.
|
|
252
|
+
if (!this._intentionallyClosed) {
|
|
253
|
+
this._scheduleReconnect();
|
|
254
|
+
}
|
|
195
255
|
}
|
|
196
256
|
|
|
197
257
|
_onError(error) {
|
|
@@ -200,14 +260,13 @@ export class WebSocketService {
|
|
|
200
260
|
}
|
|
201
261
|
|
|
202
262
|
_scheduleReconnect() {
|
|
203
|
-
if (this.
|
|
204
|
-
console.log('[WebSocket] Max reconnect attempts reached');
|
|
205
|
-
this._emit('reconnect_failed', {});
|
|
206
|
-
return;
|
|
207
|
-
}
|
|
263
|
+
if (this._intentionallyClosed) return;
|
|
208
264
|
|
|
209
265
|
this.reconnectAttempts++;
|
|
210
|
-
const delay =
|
|
266
|
+
const delay = Math.min(
|
|
267
|
+
this.reconnectBaseDelay * Math.pow(2, this.reconnectAttempts - 1),
|
|
268
|
+
this.reconnectMaxDelay
|
|
269
|
+
);
|
|
211
270
|
console.log(
|
|
212
271
|
`[WebSocket] Reconnecting in ${delay}ms (attempt ${this.reconnectAttempts})`
|
|
213
272
|
);
|
|
@@ -399,7 +399,38 @@ export class LiveChatWidget extends BaseWidget {
|
|
|
399
399
|
});
|
|
400
400
|
|
|
401
401
|
if (response.status && response.data) {
|
|
402
|
-
|
|
402
|
+
// Reconcile the optimistic 'Sending…' message with the server's
|
|
403
|
+
// confirmation as soon as the POST returns. Don't depend on the WS
|
|
404
|
+
// echo alone — when the WebSocket frame is dropped (brief
|
|
405
|
+
// disconnect, slow network, server send-buffer full), the
|
|
406
|
+
// optimistic message stays stuck on 'Sending…' forever because
|
|
407
|
+
// upsertMessage's match window is finite.
|
|
408
|
+
const serverData = response.data;
|
|
409
|
+
let attachments = [];
|
|
410
|
+
if (serverData.attachments) {
|
|
411
|
+
try {
|
|
412
|
+
attachments =
|
|
413
|
+
typeof serverData.attachments === 'string'
|
|
414
|
+
? JSON.parse(serverData.attachments)
|
|
415
|
+
: serverData.attachments;
|
|
416
|
+
} catch (e) {
|
|
417
|
+
attachments = [];
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
this.LiveChatState.upsertMessage(
|
|
421
|
+
conversationId,
|
|
422
|
+
{
|
|
423
|
+
id: serverData.id,
|
|
424
|
+
content: serverData.content,
|
|
425
|
+
isOwn: true,
|
|
426
|
+
timestamp: serverData.created_at,
|
|
427
|
+
attachments:
|
|
428
|
+
Array.isArray(attachments) && attachments.length > 0
|
|
429
|
+
? attachments
|
|
430
|
+
: undefined,
|
|
431
|
+
},
|
|
432
|
+
{ reconcileOwnOptimistic: true, optimisticMatchWindowMs: 60000 }
|
|
433
|
+
);
|
|
403
434
|
}
|
|
404
435
|
|
|
405
436
|
if (this.apiService?.mock) {
|
|
@@ -532,13 +563,34 @@ export class LiveChatWidget extends BaseWidget {
|
|
|
532
563
|
this._wsUnsubscribers.push(
|
|
533
564
|
this.wsService.on('conversation_closed', this._handleConversationClosed)
|
|
534
565
|
);
|
|
566
|
+
// Track first vs reconnect locally so we only backfill on reconnects.
|
|
567
|
+
// First connect is right after _initWebSocket() and the surrounding
|
|
568
|
+
// flow has already loaded fresh data — re-fetching would be wasted.
|
|
569
|
+
let wsHasConnectedBefore = false;
|
|
535
570
|
this._wsUnsubscribers.push(
|
|
536
571
|
this.wsService.on('connected', () => {
|
|
537
572
|
console.log('[LiveChatWidget] WebSocket connected');
|
|
573
|
+
const isReconnect = wsHasConnectedBefore;
|
|
574
|
+
wsHasConnectedBefore = true;
|
|
538
575
|
if (this.LiveChatState.activeConversationId) {
|
|
539
576
|
this.wsService.send('conversation:subscribe', {
|
|
540
577
|
conversation_id: this.LiveChatState.activeConversationId,
|
|
541
578
|
});
|
|
579
|
+
// On reconnect, refetch the active conversation's messages.
|
|
580
|
+
// The server doesn't replay events that fired during the WS
|
|
581
|
+
// disconnect, so anything broadcast in the gap is otherwise
|
|
582
|
+
// permanently lost from the customer's view until they
|
|
583
|
+
// refresh the page.
|
|
584
|
+
if (isReconnect) {
|
|
585
|
+
this.fetchMessages(this.LiveChatState.activeConversationId).catch(
|
|
586
|
+
(err) => {
|
|
587
|
+
console.error(
|
|
588
|
+
'[LiveChatWidget] Failed to backfill messages on reconnect:',
|
|
589
|
+
err
|
|
590
|
+
);
|
|
591
|
+
}
|
|
592
|
+
);
|
|
593
|
+
}
|
|
542
594
|
}
|
|
543
595
|
})
|
|
544
596
|
);
|
|
@@ -39,12 +39,11 @@
|
|
|
39
39
|
<div class="liveChat-home-logo">
|
|
40
40
|
${this.options.logoUrl ? `<img src="${this.options.logoUrl}" alt="${this.state.teamName}" />` : ''}
|
|
41
41
|
</div>
|
|
42
|
-
<div class="liveChat-home-avatars">${avatarsHtml}</div>
|
|
42
|
+
<div class="liveChat-home-avatars">${avatarsHtml || availabilityHtml}</div>
|
|
43
43
|
</div>
|
|
44
44
|
<div class="liveChat-home-welcome">
|
|
45
45
|
<span class="liveChat-home-greeting">${this.state.greetingMessage}</span>
|
|
46
46
|
<span class="liveChat-home-question">${this.state.welcomeMessage}</span>
|
|
47
|
-
${availabilityHtml}
|
|
48
47
|
</div>
|
|
49
48
|
</div>
|
|
50
49
|
|
|
@@ -107,7 +106,7 @@
|
|
|
107
106
|
return `
|
|
108
107
|
<div class="liveChat-home-availability">
|
|
109
108
|
<span class="liveChat-availability-dot liveChat-availability-away"></span>
|
|
110
|
-
<span class="liveChat-availability-text"
|
|
109
|
+
<span class="liveChat-availability-text">We're currently away</span>
|
|
111
110
|
</div>
|
|
112
111
|
`;
|
|
113
112
|
}
|