@product7/product7-js 0.6.5 → 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/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
|
);
|
|
@@ -563,13 +563,34 @@ export class LiveChatWidget extends BaseWidget {
|
|
|
563
563
|
this._wsUnsubscribers.push(
|
|
564
564
|
this.wsService.on('conversation_closed', this._handleConversationClosed)
|
|
565
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;
|
|
566
570
|
this._wsUnsubscribers.push(
|
|
567
571
|
this.wsService.on('connected', () => {
|
|
568
572
|
console.log('[LiveChatWidget] WebSocket connected');
|
|
573
|
+
const isReconnect = wsHasConnectedBefore;
|
|
574
|
+
wsHasConnectedBefore = true;
|
|
569
575
|
if (this.LiveChatState.activeConversationId) {
|
|
570
576
|
this.wsService.send('conversation:subscribe', {
|
|
571
577
|
conversation_id: this.LiveChatState.activeConversationId,
|
|
572
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
|
+
}
|
|
573
594
|
}
|
|
574
595
|
})
|
|
575
596
|
);
|