@tthr/vue 0.0.33 → 0.0.35
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.ts +16 -3
- package/package.json +12 -2
- package/nuxt/runtime/composables.ts +0 -348
- package/nuxt/runtime/plugin.client.ts +0 -34
- package/nuxt/runtime/server/plugins/cron.ts +0 -342
package/nuxt/module.ts
CHANGED
|
@@ -19,7 +19,7 @@
|
|
|
19
19
|
* - TETHER_API_KEY: Your project's API key (required, kept server-side)
|
|
20
20
|
*/
|
|
21
21
|
|
|
22
|
-
import { defineNuxtModule, addPlugin, createResolver, addImports, addComponent, addServerHandler, addServerImports } from '@nuxt/kit';
|
|
22
|
+
import { defineNuxtModule, addPlugin, createResolver, addImports, addComponent, addServerHandler, addServerImports, addTemplate } from '@nuxt/kit';
|
|
23
23
|
|
|
24
24
|
export interface TetherModuleOptions {
|
|
25
25
|
/** Project ID from Tether dashboard */
|
|
@@ -138,12 +138,25 @@ export default defineNuxtModule<TetherModuleOptions>({
|
|
|
138
138
|
nuxt.options.build.transpile = nuxt.options.build.transpile || [];
|
|
139
139
|
nuxt.options.build.transpile.push('@tthr/vue');
|
|
140
140
|
|
|
141
|
+
// Create a virtual wrapper for the cron plugin to avoid absolute path issues in production
|
|
142
|
+
// The wrapper imports from the package export which gets resolved correctly at build time
|
|
143
|
+
const cronPluginWrapper = addTemplate({
|
|
144
|
+
filename: 'tether/cron-plugin.mjs',
|
|
145
|
+
write: true,
|
|
146
|
+
getContents: () => `
|
|
147
|
+
// Virtual wrapper for Tether cron plugin
|
|
148
|
+
// This ensures the import path is resolved correctly in production builds
|
|
149
|
+
import cronPlugin from '@tthr/vue/nuxt/cron-plugin';
|
|
150
|
+
export default cronPlugin;
|
|
151
|
+
`.trim(),
|
|
152
|
+
});
|
|
153
|
+
|
|
141
154
|
// Add Nitro plugin for cron WebSocket connection
|
|
142
155
|
// This auto-connects to Tether when the server starts
|
|
143
|
-
// Use .js extension to ensure the pre-built file is used
|
|
144
156
|
nuxt.hook('nitro:config', (nitroConfig) => {
|
|
145
157
|
nitroConfig.plugins = nitroConfig.plugins || [];
|
|
146
|
-
|
|
158
|
+
// Use the generated wrapper file instead of direct path
|
|
159
|
+
nitroConfig.plugins.push(cronPluginWrapper.dst);
|
|
147
160
|
|
|
148
161
|
// Ensure Nitro inlines and transpiles the plugin from node_modules
|
|
149
162
|
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.35",
|
|
4
4
|
"description": "Tether Vue/Nuxt SDK",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -16,11 +16,21 @@
|
|
|
16
16
|
},
|
|
17
17
|
"./nuxt": {
|
|
18
18
|
"import": "./nuxt/module.ts"
|
|
19
|
+
},
|
|
20
|
+
"./nuxt/cron-plugin": {
|
|
21
|
+
"import": "./nuxt/runtime/server/plugins/cron.js"
|
|
19
22
|
}
|
|
20
23
|
},
|
|
21
24
|
"files": [
|
|
22
25
|
"dist",
|
|
23
|
-
"nuxt"
|
|
26
|
+
"nuxt/module.ts",
|
|
27
|
+
"nuxt/module.js",
|
|
28
|
+
"nuxt/module.js.map",
|
|
29
|
+
"nuxt/runtime/**/*.js",
|
|
30
|
+
"nuxt/runtime/**/*.js.map",
|
|
31
|
+
"nuxt/runtime/**/*.d.ts",
|
|
32
|
+
"nuxt/runtime/**/*.d.ts.map",
|
|
33
|
+
"nuxt/runtime/**/*.vue"
|
|
24
34
|
],
|
|
25
35
|
"dependencies": {
|
|
26
36
|
"@nuxt/kit": "^3.14.0",
|
|
@@ -1,348 +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.
|
|
7
|
-
*/
|
|
8
|
-
|
|
9
|
-
import { ref, onMounted, onUnmounted, type Ref } from 'vue';
|
|
10
|
-
|
|
11
|
-
/**
|
|
12
|
-
* Query state returned by useQuery
|
|
13
|
-
*/
|
|
14
|
-
export interface QueryState<T> {
|
|
15
|
-
data: Ref<T | undefined>;
|
|
16
|
-
error: Ref<Error | null>;
|
|
17
|
-
isLoading: Ref<boolean>;
|
|
18
|
-
refetch: () => Promise<void>;
|
|
19
|
-
/** Update data directly (used for hot-swapping from WebSocket) */
|
|
20
|
-
setData: (newData: T) => void;
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
/**
|
|
24
|
-
* Query function reference
|
|
25
|
-
*/
|
|
26
|
-
export interface QueryFunction<TArgs = void, TResult = unknown> {
|
|
27
|
-
_name: string;
|
|
28
|
-
_args?: TArgs;
|
|
29
|
-
_result?: TResult;
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
/**
|
|
33
|
-
* Reactive query composable with auto-refresh on subscription updates
|
|
34
|
-
*
|
|
35
|
-
* @example
|
|
36
|
-
* ```vue
|
|
37
|
-
* <script setup>
|
|
38
|
-
* const { data: posts, isLoading } = useQuery(api.posts.list);
|
|
39
|
-
* </script>
|
|
40
|
-
* ```
|
|
41
|
-
*/
|
|
42
|
-
export function useQuery<TArgs, TResult>(
|
|
43
|
-
query: QueryFunction<TArgs, TResult> | string,
|
|
44
|
-
args?: TArgs
|
|
45
|
-
): QueryState<TResult> {
|
|
46
|
-
const queryName = typeof query === 'string' ? query : query._name;
|
|
47
|
-
const data = ref<TResult>();
|
|
48
|
-
const error = ref<Error | null>(null);
|
|
49
|
-
const isLoading = ref(true);
|
|
50
|
-
|
|
51
|
-
const fetchData = async () => {
|
|
52
|
-
try {
|
|
53
|
-
isLoading.value = true;
|
|
54
|
-
error.value = null;
|
|
55
|
-
|
|
56
|
-
const response = await $fetch<{ data: TResult }>('/api/_tether/query', {
|
|
57
|
-
method: 'POST',
|
|
58
|
-
body: {
|
|
59
|
-
function: queryName,
|
|
60
|
-
args,
|
|
61
|
-
},
|
|
62
|
-
});
|
|
63
|
-
|
|
64
|
-
data.value = response.data;
|
|
65
|
-
} catch (e) {
|
|
66
|
-
error.value = e instanceof Error ? e : new Error(String(e));
|
|
67
|
-
} finally {
|
|
68
|
-
isLoading.value = false;
|
|
69
|
-
}
|
|
70
|
-
};
|
|
71
|
-
|
|
72
|
-
// Fetch on mount (client-side)
|
|
73
|
-
onMounted(() => {
|
|
74
|
-
fetchData();
|
|
75
|
-
});
|
|
76
|
-
|
|
77
|
-
// Also fetch immediately for SSR
|
|
78
|
-
if (import.meta.server) {
|
|
79
|
-
fetchData();
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
// Direct data setter for hot-swapping from WebSocket
|
|
83
|
-
const setData = (newData: TResult) => {
|
|
84
|
-
data.value = newData;
|
|
85
|
-
};
|
|
86
|
-
|
|
87
|
-
return {
|
|
88
|
-
data: data as Ref<TResult | undefined>,
|
|
89
|
-
error,
|
|
90
|
-
isLoading,
|
|
91
|
-
refetch: fetchData,
|
|
92
|
-
setData,
|
|
93
|
-
};
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
/**
|
|
97
|
-
* Mutation state returned by useMutation
|
|
98
|
-
*/
|
|
99
|
-
export interface MutationState<TArgs, TResult> {
|
|
100
|
-
data: Ref<TResult | undefined>;
|
|
101
|
-
error: Ref<Error | null>;
|
|
102
|
-
isPending: Ref<boolean>;
|
|
103
|
-
mutate: (args: TArgs) => Promise<TResult>;
|
|
104
|
-
reset: () => void;
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
/**
|
|
108
|
-
* Mutation function reference
|
|
109
|
-
*/
|
|
110
|
-
export interface MutationFunction<TArgs = void, TResult = unknown> {
|
|
111
|
-
_name: string;
|
|
112
|
-
_args?: TArgs;
|
|
113
|
-
_result?: TResult;
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
/**
|
|
117
|
-
* Mutation composable
|
|
118
|
-
*
|
|
119
|
-
* @example
|
|
120
|
-
* ```vue
|
|
121
|
-
* <script setup>
|
|
122
|
-
* const { mutate: createPost, isPending } = useMutation(api.posts.create);
|
|
123
|
-
*
|
|
124
|
-
* async function handleSubmit() {
|
|
125
|
-
* await createPost({ title: 'Hello', content: '...' });
|
|
126
|
-
* }
|
|
127
|
-
* </script>
|
|
128
|
-
* ```
|
|
129
|
-
*/
|
|
130
|
-
export function useMutation<TArgs, TResult>(
|
|
131
|
-
mutation: MutationFunction<TArgs, TResult> | string
|
|
132
|
-
): MutationState<TArgs, TResult> {
|
|
133
|
-
const mutationName = typeof mutation === 'string' ? mutation : mutation._name;
|
|
134
|
-
const data = ref<TResult>();
|
|
135
|
-
const error = ref<Error | null>(null);
|
|
136
|
-
const isPending = ref(false);
|
|
137
|
-
|
|
138
|
-
const mutate = async (args: TArgs): Promise<TResult> => {
|
|
139
|
-
try {
|
|
140
|
-
isPending.value = true;
|
|
141
|
-
error.value = null;
|
|
142
|
-
|
|
143
|
-
const response = await $fetch<{ data: TResult }>('/api/_tether/mutation', {
|
|
144
|
-
method: 'POST',
|
|
145
|
-
body: {
|
|
146
|
-
function: mutationName,
|
|
147
|
-
args,
|
|
148
|
-
},
|
|
149
|
-
});
|
|
150
|
-
|
|
151
|
-
data.value = response.data;
|
|
152
|
-
return response.data;
|
|
153
|
-
} catch (e) {
|
|
154
|
-
error.value = e instanceof Error ? e : new Error(String(e));
|
|
155
|
-
throw e;
|
|
156
|
-
} finally {
|
|
157
|
-
isPending.value = false;
|
|
158
|
-
}
|
|
159
|
-
};
|
|
160
|
-
|
|
161
|
-
const reset = () => {
|
|
162
|
-
data.value = undefined;
|
|
163
|
-
error.value = null;
|
|
164
|
-
isPending.value = false;
|
|
165
|
-
};
|
|
166
|
-
|
|
167
|
-
return {
|
|
168
|
-
data: data as Ref<TResult | undefined>,
|
|
169
|
-
error,
|
|
170
|
-
isPending,
|
|
171
|
-
mutate,
|
|
172
|
-
reset,
|
|
173
|
-
};
|
|
174
|
-
}
|
|
175
|
-
|
|
176
|
-
/**
|
|
177
|
-
* WebSocket subscription composable for realtime updates
|
|
178
|
-
* This runs client-side only and calls refetch when updates are received
|
|
179
|
-
*
|
|
180
|
-
* @example
|
|
181
|
-
* ```vue
|
|
182
|
-
* <script setup>
|
|
183
|
-
* const { data: posts, refetch } = useQuery('posts.list');
|
|
184
|
-
* useTetherSubscription('posts.list', {}, refetch);
|
|
185
|
-
* </script>
|
|
186
|
-
* ```
|
|
187
|
-
*/
|
|
188
|
-
export function useTetherSubscription(
|
|
189
|
-
queryName: string,
|
|
190
|
-
args: Record<string, unknown> | undefined,
|
|
191
|
-
onUpdate: (data?: unknown) => void
|
|
192
|
-
): { isConnected: Ref<boolean> } {
|
|
193
|
-
const isConnected = ref(false);
|
|
194
|
-
|
|
195
|
-
if (import.meta.client) {
|
|
196
|
-
let ws: WebSocket | null = null;
|
|
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
|
-
|
|
208
|
-
// Generate a unique subscription ID
|
|
209
|
-
const subscriptionId = `${Date.now()}-${Math.random().toString(36).slice(2, 9)}`;
|
|
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
|
-
|
|
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
|
-
|
|
248
|
-
// Get config from window (set by plugin)
|
|
249
|
-
const config = (window as any).__TETHER_CONFIG__;
|
|
250
|
-
if (!config?.wsUrl || !config?.projectId) {
|
|
251
|
-
console.warn('[Tether] WebSocket config not available');
|
|
252
|
-
return;
|
|
253
|
-
}
|
|
254
|
-
|
|
255
|
-
isConnecting = true;
|
|
256
|
-
const wsUrl = `${config.wsUrl}/ws/${config.projectId}`;
|
|
257
|
-
ws = new WebSocket(wsUrl);
|
|
258
|
-
|
|
259
|
-
ws.onopen = () => {
|
|
260
|
-
isConnecting = false;
|
|
261
|
-
// Wait for connected message before subscribing
|
|
262
|
-
};
|
|
263
|
-
|
|
264
|
-
ws.onmessage = (event) => {
|
|
265
|
-
try {
|
|
266
|
-
const message = JSON.parse(event.data);
|
|
267
|
-
|
|
268
|
-
if (message.type === 'connected') {
|
|
269
|
-
isConnected.value = true;
|
|
270
|
-
startHeartbeat();
|
|
271
|
-
// Subscribe to the query with our ID
|
|
272
|
-
ws?.send(JSON.stringify({
|
|
273
|
-
type: 'subscribe',
|
|
274
|
-
id: subscriptionId,
|
|
275
|
-
query: queryName,
|
|
276
|
-
args,
|
|
277
|
-
}));
|
|
278
|
-
} else if (message.type === 'data' && message.id === subscriptionId) {
|
|
279
|
-
// Call onUpdate with the fresh data
|
|
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
|
-
}
|
|
288
|
-
}
|
|
289
|
-
} catch {
|
|
290
|
-
// Ignore parse errors
|
|
291
|
-
}
|
|
292
|
-
};
|
|
293
|
-
|
|
294
|
-
ws.onclose = () => {
|
|
295
|
-
isConnected.value = false;
|
|
296
|
-
isConnecting = false;
|
|
297
|
-
stopHeartbeat();
|
|
298
|
-
// Only reconnect if still mounted
|
|
299
|
-
if (isMounted) {
|
|
300
|
-
reconnectTimeout = setTimeout(connect, 3000);
|
|
301
|
-
}
|
|
302
|
-
};
|
|
303
|
-
|
|
304
|
-
ws.onerror = () => {
|
|
305
|
-
isConnecting = false;
|
|
306
|
-
ws?.close();
|
|
307
|
-
};
|
|
308
|
-
};
|
|
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
|
-
|
|
326
|
-
onMounted(() => {
|
|
327
|
-
isMounted = true;
|
|
328
|
-
connect();
|
|
329
|
-
document.addEventListener('visibilitychange', handleVisibilityChange);
|
|
330
|
-
});
|
|
331
|
-
|
|
332
|
-
onUnmounted(() => {
|
|
333
|
-
isMounted = false;
|
|
334
|
-
document.removeEventListener('visibilitychange', handleVisibilityChange);
|
|
335
|
-
stopHeartbeat();
|
|
336
|
-
if (reconnectTimeout) {
|
|
337
|
-
clearTimeout(reconnectTimeout);
|
|
338
|
-
reconnectTimeout = null;
|
|
339
|
-
}
|
|
340
|
-
if (ws) {
|
|
341
|
-
ws.close();
|
|
342
|
-
ws = null;
|
|
343
|
-
}
|
|
344
|
-
});
|
|
345
|
-
}
|
|
346
|
-
|
|
347
|
-
return { isConnected };
|
|
348
|
-
}
|
|
@@ -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,342 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Nuxt server plugin for Tether cron execution
|
|
3
|
-
*
|
|
4
|
-
* This plugin automatically connects to Tether via WebSocket as a server connection
|
|
5
|
-
* to receive cron triggers. When a cron is due, Tether sends a trigger message,
|
|
6
|
-
* this plugin executes the function, and reports the result back.
|
|
7
|
-
*
|
|
8
|
-
* The connection is secured with the API key and only server connections
|
|
9
|
-
* (identified by ?type=server) receive cron triggers.
|
|
10
|
-
*/
|
|
11
|
-
|
|
12
|
-
import { defineNitroPlugin } from 'nitropack/runtime';
|
|
13
|
-
import { useRuntimeConfig } from '#imports';
|
|
14
|
-
|
|
15
|
-
// Store for registered cron handlers
|
|
16
|
-
const cronHandlers: Map<string, (args: unknown) => Promise<unknown>> = new Map();
|
|
17
|
-
|
|
18
|
-
// WebSocket connection state
|
|
19
|
-
let ws: WebSocket | null = null;
|
|
20
|
-
let connectionId: string | null = null;
|
|
21
|
-
let reconnectAttempts = 0;
|
|
22
|
-
let heartbeatTimer: ReturnType<typeof setInterval> | null = null;
|
|
23
|
-
let heartbeatTimeoutTimer: ReturnType<typeof setTimeout> | null = null;
|
|
24
|
-
let awaitingPong = false;
|
|
25
|
-
let shouldReconnect = true;
|
|
26
|
-
|
|
27
|
-
const MAX_RECONNECT_ATTEMPTS = 10;
|
|
28
|
-
const HEARTBEAT_INTERVAL = 30000;
|
|
29
|
-
const HEARTBEAT_TIMEOUT = 10000;
|
|
30
|
-
const RECONNECT_DELAY = 1000;
|
|
31
|
-
|
|
32
|
-
/**
|
|
33
|
-
* Register a cron handler for a specific function
|
|
34
|
-
*
|
|
35
|
-
* @example
|
|
36
|
-
* ```ts
|
|
37
|
-
* // In your Nuxt server setup
|
|
38
|
-
* import { registerCronHandler } from '#imports';
|
|
39
|
-
*
|
|
40
|
-
* registerCronHandler('reports.generate', async (args) => {
|
|
41
|
-
* // Your function logic here
|
|
42
|
-
* return { generated: true };
|
|
43
|
-
* });
|
|
44
|
-
* ```
|
|
45
|
-
*/
|
|
46
|
-
export function registerCronHandler(
|
|
47
|
-
functionName: string,
|
|
48
|
-
handler: (args: unknown) => Promise<unknown>
|
|
49
|
-
): void {
|
|
50
|
-
cronHandlers.set(functionName, handler);
|
|
51
|
-
console.log(`[Tether Cron] 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
|
-
interface CronTriggerMessage {
|
|
69
|
-
type: 'cron_trigger';
|
|
70
|
-
executionId: string;
|
|
71
|
-
cronId: string;
|
|
72
|
-
functionName: string;
|
|
73
|
-
functionType: string;
|
|
74
|
-
args?: unknown;
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
interface ServerMessage {
|
|
78
|
-
type: 'connected' | 'cron_trigger' | 'pong' | 'error';
|
|
79
|
-
connection_id?: string;
|
|
80
|
-
server_time?: string;
|
|
81
|
-
executionId?: string;
|
|
82
|
-
cronId?: string;
|
|
83
|
-
functionName?: string;
|
|
84
|
-
functionType?: string;
|
|
85
|
-
args?: unknown;
|
|
86
|
-
error?: { code: string; message: string };
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
function getWsUrl(config: { url: string; projectId: string; apiKey: string; environment?: string }): string {
|
|
90
|
-
const base = config.url
|
|
91
|
-
.replace('https://', 'wss://')
|
|
92
|
-
.replace('http://', 'ws://')
|
|
93
|
-
.replace(/\/$/, '');
|
|
94
|
-
|
|
95
|
-
const env = config.environment;
|
|
96
|
-
let wsPath: string;
|
|
97
|
-
|
|
98
|
-
if (env && env !== 'production') {
|
|
99
|
-
wsPath = `${base}/ws/${config.projectId}/${env}`;
|
|
100
|
-
} else {
|
|
101
|
-
wsPath = `${base}/ws/${config.projectId}`;
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
// Add type=server to identify as server connection and token for auth
|
|
105
|
-
return `${wsPath}?type=server&token=${encodeURIComponent(config.apiKey)}`;
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
async function reportExecution(
|
|
109
|
-
config: { url: string; projectId: string; apiKey: string; environment?: string },
|
|
110
|
-
trigger: CronTriggerMessage,
|
|
111
|
-
result: { success: boolean; result?: unknown; error?: string },
|
|
112
|
-
durationMs: number
|
|
113
|
-
): Promise<void> {
|
|
114
|
-
const base = config.url.replace(/\/$/, '');
|
|
115
|
-
const env = config.environment;
|
|
116
|
-
|
|
117
|
-
let apiPath: string;
|
|
118
|
-
if (env && env !== 'production') {
|
|
119
|
-
apiPath = `${base}/api/v1/projects/${config.projectId}/env/${env}`;
|
|
120
|
-
} else {
|
|
121
|
-
apiPath = `${base}/api/v1/projects/${config.projectId}`;
|
|
122
|
-
}
|
|
123
|
-
|
|
124
|
-
const url = `${apiPath}/crons/${trigger.cronId}/executions`;
|
|
125
|
-
|
|
126
|
-
try {
|
|
127
|
-
const response = await fetch(url, {
|
|
128
|
-
method: 'POST',
|
|
129
|
-
headers: {
|
|
130
|
-
'Content-Type': 'application/json',
|
|
131
|
-
'Authorization': `Bearer ${config.apiKey}`,
|
|
132
|
-
},
|
|
133
|
-
body: JSON.stringify({
|
|
134
|
-
executionId: trigger.executionId,
|
|
135
|
-
success: result.success,
|
|
136
|
-
errorMessage: result.error,
|
|
137
|
-
result: result.result,
|
|
138
|
-
durationMs,
|
|
139
|
-
}),
|
|
140
|
-
});
|
|
141
|
-
|
|
142
|
-
if (!response.ok) {
|
|
143
|
-
console.error(`[Tether Cron] Failed to report execution: ${response.status} ${response.statusText}`);
|
|
144
|
-
} else {
|
|
145
|
-
console.log(`[Tether Cron] Reported execution ${trigger.executionId}: ${result.success ? 'success' : 'failed'}`);
|
|
146
|
-
}
|
|
147
|
-
} catch (error) {
|
|
148
|
-
console.error('[Tether Cron] Failed to report execution:', error);
|
|
149
|
-
}
|
|
150
|
-
}
|
|
151
|
-
|
|
152
|
-
async function handleCronTrigger(
|
|
153
|
-
config: { url: string; projectId: string; apiKey: string; environment?: string },
|
|
154
|
-
trigger: CronTriggerMessage
|
|
155
|
-
): Promise<void> {
|
|
156
|
-
console.log(`[Tether Cron] Received trigger for ${trigger.functionName} (execution: ${trigger.executionId})`);
|
|
157
|
-
|
|
158
|
-
const startTime = Date.now();
|
|
159
|
-
const handler = cronHandlers.get(trigger.functionName);
|
|
160
|
-
|
|
161
|
-
if (!handler) {
|
|
162
|
-
console.warn(`[Tether Cron] No handler registered for: ${trigger.functionName}`);
|
|
163
|
-
const durationMs = Date.now() - startTime;
|
|
164
|
-
await reportExecution(config, trigger, {
|
|
165
|
-
success: false,
|
|
166
|
-
error: `No handler registered for function: ${trigger.functionName}`,
|
|
167
|
-
}, durationMs);
|
|
168
|
-
return;
|
|
169
|
-
}
|
|
170
|
-
|
|
171
|
-
try {
|
|
172
|
-
const result = await handler(trigger.args);
|
|
173
|
-
const durationMs = Date.now() - startTime;
|
|
174
|
-
|
|
175
|
-
await reportExecution(config, trigger, {
|
|
176
|
-
success: true,
|
|
177
|
-
result,
|
|
178
|
-
}, durationMs);
|
|
179
|
-
} catch (error) {
|
|
180
|
-
const durationMs = Date.now() - startTime;
|
|
181
|
-
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
182
|
-
|
|
183
|
-
console.error(`[Tether Cron] Handler error for ${trigger.functionName}:`, errorMessage);
|
|
184
|
-
|
|
185
|
-
await reportExecution(config, trigger, {
|
|
186
|
-
success: false,
|
|
187
|
-
error: errorMessage,
|
|
188
|
-
}, durationMs);
|
|
189
|
-
}
|
|
190
|
-
}
|
|
191
|
-
|
|
192
|
-
function startHeartbeat(): void {
|
|
193
|
-
stopHeartbeat();
|
|
194
|
-
|
|
195
|
-
heartbeatTimer = setInterval(() => {
|
|
196
|
-
if (ws?.readyState === WebSocket.OPEN) {
|
|
197
|
-
awaitingPong = true;
|
|
198
|
-
ws.send(JSON.stringify({ type: 'ping' }));
|
|
199
|
-
|
|
200
|
-
heartbeatTimeoutTimer = setTimeout(() => {
|
|
201
|
-
if (awaitingPong) {
|
|
202
|
-
console.warn('[Tether Cron] Heartbeat timeout - forcing reconnect');
|
|
203
|
-
ws?.close();
|
|
204
|
-
}
|
|
205
|
-
}, HEARTBEAT_TIMEOUT);
|
|
206
|
-
}
|
|
207
|
-
}, HEARTBEAT_INTERVAL);
|
|
208
|
-
}
|
|
209
|
-
|
|
210
|
-
function stopHeartbeat(): void {
|
|
211
|
-
if (heartbeatTimer) {
|
|
212
|
-
clearInterval(heartbeatTimer);
|
|
213
|
-
heartbeatTimer = null;
|
|
214
|
-
}
|
|
215
|
-
if (heartbeatTimeoutTimer) {
|
|
216
|
-
clearTimeout(heartbeatTimeoutTimer);
|
|
217
|
-
heartbeatTimeoutTimer = null;
|
|
218
|
-
}
|
|
219
|
-
awaitingPong = false;
|
|
220
|
-
}
|
|
221
|
-
|
|
222
|
-
function connect(config: { url: string; projectId: string; apiKey: string; environment?: string }): void {
|
|
223
|
-
if (ws?.readyState === WebSocket.OPEN || ws?.readyState === WebSocket.CONNECTING) {
|
|
224
|
-
return;
|
|
225
|
-
}
|
|
226
|
-
|
|
227
|
-
try {
|
|
228
|
-
const url = getWsUrl(config);
|
|
229
|
-
|
|
230
|
-
// Use ws package for Node.js
|
|
231
|
-
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
232
|
-
const WebSocketImpl = typeof WebSocket !== 'undefined' ? WebSocket : require('ws');
|
|
233
|
-
ws = new WebSocketImpl(url);
|
|
234
|
-
|
|
235
|
-
ws!.onopen = () => {
|
|
236
|
-
reconnectAttempts = 0;
|
|
237
|
-
console.log('[Tether Cron] WebSocket connected');
|
|
238
|
-
};
|
|
239
|
-
|
|
240
|
-
ws!.onmessage = async (event: { data: string | Buffer }) => {
|
|
241
|
-
const data = typeof event.data === 'string' ? event.data : event.data.toString();
|
|
242
|
-
|
|
243
|
-
try {
|
|
244
|
-
const message = JSON.parse(data) as ServerMessage;
|
|
245
|
-
|
|
246
|
-
switch (message.type) {
|
|
247
|
-
case 'connected':
|
|
248
|
-
connectionId = message.connection_id ?? null;
|
|
249
|
-
startHeartbeat();
|
|
250
|
-
console.log(`[Tether Cron] Connected with ID: ${connectionId}`);
|
|
251
|
-
break;
|
|
252
|
-
|
|
253
|
-
case 'cron_trigger':
|
|
254
|
-
await handleCronTrigger(config, message as CronTriggerMessage);
|
|
255
|
-
break;
|
|
256
|
-
|
|
257
|
-
case 'pong':
|
|
258
|
-
awaitingPong = false;
|
|
259
|
-
if (heartbeatTimeoutTimer) {
|
|
260
|
-
clearTimeout(heartbeatTimeoutTimer);
|
|
261
|
-
heartbeatTimeoutTimer = null;
|
|
262
|
-
}
|
|
263
|
-
break;
|
|
264
|
-
|
|
265
|
-
case 'error':
|
|
266
|
-
console.error('[Tether Cron] Server error:', message.error);
|
|
267
|
-
break;
|
|
268
|
-
}
|
|
269
|
-
} catch (e) {
|
|
270
|
-
console.error('[Tether Cron] Failed to parse message:', e);
|
|
271
|
-
}
|
|
272
|
-
};
|
|
273
|
-
|
|
274
|
-
ws!.onerror = (error: Event | Error) => {
|
|
275
|
-
const err = error instanceof Error ? error : new Error('WebSocket error');
|
|
276
|
-
console.error('[Tether Cron] WebSocket error:', err.message);
|
|
277
|
-
};
|
|
278
|
-
|
|
279
|
-
ws!.onclose = () => {
|
|
280
|
-
connectionId = null;
|
|
281
|
-
stopHeartbeat();
|
|
282
|
-
console.log('[Tether Cron] WebSocket disconnected');
|
|
283
|
-
handleReconnect(config);
|
|
284
|
-
};
|
|
285
|
-
} catch (error) {
|
|
286
|
-
console.error('[Tether Cron] Failed to connect:', error);
|
|
287
|
-
handleReconnect(config);
|
|
288
|
-
}
|
|
289
|
-
}
|
|
290
|
-
|
|
291
|
-
function handleReconnect(config: { url: string; projectId: string; apiKey: string; environment?: string }): void {
|
|
292
|
-
if (!shouldReconnect) {
|
|
293
|
-
return;
|
|
294
|
-
}
|
|
295
|
-
|
|
296
|
-
if (reconnectAttempts >= MAX_RECONNECT_ATTEMPTS) {
|
|
297
|
-
console.error('[Tether Cron] Max reconnection attempts reached');
|
|
298
|
-
return;
|
|
299
|
-
}
|
|
300
|
-
|
|
301
|
-
reconnectAttempts++;
|
|
302
|
-
const delay = Math.min(RECONNECT_DELAY * Math.pow(2, reconnectAttempts - 1), 30000);
|
|
303
|
-
|
|
304
|
-
console.log(`[Tether Cron] Reconnecting in ${delay}ms (attempt ${reconnectAttempts}/${MAX_RECONNECT_ATTEMPTS})`);
|
|
305
|
-
|
|
306
|
-
setTimeout(() => {
|
|
307
|
-
connect(config);
|
|
308
|
-
}, delay);
|
|
309
|
-
}
|
|
310
|
-
|
|
311
|
-
export default defineNitroPlugin((nitro) => {
|
|
312
|
-
// Get config from runtime config
|
|
313
|
-
const config = useRuntimeConfig();
|
|
314
|
-
|
|
315
|
-
const apiKey = config.tether?.apiKey || process.env.TETHER_API_KEY;
|
|
316
|
-
const url = config.tether?.url || process.env.TETHER_URL || 'https://tether-api.strands.gg';
|
|
317
|
-
const projectId = config.tether?.projectId || process.env.TETHER_PROJECT_ID;
|
|
318
|
-
|
|
319
|
-
// Only connect if we have all required config
|
|
320
|
-
if (!apiKey || !projectId) {
|
|
321
|
-
console.log('[Tether Cron] Missing config (apiKey or projectId) - cron connection disabled');
|
|
322
|
-
return;
|
|
323
|
-
}
|
|
324
|
-
|
|
325
|
-
console.log('[Tether Cron] Initialising cron connection...');
|
|
326
|
-
|
|
327
|
-
// Connect on next tick to allow handlers to be registered first
|
|
328
|
-
process.nextTick(() => {
|
|
329
|
-
connect({ url, projectId, apiKey });
|
|
330
|
-
});
|
|
331
|
-
|
|
332
|
-
// Clean up on shutdown
|
|
333
|
-
nitro.hooks.hook('close', () => {
|
|
334
|
-
shouldReconnect = false;
|
|
335
|
-
stopHeartbeat();
|
|
336
|
-
if (ws) {
|
|
337
|
-
ws.close();
|
|
338
|
-
ws = null;
|
|
339
|
-
}
|
|
340
|
-
console.log('[Tether Cron] Connection closed');
|
|
341
|
-
});
|
|
342
|
-
});
|