@tthr/vue 0.0.84 → 0.0.85
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/nuxt/module.js +7 -9
- package/nuxt/module.ts +7 -9
- package/nuxt/runtime/composables.ts +638 -0
- package/nuxt/runtime/plugin.client.ts +34 -0
- package/nuxt/runtime/server/mutation.post.ts +53 -0
- package/nuxt/runtime/server/plugins/cron.ts +377 -0
- package/nuxt/runtime/server/query.post.ts +51 -0
- package/nuxt/runtime/server/utils/handler.ts +375 -0
- package/nuxt/runtime/server/utils/{tether.js → tether.ts} +78 -29
- package/package.json +6 -9
- package/dist/nuxt.d.ts +0 -14
- package/dist/nuxt.d.ts.map +0 -1
- package/dist/nuxt.js +0 -48
- package/dist/nuxt.js.map +0 -1
- package/dist/runtime/composables.d.ts +0 -73
- package/dist/runtime/composables.d.ts.map +0 -1
- package/dist/runtime/composables.js +0 -112
- package/dist/runtime/composables.js.map +0 -1
- package/dist/runtime/plugin.d.ts +0 -11
- package/dist/runtime/plugin.d.ts.map +0 -1
- package/dist/runtime/plugin.js +0 -33
- package/dist/runtime/plugin.js.map +0 -1
- package/nuxt/runtime/composables.d.ts +0 -142
- package/nuxt/runtime/composables.d.ts.map +0 -1
- package/nuxt/runtime/composables.js +0 -480
- package/nuxt/runtime/composables.js.map +0 -1
- package/nuxt/runtime/plugin.client.d.ts +0 -17
- package/nuxt/runtime/plugin.client.d.ts.map +0 -1
- package/nuxt/runtime/plugin.client.js +0 -20
- package/nuxt/runtime/plugin.client.js.map +0 -1
- package/nuxt/runtime/server/mutation.post.js +0 -373
- package/nuxt/runtime/server/plugins/cron.d.ts +0 -38
- package/nuxt/runtime/server/plugins/cron.d.ts.map +0 -1
- package/nuxt/runtime/server/plugins/cron.js +0 -303
- package/nuxt/runtime/server/plugins/cron.js.map +0 -1
- package/nuxt/runtime/server/query.post.js +0 -372
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Client-side Tether plugin
|
|
3
|
+
*
|
|
4
|
+
* Sets up the WebSocket configuration for realtime subscriptions.
|
|
5
|
+
* This only exposes the project ID and WebSocket URL - no secrets.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { defineNuxtPlugin, useRuntimeConfig } from '#imports';
|
|
9
|
+
|
|
10
|
+
declare global {
|
|
11
|
+
interface Window {
|
|
12
|
+
__TETHER_CONFIG__?: {
|
|
13
|
+
projectId: string;
|
|
14
|
+
wsUrl: string;
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export default defineNuxtPlugin(() => {
|
|
20
|
+
const config = useRuntimeConfig();
|
|
21
|
+
|
|
22
|
+
// Make config available for WebSocket connections
|
|
23
|
+
// This is safe - no secrets are exposed
|
|
24
|
+
const tetherConfig = {
|
|
25
|
+
projectId: config.public.tether?.projectId || '',
|
|
26
|
+
wsUrl: config.public.tether?.wsUrl || '',
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
window.__TETHER_CONFIG__ = tetherConfig;
|
|
30
|
+
|
|
31
|
+
if (!tetherConfig.wsUrl || !tetherConfig.projectId) {
|
|
32
|
+
console.warn('[Tether] Config incomplete - WebSocket subscriptions will not work');
|
|
33
|
+
}
|
|
34
|
+
});
|