create-surf-app 0.1.14 → 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/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/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
|
@@ -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()
|
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 };
|