@tthr/vue 0.0.85 → 0.0.87
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 +9 -7
- package/nuxt/module.ts +9 -7
- package/nuxt/runtime/server/mutation.post.js +39 -0
- package/nuxt/runtime/server/mutation.post.ts +1 -1
- package/nuxt/runtime/server/plugins/cron.js +303 -0
- package/nuxt/runtime/server/query.post.js +37 -0
- package/nuxt/runtime/server/query.post.ts +1 -1
- package/nuxt/runtime/server/utils/handler.js +318 -0
- package/nuxt/runtime/server/utils/tether.js +220 -0
- package/package.json +6 -5
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.js';
|
|
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.js';
|
|
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.
|
|
3
|
+
"version": "0.0.87",
|
|
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.
|
|
21
|
+
"import": "./nuxt/runtime/server/plugins/cron.js"
|
|
22
22
|
},
|
|
23
23
|
"./nuxt/runtime/server/utils/tether": {
|
|
24
|
-
"import": "./nuxt/runtime/server/utils/tether.
|
|
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
|
}
|