@tthr/vue 0.0.71 → 0.0.75
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/nuxt/module.js +7 -4
- package/nuxt/module.ts +8 -4
- package/nuxt/runtime/composables.d.ts +106 -0
- package/nuxt/runtime/composables.d.ts.map +1 -1
- package/nuxt/runtime/composables.js +370 -0
- package/nuxt/runtime/composables.js.map +1 -1
- package/nuxt/runtime/composables.ts +506 -0
- package/nuxt/runtime/plugin.client.ts +34 -0
- package/nuxt/runtime/server/plugins/cron.d.ts.map +1 -1
- package/nuxt/runtime/server/plugins/cron.js +28 -12
- package/nuxt/runtime/server/plugins/cron.js.map +1 -1
- package/nuxt/runtime/server/plugins/cron.ts +372 -0
- package/package.json +2 -1
|
@@ -0,0 +1,506 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Nuxt composables for Tether
|
|
3
|
+
*
|
|
4
|
+
* These are auto-imported when using the Tether Nuxt module.
|
|
5
|
+
* Queries and mutations are executed server-side to keep API keys secure.
|
|
6
|
+
* WebSocket subscriptions run client-side for realtime updates - automatically!
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { ref, onMounted, onUnmounted, computed, type Ref, type ComputedRef } from 'vue';
|
|
10
|
+
import { useAsyncData } from '#imports';
|
|
11
|
+
|
|
12
|
+
// ============================================================================
|
|
13
|
+
// Shared WebSocket Connection Manager
|
|
14
|
+
// ============================================================================
|
|
15
|
+
|
|
16
|
+
interface SubscriptionCallback {
|
|
17
|
+
onData: (data: unknown) => void;
|
|
18
|
+
onInvalidate: () => void;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
interface ConnectionManager {
|
|
22
|
+
ws: WebSocket | null;
|
|
23
|
+
subscriptions: Map<string, SubscriptionCallback>;
|
|
24
|
+
isConnecting: boolean;
|
|
25
|
+
reconnectTimeout: ReturnType<typeof setTimeout> | null;
|
|
26
|
+
heartbeatInterval: ReturnType<typeof setInterval> | null;
|
|
27
|
+
heartbeatTimeout: ReturnType<typeof setTimeout> | null;
|
|
28
|
+
awaitingPong: boolean;
|
|
29
|
+
refCount: number;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// Singleton connection manager (client-side only)
|
|
33
|
+
let connectionManager: ConnectionManager | null = null;
|
|
34
|
+
|
|
35
|
+
function getConnectionManager(): ConnectionManager {
|
|
36
|
+
if (!connectionManager) {
|
|
37
|
+
connectionManager = {
|
|
38
|
+
ws: null,
|
|
39
|
+
subscriptions: new Map(),
|
|
40
|
+
isConnecting: false,
|
|
41
|
+
reconnectTimeout: null,
|
|
42
|
+
heartbeatInterval: null,
|
|
43
|
+
heartbeatTimeout: null,
|
|
44
|
+
awaitingPong: false,
|
|
45
|
+
refCount: 0,
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
return connectionManager;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const HEARTBEAT_INTERVAL = 25000;
|
|
52
|
+
const HEARTBEAT_TIMEOUT = 15000;
|
|
53
|
+
|
|
54
|
+
function startHeartbeat(cm: ConnectionManager) {
|
|
55
|
+
stopHeartbeat(cm);
|
|
56
|
+
cm.heartbeatInterval = setInterval(() => {
|
|
57
|
+
if (cm.ws?.readyState === WebSocket.OPEN) {
|
|
58
|
+
cm.awaitingPong = true;
|
|
59
|
+
cm.ws.send(JSON.stringify({ type: 'ping' }));
|
|
60
|
+
cm.heartbeatTimeout = setTimeout(() => {
|
|
61
|
+
if (cm.awaitingPong) {
|
|
62
|
+
console.warn('[Tether] Heartbeat timeout - forcing reconnect');
|
|
63
|
+
cm.ws?.close();
|
|
64
|
+
}
|
|
65
|
+
}, HEARTBEAT_TIMEOUT);
|
|
66
|
+
}
|
|
67
|
+
}, HEARTBEAT_INTERVAL);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function stopHeartbeat(cm: ConnectionManager) {
|
|
71
|
+
if (cm.heartbeatInterval) {
|
|
72
|
+
clearInterval(cm.heartbeatInterval);
|
|
73
|
+
cm.heartbeatInterval = null;
|
|
74
|
+
}
|
|
75
|
+
if (cm.heartbeatTimeout) {
|
|
76
|
+
clearTimeout(cm.heartbeatTimeout);
|
|
77
|
+
cm.heartbeatTimeout = null;
|
|
78
|
+
}
|
|
79
|
+
cm.awaitingPong = false;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function connectWebSocket(cm: ConnectionManager) {
|
|
83
|
+
if (cm.isConnecting) return;
|
|
84
|
+
if (cm.ws && (cm.ws.readyState === WebSocket.OPEN || cm.ws.readyState === WebSocket.CONNECTING)) {
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const config = (window as any).__TETHER_CONFIG__;
|
|
89
|
+
if (!config?.wsUrl || !config?.projectId) {
|
|
90
|
+
console.warn('[Tether] WebSocket config not available');
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
cm.isConnecting = true;
|
|
95
|
+
const wsUrl = `${config.wsUrl}/ws/${config.projectId}`;
|
|
96
|
+
cm.ws = new WebSocket(wsUrl);
|
|
97
|
+
|
|
98
|
+
cm.ws.onopen = () => {
|
|
99
|
+
cm.isConnecting = false;
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
cm.ws.onmessage = (event) => {
|
|
103
|
+
try {
|
|
104
|
+
const message = JSON.parse(event.data);
|
|
105
|
+
|
|
106
|
+
if (message.type === 'connected') {
|
|
107
|
+
startHeartbeat(cm);
|
|
108
|
+
// Re-subscribe all active subscriptions
|
|
109
|
+
for (const [subscriptionId, _callback] of cm.subscriptions) {
|
|
110
|
+
const [queryName, argsJson] = subscriptionId.split('::');
|
|
111
|
+
const args = argsJson ? JSON.parse(argsJson) : undefined;
|
|
112
|
+
cm.ws?.send(JSON.stringify({
|
|
113
|
+
type: 'subscribe',
|
|
114
|
+
id: subscriptionId,
|
|
115
|
+
query: queryName,
|
|
116
|
+
args,
|
|
117
|
+
}));
|
|
118
|
+
}
|
|
119
|
+
} else if (message.type === 'data') {
|
|
120
|
+
const callback = cm.subscriptions.get(message.id);
|
|
121
|
+
if (callback) {
|
|
122
|
+
callback.onData(message.data);
|
|
123
|
+
}
|
|
124
|
+
} else if (message.type === 'invalidate') {
|
|
125
|
+
const callback = cm.subscriptions.get(message.id);
|
|
126
|
+
if (callback) {
|
|
127
|
+
callback.onInvalidate();
|
|
128
|
+
}
|
|
129
|
+
} else if (message.type === 'pong') {
|
|
130
|
+
cm.awaitingPong = false;
|
|
131
|
+
if (cm.heartbeatTimeout) {
|
|
132
|
+
clearTimeout(cm.heartbeatTimeout);
|
|
133
|
+
cm.heartbeatTimeout = null;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
} catch {
|
|
137
|
+
// Ignore parse errors
|
|
138
|
+
}
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
cm.ws.onclose = () => {
|
|
142
|
+
cm.isConnecting = false;
|
|
143
|
+
stopHeartbeat(cm);
|
|
144
|
+
// Reconnect if there are still active subscriptions
|
|
145
|
+
if (cm.subscriptions.size > 0) {
|
|
146
|
+
cm.reconnectTimeout = setTimeout(() => connectWebSocket(cm), 3000);
|
|
147
|
+
}
|
|
148
|
+
};
|
|
149
|
+
|
|
150
|
+
cm.ws.onerror = () => {
|
|
151
|
+
cm.isConnecting = false;
|
|
152
|
+
cm.ws?.close();
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function subscribe(
|
|
157
|
+
queryName: string,
|
|
158
|
+
args: Record<string, unknown> | undefined,
|
|
159
|
+
callbacks: SubscriptionCallback
|
|
160
|
+
): () => void {
|
|
161
|
+
const cm = getConnectionManager();
|
|
162
|
+
const subscriptionId = `${queryName}::${JSON.stringify(args ?? {})}`;
|
|
163
|
+
|
|
164
|
+
cm.subscriptions.set(subscriptionId, callbacks);
|
|
165
|
+
cm.refCount++;
|
|
166
|
+
|
|
167
|
+
// Connect if not already connected
|
|
168
|
+
if (!cm.ws || cm.ws.readyState === WebSocket.CLOSED) {
|
|
169
|
+
connectWebSocket(cm);
|
|
170
|
+
} else if (cm.ws.readyState === WebSocket.OPEN) {
|
|
171
|
+
// Already connected - subscribe immediately
|
|
172
|
+
cm.ws.send(JSON.stringify({
|
|
173
|
+
type: 'subscribe',
|
|
174
|
+
id: subscriptionId,
|
|
175
|
+
query: queryName,
|
|
176
|
+
args,
|
|
177
|
+
}));
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// Return cleanup function
|
|
181
|
+
return () => {
|
|
182
|
+
cm.subscriptions.delete(subscriptionId);
|
|
183
|
+
cm.refCount--;
|
|
184
|
+
|
|
185
|
+
// Unsubscribe from server
|
|
186
|
+
if (cm.ws?.readyState === WebSocket.OPEN) {
|
|
187
|
+
cm.ws.send(JSON.stringify({
|
|
188
|
+
type: 'unsubscribe',
|
|
189
|
+
id: subscriptionId,
|
|
190
|
+
}));
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// Close connection if no more subscriptions
|
|
194
|
+
if (cm.refCount === 0) {
|
|
195
|
+
if (cm.reconnectTimeout) {
|
|
196
|
+
clearTimeout(cm.reconnectTimeout);
|
|
197
|
+
cm.reconnectTimeout = null;
|
|
198
|
+
}
|
|
199
|
+
stopHeartbeat(cm);
|
|
200
|
+
cm.ws?.close();
|
|
201
|
+
cm.ws = null;
|
|
202
|
+
}
|
|
203
|
+
};
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// Handle visibility changes globally
|
|
207
|
+
if (typeof window !== 'undefined') {
|
|
208
|
+
document.addEventListener('visibilitychange', () => {
|
|
209
|
+
if (document.visibilityState === 'visible') {
|
|
210
|
+
const cm = connectionManager;
|
|
211
|
+
if (cm && cm.subscriptions.size > 0) {
|
|
212
|
+
if (!cm.ws || cm.ws.readyState === WebSocket.CLOSED) {
|
|
213
|
+
if (cm.reconnectTimeout) {
|
|
214
|
+
clearTimeout(cm.reconnectTimeout);
|
|
215
|
+
cm.reconnectTimeout = null;
|
|
216
|
+
}
|
|
217
|
+
connectWebSocket(cm);
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
});
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// ============================================================================
|
|
225
|
+
// Query Composable
|
|
226
|
+
// ============================================================================
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* Query state returned by useQuery
|
|
230
|
+
*/
|
|
231
|
+
export interface QueryState<T> {
|
|
232
|
+
data: Ref<T | undefined>;
|
|
233
|
+
error: Ref<Error | null>;
|
|
234
|
+
isLoading: ComputedRef<boolean>;
|
|
235
|
+
isConnected: Ref<boolean>;
|
|
236
|
+
refetch: () => Promise<void>;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* Query function reference
|
|
241
|
+
*/
|
|
242
|
+
export interface QueryFunction<TArgs = void, TResult = unknown> {
|
|
243
|
+
_name: string;
|
|
244
|
+
_args?: TArgs;
|
|
245
|
+
_result?: TResult;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* Reactive query composable with automatic realtime updates
|
|
250
|
+
*
|
|
251
|
+
* Uses Nuxt's useAsyncData internally for proper SSR support.
|
|
252
|
+
* Data is fetched on the server and hydrated on the client.
|
|
253
|
+
* Automatically subscribes to WebSocket updates for realtime sync.
|
|
254
|
+
*
|
|
255
|
+
* @example
|
|
256
|
+
* ```vue
|
|
257
|
+
* <script setup>
|
|
258
|
+
* // That's it! Data automatically updates when it changes anywhere
|
|
259
|
+
* const { data: posts, isLoading, isConnected } = useQuery(api.posts.list);
|
|
260
|
+
* </script>
|
|
261
|
+
* ```
|
|
262
|
+
*/
|
|
263
|
+
export function useQuery<TArgs, TResult>(
|
|
264
|
+
query: QueryFunction<TArgs, TResult> | string,
|
|
265
|
+
args?: TArgs
|
|
266
|
+
): QueryState<TResult> {
|
|
267
|
+
const queryName = typeof query === 'string' ? query : query._name;
|
|
268
|
+
const queryArgs = args as Record<string, unknown> | undefined;
|
|
269
|
+
|
|
270
|
+
// Create a unique key for this query based on name and args
|
|
271
|
+
const queryKey = `tether-${queryName}-${JSON.stringify(args ?? {})}`;
|
|
272
|
+
|
|
273
|
+
// Track WebSocket connection state
|
|
274
|
+
const isConnected = ref(false);
|
|
275
|
+
|
|
276
|
+
// Use Nuxt's useAsyncData for proper SSR support
|
|
277
|
+
const { data, error: asyncError, status, refresh } = useAsyncData<TResult>(
|
|
278
|
+
queryKey,
|
|
279
|
+
async () => {
|
|
280
|
+
const response = await $fetch<{ data: TResult }>('/api/_tether/query', {
|
|
281
|
+
method: 'POST',
|
|
282
|
+
body: {
|
|
283
|
+
function: queryName,
|
|
284
|
+
args,
|
|
285
|
+
},
|
|
286
|
+
});
|
|
287
|
+
return response.data;
|
|
288
|
+
},
|
|
289
|
+
{
|
|
290
|
+
dedupe: 'cancel',
|
|
291
|
+
}
|
|
292
|
+
);
|
|
293
|
+
|
|
294
|
+
// Wrap the error as a computed ref
|
|
295
|
+
const error = computed(() => {
|
|
296
|
+
if (asyncError.value) {
|
|
297
|
+
return asyncError.value instanceof Error
|
|
298
|
+
? asyncError.value
|
|
299
|
+
: new Error(String(asyncError.value));
|
|
300
|
+
}
|
|
301
|
+
return null;
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
// Compute isLoading from status
|
|
305
|
+
const isLoading = computed(() => status.value === 'pending');
|
|
306
|
+
|
|
307
|
+
// Refetch function
|
|
308
|
+
const refetch = async () => {
|
|
309
|
+
await refresh();
|
|
310
|
+
};
|
|
311
|
+
|
|
312
|
+
// Auto-subscribe on client side
|
|
313
|
+
if (import.meta.client) {
|
|
314
|
+
let unsubscribe: (() => void) | null = null;
|
|
315
|
+
|
|
316
|
+
onMounted(() => {
|
|
317
|
+
unsubscribe = subscribe(queryName, queryArgs, {
|
|
318
|
+
onData: (newData) => {
|
|
319
|
+
data.value = newData as TResult;
|
|
320
|
+
},
|
|
321
|
+
onInvalidate: () => {
|
|
322
|
+
refresh();
|
|
323
|
+
},
|
|
324
|
+
});
|
|
325
|
+
|
|
326
|
+
// Track connection state
|
|
327
|
+
const checkConnection = () => {
|
|
328
|
+
const cm = connectionManager;
|
|
329
|
+
isConnected.value = cm?.ws?.readyState === WebSocket.OPEN ?? false;
|
|
330
|
+
};
|
|
331
|
+
checkConnection();
|
|
332
|
+
const interval = setInterval(checkConnection, 1000);
|
|
333
|
+
onUnmounted(() => clearInterval(interval));
|
|
334
|
+
});
|
|
335
|
+
|
|
336
|
+
onUnmounted(() => {
|
|
337
|
+
unsubscribe?.();
|
|
338
|
+
});
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
return {
|
|
342
|
+
data: data as Ref<TResult | undefined>,
|
|
343
|
+
error: error as unknown as Ref<Error | null>,
|
|
344
|
+
isLoading,
|
|
345
|
+
isConnected,
|
|
346
|
+
refetch,
|
|
347
|
+
};
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
// ============================================================================
|
|
351
|
+
// Mutation Composable
|
|
352
|
+
// ============================================================================
|
|
353
|
+
|
|
354
|
+
/**
|
|
355
|
+
* Mutation state returned by useMutation
|
|
356
|
+
*/
|
|
357
|
+
export interface MutationState<TArgs, TResult> {
|
|
358
|
+
data: Ref<TResult | undefined>;
|
|
359
|
+
error: Ref<Error | null>;
|
|
360
|
+
isPending: Ref<boolean>;
|
|
361
|
+
mutate: (args: TArgs) => Promise<TResult>;
|
|
362
|
+
reset: () => void;
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
/**
|
|
366
|
+
* Mutation function reference
|
|
367
|
+
*/
|
|
368
|
+
export interface MutationFunction<TArgs = void, TResult = unknown> {
|
|
369
|
+
_name: string;
|
|
370
|
+
_args?: TArgs;
|
|
371
|
+
_result?: TResult;
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
/**
|
|
375
|
+
* Mutation composable
|
|
376
|
+
*
|
|
377
|
+
* @example
|
|
378
|
+
* ```vue
|
|
379
|
+
* <script setup>
|
|
380
|
+
* const { mutate: createPost, isPending } = useMutation(api.posts.create);
|
|
381
|
+
*
|
|
382
|
+
* async function handleSubmit() {
|
|
383
|
+
* await createPost({ title: 'Hello', content: '...' });
|
|
384
|
+
* // All useQuery subscribers automatically update!
|
|
385
|
+
* }
|
|
386
|
+
* </script>
|
|
387
|
+
* ```
|
|
388
|
+
*/
|
|
389
|
+
export function useMutation<TArgs, TResult>(
|
|
390
|
+
mutation: MutationFunction<TArgs, TResult> | string
|
|
391
|
+
): MutationState<TArgs, TResult> {
|
|
392
|
+
const mutationName = typeof mutation === 'string' ? mutation : mutation._name;
|
|
393
|
+
const data = ref<TResult>();
|
|
394
|
+
const error = ref<Error | null>(null);
|
|
395
|
+
const isPending = ref(false);
|
|
396
|
+
|
|
397
|
+
const mutate = async (args: TArgs): Promise<TResult> => {
|
|
398
|
+
try {
|
|
399
|
+
isPending.value = true;
|
|
400
|
+
error.value = null;
|
|
401
|
+
|
|
402
|
+
const response = await $fetch<{ data: TResult }>('/api/_tether/mutation', {
|
|
403
|
+
method: 'POST',
|
|
404
|
+
body: {
|
|
405
|
+
function: mutationName,
|
|
406
|
+
args,
|
|
407
|
+
},
|
|
408
|
+
});
|
|
409
|
+
|
|
410
|
+
data.value = response.data;
|
|
411
|
+
return response.data;
|
|
412
|
+
} catch (e) {
|
|
413
|
+
error.value = e instanceof Error ? e : new Error(String(e));
|
|
414
|
+
throw e;
|
|
415
|
+
} finally {
|
|
416
|
+
isPending.value = false;
|
|
417
|
+
}
|
|
418
|
+
};
|
|
419
|
+
|
|
420
|
+
const reset = () => {
|
|
421
|
+
data.value = undefined;
|
|
422
|
+
error.value = null;
|
|
423
|
+
isPending.value = false;
|
|
424
|
+
};
|
|
425
|
+
|
|
426
|
+
return {
|
|
427
|
+
data: data as Ref<TResult | undefined>,
|
|
428
|
+
error,
|
|
429
|
+
isPending,
|
|
430
|
+
mutate,
|
|
431
|
+
reset,
|
|
432
|
+
};
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
// ============================================================================
|
|
436
|
+
// Manual Subscription (for advanced use cases)
|
|
437
|
+
// ============================================================================
|
|
438
|
+
|
|
439
|
+
/**
|
|
440
|
+
* Subscription handler options
|
|
441
|
+
*/
|
|
442
|
+
export interface SubscriptionHandlers {
|
|
443
|
+
/** Called when fresh data is received from the server */
|
|
444
|
+
onData?: (data: unknown) => void;
|
|
445
|
+
/** Called when server invalidates the subscription (signals to refetch) */
|
|
446
|
+
onInvalidate?: () => void;
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
/**
|
|
450
|
+
* Manual WebSocket subscription composable
|
|
451
|
+
*
|
|
452
|
+
* NOTE: You typically don't need this! useQuery automatically subscribes to updates.
|
|
453
|
+
* This is only for advanced use cases where you need custom subscription handling.
|
|
454
|
+
*
|
|
455
|
+
* @example
|
|
456
|
+
* ```vue
|
|
457
|
+
* <script setup>
|
|
458
|
+
* // For custom subscription handling (rare)
|
|
459
|
+
* const { isConnected } = useTetherSubscription('custom.query', {}, {
|
|
460
|
+
* onData: (data) => console.log('Got data:', data),
|
|
461
|
+
* onInvalidate: () => console.log('Data invalidated'),
|
|
462
|
+
* });
|
|
463
|
+
* </script>
|
|
464
|
+
* ```
|
|
465
|
+
*/
|
|
466
|
+
export function useTetherSubscription(
|
|
467
|
+
queryName: string,
|
|
468
|
+
args: Record<string, unknown> | undefined,
|
|
469
|
+
handlers: SubscriptionHandlers | ((data?: unknown) => void)
|
|
470
|
+
): { isConnected: Ref<boolean> } {
|
|
471
|
+
const isConnected = ref(false);
|
|
472
|
+
|
|
473
|
+
// Normalize handlers
|
|
474
|
+
const onData = typeof handlers === 'function'
|
|
475
|
+
? handlers
|
|
476
|
+
: handlers.onData ?? (() => {});
|
|
477
|
+
const onInvalidate = typeof handlers === 'function'
|
|
478
|
+
? () => handlers(undefined)
|
|
479
|
+
: handlers.onInvalidate ?? (() => {});
|
|
480
|
+
|
|
481
|
+
if (import.meta.client) {
|
|
482
|
+
let unsubscribe: (() => void) | null = null;
|
|
483
|
+
|
|
484
|
+
onMounted(() => {
|
|
485
|
+
unsubscribe = subscribe(queryName, args, {
|
|
486
|
+
onData,
|
|
487
|
+
onInvalidate,
|
|
488
|
+
});
|
|
489
|
+
|
|
490
|
+
// Track connection state
|
|
491
|
+
const checkConnection = () => {
|
|
492
|
+
const cm = connectionManager;
|
|
493
|
+
isConnected.value = cm?.ws?.readyState === WebSocket.OPEN ?? false;
|
|
494
|
+
};
|
|
495
|
+
checkConnection();
|
|
496
|
+
const interval = setInterval(checkConnection, 1000);
|
|
497
|
+
onUnmounted(() => clearInterval(interval));
|
|
498
|
+
});
|
|
499
|
+
|
|
500
|
+
onUnmounted(() => {
|
|
501
|
+
unsubscribe?.();
|
|
502
|
+
});
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
return { isConnected };
|
|
506
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Client-side Tether plugin
|
|
3
|
+
*
|
|
4
|
+
* Sets up the WebSocket configuration for realtime subscriptions.
|
|
5
|
+
* This only exposes the project ID and WebSocket URL - no secrets.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { defineNuxtPlugin, useRuntimeConfig } from '#imports';
|
|
9
|
+
|
|
10
|
+
declare global {
|
|
11
|
+
interface Window {
|
|
12
|
+
__TETHER_CONFIG__?: {
|
|
13
|
+
projectId: string;
|
|
14
|
+
wsUrl: string;
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export default defineNuxtPlugin(() => {
|
|
20
|
+
const config = useRuntimeConfig();
|
|
21
|
+
|
|
22
|
+
// Make config available for WebSocket connections
|
|
23
|
+
// This is safe - no secrets are exposed
|
|
24
|
+
const tetherConfig = {
|
|
25
|
+
projectId: config.public.tether?.projectId || '',
|
|
26
|
+
wsUrl: config.public.tether?.wsUrl || '',
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
window.__TETHER_CONFIG__ = tetherConfig;
|
|
30
|
+
|
|
31
|
+
if (!tetherConfig.wsUrl || !tetherConfig.projectId) {
|
|
32
|
+
console.warn('[Tether] Config incomplete - WebSocket subscriptions will not work');
|
|
33
|
+
}
|
|
34
|
+
});
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"cron.d.ts","sourceRoot":"","sources":["cron.ts"],"names":[],"mappings":"AAAA;;;;;;;;;GASG;AAEH,OAAO,EAAE,iBAAiB,EAAE,MAAM,mBAAmB,CAAC;AAGtD,OAAO,EAAE,iBAAiB,EAAE,CAAC;
|
|
1
|
+
{"version":3,"file":"cron.d.ts","sourceRoot":"","sources":["cron.ts"],"names":[],"mappings":"AAAA;;;;;;;;;GASG;AAEH,OAAO,EAAE,iBAAiB,EAAE,MAAM,mBAAmB,CAAC;AAGtD,OAAO,EAAE,iBAAiB,EAAE,CAAC;AAqC7B;;;;;;;;;;;;;GAaG;AACH,wBAAgB,mBAAmB,CACjC,YAAY,EAAE,MAAM,EACpB,OAAO,EAAE,CAAC,IAAI,EAAE,OAAO,KAAK,OAAO,CAAC,OAAO,CAAC,GAC3C,IAAI,CAGN;AAED;;GAEG;AACH,wBAAgB,qBAAqB,CAAC,YAAY,EAAE,MAAM,GAAG,IAAI,CAEhE;AAED;;GAEG;AACH,wBAAgB,eAAe,IAAI,MAAM,EAAE,CAE1C;;AAslBD,wBAmCG"}
|
|
@@ -372,7 +372,8 @@ async function executeViaApi(config, functionName, functionType, args) {
|
|
|
372
372
|
try {
|
|
373
373
|
const error = JSON.parse(responseText);
|
|
374
374
|
errorMsg = error.error || error.message || `Function '${functionName}' failed with status ${response.status}`;
|
|
375
|
-
}
|
|
375
|
+
}
|
|
376
|
+
catch {
|
|
376
377
|
errorMsg = responseText || `Function '${functionName}' failed with status ${response.status}`;
|
|
377
378
|
}
|
|
378
379
|
log.debug(`/${type} endpoint returned ${response.status}: ${errorMsg}`);
|
|
@@ -427,10 +428,11 @@ async function handleCronTrigger(config, trigger) {
|
|
|
427
428
|
};
|
|
428
429
|
// Create tether context with env vars from process.env
|
|
429
430
|
// Note: For cron execution, env vars should be set in the Nuxt app's environment
|
|
431
|
+
const processEnv = globalThis.process?.env ?? {};
|
|
430
432
|
const tether = {
|
|
431
433
|
env: new Proxy({}, {
|
|
432
434
|
get(_target, key) {
|
|
433
|
-
return
|
|
435
|
+
return processEnv[key];
|
|
434
436
|
},
|
|
435
437
|
}),
|
|
436
438
|
};
|
|
@@ -469,7 +471,8 @@ async function handleCronTrigger(config, trigger) {
|
|
|
469
471
|
function startHeartbeat() {
|
|
470
472
|
stopHeartbeat();
|
|
471
473
|
heartbeatTimer = setInterval(() => {
|
|
472
|
-
|
|
474
|
+
// WebSocket.OPEN = 1
|
|
475
|
+
if (ws?.readyState === 1) {
|
|
473
476
|
awaitingPong = true;
|
|
474
477
|
log.debug('Sending heartbeat ping');
|
|
475
478
|
ws.send(JSON.stringify({ type: 'ping' }));
|
|
@@ -496,16 +499,29 @@ function stopHeartbeat() {
|
|
|
496
499
|
}
|
|
497
500
|
awaitingPong = false;
|
|
498
501
|
}
|
|
499
|
-
|
|
500
|
-
|
|
502
|
+
// WebSocket implementation - loaded dynamically for Node.js
|
|
503
|
+
let WebSocketImpl = null;
|
|
504
|
+
async function getWebSocketImpl() {
|
|
505
|
+
if (WebSocketImpl)
|
|
506
|
+
return WebSocketImpl;
|
|
507
|
+
if (typeof WebSocket !== 'undefined') {
|
|
508
|
+
WebSocketImpl = WebSocket;
|
|
509
|
+
}
|
|
510
|
+
else {
|
|
511
|
+
// Dynamic import for Node.js ESM compatibility
|
|
512
|
+
const wsModule = await import('ws');
|
|
513
|
+
WebSocketImpl = wsModule.default;
|
|
514
|
+
}
|
|
515
|
+
return WebSocketImpl;
|
|
516
|
+
}
|
|
517
|
+
async function connect(config) {
|
|
518
|
+
const WS = await getWebSocketImpl();
|
|
519
|
+
if (ws?.readyState === WS.OPEN || ws?.readyState === WS.CONNECTING) {
|
|
501
520
|
return;
|
|
502
521
|
}
|
|
503
522
|
try {
|
|
504
523
|
const url = getWsUrl(config);
|
|
505
|
-
|
|
506
|
-
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
507
|
-
const WebSocketImpl = typeof WebSocket !== 'undefined' ? WebSocket : require('ws');
|
|
508
|
-
ws = new WebSocketImpl(url);
|
|
524
|
+
ws = new WS(url);
|
|
509
525
|
ws.onopen = () => {
|
|
510
526
|
reconnectAttempts = 0;
|
|
511
527
|
log.debug('WebSocket connected');
|
|
@@ -568,7 +584,7 @@ function handleReconnect(config) {
|
|
|
568
584
|
const delay = Math.min(RECONNECT_DELAY * Math.pow(2, reconnectAttempts - 1), 30000);
|
|
569
585
|
log.debug(`Reconnecting in ${delay}ms (attempt ${reconnectAttempts}/${MAX_RECONNECT_ATTEMPTS})`);
|
|
570
586
|
setTimeout(() => {
|
|
571
|
-
connect(config);
|
|
587
|
+
connect(config).catch((err) => log.error('Reconnect failed:', err));
|
|
572
588
|
}, delay);
|
|
573
589
|
}
|
|
574
590
|
export default defineNitroPlugin((nitro) => {
|
|
@@ -589,7 +605,7 @@ export default defineNitroPlugin((nitro) => {
|
|
|
589
605
|
const config = { url, projectId, apiKey, environment };
|
|
590
606
|
// Connect on next tick to allow the server to fully initialise
|
|
591
607
|
process.nextTick(() => {
|
|
592
|
-
connect(config);
|
|
608
|
+
connect(config).catch((err) => log.error('Initial connect failed:', err));
|
|
593
609
|
});
|
|
594
610
|
// Clean up on shutdown
|
|
595
611
|
nitro.hooks.hook('close', () => {
|
|
@@ -602,4 +618,4 @@ export default defineNitroPlugin((nitro) => {
|
|
|
602
618
|
log.debug('Connection closed');
|
|
603
619
|
});
|
|
604
620
|
});
|
|
605
|
-
//# sourceMappingURL=cron.js.map
|
|
621
|
+
//# sourceMappingURL=cron.js.map
|