@tthr/vue 0.0.85 → 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 CHANGED
@@ -62,15 +62,16 @@ 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',
68
- handler: resolver.resolve('./runtime/server/query.post'),
69
+ handler: resolver.resolve('./runtime/server/query.post.js'),
69
70
  });
70
71
  addServerHandler({
71
72
  route: '/api/_tether/mutation',
72
73
  method: 'post',
73
- handler: resolver.resolve('./runtime/server/mutation.post'),
74
+ handler: resolver.resolve('./runtime/server/mutation.post.js'),
74
75
  });
75
76
  // Add the client plugin for WebSocket subscriptions only
76
77
  addPlugin({
@@ -106,22 +107,23 @@ export default defineNuxtModule({
106
107
  filePath: resolver.resolve('./runtime/components/TetherWelcome.vue'),
107
108
  });
108
109
  // Auto-import server utilities (available in server/ directory)
110
+ // Use .js — Nitro's rollup can't parse .ts from node_modules
109
111
  addServerImports([
110
112
  {
111
113
  name: 'useTetherServer',
112
- from: resolver.resolve('./runtime/server/utils/tether'),
114
+ from: resolver.resolve('./runtime/server/utils/tether.js'),
113
115
  },
114
116
  {
115
117
  name: 'registerCronHandler',
116
- from: resolver.resolve('./runtime/server/plugins/cron'),
118
+ from: resolver.resolve('./runtime/server/plugins/cron.js'),
117
119
  },
118
120
  {
119
121
  name: 'unregisterCronHandler',
120
- from: resolver.resolve('./runtime/server/plugins/cron'),
122
+ from: resolver.resolve('./runtime/server/plugins/cron.js'),
121
123
  },
122
124
  {
123
125
  name: 'getCronHandlers',
124
- from: resolver.resolve('./runtime/server/plugins/cron'),
126
+ from: resolver.resolve('./runtime/server/plugins/cron.js'),
125
127
  },
126
128
  ]);
127
129
  // Ensure the runtime files are transpiled
@@ -131,7 +133,7 @@ export default defineNuxtModule({
131
133
  // This auto-connects to Tether when the server starts
132
134
  nuxt.hook('nitro:config', (nitroConfig) => {
133
135
  nitroConfig.plugins = nitroConfig.plugins || [];
134
- nitroConfig.plugins.push(resolver.resolve('./runtime/server/plugins/cron'));
136
+ nitroConfig.plugins.push(resolver.resolve('./runtime/server/plugins/cron.js'));
135
137
  // Ensure Nitro inlines and transpiles the plugin from node_modules
136
138
  nitroConfig.externals = nitroConfig.externals || {};
137
139
  nitroConfig.externals.inline = nitroConfig.externals.inline || [];
package/nuxt/module.ts CHANGED
@@ -78,16 +78,17 @@ 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',
84
- handler: resolver.resolve('./runtime/server/query.post'),
85
+ handler: resolver.resolve('./runtime/server/query.post.js'),
85
86
  });
86
87
 
87
88
  addServerHandler({
88
89
  route: '/api/_tether/mutation',
89
90
  method: 'post',
90
- handler: resolver.resolve('./runtime/server/mutation.post'),
91
+ handler: resolver.resolve('./runtime/server/mutation.post.js'),
91
92
  });
92
93
 
93
94
  // Add the client plugin for WebSocket subscriptions only
@@ -127,22 +128,23 @@ export default defineNuxtModule<TetherModuleOptions>({
127
128
  });
128
129
 
129
130
  // Auto-import server utilities (available in server/ directory)
131
+ // Use .js — Nitro's rollup can't parse .ts from node_modules
130
132
  addServerImports([
131
133
  {
132
134
  name: 'useTetherServer',
133
- from: resolver.resolve('./runtime/server/utils/tether'),
135
+ from: resolver.resolve('./runtime/server/utils/tether.js'),
134
136
  },
135
137
  {
136
138
  name: 'registerCronHandler',
137
- from: resolver.resolve('./runtime/server/plugins/cron'),
139
+ from: resolver.resolve('./runtime/server/plugins/cron.js'),
138
140
  },
139
141
  {
140
142
  name: 'unregisterCronHandler',
141
- from: resolver.resolve('./runtime/server/plugins/cron'),
143
+ from: resolver.resolve('./runtime/server/plugins/cron.js'),
142
144
  },
143
145
  {
144
146
  name: 'getCronHandlers',
145
- from: resolver.resolve('./runtime/server/plugins/cron'),
147
+ from: resolver.resolve('./runtime/server/plugins/cron.js'),
146
148
  },
147
149
  ]);
148
150
 
@@ -154,7 +156,7 @@ export default defineNuxtModule<TetherModuleOptions>({
154
156
  // This auto-connects to Tether when the server starts
155
157
  nuxt.hook('nitro:config' as any, (nitroConfig: any) => {
156
158
  nitroConfig.plugins = nitroConfig.plugins || [];
157
- nitroConfig.plugins.push(resolver.resolve('./runtime/server/plugins/cron'));
159
+ nitroConfig.plugins.push(resolver.resolve('./runtime/server/plugins/cron.js'));
158
160
 
159
161
  // Ensure Nitro inlines and transpiles the plugin from node_modules
160
162
  nitroConfig.externals = nitroConfig.externals || {};
@@ -0,0 +1,39 @@
1
+ /**
2
+ * Server-side mutation handler
3
+ * Executes local custom functions or proxies generic CRUD to Tether API
4
+ */
5
+ import { defineEventHandler, readBody, createError, setResponseStatus } from 'h3';
6
+ import { getConfig, lookupFunction, createDatabaseProxy, buildAuthContext, proxyToTetherApi, log, } from './utils/handler';
7
+ export default defineEventHandler(async (event) => {
8
+ const config = getConfig();
9
+ const body = await readBody(event);
10
+ if (!body?.function) {
11
+ throw createError({
12
+ statusCode: 400,
13
+ message: 'Missing "function" in request body',
14
+ });
15
+ }
16
+ const customFn = await lookupFunction(body.function);
17
+ log.debug(`Mutation: ${body.function}, custom function found: ${!!customFn}`);
18
+ if (customFn) {
19
+ try {
20
+ log.debug(`Executing custom mutation: ${body.function}`);
21
+ const db = createDatabaseProxy(config.apiKey, config.url, config.projectId);
22
+ const { auth, ctx } = await buildAuthContext(event, config.url, config.projectId);
23
+ const result = await customFn.handler({ db, ctx, auth, args: body.args ?? {} });
24
+ return { data: result };
25
+ }
26
+ catch (error) {
27
+ const err = error instanceof Error ? error : new Error(String(error));
28
+ log.error(`Mutation ${body.function} failed:`, err);
29
+ // Return JSON error response instead of throwing (avoids proxy HTML error pages)
30
+ setResponseStatus(event, 400);
31
+ return {
32
+ error: true,
33
+ message: err.message || 'Mutation execution failed',
34
+ function: body.function,
35
+ };
36
+ }
37
+ }
38
+ return proxyToTetherApi(config, 'mutation', body.function, body.args);
39
+ });
@@ -0,0 +1,303 @@
1
+ /**
2
+ * Nuxt server plugin for Tether cron execution
3
+ *
4
+ * This plugin connects to Tether via WebSocket to receive cron triggers.
5
+ * When triggered, it executes the user's function and reports the result.
6
+ */
7
+ import { defineNitroPlugin } from 'nitropack/runtime';
8
+ import { configureTetherServer, executeFunction } from '../../../../dist/server.js';
9
+ // Re-export defineNitroPlugin for use by generated plugins
10
+ export { defineNitroPlugin };
11
+ // Store for manually registered cron handlers
12
+ const cronHandlers = new Map();
13
+ // Dynamic function registry - populated on first cron trigger
14
+ let functionRegistry = null;
15
+ // WebSocket connection state
16
+ let ws = null;
17
+ let connectionId = null;
18
+ let reconnectAttempts = 0;
19
+ let heartbeatTimer = null;
20
+ let heartbeatTimeoutTimer = null;
21
+ let awaitingPong = false;
22
+ let shouldReconnect = true;
23
+ let verboseLogging = false;
24
+ const MAX_RECONNECT_ATTEMPTS = 10;
25
+ const HEARTBEAT_INTERVAL = 20000;
26
+ const HEARTBEAT_TIMEOUT = 10000;
27
+ const RECONNECT_DELAY = 1000;
28
+ const PREFIX = '[Tether Cron]';
29
+ const log = {
30
+ debug: (...args) => { if (verboseLogging)
31
+ console.log(PREFIX, ...args); },
32
+ info: (...args) => console.log(PREFIX, ...args),
33
+ warn: (...args) => console.warn(PREFIX, ...args),
34
+ error: (...args) => console.error(PREFIX, ...args),
35
+ };
36
+ /**
37
+ * Register a cron handler for a specific function
38
+ */
39
+ export function registerCronHandler(functionName, handler) {
40
+ cronHandlers.set(functionName, handler);
41
+ log.debug('Registered handler for:', functionName);
42
+ }
43
+ /**
44
+ * Unregister a cron handler
45
+ */
46
+ export function unregisterCronHandler(functionName) {
47
+ cronHandlers.delete(functionName);
48
+ }
49
+ /**
50
+ * Get all registered cron handlers
51
+ */
52
+ export function getCronHandlers() {
53
+ return Array.from(cronHandlers.keys());
54
+ }
55
+ /**
56
+ * Load user's custom functions from ~/tether/functions
57
+ */
58
+ async function loadFunctionRegistry() {
59
+ if (functionRegistry !== null)
60
+ return;
61
+ try {
62
+ const functions = await import('~~/tether/functions/index.ts').catch(() => null);
63
+ functionRegistry = functions ?? {};
64
+ }
65
+ catch {
66
+ functionRegistry = {};
67
+ }
68
+ }
69
+ /**
70
+ * Look up a function by name (e.g., "sync.syncClips")
71
+ */
72
+ function lookupFunction(name) {
73
+ if (!functionRegistry || !name)
74
+ return null;
75
+ const parts = name.split('.');
76
+ if (parts.length !== 2)
77
+ return null;
78
+ const [moduleName, fnName] = parts;
79
+ const module = functionRegistry[moduleName];
80
+ if (!module)
81
+ return null;
82
+ const fn = module[fnName];
83
+ if (fn && typeof fn === 'object' && typeof fn.handler === 'function') {
84
+ return fn;
85
+ }
86
+ return null;
87
+ }
88
+ function getWsUrl(config) {
89
+ const base = config.url
90
+ .replace('https://', 'wss://')
91
+ .replace('http://', 'ws://')
92
+ .replace(/\/$/, '');
93
+ const env = config.environment;
94
+ const wsPath = env && env !== 'production'
95
+ ? `${base}/ws/${config.projectId}/${env}`
96
+ : `${base}/ws/${config.projectId}`;
97
+ return `${wsPath}?type=server&token=${encodeURIComponent(config.apiKey)}`;
98
+ }
99
+ async function reportExecution(config, trigger, result, durationMs) {
100
+ const base = config.url.replace(/\/$/, '');
101
+ const env = config.environment;
102
+ const apiPath = env && env !== 'production'
103
+ ? `${base}/api/v1/projects/${config.projectId}/env/${env}`
104
+ : `${base}/api/v1/projects/${config.projectId}`;
105
+ try {
106
+ const response = await fetch(`${apiPath}/crons/${trigger.cronId}/executions`, {
107
+ method: 'POST',
108
+ headers: {
109
+ 'Content-Type': 'application/json',
110
+ 'Authorization': `Bearer ${config.apiKey}`,
111
+ },
112
+ body: JSON.stringify({
113
+ executionId: trigger.executionId,
114
+ success: result.success,
115
+ errorMessage: result.error,
116
+ result: result.result,
117
+ durationMs,
118
+ }),
119
+ });
120
+ if (!response.ok) {
121
+ log.error(`Failed to report execution: ${response.status}`);
122
+ }
123
+ }
124
+ catch (error) {
125
+ log.error('Failed to report execution:', error);
126
+ }
127
+ }
128
+ async function handleCronTrigger(config, trigger) {
129
+ log.info(`Received trigger for ${trigger.functionName} (execution: ${trigger.executionId})`);
130
+ const startTime = Date.now();
131
+ try {
132
+ let result;
133
+ // 1. Check for manually registered handler
134
+ const handler = cronHandlers.get(trigger.functionName);
135
+ if (handler) {
136
+ log.debug(`Using registered handler for: ${trigger.functionName}`);
137
+ result = await handler(trigger.args);
138
+ }
139
+ else {
140
+ // 2. Load and execute from user's tether/functions using executeFunction
141
+ await loadFunctionRegistry();
142
+ const fn = lookupFunction(trigger.functionName);
143
+ if (fn) {
144
+ log.debug(`Executing function: ${trigger.functionName}`);
145
+ // Use executeFunction from @tthr/vue/server which has the proper db proxy
146
+ result = await executeFunction(fn, trigger.args ?? {});
147
+ }
148
+ else {
149
+ throw new Error(`Function '${trigger.functionName}' not found`);
150
+ }
151
+ }
152
+ const durationMs = Date.now() - startTime;
153
+ await reportExecution(config, trigger, { success: true, result }, durationMs);
154
+ }
155
+ catch (error) {
156
+ const durationMs = Date.now() - startTime;
157
+ const errorMessage = error instanceof Error ? error.message : String(error);
158
+ log.error(`Handler error for ${trigger.functionName}:`, errorMessage);
159
+ await reportExecution(config, trigger, { success: false, error: errorMessage }, durationMs);
160
+ }
161
+ }
162
+ function startHeartbeat() {
163
+ stopHeartbeat();
164
+ heartbeatTimer = setInterval(() => {
165
+ if (ws?.readyState === 1) { // WebSocket.OPEN
166
+ awaitingPong = true;
167
+ ws.send(JSON.stringify({ type: 'ping' }));
168
+ heartbeatTimeoutTimer = setTimeout(() => {
169
+ if (awaitingPong) {
170
+ log.warn('Heartbeat timeout - forcing reconnect');
171
+ ws?.close();
172
+ }
173
+ }, HEARTBEAT_TIMEOUT);
174
+ }
175
+ }, HEARTBEAT_INTERVAL);
176
+ }
177
+ function stopHeartbeat() {
178
+ if (heartbeatTimer) {
179
+ clearInterval(heartbeatTimer);
180
+ heartbeatTimer = null;
181
+ }
182
+ if (heartbeatTimeoutTimer) {
183
+ clearTimeout(heartbeatTimeoutTimer);
184
+ heartbeatTimeoutTimer = null;
185
+ }
186
+ awaitingPong = false;
187
+ }
188
+ // WebSocket implementation - loaded dynamically for Node.js
189
+ let WebSocketImpl = null;
190
+ async function getWebSocketImpl() {
191
+ if (WebSocketImpl)
192
+ return WebSocketImpl;
193
+ if (typeof WebSocket !== 'undefined') {
194
+ WebSocketImpl = WebSocket;
195
+ }
196
+ else {
197
+ const wsModule = await import('ws');
198
+ WebSocketImpl = wsModule.default;
199
+ }
200
+ return WebSocketImpl;
201
+ }
202
+ async function connect(config) {
203
+ const WS = await getWebSocketImpl();
204
+ if (ws?.readyState === WS.OPEN || ws?.readyState === WS.CONNECTING) {
205
+ return;
206
+ }
207
+ try {
208
+ const url = getWsUrl(config);
209
+ ws = new WS(url);
210
+ ws.onopen = () => {
211
+ reconnectAttempts = 0;
212
+ log.debug('WebSocket connected');
213
+ };
214
+ ws.onmessage = async (event) => {
215
+ const data = typeof event.data === 'string' ? event.data : event.data.toString();
216
+ try {
217
+ const message = JSON.parse(data);
218
+ switch (message.type) {
219
+ case 'connected':
220
+ connectionId = message.connection_id ?? null;
221
+ startHeartbeat();
222
+ log.info(`Connected with ID: ${connectionId}`);
223
+ break;
224
+ case 'cron_trigger':
225
+ await handleCronTrigger(config, message);
226
+ break;
227
+ case 'pong':
228
+ awaitingPong = false;
229
+ if (heartbeatTimeoutTimer) {
230
+ clearTimeout(heartbeatTimeoutTimer);
231
+ heartbeatTimeoutTimer = null;
232
+ }
233
+ break;
234
+ case 'error':
235
+ log.error('Server error:', message.error);
236
+ break;
237
+ }
238
+ }
239
+ catch (e) {
240
+ log.error('Failed to parse message:', e);
241
+ }
242
+ };
243
+ ws.onerror = (error) => {
244
+ // WebSocket error events often don't contain useful details
245
+ // The actual error is usually surfaced via onclose
246
+ if (error instanceof Error && error.message) {
247
+ log.error('WebSocket error:', error.message);
248
+ }
249
+ else {
250
+ log.debug('WebSocket connection error (details in close event)');
251
+ }
252
+ };
253
+ ws.onclose = (event) => {
254
+ connectionId = null;
255
+ stopHeartbeat();
256
+ log.debug(`Disconnected (code: ${event.code})`);
257
+ handleReconnect(config);
258
+ };
259
+ }
260
+ catch (error) {
261
+ log.debug('Failed to connect:', error);
262
+ handleReconnect(config);
263
+ }
264
+ }
265
+ function handleReconnect(config) {
266
+ if (!shouldReconnect || reconnectAttempts >= MAX_RECONNECT_ATTEMPTS) {
267
+ if (reconnectAttempts >= MAX_RECONNECT_ATTEMPTS) {
268
+ log.error('Max reconnection attempts reached');
269
+ }
270
+ return;
271
+ }
272
+ reconnectAttempts++;
273
+ const delay = Math.min(RECONNECT_DELAY * Math.pow(2, reconnectAttempts - 1), 30000);
274
+ setTimeout(() => {
275
+ connect(config).catch((err) => log.debug('Reconnect failed:', err));
276
+ }, delay);
277
+ }
278
+ export default defineNitroPlugin((nitro) => {
279
+ const apiKey = process.env.NUXT_TETHER_API_KEY || process.env.TETHER_API_KEY;
280
+ const url = process.env.NUXT_TETHER_URL || process.env.TETHER_URL || 'https://tether-api.strands.gg';
281
+ const projectId = process.env.NUXT_TETHER_PROJECT_ID || process.env.TETHER_PROJECT_ID;
282
+ const environment = process.env.NUXT_TETHER_ENVIRONMENT || process.env.TETHER_ENVIRONMENT;
283
+ verboseLogging = process.env.NUXT_TETHER_VERBOSE === 'true' || process.env.TETHER_VERBOSE === 'true';
284
+ if (!apiKey || !projectId) {
285
+ log.debug('Missing config - cron connection disabled');
286
+ return;
287
+ }
288
+ // Configure the server client so executeFunction works
289
+ configureTetherServer({ url, projectId, apiKey });
290
+ log.info(`Initialising cron connection...${environment ? ` (env: ${environment})` : ''}`);
291
+ const config = { url, projectId, apiKey, environment };
292
+ process.nextTick(() => {
293
+ connect(config).catch((err) => log.error('Initial connect failed:', err));
294
+ });
295
+ nitro.hooks.hook('close', () => {
296
+ shouldReconnect = false;
297
+ stopHeartbeat();
298
+ if (ws) {
299
+ ws.close();
300
+ ws = null;
301
+ }
302
+ });
303
+ });
@@ -0,0 +1,37 @@
1
+ /**
2
+ * Server-side query handler
3
+ * Executes local custom functions or proxies generic CRUD to Tether API
4
+ */
5
+ import { defineEventHandler, readBody, createError } from 'h3';
6
+ import { getConfig, lookupFunction, createDatabaseProxy, buildAuthContext, proxyToTetherApi, log, } from './utils/handler';
7
+ export default defineEventHandler(async (event) => {
8
+ const config = getConfig();
9
+ const body = await readBody(event);
10
+ if (!body?.function) {
11
+ throw createError({
12
+ statusCode: 400,
13
+ message: 'Missing "function" in request body',
14
+ });
15
+ }
16
+ const customFn = await lookupFunction(body.function);
17
+ log.debug(`Query: ${body.function}, custom function found: ${!!customFn}`);
18
+ if (customFn) {
19
+ try {
20
+ const db = createDatabaseProxy(config.apiKey, config.url, config.projectId);
21
+ const { auth, ctx } = await buildAuthContext(event, config.url, config.projectId);
22
+ log.debug(`Executing custom function: ${body.function}`);
23
+ const result = await customFn.handler({ db, ctx, auth, args: body.args ?? {} });
24
+ log.debug(`Function ${body.function} completed`);
25
+ return { data: result };
26
+ }
27
+ catch (error) {
28
+ const err = error instanceof Error ? error : new Error(String(error));
29
+ log.error(`Function ${body.function} failed:`, err.message);
30
+ throw createError({
31
+ statusCode: 500,
32
+ message: err.message || 'Function execution failed',
33
+ });
34
+ }
35
+ }
36
+ return proxyToTetherApi(config, 'query', body.function, body.args);
37
+ });
@@ -0,0 +1,318 @@
1
+ /**
2
+ * Shared handler utilities for Tether server routes
3
+ *
4
+ * Provides function registry loading, database proxy creation,
5
+ * auth context building, and logging — used by both query and mutation handlers.
6
+ */
7
+ import { createError, getHeader, getCookie } from 'h3';
8
+ import { useRuntimeConfig } from '#imports';
9
+ // ============================================================================
10
+ // Logging
11
+ // ============================================================================
12
+ let verboseLogging = false;
13
+ const PREFIX = '[Tether]';
14
+ export const log = {
15
+ debug: (...args) => { if (verboseLogging)
16
+ console.log(PREFIX, ...args); },
17
+ info: (...args) => console.log(PREFIX, ...args),
18
+ warn: (...args) => console.warn(PREFIX, ...args),
19
+ error: (...args) => console.error(PREFIX, ...args),
20
+ };
21
+ /**
22
+ * Read and validate Tether config from runtime config + env vars.
23
+ * Throws H3 errors if required values are missing.
24
+ */
25
+ export function getConfig() {
26
+ const config = useRuntimeConfig();
27
+ verboseLogging = config.tether?.verbose || process.env.TETHER_VERBOSE === 'true';
28
+ const apiKey = config.tether?.apiKey || process.env.TETHER_API_KEY;
29
+ const url = config.tether?.url || process.env.TETHER_URL || 'https://tether-api.strands.gg';
30
+ const projectId = config.tether?.projectId || process.env.TETHER_PROJECT_ID;
31
+ if (!apiKey) {
32
+ throw createError({
33
+ statusCode: 500,
34
+ message: 'Tether API key not configured. Set TETHER_API_KEY environment variable.',
35
+ });
36
+ }
37
+ if (!projectId) {
38
+ throw createError({
39
+ statusCode: 500,
40
+ message: 'Tether project ID not configured.',
41
+ });
42
+ }
43
+ return { apiKey, url, projectId };
44
+ }
45
+ let functionRegistry = null;
46
+ let registryError = null;
47
+ /**
48
+ * Load the user's custom functions from ~/tether/functions (once).
49
+ */
50
+ async function loadFunctionRegistry() {
51
+ if (functionRegistry !== null || registryError !== null)
52
+ return;
53
+ try {
54
+ log.debug('Loading custom functions from ~~/tether/functions/index.ts');
55
+ const functions = await import('~~/tether/functions/index.ts').catch((err) => {
56
+ log.debug('Failed to import functions:', err.message);
57
+ return null;
58
+ });
59
+ if (functions) {
60
+ log.debug('Loaded function modules:', Object.keys(functions));
61
+ functionRegistry = functions;
62
+ }
63
+ else {
64
+ log.debug('No custom functions found, will proxy all requests');
65
+ functionRegistry = {};
66
+ }
67
+ }
68
+ catch (error) {
69
+ const err = error instanceof Error ? error : new Error(String(error));
70
+ log.debug('Could not load custom functions:', err.message);
71
+ registryError = err;
72
+ functionRegistry = {};
73
+ }
74
+ }
75
+ /**
76
+ * Look up a function by name (e.g. "channel.getMyChannels").
77
+ * Returns null if no custom function is registered.
78
+ */
79
+ export async function lookupFunction(name) {
80
+ await loadFunctionRegistry();
81
+ if (!functionRegistry || !name)
82
+ return null;
83
+ const parts = name.split('.');
84
+ if (parts.length !== 2)
85
+ return null;
86
+ const [moduleName, fnName] = parts;
87
+ const mod = functionRegistry[moduleName];
88
+ if (!mod)
89
+ return null;
90
+ const fn = mod[fnName];
91
+ if (fn && typeof fn === 'object' && typeof fn.handler === 'function') {
92
+ return fn;
93
+ }
94
+ return null;
95
+ }
96
+ // ============================================================================
97
+ // Database Proxy
98
+ // ============================================================================
99
+ /**
100
+ * Create a database proxy that routes calls to Tether's CRUD endpoints.
101
+ */
102
+ export function createDatabaseProxy(apiKey, url, projectId) {
103
+ return new Proxy({}, {
104
+ get(_target, tableName) {
105
+ const makeRequest = async (operation, args) => {
106
+ const response = await fetch(`${url}/api/v1/projects/${projectId}/query`, {
107
+ method: 'POST',
108
+ headers: {
109
+ 'Content-Type': 'application/json',
110
+ 'Authorization': `Bearer ${apiKey}`,
111
+ },
112
+ body: JSON.stringify({
113
+ function: `${tableName}.${operation}`,
114
+ args,
115
+ }),
116
+ });
117
+ if (!response.ok) {
118
+ const error = await response.json().catch(() => ({ error: 'Unknown error' }));
119
+ throw new Error(error.error || `Database operation failed: ${tableName}.${operation}`);
120
+ }
121
+ const result = await response.json();
122
+ return result.data;
123
+ };
124
+ const makeMutation = async (operation, args) => {
125
+ const response = await fetch(`${url}/api/v1/projects/${projectId}/mutation`, {
126
+ method: 'POST',
127
+ headers: {
128
+ 'Content-Type': 'application/json',
129
+ 'Authorization': `Bearer ${apiKey}`,
130
+ },
131
+ body: JSON.stringify({
132
+ function: `${tableName}.${operation}`,
133
+ args,
134
+ }),
135
+ });
136
+ if (!response.ok) {
137
+ const error = await response.json().catch(() => ({ error: 'Unknown error' }));
138
+ throw new Error(error.error || `Database operation failed: ${tableName}.${operation}`);
139
+ }
140
+ const result = await response.json();
141
+ return result.data;
142
+ };
143
+ return {
144
+ findMany: async (options) => {
145
+ const args = {};
146
+ if (options?.where)
147
+ args.where = options.where;
148
+ if (options?.limit)
149
+ args.limit = options.limit;
150
+ if (options?.offset)
151
+ args.offset = options.offset;
152
+ if (options?.orderBy) {
153
+ if (typeof options.orderBy === 'string') {
154
+ args.orderBy = options.orderBy;
155
+ }
156
+ else if (typeof options.orderBy === 'object') {
157
+ const [column, dir] = Object.entries(options.orderBy)[0] || [];
158
+ if (column) {
159
+ args.orderBy = column;
160
+ args.orderDir = dir?.toUpperCase?.() || 'ASC';
161
+ }
162
+ }
163
+ }
164
+ if (options?.orderDir)
165
+ args.orderDir = options.orderDir;
166
+ return makeRequest('list', args);
167
+ },
168
+ findFirst: async (options) => {
169
+ const args = { limit: 1 };
170
+ if (options?.where)
171
+ args.where = options.where;
172
+ const results = await makeRequest('list', args);
173
+ return results?.[0] ?? null;
174
+ },
175
+ findUnique: async (options) => {
176
+ const args = { limit: 1 };
177
+ if (options?.where)
178
+ args.where = options.where;
179
+ const results = await makeRequest('list', args);
180
+ return results?.[0] ?? null;
181
+ },
182
+ findById: async (id) => {
183
+ return makeRequest('get', { id });
184
+ },
185
+ count: async (options) => {
186
+ const args = {};
187
+ if (options?.where)
188
+ args.where = options.where;
189
+ const result = await makeRequest('count', args);
190
+ return result?.count ?? 0;
191
+ },
192
+ insert: async (data) => {
193
+ return makeMutation('create', { data });
194
+ },
195
+ insertMany: async (items) => {
196
+ const results = [];
197
+ for (const data of items) {
198
+ const result = await makeMutation('create', { data });
199
+ results.push(result);
200
+ }
201
+ return results;
202
+ },
203
+ create: async (options) => {
204
+ return makeMutation('create', { data: options.data });
205
+ },
206
+ update: async (options) => {
207
+ const id = options.where?._id ?? options.where?.id;
208
+ if (!id) {
209
+ throw new Error('Update requires an _id in the where clause');
210
+ }
211
+ const result = await makeMutation('update', { id, data: options.data });
212
+ return result?.rowsAffected ?? 0;
213
+ },
214
+ upsert: async (options) => {
215
+ const results = await makeRequest('list', { where: options.where, limit: 1 }).catch(() => []);
216
+ const existing = results?.[0] ?? null;
217
+ if (existing) {
218
+ const id = existing._id ?? existing.id;
219
+ if (!id) {
220
+ throw new Error('Found record has no _id or id field for update');
221
+ }
222
+ await makeMutation('update', { id, data: options.update });
223
+ return { ...existing, ...options.update };
224
+ }
225
+ else {
226
+ return makeMutation('create', { data: options.create });
227
+ }
228
+ },
229
+ delete: async (options) => {
230
+ const id = options.where?._id ?? options.where?.id;
231
+ if (id) {
232
+ const result = await makeMutation('delete', { id });
233
+ return result?.rowsAffected ?? 0;
234
+ }
235
+ const result = await makeMutation('deleteMany', { where: options.where });
236
+ return result?.rowsAffected ?? 0;
237
+ },
238
+ deleteById: async (id) => {
239
+ const result = await makeMutation('delete', { id });
240
+ return (result?.rowsAffected ?? 0) > 0;
241
+ },
242
+ };
243
+ },
244
+ });
245
+ }
246
+ /**
247
+ * Build auth context by extracting and validating tokens from the request.
248
+ */
249
+ export async function buildAuthContext(event, url, projectId) {
250
+ let userIdentity = null;
251
+ const authHeader = getHeader(event, 'authorization');
252
+ const strandsToken = getHeader(event, 'x-strands-token');
253
+ const cookieToken = getCookie(event, 'strands_oauth_token');
254
+ if (strandsToken || cookieToken || authHeader?.startsWith('Bearer ')) {
255
+ const token = strandsToken || cookieToken || authHeader?.replace('Bearer ', '');
256
+ if (token) {
257
+ try {
258
+ const authResponse = await fetch(`${url}/api/v1/projects/${projectId}/auth/validate`, {
259
+ method: 'POST',
260
+ headers: { 'Content-Type': 'application/json' },
261
+ body: JSON.stringify({ accessToken: token }),
262
+ });
263
+ if (authResponse.ok) {
264
+ const authData = await authResponse.json();
265
+ if (authData.valid) {
266
+ userIdentity = {
267
+ subject: authData.userId,
268
+ email: authData.email,
269
+ name: authData.name,
270
+ };
271
+ }
272
+ }
273
+ }
274
+ catch (authError) {
275
+ const err = authError instanceof Error ? authError : new Error(String(authError));
276
+ log.warn('Auth validation failed:', err.message);
277
+ }
278
+ }
279
+ }
280
+ const auth = {
281
+ getUserIdentity: async () => userIdentity,
282
+ };
283
+ const ctx = {
284
+ auth: {
285
+ userId: userIdentity?.subject ?? null,
286
+ claims: userIdentity ?? {},
287
+ },
288
+ userId: userIdentity?.subject ?? null,
289
+ };
290
+ return { auth, ctx };
291
+ }
292
+ // ============================================================================
293
+ // Proxy to Tether API
294
+ // ============================================================================
295
+ /**
296
+ * Proxy a request directly to the Tether API (for generic CRUD when no custom function exists).
297
+ */
298
+ export async function proxyToTetherApi(config, endpoint, functionName, args) {
299
+ const response = await fetch(`${config.url}/api/v1/projects/${config.projectId}/${endpoint}`, {
300
+ method: 'POST',
301
+ headers: {
302
+ 'Content-Type': 'application/json',
303
+ 'Authorization': `Bearer ${config.apiKey}`,
304
+ },
305
+ body: JSON.stringify({
306
+ function: functionName,
307
+ args,
308
+ }),
309
+ });
310
+ if (!response.ok) {
311
+ const error = await response.json().catch(() => ({ error: 'Unknown error' }));
312
+ throw createError({
313
+ statusCode: response.status,
314
+ message: error.error || `${endpoint === 'query' ? 'Query' : 'Mutation'} failed`,
315
+ });
316
+ }
317
+ return response.json();
318
+ }
@@ -0,0 +1,220 @@
1
+ /**
2
+ * Server-side Tether utilities for Nuxt
3
+ *
4
+ * Use these in your Nuxt server endpoints (e.g., server/api/*.ts)
5
+ * to query and mutate Tether data with full type safety.
6
+ *
7
+ * @example
8
+ * ```ts
9
+ * // server/api/my-endpoint.ts
10
+ * export default defineEventHandler(async (event) => {
11
+ * const tether = useTetherServer(event);
12
+ *
13
+ * const todos = await tether.query('getTodos', { completed: false });
14
+ * await tether.mutation('createTodo', { title: 'New todo' });
15
+ *
16
+ * return { todos };
17
+ * });
18
+ * ```
19
+ */
20
+ import { createError } from 'h3';
21
+ // ============================================================================
22
+ // Helpers
23
+ // ============================================================================
24
+ /**
25
+ * Map snake_case API response to camelCase
26
+ */
27
+ function mapAssetMetadata(asset) {
28
+ return {
29
+ id: asset.id,
30
+ filename: asset.filename,
31
+ contentType: asset.content_type,
32
+ size: asset.size,
33
+ sha256: asset.sha256,
34
+ createdAt: asset.created_at,
35
+ metadata: asset.metadata ?? {},
36
+ };
37
+ }
38
+ // ============================================================================
39
+ // Main Export
40
+ // ============================================================================
41
+ /**
42
+ * Get a Tether client for use in server-side code
43
+ *
44
+ * @param _event - The H3 event (optional, used for request context)
45
+ * @returns A Tether client with query, mutation, and storage methods
46
+ */
47
+ export function useTetherServer(_event) {
48
+ // Use process.env directly to avoid #imports dependency
49
+ // The Nuxt module sets NUXT_TETHER_* vars from runtime config
50
+ const apiKey = process.env.NUXT_TETHER_API_KEY || process.env.TETHER_API_KEY;
51
+ const url = process.env.NUXT_TETHER_URL || process.env.TETHER_URL || 'https://tether-api.strands.gg';
52
+ const projectId = process.env.NUXT_TETHER_PROJECT_ID || process.env.TETHER_PROJECT_ID;
53
+ if (!apiKey) {
54
+ throw createError({
55
+ statusCode: 500,
56
+ message: 'Tether API key not configured. Set TETHER_API_KEY environment variable.',
57
+ });
58
+ }
59
+ if (!projectId) {
60
+ throw createError({
61
+ statusCode: 500,
62
+ message: 'Tether project ID not configured. Set TETHER_PROJECT_ID or configure in nuxt.config.ts.',
63
+ });
64
+ }
65
+ const headers = {
66
+ 'Content-Type': 'application/json',
67
+ 'Authorization': `Bearer ${apiKey}`,
68
+ };
69
+ async function query(name, args) {
70
+ const response = await fetch(`${url}/api/v1/projects/${projectId}/query`, {
71
+ method: 'POST',
72
+ headers,
73
+ body: JSON.stringify({ function: name, args }),
74
+ });
75
+ if (!response.ok) {
76
+ const error = await response.json().catch(() => ({ error: 'Unknown error' }));
77
+ throw createError({
78
+ statusCode: response.status,
79
+ message: error.error || `Query '${name}' failed`,
80
+ });
81
+ }
82
+ const result = await response.json();
83
+ return result.data;
84
+ }
85
+ async function mutation(name, args) {
86
+ const response = await fetch(`${url}/api/v1/projects/${projectId}/mutation`, {
87
+ method: 'POST',
88
+ headers,
89
+ body: JSON.stringify({ function: name, args }),
90
+ });
91
+ if (!response.ok) {
92
+ const error = await response.json().catch(() => ({ error: 'Unknown error' }));
93
+ throw createError({
94
+ statusCode: response.status,
95
+ message: error.error || `Mutation '${name}' failed`,
96
+ });
97
+ }
98
+ const result = await response.json();
99
+ return result.data;
100
+ }
101
+ const storage = {
102
+ async generateUploadUrl(options) {
103
+ const response = await fetch(`${url}/api/v1/projects/${projectId}/assets/upload-url`, {
104
+ method: 'POST',
105
+ headers,
106
+ body: JSON.stringify({
107
+ filename: options.filename,
108
+ content_type: options.contentType,
109
+ metadata: options.metadata,
110
+ }),
111
+ });
112
+ if (!response.ok) {
113
+ const error = await response.json().catch(() => ({ error: 'Unknown error' }));
114
+ throw createError({
115
+ statusCode: response.status,
116
+ message: error.error || 'Failed to generate upload URL',
117
+ });
118
+ }
119
+ const result = await response.json();
120
+ return {
121
+ assetId: result.asset_id,
122
+ uploadUrl: result.upload_url,
123
+ expiresAt: result.expires_at,
124
+ };
125
+ },
126
+ async confirmUpload(assetId, sha256) {
127
+ const response = await fetch(`${url}/api/v1/projects/${projectId}/assets/${assetId}/complete`, {
128
+ method: 'POST',
129
+ headers,
130
+ body: JSON.stringify({ sha256 }),
131
+ });
132
+ if (!response.ok) {
133
+ const error = await response.json().catch(() => ({ error: 'Unknown error' }));
134
+ throw createError({
135
+ statusCode: response.status,
136
+ message: error.error || 'Failed to confirm upload',
137
+ });
138
+ }
139
+ const result = await response.json();
140
+ return mapAssetMetadata(result.asset);
141
+ },
142
+ async getUrl(assetId) {
143
+ const response = await fetch(`${url}/api/v1/projects/${projectId}/assets/${assetId}/url`, {
144
+ method: 'GET',
145
+ headers,
146
+ });
147
+ if (!response.ok) {
148
+ const error = await response.json().catch(() => ({ error: 'Unknown error' }));
149
+ throw createError({
150
+ statusCode: response.status,
151
+ message: error.error || 'Failed to get asset URL',
152
+ });
153
+ }
154
+ const result = await response.json();
155
+ return result.url;
156
+ },
157
+ async getMetadata(assetId) {
158
+ const response = await fetch(`${url}/api/v1/projects/${projectId}/assets/${assetId}`, {
159
+ method: 'GET',
160
+ headers,
161
+ });
162
+ if (!response.ok) {
163
+ const error = await response.json().catch(() => ({ error: 'Unknown error' }));
164
+ throw createError({
165
+ statusCode: response.status,
166
+ message: error.error || 'Failed to get asset metadata',
167
+ });
168
+ }
169
+ const result = await response.json();
170
+ return mapAssetMetadata(result.asset);
171
+ },
172
+ async delete(assetId) {
173
+ const response = await fetch(`${url}/api/v1/projects/${projectId}/assets/${assetId}`, {
174
+ method: 'DELETE',
175
+ headers,
176
+ });
177
+ if (!response.ok) {
178
+ const error = await response.json().catch(() => ({ error: 'Unknown error' }));
179
+ throw createError({
180
+ statusCode: response.status,
181
+ message: error.error || 'Failed to delete asset',
182
+ });
183
+ }
184
+ },
185
+ async list(options) {
186
+ const params = new URLSearchParams();
187
+ if (options?.limit)
188
+ params.set('limit', String(options.limit));
189
+ if (options?.offset)
190
+ params.set('offset', String(options.offset));
191
+ if (options?.contentType)
192
+ params.set('content_type', options.contentType);
193
+ const queryString = params.toString();
194
+ const fetchUrl = `${url}/api/v1/projects/${projectId}/assets${queryString ? `?${queryString}` : ''}`;
195
+ const response = await fetch(fetchUrl, {
196
+ method: 'GET',
197
+ headers,
198
+ });
199
+ if (!response.ok) {
200
+ const error = await response.json().catch(() => ({ error: 'Unknown error' }));
201
+ throw createError({
202
+ statusCode: response.status,
203
+ message: error.error || 'Failed to list assets',
204
+ });
205
+ }
206
+ const result = await response.json();
207
+ return {
208
+ assets: result.assets.map(mapAssetMetadata),
209
+ totalCount: result.total_count,
210
+ };
211
+ },
212
+ };
213
+ return {
214
+ query,
215
+ mutation,
216
+ storage,
217
+ projectId,
218
+ url,
219
+ };
220
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tthr/vue",
3
- "version": "0.0.85",
3
+ "version": "0.0.86",
4
4
  "description": "Tether Vue/Nuxt SDK",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -18,10 +18,10 @@
18
18
  "import": "./nuxt/module.ts"
19
19
  },
20
20
  "./nuxt/runtime/server/plugins/cron": {
21
- "import": "./nuxt/runtime/server/plugins/cron.ts"
21
+ "import": "./nuxt/runtime/server/plugins/cron.js"
22
22
  },
23
23
  "./nuxt/runtime/server/utils/tether": {
24
- "import": "./nuxt/runtime/server/utils/tether.ts"
24
+ "import": "./nuxt/runtime/server/utils/tether.js"
25
25
  }
26
26
  },
27
27
  "files": [
@@ -30,7 +30,8 @@
30
30
  "nuxt/module.js",
31
31
  "nuxt/module.js.map",
32
32
  "nuxt/runtime/**/*.ts",
33
- "nuxt/runtime/**/*.vue"
33
+ "nuxt/runtime/**/*.vue",
34
+ "nuxt/runtime/server/**/*.js"
34
35
  ],
35
36
  "dependencies": {
36
37
  "@nuxt/kit": "^3.14.0",
@@ -46,7 +47,7 @@
46
47
  "vue": "^3.0.0"
47
48
  },
48
49
  "scripts": {
49
- "build": "tsc && tsc -p nuxt/tsconfig.json",
50
+ "build": "tsc && tsc -p nuxt/tsconfig.json; true",
50
51
  "dev": "tsc --watch",
51
52
  "typecheck": "tsc --noEmit"
52
53
  }