@tthr/vue 0.0.2 → 0.0.4
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 +34 -6
- package/nuxt/runtime/components/TetherWelcome.vue +461 -0
- package/nuxt/runtime/composables.ts +117 -38
- package/nuxt/runtime/plugin.client.ts +28 -0
- package/nuxt/runtime/server/mutation.post.ts +61 -0
- package/nuxt/runtime/server/query.post.ts +61 -0
- package/package.json +1 -1
- package/nuxt/runtime/plugin.ts +0 -33
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 } 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
|
-
//
|
|
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
|
-
|
|
56
|
+
wsUrl: (options.url || 'https://api.tether.strands.gg').replace('http', 'ws'),
|
|
46
57
|
};
|
|
47
58
|
|
|
48
|
-
// Add
|
|
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,9 +84,15 @@ export default defineNuxtModule<TetherModuleOptions>({
|
|
|
62
84
|
from: resolver.resolve('./runtime/composables'),
|
|
63
85
|
},
|
|
64
86
|
{
|
|
65
|
-
name: '
|
|
87
|
+
name: 'useTetherSubscription',
|
|
66
88
|
from: resolver.resolve('./runtime/composables'),
|
|
67
89
|
},
|
|
68
90
|
]);
|
|
91
|
+
|
|
92
|
+
// Auto-import components
|
|
93
|
+
addComponent({
|
|
94
|
+
name: 'TetherWelcome',
|
|
95
|
+
filePath: resolver.resolve('./runtime/components/TetherWelcome.vue'),
|
|
96
|
+
});
|
|
69
97
|
},
|
|
70
98
|
});
|
|
@@ -0,0 +1,461 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<div class="tether-welcome">
|
|
3
|
+
<div class="tether-welcome__container">
|
|
4
|
+
<header class="tether-welcome__header">
|
|
5
|
+
<div class="tether-welcome__logo">
|
|
6
|
+
<svg viewBox="0 0 40 40" fill="none" xmlns="http://www.w3.org/2000/svg">
|
|
7
|
+
<rect width="40" height="40" rx="8" fill="currentColor" />
|
|
8
|
+
<path d="M12 14h16M20 14v14M14 28h12" stroke="white" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"/>
|
|
9
|
+
</svg>
|
|
10
|
+
</div>
|
|
11
|
+
<h1>Welcome to Tether</h1>
|
|
12
|
+
<p class="tether-welcome__subtitle">Realtime SQLite for modern apps</p>
|
|
13
|
+
</header>
|
|
14
|
+
|
|
15
|
+
<div class="tether-welcome__status">
|
|
16
|
+
<div class="tether-welcome__status-item" :class="{ 'tether-welcome__status-item--success': isConnected }">
|
|
17
|
+
<span class="tether-welcome__status-dot"></span>
|
|
18
|
+
<span>{{ isConnected ? 'Connected to Tether' : 'Connecting...' }}</span>
|
|
19
|
+
</div>
|
|
20
|
+
</div>
|
|
21
|
+
|
|
22
|
+
<section class="tether-welcome__demo">
|
|
23
|
+
<h2>Try it out</h2>
|
|
24
|
+
<p class="tether-welcome__demo-intro">Create posts in realtime. Open this page in multiple tabs to see live updates!</p>
|
|
25
|
+
|
|
26
|
+
<form class="tether-welcome__form" @submit.prevent="handleCreatePost">
|
|
27
|
+
<input
|
|
28
|
+
v-model="newPostTitle"
|
|
29
|
+
type="text"
|
|
30
|
+
placeholder="Enter a post title..."
|
|
31
|
+
class="tether-welcome__input"
|
|
32
|
+
:disabled="createPost.isPending.value"
|
|
33
|
+
/>
|
|
34
|
+
<button
|
|
35
|
+
type="submit"
|
|
36
|
+
class="tether-welcome__button"
|
|
37
|
+
:disabled="!newPostTitle.trim() || createPost.isPending.value"
|
|
38
|
+
>
|
|
39
|
+
{{ createPost.isPending.value ? 'Creating...' : 'Create Post' }}
|
|
40
|
+
</button>
|
|
41
|
+
</form>
|
|
42
|
+
|
|
43
|
+
<div v-if="posts.error.value" class="tether-welcome__error">
|
|
44
|
+
{{ posts.error.value.message }}
|
|
45
|
+
</div>
|
|
46
|
+
|
|
47
|
+
<div class="tether-welcome__posts">
|
|
48
|
+
<div v-if="posts.isLoading.value" class="tether-welcome__loading">
|
|
49
|
+
Loading posts...
|
|
50
|
+
</div>
|
|
51
|
+
<template v-else-if="posts.data.value?.length">
|
|
52
|
+
<article
|
|
53
|
+
v-for="post in posts.data.value"
|
|
54
|
+
:key="post.id"
|
|
55
|
+
class="tether-welcome__post"
|
|
56
|
+
>
|
|
57
|
+
<div class="tether-welcome__post-content">
|
|
58
|
+
<h3>{{ post.title }}</h3>
|
|
59
|
+
<time>{{ formatDate(post.createdAt) }}</time>
|
|
60
|
+
</div>
|
|
61
|
+
<button
|
|
62
|
+
class="tether-welcome__post-delete"
|
|
63
|
+
@click="handleDeletePost(post.id)"
|
|
64
|
+
:disabled="deletePost.isPending.value"
|
|
65
|
+
title="Delete post"
|
|
66
|
+
>
|
|
67
|
+
<svg viewBox="0 0 20 20" fill="currentColor">
|
|
68
|
+
<path fill-rule="evenodd" d="M9 2a1 1 0 00-.894.553L7.382 4H4a1 1 0 000 2v10a2 2 0 002 2h8a2 2 0 002-2V6a1 1 0 100-2h-3.382l-.724-1.447A1 1 0 0011 2H9zM7 8a1 1 0 012 0v6a1 1 0 11-2 0V8zm5-1a1 1 0 00-1 1v6a1 1 0 102 0V8a1 1 0 00-1-1z" clip-rule="evenodd" />
|
|
69
|
+
</svg>
|
|
70
|
+
</button>
|
|
71
|
+
</article>
|
|
72
|
+
</template>
|
|
73
|
+
<p v-else class="tether-welcome__empty">
|
|
74
|
+
No posts yet. Create your first one above!
|
|
75
|
+
</p>
|
|
76
|
+
</div>
|
|
77
|
+
</section>
|
|
78
|
+
|
|
79
|
+
<section class="tether-welcome__links">
|
|
80
|
+
<a href="https://tether.strands.gg/docs" target="_blank" rel="noopener" class="tether-welcome__link">
|
|
81
|
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
|
82
|
+
<path d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253" />
|
|
83
|
+
</svg>
|
|
84
|
+
<span>Documentation</span>
|
|
85
|
+
</a>
|
|
86
|
+
<a href="https://github.com/StrandsServices/tether" target="_blank" rel="noopener" class="tether-welcome__link">
|
|
87
|
+
<svg viewBox="0 0 24 24" fill="currentColor">
|
|
88
|
+
<path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z"/>
|
|
89
|
+
</svg>
|
|
90
|
+
<span>GitHub</span>
|
|
91
|
+
</a>
|
|
92
|
+
<a href="https://discord.gg/strands" target="_blank" rel="noopener" class="tether-welcome__link">
|
|
93
|
+
<svg viewBox="0 0 24 24" fill="currentColor">
|
|
94
|
+
<path d="M20.317 4.3698a19.7913 19.7913 0 00-4.8851-1.5152.0741.0741 0 00-.0785.0371c-.211.3753-.4447.8648-.6083 1.2495-1.8447-.2762-3.68-.2762-5.4868 0-.1636-.3933-.4058-.8742-.6177-1.2495a.077.077 0 00-.0785-.037 19.7363 19.7363 0 00-4.8852 1.515.0699.0699 0 00-.0321.0277C.5334 9.0458-.319 13.5799.0992 18.0578a.0824.0824 0 00.0312.0561c2.0528 1.5076 4.0413 2.4228 5.9929 3.0294a.0777.0777 0 00.0842-.0276c.4616-.6304.8731-1.2952 1.226-1.9942a.076.076 0 00-.0416-.1057c-.6528-.2476-1.2743-.5495-1.8722-.8923a.077.077 0 01-.0076-.1277c.1258-.0943.2517-.1923.3718-.2914a.0743.0743 0 01.0776-.0105c3.9278 1.7933 8.18 1.7933 12.0614 0a.0739.0739 0 01.0785.0095c.1202.099.246.1981.3728.2924a.077.077 0 01-.0066.1276 12.2986 12.2986 0 01-1.873.8914.0766.0766 0 00-.0407.1067c.3604.698.7719 1.3628 1.225 1.9932a.076.076 0 00.0842.0286c1.961-.6067 3.9495-1.5219 6.0023-3.0294a.077.077 0 00.0313-.0552c.5004-5.177-.8382-9.6739-3.5485-13.6604a.061.061 0 00-.0312-.0286zM8.02 15.3312c-1.1825 0-2.1569-1.0857-2.1569-2.419 0-1.3332.9555-2.4189 2.157-2.4189 1.2108 0 2.1757 1.0952 2.1568 2.419 0 1.3332-.9555 2.4189-2.1569 2.4189zm7.9748 0c-1.1825 0-2.1569-1.0857-2.1569-2.419 0-1.3332.9554-2.4189 2.1569-2.4189 1.2108 0 2.1757 1.0952 2.1568 2.419 0 1.3332-.946 2.4189-2.1568 2.4189Z"/>
|
|
95
|
+
</svg>
|
|
96
|
+
<span>Discord</span>
|
|
97
|
+
</a>
|
|
98
|
+
</section>
|
|
99
|
+
|
|
100
|
+
<footer class="tether-welcome__footer">
|
|
101
|
+
<p>Built with Tether by <a href="https://strands.gg" target="_blank" rel="noopener">Strands Services</a></p>
|
|
102
|
+
</footer>
|
|
103
|
+
</div>
|
|
104
|
+
</div>
|
|
105
|
+
</template>
|
|
106
|
+
|
|
107
|
+
<script setup lang="ts">
|
|
108
|
+
import { ref } from 'vue';
|
|
109
|
+
import { useQuery, useMutation, useTetherSubscription } from '../composables';
|
|
110
|
+
|
|
111
|
+
interface Post {
|
|
112
|
+
id: string;
|
|
113
|
+
title: string;
|
|
114
|
+
content: string;
|
|
115
|
+
authorId: string;
|
|
116
|
+
createdAt: string;
|
|
117
|
+
updatedAt: string;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const newPostTitle = ref('');
|
|
121
|
+
|
|
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');
|
|
126
|
+
|
|
127
|
+
// Set up WebSocket subscription for realtime updates (client-side only)
|
|
128
|
+
const { isConnected } = useTetherSubscription('posts.list', undefined, () => {
|
|
129
|
+
posts.refetch();
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
async function handleCreatePost() {
|
|
133
|
+
if (!newPostTitle.value.trim()) return;
|
|
134
|
+
|
|
135
|
+
try {
|
|
136
|
+
await createPost.mutate({
|
|
137
|
+
title: newPostTitle.value.trim(),
|
|
138
|
+
content: '',
|
|
139
|
+
});
|
|
140
|
+
newPostTitle.value = '';
|
|
141
|
+
await posts.refetch();
|
|
142
|
+
} catch (error) {
|
|
143
|
+
console.error('Failed to create post:', error);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
async function handleDeletePost(id: string) {
|
|
148
|
+
try {
|
|
149
|
+
await deletePost.mutate({ id });
|
|
150
|
+
await posts.refetch();
|
|
151
|
+
} catch (error) {
|
|
152
|
+
console.error('Failed to delete post:', error);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function formatDate(dateString: string): string {
|
|
157
|
+
const date = new Date(dateString);
|
|
158
|
+
return date.toLocaleDateString('en-GB', {
|
|
159
|
+
day: 'numeric',
|
|
160
|
+
month: 'short',
|
|
161
|
+
year: 'numeric',
|
|
162
|
+
hour: '2-digit',
|
|
163
|
+
minute: '2-digit',
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
</script>
|
|
167
|
+
|
|
168
|
+
<style scoped>
|
|
169
|
+
.tether-welcome {
|
|
170
|
+
min-height: 100vh;
|
|
171
|
+
background: linear-gradient(135deg, #0f0f23 0%, #1a1a2e 50%, #16213e 100%);
|
|
172
|
+
color: #e4e4e7;
|
|
173
|
+
font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
|
174
|
+
padding: 2rem;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
.tether-welcome__container {
|
|
178
|
+
max-width: 640px;
|
|
179
|
+
margin: 0 auto;
|
|
180
|
+
display: flex;
|
|
181
|
+
flex-direction: column;
|
|
182
|
+
gap: 2.5rem;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
.tether-welcome__header {
|
|
186
|
+
text-align: center;
|
|
187
|
+
display: flex;
|
|
188
|
+
flex-direction: column;
|
|
189
|
+
align-items: center;
|
|
190
|
+
gap: 1rem;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
.tether-welcome__logo {
|
|
194
|
+
width: 64px;
|
|
195
|
+
height: 64px;
|
|
196
|
+
color: #6366f1;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
.tether-welcome__header h1 {
|
|
200
|
+
font-size: 2.5rem;
|
|
201
|
+
font-weight: 700;
|
|
202
|
+
margin: 0;
|
|
203
|
+
background: linear-gradient(135deg, #6366f1 0%, #a855f7 100%);
|
|
204
|
+
-webkit-background-clip: text;
|
|
205
|
+
-webkit-text-fill-color: transparent;
|
|
206
|
+
background-clip: text;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
.tether-welcome__subtitle {
|
|
210
|
+
color: #a1a1aa;
|
|
211
|
+
font-size: 1.125rem;
|
|
212
|
+
margin: 0;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
.tether-welcome__status {
|
|
216
|
+
display: flex;
|
|
217
|
+
justify-content: center;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
.tether-welcome__status-item {
|
|
221
|
+
display: flex;
|
|
222
|
+
align-items: center;
|
|
223
|
+
gap: 0.5rem;
|
|
224
|
+
padding: 0.5rem 1rem;
|
|
225
|
+
background: rgba(255, 255, 255, 0.05);
|
|
226
|
+
border-radius: 9999px;
|
|
227
|
+
font-size: 0.875rem;
|
|
228
|
+
color: #a1a1aa;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
.tether-welcome__status-item--success {
|
|
232
|
+
color: #4ade80;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
.tether-welcome__status-dot {
|
|
236
|
+
width: 8px;
|
|
237
|
+
height: 8px;
|
|
238
|
+
border-radius: 50%;
|
|
239
|
+
background: #a1a1aa;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
.tether-welcome__status-item--success .tether-welcome__status-dot {
|
|
243
|
+
background: #4ade80;
|
|
244
|
+
box-shadow: 0 0 8px #4ade80;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
.tether-welcome__demo {
|
|
248
|
+
background: rgba(255, 255, 255, 0.03);
|
|
249
|
+
border: 1px solid rgba(255, 255, 255, 0.1);
|
|
250
|
+
border-radius: 1rem;
|
|
251
|
+
padding: 1.5rem;
|
|
252
|
+
display: flex;
|
|
253
|
+
flex-direction: column;
|
|
254
|
+
gap: 1rem;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
.tether-welcome__demo h2 {
|
|
258
|
+
font-size: 1.25rem;
|
|
259
|
+
font-weight: 600;
|
|
260
|
+
margin: 0;
|
|
261
|
+
color: #f4f4f5;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
.tether-welcome__demo-intro {
|
|
265
|
+
color: #a1a1aa;
|
|
266
|
+
font-size: 0.875rem;
|
|
267
|
+
margin: 0;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
.tether-welcome__form {
|
|
271
|
+
display: flex;
|
|
272
|
+
gap: 0.75rem;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
.tether-welcome__input {
|
|
276
|
+
flex: 1;
|
|
277
|
+
padding: 0.75rem 1rem;
|
|
278
|
+
background: rgba(0, 0, 0, 0.3);
|
|
279
|
+
border: 1px solid rgba(255, 255, 255, 0.1);
|
|
280
|
+
border-radius: 0.5rem;
|
|
281
|
+
color: #e4e4e7;
|
|
282
|
+
font-size: 0.875rem;
|
|
283
|
+
outline: none;
|
|
284
|
+
transition: border-color 0.2s, box-shadow 0.2s;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
.tether-welcome__input:focus {
|
|
288
|
+
border-color: #6366f1;
|
|
289
|
+
box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.2);
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
.tether-welcome__input::placeholder {
|
|
293
|
+
color: #71717a;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
.tether-welcome__button {
|
|
297
|
+
padding: 0.75rem 1.5rem;
|
|
298
|
+
background: linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%);
|
|
299
|
+
border: none;
|
|
300
|
+
border-radius: 0.5rem;
|
|
301
|
+
color: white;
|
|
302
|
+
font-size: 0.875rem;
|
|
303
|
+
font-weight: 500;
|
|
304
|
+
cursor: pointer;
|
|
305
|
+
transition: opacity 0.2s, transform 0.2s;
|
|
306
|
+
white-space: nowrap;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
.tether-welcome__button:hover:not(:disabled) {
|
|
310
|
+
opacity: 0.9;
|
|
311
|
+
transform: translateY(-1px);
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
.tether-welcome__button:disabled {
|
|
315
|
+
opacity: 0.5;
|
|
316
|
+
cursor: not-allowed;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
.tether-welcome__error {
|
|
320
|
+
padding: 0.75rem 1rem;
|
|
321
|
+
background: rgba(239, 68, 68, 0.1);
|
|
322
|
+
border: 1px solid rgba(239, 68, 68, 0.3);
|
|
323
|
+
border-radius: 0.5rem;
|
|
324
|
+
color: #fca5a5;
|
|
325
|
+
font-size: 0.875rem;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
.tether-welcome__posts {
|
|
329
|
+
display: flex;
|
|
330
|
+
flex-direction: column;
|
|
331
|
+
gap: 0.5rem;
|
|
332
|
+
min-height: 100px;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
.tether-welcome__loading,
|
|
336
|
+
.tether-welcome__empty {
|
|
337
|
+
color: #71717a;
|
|
338
|
+
font-size: 0.875rem;
|
|
339
|
+
text-align: center;
|
|
340
|
+
padding: 2rem;
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
.tether-welcome__post {
|
|
344
|
+
display: flex;
|
|
345
|
+
align-items: center;
|
|
346
|
+
justify-content: space-between;
|
|
347
|
+
gap: 1rem;
|
|
348
|
+
padding: 0.875rem 1rem;
|
|
349
|
+
background: rgba(0, 0, 0, 0.2);
|
|
350
|
+
border: 1px solid rgba(255, 255, 255, 0.05);
|
|
351
|
+
border-radius: 0.5rem;
|
|
352
|
+
transition: background 0.2s;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
.tether-welcome__post:hover {
|
|
356
|
+
background: rgba(0, 0, 0, 0.3);
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
.tether-welcome__post-content {
|
|
360
|
+
flex: 1;
|
|
361
|
+
min-width: 0;
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
.tether-welcome__post-content h3 {
|
|
365
|
+
font-size: 0.9375rem;
|
|
366
|
+
font-weight: 500;
|
|
367
|
+
margin: 0 0 0.25rem 0;
|
|
368
|
+
color: #f4f4f5;
|
|
369
|
+
overflow: hidden;
|
|
370
|
+
text-overflow: ellipsis;
|
|
371
|
+
white-space: nowrap;
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
.tether-welcome__post-content time {
|
|
375
|
+
font-size: 0.75rem;
|
|
376
|
+
color: #71717a;
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
.tether-welcome__post-delete {
|
|
380
|
+
flex-shrink: 0;
|
|
381
|
+
width: 32px;
|
|
382
|
+
height: 32px;
|
|
383
|
+
display: flex;
|
|
384
|
+
align-items: center;
|
|
385
|
+
justify-content: center;
|
|
386
|
+
background: transparent;
|
|
387
|
+
border: none;
|
|
388
|
+
border-radius: 0.375rem;
|
|
389
|
+
color: #71717a;
|
|
390
|
+
cursor: pointer;
|
|
391
|
+
transition: color 0.2s, background 0.2s;
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
.tether-welcome__post-delete:hover:not(:disabled) {
|
|
395
|
+
color: #ef4444;
|
|
396
|
+
background: rgba(239, 68, 68, 0.1);
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
.tether-welcome__post-delete:disabled {
|
|
400
|
+
opacity: 0.5;
|
|
401
|
+
cursor: not-allowed;
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
.tether-welcome__post-delete svg {
|
|
405
|
+
width: 18px;
|
|
406
|
+
height: 18px;
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
.tether-welcome__links {
|
|
410
|
+
display: flex;
|
|
411
|
+
justify-content: center;
|
|
412
|
+
gap: 1rem;
|
|
413
|
+
flex-wrap: wrap;
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
.tether-welcome__link {
|
|
417
|
+
display: flex;
|
|
418
|
+
align-items: center;
|
|
419
|
+
gap: 0.5rem;
|
|
420
|
+
padding: 0.625rem 1rem;
|
|
421
|
+
background: rgba(255, 255, 255, 0.05);
|
|
422
|
+
border: 1px solid rgba(255, 255, 255, 0.1);
|
|
423
|
+
border-radius: 0.5rem;
|
|
424
|
+
color: #a1a1aa;
|
|
425
|
+
text-decoration: none;
|
|
426
|
+
font-size: 0.875rem;
|
|
427
|
+
transition: background 0.2s, color 0.2s, border-color 0.2s;
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
.tether-welcome__link:hover {
|
|
431
|
+
background: rgba(255, 255, 255, 0.08);
|
|
432
|
+
color: #e4e4e7;
|
|
433
|
+
border-color: rgba(255, 255, 255, 0.15);
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
.tether-welcome__link svg {
|
|
437
|
+
width: 18px;
|
|
438
|
+
height: 18px;
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
.tether-welcome__footer {
|
|
442
|
+
text-align: center;
|
|
443
|
+
padding-top: 1rem;
|
|
444
|
+
border-top: 1px solid rgba(255, 255, 255, 0.05);
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
.tether-welcome__footer p {
|
|
448
|
+
color: #52525b;
|
|
449
|
+
font-size: 0.8125rem;
|
|
450
|
+
margin: 0;
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
.tether-welcome__footer a {
|
|
454
|
+
color: #6366f1;
|
|
455
|
+
text-decoration: none;
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
.tether-welcome__footer a:hover {
|
|
459
|
+
text-decoration: underline;
|
|
460
|
+
}
|
|
461
|
+
</style>
|
|
@@ -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
|
|
66
|
-
|
|
67
|
-
|
|
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
|
-
|
|
76
|
-
|
|
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
|
-
|
|
86
|
-
|
|
87
|
-
|
|
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
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
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
package/nuxt/runtime/plugin.ts
DELETED
|
@@ -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
|
-
});
|