@tthr/vue 0.0.3 → 0.0.5

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.ts CHANGED
@@ -2,6 +2,7 @@
2
2
  * @tthr/vue/nuxt - Tether Nuxt Module
3
3
  *
4
4
  * Automatically configures Tether for Nuxt applications.
5
+ * All API calls are proxied through Nuxt server routes to keep API keys secure.
5
6
  *
6
7
  * Usage in nuxt.config.ts:
7
8
  * ```ts
@@ -13,9 +14,12 @@
13
14
  * },
14
15
  * })
15
16
  * ```
17
+ *
18
+ * Environment variables (in .env):
19
+ * - TETHER_API_KEY: Your project's API key (required, kept server-side)
16
20
  */
17
21
 
18
- import { defineNuxtModule, addPlugin, createResolver, addImports, addComponent } from '@nuxt/kit';
22
+ import { defineNuxtModule, addPlugin, createResolver, addImports, addComponent, addServerHandler } from '@nuxt/kit';
19
23
 
20
24
  export interface TetherModuleOptions {
21
25
  /** Project ID from Tether dashboard */
@@ -39,15 +43,33 @@ export default defineNuxtModule<TetherModuleOptions>({
39
43
  setup(options, nuxt) {
40
44
  const resolver = createResolver(import.meta.url);
41
45
 
42
- // Add runtime config for client-side access
46
+ // Server-side config (includes API key - never exposed to client)
47
+ nuxt.options.runtimeConfig.tether = {
48
+ apiKey: '', // Will be populated from TETHER_API_KEY env var
49
+ url: options.url || 'https://api.tether.strands.gg',
50
+ projectId: options.projectId || '',
51
+ };
52
+
53
+ // Public config (safe for client - no secrets)
43
54
  nuxt.options.runtimeConfig.public.tether = {
44
55
  projectId: options.projectId || '',
45
- url: options.url || 'https://api.tether.strands.gg',
56
+ wsUrl: (options.url || 'https://api.tether.strands.gg').replace('http', 'ws'),
46
57
  };
47
58
 
48
- // Add the plugin that initialises Tether on the client
59
+ // Add server API routes for proxying Tether requests
60
+ addServerHandler({
61
+ route: '/api/_tether/query',
62
+ handler: resolver.resolve('./runtime/server/query.post'),
63
+ });
64
+
65
+ addServerHandler({
66
+ route: '/api/_tether/mutation',
67
+ handler: resolver.resolve('./runtime/server/mutation.post'),
68
+ });
69
+
70
+ // Add the client plugin for WebSocket subscriptions only
49
71
  addPlugin({
50
- src: resolver.resolve('./runtime/plugin'),
72
+ src: resolver.resolve('./runtime/plugin.client'),
51
73
  mode: 'client',
52
74
  });
53
75
 
@@ -62,7 +84,7 @@ export default defineNuxtModule<TetherModuleOptions>({
62
84
  from: resolver.resolve('./runtime/composables'),
63
85
  },
64
86
  {
65
- name: 'useTether',
87
+ name: 'useTetherSubscription',
66
88
  from: resolver.resolve('./runtime/composables'),
67
89
  },
68
90
  ]);
@@ -105,8 +105,8 @@
105
105
  </template>
106
106
 
107
107
  <script setup lang="ts">
108
- import { ref, onMounted } from 'vue';
109
- import { useTether, useQuery, useMutation } from '../composables';
108
+ import { ref } from 'vue';
109
+ import { useQuery, useMutation, useTetherSubscription } from '../composables';
110
110
 
111
111
  interface Post {
112
112
  id: string;
@@ -117,22 +117,16 @@ interface Post {
117
117
  updatedAt: string;
118
118
  }
119
119
 
120
- const isConnected = ref(false);
121
120
  const newPostTitle = ref('');
122
121
 
123
- // Set up queries and mutations
124
- const posts = useQuery<void, Post[]>({ _name: 'posts.list' });
125
- const createPost = useMutation<{ title: string; content?: string }, { id: string }>({ _name: 'posts.create' });
126
- const deletePost = useMutation<{ id: string }, void>({ _name: 'posts.remove' });
122
+ // Set up queries and mutations (these use SSR-safe server routes)
123
+ const posts = useQuery<void, Post[]>('posts.list');
124
+ const createPost = useMutation<{ title: string; content?: string }, { id: string }>('posts.create');
125
+ const deletePost = useMutation<{ id: string }, void>('posts.remove');
127
126
 
128
- onMounted(async () => {
129
- try {
130
- const client = useTether();
131
- await client.connect();
132
- isConnected.value = true;
133
- } catch {
134
- // Connection handled by client
135
- }
127
+ // Set up WebSocket subscription for realtime updates (client-side only)
128
+ const { isConnected } = useTetherSubscription('posts.list', undefined, () => {
129
+ posts.refetch();
136
130
  });
137
131
 
138
132
  async function handleCreatePost() {
@@ -2,21 +2,11 @@
2
2
  * Nuxt composables for Tether
3
3
  *
4
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.
5
7
  */
6
8
 
7
- import { ref, onMounted, onUnmounted, type Ref } from 'vue';
8
- import { tetherClient } from './plugin';
9
- import type { TetherClient } from '@tthr/client';
10
-
11
- /**
12
- * Get the Tether client instance
13
- */
14
- export function useTether(): TetherClient {
15
- if (!tetherClient) {
16
- throw new Error('[Tether] Client not initialised. Make sure the Tether module is configured in nuxt.config.ts');
17
- }
18
- return tetherClient;
19
- }
9
+ import { ref, onMounted, onUnmounted, watch, type Ref } from 'vue';
20
10
 
21
11
  /**
22
12
  * Query state returned by useQuery
@@ -38,7 +28,7 @@ export interface QueryFunction<TArgs = void, TResult = unknown> {
38
28
  }
39
29
 
40
30
  /**
41
- * Reactive query composable with auto-subscription
31
+ * Reactive query composable with auto-refresh on subscription updates
42
32
  *
43
33
  * @example
44
34
  * ```vue
@@ -48,23 +38,28 @@ export interface QueryFunction<TArgs = void, TResult = unknown> {
48
38
  * ```
49
39
  */
50
40
  export function useQuery<TArgs, TResult>(
51
- query: QueryFunction<TArgs, TResult>,
41
+ query: QueryFunction<TArgs, TResult> | string,
52
42
  args?: TArgs
53
43
  ): QueryState<TResult> {
44
+ const queryName = typeof query === 'string' ? query : query._name;
54
45
  const data = ref<TResult>();
55
46
  const error = ref<Error | null>(null);
56
47
  const isLoading = ref(true);
57
48
 
58
- let unsubscribe: (() => void) | null = null;
59
-
60
49
  const fetchData = async () => {
61
50
  try {
62
51
  isLoading.value = true;
63
52
  error.value = null;
64
53
 
65
- const client = useTether();
66
- const result = await client.query(query._name, args);
67
- data.value = result as TResult;
54
+ const response = await $fetch<{ data: TResult }>('/api/_tether/query', {
55
+ method: 'POST',
56
+ body: {
57
+ function: queryName,
58
+ args,
59
+ },
60
+ });
61
+
62
+ data.value = response.data;
68
63
  } catch (e) {
69
64
  error.value = e instanceof Error ? e : new Error(String(e));
70
65
  } finally {
@@ -72,21 +67,15 @@ export function useQuery<TArgs, TResult>(
72
67
  }
73
68
  };
74
69
 
75
- onMounted(async () => {
76
- await fetchData();
77
-
78
- // Subscribe to realtime updates
79
- const client = useTether();
80
- unsubscribe = client.subscribe(query._name, args, (newData) => {
81
- data.value = newData as TResult;
82
- });
70
+ // Fetch on mount (client-side)
71
+ onMounted(() => {
72
+ fetchData();
83
73
  });
84
74
 
85
- onUnmounted(() => {
86
- if (unsubscribe) {
87
- unsubscribe();
88
- }
89
- });
75
+ // Also fetch immediately for SSR
76
+ if (import.meta.server) {
77
+ fetchData();
78
+ }
90
79
 
91
80
  return {
92
81
  data: data as Ref<TResult | undefined>,
@@ -131,8 +120,9 @@ export interface MutationFunction<TArgs = void, TResult = unknown> {
131
120
  * ```
132
121
  */
133
122
  export function useMutation<TArgs, TResult>(
134
- mutation: MutationFunction<TArgs, TResult>
123
+ mutation: MutationFunction<TArgs, TResult> | string
135
124
  ): MutationState<TArgs, TResult> {
125
+ const mutationName = typeof mutation === 'string' ? mutation : mutation._name;
136
126
  const data = ref<TResult>();
137
127
  const error = ref<Error | null>(null);
138
128
  const isPending = ref(false);
@@ -142,10 +132,16 @@ export function useMutation<TArgs, TResult>(
142
132
  isPending.value = true;
143
133
  error.value = null;
144
134
 
145
- const client = useTether();
146
- const result = await client.mutation(mutation._name, args);
147
- data.value = result as TResult;
148
- return result as TResult;
135
+ const response = await $fetch<{ data: TResult }>('/api/_tether/mutation', {
136
+ method: 'POST',
137
+ body: {
138
+ function: mutationName,
139
+ args,
140
+ },
141
+ });
142
+
143
+ data.value = response.data;
144
+ return response.data;
149
145
  } catch (e) {
150
146
  error.value = e instanceof Error ? e : new Error(String(e));
151
147
  throw e;
@@ -168,3 +164,86 @@ export function useMutation<TArgs, TResult>(
168
164
  reset,
169
165
  };
170
166
  }
167
+
168
+ /**
169
+ * WebSocket subscription composable for realtime updates
170
+ * This runs client-side only and calls refetch when updates are received
171
+ *
172
+ * @example
173
+ * ```vue
174
+ * <script setup>
175
+ * const { data: posts, refetch } = useQuery('posts.list');
176
+ * useTetherSubscription('posts.list', {}, refetch);
177
+ * </script>
178
+ * ```
179
+ */
180
+ export function useTetherSubscription(
181
+ queryName: string,
182
+ args: Record<string, unknown> | undefined,
183
+ onUpdate: () => void
184
+ ): { isConnected: Ref<boolean> } {
185
+ const isConnected = ref(false);
186
+
187
+ if (import.meta.client) {
188
+ let ws: WebSocket | null = null;
189
+ let reconnectTimeout: ReturnType<typeof setTimeout> | null = null;
190
+
191
+ const connect = () => {
192
+ // Get config from window (set by plugin)
193
+ const config = (window as any).__TETHER_CONFIG__;
194
+ if (!config?.wsUrl || !config?.projectId) {
195
+ console.warn('[Tether] WebSocket config not available');
196
+ return;
197
+ }
198
+
199
+ const wsUrl = `${config.wsUrl}/ws/${config.projectId}`;
200
+ ws = new WebSocket(wsUrl);
201
+
202
+ ws.onopen = () => {
203
+ isConnected.value = true;
204
+ // Subscribe to the query
205
+ ws?.send(JSON.stringify({
206
+ type: 'subscribe',
207
+ query: queryName,
208
+ args,
209
+ }));
210
+ };
211
+
212
+ ws.onmessage = (event) => {
213
+ try {
214
+ const message = JSON.parse(event.data);
215
+ if (message.type === 'update' && message.query === queryName) {
216
+ onUpdate();
217
+ }
218
+ } catch {
219
+ // Ignore parse errors
220
+ }
221
+ };
222
+
223
+ ws.onclose = () => {
224
+ isConnected.value = false;
225
+ // Reconnect after 3 seconds
226
+ reconnectTimeout = setTimeout(connect, 3000);
227
+ };
228
+
229
+ ws.onerror = () => {
230
+ ws?.close();
231
+ };
232
+ };
233
+
234
+ onMounted(() => {
235
+ connect();
236
+ });
237
+
238
+ onUnmounted(() => {
239
+ if (reconnectTimeout) {
240
+ clearTimeout(reconnectTimeout);
241
+ }
242
+ if (ws) {
243
+ ws.close();
244
+ }
245
+ });
246
+ }
247
+
248
+ return { isConnected };
249
+ }
@@ -0,0 +1,28 @@
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
+ window.__TETHER_CONFIG__ = {
25
+ projectId: config.public.tether.projectId,
26
+ wsUrl: config.public.tether.wsUrl,
27
+ };
28
+ });
@@ -0,0 +1,61 @@
1
+ /**
2
+ * Server-side mutation handler
3
+ * Proxies mutations to Tether API with the secret API key
4
+ */
5
+
6
+ import { defineEventHandler, readBody, createError } from 'h3';
7
+ import { useRuntimeConfig } from '#imports';
8
+
9
+ export default defineEventHandler(async (event) => {
10
+ const config = useRuntimeConfig();
11
+
12
+ // Get API key from runtime config (populated from TETHER_API_KEY env var)
13
+ const apiKey = config.tether?.apiKey || process.env.TETHER_API_KEY;
14
+ const url = config.tether?.url || process.env.TETHER_URL || 'http://localhost:3001';
15
+ const projectId = config.tether?.projectId || process.env.TETHER_PROJECT_ID;
16
+
17
+ if (!apiKey) {
18
+ throw createError({
19
+ statusCode: 500,
20
+ message: 'Tether API key not configured. Set TETHER_API_KEY environment variable.',
21
+ });
22
+ }
23
+
24
+ if (!projectId) {
25
+ throw createError({
26
+ statusCode: 500,
27
+ message: 'Tether project ID not configured.',
28
+ });
29
+ }
30
+
31
+ const body = await readBody(event);
32
+
33
+ if (!body?.function) {
34
+ throw createError({
35
+ statusCode: 400,
36
+ message: 'Missing "function" in request body',
37
+ });
38
+ }
39
+
40
+ const response = await fetch(`${url}/api/v1/${projectId}/mutation`, {
41
+ method: 'POST',
42
+ headers: {
43
+ 'Content-Type': 'application/json',
44
+ 'Authorization': `Bearer ${apiKey}`,
45
+ },
46
+ body: JSON.stringify({
47
+ function: body.function,
48
+ args: body.args,
49
+ }),
50
+ });
51
+
52
+ if (!response.ok) {
53
+ const error = await response.json().catch(() => ({ error: 'Unknown error' }));
54
+ throw createError({
55
+ statusCode: response.status,
56
+ message: error.error || 'Mutation failed',
57
+ });
58
+ }
59
+
60
+ return response.json();
61
+ });
@@ -0,0 +1,61 @@
1
+ /**
2
+ * Server-side query handler
3
+ * Proxies queries to Tether API with the secret API key
4
+ */
5
+
6
+ import { defineEventHandler, readBody, createError } from 'h3';
7
+ import { useRuntimeConfig } from '#imports';
8
+
9
+ export default defineEventHandler(async (event) => {
10
+ const config = useRuntimeConfig();
11
+
12
+ // Get API key from runtime config (populated from TETHER_API_KEY env var)
13
+ const apiKey = config.tether?.apiKey || process.env.TETHER_API_KEY;
14
+ const url = config.tether?.url || process.env.TETHER_URL || 'http://localhost:3001';
15
+ const projectId = config.tether?.projectId || process.env.TETHER_PROJECT_ID;
16
+
17
+ if (!apiKey) {
18
+ throw createError({
19
+ statusCode: 500,
20
+ message: 'Tether API key not configured. Set TETHER_API_KEY environment variable.',
21
+ });
22
+ }
23
+
24
+ if (!projectId) {
25
+ throw createError({
26
+ statusCode: 500,
27
+ message: 'Tether project ID not configured.',
28
+ });
29
+ }
30
+
31
+ const body = await readBody(event);
32
+
33
+ if (!body?.function) {
34
+ throw createError({
35
+ statusCode: 400,
36
+ message: 'Missing "function" in request body',
37
+ });
38
+ }
39
+
40
+ const response = await fetch(`${url}/api/v1/${projectId}/query`, {
41
+ method: 'POST',
42
+ headers: {
43
+ 'Content-Type': 'application/json',
44
+ 'Authorization': `Bearer ${apiKey}`,
45
+ },
46
+ body: JSON.stringify({
47
+ function: body.function,
48
+ args: body.args,
49
+ }),
50
+ });
51
+
52
+ if (!response.ok) {
53
+ const error = await response.json().catch(() => ({ error: 'Unknown error' }));
54
+ throw createError({
55
+ statusCode: response.status,
56
+ message: error.error || 'Query failed',
57
+ });
58
+ }
59
+
60
+ return response.json();
61
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tthr/vue",
3
- "version": "0.0.3",
3
+ "version": "0.0.5",
4
4
  "description": "Tether Vue/Nuxt SDK",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -1,33 +0,0 @@
1
- /**
2
- * Nuxt plugin that initialises the Tether client
3
- */
4
-
5
- import { defineNuxtPlugin, useRuntimeConfig } from '#app';
6
- import { TetherClient } from '@tthr/client';
7
-
8
- // Global client instance for use by composables
9
- export let tetherClient: TetherClient | null = null;
10
-
11
- export default defineNuxtPlugin(() => {
12
- const config = useRuntimeConfig();
13
- const tetherConfig = config.public.tether as { projectId: string; url: string };
14
-
15
- if (!tetherConfig.projectId) {
16
- console.warn('[Tether] No projectId configured. Set it in nuxt.config.ts under tether.projectId or via NUXT_PUBLIC_TETHER_PROJECT_ID env var.');
17
- }
18
-
19
- // Build the WebSocket URL from the API URL and project ID
20
- const wsUrl = tetherConfig.url
21
- .replace('https://', 'wss://')
22
- .replace('http://', 'ws://');
23
-
24
- tetherClient = new TetherClient({
25
- url: `${wsUrl}/ws/${tetherConfig.projectId}`,
26
- });
27
-
28
- return {
29
- provide: {
30
- tether: tetherClient,
31
- },
32
- };
33
- });