@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.
- package/nuxt/module.js +2 -2
- package/nuxt/module.ts +2 -2
- package/nuxt/runtime/composables.ts +638 -0
- package/nuxt/runtime/plugin.client.ts +34 -0
- package/nuxt/runtime/server/mutation.post.js +30 -364
- package/nuxt/runtime/server/mutation.post.ts +53 -0
- package/nuxt/runtime/server/plugins/cron.ts +377 -0
- package/nuxt/runtime/server/query.post.js +27 -362
- package/nuxt/runtime/server/query.post.ts +51 -0
- package/nuxt/runtime/server/utils/handler.js +318 -0
- package/nuxt/runtime/server/utils/handler.ts +375 -0
- package/nuxt/runtime/server/utils/tether.js +187 -210
- package/nuxt/runtime/server/utils/tether.ts +292 -0
- package/package.json +7 -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/plugins/cron.d.ts +0 -38
- package/nuxt/runtime/server/plugins/cron.d.ts.map +0 -1
- 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
|
|
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
|
|
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
|
+
}
|