@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 subscriptionId = `${queryName}::${JSON.stringify(args ?? {})}`;
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
- * // That's it! Data automatically updates when it changes anywhere
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 queryArgs = args;
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(args ?? {})}`;
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
@@ -18,4 +18,3 @@ export default defineNuxtPlugin(() => {
18
18
  console.warn('[Tether] Config incomplete - WebSocket subscriptions will not work');
19
19
  }
20
20
  });
21
- //# sourceMappingURL=plugin.client.js.map
@@ -1,21 +1,17 @@
1
1
  /**
2
2
  * Nuxt server plugin for Tether cron execution
3
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.
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; // 20 seconds - keeps connection alive through proxies with 30s idle timeout
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 || registryError !== null) {
59
+ if (functionRegistry !== null)
80
60
  return;
81
- }
82
61
  try {
83
- log.debug('Loading custom functions from ~~/tether/functions/index.ts');
84
- const functions = await import('~~/tether/functions/index.ts').catch((err) => {
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 (error) {
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
- let wsPath;
286
- if (env && env !== 'production') {
287
- wsPath = `${base}/ws/${config.projectId}/${env}`;
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
- let apiPath;
299
- if (env && env !== 'production') {
300
- apiPath = `${base}/api/v1/projects/${config.projectId}/env/${env}`;
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(url, {
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} ${response.statusText}`);
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. Try to load and execute from user's tether/functions
140
+ // 2. Load and execute from user's tether/functions using executeFunction
415
141
  await loadFunctionRegistry();
416
- const localFn = lookupFunction(trigger.functionName);
417
- if (localFn) {
418
- log.debug(`Executing local function: ${trigger.functionName}`);
419
- // Create execution context with database proxy
420
- const db = createDatabaseProxy(config.apiKey, config.url, config.projectId, config.environment);
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
- // 3. Fall back to API for generic CRUD operations (table.operation)
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 = 1
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
- const err = error instanceof Error ? error : new Error('WebSocket error');
561
- log.error('WebSocket error:', err.message);
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(`WebSocket disconnected (code: ${event.code}, reason: ${event.reason || 'none'})`);
256
+ log.debug(`Disconnected (code: ${event.code})`);
567
257
  handleReconnect(config);
568
258
  };
569
259
  }
570
260
  catch (error) {
571
- log.error('Failed to connect:', error);
261
+ log.debug('Failed to connect:', error);
572
262
  handleReconnect(config);
573
263
  }
574
264
  }
575
265
  function handleReconnect(config) {
576
- if (!shouldReconnect) {
577
- return;
578
- }
579
- if (reconnectAttempts >= MAX_RECONNECT_ATTEMPTS) {
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.error('Reconnect failed:', err));
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 (apiKey or projectId) - cron connection disabled');
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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tthr/vue",
3
- "version": "0.0.83",
3
+ "version": "0.0.84",
4
4
  "description": "Tether Vue/Nuxt SDK",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",