@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.
Files changed (37) hide show
  1. package/nuxt/module.js +2 -2
  2. package/nuxt/module.ts +2 -2
  3. package/nuxt/runtime/composables.ts +638 -0
  4. package/nuxt/runtime/plugin.client.ts +34 -0
  5. package/nuxt/runtime/server/mutation.post.js +30 -364
  6. package/nuxt/runtime/server/mutation.post.ts +53 -0
  7. package/nuxt/runtime/server/plugins/cron.ts +377 -0
  8. package/nuxt/runtime/server/query.post.js +27 -362
  9. package/nuxt/runtime/server/query.post.ts +51 -0
  10. package/nuxt/runtime/server/utils/handler.js +318 -0
  11. package/nuxt/runtime/server/utils/handler.ts +375 -0
  12. package/nuxt/runtime/server/utils/tether.js +187 -210
  13. package/nuxt/runtime/server/utils/tether.ts +292 -0
  14. package/package.json +7 -9
  15. package/dist/nuxt.d.ts +0 -14
  16. package/dist/nuxt.d.ts.map +0 -1
  17. package/dist/nuxt.js +0 -48
  18. package/dist/nuxt.js.map +0 -1
  19. package/dist/runtime/composables.d.ts +0 -73
  20. package/dist/runtime/composables.d.ts.map +0 -1
  21. package/dist/runtime/composables.js +0 -112
  22. package/dist/runtime/composables.js.map +0 -1
  23. package/dist/runtime/plugin.d.ts +0 -11
  24. package/dist/runtime/plugin.d.ts.map +0 -1
  25. package/dist/runtime/plugin.js +0 -33
  26. package/dist/runtime/plugin.js.map +0 -1
  27. package/nuxt/runtime/composables.d.ts +0 -142
  28. package/nuxt/runtime/composables.d.ts.map +0 -1
  29. package/nuxt/runtime/composables.js +0 -480
  30. package/nuxt/runtime/composables.js.map +0 -1
  31. package/nuxt/runtime/plugin.client.d.ts +0 -17
  32. package/nuxt/runtime/plugin.client.d.ts.map +0 -1
  33. package/nuxt/runtime/plugin.client.js +0 -20
  34. package/nuxt/runtime/plugin.client.js.map +0 -1
  35. package/nuxt/runtime/server/plugins/cron.d.ts +0 -38
  36. package/nuxt/runtime/server/plugins/cron.d.ts.map +0 -1
  37. 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
+ });