@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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@product7/product7-js",
3
- "version": "0.6.5",
3
+ "version": "0.6.7",
4
4
  "description": "JavaScript SDK for integrating Product7 feedback widgets into any website",
5
5
  "main": "dist/product7-js.js",
6
6
  "module": "src/index.js",
@@ -11,11 +11,28 @@ export class WebSocketService {
11
11
 
12
12
  this.ws = null;
13
13
  this.reconnectAttempts = 0;
14
- this.maxReconnectAttempts = 5;
15
- this.reconnectDelay = 1000;
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.reconnectAttempts = this.maxReconnectAttempts; // Prevent reconnection
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
- this._scheduleReconnect();
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.reconnectAttempts >= this.maxReconnectAttempts) {
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 = this.reconnectDelay * Math.pow(2, this.reconnectAttempts - 1);
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
  );