@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
package/nuxt/module.js CHANGED
@@ -62,6 +62,7 @@ export default defineNuxtModule({
62
62
  wsUrl,
63
63
  };
64
64
  // Add server API routes for proxying Tether requests
65
+ // Use .js — Nitro's rollup can't parse .ts from node_modules
65
66
  addServerHandler({
66
67
  route: '/api/_tether/query',
67
68
  method: 'post',
@@ -106,7 +107,7 @@ export default defineNuxtModule({
106
107
  filePath: resolver.resolve('./runtime/components/TetherWelcome.vue'),
107
108
  });
108
109
  // Auto-import server utilities (available in server/ directory)
109
- // Use .js extensions to ensure the pre-built files are used
110
+ // Use .js Nitro's rollup can't parse .ts from node_modules
110
111
  addServerImports([
111
112
  {
112
113
  name: 'useTetherServer',
@@ -132,7 +133,6 @@ export default defineNuxtModule({
132
133
  // This auto-connects to Tether when the server starts
133
134
  nuxt.hook('nitro:config', (nitroConfig) => {
134
135
  nitroConfig.plugins = nitroConfig.plugins || [];
135
- // Use pre-compiled JS - Nitro can parse this without TypeScript plugins
136
136
  nitroConfig.plugins.push(resolver.resolve('./runtime/server/plugins/cron.js'));
137
137
  // Ensure Nitro inlines and transpiles the plugin from node_modules
138
138
  nitroConfig.externals = nitroConfig.externals || {};
package/nuxt/module.ts CHANGED
@@ -78,6 +78,7 @@ export default defineNuxtModule<TetherModuleOptions>({
78
78
  };
79
79
 
80
80
  // Add server API routes for proxying Tether requests
81
+ // Use .js — Nitro's rollup can't parse .ts from node_modules
81
82
  addServerHandler({
82
83
  route: '/api/_tether/query',
83
84
  method: 'post',
@@ -127,7 +128,7 @@ export default defineNuxtModule<TetherModuleOptions>({
127
128
  });
128
129
 
129
130
  // Auto-import server utilities (available in server/ directory)
130
- // Use .js extensions to ensure the pre-built files are used
131
+ // Use .js Nitro's rollup can't parse .ts from node_modules
131
132
  addServerImports([
132
133
  {
133
134
  name: 'useTetherServer',
@@ -155,7 +156,6 @@ export default defineNuxtModule<TetherModuleOptions>({
155
156
  // This auto-connects to Tether when the server starts
156
157
  nuxt.hook('nitro:config' as any, (nitroConfig: any) => {
157
158
  nitroConfig.plugins = nitroConfig.plugins || [];
158
- // Use pre-compiled JS - Nitro can parse this without TypeScript plugins
159
159
  nitroConfig.plugins.push(resolver.resolve('./runtime/server/plugins/cron.js'));
160
160
 
161
161
  // Ensure Nitro inlines and transpiles the plugin from node_modules
@@ -0,0 +1,638 @@
1
+ /**
2
+ * Nuxt composables for Tether
3
+ *
4
+ * These are auto-imported when using the Tether Nuxt module.
5
+ * Queries and mutations are executed server-side to keep API keys secure.
6
+ * WebSocket subscriptions run client-side for realtime updates - automatically!
7
+ */
8
+
9
+ import { ref, onMounted, onUnmounted, computed, watch, toRaw, isRef, type Ref, type ComputedRef } from 'vue';
10
+ import { useAsyncData } from '#imports';
11
+
12
+ /**
13
+ * Recursively unwrap Vue reactive proxies and refs so the value is
14
+ * safe to pass to JSON.stringify (no circular Vue internals).
15
+ */
16
+ function toPlainArgs<T>(value: T): T {
17
+ if (value == null || typeof value !== 'object') return value;
18
+ const raw = toRaw(value);
19
+ if (isRef(raw)) return toPlainArgs((raw as Ref).value) as T;
20
+ if (Array.isArray(raw)) return raw.map(toPlainArgs) as T;
21
+ const out: Record<string, unknown> = {};
22
+ for (const key of Object.keys(raw as Record<string, unknown>)) {
23
+ out[key] = toPlainArgs((raw as Record<string, unknown>)[key]);
24
+ }
25
+ return out as T;
26
+ }
27
+
28
+ // ============================================================================
29
+ // Shared WebSocket Connection Manager
30
+ // ============================================================================
31
+
32
+ interface SubscriptionCallback {
33
+ onData: (data: unknown) => void;
34
+ onInvalidate: () => void;
35
+ }
36
+
37
+ interface ConnectionManager {
38
+ ws: WebSocket | null;
39
+ subscriptions: Map<string, SubscriptionCallback>;
40
+ isConnecting: boolean;
41
+ reconnectTimeout: ReturnType<typeof setTimeout> | null;
42
+ heartbeatInterval: ReturnType<typeof setInterval> | null;
43
+ heartbeatTimeout: ReturnType<typeof setTimeout> | null;
44
+ awaitingPong: boolean;
45
+ refCount: number;
46
+ }
47
+
48
+ // Singleton connection manager (client-side only)
49
+ let connectionManager: ConnectionManager | null = null;
50
+
51
+ function getConnectionManager(): ConnectionManager {
52
+ if (!connectionManager) {
53
+ connectionManager = {
54
+ ws: null,
55
+ subscriptions: new Map(),
56
+ isConnecting: false,
57
+ reconnectTimeout: null,
58
+ heartbeatInterval: null,
59
+ heartbeatTimeout: null,
60
+ awaitingPong: false,
61
+ refCount: 0,
62
+ };
63
+ }
64
+ return connectionManager;
65
+ }
66
+
67
+ const HEARTBEAT_INTERVAL = 25000;
68
+ const HEARTBEAT_TIMEOUT = 15000;
69
+
70
+ function startHeartbeat(cm: ConnectionManager) {
71
+ stopHeartbeat(cm);
72
+ cm.heartbeatInterval = setInterval(() => {
73
+ if (cm.ws?.readyState === WebSocket.OPEN) {
74
+ cm.awaitingPong = true;
75
+ cm.ws.send(JSON.stringify({ type: 'ping' }));
76
+ cm.heartbeatTimeout = setTimeout(() => {
77
+ if (cm.awaitingPong) {
78
+ console.warn('[Tether] Heartbeat timeout - forcing reconnect');
79
+ cm.ws?.close();
80
+ }
81
+ }, HEARTBEAT_TIMEOUT);
82
+ }
83
+ }, HEARTBEAT_INTERVAL);
84
+ }
85
+
86
+ function stopHeartbeat(cm: ConnectionManager) {
87
+ if (cm.heartbeatInterval) {
88
+ clearInterval(cm.heartbeatInterval);
89
+ cm.heartbeatInterval = null;
90
+ }
91
+ if (cm.heartbeatTimeout) {
92
+ clearTimeout(cm.heartbeatTimeout);
93
+ cm.heartbeatTimeout = null;
94
+ }
95
+ cm.awaitingPong = false;
96
+ }
97
+
98
+ function connectWebSocket(cm: ConnectionManager) {
99
+ if (cm.isConnecting) return;
100
+ if (cm.ws && (cm.ws.readyState === WebSocket.OPEN || cm.ws.readyState === WebSocket.CONNECTING)) {
101
+ return;
102
+ }
103
+
104
+ const config = (window as any).__TETHER_CONFIG__;
105
+ if (!config?.wsUrl || !config?.projectId) {
106
+ console.warn('[Tether] WebSocket config not available');
107
+ return;
108
+ }
109
+
110
+ cm.isConnecting = true;
111
+ const wsUrl = `${config.wsUrl}/ws/${config.projectId}`;
112
+ cm.ws = new WebSocket(wsUrl);
113
+
114
+ cm.ws.onopen = () => {
115
+ cm.isConnecting = false;
116
+ };
117
+
118
+ cm.ws.onmessage = (event) => {
119
+ try {
120
+ const message = JSON.parse(event.data);
121
+
122
+ if (message.type === 'connected') {
123
+ startHeartbeat(cm);
124
+ // Re-subscribe all active subscriptions
125
+ for (const [subscriptionId, _callback] of cm.subscriptions) {
126
+ const [queryName, argsJson] = subscriptionId.split('::');
127
+ const args = argsJson ? JSON.parse(argsJson) : undefined;
128
+ cm.ws?.send(JSON.stringify({
129
+ type: 'subscribe',
130
+ id: subscriptionId,
131
+ query: queryName,
132
+ args,
133
+ }));
134
+ }
135
+ } else if (message.type === 'data') {
136
+ const callback = cm.subscriptions.get(message.id);
137
+ if (callback) {
138
+ callback.onData(message.data);
139
+ }
140
+ } else if (message.type === 'invalidate') {
141
+ const callback = cm.subscriptions.get(message.id);
142
+ if (callback) {
143
+ callback.onInvalidate();
144
+ }
145
+ } else if (message.type === 'pong') {
146
+ cm.awaitingPong = false;
147
+ if (cm.heartbeatTimeout) {
148
+ clearTimeout(cm.heartbeatTimeout);
149
+ cm.heartbeatTimeout = null;
150
+ }
151
+ }
152
+ } catch {
153
+ // Ignore parse errors
154
+ }
155
+ };
156
+
157
+ cm.ws.onclose = () => {
158
+ cm.isConnecting = false;
159
+ stopHeartbeat(cm);
160
+ // Reconnect if there are still active subscriptions
161
+ if (cm.subscriptions.size > 0) {
162
+ cm.reconnectTimeout = setTimeout(() => connectWebSocket(cm), 3000);
163
+ }
164
+ };
165
+
166
+ cm.ws.onerror = () => {
167
+ cm.isConnecting = false;
168
+ cm.ws?.close();
169
+ };
170
+ }
171
+
172
+ function subscribe(
173
+ queryName: string,
174
+ args: Record<string, unknown> | undefined,
175
+ callbacks: SubscriptionCallback
176
+ ): () => void {
177
+ const cm = getConnectionManager();
178
+ const plainArgs = toPlainArgs(args);
179
+ const subscriptionId = `${queryName}::${JSON.stringify(plainArgs ?? {})}`;
180
+
181
+ cm.subscriptions.set(subscriptionId, callbacks);
182
+ cm.refCount++;
183
+
184
+ // Connect if not already connected
185
+ if (!cm.ws || cm.ws.readyState === WebSocket.CLOSED) {
186
+ connectWebSocket(cm);
187
+ } else if (cm.ws.readyState === WebSocket.OPEN) {
188
+ // Already connected - subscribe immediately
189
+ cm.ws.send(JSON.stringify({
190
+ type: 'subscribe',
191
+ id: subscriptionId,
192
+ query: queryName,
193
+ args: plainArgs,
194
+ }));
195
+ }
196
+
197
+ // Return cleanup function
198
+ return () => {
199
+ cm.subscriptions.delete(subscriptionId);
200
+ cm.refCount--;
201
+
202
+ // Unsubscribe from server
203
+ if (cm.ws?.readyState === WebSocket.OPEN) {
204
+ cm.ws.send(JSON.stringify({
205
+ type: 'unsubscribe',
206
+ id: subscriptionId,
207
+ }));
208
+ }
209
+
210
+ // Close connection if no more subscriptions
211
+ if (cm.refCount === 0) {
212
+ if (cm.reconnectTimeout) {
213
+ clearTimeout(cm.reconnectTimeout);
214
+ cm.reconnectTimeout = null;
215
+ }
216
+ stopHeartbeat(cm);
217
+ cm.ws?.close();
218
+ cm.ws = null;
219
+ }
220
+ };
221
+ }
222
+
223
+ // Handle visibility changes globally
224
+ if (typeof window !== 'undefined') {
225
+ document.addEventListener('visibilitychange', () => {
226
+ if (document.visibilityState === 'visible') {
227
+ const cm = connectionManager;
228
+ if (cm && cm.subscriptions.size > 0) {
229
+ if (!cm.ws || cm.ws.readyState === WebSocket.CLOSED) {
230
+ if (cm.reconnectTimeout) {
231
+ clearTimeout(cm.reconnectTimeout);
232
+ cm.reconnectTimeout = null;
233
+ }
234
+ connectWebSocket(cm);
235
+ }
236
+ }
237
+ }
238
+ });
239
+ }
240
+
241
+ // ============================================================================
242
+ // Query Composable
243
+ // ============================================================================
244
+
245
+ /**
246
+ * Query state returned by useQuery
247
+ */
248
+ export interface QueryState<T> {
249
+ data: Ref<T | undefined>;
250
+ error: Ref<Error | null>;
251
+ isLoading: ComputedRef<boolean>;
252
+ isConnected: Ref<boolean>;
253
+ refetch: () => Promise<void>;
254
+ }
255
+
256
+ /**
257
+ * Thenable query state — can be awaited or destructured directly.
258
+ * When awaited, resolves after the initial fetch completes (data is populated).
259
+ * When destructured without await, data starts as undefined and populates reactively.
260
+ */
261
+ export type AsyncQueryState<T> = QueryState<T> & PromiseLike<QueryState<T>>;
262
+
263
+ /**
264
+ * Query function reference
265
+ */
266
+ export interface QueryFunction<TArgs = void, TResult = unknown> {
267
+ _name: string;
268
+ _args?: TArgs;
269
+ _result?: TResult;
270
+ }
271
+
272
+ /**
273
+ * Reactive query composable with automatic realtime updates
274
+ *
275
+ * Uses Nuxt's useAsyncData internally for proper SSR support.
276
+ * Data is fetched on the server and hydrated on the client.
277
+ * Automatically subscribes to WebSocket updates for realtime sync.
278
+ *
279
+ * Can be used with or without await:
280
+ *
281
+ * @example
282
+ * ```vue
283
+ * <script setup>
284
+ * // Reactive — data fills in asynchronously, updates in realtime
285
+ * const { data: posts, isLoading, isConnected } = useQuery(api.posts.list);
286
+ *
287
+ * // Awaited — data is populated when the promise resolves, still updates in realtime
288
+ * const { data: posts } = await useQuery(api.posts.list);
289
+ * </script>
290
+ * ```
291
+ */
292
+ export function useQuery<TArgs, TResult>(
293
+ query: QueryFunction<TArgs, TResult> | string,
294
+ args?: TArgs
295
+ ): AsyncQueryState<TResult> {
296
+ const queryName = typeof query === 'string' ? query : query._name;
297
+ const plainArgs = toPlainArgs(args);
298
+ const queryArgs = plainArgs as Record<string, unknown> | undefined;
299
+
300
+ // Create a unique key for this query based on name and args
301
+ const queryKey = `tether-${queryName}-${JSON.stringify(plainArgs ?? {})}`;
302
+
303
+ // Track WebSocket connection state
304
+ const isConnected = ref(false);
305
+
306
+ // Use Nuxt's useAsyncData for proper SSR support
307
+ const { data, error: asyncError, status, refresh } = useAsyncData<TResult>(
308
+ queryKey,
309
+ async () => {
310
+ const response = await $fetch<{ data: TResult }>('/api/_tether/query', {
311
+ method: 'POST',
312
+ body: {
313
+ function: queryName,
314
+ args: plainArgs,
315
+ },
316
+ });
317
+ return response.data;
318
+ },
319
+ {
320
+ dedupe: 'cancel',
321
+ }
322
+ );
323
+
324
+ // Wrap the error as a computed ref
325
+ const error = computed(() => {
326
+ if (asyncError.value) {
327
+ return asyncError.value instanceof Error
328
+ ? asyncError.value
329
+ : new Error(String(asyncError.value));
330
+ }
331
+ return null;
332
+ });
333
+
334
+ // Compute isLoading from status
335
+ const isLoading = computed(() => status.value === 'pending');
336
+
337
+ // Refetch function
338
+ const refetch = async () => {
339
+ await refresh();
340
+ };
341
+
342
+ // Auto-subscribe on client side
343
+ if (import.meta.client) {
344
+ let unsubscribe: (() => void) | null = null;
345
+
346
+ onMounted(() => {
347
+ unsubscribe = subscribe(queryName, queryArgs, {
348
+ onData: (newData) => {
349
+ data.value = newData as TResult;
350
+ },
351
+ onInvalidate: () => {
352
+ refresh();
353
+ },
354
+ });
355
+
356
+ // Track connection state
357
+ const checkConnection = () => {
358
+ const cm = connectionManager;
359
+ isConnected.value = cm?.ws?.readyState === WebSocket.OPEN ?? false;
360
+ };
361
+ checkConnection();
362
+ const interval = setInterval(checkConnection, 1000);
363
+ onUnmounted(() => clearInterval(interval));
364
+ });
365
+
366
+ onUnmounted(() => {
367
+ unsubscribe?.();
368
+ });
369
+ }
370
+
371
+ // Build the synchronous return object
372
+ const state: QueryState<TResult> = {
373
+ data: data as Ref<TResult | undefined>,
374
+ error: error as unknown as Ref<Error | null>,
375
+ isLoading,
376
+ isConnected,
377
+ refetch,
378
+ };
379
+
380
+ // Create a promise that resolves when the initial fetch completes.
381
+ // On SSR or hydration, status is already not 'pending', so it resolves immediately.
382
+ // On client-side navigation, it waits for the fetch to finish.
383
+ const initialFetchPromise = new Promise<QueryState<TResult>>((resolve) => {
384
+ if (status.value !== 'pending') {
385
+ resolve(state);
386
+ return;
387
+ }
388
+ const unwatch = watch(status, (newStatus) => {
389
+ if (newStatus !== 'pending') {
390
+ unwatch();
391
+ resolve(state);
392
+ }
393
+ });
394
+ });
395
+
396
+ // Merge the promise onto the state object so it's both destructurable and thenable
397
+ return Object.assign(initialFetchPromise, state) as AsyncQueryState<TResult>;
398
+ }
399
+
400
+ // ============================================================================
401
+ // Standalone Query Function
402
+ // ============================================================================
403
+
404
+ /**
405
+ * Execute a query and return raw data directly.
406
+ *
407
+ * Unlike useQuery, this does NOT set up reactive state or WebSocket subscriptions.
408
+ * Use this in event handlers, utilities, or anywhere you need a one-shot data fetch.
409
+ * Proxies through the Nuxt server route to keep API keys secure.
410
+ *
411
+ * @example
412
+ * ```ts
413
+ * // In an event handler
414
+ * async function loadMessages(channelId: string) {
415
+ * const messages = await $query(api.messages.listByChannel, { channelId });
416
+ * return messages;
417
+ * }
418
+ *
419
+ * // With a string name
420
+ * const posts = await $query('posts.list', { limit: 10 });
421
+ * ```
422
+ */
423
+ export async function $query<TArgs, TResult>(
424
+ query: QueryFunction<TArgs, TResult> | string,
425
+ args?: TArgs
426
+ ): Promise<TResult> {
427
+ const queryName = typeof query === 'string' ? query : query._name;
428
+ const plainArgs = toPlainArgs(args);
429
+
430
+ const response = await $fetch<{ data: TResult }>('/api/_tether/query', {
431
+ method: 'POST',
432
+ body: {
433
+ function: queryName,
434
+ args: plainArgs,
435
+ },
436
+ });
437
+
438
+ return response.data;
439
+ }
440
+
441
+ // ============================================================================
442
+ // Mutation Composable
443
+ // ============================================================================
444
+
445
+ /**
446
+ * Mutation state returned by useMutation
447
+ */
448
+ export interface MutationState<TArgs, TResult> {
449
+ data: Ref<TResult | undefined>;
450
+ error: Ref<Error | null>;
451
+ isPending: Ref<boolean>;
452
+ mutate: (args: TArgs) => Promise<TResult>;
453
+ reset: () => void;
454
+ }
455
+
456
+ /**
457
+ * Mutation function reference
458
+ */
459
+ export interface MutationFunction<TArgs = void, TResult = unknown> {
460
+ _name: string;
461
+ _args?: TArgs;
462
+ _result?: TResult;
463
+ }
464
+
465
+ /**
466
+ * Mutation composable
467
+ *
468
+ * @example
469
+ * ```vue
470
+ * <script setup>
471
+ * const { mutate: createPost, isPending } = useMutation(api.posts.create);
472
+ *
473
+ * async function handleSubmit() {
474
+ * await createPost({ title: 'Hello', content: '...' });
475
+ * // All useQuery subscribers automatically update!
476
+ * }
477
+ * </script>
478
+ * ```
479
+ */
480
+ export function useMutation<TArgs, TResult>(
481
+ mutation: MutationFunction<TArgs, TResult> | string
482
+ ): MutationState<TArgs, TResult> {
483
+ const mutationName = typeof mutation === 'string' ? mutation : mutation._name;
484
+ const data = ref<TResult>();
485
+ const error = ref<Error | null>(null);
486
+ const isPending = ref(false);
487
+
488
+ const mutate = async (args: TArgs): Promise<TResult> => {
489
+ try {
490
+ isPending.value = true;
491
+ error.value = null;
492
+
493
+ const response = await $fetch<{ data: TResult }>('/api/_tether/mutation', {
494
+ method: 'POST',
495
+ body: {
496
+ function: mutationName,
497
+ args: toPlainArgs(args),
498
+ },
499
+ });
500
+
501
+ data.value = response.data;
502
+ return response.data;
503
+ } catch (e) {
504
+ error.value = e instanceof Error ? e : new Error(String(e));
505
+ throw e;
506
+ } finally {
507
+ isPending.value = false;
508
+ }
509
+ };
510
+
511
+ const reset = () => {
512
+ data.value = undefined;
513
+ error.value = null;
514
+ isPending.value = false;
515
+ };
516
+
517
+ return {
518
+ data: data as Ref<TResult | undefined>,
519
+ error,
520
+ isPending,
521
+ mutate,
522
+ reset,
523
+ };
524
+ }
525
+
526
+ // ============================================================================
527
+ // Standalone Mutation Function
528
+ // ============================================================================
529
+
530
+ /**
531
+ * Execute a mutation and return the result directly.
532
+ *
533
+ * Unlike useMutation, this does NOT set up reactive state.
534
+ * Use this in event handlers, utilities, or anywhere you need a one-shot mutation.
535
+ * Proxies through the Nuxt server route to keep API keys secure.
536
+ *
537
+ * @example
538
+ * ```ts
539
+ * // In an event handler
540
+ * async function handleCreate() {
541
+ * const post = await $mutation(api.posts.create, { title: 'Hello' });
542
+ * console.log('Created:', post);
543
+ * }
544
+ *
545
+ * // With a string name
546
+ * const result = await $mutation('posts.delete', { id: 123 });
547
+ * ```
548
+ */
549
+ export async function $mutation<TArgs, TResult>(
550
+ mutation: MutationFunction<TArgs, TResult> | string,
551
+ args?: TArgs
552
+ ): Promise<TResult> {
553
+ const mutationName = typeof mutation === 'string' ? mutation : mutation._name;
554
+ const plainArgs = toPlainArgs(args);
555
+
556
+ const response = await $fetch<{ data: TResult }>('/api/_tether/mutation', {
557
+ method: 'POST',
558
+ body: {
559
+ function: mutationName,
560
+ args: plainArgs,
561
+ },
562
+ });
563
+
564
+ return response.data;
565
+ }
566
+
567
+ // ============================================================================
568
+ // Manual Subscription (for advanced use cases)
569
+ // ============================================================================
570
+
571
+ /**
572
+ * Subscription handler options
573
+ */
574
+ export interface SubscriptionHandlers {
575
+ /** Called when fresh data is received from the server */
576
+ onData?: (data: unknown) => void;
577
+ /** Called when server invalidates the subscription (signals to refetch) */
578
+ onInvalidate?: () => void;
579
+ }
580
+
581
+ /**
582
+ * Manual WebSocket subscription composable
583
+ *
584
+ * NOTE: You typically don't need this! useQuery automatically subscribes to updates.
585
+ * This is only for advanced use cases where you need custom subscription handling.
586
+ *
587
+ * @example
588
+ * ```vue
589
+ * <script setup>
590
+ * // For custom subscription handling (rare)
591
+ * const { isConnected } = useTetherSubscription('custom.query', {}, {
592
+ * onData: (data) => console.log('Got data:', data),
593
+ * onInvalidate: () => console.log('Data invalidated'),
594
+ * });
595
+ * </script>
596
+ * ```
597
+ */
598
+ export function useTetherSubscription(
599
+ queryName: string,
600
+ args: Record<string, unknown> | undefined,
601
+ handlers: SubscriptionHandlers | ((data?: unknown) => void)
602
+ ): { isConnected: Ref<boolean> } {
603
+ const isConnected = ref(false);
604
+
605
+ // Normalize handlers
606
+ const onData = typeof handlers === 'function'
607
+ ? handlers
608
+ : handlers.onData ?? (() => {});
609
+ const onInvalidate = typeof handlers === 'function'
610
+ ? () => handlers(undefined)
611
+ : handlers.onInvalidate ?? (() => {});
612
+
613
+ if (import.meta.client) {
614
+ let unsubscribe: (() => void) | null = null;
615
+
616
+ onMounted(() => {
617
+ unsubscribe = subscribe(queryName, args, {
618
+ onData,
619
+ onInvalidate,
620
+ });
621
+
622
+ // Track connection state
623
+ const checkConnection = () => {
624
+ const cm = connectionManager;
625
+ isConnected.value = cm?.ws?.readyState === WebSocket.OPEN ?? false;
626
+ };
627
+ checkConnection();
628
+ const interval = setInterval(checkConnection, 1000);
629
+ onUnmounted(() => clearInterval(interval));
630
+ });
631
+
632
+ onUnmounted(() => {
633
+ unsubscribe?.();
634
+ });
635
+ }
636
+
637
+ return { isConnected };
638
+ }