@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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@product7/product7-js",
3
- "version": "0.6.4",
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
  );
@@ -399,7 +399,38 @@ export class LiveChatWidget extends BaseWidget {
399
399
  });
400
400
 
401
401
  if (response.status && response.data) {
402
- console.log('[LiveChatWidget] Message sent:', response.data.id);
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">${this.state.responseTime}</span>
109
+ <span class="liveChat-availability-text">We're currently away</span>
111
110
  </div>
112
111
  `;
113
112
  }