@tthr/vue 0.0.76 → 0.0.78
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 +5 -5
- package/nuxt/module.ts +5 -5
- package/package.json +1 -2
- package/nuxt/runtime/composables.ts +0 -506
- package/nuxt/runtime/plugin.client.ts +0 -34
- package/nuxt/runtime/server/plugins/cron.ts +0 -377
package/nuxt/module.js
CHANGED
|
@@ -106,15 +106,15 @@ export default defineNuxtModule({
|
|
|
106
106
|
},
|
|
107
107
|
{
|
|
108
108
|
name: 'registerCronHandler',
|
|
109
|
-
from: resolver.resolve('./runtime/server/plugins/cron.
|
|
109
|
+
from: resolver.resolve('./runtime/server/plugins/cron.js'),
|
|
110
110
|
},
|
|
111
111
|
{
|
|
112
112
|
name: 'unregisterCronHandler',
|
|
113
|
-
from: resolver.resolve('./runtime/server/plugins/cron.
|
|
113
|
+
from: resolver.resolve('./runtime/server/plugins/cron.js'),
|
|
114
114
|
},
|
|
115
115
|
{
|
|
116
116
|
name: 'getCronHandlers',
|
|
117
|
-
from: resolver.resolve('./runtime/server/plugins/cron.
|
|
117
|
+
from: resolver.resolve('./runtime/server/plugins/cron.js'),
|
|
118
118
|
},
|
|
119
119
|
]);
|
|
120
120
|
// Ensure the runtime files are transpiled
|
|
@@ -124,8 +124,8 @@ export default defineNuxtModule({
|
|
|
124
124
|
// This auto-connects to Tether when the server starts
|
|
125
125
|
nuxt.hook('nitro:config', (nitroConfig) => {
|
|
126
126
|
nitroConfig.plugins = nitroConfig.plugins || [];
|
|
127
|
-
// Use
|
|
128
|
-
nitroConfig.plugins.push(resolver.resolve('./runtime/server/plugins/cron.
|
|
127
|
+
// Use pre-compiled JS - Nitro can parse this without TypeScript plugins
|
|
128
|
+
nitroConfig.plugins.push(resolver.resolve('./runtime/server/plugins/cron.js'));
|
|
129
129
|
// Ensure Nitro inlines and transpiles the plugin from node_modules
|
|
130
130
|
nitroConfig.externals = nitroConfig.externals || {};
|
|
131
131
|
nitroConfig.externals.inline = nitroConfig.externals.inline || [];
|
package/nuxt/module.ts
CHANGED
|
@@ -127,15 +127,15 @@ export default defineNuxtModule<TetherModuleOptions>({
|
|
|
127
127
|
},
|
|
128
128
|
{
|
|
129
129
|
name: 'registerCronHandler',
|
|
130
|
-
from: resolver.resolve('./runtime/server/plugins/cron.
|
|
130
|
+
from: resolver.resolve('./runtime/server/plugins/cron.js'),
|
|
131
131
|
},
|
|
132
132
|
{
|
|
133
133
|
name: 'unregisterCronHandler',
|
|
134
|
-
from: resolver.resolve('./runtime/server/plugins/cron.
|
|
134
|
+
from: resolver.resolve('./runtime/server/plugins/cron.js'),
|
|
135
135
|
},
|
|
136
136
|
{
|
|
137
137
|
name: 'getCronHandlers',
|
|
138
|
-
from: resolver.resolve('./runtime/server/plugins/cron.
|
|
138
|
+
from: resolver.resolve('./runtime/server/plugins/cron.js'),
|
|
139
139
|
},
|
|
140
140
|
]);
|
|
141
141
|
|
|
@@ -147,8 +147,8 @@ export default defineNuxtModule<TetherModuleOptions>({
|
|
|
147
147
|
// This auto-connects to Tether when the server starts
|
|
148
148
|
nuxt.hook('nitro:config' as any, (nitroConfig: any) => {
|
|
149
149
|
nitroConfig.plugins = nitroConfig.plugins || [];
|
|
150
|
-
// Use
|
|
151
|
-
nitroConfig.plugins.push(resolver.resolve('./runtime/server/plugins/cron.
|
|
150
|
+
// Use pre-compiled JS - Nitro can parse this without TypeScript plugins
|
|
151
|
+
nitroConfig.plugins.push(resolver.resolve('./runtime/server/plugins/cron.js'));
|
|
152
152
|
|
|
153
153
|
// Ensure Nitro inlines and transpiles the plugin from node_modules
|
|
154
154
|
nitroConfig.externals = nitroConfig.externals || {};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tthr/vue",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.78",
|
|
4
4
|
"description": "Tether Vue/Nuxt SDK",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -29,7 +29,6 @@
|
|
|
29
29
|
"nuxt/module.ts",
|
|
30
30
|
"nuxt/module.js",
|
|
31
31
|
"nuxt/module.js.map",
|
|
32
|
-
"nuxt/runtime/**/*.ts",
|
|
33
32
|
"nuxt/runtime/**/*.js",
|
|
34
33
|
"nuxt/runtime/**/*.js.map",
|
|
35
34
|
"nuxt/runtime/**/*.d.ts",
|
|
@@ -1,506 +0,0 @@
|
|
|
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
|
-
}
|
|
@@ -1,34 +0,0 @@
|
|
|
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,377 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Nuxt server plugin for Tether cron execution
|
|
3
|
-
*
|
|
4
|
-
* This plugin connects to Tether via WebSocket to receive cron triggers.
|
|
5
|
-
* When triggered, it executes the user's function and reports the result.
|
|
6
|
-
*/
|
|
7
|
-
|
|
8
|
-
import { defineNitroPlugin } from 'nitropack/runtime';
|
|
9
|
-
import { configureTetherServer, executeFunction } from '../../../../dist/server.js';
|
|
10
|
-
|
|
11
|
-
// Re-export defineNitroPlugin for use by generated plugins
|
|
12
|
-
export { defineNitroPlugin };
|
|
13
|
-
|
|
14
|
-
// Store for manually registered cron handlers
|
|
15
|
-
const cronHandlers: Map<string, (args: unknown) => Promise<unknown>> = new Map();
|
|
16
|
-
|
|
17
|
-
// Dynamic function registry - populated on first cron trigger
|
|
18
|
-
let functionRegistry: Record<string, Record<string, { handler: Function; args?: unknown }>> | null = null;
|
|
19
|
-
|
|
20
|
-
// WebSocket connection state
|
|
21
|
-
let ws: WebSocket | null = null;
|
|
22
|
-
let connectionId: string | null = null;
|
|
23
|
-
let reconnectAttempts = 0;
|
|
24
|
-
let heartbeatTimer: ReturnType<typeof setInterval> | null = null;
|
|
25
|
-
let heartbeatTimeoutTimer: ReturnType<typeof setTimeout> | null = null;
|
|
26
|
-
let awaitingPong = false;
|
|
27
|
-
let shouldReconnect = true;
|
|
28
|
-
let verboseLogging = false;
|
|
29
|
-
|
|
30
|
-
const MAX_RECONNECT_ATTEMPTS = 10;
|
|
31
|
-
const HEARTBEAT_INTERVAL = 20000;
|
|
32
|
-
const HEARTBEAT_TIMEOUT = 10000;
|
|
33
|
-
const RECONNECT_DELAY = 1000;
|
|
34
|
-
const PREFIX = '[Tether Cron]';
|
|
35
|
-
|
|
36
|
-
const log = {
|
|
37
|
-
debug: (...args: unknown[]) => { if (verboseLogging) console.log(PREFIX, ...args); },
|
|
38
|
-
info: (...args: unknown[]) => console.log(PREFIX, ...args),
|
|
39
|
-
warn: (...args: unknown[]) => console.warn(PREFIX, ...args),
|
|
40
|
-
error: (...args: unknown[]) => console.error(PREFIX, ...args),
|
|
41
|
-
};
|
|
42
|
-
|
|
43
|
-
/**
|
|
44
|
-
* Register a cron handler for a specific function
|
|
45
|
-
*/
|
|
46
|
-
export function registerCronHandler(
|
|
47
|
-
functionName: string,
|
|
48
|
-
handler: (args: unknown) => Promise<unknown>
|
|
49
|
-
): void {
|
|
50
|
-
cronHandlers.set(functionName, handler);
|
|
51
|
-
log.debug('Registered handler for:', functionName);
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
/**
|
|
55
|
-
* Unregister a cron handler
|
|
56
|
-
*/
|
|
57
|
-
export function unregisterCronHandler(functionName: string): void {
|
|
58
|
-
cronHandlers.delete(functionName);
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
/**
|
|
62
|
-
* Get all registered cron handlers
|
|
63
|
-
*/
|
|
64
|
-
export function getCronHandlers(): string[] {
|
|
65
|
-
return Array.from(cronHandlers.keys());
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
/**
|
|
69
|
-
* Load user's custom functions from ~/tether/functions
|
|
70
|
-
*/
|
|
71
|
-
async function loadFunctionRegistry(): Promise<void> {
|
|
72
|
-
if (functionRegistry !== null) return;
|
|
73
|
-
|
|
74
|
-
try {
|
|
75
|
-
const functions = await import('~~/tether/functions/index.ts').catch(() => null);
|
|
76
|
-
functionRegistry = functions as Record<string, Record<string, { handler: Function; args?: unknown }>> ?? {};
|
|
77
|
-
} catch {
|
|
78
|
-
functionRegistry = {};
|
|
79
|
-
}
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
/**
|
|
83
|
-
* Look up a function by name (e.g., "sync.syncClips")
|
|
84
|
-
*/
|
|
85
|
-
function lookupFunction(name: string): { handler: Function; args?: unknown } | null {
|
|
86
|
-
if (!functionRegistry || !name) return null;
|
|
87
|
-
|
|
88
|
-
const parts = name.split('.');
|
|
89
|
-
if (parts.length !== 2) return null;
|
|
90
|
-
|
|
91
|
-
const [moduleName, fnName] = parts;
|
|
92
|
-
const module = functionRegistry[moduleName];
|
|
93
|
-
if (!module) return null;
|
|
94
|
-
|
|
95
|
-
const fn = module[fnName];
|
|
96
|
-
if (fn && typeof fn === 'object' && typeof fn.handler === 'function') {
|
|
97
|
-
return fn;
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
return null;
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
interface CronTriggerMessage {
|
|
104
|
-
type: 'cron_trigger';
|
|
105
|
-
executionId: string;
|
|
106
|
-
cronId: string;
|
|
107
|
-
functionName: string;
|
|
108
|
-
functionType: string;
|
|
109
|
-
args?: unknown;
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
interface ServerMessage {
|
|
113
|
-
type: 'connected' | 'cron_trigger' | 'pong' | 'error';
|
|
114
|
-
connection_id?: string;
|
|
115
|
-
executionId?: string;
|
|
116
|
-
cronId?: string;
|
|
117
|
-
functionName?: string;
|
|
118
|
-
functionType?: string;
|
|
119
|
-
args?: unknown;
|
|
120
|
-
error?: { code: string; message: string };
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
function getWsUrl(config: { url: string; projectId: string; apiKey: string; environment?: string }): string {
|
|
124
|
-
const base = config.url
|
|
125
|
-
.replace('https://', 'wss://')
|
|
126
|
-
.replace('http://', 'ws://')
|
|
127
|
-
.replace(/\/$/, '');
|
|
128
|
-
|
|
129
|
-
const env = config.environment;
|
|
130
|
-
const wsPath = env && env !== 'production'
|
|
131
|
-
? `${base}/ws/${config.projectId}/${env}`
|
|
132
|
-
: `${base}/ws/${config.projectId}`;
|
|
133
|
-
|
|
134
|
-
return `${wsPath}?type=server&token=${encodeURIComponent(config.apiKey)}`;
|
|
135
|
-
}
|
|
136
|
-
|
|
137
|
-
async function reportExecution(
|
|
138
|
-
config: { url: string; projectId: string; apiKey: string; environment?: string },
|
|
139
|
-
trigger: CronTriggerMessage,
|
|
140
|
-
result: { success: boolean; result?: unknown; error?: string },
|
|
141
|
-
durationMs: number
|
|
142
|
-
): Promise<void> {
|
|
143
|
-
const base = config.url.replace(/\/$/, '');
|
|
144
|
-
const env = config.environment;
|
|
145
|
-
const apiPath = env && env !== 'production'
|
|
146
|
-
? `${base}/api/v1/projects/${config.projectId}/env/${env}`
|
|
147
|
-
: `${base}/api/v1/projects/${config.projectId}`;
|
|
148
|
-
|
|
149
|
-
try {
|
|
150
|
-
const response = await fetch(`${apiPath}/crons/${trigger.cronId}/executions`, {
|
|
151
|
-
method: 'POST',
|
|
152
|
-
headers: {
|
|
153
|
-
'Content-Type': 'application/json',
|
|
154
|
-
'Authorization': `Bearer ${config.apiKey}`,
|
|
155
|
-
},
|
|
156
|
-
body: JSON.stringify({
|
|
157
|
-
executionId: trigger.executionId,
|
|
158
|
-
success: result.success,
|
|
159
|
-
errorMessage: result.error,
|
|
160
|
-
result: result.result,
|
|
161
|
-
durationMs,
|
|
162
|
-
}),
|
|
163
|
-
});
|
|
164
|
-
|
|
165
|
-
if (!response.ok) {
|
|
166
|
-
log.error(`Failed to report execution: ${response.status}`);
|
|
167
|
-
}
|
|
168
|
-
} catch (error) {
|
|
169
|
-
log.error('Failed to report execution:', error);
|
|
170
|
-
}
|
|
171
|
-
}
|
|
172
|
-
|
|
173
|
-
async function handleCronTrigger(
|
|
174
|
-
config: { url: string; projectId: string; apiKey: string; environment?: string },
|
|
175
|
-
trigger: CronTriggerMessage
|
|
176
|
-
): Promise<void> {
|
|
177
|
-
log.info(`Received trigger for ${trigger.functionName} (execution: ${trigger.executionId})`);
|
|
178
|
-
|
|
179
|
-
const startTime = Date.now();
|
|
180
|
-
|
|
181
|
-
try {
|
|
182
|
-
let result: unknown;
|
|
183
|
-
|
|
184
|
-
// 1. Check for manually registered handler
|
|
185
|
-
const handler = cronHandlers.get(trigger.functionName);
|
|
186
|
-
if (handler) {
|
|
187
|
-
log.debug(`Using registered handler for: ${trigger.functionName}`);
|
|
188
|
-
result = await handler(trigger.args);
|
|
189
|
-
} else {
|
|
190
|
-
// 2. Load and execute from user's tether/functions using executeFunction
|
|
191
|
-
await loadFunctionRegistry();
|
|
192
|
-
const fn = lookupFunction(trigger.functionName);
|
|
193
|
-
|
|
194
|
-
if (fn) {
|
|
195
|
-
log.debug(`Executing function: ${trigger.functionName}`);
|
|
196
|
-
// Use executeFunction from @tthr/vue/server which has the proper db proxy
|
|
197
|
-
result = await executeFunction(fn as any, trigger.args ?? {});
|
|
198
|
-
} else {
|
|
199
|
-
throw new Error(`Function '${trigger.functionName}' not found`);
|
|
200
|
-
}
|
|
201
|
-
}
|
|
202
|
-
|
|
203
|
-
const durationMs = Date.now() - startTime;
|
|
204
|
-
await reportExecution(config, trigger, { success: true, result }, durationMs);
|
|
205
|
-
} catch (error) {
|
|
206
|
-
const durationMs = Date.now() - startTime;
|
|
207
|
-
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
208
|
-
log.error(`Handler error for ${trigger.functionName}:`, errorMessage);
|
|
209
|
-
await reportExecution(config, trigger, { success: false, error: errorMessage }, durationMs);
|
|
210
|
-
}
|
|
211
|
-
}
|
|
212
|
-
|
|
213
|
-
function startHeartbeat(): void {
|
|
214
|
-
stopHeartbeat();
|
|
215
|
-
|
|
216
|
-
heartbeatTimer = setInterval(() => {
|
|
217
|
-
if (ws?.readyState === 1) { // WebSocket.OPEN
|
|
218
|
-
awaitingPong = true;
|
|
219
|
-
ws.send(JSON.stringify({ type: 'ping' }));
|
|
220
|
-
|
|
221
|
-
heartbeatTimeoutTimer = setTimeout(() => {
|
|
222
|
-
if (awaitingPong) {
|
|
223
|
-
log.warn('Heartbeat timeout - forcing reconnect');
|
|
224
|
-
ws?.close();
|
|
225
|
-
}
|
|
226
|
-
}, HEARTBEAT_TIMEOUT);
|
|
227
|
-
}
|
|
228
|
-
}, HEARTBEAT_INTERVAL);
|
|
229
|
-
}
|
|
230
|
-
|
|
231
|
-
function stopHeartbeat(): void {
|
|
232
|
-
if (heartbeatTimer) {
|
|
233
|
-
clearInterval(heartbeatTimer);
|
|
234
|
-
heartbeatTimer = null;
|
|
235
|
-
}
|
|
236
|
-
if (heartbeatTimeoutTimer) {
|
|
237
|
-
clearTimeout(heartbeatTimeoutTimer);
|
|
238
|
-
heartbeatTimeoutTimer = null;
|
|
239
|
-
}
|
|
240
|
-
awaitingPong = false;
|
|
241
|
-
}
|
|
242
|
-
|
|
243
|
-
// WebSocket implementation - loaded dynamically for Node.js
|
|
244
|
-
let WebSocketImpl: typeof WebSocket | null = null;
|
|
245
|
-
|
|
246
|
-
async function getWebSocketImpl(): Promise<typeof WebSocket> {
|
|
247
|
-
if (WebSocketImpl) return WebSocketImpl;
|
|
248
|
-
|
|
249
|
-
if (typeof WebSocket !== 'undefined') {
|
|
250
|
-
WebSocketImpl = WebSocket;
|
|
251
|
-
} else {
|
|
252
|
-
const wsModule = await import('ws');
|
|
253
|
-
WebSocketImpl = wsModule.default as unknown as typeof WebSocket;
|
|
254
|
-
}
|
|
255
|
-
return WebSocketImpl;
|
|
256
|
-
}
|
|
257
|
-
|
|
258
|
-
async function connect(config: { url: string; projectId: string; apiKey: string; environment?: string }): Promise<void> {
|
|
259
|
-
const WS = await getWebSocketImpl();
|
|
260
|
-
|
|
261
|
-
if (ws?.readyState === WS.OPEN || ws?.readyState === WS.CONNECTING) {
|
|
262
|
-
return;
|
|
263
|
-
}
|
|
264
|
-
|
|
265
|
-
try {
|
|
266
|
-
const url = getWsUrl(config);
|
|
267
|
-
ws = new WS(url);
|
|
268
|
-
|
|
269
|
-
ws.onopen = () => {
|
|
270
|
-
reconnectAttempts = 0;
|
|
271
|
-
log.debug('WebSocket connected');
|
|
272
|
-
};
|
|
273
|
-
|
|
274
|
-
ws.onmessage = async (event: { data: string | Buffer }) => {
|
|
275
|
-
const data = typeof event.data === 'string' ? event.data : event.data.toString();
|
|
276
|
-
|
|
277
|
-
try {
|
|
278
|
-
const message = JSON.parse(data) as ServerMessage;
|
|
279
|
-
|
|
280
|
-
switch (message.type) {
|
|
281
|
-
case 'connected':
|
|
282
|
-
connectionId = message.connection_id ?? null;
|
|
283
|
-
startHeartbeat();
|
|
284
|
-
log.info(`Connected with ID: ${connectionId}`);
|
|
285
|
-
break;
|
|
286
|
-
|
|
287
|
-
case 'cron_trigger':
|
|
288
|
-
await handleCronTrigger(config, message as CronTriggerMessage);
|
|
289
|
-
break;
|
|
290
|
-
|
|
291
|
-
case 'pong':
|
|
292
|
-
awaitingPong = false;
|
|
293
|
-
if (heartbeatTimeoutTimer) {
|
|
294
|
-
clearTimeout(heartbeatTimeoutTimer);
|
|
295
|
-
heartbeatTimeoutTimer = null;
|
|
296
|
-
}
|
|
297
|
-
break;
|
|
298
|
-
|
|
299
|
-
case 'error':
|
|
300
|
-
log.error('Server error:', message.error);
|
|
301
|
-
break;
|
|
302
|
-
}
|
|
303
|
-
} catch (e) {
|
|
304
|
-
log.error('Failed to parse message:', e);
|
|
305
|
-
}
|
|
306
|
-
};
|
|
307
|
-
|
|
308
|
-
ws.onerror = (error: Event | Error) => {
|
|
309
|
-
// WebSocket error events often don't contain useful details
|
|
310
|
-
// The actual error is usually surfaced via onclose
|
|
311
|
-
if (error instanceof Error && error.message) {
|
|
312
|
-
log.error('WebSocket error:', error.message);
|
|
313
|
-
} else {
|
|
314
|
-
log.debug('WebSocket connection error (details in close event)');
|
|
315
|
-
}
|
|
316
|
-
};
|
|
317
|
-
|
|
318
|
-
ws.onclose = (event: { code?: number; reason?: string }) => {
|
|
319
|
-
connectionId = null;
|
|
320
|
-
stopHeartbeat();
|
|
321
|
-
log.debug(`Disconnected (code: ${event.code})`);
|
|
322
|
-
handleReconnect(config);
|
|
323
|
-
};
|
|
324
|
-
} catch (error) {
|
|
325
|
-
log.debug('Failed to connect:', error);
|
|
326
|
-
handleReconnect(config);
|
|
327
|
-
}
|
|
328
|
-
}
|
|
329
|
-
|
|
330
|
-
function handleReconnect(config: { url: string; projectId: string; apiKey: string; environment?: string }): void {
|
|
331
|
-
if (!shouldReconnect || reconnectAttempts >= MAX_RECONNECT_ATTEMPTS) {
|
|
332
|
-
if (reconnectAttempts >= MAX_RECONNECT_ATTEMPTS) {
|
|
333
|
-
log.error('Max reconnection attempts reached');
|
|
334
|
-
}
|
|
335
|
-
return;
|
|
336
|
-
}
|
|
337
|
-
|
|
338
|
-
reconnectAttempts++;
|
|
339
|
-
const delay = Math.min(RECONNECT_DELAY * Math.pow(2, reconnectAttempts - 1), 30000);
|
|
340
|
-
|
|
341
|
-
setTimeout(() => {
|
|
342
|
-
connect(config).catch((err) => log.debug('Reconnect failed:', err));
|
|
343
|
-
}, delay);
|
|
344
|
-
}
|
|
345
|
-
|
|
346
|
-
export default defineNitroPlugin((nitro) => {
|
|
347
|
-
const apiKey = process.env.NUXT_TETHER_API_KEY || process.env.TETHER_API_KEY;
|
|
348
|
-
const url = process.env.NUXT_TETHER_URL || process.env.TETHER_URL || 'https://tether-api.strands.gg';
|
|
349
|
-
const projectId = process.env.NUXT_TETHER_PROJECT_ID || process.env.TETHER_PROJECT_ID;
|
|
350
|
-
const environment = process.env.NUXT_TETHER_ENVIRONMENT || process.env.TETHER_ENVIRONMENT;
|
|
351
|
-
verboseLogging = process.env.NUXT_TETHER_VERBOSE === 'true' || process.env.TETHER_VERBOSE === 'true';
|
|
352
|
-
|
|
353
|
-
if (!apiKey || !projectId) {
|
|
354
|
-
log.debug('Missing config - cron connection disabled');
|
|
355
|
-
return;
|
|
356
|
-
}
|
|
357
|
-
|
|
358
|
-
// Configure the server client so executeFunction works
|
|
359
|
-
configureTetherServer({ url, projectId, apiKey });
|
|
360
|
-
|
|
361
|
-
log.info(`Initialising cron connection...${environment ? ` (env: ${environment})` : ''}`);
|
|
362
|
-
|
|
363
|
-
const config = { url, projectId, apiKey, environment };
|
|
364
|
-
|
|
365
|
-
process.nextTick(() => {
|
|
366
|
-
connect(config).catch((err) => log.error('Initial connect failed:', err));
|
|
367
|
-
});
|
|
368
|
-
|
|
369
|
-
nitro.hooks.hook('close', () => {
|
|
370
|
-
shouldReconnect = false;
|
|
371
|
-
stopHeartbeat();
|
|
372
|
-
if (ws) {
|
|
373
|
-
ws.close();
|
|
374
|
-
ws = null;
|
|
375
|
-
}
|
|
376
|
-
});
|
|
377
|
-
});
|