@tthr/vue 0.0.26 → 0.0.28

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.
@@ -6,7 +6,7 @@
6
6
  * WebSocket subscriptions run client-side for realtime updates.
7
7
  */
8
8
 
9
- import { ref, onMounted, onUnmounted, watch, type Ref } from 'vue';
9
+ import { ref, onMounted, onUnmounted, type Ref } from 'vue';
10
10
 
11
11
  /**
12
12
  * Query state returned by useQuery
@@ -195,10 +195,56 @@ export function useTetherSubscription(
195
195
  if (import.meta.client) {
196
196
  let ws: WebSocket | null = null;
197
197
  let reconnectTimeout: ReturnType<typeof setTimeout> | null = null;
198
+ let heartbeatInterval: ReturnType<typeof setInterval> | null = null;
199
+ let heartbeatTimeout: ReturnType<typeof setTimeout> | null = null;
200
+ let awaitingPong = false;
201
+ let isConnecting = false;
202
+ let isMounted = false;
203
+
204
+ // Heartbeat configuration
205
+ const HEARTBEAT_INTERVAL = 25000; // 25 seconds (well under server's 90s timeout)
206
+ const HEARTBEAT_TIMEOUT = 15000; // 15 seconds to receive pong (generous for slow networks)
207
+
198
208
  // Generate a unique subscription ID
199
209
  const subscriptionId = `${Date.now()}-${Math.random().toString(36).slice(2, 9)}`;
200
210
 
211
+ const startHeartbeat = () => {
212
+ stopHeartbeat();
213
+ heartbeatInterval = setInterval(() => {
214
+ if (ws?.readyState === WebSocket.OPEN) {
215
+ awaitingPong = true;
216
+ ws.send(JSON.stringify({ type: 'ping' }));
217
+
218
+ heartbeatTimeout = setTimeout(() => {
219
+ if (awaitingPong && isMounted) {
220
+ console.warn('[Tether] Heartbeat timeout - forcing reconnect');
221
+ ws?.close();
222
+ }
223
+ }, HEARTBEAT_TIMEOUT);
224
+ }
225
+ }, HEARTBEAT_INTERVAL);
226
+ };
227
+
228
+ const stopHeartbeat = () => {
229
+ if (heartbeatInterval) {
230
+ clearInterval(heartbeatInterval);
231
+ heartbeatInterval = null;
232
+ }
233
+ if (heartbeatTimeout) {
234
+ clearTimeout(heartbeatTimeout);
235
+ heartbeatTimeout = null;
236
+ }
237
+ awaitingPong = false;
238
+ };
239
+
201
240
  const connect = () => {
241
+ // Don't connect if unmounted or already connecting/connected
242
+ if (!isMounted) return;
243
+ if (isConnecting) return;
244
+ if (ws && (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING)) {
245
+ return;
246
+ }
247
+
202
248
  // Get config from window (set by plugin)
203
249
  const config = (window as any).__TETHER_CONFIG__;
204
250
  if (!config?.wsUrl || !config?.projectId) {
@@ -206,10 +252,12 @@ export function useTetherSubscription(
206
252
  return;
207
253
  }
208
254
 
255
+ isConnecting = true;
209
256
  const wsUrl = `${config.wsUrl}/ws/${config.projectId}`;
210
257
  ws = new WebSocket(wsUrl);
211
258
 
212
259
  ws.onopen = () => {
260
+ isConnecting = false;
213
261
  // Wait for connected message before subscribing
214
262
  };
215
263
 
@@ -219,6 +267,7 @@ export function useTetherSubscription(
219
267
 
220
268
  if (message.type === 'connected') {
221
269
  isConnected.value = true;
270
+ startHeartbeat();
222
271
  // Subscribe to the query with our ID
223
272
  ws?.send(JSON.stringify({
224
273
  type: 'subscribe',
@@ -229,6 +278,13 @@ export function useTetherSubscription(
229
278
  } else if (message.type === 'data' && message.id === subscriptionId) {
230
279
  // Call onUpdate with the fresh data
231
280
  onUpdate(message.data);
281
+ } else if (message.type === 'pong') {
282
+ // Heartbeat acknowledged
283
+ awaitingPong = false;
284
+ if (heartbeatTimeout) {
285
+ clearTimeout(heartbeatTimeout);
286
+ heartbeatTimeout = null;
287
+ }
232
288
  }
233
289
  } catch {
234
290
  // Ignore parse errors
@@ -237,25 +293,53 @@ export function useTetherSubscription(
237
293
 
238
294
  ws.onclose = () => {
239
295
  isConnected.value = false;
240
- // Reconnect after 3 seconds
241
- reconnectTimeout = setTimeout(connect, 3000);
296
+ isConnecting = false;
297
+ stopHeartbeat();
298
+ // Only reconnect if still mounted
299
+ if (isMounted) {
300
+ reconnectTimeout = setTimeout(connect, 3000);
301
+ }
242
302
  };
243
303
 
244
304
  ws.onerror = () => {
305
+ isConnecting = false;
245
306
  ws?.close();
246
307
  };
247
308
  };
248
309
 
310
+ // Handle page visibility changes - reconnect when tab becomes visible
311
+ const handleVisibilityChange = () => {
312
+ if (!isMounted) return;
313
+ if (document.visibilityState === 'visible') {
314
+ // Tab became visible - check connection health
315
+ if (!ws || ws.readyState === WebSocket.CLOSED) {
316
+ // Clear any pending reconnect and connect immediately
317
+ if (reconnectTimeout) {
318
+ clearTimeout(reconnectTimeout);
319
+ reconnectTimeout = null;
320
+ }
321
+ connect();
322
+ }
323
+ }
324
+ };
325
+
249
326
  onMounted(() => {
327
+ isMounted = true;
250
328
  connect();
329
+ document.addEventListener('visibilitychange', handleVisibilityChange);
251
330
  });
252
331
 
253
332
  onUnmounted(() => {
333
+ isMounted = false;
334
+ document.removeEventListener('visibilitychange', handleVisibilityChange);
335
+ stopHeartbeat();
254
336
  if (reconnectTimeout) {
255
337
  clearTimeout(reconnectTimeout);
338
+ reconnectTimeout = null;
256
339
  }
257
340
  if (ws) {
258
341
  ws.close();
342
+ ws = null;
259
343
  }
260
344
  });
261
345
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tthr/vue",
3
- "version": "0.0.26",
3
+ "version": "0.0.28",
4
4
  "description": "Tether Vue/Nuxt SDK",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",