create-surf-app 0.1.13 → 0.1.15
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/dist/{chunk-DJ2JRBGI.js → chunk-53EUQV7R.js} +3 -2
- package/dist/cli.js +3 -1
- package/dist/index.js +1 -1
- package/dist/templates/default/backend/db/schema.js +8 -20
- package/dist/templates/default/backend/eslint.config.mjs +1 -1
- package/dist/templates/default/backend/package.json +2 -16
- package/dist/templates/default/backend/routes/.gitkeep +1 -0
- package/dist/templates/default/backend/server.js +2 -444
- package/dist/templates/mini-surf/CLAUDE.md +88 -0
- package/dist/templates/mini-surf/backend/db/schema.js +8 -0
- package/dist/templates/mini-surf/backend/eslint.config.mjs +21 -0
- package/dist/templates/mini-surf/backend/package.json +12 -0
- package/dist/templates/mini-surf/backend/routes/.gitkeep +0 -0
- package/dist/templates/mini-surf/backend/server.js +2 -0
- package/dist/templates/mini-surf/frontend/components.json +17 -0
- package/dist/templates/mini-surf/frontend/eslint.config.js +42 -0
- package/dist/templates/mini-surf/frontend/index.html +43 -0
- package/dist/templates/mini-surf/frontend/package.json +73 -0
- package/dist/templates/mini-surf/frontend/src/App.tsx +18 -0
- package/dist/templates/mini-surf/frontend/src/db/schema.ts +10 -0
- package/dist/templates/mini-surf/frontend/src/entry-client.tsx +109 -0
- package/dist/templates/mini-surf/frontend/src/entry-server.tsx +13 -0
- package/dist/templates/mini-surf/frontend/src/index.css +4 -0
- package/dist/templates/mini-surf/frontend/src/vite-env.d.ts +1 -0
- package/dist/templates/mini-surf/frontend/tsconfig.json +19 -0
- package/dist/templates/mini-surf/frontend/vite.config.ts +25 -0
- package/package.json +1 -1
- package/dist/templates/default/backend/db/index.js +0 -23
- package/dist/templates/default/backend/lib/db.js +0 -67
- package/dist/templates/default/backend/routes/proxy.js +0 -66
|
@@ -8,6 +8,7 @@ var DEFAULT_BACKEND_PORT = "3001";
|
|
|
8
8
|
var DEFAULT_TEMPLATE = "default";
|
|
9
9
|
async function createSurfApp({
|
|
10
10
|
projectName = ".",
|
|
11
|
+
templateName = DEFAULT_TEMPLATE,
|
|
11
12
|
frontendPort = process.env.VITE_PORT || DEFAULT_FRONTEND_PORT,
|
|
12
13
|
backendPort = process.env.VITE_BACKEND_PORT || DEFAULT_BACKEND_PORT,
|
|
13
14
|
previewBase = process.env.VITE_BASE,
|
|
@@ -17,9 +18,9 @@ async function createSurfApp({
|
|
|
17
18
|
const name = path.basename(root);
|
|
18
19
|
const validatedFrontendPort = validatePort("frontend", frontendPort);
|
|
19
20
|
const validatedBackendPort = validatePort("backend", backendPort);
|
|
20
|
-
const templateDir = resolveTemplateDir(
|
|
21
|
+
const templateDir = resolveTemplateDir(templateName);
|
|
21
22
|
logger(`
|
|
22
|
-
Creating Surf app in ${root}
|
|
23
|
+
Creating Surf app in ${root} (${templateName})
|
|
23
24
|
`);
|
|
24
25
|
fs.mkdirSync(root, { recursive: true });
|
|
25
26
|
copyDir(templateDir, root, root, logger);
|
package/dist/cli.js
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import {
|
|
3
3
|
createSurfApp
|
|
4
|
-
} from "./chunk-
|
|
4
|
+
} from "./chunk-53EUQV7R.js";
|
|
5
5
|
|
|
6
6
|
// src/cli.ts
|
|
7
7
|
var VALUE_FLAGS = /* @__PURE__ */ new Set([
|
|
8
|
+
"--template",
|
|
8
9
|
"--frontend-port",
|
|
9
10
|
"--backend-port",
|
|
10
11
|
"--preview-base"
|
|
@@ -34,6 +35,7 @@ function parseCliArgs(args) {
|
|
|
34
35
|
}
|
|
35
36
|
return {
|
|
36
37
|
projectName: positionalArgs[0] || ".",
|
|
38
|
+
templateName: getFlag(args, "--template"),
|
|
37
39
|
frontendPort: getFlag(args, "--frontend-port"),
|
|
38
40
|
backendPort: getFlag(args, "--backend-port"),
|
|
39
41
|
previewBase: getFlag(args, "--preview-base")
|
package/dist/index.js
CHANGED
|
@@ -1,20 +1,8 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
*
|
|
10
|
-
* const todos = pgTable('todos', {
|
|
11
|
-
* id: serial('id').primaryKey(),
|
|
12
|
-
* title: text('title').notNull(),
|
|
13
|
-
* completed: boolean('completed').default(false),
|
|
14
|
-
* createdAt: timestamp('created_at', { withTimezone: true }).defaultNow(),
|
|
15
|
-
* });
|
|
16
|
-
*
|
|
17
|
-
* module.exports = { todos };
|
|
18
|
-
*/
|
|
19
|
-
|
|
20
|
-
// Add your table definitions here.
|
|
1
|
+
// Define your Drizzle ORM tables here.
|
|
2
|
+
// Example:
|
|
3
|
+
// const { pgTable, serial, text, timestamp } = require('drizzle-orm/pg-core')
|
|
4
|
+
// exports.users = pgTable('users', {
|
|
5
|
+
// id: serial('id').primaryKey(),
|
|
6
|
+
// name: text('name').notNull(),
|
|
7
|
+
// created_at: timestamp('created_at').defaultNow(),
|
|
8
|
+
// })
|
|
@@ -1,26 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "backend",
|
|
3
3
|
"private": true,
|
|
4
|
-
"version": "0.0.0",
|
|
5
4
|
"scripts": {
|
|
6
|
-
"check": "node --check server.js && find routes lib db -type f -name '*.js' -print0 | xargs -0 -n1 node --check",
|
|
7
|
-
"lint": "eslint .",
|
|
8
5
|
"start": "node server.js",
|
|
9
|
-
"dev": "node --watch
|
|
10
|
-
"verify": "npm run lint && npm run check"
|
|
6
|
+
"dev": "node --watch server.js"
|
|
11
7
|
},
|
|
12
8
|
"dependencies": {
|
|
13
9
|
"@surf-ai/sdk": "0.1.4-beta",
|
|
14
|
-
"express": "4.22.1"
|
|
15
|
-
"cors": "2.8.6",
|
|
16
|
-
"http-proxy-middleware": "3.0.5",
|
|
17
|
-
"drizzle-orm": "0.44.7",
|
|
18
|
-
"drizzle-kit": "0.30.6",
|
|
19
|
-
"croner": "9.1.0"
|
|
20
|
-
},
|
|
21
|
-
"devDependencies": {
|
|
22
|
-
"@eslint/js": "9.39.4",
|
|
23
|
-
"eslint": "9.39.4",
|
|
24
|
-
"globals": "16.5.0"
|
|
10
|
+
"express": "4.22.1"
|
|
25
11
|
}
|
|
26
12
|
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -1,444 +1,2 @@
|
|
|
1
|
-
const
|
|
2
|
-
|
|
3
|
-
const { Cron } = require('croner');
|
|
4
|
-
const fs = require('fs');
|
|
5
|
-
const path = require('path');
|
|
6
|
-
const { setupProxyRoutes } = require('./routes/proxy');
|
|
7
|
-
|
|
8
|
-
if (!process.env.PORT) {
|
|
9
|
-
const envPath = path.join(__dirname, '.env');
|
|
10
|
-
if (fs.existsSync(envPath)) {
|
|
11
|
-
for (const line of fs.readFileSync(envPath, 'utf8').split(/\r?\n/)) {
|
|
12
|
-
const trimmed = line.trim();
|
|
13
|
-
if (!trimmed || trimmed.startsWith('#')) continue;
|
|
14
|
-
const idx = trimmed.indexOf('=');
|
|
15
|
-
if (idx === -1) continue;
|
|
16
|
-
const key = trimmed.slice(0, idx).trim();
|
|
17
|
-
const value = trimmed.slice(idx + 1).trim();
|
|
18
|
-
if (key && !(key in process.env)) {
|
|
19
|
-
process.env[key] = value;
|
|
20
|
-
}
|
|
21
|
-
}
|
|
22
|
-
}
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
const app = express();
|
|
26
|
-
const PORT = Number.parseInt(process.env.PORT || '', 10);
|
|
27
|
-
if (!Number.isInteger(PORT)) {
|
|
28
|
-
throw new Error('PORT env var is required');
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
// CORS: allow any origin (external frontends can connect)
|
|
32
|
-
app.use(cors());
|
|
33
|
-
|
|
34
|
-
// Forward /proxy/* → OutboundProxy (API key injection)
|
|
35
|
-
// NOTE: Must be registered BEFORE express.json() so that the raw request body
|
|
36
|
-
// stream is preserved for http-proxy-middleware to forward POST requests.
|
|
37
|
-
setupProxyRoutes(app);
|
|
38
|
-
|
|
39
|
-
// Parse JSON bodies (after proxy routes to avoid consuming body stream)
|
|
40
|
-
app.use(express.json());
|
|
41
|
-
|
|
42
|
-
// Health check
|
|
43
|
-
app.get('/api/health', (_req, res) => {
|
|
44
|
-
res.json({ status: 'ok' });
|
|
45
|
-
});
|
|
46
|
-
|
|
47
|
-
// ── Auto-register API routes ─────────────────────────────────────────────
|
|
48
|
-
// Every .js file in routes/ (except proxy.js) is auto-mounted at /api/{name}.
|
|
49
|
-
// e.g. routes/todos.js → /api/todos, routes/auth.js → /api/auth
|
|
50
|
-
const routesDir = path.join(__dirname, 'routes');
|
|
51
|
-
for (const file of fs.readdirSync(routesDir)) {
|
|
52
|
-
if (file === 'proxy.js' || !file.endsWith('.js')) continue;
|
|
53
|
-
const name = file.replace('.js', '');
|
|
54
|
-
try {
|
|
55
|
-
app.use(`/api/${name}`, require(`./routes/${file}`));
|
|
56
|
-
console.log(`Route registered: /api/${name}`);
|
|
57
|
-
} catch (err) {
|
|
58
|
-
console.error(`Failed to load route ${file}: ${err.message}`);
|
|
59
|
-
}
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
// ── Auto-create DB tables from schema.js ─────────────────────────────────
|
|
63
|
-
// The agent writes db/schema.js AFTER the server starts, so we need two
|
|
64
|
-
// trigger paths: (1) POST /api/__sync-schema for explicit agent calls,
|
|
65
|
-
// (2) fs.watchFile as a fallback in case the agent doesn't call the endpoint.
|
|
66
|
-
//
|
|
67
|
-
// Both paths funnel into doSyncSchema() which has:
|
|
68
|
-
// - Concurrency lock (syncing flag) to prevent overlapping DDL
|
|
69
|
-
// - SyntaxError guard for half-written files
|
|
70
|
-
// - Retry with backoff for Neon cold-start errors
|
|
71
|
-
let schemaReady = false;
|
|
72
|
-
let syncing = false;
|
|
73
|
-
|
|
74
|
-
async function doSyncSchema() {
|
|
75
|
-
if (syncing) return;
|
|
76
|
-
syncing = true;
|
|
77
|
-
try {
|
|
78
|
-
// Clear require cache so we pick up the latest schema.js
|
|
79
|
-
try { delete require.cache[require.resolve('./db/schema')]; } catch (_e) { /* not cached — ignore */ }
|
|
80
|
-
|
|
81
|
-
// Try to load schema — SyntaxError means the file is half-written
|
|
82
|
-
let schema;
|
|
83
|
-
try {
|
|
84
|
-
schema = require('./db/schema');
|
|
85
|
-
} catch (err) {
|
|
86
|
-
if (err instanceof SyntaxError) {
|
|
87
|
-
console.log('DB: schema.js has syntax error, waiting for next change...');
|
|
88
|
-
return;
|
|
89
|
-
}
|
|
90
|
-
if (err.message.includes('Cannot find module') || err.message.includes('is not a function')) {
|
|
91
|
-
return; // No schema or no drizzle-orm — skip silently
|
|
92
|
-
}
|
|
93
|
-
throw err;
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
const { getTableConfig } = require('drizzle-orm/pg-core');
|
|
97
|
-
const tables = Object.values(schema).filter(t =>
|
|
98
|
-
t && typeof t === 'object' && Symbol.for('drizzle:Name') in t
|
|
99
|
-
);
|
|
100
|
-
if (tables.length === 0) return;
|
|
101
|
-
|
|
102
|
-
const { dbTables, dbProvision, dbQuery, dbTableSchema } = require('./lib/db');
|
|
103
|
-
await dbProvision('auto-sync schema tables');
|
|
104
|
-
|
|
105
|
-
const existing = (await dbTables()).map(t => t.name);
|
|
106
|
-
const missing = tables.filter(t => !existing.includes(getTableConfig(t).name));
|
|
107
|
-
|
|
108
|
-
if (missing.length > 0) {
|
|
109
|
-
// Use drizzle-kit to generate correct DDL (handles UNIQUE, FK, defaults, etc.)
|
|
110
|
-
const { generateDrizzleJson, generateMigration } = require('drizzle-kit/api');
|
|
111
|
-
const missingSchema = {};
|
|
112
|
-
for (const t of missing) missingSchema[getTableConfig(t).name] = t;
|
|
113
|
-
const sqls = await generateMigration(generateDrizzleJson({}), generateDrizzleJson(missingSchema));
|
|
114
|
-
|
|
115
|
-
for (const sql of sqls) {
|
|
116
|
-
for (let attempt = 0; attempt < 2; attempt++) {
|
|
117
|
-
try {
|
|
118
|
-
await dbQuery(sql, []);
|
|
119
|
-
console.log(`DB: Executed: ${sql.slice(0, 80)}...`);
|
|
120
|
-
break;
|
|
121
|
-
} catch (err) {
|
|
122
|
-
if (attempt === 0) {
|
|
123
|
-
console.warn(`DB: Retrying after: ${err.message}`);
|
|
124
|
-
await new Promise(r => setTimeout(r, 1500));
|
|
125
|
-
} else {
|
|
126
|
-
console.error(`DB: Failed: ${sql.slice(0, 80)}... — ${err.message}`);
|
|
127
|
-
}
|
|
128
|
-
}
|
|
129
|
-
}
|
|
130
|
-
}
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
// Check existing tables for missing columns (runs even when new tables were also created)
|
|
134
|
-
const existingTables = tables.filter(t => existing.includes(getTableConfig(t).name));
|
|
135
|
-
for (const t of existingTables) {
|
|
136
|
-
const cfg = getTableConfig(t);
|
|
137
|
-
try {
|
|
138
|
-
const live = await dbTableSchema(cfg.name);
|
|
139
|
-
const liveCols = new Set(live.columns.map(c => c.name));
|
|
140
|
-
for (const col of cfg.columns) {
|
|
141
|
-
if (!liveCols.has(col.name)) {
|
|
142
|
-
const colType = col.getSQLType();
|
|
143
|
-
const ddl = 'ALTER TABLE "' + cfg.name + '" ADD COLUMN IF NOT EXISTS "' + col.name + '" ' + colType;
|
|
144
|
-
try {
|
|
145
|
-
await dbQuery(ddl, []);
|
|
146
|
-
console.log('DB: Added column ' + col.name + ' to ' + cfg.name);
|
|
147
|
-
} catch (err) {
|
|
148
|
-
console.warn('DB: Failed to add column ' + col.name + ' to ' + cfg.name + ': ' + err.message);
|
|
149
|
-
}
|
|
150
|
-
}
|
|
151
|
-
}
|
|
152
|
-
} catch (err) {
|
|
153
|
-
console.warn('DB: Column check failed for ' + cfg.name + ': ' + err.message);
|
|
154
|
-
}
|
|
155
|
-
}
|
|
156
|
-
} finally {
|
|
157
|
-
syncing = false;
|
|
158
|
-
}
|
|
159
|
-
}
|
|
160
|
-
|
|
161
|
-
// Retry wrapper for Neon cold-start transient errors.
|
|
162
|
-
async function syncSchemaWithRetry(retries = 3, delay = 2000) {
|
|
163
|
-
for (let i = 0; i < retries; i++) {
|
|
164
|
-
try {
|
|
165
|
-
await doSyncSchema();
|
|
166
|
-
return;
|
|
167
|
-
} catch (err) {
|
|
168
|
-
console.error(`DB schema sync attempt ${i + 1}/${retries} failed: ${err.message}`);
|
|
169
|
-
if (i < retries - 1) await new Promise(r => setTimeout(r, delay * (i + 1)));
|
|
170
|
-
}
|
|
171
|
-
}
|
|
172
|
-
console.error('DB schema sync failed after all retries');
|
|
173
|
-
}
|
|
174
|
-
|
|
175
|
-
// Explicit sync endpoint — agent calls this after writing schema.js.
|
|
176
|
-
app.post('/api/__sync-schema', async (_req, res) => {
|
|
177
|
-
try {
|
|
178
|
-
await syncSchemaWithRetry(2, 1500);
|
|
179
|
-
res.json({ ok: true });
|
|
180
|
-
} catch (err) {
|
|
181
|
-
res.status(500).json({ ok: false, error: err.message });
|
|
182
|
-
}
|
|
183
|
-
});
|
|
184
|
-
|
|
185
|
-
// ── Cron job system ──────────────────────────────────────────────────────
|
|
186
|
-
// Reads backend/cron.json, schedules tasks with croner, exposes CRUD API.
|
|
187
|
-
const _cronInstances = new Map(); // id -> Cron instance
|
|
188
|
-
const _cronState = new Map(); // id -> { lastRunAt, lastStatus, lastError }
|
|
189
|
-
|
|
190
|
-
function _loadCronJobs() {
|
|
191
|
-
// Stop all existing cron jobs
|
|
192
|
-
for (const [, job] of _cronInstances) {
|
|
193
|
-
try { job.stop(); } catch (_e) { /* already stopped — ignore */ }
|
|
194
|
-
}
|
|
195
|
-
_cronInstances.clear();
|
|
196
|
-
|
|
197
|
-
const cronPath = path.join(__dirname, 'cron.json');
|
|
198
|
-
if (!fs.existsSync(cronPath)) return;
|
|
199
|
-
|
|
200
|
-
let tasks;
|
|
201
|
-
try {
|
|
202
|
-
tasks = JSON.parse(fs.readFileSync(cronPath, 'utf8'));
|
|
203
|
-
} catch (err) {
|
|
204
|
-
console.error('Cron: failed to parse cron.json:', err.message);
|
|
205
|
-
return;
|
|
206
|
-
}
|
|
207
|
-
|
|
208
|
-
if (!Array.isArray(tasks)) {
|
|
209
|
-
console.error('Cron: cron.json must be a JSON array');
|
|
210
|
-
return;
|
|
211
|
-
}
|
|
212
|
-
|
|
213
|
-
for (const task of tasks) {
|
|
214
|
-
if (!task.enabled) continue;
|
|
215
|
-
if (!task.id || !task.schedule || !task.handler) {
|
|
216
|
-
console.warn('Cron: skipping task with missing fields:', JSON.stringify(task));
|
|
217
|
-
continue;
|
|
218
|
-
}
|
|
219
|
-
|
|
220
|
-
let handlerMod;
|
|
221
|
-
try {
|
|
222
|
-
const handlerPath = path.resolve(__dirname, task.handler);
|
|
223
|
-
delete require.cache[handlerPath];
|
|
224
|
-
handlerMod = require(handlerPath);
|
|
225
|
-
} catch (err) {
|
|
226
|
-
console.error(`Cron: failed to load handler ${task.handler}: ${err.message}`);
|
|
227
|
-
continue;
|
|
228
|
-
}
|
|
229
|
-
|
|
230
|
-
if (typeof handlerMod.handler !== 'function') {
|
|
231
|
-
console.warn(`Cron: handler ${task.handler} does not export a handler function`);
|
|
232
|
-
continue;
|
|
233
|
-
}
|
|
234
|
-
|
|
235
|
-
const timeoutMs = (task.timeout || 300) * 1000;
|
|
236
|
-
|
|
237
|
-
// Initialize runtime state if not present
|
|
238
|
-
if (!_cronState.has(task.id)) {
|
|
239
|
-
_cronState.set(task.id, { lastRunAt: null, lastStatus: null, lastError: null });
|
|
240
|
-
}
|
|
241
|
-
|
|
242
|
-
const runHandler = async () => {
|
|
243
|
-
const state = _cronState.get(task.id);
|
|
244
|
-
state.lastRunAt = new Date().toISOString();
|
|
245
|
-
try {
|
|
246
|
-
await Promise.race([
|
|
247
|
-
handlerMod.handler(),
|
|
248
|
-
new Promise((_, reject) => setTimeout(() => reject(new Error('timeout')), timeoutMs)),
|
|
249
|
-
]);
|
|
250
|
-
state.lastStatus = 'success';
|
|
251
|
-
state.lastError = null;
|
|
252
|
-
console.log(`Cron: task ${task.id} completed successfully`);
|
|
253
|
-
} catch (err) {
|
|
254
|
-
state.lastStatus = 'error';
|
|
255
|
-
state.lastError = err.message;
|
|
256
|
-
console.error(`Cron: task ${task.id} failed: ${err.message}`);
|
|
257
|
-
}
|
|
258
|
-
};
|
|
259
|
-
|
|
260
|
-
try {
|
|
261
|
-
const job = new Cron(task.schedule, runHandler);
|
|
262
|
-
_cronInstances.set(task.id, job);
|
|
263
|
-
console.log(`Cron: scheduled ${task.id} (${task.name || task.id}) with schedule "${task.schedule}"`);
|
|
264
|
-
} catch (err) {
|
|
265
|
-
console.error(`Cron: invalid schedule for ${task.id}: ${err.message}`);
|
|
266
|
-
}
|
|
267
|
-
}
|
|
268
|
-
}
|
|
269
|
-
|
|
270
|
-
// ── Cron CRUD API routes (before schema readiness guard) ─────────────────
|
|
271
|
-
// Auth middleware for /api/cron routes
|
|
272
|
-
app.use('/api/cron', (req, res, next) => {
|
|
273
|
-
const appToken = process.env.APP_TOKEN;
|
|
274
|
-
if (!appToken) return next(); // dev mode: skip auth
|
|
275
|
-
const auth = req.headers.authorization;
|
|
276
|
-
if (!auth || auth !== `Bearer ${appToken}`) {
|
|
277
|
-
return res.status(401).json({ error: 'Unauthorized' });
|
|
278
|
-
}
|
|
279
|
-
next();
|
|
280
|
-
});
|
|
281
|
-
|
|
282
|
-
app.get('/api/cron', (_req, res) => {
|
|
283
|
-
const cronPath = path.join(__dirname, 'cron.json');
|
|
284
|
-
let tasks = [];
|
|
285
|
-
try {
|
|
286
|
-
if (fs.existsSync(cronPath)) {
|
|
287
|
-
tasks = JSON.parse(fs.readFileSync(cronPath, 'utf8'));
|
|
288
|
-
}
|
|
289
|
-
} catch { tasks = []; }
|
|
290
|
-
|
|
291
|
-
const result = tasks.map(t => {
|
|
292
|
-
const state = _cronState.get(t.id) || { lastRunAt: null, lastStatus: null, lastError: null };
|
|
293
|
-
const job = _cronInstances.get(t.id);
|
|
294
|
-
return {
|
|
295
|
-
...t,
|
|
296
|
-
lastRunAt: state.lastRunAt,
|
|
297
|
-
lastStatus: state.lastStatus,
|
|
298
|
-
lastError: state.lastError,
|
|
299
|
-
nextRun: job ? job.nextRun()?.toISOString() || null : null,
|
|
300
|
-
};
|
|
301
|
-
});
|
|
302
|
-
res.json(result);
|
|
303
|
-
});
|
|
304
|
-
|
|
305
|
-
app.post('/api/cron', (req, res) => {
|
|
306
|
-
const { id, name, schedule, handler, enabled = true, timeout = 300 } = req.body || {};
|
|
307
|
-
if (!id || !name || !schedule || !handler) {
|
|
308
|
-
return res.status(400).json({ error: 'Missing required fields: id, name, schedule, handler' });
|
|
309
|
-
}
|
|
310
|
-
|
|
311
|
-
// Validate cron expression and minimum interval (>= 1 minute)
|
|
312
|
-
try {
|
|
313
|
-
const testJob = new Cron(schedule);
|
|
314
|
-
const next1 = testJob.nextRun();
|
|
315
|
-
const next2 = testJob.nextRun(next1);
|
|
316
|
-
testJob.stop();
|
|
317
|
-
if (next1 && next2 && (next2.getTime() - next1.getTime()) < 60000) {
|
|
318
|
-
return res.status(400).json({ error: 'Minimum interval between runs must be at least 1 minute' });
|
|
319
|
-
}
|
|
320
|
-
} catch (err) {
|
|
321
|
-
return res.status(400).json({ error: `Invalid cron expression: ${err.message}` });
|
|
322
|
-
}
|
|
323
|
-
|
|
324
|
-
const cronPath = path.join(__dirname, 'cron.json');
|
|
325
|
-
let tasks = [];
|
|
326
|
-
try {
|
|
327
|
-
if (fs.existsSync(cronPath)) tasks = JSON.parse(fs.readFileSync(cronPath, 'utf8'));
|
|
328
|
-
} catch { tasks = []; }
|
|
329
|
-
|
|
330
|
-
if (tasks.some(t => t.id === id)) {
|
|
331
|
-
return res.status(409).json({ error: `Task with id "${id}" already exists` });
|
|
332
|
-
}
|
|
333
|
-
|
|
334
|
-
const newTask = { id, name, schedule, handler, enabled, timeout };
|
|
335
|
-
tasks.push(newTask);
|
|
336
|
-
fs.writeFileSync(cronPath, JSON.stringify(tasks, null, 2));
|
|
337
|
-
_loadCronJobs();
|
|
338
|
-
res.status(201).json(newTask);
|
|
339
|
-
});
|
|
340
|
-
|
|
341
|
-
app.patch('/api/cron/:id', (req, res) => {
|
|
342
|
-
const cronPath = path.join(__dirname, 'cron.json');
|
|
343
|
-
let tasks = [];
|
|
344
|
-
try {
|
|
345
|
-
if (fs.existsSync(cronPath)) tasks = JSON.parse(fs.readFileSync(cronPath, 'utf8'));
|
|
346
|
-
} catch { tasks = []; }
|
|
347
|
-
|
|
348
|
-
const idx = tasks.findIndex(t => t.id === req.params.id);
|
|
349
|
-
if (idx === -1) return res.status(404).json({ error: 'Task not found' });
|
|
350
|
-
|
|
351
|
-
const updates = req.body || {};
|
|
352
|
-
|
|
353
|
-
// If schedule is being updated, validate it
|
|
354
|
-
if (updates.schedule) {
|
|
355
|
-
try {
|
|
356
|
-
const testJob = new Cron(updates.schedule);
|
|
357
|
-
const next1 = testJob.nextRun();
|
|
358
|
-
const next2 = testJob.nextRun(next1);
|
|
359
|
-
testJob.stop();
|
|
360
|
-
if (next1 && next2 && (next2.getTime() - next1.getTime()) < 60000) {
|
|
361
|
-
return res.status(400).json({ error: 'Minimum interval between runs must be at least 1 minute' });
|
|
362
|
-
}
|
|
363
|
-
} catch (err) {
|
|
364
|
-
return res.status(400).json({ error: `Invalid cron expression: ${err.message}` });
|
|
365
|
-
}
|
|
366
|
-
}
|
|
367
|
-
|
|
368
|
-
tasks[idx] = { ...tasks[idx], ...updates, id: req.params.id };
|
|
369
|
-
fs.writeFileSync(cronPath, JSON.stringify(tasks, null, 2));
|
|
370
|
-
_loadCronJobs();
|
|
371
|
-
res.json(tasks[idx]);
|
|
372
|
-
});
|
|
373
|
-
|
|
374
|
-
app.delete('/api/cron/:id', (req, res) => {
|
|
375
|
-
const cronPath = path.join(__dirname, 'cron.json');
|
|
376
|
-
let tasks = [];
|
|
377
|
-
try {
|
|
378
|
-
if (fs.existsSync(cronPath)) tasks = JSON.parse(fs.readFileSync(cronPath, 'utf8'));
|
|
379
|
-
} catch { tasks = []; }
|
|
380
|
-
|
|
381
|
-
const idx = tasks.findIndex(t => t.id === req.params.id);
|
|
382
|
-
if (idx === -1) return res.status(404).json({ error: 'Task not found' });
|
|
383
|
-
|
|
384
|
-
tasks.splice(idx, 1);
|
|
385
|
-
fs.writeFileSync(cronPath, JSON.stringify(tasks, null, 2));
|
|
386
|
-
_cronState.delete(req.params.id);
|
|
387
|
-
_loadCronJobs();
|
|
388
|
-
res.json({ ok: true });
|
|
389
|
-
});
|
|
390
|
-
|
|
391
|
-
app.post('/api/cron/:id/run', async (req, res) => {
|
|
392
|
-
const job = _cronInstances.get(req.params.id);
|
|
393
|
-
if (!job) return res.status(404).json({ error: 'Task not found or not enabled' });
|
|
394
|
-
try {
|
|
395
|
-
job.trigger();
|
|
396
|
-
res.json({ ok: true, message: `Task ${req.params.id} triggered` });
|
|
397
|
-
} catch (err) {
|
|
398
|
-
res.status(500).json({ error: err.message });
|
|
399
|
-
}
|
|
400
|
-
});
|
|
401
|
-
|
|
402
|
-
// Block /api/* (except health + sync + cron) until initial schema sync completes.
|
|
403
|
-
app.use('/api', (req, res, next) => {
|
|
404
|
-
if (schemaReady || req.path === '/health' || req.path === '/__sync-schema' || req.path.startsWith('/cron')) return next();
|
|
405
|
-
res.status(503).json({ error: 'Database schema initializing...' });
|
|
406
|
-
});
|
|
407
|
-
|
|
408
|
-
function startServer(retries = 10, delay = 1000) {
|
|
409
|
-
const server = app.listen(PORT, '0.0.0.0', async () => {
|
|
410
|
-
console.log(`Backend listening on port ${PORT}`);
|
|
411
|
-
await syncSchemaWithRetry();
|
|
412
|
-
schemaReady = true;
|
|
413
|
-
console.log('Schema sync complete, API ready');
|
|
414
|
-
|
|
415
|
-
// Load cron jobs after DB is ready
|
|
416
|
-
_loadCronJobs();
|
|
417
|
-
|
|
418
|
-
// Fallback: watch schema.js for changes in case agent doesn't call __sync-schema.
|
|
419
|
-
const schemaPath = path.join(__dirname, 'db', 'schema.js');
|
|
420
|
-
let syncDebounce = null;
|
|
421
|
-
fs.watchFile(schemaPath, { interval: 2000 }, () => {
|
|
422
|
-
if (syncDebounce) clearTimeout(syncDebounce);
|
|
423
|
-
syncDebounce = setTimeout(async () => {
|
|
424
|
-
console.log('DB: schema.js changed, re-syncing tables...');
|
|
425
|
-
try {
|
|
426
|
-
await syncSchemaWithRetry(2, 1500);
|
|
427
|
-
console.log('DB: schema re-sync complete');
|
|
428
|
-
} catch (err) {
|
|
429
|
-
console.error(`DB: schema re-sync failed: ${err.message}`);
|
|
430
|
-
}
|
|
431
|
-
}, 1000);
|
|
432
|
-
});
|
|
433
|
-
});
|
|
434
|
-
server.on('error', (err) => {
|
|
435
|
-
if (err.code === 'EADDRINUSE' && retries > 0) {
|
|
436
|
-
console.log(`Port ${PORT} in use, retrying in ${delay}ms... (${retries} left)`);
|
|
437
|
-
setTimeout(() => startServer(retries - 1, delay), delay);
|
|
438
|
-
} else {
|
|
439
|
-
console.error(`Failed to start server: ${err.message}`);
|
|
440
|
-
process.exit(1);
|
|
441
|
-
}
|
|
442
|
-
});
|
|
443
|
-
}
|
|
444
|
-
startServer();
|
|
1
|
+
const { createServer } = require('@surf-ai/sdk/server')
|
|
2
|
+
createServer().start()
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
# Project
|
|
2
|
+
|
|
3
|
+
Built with [Surf SDK](https://github.com/cyberconnecthq/urania/tree/main/packages/sdk).
|
|
4
|
+
|
|
5
|
+
## Imports from @surf-ai/sdk
|
|
6
|
+
|
|
7
|
+
Everything comes from `@surf-ai/sdk`. Do NOT create local utility files for these.
|
|
8
|
+
|
|
9
|
+
**Frontend (`@surf-ai/sdk/react`):**
|
|
10
|
+
```tsx
|
|
11
|
+
import { useMarketPrice, useTokenHolders } from '@surf-ai/sdk/react' // data hooks
|
|
12
|
+
import { cn } from '@surf-ai/sdk/react' // Tailwind class merge
|
|
13
|
+
import { useToast, toast } from '@surf-ai/sdk/react' // toast notifications
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
**Backend (`@surf-ai/sdk/server`):**
|
|
17
|
+
```js
|
|
18
|
+
const { dataApi } = require('@surf-ai/sdk/server')
|
|
19
|
+
const data = await dataApi.market.price({ symbol: 'BTC' })
|
|
20
|
+
const holders = await dataApi.token.holders({ address: '0x...', chain: 'ethereum' })
|
|
21
|
+
// Escape hatch for new endpoints:
|
|
22
|
+
const raw = await dataApi.get('newcategory/endpoint', { foo: 'bar' })
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
## Structure
|
|
26
|
+
|
|
27
|
+
```
|
|
28
|
+
frontend/src/App.tsx - build your UI here
|
|
29
|
+
frontend/src/components/ - add components
|
|
30
|
+
frontend/src/db/schema.ts - frontend DB schema mirror
|
|
31
|
+
backend/routes/*.js - add API routes (auto-mounted at /api/{name})
|
|
32
|
+
backend/db/schema.js - define database tables
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
## Built-in Endpoints (from @surf-ai/sdk/server)
|
|
36
|
+
|
|
37
|
+
`createServer()` provides these automatically — do NOT create routes for them:
|
|
38
|
+
|
|
39
|
+
| Endpoint | Method | Purpose |
|
|
40
|
+
|----------|--------|---------|
|
|
41
|
+
| `/api/health` | GET | Health check — `{ status: 'ok' }` |
|
|
42
|
+
| `/api/__sync-schema` | POST | Sync `backend/db/schema.js` tables to database |
|
|
43
|
+
| `/api/cron` | GET | List cron jobs with status and next run time |
|
|
44
|
+
| `/api/cron` | POST | Create a new cron task |
|
|
45
|
+
| `/api/cron/:id` | PATCH | Update a cron task (schedule, enabled, etc.) |
|
|
46
|
+
| `/api/cron/:id` | DELETE | Delete a cron task |
|
|
47
|
+
| `/api/cron/:id/run` | POST | Manually trigger a cron task |
|
|
48
|
+
| `/proxy/*` | ANY | Data API passthrough — `/proxy/market/price` → hermod |
|
|
49
|
+
|
|
50
|
+
Auto-registered from `backend/routes/*.js`:
|
|
51
|
+
| File | Endpoint |
|
|
52
|
+
|------|----------|
|
|
53
|
+
| `routes/btc.js` | `/api/btc` |
|
|
54
|
+
| `routes/portfolio.js` | `/api/portfolio` |
|
|
55
|
+
|
|
56
|
+
## Database
|
|
57
|
+
|
|
58
|
+
Define tables in `backend/db/schema.js` using Drizzle ORM:
|
|
59
|
+
```js
|
|
60
|
+
const { pgTable, serial, text, timestamp } = require('drizzle-orm/pg-core')
|
|
61
|
+
exports.users = pgTable('users', {
|
|
62
|
+
id: serial('id').primaryKey(),
|
|
63
|
+
name: text('name').notNull(),
|
|
64
|
+
created_at: timestamp('created_at').defaultNow(),
|
|
65
|
+
})
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
Tables are auto-created on startup and when `schema.js` changes (file watcher).
|
|
69
|
+
The agent can also call `POST /api/__sync-schema` explicitly after editing.
|
|
70
|
+
|
|
71
|
+
## Do NOT modify
|
|
72
|
+
|
|
73
|
+
- `vite.config.ts` — proxy and build config
|
|
74
|
+
- `backend/server.js` — uses @surf-ai/sdk/server
|
|
75
|
+
- `entry-client.tsx` — app bootstrap with SSR hydration
|
|
76
|
+
- `entry-server.tsx` — SSR render for deploy
|
|
77
|
+
- `index.html` — cold-start guard and Surf badge
|
|
78
|
+
- `eslint.config.*` — lint rules
|
|
79
|
+
- `index.css` — only imports, do not add styles here (use Tailwind classes)
|
|
80
|
+
|
|
81
|
+
## Rules
|
|
82
|
+
|
|
83
|
+
- Use `@surf-ai/sdk/react` hooks in frontend, `@surf-ai/sdk/server` dataApi in backend
|
|
84
|
+
- Use Tailwind CSS classes for styling (Surf Design System theme via `@surf-ai/theme`)
|
|
85
|
+
- Use shadcn/ui components — install with `bunx shadcn@latest add button`
|
|
86
|
+
- Use `cn()` from `@surf-ai/sdk/react` to merge Tailwind classes
|
|
87
|
+
- Frontend packages are pre-installed — check `package.json` before installing
|
|
88
|
+
- Dark theme is the default (configured in entry-client.tsx)
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
// Define your Drizzle ORM tables here.
|
|
2
|
+
// Example:
|
|
3
|
+
// const { pgTable, serial, text, timestamp } = require('drizzle-orm/pg-core')
|
|
4
|
+
// exports.users = pgTable('users', {
|
|
5
|
+
// id: serial('id').primaryKey(),
|
|
6
|
+
// name: text('name').notNull(),
|
|
7
|
+
// created_at: timestamp('created_at').defaultNow(),
|
|
8
|
+
// })
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import js from '@eslint/js';
|
|
2
|
+
import globals from 'globals';
|
|
3
|
+
|
|
4
|
+
export default [
|
|
5
|
+
{ ignores: ['node_modules'] },
|
|
6
|
+
{
|
|
7
|
+
files: ['**/*.js'],
|
|
8
|
+
...js.configs.recommended,
|
|
9
|
+
languageOptions: {
|
|
10
|
+
ecmaVersion: 2020,
|
|
11
|
+
sourceType: 'commonjs',
|
|
12
|
+
globals: globals.node,
|
|
13
|
+
},
|
|
14
|
+
rules: {
|
|
15
|
+
'no-unused-vars': ['warn', { argsIgnorePattern: '^_', varsIgnorePattern: '^_' }],
|
|
16
|
+
'no-empty': 'warn',
|
|
17
|
+
'eqeqeq': 'warn',
|
|
18
|
+
'no-fallthrough': 'warn',
|
|
19
|
+
},
|
|
20
|
+
},
|
|
21
|
+
];
|
|
File without changes
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://ui.shadcn.com/schema.json",
|
|
3
|
+
"style": "default",
|
|
4
|
+
"rsc": false,
|
|
5
|
+
"tsx": true,
|
|
6
|
+
"tailwind": {
|
|
7
|
+
"config": "",
|
|
8
|
+
"css": "src/index.css",
|
|
9
|
+
"baseColor": "slate",
|
|
10
|
+
"cssVariables": true
|
|
11
|
+
},
|
|
12
|
+
"aliases": {
|
|
13
|
+
"components": "@/components",
|
|
14
|
+
"utils": "@surf-ai/sdk/react",
|
|
15
|
+
"hooks": "@/hooks"
|
|
16
|
+
}
|
|
17
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import js from '@eslint/js'
|
|
2
|
+
import globals from 'globals'
|
|
3
|
+
import reactHooks from 'eslint-plugin-react-hooks'
|
|
4
|
+
import reactRefresh from 'eslint-plugin-react-refresh'
|
|
5
|
+
import tseslint from 'typescript-eslint'
|
|
6
|
+
|
|
7
|
+
export default tseslint.config(
|
|
8
|
+
{ ignores: ['dist', 'node_modules'] },
|
|
9
|
+
{
|
|
10
|
+
extends: [js.configs.recommended, ...tseslint.configs.recommended],
|
|
11
|
+
files: ['**/*.{js,jsx,ts,tsx}'],
|
|
12
|
+
languageOptions: {
|
|
13
|
+
ecmaVersion: 2020,
|
|
14
|
+
globals: {
|
|
15
|
+
...globals.browser,
|
|
16
|
+
...globals.node,
|
|
17
|
+
},
|
|
18
|
+
parserOptions: {
|
|
19
|
+
ecmaFeatures: { jsx: true },
|
|
20
|
+
},
|
|
21
|
+
},
|
|
22
|
+
plugins: {
|
|
23
|
+
'react-hooks': reactHooks,
|
|
24
|
+
'react-refresh': reactRefresh,
|
|
25
|
+
},
|
|
26
|
+
rules: {
|
|
27
|
+
...reactHooks.configs.recommended.rules,
|
|
28
|
+
'react-refresh/only-export-components': [
|
|
29
|
+
'warn',
|
|
30
|
+
{ allowConstantExport: true },
|
|
31
|
+
],
|
|
32
|
+
'no-unused-vars': 'off',
|
|
33
|
+
'@typescript-eslint/no-unused-vars': ['warn', { argsIgnorePattern: '^_', varsIgnorePattern: '^_' }],
|
|
34
|
+
'@typescript-eslint/no-explicit-any': 'off',
|
|
35
|
+
'@typescript-eslint/no-empty-object-type': 'warn',
|
|
36
|
+
'@typescript-eslint/ban-ts-comment': 'warn',
|
|
37
|
+
'@typescript-eslint/no-require-imports': 'off',
|
|
38
|
+
'@typescript-eslint/no-unused-expressions': 'warn',
|
|
39
|
+
'@typescript-eslint/no-unsafe-function-type': 'warn',
|
|
40
|
+
},
|
|
41
|
+
},
|
|
42
|
+
)
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8" />
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
6
|
+
<link rel="icon" href="/favicon.ico" />
|
|
7
|
+
<title>App</title>
|
|
8
|
+
</head>
|
|
9
|
+
<body>
|
|
10
|
+
<div id="root"><!--ssr-outlet--></div>
|
|
11
|
+
<script type="module" src="./src/entry-client.tsx"></script>
|
|
12
|
+
<script>
|
|
13
|
+
// Fallback: if React fails to render (e.g. Vite dep stubs during cold
|
|
14
|
+
// start), auto-reload until deps are ready. This fires OUTSIDE React
|
|
15
|
+
// so it works even when React itself is null/undefined.
|
|
16
|
+
(function(){
|
|
17
|
+
var K='__boot_reload', W=30000, M=8;
|
|
18
|
+
function st(){try{return JSON.parse(sessionStorage.getItem(K))||{c:0,t:0}}catch(e){return{c:0,t:0}}}
|
|
19
|
+
setTimeout(function(){
|
|
20
|
+
if(window.__reactOk)return;
|
|
21
|
+
var s=st(),c=(Date.now()-s.t>W)?0:s.c;
|
|
22
|
+
if(c<M){sessionStorage.setItem(K,JSON.stringify({c:c+1,t:Date.now()}));location.reload()}
|
|
23
|
+
},3000);
|
|
24
|
+
})();
|
|
25
|
+
</script>
|
|
26
|
+
<!-- Made by Surf badge -->
|
|
27
|
+
<style>
|
|
28
|
+
#surf-badge{position:fixed;bottom:16px;right:16px;z-index:2147483647;display:inline-flex;align-items:center;gap:4px;padding:4px 12px;border-radius:20px;font-family:"Lato",system-ui,sans-serif;font-size:12px;font-weight:500;line-height:16px;text-decoration:none;box-shadow:0 1px 1px rgba(51,51,51,.08),0 6px 12px rgba(51,51,51,.08);opacity:.9;transition:opacity .2s}
|
|
29
|
+
#surf-badge:hover{opacity:1}
|
|
30
|
+
@media print{#surf-badge{display:none}}
|
|
31
|
+
</style>
|
|
32
|
+
<a id="surf-badge" href="https://asksurf.ai" target="_blank" rel="noopener noreferrer" aria-label="Made by Surf" style="background:var(--bg-menu,#fff);color:var(--fg-base,#212121)">
|
|
33
|
+
<span>Made by</span>
|
|
34
|
+
<svg width="40" height="13" viewBox="0 0 63 20" fill="currentColor" xmlns="http://www.w3.org/2000/svg">
|
|
35
|
+
<path d="M15.583 13.333C15.9931 13.3331 16.3814 13.5196 16.6377 13.8398L18.7666 16.5019C18.7956 16.5382 18.8261 16.5719 18.8574 16.6035C19.2049 16.954 19.6484 17.3405 19.6484 17.834C19.6484 18.3399 19.2383 18.75 18.7324 18.75H18.4668C18.1079 18.75 17.7682 18.5868 17.5439 18.3066L16.3633 16.831C15.6565 15.9476 14.2315 16.4477 14.2314 17.5791V18.6494C14.2314 18.7061 14.1827 18.7499 14.126 18.75H12.2529C11.6074 18.75 10.997 18.4562 10.5938 17.9521L9.69629 16.831C8.98948 15.9478 7.56544 16.4478 7.56543 17.5791V18.6494C7.56542 18.7062 7.51669 18.7499 7.45996 18.75H5.58594C4.94038 18.7499 4.32999 18.4563 3.92676 17.9521L3.03027 16.831C2.67436 16.3862 2.14844 15.9311 2.14844 15.3613V13.4385C2.14851 13.3817 2.19228 13.333 2.24902 13.333C2.65919 13.333 3.04737 13.5197 3.30371 13.8398L5.43359 16.5019C6.14031 17.3853 7.56521 16.8861 7.56543 15.7549V14.6846C7.56543 13.9385 8.16998 13.333 8.91602 13.333C9.32621 13.333 9.71437 13.5196 9.9707 13.8398L12.1006 16.5019C12.8052 17.3827 14.2227 16.8887 14.2314 15.7646V14.6846C14.2314 13.9385 14.837 13.333 15.583 13.333ZM15.583 6.66698C15.9932 6.66711 16.3814 6.8535 16.6377 7.17382L18.7666 9.83593C19.1224 10.2806 19.6484 10.7352 19.6484 11.3047V13.2266C19.6483 13.2833 19.6046 13.333 19.5479 13.333C19.1375 13.333 18.7495 13.1466 18.4932 12.8262L16.3633 10.1641C15.6565 9.28087 14.2315 9.78081 14.2314 10.9121V11.9824C14.2314 12.7284 13.6269 13.333 12.8809 13.333C12.4706 13.3329 12.0825 13.1465 11.8262 12.8262L9.69629 10.1641C8.98942 9.28109 7.56544 9.78088 7.56543 10.9121V11.9824C7.56543 12.7282 6.96062 13.3327 6.21484 13.333C5.80451 13.333 5.41551 13.1466 5.15918 12.8262L3.03027 10.1641C2.67435 9.71918 2.14844 9.26406 2.14844 8.69433V6.77245C2.14851 6.71573 2.19228 6.66699 2.24902 6.66698C2.65936 6.66698 3.04738 6.8534 3.30371 7.17382L5.43359 9.83593C6.14042 10.7191 7.56543 10.2192 7.56543 9.08788V8.01757C7.56543 7.27154 8.16998 6.66698 8.91602 6.66698C9.32634 6.66701 9.71437 6.85341 9.9707 7.17382L12.1006 9.83593C12.8052 10.7162 14.2225 10.2223 14.2314 9.09863V8.01757C14.2314 7.27154 14.837 6.66698 15.583 6.66698ZM3.33008 1.24999C3.68899 1.24999 4.02863 1.41318 4.25293 1.69335L5.43359 3.16894C6.14036 4.0524 7.56543 3.55227 7.56543 2.42089V1.35058C7.56544 1.29384 7.61417 1.25007 7.6709 1.24999H9.54395C10.1895 1.24999 10.7999 1.54378 11.2031 2.04784L12.1006 3.16894C12.8052 4.04953 14.2227 3.5556 14.2314 2.43163V1.35058C14.2315 1.29384 14.2802 1.25007 14.3369 1.24999H16.2109C16.8565 1.25014 17.4669 1.54375 17.8701 2.04784L18.7666 3.16894C19.1223 3.61354 19.6481 4.06741 19.6484 4.63671V6.56054C19.6483 6.61728 19.6046 6.66698 19.5479 6.66698C19.1377 6.66698 18.7495 6.48034 18.4932 6.16015L16.3633 3.49804C15.6566 2.61465 14.2317 3.11391 14.2314 4.24511V5.3164C14.2312 6.06217 13.6267 6.66698 12.8809 6.66698C12.4707 6.66693 12.0825 6.48039 11.8262 6.16015L9.69629 3.49804C8.98957 2.61465 7.56565 3.11391 7.56543 4.24511V5.3164C7.56518 6.06202 6.96046 6.66673 6.21484 6.66698C5.80453 6.66698 5.41551 6.48054 5.15918 6.16015L3.03027 3.49804C3.00109 3.46156 2.97093 3.42724 2.93945 3.3955C2.59217 3.04543 2.14867 2.65906 2.14844 2.16601C2.14844 1.66032 2.55877 1.24999 3.06445 1.24999H3.33008Z"/>
|
|
36
|
+
<path d="M35.1083 6.97044C34.7154 6.97044 34.5223 6.77734 34.3692 6.39782C33.9963 5.49228 33.0042 4.97958 31.6925 4.97958C30.0146 4.97958 28.9559 5.83851 28.9559 6.99707C28.9493 8.30211 30.3675 8.80149 31.4328 9.07449L32.7645 9.42072C34.4957 9.84686 36.6863 10.799 36.6863 13.2559C36.6863 15.5664 34.8419 17.251 31.6392 17.251C28.8827 17.251 27.0649 16.0125 26.6321 13.9617C26.5389 13.5156 26.7986 13.2559 27.2514 13.2559H27.9971C28.3966 13.2559 28.5897 13.4557 28.7229 13.8485C29.1024 14.9272 30.2676 15.4466 31.6259 15.4466C33.3771 15.4466 34.6488 14.5676 34.6488 13.236C34.6488 12.0241 33.5236 11.5447 32.0654 11.1586L30.454 10.7191C28.2634 10.1265 26.9051 9.01456 26.9051 7.11692C26.9051 4.76651 29.0025 3.20179 31.7391 3.20179C34.2027 3.20179 36.0005 4.45357 36.4133 6.27131C36.5132 6.71076 36.2335 6.97044 35.7874 6.97044H35.1083Z"/>
|
|
37
|
+
<path d="M45.0887 7.46316C45.0887 7.03036 45.3217 6.79732 45.7545 6.79732H46.4204C46.8532 6.79732 47.0862 7.03036 47.0862 7.46316V16.3588C47.0862 16.7915 46.8532 17.0246 46.4204 17.0246H45.7945C45.3617 17.0246 45.1286 16.7915 45.1286 16.3588V15.2535H45.0221C44.5494 16.3454 43.504 17.1578 41.9659 17.1578C40.0017 17.1578 38.6101 15.8394 38.6101 13.3026V7.46316C38.6101 7.03036 38.8431 6.79732 39.2759 6.79732H39.9351C40.3679 6.79732 40.601 7.03036 40.601 7.46316V13.0629C40.601 14.4545 41.4532 15.3467 42.685 15.3467C43.8036 15.3467 45.0954 14.521 45.0887 12.7832V7.46316Z"/>
|
|
38
|
+
<path d="M49.3862 7.46316C49.3862 7.03036 49.6193 6.79732 50.0521 6.79732H50.6447C51.0774 6.79732 51.3105 7.03036 51.3105 7.46316V8.42196H51.417C51.7899 7.32333 52.8153 6.64418 54.0138 6.64418C54.0737 6.64418 54.147 6.64418 54.2202 6.65084C54.6397 6.65749 54.8594 6.91717 54.8594 7.33665V7.88264C54.8594 8.33541 54.673 8.54182 54.3467 8.50852C54.1803 8.48855 53.9938 8.47523 53.8207 8.47523C52.4091 8.47523 51.3771 9.4407 51.3771 10.779V16.3588C51.3771 16.7915 51.144 17.0246 50.7112 17.0246H50.0521C49.6193 17.0246 49.3862 16.7915 49.3862 16.3588V7.46316Z"/>
|
|
39
|
+
<path d="M61.6788 7.72949C61.6788 8.16229 61.4457 8.39533 61.013 8.39533H59.4749V16.3588C59.4749 16.7915 59.2418 17.0246 58.809 17.0246H58.1432C57.7104 17.0246 57.4774 16.7915 57.4774 16.3588V8.39533H56.5652C56.1324 8.39533 55.8993 8.16229 55.8993 7.72949V7.46316C55.8993 7.03036 56.1324 6.79732 56.5652 6.79732H57.4774V5.61213C57.4774 3.69451 58.8956 2.74902 60.5136 2.74902C60.9131 2.74902 61.2593 2.78897 61.539 2.84224C61.9651 2.9288 62.1116 3.2484 61.9917 3.66122L61.8919 4.01412C61.7787 4.42028 61.5656 4.57342 61.2726 4.52015C61.1395 4.49352 60.993 4.4802 60.8199 4.4802C59.8477 4.4802 59.4749 4.97292 59.4749 5.87846V6.79732H61.013C61.4457 6.79732 61.6788 7.03036 61.6788 7.46316V7.72949Z"/>
|
|
40
|
+
</svg>
|
|
41
|
+
</a>
|
|
42
|
+
</body>
|
|
43
|
+
</html>
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "frontend",
|
|
3
|
+
"private": true,
|
|
4
|
+
"type": "module",
|
|
5
|
+
"scripts": {
|
|
6
|
+
"dev": "vite --port 5173",
|
|
7
|
+
"build": "vite build",
|
|
8
|
+
"preview": "vite preview",
|
|
9
|
+
"lint": "eslint ."
|
|
10
|
+
},
|
|
11
|
+
"dependencies": {
|
|
12
|
+
"@surf-ai/sdk": "latest",
|
|
13
|
+
"@surf-ai/theme": "latest",
|
|
14
|
+
"@radix-ui/react-accordion": "1.2.12",
|
|
15
|
+
"@radix-ui/react-aspect-ratio": "1.1.8",
|
|
16
|
+
"@radix-ui/react-avatar": "1.1.11",
|
|
17
|
+
"@radix-ui/react-checkbox": "1.3.3",
|
|
18
|
+
"@radix-ui/react-collapsible": "1.1.12",
|
|
19
|
+
"@radix-ui/react-context-menu": "2.2.16",
|
|
20
|
+
"@radix-ui/react-dialog": "1.1.15",
|
|
21
|
+
"@radix-ui/react-dropdown-menu": "2.1.16",
|
|
22
|
+
"@radix-ui/react-hover-card": "1.1.15",
|
|
23
|
+
"@radix-ui/react-label": "2.1.8",
|
|
24
|
+
"@radix-ui/react-menubar": "1.1.16",
|
|
25
|
+
"@radix-ui/react-navigation-menu": "1.2.14",
|
|
26
|
+
"@radix-ui/react-popover": "1.1.15",
|
|
27
|
+
"@radix-ui/react-progress": "1.1.8",
|
|
28
|
+
"@radix-ui/react-radio-group": "1.3.8",
|
|
29
|
+
"@radix-ui/react-scroll-area": "1.2.10",
|
|
30
|
+
"@radix-ui/react-select": "2.2.6",
|
|
31
|
+
"@radix-ui/react-separator": "1.1.8",
|
|
32
|
+
"@radix-ui/react-slider": "1.3.6",
|
|
33
|
+
"@radix-ui/react-slot": "1.2.4",
|
|
34
|
+
"@radix-ui/react-switch": "1.2.6",
|
|
35
|
+
"@radix-ui/react-tabs": "1.1.13",
|
|
36
|
+
"@radix-ui/react-toast": "1.2.15",
|
|
37
|
+
"@radix-ui/react-toggle": "1.1.10",
|
|
38
|
+
"@radix-ui/react-toggle-group": "1.1.11",
|
|
39
|
+
"@radix-ui/react-tooltip": "1.2.8",
|
|
40
|
+
"@tanstack/react-query": "5.94.5",
|
|
41
|
+
"class-variance-authority": "0.7.1",
|
|
42
|
+
"clsx": "2.1.1",
|
|
43
|
+
"cmdk": "1.1.1",
|
|
44
|
+
"echarts": "5.6.0",
|
|
45
|
+
"echarts-for-react": "3.0.6",
|
|
46
|
+
"embla-carousel-react": "8.6.0",
|
|
47
|
+
"lucide-react": "0.454.0",
|
|
48
|
+
"next-themes": "0.4.6",
|
|
49
|
+
"react": "19.2.4",
|
|
50
|
+
"react-dom": "19.2.4",
|
|
51
|
+
"sonner": "1.7.4",
|
|
52
|
+
"tailwind-merge": "2.6.1",
|
|
53
|
+
"vaul": "1.1.2",
|
|
54
|
+
"zod": "3.25.76"
|
|
55
|
+
},
|
|
56
|
+
"devDependencies": {
|
|
57
|
+
"@eslint/js": "9.39.4",
|
|
58
|
+
"@tailwindcss/vite": "4.2.2",
|
|
59
|
+
"@types/node": "22.19.15",
|
|
60
|
+
"@types/react": "19.2.14",
|
|
61
|
+
"@types/react-dom": "19.2.3",
|
|
62
|
+
"@vitejs/plugin-react": "4.7.0",
|
|
63
|
+
"eslint": "9.39.4",
|
|
64
|
+
"eslint-plugin-react-hooks": "5.2.0",
|
|
65
|
+
"eslint-plugin-react-refresh": "0.4.26",
|
|
66
|
+
"globals": "16.5.0",
|
|
67
|
+
"tailwindcss": "4.2.2",
|
|
68
|
+
"tw-animate-css": "1.4.0",
|
|
69
|
+
"typescript": "5.9.3",
|
|
70
|
+
"typescript-eslint": "8.57.1",
|
|
71
|
+
"vite": "6.4.1"
|
|
72
|
+
}
|
|
73
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { useMarketPrice } from '@surf-ai/sdk/react'
|
|
2
|
+
|
|
3
|
+
export default function App() {
|
|
4
|
+
const { data, isLoading, error } = useMarketPrice({ symbol: 'BTC', time_range: '1d' })
|
|
5
|
+
|
|
6
|
+
return (
|
|
7
|
+
<div className="min-h-screen bg-background text-foreground p-10">
|
|
8
|
+
<h1 className="text-3xl font-bold mb-4">Surf App</h1>
|
|
9
|
+
{isLoading && <p className="text-muted-foreground">Loading BTC price...</p>}
|
|
10
|
+
{error && <p className="text-destructive">Error: {(error as Error).message}</p>}
|
|
11
|
+
{data?.data?.[0] && (
|
|
12
|
+
<p className="text-4xl font-bold">
|
|
13
|
+
BTC: <span className="text-primary">${data.data[0].value?.toLocaleString()}</span>
|
|
14
|
+
</p>
|
|
15
|
+
)}
|
|
16
|
+
</div>
|
|
17
|
+
)
|
|
18
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
// Database schema definition — keep in sync with backend/db/schema.js.
|
|
2
|
+
// This file mirrors the backend schema for TypeScript type safety in the frontend.
|
|
3
|
+
//
|
|
4
|
+
// Example:
|
|
5
|
+
// import { pgTable, serial, text, timestamp } from 'drizzle-orm/pg-core'
|
|
6
|
+
// export const users = pgTable('users', {
|
|
7
|
+
// id: serial('id').primaryKey(),
|
|
8
|
+
// name: text('name').notNull(),
|
|
9
|
+
// created_at: timestamp('created_at').defaultNow(),
|
|
10
|
+
// })
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import './index.css'
|
|
2
|
+
|
|
3
|
+
// ---------------------------------------------------------------------------
|
|
4
|
+
// Cold-start guard: verify React is real BEFORE rendering.
|
|
5
|
+
//
|
|
6
|
+
// During Vite dep-optimization (cold start or after `npm install <pkg>`),
|
|
7
|
+
// Vite may serve placeholder "stub" modules where all React exports are
|
|
8
|
+
// undefined. Rendering with stub hooks causes "Cannot read properties of
|
|
9
|
+
// null (reading 'useState')".
|
|
10
|
+
//
|
|
11
|
+
// Strategy:
|
|
12
|
+
// 1. Dynamic-import React and check if useState is a function.
|
|
13
|
+
// 2. If stub → show a loading banner, schedule a reload with increasing
|
|
14
|
+
// delay, and do NOT mount any React tree (avoids the error entirely).
|
|
15
|
+
// 3. If real → dynamic-import App/ErrorBoundary and render normally.
|
|
16
|
+
// 4. Use createElement (not JSX) in this file so Vite doesn't inject a
|
|
17
|
+
// static `import { jsx } from 'react/jsx-dev-runtime'` which would
|
|
18
|
+
// itself be a stub and crash before our guard runs.
|
|
19
|
+
// ---------------------------------------------------------------------------
|
|
20
|
+
|
|
21
|
+
const RELOAD_KEY = '__dep_reload'
|
|
22
|
+
const MAX_RELOADS = 6
|
|
23
|
+
const RELOAD_WINDOW = 60_000 // 1 minute
|
|
24
|
+
|
|
25
|
+
function getReloads(): { c: number; t: number } {
|
|
26
|
+
try {
|
|
27
|
+
return JSON.parse(sessionStorage.getItem(RELOAD_KEY) || '{}') as { c: number; t: number }
|
|
28
|
+
} catch { return { c: 0, t: 0 } }
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
async function boot() {
|
|
32
|
+
const root = document.getElementById('root')!
|
|
33
|
+
|
|
34
|
+
try {
|
|
35
|
+
const React = await import('react')
|
|
36
|
+
|
|
37
|
+
// If useState is not a function, React is a dep-optimization stub
|
|
38
|
+
if (typeof React.useState !== 'function') throw new Error('dep-stub')
|
|
39
|
+
|
|
40
|
+
const { createElement } = React
|
|
41
|
+
const { createRoot, hydrateRoot } = await import('react-dom/client')
|
|
42
|
+
const { QueryClient, QueryClientProvider } = await import('@tanstack/react-query')
|
|
43
|
+
const { default: App } = await import('./App')
|
|
44
|
+
|
|
45
|
+
const queryClient = new QueryClient({
|
|
46
|
+
defaultOptions: { queries: {
|
|
47
|
+
refetchOnWindowFocus: false,
|
|
48
|
+
retry: 3,
|
|
49
|
+
retryDelay: (attempt) => Math.min(1000 * 2 ** attempt, 10000),
|
|
50
|
+
staleTime: 30_000,
|
|
51
|
+
} },
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
const children = createElement(QueryClientProvider, { client: queryClient },
|
|
55
|
+
createElement(App)
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
// Use hydrateRoot only when SSR rendered real app content. The scaffold's
|
|
59
|
+
// server entry intentionally returns a lightweight placeholder shell so
|
|
60
|
+
// SSR-incompatible libraries (for example echarts-for-react) cannot crash
|
|
61
|
+
// deploy-time render. Placeholder markup must be client-rendered, not hydrated.
|
|
62
|
+
const hasPlaceholder = !!root.querySelector('[data-surf-placeholder]')
|
|
63
|
+
if (root.childNodes.length > 0 && root.innerHTML !== '<!--ssr-outlet-->' && !hasPlaceholder) {
|
|
64
|
+
hydrateRoot(root, children)
|
|
65
|
+
} else {
|
|
66
|
+
root.innerHTML = ''
|
|
67
|
+
createRoot(root).render(children)
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// React rendered successfully — signal to index.html fallback & reset counter
|
|
71
|
+
;(window as any).__reactOk = true
|
|
72
|
+
sessionStorage.removeItem(RELOAD_KEY)
|
|
73
|
+
|
|
74
|
+
// Notify parent frame when real app content renders (not the placeholder).
|
|
75
|
+
// DO NOT REMOVE — the hosting app uses this to dismiss the loading overlay.
|
|
76
|
+
function notifyParentReady() {
|
|
77
|
+
if (!document.querySelector('[data-surf-placeholder]')) {
|
|
78
|
+
try { window.parent.postMessage({ type: 'surf-app-ready' }, '*') } catch { /* cross-origin — ignore */ }
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
notifyParentReady()
|
|
82
|
+
new MutationObserver(notifyParentReady).observe(root, { childList: true, subtree: true })
|
|
83
|
+
} catch {
|
|
84
|
+
// React is not ready — show loading banner and schedule reload
|
|
85
|
+
const prev = getReloads()
|
|
86
|
+
const count = (Date.now() - prev.t > RELOAD_WINDOW) ? 0 : prev.c
|
|
87
|
+
|
|
88
|
+
if (count < MAX_RELOADS) {
|
|
89
|
+
root.innerHTML = [
|
|
90
|
+
'<div style=\"padding:24px;text-align:center;font-family:system-ui,sans-serif\">',
|
|
91
|
+
'<p style=\"color:#3b82f6;font-weight:600;margin:0 0 4px\">Loading dependencies...</p>',
|
|
92
|
+
'<p style=\"color:#3b82f6;opacity:0.7;font-size:12px;margin:0\">Reloading automatically</p>',
|
|
93
|
+
'</div>',
|
|
94
|
+
].join('')
|
|
95
|
+
sessionStorage.setItem(RELOAD_KEY, JSON.stringify({ c: count + 1, t: Date.now() }))
|
|
96
|
+
// Increasing delay: 3s, 4s, 5s, ... gives Vite more time to finish
|
|
97
|
+
setTimeout(() => location.reload(), 3000 + count * 1000)
|
|
98
|
+
} else {
|
|
99
|
+
root.innerHTML = [
|
|
100
|
+
'<div style=\"padding:24px;text-align:center;font-family:system-ui,sans-serif\">',
|
|
101
|
+
'<p style=\"color:#c0392b;font-weight:600;margin:0 0 4px\">Failed to load dependencies</p>',
|
|
102
|
+
'<p style=\"color:#c0392b;opacity:0.8;font-size:12px;margin:0\">Please refresh the page</p>',
|
|
103
|
+
'</div>',
|
|
104
|
+
].join('')
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
boot()
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
/// <reference types="vite/client" />
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ES2020",
|
|
4
|
+
"module": "ESNext",
|
|
5
|
+
"moduleResolution": "bundler",
|
|
6
|
+
"jsx": "react-jsx",
|
|
7
|
+
"strict": true,
|
|
8
|
+
"esModuleInterop": true,
|
|
9
|
+
"skipLibCheck": true,
|
|
10
|
+
"paths": {
|
|
11
|
+
"@/*": [
|
|
12
|
+
"./src/*"
|
|
13
|
+
]
|
|
14
|
+
}
|
|
15
|
+
},
|
|
16
|
+
"include": [
|
|
17
|
+
"src"
|
|
18
|
+
]
|
|
19
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { defineConfig, loadEnv } from 'vite'
|
|
2
|
+
import react from '@vitejs/plugin-react'
|
|
3
|
+
import tailwindcss from '@tailwindcss/vite'
|
|
4
|
+
import path from 'path'
|
|
5
|
+
|
|
6
|
+
const env = loadEnv('', process.cwd(), '')
|
|
7
|
+
const FRONTEND_PORT = parseInt(env.VITE_PORT || '5173', 10)
|
|
8
|
+
const BACKEND_PORT = parseInt(env.VITE_BACKEND_PORT || '3001', 10)
|
|
9
|
+
|
|
10
|
+
export default defineConfig({
|
|
11
|
+
plugins: [react(), tailwindcss()],
|
|
12
|
+
server: {
|
|
13
|
+
port: FRONTEND_PORT,
|
|
14
|
+
host: '0.0.0.0',
|
|
15
|
+
proxy: {
|
|
16
|
+
'/proxy': { target: `http://127.0.0.1:${BACKEND_PORT}`, changeOrigin: true },
|
|
17
|
+
'/api': { target: `http://127.0.0.1:${BACKEND_PORT}`, changeOrigin: true },
|
|
18
|
+
},
|
|
19
|
+
},
|
|
20
|
+
resolve: {
|
|
21
|
+
alias: {
|
|
22
|
+
'@': path.resolve(__dirname, './src'),
|
|
23
|
+
},
|
|
24
|
+
},
|
|
25
|
+
})
|
package/package.json
CHANGED
|
@@ -1,23 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Drizzle ORM client (pg-proxy driver).
|
|
3
|
-
*
|
|
4
|
-
* Routes all SQL through hermod's DB proxy — works identically in
|
|
5
|
-
* sandbox (OutboundProxy) and deployed mode (hermod gateway).
|
|
6
|
-
*
|
|
7
|
-
* Usage:
|
|
8
|
-
* const { db } = require('./db');
|
|
9
|
-
* const { todos } = require('./db/schema');
|
|
10
|
-
* const rows = await db.select().from(todos);
|
|
11
|
-
*/
|
|
12
|
-
|
|
13
|
-
const { drizzle } = require('drizzle-orm/pg-proxy');
|
|
14
|
-
const { dbQuery } = require('../lib/db');
|
|
15
|
-
|
|
16
|
-
const db = drizzle(async (sql, params, method) => {
|
|
17
|
-
const result = await dbQuery(sql, params, { arrayMode: true });
|
|
18
|
-
// pg-proxy expects rows as positional arrays — arrayMode makes hermod
|
|
19
|
-
// return [[val1, val2, ...], ...] instead of [{col: val}, ...].
|
|
20
|
-
return { rows: result.rows || [] };
|
|
21
|
-
});
|
|
22
|
-
|
|
23
|
-
module.exports = { db };
|
|
@@ -1,67 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Server-side database helpers.
|
|
3
|
-
*
|
|
4
|
-
* All SQL execution happens here in the backend — NEVER in frontend code.
|
|
5
|
-
* Calls /proxy/db/* via loopback through the Express proxy middleware,
|
|
6
|
-
* which handles both sandbox mode (OutboundProxy) and deployed mode (hermod).
|
|
7
|
-
*/
|
|
8
|
-
|
|
9
|
-
const PORT = Number.parseInt(process.env.PORT || '', 10);
|
|
10
|
-
if (!Number.isInteger(PORT)) {
|
|
11
|
-
throw new Error('PORT env var is required');
|
|
12
|
-
}
|
|
13
|
-
const BASE = `http://127.0.0.1:${PORT}/proxy/db`;
|
|
14
|
-
|
|
15
|
-
async function request(path, options = {}) {
|
|
16
|
-
const res = await fetch(`${BASE}${path}`, options);
|
|
17
|
-
if (!res.ok) {
|
|
18
|
-
const body = await res.json().catch(() => ({ message: res.statusText }));
|
|
19
|
-
const err = new Error(body.detail || body.error || body.message || `DB request failed: ${res.status}`);
|
|
20
|
-
err.status = res.status;
|
|
21
|
-
throw err;
|
|
22
|
-
}
|
|
23
|
-
return res.json();
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
/** Provision a database for the current user (idempotent). */
|
|
27
|
-
async function dbProvision(reason) {
|
|
28
|
-
return request('/provision', {
|
|
29
|
-
method: 'POST',
|
|
30
|
-
headers: { 'Content-Type': 'application/json' },
|
|
31
|
-
body: JSON.stringify({ reason: reason || '' }),
|
|
32
|
-
});
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
/**
|
|
36
|
-
* Execute a parameterized SQL query.
|
|
37
|
-
* ALWAYS use $1, $2, ... placeholders — NEVER concatenate user input.
|
|
38
|
-
* @param {string} sql
|
|
39
|
-
* @param {any[]} params
|
|
40
|
-
* @param {{ arrayMode?: boolean }} [options] - Pass { arrayMode: true } for Drizzle pg-proxy
|
|
41
|
-
*/
|
|
42
|
-
async function dbQuery(sql, params = [], options = {}) {
|
|
43
|
-
return request('/query', {
|
|
44
|
-
method: 'POST',
|
|
45
|
-
headers: { 'Content-Type': 'application/json' },
|
|
46
|
-
body: JSON.stringify({ sql, params, ...options }),
|
|
47
|
-
});
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
/** List all tables with approximate row counts. */
|
|
51
|
-
async function dbTables() {
|
|
52
|
-
const data = await request('/tables');
|
|
53
|
-
return data.tables;
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
/** Get column definitions for a table. */
|
|
57
|
-
async function dbTableSchema(name) {
|
|
58
|
-
const data = await request(`/tables/${encodeURIComponent(name)}/schema`);
|
|
59
|
-
return data.columns;
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
/** Check database connection status and usage. */
|
|
63
|
-
async function dbStatus() {
|
|
64
|
-
return request('/status');
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
module.exports = { dbProvision, dbQuery, dbTables, dbTableSchema, dbStatus };
|
|
@@ -1,66 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Catch-all proxy to data APIs.
|
|
3
|
-
*
|
|
4
|
-
* Sandbox mode: forwards /proxy/* to OutboundProxy at 127.0.0.1:9999
|
|
5
|
-
* Deployed mode: transparent pass-through /proxy/{path} -> hermod /gateway/v1/{path}
|
|
6
|
-
*
|
|
7
|
-
* IMPORTANT: All proxy middlewares use selfHandleResponse + responseInterceptor
|
|
8
|
-
* to buffer the full upstream response before sending. This forces Express to
|
|
9
|
-
* respond with Content-Length instead of Transfer-Encoding: chunked, which is
|
|
10
|
-
* required for compatibility with the KEDA HTTP interceptor (it drops chunked
|
|
11
|
-
* response bodies when the upstream sends Connection: close).
|
|
12
|
-
*/
|
|
13
|
-
|
|
14
|
-
const { createProxyMiddleware, responseInterceptor } = require('http-proxy-middleware');
|
|
15
|
-
|
|
16
|
-
const GATEWAY_URL = process.env.GATEWAY_URL;
|
|
17
|
-
const APP_TOKEN = process.env.APP_TOKEN;
|
|
18
|
-
const IS_DEPLOYED = Boolean(GATEWAY_URL && APP_TOKEN);
|
|
19
|
-
|
|
20
|
-
// Shared response interceptor: buffer full response so Express sends Content-Length
|
|
21
|
-
// instead of Transfer-Encoding: chunked (fixes KEDA HTTP interceptor body-drop bug).
|
|
22
|
-
const bufferResponse = responseInterceptor(async (responseBuffer, _proxyRes, _req, _res) => {
|
|
23
|
-
return responseBuffer;
|
|
24
|
-
});
|
|
25
|
-
|
|
26
|
-
function setupProxyRoutes(app) {
|
|
27
|
-
if (IS_DEPLOYED) {
|
|
28
|
-
// Transparent pass-through: /proxy/{path} -> hermod /gateway/v1/{path}
|
|
29
|
-
// pathRewrite strips /proxy prefix and prepends /gateway/v1
|
|
30
|
-
app.use('/proxy', createProxyMiddleware({
|
|
31
|
-
target: GATEWAY_URL,
|
|
32
|
-
changeOrigin: true,
|
|
33
|
-
selfHandleResponse: true,
|
|
34
|
-
pathRewrite: (p) => '/gateway/v1' + p,
|
|
35
|
-
headers: { Authorization: `Bearer ${APP_TOKEN}`, 'Accept-Encoding': 'identity' },
|
|
36
|
-
on: { proxyRes: bufferResponse },
|
|
37
|
-
}));
|
|
38
|
-
} else {
|
|
39
|
-
// Sandbox: forward to OutboundProxy (handles all routing internally)
|
|
40
|
-
app.use(
|
|
41
|
-
createProxyMiddleware({
|
|
42
|
-
target: process.env.DATA_PROXY_BASE ? process.env.DATA_PROXY_BASE.replace(/\/proxy$/, '') : 'http://127.0.0.1:9999',
|
|
43
|
-
changeOrigin: true,
|
|
44
|
-
selfHandleResponse: true,
|
|
45
|
-
pathFilter: '/proxy',
|
|
46
|
-
on: {
|
|
47
|
-
proxyReq: (proxyReq, req) => {
|
|
48
|
-
console.log(`[proxy] >> ${req.method} ${req.originalUrl}`);
|
|
49
|
-
},
|
|
50
|
-
proxyRes: responseInterceptor(async (responseBuffer, proxyRes, req, _res) => {
|
|
51
|
-
const status = proxyRes.statusCode;
|
|
52
|
-
const tag = status >= 400 ? 'ERR' : 'OK';
|
|
53
|
-
console.log(`[proxy] << ${status} ${tag} ${req.method} ${req.originalUrl} bytes=${responseBuffer.length}`);
|
|
54
|
-
return responseBuffer;
|
|
55
|
-
}),
|
|
56
|
-
error: (err, req, res) => {
|
|
57
|
-
console.error(`[proxy] !! ${req.method} ${req.originalUrl} error: ${err.message}`);
|
|
58
|
-
if (!res.headersSent) res.status(502).json({ error: err.message });
|
|
59
|
-
},
|
|
60
|
-
},
|
|
61
|
-
})
|
|
62
|
-
);
|
|
63
|
-
}
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
module.exports = { setupProxyRoutes };
|