@tthr/vue 0.0.25 → 0.0.27
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,
|
|
9
|
+
import { ref, onMounted, onUnmounted, type Ref } from 'vue';
|
|
10
10
|
|
|
11
11
|
/**
|
|
12
12
|
* Query state returned by useQuery
|
|
@@ -195,9 +195,46 @@ 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
|
+
|
|
202
|
+
// Heartbeat configuration
|
|
203
|
+
const HEARTBEAT_INTERVAL = 30000; // 30 seconds
|
|
204
|
+
const HEARTBEAT_TIMEOUT = 10000; // 10 seconds to receive pong
|
|
205
|
+
|
|
198
206
|
// Generate a unique subscription ID
|
|
199
207
|
const subscriptionId = `${Date.now()}-${Math.random().toString(36).slice(2, 9)}`;
|
|
200
208
|
|
|
209
|
+
const startHeartbeat = () => {
|
|
210
|
+
stopHeartbeat();
|
|
211
|
+
heartbeatInterval = setInterval(() => {
|
|
212
|
+
if (ws?.readyState === WebSocket.OPEN) {
|
|
213
|
+
awaitingPong = true;
|
|
214
|
+
ws.send(JSON.stringify({ type: 'ping' }));
|
|
215
|
+
|
|
216
|
+
heartbeatTimeout = setTimeout(() => {
|
|
217
|
+
if (awaitingPong) {
|
|
218
|
+
console.warn('[Tether] Heartbeat timeout - forcing reconnect');
|
|
219
|
+
ws?.close();
|
|
220
|
+
}
|
|
221
|
+
}, HEARTBEAT_TIMEOUT);
|
|
222
|
+
}
|
|
223
|
+
}, HEARTBEAT_INTERVAL);
|
|
224
|
+
};
|
|
225
|
+
|
|
226
|
+
const stopHeartbeat = () => {
|
|
227
|
+
if (heartbeatInterval) {
|
|
228
|
+
clearInterval(heartbeatInterval);
|
|
229
|
+
heartbeatInterval = null;
|
|
230
|
+
}
|
|
231
|
+
if (heartbeatTimeout) {
|
|
232
|
+
clearTimeout(heartbeatTimeout);
|
|
233
|
+
heartbeatTimeout = null;
|
|
234
|
+
}
|
|
235
|
+
awaitingPong = false;
|
|
236
|
+
};
|
|
237
|
+
|
|
201
238
|
const connect = () => {
|
|
202
239
|
// Get config from window (set by plugin)
|
|
203
240
|
const config = (window as any).__TETHER_CONFIG__;
|
|
@@ -219,6 +256,7 @@ export function useTetherSubscription(
|
|
|
219
256
|
|
|
220
257
|
if (message.type === 'connected') {
|
|
221
258
|
isConnected.value = true;
|
|
259
|
+
startHeartbeat();
|
|
222
260
|
// Subscribe to the query with our ID
|
|
223
261
|
ws?.send(JSON.stringify({
|
|
224
262
|
type: 'subscribe',
|
|
@@ -229,6 +267,13 @@ export function useTetherSubscription(
|
|
|
229
267
|
} else if (message.type === 'data' && message.id === subscriptionId) {
|
|
230
268
|
// Call onUpdate with the fresh data
|
|
231
269
|
onUpdate(message.data);
|
|
270
|
+
} else if (message.type === 'pong') {
|
|
271
|
+
// Heartbeat acknowledged
|
|
272
|
+
awaitingPong = false;
|
|
273
|
+
if (heartbeatTimeout) {
|
|
274
|
+
clearTimeout(heartbeatTimeout);
|
|
275
|
+
heartbeatTimeout = null;
|
|
276
|
+
}
|
|
232
277
|
}
|
|
233
278
|
} catch {
|
|
234
279
|
// Ignore parse errors
|
|
@@ -237,6 +282,7 @@ export function useTetherSubscription(
|
|
|
237
282
|
|
|
238
283
|
ws.onclose = () => {
|
|
239
284
|
isConnected.value = false;
|
|
285
|
+
stopHeartbeat();
|
|
240
286
|
// Reconnect after 3 seconds
|
|
241
287
|
reconnectTimeout = setTimeout(connect, 3000);
|
|
242
288
|
};
|
|
@@ -246,11 +292,29 @@ export function useTetherSubscription(
|
|
|
246
292
|
};
|
|
247
293
|
};
|
|
248
294
|
|
|
295
|
+
// Handle page visibility changes - reconnect when tab becomes visible
|
|
296
|
+
const handleVisibilityChange = () => {
|
|
297
|
+
if (document.visibilityState === 'visible') {
|
|
298
|
+
// Tab became visible - check connection health
|
|
299
|
+
if (!ws || ws.readyState !== WebSocket.OPEN) {
|
|
300
|
+
// Clear any pending reconnect and connect immediately
|
|
301
|
+
if (reconnectTimeout) {
|
|
302
|
+
clearTimeout(reconnectTimeout);
|
|
303
|
+
reconnectTimeout = null;
|
|
304
|
+
}
|
|
305
|
+
connect();
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
};
|
|
309
|
+
|
|
249
310
|
onMounted(() => {
|
|
250
311
|
connect();
|
|
312
|
+
document.addEventListener('visibilitychange', handleVisibilityChange);
|
|
251
313
|
});
|
|
252
314
|
|
|
253
315
|
onUnmounted(() => {
|
|
316
|
+
document.removeEventListener('visibilitychange', handleVisibilityChange);
|
|
317
|
+
stopHeartbeat();
|
|
254
318
|
if (reconnectTimeout) {
|
|
255
319
|
clearTimeout(reconnectTimeout);
|
|
256
320
|
}
|
|
@@ -123,7 +123,18 @@ function createDatabaseProxy(apiKey, url, projectId) {
|
|
|
123
123
|
if (options?.where) args.where = options.where;
|
|
124
124
|
if (options?.limit) args.limit = options.limit;
|
|
125
125
|
if (options?.offset) args.offset = options.offset;
|
|
126
|
-
|
|
126
|
+
// Handle orderBy as either a string or an object { column: 'asc'|'desc' }
|
|
127
|
+
if (options?.orderBy) {
|
|
128
|
+
if (typeof options.orderBy === 'string') {
|
|
129
|
+
args.orderBy = options.orderBy;
|
|
130
|
+
} else if (typeof options.orderBy === 'object') {
|
|
131
|
+
const [column, dir] = Object.entries(options.orderBy)[0] || [];
|
|
132
|
+
if (column) {
|
|
133
|
+
args.orderBy = column;
|
|
134
|
+
args.orderDir = dir?.toUpperCase?.() || 'ASC';
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
}
|
|
127
138
|
if (options?.orderDir) args.orderDir = options.orderDir;
|
|
128
139
|
return makeRequest('list', args);
|
|
129
140
|
},
|
|
@@ -123,7 +123,18 @@ function createDatabaseProxy(apiKey, url, projectId) {
|
|
|
123
123
|
if (options?.where) args.where = options.where;
|
|
124
124
|
if (options?.limit) args.limit = options.limit;
|
|
125
125
|
if (options?.offset) args.offset = options.offset;
|
|
126
|
-
|
|
126
|
+
// Handle orderBy as either a string or an object { column: 'asc'|'desc' }
|
|
127
|
+
if (options?.orderBy) {
|
|
128
|
+
if (typeof options.orderBy === 'string') {
|
|
129
|
+
args.orderBy = options.orderBy;
|
|
130
|
+
} else if (typeof options.orderBy === 'object') {
|
|
131
|
+
const [column, dir] = Object.entries(options.orderBy)[0] || [];
|
|
132
|
+
if (column) {
|
|
133
|
+
args.orderBy = column;
|
|
134
|
+
args.orderDir = dir?.toUpperCase?.() || 'ASC';
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
}
|
|
127
138
|
if (options?.orderDir) args.orderDir = options.orderDir;
|
|
128
139
|
return makeRequest('list', args);
|
|
129
140
|
},
|