@tthr/vue 0.0.83 → 0.0.84
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.
|
@@ -5,8 +5,26 @@
|
|
|
5
5
|
* Queries and mutations are executed server-side to keep API keys secure.
|
|
6
6
|
* WebSocket subscriptions run client-side for realtime updates - automatically!
|
|
7
7
|
*/
|
|
8
|
-
import { ref, onMounted, onUnmounted, computed, watch } from 'vue';
|
|
8
|
+
import { ref, onMounted, onUnmounted, computed, watch, toRaw, isRef } from 'vue';
|
|
9
9
|
import { useAsyncData } from '#imports';
|
|
10
|
+
/**
|
|
11
|
+
* Recursively unwrap Vue reactive proxies and refs so the value is
|
|
12
|
+
* safe to pass to JSON.stringify (no circular Vue internals).
|
|
13
|
+
*/
|
|
14
|
+
function toPlainArgs(value) {
|
|
15
|
+
if (value == null || typeof value !== 'object')
|
|
16
|
+
return value;
|
|
17
|
+
const raw = toRaw(value);
|
|
18
|
+
if (isRef(raw))
|
|
19
|
+
return toPlainArgs(raw.value);
|
|
20
|
+
if (Array.isArray(raw))
|
|
21
|
+
return raw.map(toPlainArgs);
|
|
22
|
+
const out = {};
|
|
23
|
+
for (const key of Object.keys(raw)) {
|
|
24
|
+
out[key] = toPlainArgs(raw[key]);
|
|
25
|
+
}
|
|
26
|
+
return out;
|
|
27
|
+
}
|
|
10
28
|
// Singleton connection manager (client-side only)
|
|
11
29
|
let connectionManager = null;
|
|
12
30
|
function getConnectionManager() {
|
|
@@ -125,7 +143,8 @@ function connectWebSocket(cm) {
|
|
|
125
143
|
}
|
|
126
144
|
function subscribe(queryName, args, callbacks) {
|
|
127
145
|
const cm = getConnectionManager();
|
|
128
|
-
const
|
|
146
|
+
const plainArgs = toPlainArgs(args);
|
|
147
|
+
const subscriptionId = `${queryName}::${JSON.stringify(plainArgs ?? {})}`;
|
|
129
148
|
cm.subscriptions.set(subscriptionId, callbacks);
|
|
130
149
|
cm.refCount++;
|
|
131
150
|
// Connect if not already connected
|
|
@@ -138,7 +157,7 @@ function subscribe(queryName, args, callbacks) {
|
|
|
138
157
|
type: 'subscribe',
|
|
139
158
|
id: subscriptionId,
|
|
140
159
|
query: queryName,
|
|
141
|
-
args,
|
|
160
|
+
args: plainArgs,
|
|
142
161
|
}));
|
|
143
162
|
}
|
|
144
163
|
// Return cleanup function
|
|
@@ -188,19 +207,25 @@ if (typeof window !== 'undefined') {
|
|
|
188
207
|
* Data is fetched on the server and hydrated on the client.
|
|
189
208
|
* Automatically subscribes to WebSocket updates for realtime sync.
|
|
190
209
|
*
|
|
210
|
+
* Can be used with or without await:
|
|
211
|
+
*
|
|
191
212
|
* @example
|
|
192
213
|
* ```vue
|
|
193
214
|
* <script setup>
|
|
194
|
-
* //
|
|
215
|
+
* // Reactive — data fills in asynchronously, updates in realtime
|
|
195
216
|
* const { data: posts, isLoading, isConnected } = useQuery(api.posts.list);
|
|
217
|
+
*
|
|
218
|
+
* // Awaited — data is populated when the promise resolves, still updates in realtime
|
|
219
|
+
* const { data: posts } = await useQuery(api.posts.list);
|
|
196
220
|
* </script>
|
|
197
221
|
* ```
|
|
198
222
|
*/
|
|
199
223
|
export function useQuery(query, args) {
|
|
200
224
|
const queryName = typeof query === 'string' ? query : query._name;
|
|
201
|
-
const
|
|
225
|
+
const plainArgs = toPlainArgs(args);
|
|
226
|
+
const queryArgs = plainArgs;
|
|
202
227
|
// Create a unique key for this query based on name and args
|
|
203
|
-
const queryKey = `tether-${queryName}-${JSON.stringify(
|
|
228
|
+
const queryKey = `tether-${queryName}-${JSON.stringify(plainArgs ?? {})}`;
|
|
204
229
|
// Track WebSocket connection state
|
|
205
230
|
const isConnected = ref(false);
|
|
206
231
|
// Use Nuxt's useAsyncData for proper SSR support
|
|
@@ -209,7 +234,7 @@ export function useQuery(query, args) {
|
|
|
209
234
|
method: 'POST',
|
|
210
235
|
body: {
|
|
211
236
|
function: queryName,
|
|
212
|
-
args,
|
|
237
|
+
args: plainArgs,
|
|
213
238
|
},
|
|
214
239
|
});
|
|
215
240
|
return response.data;
|
|
@@ -265,6 +290,8 @@ export function useQuery(query, args) {
|
|
|
265
290
|
refetch,
|
|
266
291
|
};
|
|
267
292
|
// Create a promise that resolves when the initial fetch completes.
|
|
293
|
+
// On SSR or hydration, status is already not 'pending', so it resolves immediately.
|
|
294
|
+
// On client-side navigation, it waits for the fetch to finish.
|
|
268
295
|
const initialFetchPromise = new Promise((resolve) => {
|
|
269
296
|
if (status.value !== 'pending') {
|
|
270
297
|
resolve(state);
|
|
@@ -280,16 +307,36 @@ export function useQuery(query, args) {
|
|
|
280
307
|
// Merge the promise onto the state object so it's both destructurable and thenable
|
|
281
308
|
return Object.assign(initialFetchPromise, state);
|
|
282
309
|
}
|
|
310
|
+
// ============================================================================
|
|
311
|
+
// Standalone Query Function
|
|
312
|
+
// ============================================================================
|
|
283
313
|
/**
|
|
284
314
|
* Execute a query and return raw data directly.
|
|
315
|
+
*
|
|
316
|
+
* Unlike useQuery, this does NOT set up reactive state or WebSocket subscriptions.
|
|
317
|
+
* Use this in event handlers, utilities, or anywhere you need a one-shot data fetch.
|
|
318
|
+
* Proxies through the Nuxt server route to keep API keys secure.
|
|
319
|
+
*
|
|
320
|
+
* @example
|
|
321
|
+
* ```ts
|
|
322
|
+
* // In an event handler
|
|
323
|
+
* async function loadMessages(channelId: string) {
|
|
324
|
+
* const messages = await $query(api.messages.listByChannel, { channelId });
|
|
325
|
+
* return messages;
|
|
326
|
+
* }
|
|
327
|
+
*
|
|
328
|
+
* // With a string name
|
|
329
|
+
* const posts = await $query('posts.list', { limit: 10 });
|
|
330
|
+
* ```
|
|
285
331
|
*/
|
|
286
332
|
export async function $query(query, args) {
|
|
287
333
|
const queryName = typeof query === 'string' ? query : query._name;
|
|
334
|
+
const plainArgs = toPlainArgs(args);
|
|
288
335
|
const response = await $fetch('/api/_tether/query', {
|
|
289
336
|
method: 'POST',
|
|
290
337
|
body: {
|
|
291
338
|
function: queryName,
|
|
292
|
-
args,
|
|
339
|
+
args: plainArgs,
|
|
293
340
|
},
|
|
294
341
|
});
|
|
295
342
|
return response.data;
|
|
@@ -322,7 +369,7 @@ export function useMutation(mutation) {
|
|
|
322
369
|
method: 'POST',
|
|
323
370
|
body: {
|
|
324
371
|
function: mutationName,
|
|
325
|
-
args,
|
|
372
|
+
args: toPlainArgs(args),
|
|
326
373
|
},
|
|
327
374
|
});
|
|
328
375
|
data.value = response.data;
|
|
@@ -349,16 +396,36 @@ export function useMutation(mutation) {
|
|
|
349
396
|
reset,
|
|
350
397
|
};
|
|
351
398
|
}
|
|
399
|
+
// ============================================================================
|
|
400
|
+
// Standalone Mutation Function
|
|
401
|
+
// ============================================================================
|
|
352
402
|
/**
|
|
353
403
|
* Execute a mutation and return the result directly.
|
|
404
|
+
*
|
|
405
|
+
* Unlike useMutation, this does NOT set up reactive state.
|
|
406
|
+
* Use this in event handlers, utilities, or anywhere you need a one-shot mutation.
|
|
407
|
+
* Proxies through the Nuxt server route to keep API keys secure.
|
|
408
|
+
*
|
|
409
|
+
* @example
|
|
410
|
+
* ```ts
|
|
411
|
+
* // In an event handler
|
|
412
|
+
* async function handleCreate() {
|
|
413
|
+
* const post = await $mutation(api.posts.create, { title: 'Hello' });
|
|
414
|
+
* console.log('Created:', post);
|
|
415
|
+
* }
|
|
416
|
+
*
|
|
417
|
+
* // With a string name
|
|
418
|
+
* const result = await $mutation('posts.delete', { id: 123 });
|
|
419
|
+
* ```
|
|
354
420
|
*/
|
|
355
421
|
export async function $mutation(mutation, args) {
|
|
356
422
|
const mutationName = typeof mutation === 'string' ? mutation : mutation._name;
|
|
423
|
+
const plainArgs = toPlainArgs(args);
|
|
357
424
|
const response = await $fetch('/api/_tether/mutation', {
|
|
358
425
|
method: 'POST',
|
|
359
426
|
body: {
|
|
360
427
|
function: mutationName,
|
|
361
|
-
args,
|
|
428
|
+
args: plainArgs,
|
|
362
429
|
},
|
|
363
430
|
});
|
|
364
431
|
return response.data;
|
|
@@ -411,4 +478,3 @@ export function useTetherSubscription(queryName, args, handlers) {
|
|
|
411
478
|
}
|
|
412
479
|
return { isConnected };
|
|
413
480
|
}
|
|
414
|
-
//# sourceMappingURL=composables.js.map
|
|
@@ -1,21 +1,17 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Nuxt server plugin for Tether cron execution
|
|
3
3
|
*
|
|
4
|
-
* This plugin
|
|
5
|
-
*
|
|
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.
|
|
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.
|
|
10
6
|
*/
|
|
11
7
|
import { defineNitroPlugin } from 'nitropack/runtime';
|
|
8
|
+
import { configureTetherServer, executeFunction } from '../../../../dist/server.js';
|
|
12
9
|
// Re-export defineNitroPlugin for use by generated plugins
|
|
13
10
|
export { defineNitroPlugin };
|
|
14
|
-
// Store for registered cron handlers
|
|
11
|
+
// Store for manually registered cron handlers
|
|
15
12
|
const cronHandlers = new Map();
|
|
16
13
|
// Dynamic function registry - populated on first cron trigger
|
|
17
14
|
let functionRegistry = null;
|
|
18
|
-
let registryError = null;
|
|
19
15
|
// WebSocket connection state
|
|
20
16
|
let ws = null;
|
|
21
17
|
let connectionId = null;
|
|
@@ -26,35 +22,19 @@ let awaitingPong = false;
|
|
|
26
22
|
let shouldReconnect = true;
|
|
27
23
|
let verboseLogging = false;
|
|
28
24
|
const MAX_RECONNECT_ATTEMPTS = 10;
|
|
29
|
-
const HEARTBEAT_INTERVAL = 20000;
|
|
25
|
+
const HEARTBEAT_INTERVAL = 20000;
|
|
30
26
|
const HEARTBEAT_TIMEOUT = 10000;
|
|
31
27
|
const RECONNECT_DELAY = 1000;
|
|
32
28
|
const PREFIX = '[Tether Cron]';
|
|
33
|
-
/** Simple logger that respects verbose flag */
|
|
34
29
|
const log = {
|
|
35
|
-
/** Debug messages - only shown when verbose is enabled */
|
|
36
30
|
debug: (...args) => { if (verboseLogging)
|
|
37
31
|
console.log(PREFIX, ...args); },
|
|
38
|
-
/** Info messages - always shown */
|
|
39
32
|
info: (...args) => console.log(PREFIX, ...args),
|
|
40
|
-
/** Warning messages - always shown */
|
|
41
33
|
warn: (...args) => console.warn(PREFIX, ...args),
|
|
42
|
-
/** Error messages - always shown */
|
|
43
34
|
error: (...args) => console.error(PREFIX, ...args),
|
|
44
35
|
};
|
|
45
36
|
/**
|
|
46
37
|
* Register a cron handler for a specific function
|
|
47
|
-
*
|
|
48
|
-
* @example
|
|
49
|
-
* ```ts
|
|
50
|
-
* // In your Nuxt server setup
|
|
51
|
-
* import { registerCronHandler } from '#imports';
|
|
52
|
-
*
|
|
53
|
-
* registerCronHandler('reports.generate', async (args) => {
|
|
54
|
-
* // Your function logic here
|
|
55
|
-
* return { generated: true };
|
|
56
|
-
* });
|
|
57
|
-
* ```
|
|
58
38
|
*/
|
|
59
39
|
export function registerCronHandler(functionName, handler) {
|
|
60
40
|
cronHandlers.set(functionName, handler);
|
|
@@ -76,27 +56,13 @@ export function getCronHandlers() {
|
|
|
76
56
|
* Load user's custom functions from ~/tether/functions
|
|
77
57
|
*/
|
|
78
58
|
async function loadFunctionRegistry() {
|
|
79
|
-
if (functionRegistry !== null
|
|
59
|
+
if (functionRegistry !== null)
|
|
80
60
|
return;
|
|
81
|
-
}
|
|
82
61
|
try {
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
log.debug('Failed to import functions:', err.message);
|
|
86
|
-
return null;
|
|
87
|
-
});
|
|
88
|
-
if (functions) {
|
|
89
|
-
log.debug('Loaded function modules:', Object.keys(functions));
|
|
90
|
-
functionRegistry = functions;
|
|
91
|
-
}
|
|
92
|
-
else {
|
|
93
|
-
log.debug('No custom functions found');
|
|
94
|
-
functionRegistry = {};
|
|
95
|
-
}
|
|
62
|
+
const functions = await import('~~/tether/functions/index.ts').catch(() => null);
|
|
63
|
+
functionRegistry = functions ?? {};
|
|
96
64
|
}
|
|
97
|
-
catch
|
|
98
|
-
log.debug('Could not load custom functions:', error instanceof Error ? error.message : String(error));
|
|
99
|
-
registryError = error instanceof Error ? error : new Error(String(error));
|
|
65
|
+
catch {
|
|
100
66
|
functionRegistry = {};
|
|
101
67
|
}
|
|
102
68
|
}
|
|
@@ -114,197 +80,30 @@ function lookupFunction(name) {
|
|
|
114
80
|
if (!module)
|
|
115
81
|
return null;
|
|
116
82
|
const fn = module[fnName];
|
|
117
|
-
// Check if it's a valid Tether function (has a handler)
|
|
118
83
|
if (fn && typeof fn === 'object' && typeof fn.handler === 'function') {
|
|
119
84
|
return fn;
|
|
120
85
|
}
|
|
121
86
|
return null;
|
|
122
87
|
}
|
|
123
|
-
/**
|
|
124
|
-
* Create a database proxy that routes calls to Tether's CRUD endpoints
|
|
125
|
-
*/
|
|
126
|
-
function createDatabaseProxy(apiKey, url, projectId, environment) {
|
|
127
|
-
const base = url.replace(/\/$/, '');
|
|
128
|
-
let apiPath;
|
|
129
|
-
if (environment && environment !== 'production') {
|
|
130
|
-
apiPath = `${base}/api/v1/projects/${projectId}/env/${environment}`;
|
|
131
|
-
}
|
|
132
|
-
else {
|
|
133
|
-
apiPath = `${base}/api/v1/projects/${projectId}`;
|
|
134
|
-
}
|
|
135
|
-
return new Proxy({}, {
|
|
136
|
-
get(_target, tableName) {
|
|
137
|
-
const makeRequest = async (operation, args) => {
|
|
138
|
-
const response = await fetch(`${apiPath}/query`, {
|
|
139
|
-
method: 'POST',
|
|
140
|
-
headers: {
|
|
141
|
-
'Content-Type': 'application/json',
|
|
142
|
-
'Authorization': `Bearer ${apiKey}`,
|
|
143
|
-
},
|
|
144
|
-
body: JSON.stringify({
|
|
145
|
-
function: `${tableName}.${operation}`,
|
|
146
|
-
args,
|
|
147
|
-
}),
|
|
148
|
-
});
|
|
149
|
-
if (!response.ok) {
|
|
150
|
-
const error = await response.json().catch(() => ({ error: 'Unknown error' }));
|
|
151
|
-
throw new Error(error.error || `Database operation failed: ${tableName}.${operation}`);
|
|
152
|
-
}
|
|
153
|
-
const result = await response.json();
|
|
154
|
-
return result.data;
|
|
155
|
-
};
|
|
156
|
-
const makeMutation = async (operation, args) => {
|
|
157
|
-
const response = await fetch(`${apiPath}/mutation`, {
|
|
158
|
-
method: 'POST',
|
|
159
|
-
headers: {
|
|
160
|
-
'Content-Type': 'application/json',
|
|
161
|
-
'Authorization': `Bearer ${apiKey}`,
|
|
162
|
-
},
|
|
163
|
-
body: JSON.stringify({
|
|
164
|
-
function: `${tableName}.${operation}`,
|
|
165
|
-
args,
|
|
166
|
-
}),
|
|
167
|
-
});
|
|
168
|
-
if (!response.ok) {
|
|
169
|
-
const error = await response.json().catch(() => ({ error: 'Unknown error' }));
|
|
170
|
-
throw new Error(error.error || `Database operation failed: ${tableName}.${operation}`);
|
|
171
|
-
}
|
|
172
|
-
const result = await response.json();
|
|
173
|
-
return result.data;
|
|
174
|
-
};
|
|
175
|
-
return {
|
|
176
|
-
findMany: async (options) => {
|
|
177
|
-
const args = {};
|
|
178
|
-
if (options?.where)
|
|
179
|
-
args.where = options.where;
|
|
180
|
-
if (options?.limit)
|
|
181
|
-
args.limit = options.limit;
|
|
182
|
-
if (options?.offset)
|
|
183
|
-
args.offset = options.offset;
|
|
184
|
-
if (options?.orderBy) {
|
|
185
|
-
if (typeof options.orderBy === 'string') {
|
|
186
|
-
args.orderBy = options.orderBy;
|
|
187
|
-
}
|
|
188
|
-
else if (typeof options.orderBy === 'object') {
|
|
189
|
-
const entries = Object.entries(options.orderBy);
|
|
190
|
-
if (entries.length > 0) {
|
|
191
|
-
const [column, dir] = entries[0];
|
|
192
|
-
args.orderBy = column;
|
|
193
|
-
args.orderDir = dir?.toUpperCase?.() || 'ASC';
|
|
194
|
-
}
|
|
195
|
-
}
|
|
196
|
-
}
|
|
197
|
-
if (options?.orderDir)
|
|
198
|
-
args.orderDir = options.orderDir;
|
|
199
|
-
return makeRequest('list', args);
|
|
200
|
-
},
|
|
201
|
-
findFirst: async (options) => {
|
|
202
|
-
const args = { limit: 1 };
|
|
203
|
-
if (options?.where)
|
|
204
|
-
args.where = options.where;
|
|
205
|
-
const results = await makeRequest('list', args);
|
|
206
|
-
return Array.isArray(results) ? results[0] ?? null : null;
|
|
207
|
-
},
|
|
208
|
-
findUnique: async (options) => {
|
|
209
|
-
const args = { limit: 1 };
|
|
210
|
-
if (options?.where)
|
|
211
|
-
args.where = options.where;
|
|
212
|
-
const results = await makeRequest('list', args);
|
|
213
|
-
return Array.isArray(results) ? results[0] ?? null : null;
|
|
214
|
-
},
|
|
215
|
-
findById: async (id) => {
|
|
216
|
-
return makeRequest('get', { id });
|
|
217
|
-
},
|
|
218
|
-
count: async (options) => {
|
|
219
|
-
const args = {};
|
|
220
|
-
if (options?.where)
|
|
221
|
-
args.where = options.where;
|
|
222
|
-
const result = await makeRequest('count', args);
|
|
223
|
-
return result?.count ?? 0;
|
|
224
|
-
},
|
|
225
|
-
insert: async (data) => {
|
|
226
|
-
return makeMutation('create', { data });
|
|
227
|
-
},
|
|
228
|
-
insertMany: async (items) => {
|
|
229
|
-
const results = [];
|
|
230
|
-
for (const data of items) {
|
|
231
|
-
const result = await makeMutation('create', { data });
|
|
232
|
-
results.push(result);
|
|
233
|
-
}
|
|
234
|
-
return results;
|
|
235
|
-
},
|
|
236
|
-
create: async (options) => {
|
|
237
|
-
return makeMutation('create', { data: options.data });
|
|
238
|
-
},
|
|
239
|
-
update: async (options) => {
|
|
240
|
-
const id = options.where?.id;
|
|
241
|
-
if (!id)
|
|
242
|
-
throw new Error('Update requires an id in the where clause');
|
|
243
|
-
const result = await makeMutation('update', { id, data: options.data });
|
|
244
|
-
return result?.rowsAffected ?? 0;
|
|
245
|
-
},
|
|
246
|
-
upsert: async (options) => {
|
|
247
|
-
// Try to find existing record using the where clause
|
|
248
|
-
// This allows upserting on any field (e.g. clip_id, email, etc.)
|
|
249
|
-
// Use list with limit: 1 since findFirst doesn't exist on the server
|
|
250
|
-
const results = await makeRequest('list', { where: options.where, limit: 1 }).catch(() => []);
|
|
251
|
-
const existing = results?.[0] ?? null;
|
|
252
|
-
if (existing) {
|
|
253
|
-
// Use _id from the found record for the update
|
|
254
|
-
const id = existing._id ?? existing.id;
|
|
255
|
-
if (!id)
|
|
256
|
-
throw new Error('Found record has no _id or id field for update');
|
|
257
|
-
await makeMutation('update', { id, data: options.update });
|
|
258
|
-
return { ...existing, ...options.update };
|
|
259
|
-
}
|
|
260
|
-
else {
|
|
261
|
-
return makeMutation('create', { data: options.create });
|
|
262
|
-
}
|
|
263
|
-
},
|
|
264
|
-
delete: async (options) => {
|
|
265
|
-
const id = options.where?.id;
|
|
266
|
-
if (!id)
|
|
267
|
-
throw new Error('Delete requires an id in the where clause');
|
|
268
|
-
const result = await makeMutation('delete', { id });
|
|
269
|
-
return result?.rowsAffected ?? 0;
|
|
270
|
-
},
|
|
271
|
-
deleteById: async (id) => {
|
|
272
|
-
const result = await makeMutation('delete', { id });
|
|
273
|
-
return (result?.rowsAffected ?? 0) > 0;
|
|
274
|
-
},
|
|
275
|
-
};
|
|
276
|
-
},
|
|
277
|
-
});
|
|
278
|
-
}
|
|
279
88
|
function getWsUrl(config) {
|
|
280
89
|
const base = config.url
|
|
281
90
|
.replace('https://', 'wss://')
|
|
282
91
|
.replace('http://', 'ws://')
|
|
283
92
|
.replace(/\/$/, '');
|
|
284
93
|
const env = config.environment;
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
}
|
|
289
|
-
else {
|
|
290
|
-
wsPath = `${base}/ws/${config.projectId}`;
|
|
291
|
-
}
|
|
292
|
-
// Add type=server to identify as server connection and token for auth
|
|
94
|
+
const wsPath = env && env !== 'production'
|
|
95
|
+
? `${base}/ws/${config.projectId}/${env}`
|
|
96
|
+
: `${base}/ws/${config.projectId}`;
|
|
293
97
|
return `${wsPath}?type=server&token=${encodeURIComponent(config.apiKey)}`;
|
|
294
98
|
}
|
|
295
99
|
async function reportExecution(config, trigger, result, durationMs) {
|
|
296
100
|
const base = config.url.replace(/\/$/, '');
|
|
297
101
|
const env = config.environment;
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
}
|
|
302
|
-
else {
|
|
303
|
-
apiPath = `${base}/api/v1/projects/${config.projectId}`;
|
|
304
|
-
}
|
|
305
|
-
const url = `${apiPath}/crons/${trigger.cronId}/executions`;
|
|
102
|
+
const apiPath = env && env !== 'production'
|
|
103
|
+
? `${base}/api/v1/projects/${config.projectId}/env/${env}`
|
|
104
|
+
: `${base}/api/v1/projects/${config.projectId}`;
|
|
306
105
|
try {
|
|
307
|
-
const response = await fetch(
|
|
106
|
+
const response = await fetch(`${apiPath}/crons/${trigger.cronId}/executions`, {
|
|
308
107
|
method: 'POST',
|
|
309
108
|
headers: {
|
|
310
109
|
'Content-Type': 'application/json',
|
|
@@ -319,86 +118,13 @@ async function reportExecution(config, trigger, result, durationMs) {
|
|
|
319
118
|
}),
|
|
320
119
|
});
|
|
321
120
|
if (!response.ok) {
|
|
322
|
-
log.error(`Failed to report execution: ${response.status}
|
|
323
|
-
}
|
|
324
|
-
else {
|
|
325
|
-
log.debug(`Reported execution ${trigger.executionId}: ${result.success ? 'success' : 'failed'}`);
|
|
121
|
+
log.error(`Failed to report execution: ${response.status}`);
|
|
326
122
|
}
|
|
327
123
|
}
|
|
328
124
|
catch (error) {
|
|
329
125
|
log.error('Failed to report execution:', error);
|
|
330
126
|
}
|
|
331
127
|
}
|
|
332
|
-
/**
|
|
333
|
-
* Execute a function via the Tether API
|
|
334
|
-
* This is used when no local handler is registered - the function is executed
|
|
335
|
-
* on the Tether server which has access to the database and environment.
|
|
336
|
-
*
|
|
337
|
-
* Tries endpoints in order based on functionType hint, with fallback to other
|
|
338
|
-
* types if the first attempt fails with a type mismatch error.
|
|
339
|
-
*/
|
|
340
|
-
async function executeViaApi(config, functionName, functionType, args) {
|
|
341
|
-
const base = config.url.replace(/\/$/, '');
|
|
342
|
-
const env = config.environment;
|
|
343
|
-
let apiPath;
|
|
344
|
-
if (env && env !== 'production') {
|
|
345
|
-
apiPath = `${base}/api/v1/projects/${config.projectId}/env/${env}`;
|
|
346
|
-
}
|
|
347
|
-
else {
|
|
348
|
-
apiPath = `${base}/api/v1/projects/${config.projectId}`;
|
|
349
|
-
}
|
|
350
|
-
// Order endpoints to try based on functionType hint
|
|
351
|
-
const endpointTypes = ['mutation', 'query'];
|
|
352
|
-
// Move the hinted type to the front if valid
|
|
353
|
-
if (functionType && endpointTypes.includes(functionType)) {
|
|
354
|
-
endpointTypes.splice(endpointTypes.indexOf(functionType), 1);
|
|
355
|
-
endpointTypes.unshift(functionType);
|
|
356
|
-
}
|
|
357
|
-
let lastError = null;
|
|
358
|
-
for (const type of endpointTypes) {
|
|
359
|
-
const endpoint = `${apiPath}/${type}`;
|
|
360
|
-
try {
|
|
361
|
-
const response = await fetch(endpoint, {
|
|
362
|
-
method: 'POST',
|
|
363
|
-
headers: {
|
|
364
|
-
'Content-Type': 'application/json',
|
|
365
|
-
'Authorization': `Bearer ${config.apiKey}`,
|
|
366
|
-
},
|
|
367
|
-
body: JSON.stringify({ function: functionName, args }),
|
|
368
|
-
});
|
|
369
|
-
if (!response.ok) {
|
|
370
|
-
const responseText = await response.text();
|
|
371
|
-
let errorMsg;
|
|
372
|
-
try {
|
|
373
|
-
const error = JSON.parse(responseText);
|
|
374
|
-
errorMsg = error.error || error.message || `Function '${functionName}' failed with status ${response.status}`;
|
|
375
|
-
}
|
|
376
|
-
catch {
|
|
377
|
-
errorMsg = responseText || `Function '${functionName}' failed with status ${response.status}`;
|
|
378
|
-
}
|
|
379
|
-
log.debug(`/${type} endpoint returned ${response.status}: ${errorMsg}`);
|
|
380
|
-
// If it's a type mismatch error, try the next endpoint
|
|
381
|
-
if (errorMsg.includes('not a') && errorMsg.includes("it's a")) {
|
|
382
|
-
log.debug(`Function type mismatch on /${type}, trying next endpoint...`);
|
|
383
|
-
lastError = new Error(errorMsg);
|
|
384
|
-
continue;
|
|
385
|
-
}
|
|
386
|
-
throw new Error(errorMsg);
|
|
387
|
-
}
|
|
388
|
-
const result = await response.json();
|
|
389
|
-
return result.data;
|
|
390
|
-
}
|
|
391
|
-
catch (error) {
|
|
392
|
-
if (error instanceof Error && error.message.includes('not a') && error.message.includes("it's a")) {
|
|
393
|
-
lastError = error;
|
|
394
|
-
continue;
|
|
395
|
-
}
|
|
396
|
-
throw error;
|
|
397
|
-
}
|
|
398
|
-
}
|
|
399
|
-
// All endpoints failed
|
|
400
|
-
throw lastError || new Error(`Function '${functionName}' failed on all endpoints`);
|
|
401
|
-
}
|
|
402
128
|
async function handleCronTrigger(config, trigger) {
|
|
403
129
|
log.info(`Received trigger for ${trigger.functionName} (execution: ${trigger.executionId})`);
|
|
404
130
|
const startTime = Date.now();
|
|
@@ -411,70 +137,33 @@ async function handleCronTrigger(config, trigger) {
|
|
|
411
137
|
result = await handler(trigger.args);
|
|
412
138
|
}
|
|
413
139
|
else {
|
|
414
|
-
// 2.
|
|
140
|
+
// 2. Load and execute from user's tether/functions using executeFunction
|
|
415
141
|
await loadFunctionRegistry();
|
|
416
|
-
const
|
|
417
|
-
if (
|
|
418
|
-
log.debug(`Executing
|
|
419
|
-
//
|
|
420
|
-
|
|
421
|
-
// Create a simple logger for the function
|
|
422
|
-
const fnLog = {
|
|
423
|
-
log: (...args) => log.info(`[${trigger.functionName}]`, ...args),
|
|
424
|
-
debug: (...args) => log.debug(`[${trigger.functionName}]`, ...args),
|
|
425
|
-
info: (...args) => log.info(`[${trigger.functionName}]`, ...args),
|
|
426
|
-
warn: (...args) => log.warn(`[${trigger.functionName}]`, ...args),
|
|
427
|
-
error: (...args) => log.error(`[${trigger.functionName}]`, ...args),
|
|
428
|
-
};
|
|
429
|
-
// Create tether context with env vars from process.env
|
|
430
|
-
// Note: For cron execution, env vars should be set in the Nuxt app's environment
|
|
431
|
-
const processEnv = globalThis.process?.env ?? {};
|
|
432
|
-
const tether = {
|
|
433
|
-
env: new Proxy({}, {
|
|
434
|
-
get(_target, key) {
|
|
435
|
-
return processEnv[key];
|
|
436
|
-
},
|
|
437
|
-
}),
|
|
438
|
-
};
|
|
439
|
-
// Execute the function with context
|
|
440
|
-
result = await localFn.handler({
|
|
441
|
-
db,
|
|
442
|
-
args: trigger.args ?? {},
|
|
443
|
-
log: fnLog,
|
|
444
|
-
tether,
|
|
445
|
-
ctx: { auth: { userId: null, claims: {} }, userId: null },
|
|
446
|
-
auth: { getUserIdentity: async () => null },
|
|
447
|
-
});
|
|
142
|
+
const fn = lookupFunction(trigger.functionName);
|
|
143
|
+
if (fn) {
|
|
144
|
+
log.debug(`Executing function: ${trigger.functionName}`);
|
|
145
|
+
// Use executeFunction from @tthr/vue/server which has the proper db proxy
|
|
146
|
+
result = await executeFunction(fn, trigger.args ?? {});
|
|
448
147
|
}
|
|
449
148
|
else {
|
|
450
|
-
|
|
451
|
-
log.debug(`Executing via API: ${trigger.functionName} (${trigger.functionType})`);
|
|
452
|
-
result = await executeViaApi(config, trigger.functionName, trigger.functionType, trigger.args);
|
|
149
|
+
throw new Error(`Function '${trigger.functionName}' not found`);
|
|
453
150
|
}
|
|
454
151
|
}
|
|
455
152
|
const durationMs = Date.now() - startTime;
|
|
456
|
-
await reportExecution(config, trigger, {
|
|
457
|
-
success: true,
|
|
458
|
-
result,
|
|
459
|
-
}, durationMs);
|
|
153
|
+
await reportExecution(config, trigger, { success: true, result }, durationMs);
|
|
460
154
|
}
|
|
461
155
|
catch (error) {
|
|
462
156
|
const durationMs = Date.now() - startTime;
|
|
463
157
|
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
464
158
|
log.error(`Handler error for ${trigger.functionName}:`, errorMessage);
|
|
465
|
-
await reportExecution(config, trigger, {
|
|
466
|
-
success: false,
|
|
467
|
-
error: errorMessage,
|
|
468
|
-
}, durationMs);
|
|
159
|
+
await reportExecution(config, trigger, { success: false, error: errorMessage }, durationMs);
|
|
469
160
|
}
|
|
470
161
|
}
|
|
471
162
|
function startHeartbeat() {
|
|
472
163
|
stopHeartbeat();
|
|
473
164
|
heartbeatTimer = setInterval(() => {
|
|
474
|
-
// WebSocket.OPEN
|
|
475
|
-
if (ws?.readyState === 1) {
|
|
165
|
+
if (ws?.readyState === 1) { // WebSocket.OPEN
|
|
476
166
|
awaitingPong = true;
|
|
477
|
-
log.debug('Sending heartbeat ping');
|
|
478
167
|
ws.send(JSON.stringify({ type: 'ping' }));
|
|
479
168
|
heartbeatTimeoutTimer = setTimeout(() => {
|
|
480
169
|
if (awaitingPong) {
|
|
@@ -483,9 +172,6 @@ function startHeartbeat() {
|
|
|
483
172
|
}
|
|
484
173
|
}, HEARTBEAT_TIMEOUT);
|
|
485
174
|
}
|
|
486
|
-
else {
|
|
487
|
-
log.warn(`Cannot send ping - WebSocket state: ${ws?.readyState}`);
|
|
488
|
-
}
|
|
489
175
|
}, HEARTBEAT_INTERVAL);
|
|
490
176
|
}
|
|
491
177
|
function stopHeartbeat() {
|
|
@@ -508,7 +194,6 @@ async function getWebSocketImpl() {
|
|
|
508
194
|
WebSocketImpl = WebSocket;
|
|
509
195
|
}
|
|
510
196
|
else {
|
|
511
|
-
// Dynamic import for Node.js ESM compatibility
|
|
512
197
|
const wsModule = await import('ws');
|
|
513
198
|
WebSocketImpl = wsModule.default;
|
|
514
199
|
}
|
|
@@ -540,7 +225,6 @@ async function connect(config) {
|
|
|
540
225
|
await handleCronTrigger(config, message);
|
|
541
226
|
break;
|
|
542
227
|
case 'pong':
|
|
543
|
-
log.debug('Received pong');
|
|
544
228
|
awaitingPong = false;
|
|
545
229
|
if (heartbeatTimeoutTimer) {
|
|
546
230
|
clearTimeout(heartbeatTimeoutTimer);
|
|
@@ -557,57 +241,57 @@ async function connect(config) {
|
|
|
557
241
|
}
|
|
558
242
|
};
|
|
559
243
|
ws.onerror = (error) => {
|
|
560
|
-
|
|
561
|
-
|
|
244
|
+
// WebSocket error events often don't contain useful details
|
|
245
|
+
// The actual error is usually surfaced via onclose
|
|
246
|
+
if (error instanceof Error && error.message) {
|
|
247
|
+
log.error('WebSocket error:', error.message);
|
|
248
|
+
}
|
|
249
|
+
else {
|
|
250
|
+
log.debug('WebSocket connection error (details in close event)');
|
|
251
|
+
}
|
|
562
252
|
};
|
|
563
253
|
ws.onclose = (event) => {
|
|
564
254
|
connectionId = null;
|
|
565
255
|
stopHeartbeat();
|
|
566
|
-
log.debug(`
|
|
256
|
+
log.debug(`Disconnected (code: ${event.code})`);
|
|
567
257
|
handleReconnect(config);
|
|
568
258
|
};
|
|
569
259
|
}
|
|
570
260
|
catch (error) {
|
|
571
|
-
log.
|
|
261
|
+
log.debug('Failed to connect:', error);
|
|
572
262
|
handleReconnect(config);
|
|
573
263
|
}
|
|
574
264
|
}
|
|
575
265
|
function handleReconnect(config) {
|
|
576
|
-
if (!shouldReconnect) {
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
log.error('Max reconnection attempts reached');
|
|
266
|
+
if (!shouldReconnect || reconnectAttempts >= MAX_RECONNECT_ATTEMPTS) {
|
|
267
|
+
if (reconnectAttempts >= MAX_RECONNECT_ATTEMPTS) {
|
|
268
|
+
log.error('Max reconnection attempts reached');
|
|
269
|
+
}
|
|
581
270
|
return;
|
|
582
271
|
}
|
|
583
272
|
reconnectAttempts++;
|
|
584
273
|
const delay = Math.min(RECONNECT_DELAY * Math.pow(2, reconnectAttempts - 1), 30000);
|
|
585
|
-
log.debug(`Reconnecting in ${delay}ms (attempt ${reconnectAttempts}/${MAX_RECONNECT_ATTEMPTS})`);
|
|
586
274
|
setTimeout(() => {
|
|
587
|
-
connect(config).catch((err) => log.
|
|
275
|
+
connect(config).catch((err) => log.debug('Reconnect failed:', err));
|
|
588
276
|
}, delay);
|
|
589
277
|
}
|
|
590
278
|
export default defineNitroPlugin((nitro) => {
|
|
591
|
-
// Get config from environment variables
|
|
592
|
-
// Note: We use process.env directly instead of useRuntimeConfig to avoid #imports dependency
|
|
593
|
-
// The Nuxt module sets these from runtime config, but they can also be set directly
|
|
594
279
|
const apiKey = process.env.NUXT_TETHER_API_KEY || process.env.TETHER_API_KEY;
|
|
595
280
|
const url = process.env.NUXT_TETHER_URL || process.env.TETHER_URL || 'https://tether-api.strands.gg';
|
|
596
281
|
const projectId = process.env.NUXT_TETHER_PROJECT_ID || process.env.TETHER_PROJECT_ID;
|
|
597
282
|
const environment = process.env.NUXT_TETHER_ENVIRONMENT || process.env.TETHER_ENVIRONMENT;
|
|
598
283
|
verboseLogging = process.env.NUXT_TETHER_VERBOSE === 'true' || process.env.TETHER_VERBOSE === 'true';
|
|
599
|
-
// Only connect if we have all required config
|
|
600
284
|
if (!apiKey || !projectId) {
|
|
601
|
-
log.debug('Missing config
|
|
285
|
+
log.debug('Missing config - cron connection disabled');
|
|
602
286
|
return;
|
|
603
287
|
}
|
|
288
|
+
// Configure the server client so executeFunction works
|
|
289
|
+
configureTetherServer({ url, projectId, apiKey });
|
|
604
290
|
log.info(`Initialising cron connection...${environment ? ` (env: ${environment})` : ''}`);
|
|
605
291
|
const config = { url, projectId, apiKey, environment };
|
|
606
|
-
// Connect on next tick to allow the server to fully initialise
|
|
607
292
|
process.nextTick(() => {
|
|
608
293
|
connect(config).catch((err) => log.error('Initial connect failed:', err));
|
|
609
294
|
});
|
|
610
|
-
// Clean up on shutdown
|
|
611
295
|
nitro.hooks.hook('close', () => {
|
|
612
296
|
shouldReconnect = false;
|
|
613
297
|
stopHeartbeat();
|
|
@@ -615,7 +299,5 @@ export default defineNitroPlugin((nitro) => {
|
|
|
615
299
|
ws.close();
|
|
616
300
|
ws = null;
|
|
617
301
|
}
|
|
618
|
-
log.debug('Connection closed');
|
|
619
302
|
});
|
|
620
303
|
});
|
|
621
|
-
//# sourceMappingURL=cron.js.map
|