@tthr/vue 0.0.84 → 0.0.86
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 +2 -2
- package/nuxt/module.ts +2 -2
- package/nuxt/runtime/composables.ts +638 -0
- package/nuxt/runtime/plugin.client.ts +34 -0
- package/nuxt/runtime/server/mutation.post.js +30 -364
- package/nuxt/runtime/server/mutation.post.ts +53 -0
- package/nuxt/runtime/server/plugins/cron.ts +377 -0
- package/nuxt/runtime/server/query.post.js +27 -362
- package/nuxt/runtime/server/query.post.ts +51 -0
- package/nuxt/runtime/server/utils/handler.js +318 -0
- package/nuxt/runtime/server/utils/handler.ts +375 -0
- package/nuxt/runtime/server/utils/tether.js +187 -210
- package/nuxt/runtime/server/utils/tether.ts +292 -0
- package/package.json +7 -9
- package/dist/nuxt.d.ts +0 -14
- package/dist/nuxt.d.ts.map +0 -1
- package/dist/nuxt.js +0 -48
- package/dist/nuxt.js.map +0 -1
- package/dist/runtime/composables.d.ts +0 -73
- package/dist/runtime/composables.d.ts.map +0 -1
- package/dist/runtime/composables.js +0 -112
- package/dist/runtime/composables.js.map +0 -1
- package/dist/runtime/plugin.d.ts +0 -11
- package/dist/runtime/plugin.d.ts.map +0 -1
- package/dist/runtime/plugin.js +0 -33
- package/dist/runtime/plugin.js.map +0 -1
- package/nuxt/runtime/composables.d.ts +0 -142
- package/nuxt/runtime/composables.d.ts.map +0 -1
- package/nuxt/runtime/composables.js +0 -480
- package/nuxt/runtime/composables.js.map +0 -1
- package/nuxt/runtime/plugin.client.d.ts +0 -17
- package/nuxt/runtime/plugin.client.d.ts.map +0 -1
- package/nuxt/runtime/plugin.client.js +0 -20
- package/nuxt/runtime/plugin.client.js.map +0 -1
- package/nuxt/runtime/server/plugins/cron.d.ts +0 -38
- package/nuxt/runtime/server/plugins/cron.d.ts.map +0 -1
- package/nuxt/runtime/server/plugins/cron.js.map +0 -1
|
@@ -0,0 +1,377 @@
|
|
|
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
|
+
});
|