charon-hooks 0.1.0
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/LICENSE +21 -0
- package/README.md +156 -0
- package/bin/charon.js +103 -0
- package/dist/client/assets/index-Ccj2TupZ.css +1 -0
- package/dist/client/assets/index-hnnw32hs.js +223 -0
- package/dist/client/favicon.svg +65 -0
- package/dist/client/file.svg +1 -0
- package/dist/client/globe.svg +1 -0
- package/dist/client/index.html +14 -0
- package/dist/client/next.svg +1 -0
- package/dist/client/vercel.svg +1 -0
- package/dist/client/window.svg +1 -0
- package/dist/server/index.js +1610 -0
- package/package.json +94 -0
|
@@ -0,0 +1,1610 @@
|
|
|
1
|
+
// src/server/app.ts
|
|
2
|
+
import { Hono as Hono7 } from "hono";
|
|
3
|
+
import { logger } from "hono/logger";
|
|
4
|
+
|
|
5
|
+
// src/server/middleware/static.ts
|
|
6
|
+
import { createMiddleware } from "hono/factory";
|
|
7
|
+
import { readFileSync, existsSync, statSync } from "fs";
|
|
8
|
+
import { join, extname, resolve } from "path";
|
|
9
|
+
var MIME_TYPES = {
|
|
10
|
+
".html": "text/html; charset=utf-8",
|
|
11
|
+
".css": "text/css; charset=utf-8",
|
|
12
|
+
".js": "text/javascript; charset=utf-8",
|
|
13
|
+
".json": "application/json; charset=utf-8",
|
|
14
|
+
".png": "image/png",
|
|
15
|
+
".jpg": "image/jpeg",
|
|
16
|
+
".jpeg": "image/jpeg",
|
|
17
|
+
".gif": "image/gif",
|
|
18
|
+
".svg": "image/svg+xml",
|
|
19
|
+
".ico": "image/x-icon",
|
|
20
|
+
".woff": "font/woff",
|
|
21
|
+
".woff2": "font/woff2",
|
|
22
|
+
".ttf": "font/ttf",
|
|
23
|
+
".eot": "application/vnd.ms-fontobject"
|
|
24
|
+
};
|
|
25
|
+
function serveStatic({ root, path: fallbackPath }) {
|
|
26
|
+
const rootDir = root ? resolve(root) : process.cwd();
|
|
27
|
+
const fallback = fallbackPath ? resolve(fallbackPath) : null;
|
|
28
|
+
return createMiddleware(async (c, next) => {
|
|
29
|
+
if (c.req.method !== "GET" && c.req.method !== "HEAD") {
|
|
30
|
+
return next();
|
|
31
|
+
}
|
|
32
|
+
const url = new URL(c.req.url);
|
|
33
|
+
let pathname = decodeURIComponent(url.pathname);
|
|
34
|
+
if (pathname.includes("..")) {
|
|
35
|
+
return next();
|
|
36
|
+
}
|
|
37
|
+
if (root) {
|
|
38
|
+
const relativePath = pathname.replace(/^\//, "");
|
|
39
|
+
const filePath = join(rootDir, relativePath);
|
|
40
|
+
if (existsSync(filePath)) {
|
|
41
|
+
try {
|
|
42
|
+
const stat = statSync(filePath);
|
|
43
|
+
if (stat.isFile()) {
|
|
44
|
+
const ext = extname(filePath).toLowerCase();
|
|
45
|
+
const contentType = MIME_TYPES[ext] || "application/octet-stream";
|
|
46
|
+
const content = readFileSync(filePath);
|
|
47
|
+
return new Response(content, {
|
|
48
|
+
headers: {
|
|
49
|
+
"Content-Type": contentType,
|
|
50
|
+
"Content-Length": String(content.length)
|
|
51
|
+
}
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
} catch {
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
if (fallback && existsSync(fallback)) {
|
|
59
|
+
try {
|
|
60
|
+
const stat = statSync(fallback);
|
|
61
|
+
if (stat.isFile()) {
|
|
62
|
+
const ext = extname(fallback).toLowerCase();
|
|
63
|
+
const contentType = MIME_TYPES[ext] || "application/octet-stream";
|
|
64
|
+
const content = readFileSync(fallback);
|
|
65
|
+
return new Response(content, {
|
|
66
|
+
headers: {
|
|
67
|
+
"Content-Type": contentType,
|
|
68
|
+
"Content-Length": String(content.length)
|
|
69
|
+
}
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
} catch {
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
return next();
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// src/server/routes/triggers.ts
|
|
80
|
+
import { Hono } from "hono";
|
|
81
|
+
|
|
82
|
+
// src/lib/config/loader.ts
|
|
83
|
+
import { readFileSync as readFileSync2, writeFileSync, mkdirSync, watch, existsSync as existsSync2 } from "fs";
|
|
84
|
+
import { dirname } from "path";
|
|
85
|
+
|
|
86
|
+
// src/lib/config/schema.ts
|
|
87
|
+
import { z } from "zod";
|
|
88
|
+
import { parse as parseYaml } from "yaml";
|
|
89
|
+
var TriggerSchema = z.object({
|
|
90
|
+
id: z.string().min(1),
|
|
91
|
+
name: z.string().min(1),
|
|
92
|
+
type: z.enum(["webhook", "cron"]),
|
|
93
|
+
enabled: z.boolean(),
|
|
94
|
+
schedule: z.string().optional(),
|
|
95
|
+
template: z.string().min(1),
|
|
96
|
+
sanitizer: z.string().optional(),
|
|
97
|
+
egress: z.string().min(1),
|
|
98
|
+
context: z.record(z.string(), z.unknown()).optional()
|
|
99
|
+
}).refine((data) => data.type !== "cron" || data.schedule, {
|
|
100
|
+
message: "Cron triggers require a schedule"
|
|
101
|
+
});
|
|
102
|
+
var TunnelSchema = z.object({
|
|
103
|
+
enabled: z.boolean().default(false),
|
|
104
|
+
provider: z.enum(["ngrok"]).default("ngrok"),
|
|
105
|
+
authtoken: z.string().min(1),
|
|
106
|
+
domain: z.string().optional(),
|
|
107
|
+
// Static domain (e.g., "your-name.ngrok-free.app")
|
|
108
|
+
expose_ui: z.boolean().default(false)
|
|
109
|
+
// Only expose /api/webhook/* by default
|
|
110
|
+
});
|
|
111
|
+
var ConfigSchema = z.object({
|
|
112
|
+
triggers: z.array(TriggerSchema),
|
|
113
|
+
tunnel: TunnelSchema.optional()
|
|
114
|
+
});
|
|
115
|
+
function validateTrigger(input) {
|
|
116
|
+
const result = TriggerSchema.safeParse(input);
|
|
117
|
+
if (result.success) {
|
|
118
|
+
return { success: true, data: result.data };
|
|
119
|
+
}
|
|
120
|
+
return { success: false, error: result.error };
|
|
121
|
+
}
|
|
122
|
+
function parseConfig(yamlContent) {
|
|
123
|
+
const parsed = parseYaml(yamlContent);
|
|
124
|
+
const result = ConfigSchema.safeParse(parsed);
|
|
125
|
+
if (result.success) {
|
|
126
|
+
return { success: true, data: result.data };
|
|
127
|
+
}
|
|
128
|
+
return { success: false, error: result.error };
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// src/lib/db/sqlite.ts
|
|
132
|
+
var createDatabase;
|
|
133
|
+
var isBun = typeof globalThis.Bun !== "undefined";
|
|
134
|
+
if (isBun) {
|
|
135
|
+
const { Database } = await import("bun:sqlite");
|
|
136
|
+
createDatabase = (path) => {
|
|
137
|
+
const db2 = new Database(path);
|
|
138
|
+
return {
|
|
139
|
+
exec: (sql) => db2.run(sql),
|
|
140
|
+
prepare: (sql) => {
|
|
141
|
+
const stmt = db2.query(sql);
|
|
142
|
+
return {
|
|
143
|
+
run: (...params) => stmt.run(...params),
|
|
144
|
+
get: (...params) => stmt.get(...params),
|
|
145
|
+
all: (...params) => stmt.all(...params)
|
|
146
|
+
};
|
|
147
|
+
},
|
|
148
|
+
close: () => db2.close()
|
|
149
|
+
};
|
|
150
|
+
};
|
|
151
|
+
} else {
|
|
152
|
+
const BetterSqlite3 = (await import("better-sqlite3")).default;
|
|
153
|
+
createDatabase = (path) => {
|
|
154
|
+
const db2 = new BetterSqlite3(path);
|
|
155
|
+
return {
|
|
156
|
+
exec: (sql) => db2.exec(sql),
|
|
157
|
+
prepare: (sql) => {
|
|
158
|
+
const stmt = db2.prepare(sql);
|
|
159
|
+
return {
|
|
160
|
+
run: (...params) => stmt.run(...params),
|
|
161
|
+
get: (...params) => stmt.get(...params),
|
|
162
|
+
all: (...params) => stmt.all(...params)
|
|
163
|
+
};
|
|
164
|
+
},
|
|
165
|
+
close: () => db2.close()
|
|
166
|
+
};
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// src/lib/db/schema.ts
|
|
171
|
+
function initSchema(db2) {
|
|
172
|
+
db2.exec(`
|
|
173
|
+
CREATE TABLE IF NOT EXISTS runs (
|
|
174
|
+
id TEXT PRIMARY KEY,
|
|
175
|
+
trigger_id TEXT NOT NULL,
|
|
176
|
+
trigger_type TEXT NOT NULL,
|
|
177
|
+
status TEXT NOT NULL DEFAULT 'pending',
|
|
178
|
+
started_at TEXT NOT NULL,
|
|
179
|
+
completed_at TEXT,
|
|
180
|
+
webhook_payload TEXT,
|
|
181
|
+
webhook_headers TEXT,
|
|
182
|
+
sanitizer_result TEXT,
|
|
183
|
+
composer_result TEXT,
|
|
184
|
+
task_descriptor TEXT,
|
|
185
|
+
egress_result TEXT,
|
|
186
|
+
error TEXT
|
|
187
|
+
)
|
|
188
|
+
`);
|
|
189
|
+
try {
|
|
190
|
+
db2.exec(`ALTER TABLE runs ADD COLUMN egress_result TEXT`);
|
|
191
|
+
} catch {
|
|
192
|
+
}
|
|
193
|
+
db2.exec(`CREATE INDEX IF NOT EXISTS idx_runs_trigger ON runs(trigger_id)`);
|
|
194
|
+
db2.exec(`CREATE INDEX IF NOT EXISTS idx_runs_status ON runs(status)`);
|
|
195
|
+
db2.exec(`CREATE INDEX IF NOT EXISTS idx_runs_started ON runs(started_at DESC)`);
|
|
196
|
+
db2.exec(`
|
|
197
|
+
CREATE TABLE IF NOT EXISTS events (
|
|
198
|
+
id TEXT PRIMARY KEY,
|
|
199
|
+
type TEXT NOT NULL,
|
|
200
|
+
trigger_id TEXT NOT NULL,
|
|
201
|
+
run_id TEXT NOT NULL,
|
|
202
|
+
timestamp TEXT NOT NULL,
|
|
203
|
+
data TEXT NOT NULL
|
|
204
|
+
)
|
|
205
|
+
`);
|
|
206
|
+
db2.exec(`CREATE INDEX IF NOT EXISTS idx_events_run ON events(run_id)`);
|
|
207
|
+
db2.exec(`CREATE INDEX IF NOT EXISTS idx_events_type ON events(type)`);
|
|
208
|
+
db2.exec(`
|
|
209
|
+
CREATE TABLE IF NOT EXISTS tunnel_state (
|
|
210
|
+
id INTEGER PRIMARY KEY CHECK (id = 1),
|
|
211
|
+
configured INTEGER NOT NULL DEFAULT 0,
|
|
212
|
+
enabled INTEGER NOT NULL DEFAULT 0,
|
|
213
|
+
connected INTEGER NOT NULL DEFAULT 0,
|
|
214
|
+
url TEXT,
|
|
215
|
+
domain TEXT,
|
|
216
|
+
expose_ui INTEGER NOT NULL DEFAULT 0,
|
|
217
|
+
error TEXT,
|
|
218
|
+
updated_at TEXT NOT NULL
|
|
219
|
+
)
|
|
220
|
+
`);
|
|
221
|
+
try {
|
|
222
|
+
db2.exec(`ALTER TABLE tunnel_state ADD COLUMN configured INTEGER NOT NULL DEFAULT 0`);
|
|
223
|
+
} catch {
|
|
224
|
+
}
|
|
225
|
+
db2.exec(`
|
|
226
|
+
INSERT OR IGNORE INTO tunnel_state (id, configured, enabled, connected, expose_ui, updated_at)
|
|
227
|
+
VALUES (1, 0, 0, 0, 0, datetime('now'))
|
|
228
|
+
`);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// src/lib/db/client.ts
|
|
232
|
+
var db = null;
|
|
233
|
+
function getDb() {
|
|
234
|
+
if (!db) {
|
|
235
|
+
const dbPath = process.env.CHARON_DB || "charon.db";
|
|
236
|
+
db = createDatabase(dbPath);
|
|
237
|
+
initSchema(db);
|
|
238
|
+
}
|
|
239
|
+
return db;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// src/lib/scheduler/cron.ts
|
|
243
|
+
import cron from "node-cron";
|
|
244
|
+
|
|
245
|
+
// src/lib/db/runs.ts
|
|
246
|
+
function generateId() {
|
|
247
|
+
return "run_" + Math.random().toString(36).slice(2, 10);
|
|
248
|
+
}
|
|
249
|
+
function createRun(db2, input) {
|
|
250
|
+
const id = generateId();
|
|
251
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
252
|
+
db2.prepare(
|
|
253
|
+
`INSERT INTO runs (id, trigger_id, trigger_type, status, started_at, webhook_payload, webhook_headers)
|
|
254
|
+
VALUES (?, ?, ?, 'pending', ?, ?, ?)`
|
|
255
|
+
).run(
|
|
256
|
+
id,
|
|
257
|
+
input.trigger_id,
|
|
258
|
+
input.trigger_type,
|
|
259
|
+
now,
|
|
260
|
+
input.webhook_payload ? JSON.stringify(input.webhook_payload) : null,
|
|
261
|
+
input.webhook_headers ? JSON.stringify(input.webhook_headers) : null
|
|
262
|
+
);
|
|
263
|
+
return getRun(db2, id);
|
|
264
|
+
}
|
|
265
|
+
function getRun(db2, id) {
|
|
266
|
+
const row = db2.prepare(`SELECT * FROM runs WHERE id = ?`).get(id);
|
|
267
|
+
if (!row) return null;
|
|
268
|
+
return deserializeRun(row);
|
|
269
|
+
}
|
|
270
|
+
function updateRun(db2, id, input) {
|
|
271
|
+
const sets = [];
|
|
272
|
+
const values = [];
|
|
273
|
+
if (input.status !== void 0) {
|
|
274
|
+
sets.push("status = ?");
|
|
275
|
+
values.push(input.status);
|
|
276
|
+
}
|
|
277
|
+
if (input.completed_at !== void 0) {
|
|
278
|
+
sets.push("completed_at = ?");
|
|
279
|
+
values.push(input.completed_at);
|
|
280
|
+
}
|
|
281
|
+
if (input.sanitizer_result !== void 0) {
|
|
282
|
+
sets.push("sanitizer_result = ?");
|
|
283
|
+
values.push(JSON.stringify(input.sanitizer_result));
|
|
284
|
+
}
|
|
285
|
+
if (input.composer_result !== void 0) {
|
|
286
|
+
sets.push("composer_result = ?");
|
|
287
|
+
values.push(JSON.stringify(input.composer_result));
|
|
288
|
+
}
|
|
289
|
+
if (input.task_descriptor !== void 0) {
|
|
290
|
+
sets.push("task_descriptor = ?");
|
|
291
|
+
values.push(JSON.stringify(input.task_descriptor));
|
|
292
|
+
}
|
|
293
|
+
if (input.egress_result !== void 0) {
|
|
294
|
+
sets.push("egress_result = ?");
|
|
295
|
+
values.push(JSON.stringify(input.egress_result));
|
|
296
|
+
}
|
|
297
|
+
if (input.error !== void 0) {
|
|
298
|
+
sets.push("error = ?");
|
|
299
|
+
values.push(JSON.stringify(input.error));
|
|
300
|
+
}
|
|
301
|
+
if (sets.length === 0) return;
|
|
302
|
+
values.push(id);
|
|
303
|
+
db2.prepare(`UPDATE runs SET ${sets.join(", ")} WHERE id = ?`).run(...values);
|
|
304
|
+
}
|
|
305
|
+
function listRuns(db2, filter) {
|
|
306
|
+
const conditions = [];
|
|
307
|
+
const values = [];
|
|
308
|
+
if (filter.trigger_id) {
|
|
309
|
+
conditions.push("trigger_id = ?");
|
|
310
|
+
values.push(filter.trigger_id);
|
|
311
|
+
}
|
|
312
|
+
if (filter.status) {
|
|
313
|
+
conditions.push("status = ?");
|
|
314
|
+
values.push(filter.status);
|
|
315
|
+
}
|
|
316
|
+
let sql = "SELECT * FROM runs";
|
|
317
|
+
if (conditions.length > 0) {
|
|
318
|
+
sql += " WHERE " + conditions.join(" AND ");
|
|
319
|
+
}
|
|
320
|
+
sql += " ORDER BY started_at DESC";
|
|
321
|
+
if (filter.limit) {
|
|
322
|
+
sql += " LIMIT ?";
|
|
323
|
+
values.push(filter.limit);
|
|
324
|
+
}
|
|
325
|
+
if (filter.offset) {
|
|
326
|
+
sql += " OFFSET ?";
|
|
327
|
+
values.push(filter.offset);
|
|
328
|
+
}
|
|
329
|
+
const rows = db2.prepare(sql).all(...values);
|
|
330
|
+
return rows.map(deserializeRun);
|
|
331
|
+
}
|
|
332
|
+
function deserializeRun(row) {
|
|
333
|
+
return {
|
|
334
|
+
id: row.id,
|
|
335
|
+
trigger_id: row.trigger_id,
|
|
336
|
+
trigger_type: row.trigger_type,
|
|
337
|
+
status: row.status,
|
|
338
|
+
started_at: row.started_at,
|
|
339
|
+
completed_at: row.completed_at,
|
|
340
|
+
webhook_payload: row.webhook_payload ? JSON.parse(row.webhook_payload) : void 0,
|
|
341
|
+
webhook_headers: row.webhook_headers ? JSON.parse(row.webhook_headers) : void 0,
|
|
342
|
+
sanitizer_result: row.sanitizer_result ? JSON.parse(row.sanitizer_result) : null,
|
|
343
|
+
composer_result: row.composer_result ? JSON.parse(row.composer_result) : null,
|
|
344
|
+
task_descriptor: row.task_descriptor ? JSON.parse(row.task_descriptor) : null,
|
|
345
|
+
egress_result: row.egress_result ? JSON.parse(row.egress_result) : null,
|
|
346
|
+
error: row.error ? JSON.parse(row.error) : null
|
|
347
|
+
};
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
// src/lib/db/events.ts
|
|
351
|
+
function generateId2() {
|
|
352
|
+
return "evt_" + Math.random().toString(36).slice(2, 10);
|
|
353
|
+
}
|
|
354
|
+
function logEvent(db2, input) {
|
|
355
|
+
const id = generateId2();
|
|
356
|
+
const timestamp = (/* @__PURE__ */ new Date()).toISOString();
|
|
357
|
+
db2.prepare(
|
|
358
|
+
`INSERT INTO events (id, type, trigger_id, run_id, timestamp, data)
|
|
359
|
+
VALUES (?, ?, ?, ?, ?, ?)`
|
|
360
|
+
).run(id, input.type, input.trigger_id, input.run_id, timestamp, JSON.stringify(input.data));
|
|
361
|
+
return { id, timestamp, ...input };
|
|
362
|
+
}
|
|
363
|
+
function listEvents(db2, filter) {
|
|
364
|
+
const conditions = [];
|
|
365
|
+
const values = [];
|
|
366
|
+
if (filter.run_id) {
|
|
367
|
+
conditions.push("run_id = ?");
|
|
368
|
+
values.push(filter.run_id);
|
|
369
|
+
}
|
|
370
|
+
if (filter.trigger_id) {
|
|
371
|
+
conditions.push("trigger_id = ?");
|
|
372
|
+
values.push(filter.trigger_id);
|
|
373
|
+
}
|
|
374
|
+
if (filter.type) {
|
|
375
|
+
conditions.push("type = ?");
|
|
376
|
+
values.push(filter.type);
|
|
377
|
+
}
|
|
378
|
+
let sql = "SELECT * FROM events";
|
|
379
|
+
if (conditions.length > 0) {
|
|
380
|
+
sql += " WHERE " + conditions.join(" AND ");
|
|
381
|
+
}
|
|
382
|
+
sql += " ORDER BY timestamp DESC";
|
|
383
|
+
if (filter.limit) {
|
|
384
|
+
sql += " LIMIT ?";
|
|
385
|
+
values.push(filter.limit);
|
|
386
|
+
}
|
|
387
|
+
const rows = db2.prepare(sql).all(...values);
|
|
388
|
+
return rows.map((row) => ({
|
|
389
|
+
id: row.id,
|
|
390
|
+
type: row.type,
|
|
391
|
+
trigger_id: row.trigger_id,
|
|
392
|
+
run_id: row.run_id,
|
|
393
|
+
timestamp: row.timestamp,
|
|
394
|
+
data: JSON.parse(row.data)
|
|
395
|
+
}));
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
// src/lib/pipeline/sanitizer.ts
|
|
399
|
+
import { resolve as resolve2 } from "path";
|
|
400
|
+
import { pathToFileURL } from "url";
|
|
401
|
+
var sanitizerCache = /* @__PURE__ */ new Map();
|
|
402
|
+
var SANITIZERS_DIR = process.env.CHARON_SANITIZERS_DIR || "sanitizers";
|
|
403
|
+
async function loadSanitizer(name) {
|
|
404
|
+
if (sanitizerCache.has(name)) {
|
|
405
|
+
return sanitizerCache.get(name);
|
|
406
|
+
}
|
|
407
|
+
try {
|
|
408
|
+
const sanitizerPath = resolve2(SANITIZERS_DIR, `${name}.ts`);
|
|
409
|
+
const sanitizerUrl = pathToFileURL(sanitizerPath).href;
|
|
410
|
+
const mod = await import(sanitizerUrl);
|
|
411
|
+
const fn = mod.default;
|
|
412
|
+
sanitizerCache.set(name, fn);
|
|
413
|
+
return fn;
|
|
414
|
+
} catch {
|
|
415
|
+
sanitizerCache.set(name, null);
|
|
416
|
+
return null;
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
async function executeSanitizer(sanitizer, payload, headers, trigger) {
|
|
420
|
+
if (!sanitizer) {
|
|
421
|
+
return { payload };
|
|
422
|
+
}
|
|
423
|
+
const result = await sanitizer(payload, headers, trigger);
|
|
424
|
+
return result;
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
// src/lib/egress/loader.ts
|
|
428
|
+
var egressCache = /* @__PURE__ */ new Map();
|
|
429
|
+
async function loadEgress(name) {
|
|
430
|
+
if (egressCache.has(name)) {
|
|
431
|
+
return egressCache.get(name);
|
|
432
|
+
}
|
|
433
|
+
try {
|
|
434
|
+
const mod = await import(`@/egress/${name}`);
|
|
435
|
+
const fn = mod.default;
|
|
436
|
+
egressCache.set(name, fn);
|
|
437
|
+
return fn;
|
|
438
|
+
} catch {
|
|
439
|
+
egressCache.set(name, null);
|
|
440
|
+
return null;
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
async function executeEgress(egress, task, trigger) {
|
|
444
|
+
await egress(task, trigger);
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
// src/lib/pipeline/composer.ts
|
|
448
|
+
function compose(template, context) {
|
|
449
|
+
return template.replace(/\{(\w+)\}/g, (match, key) => {
|
|
450
|
+
if (key in context) {
|
|
451
|
+
const value = context[key];
|
|
452
|
+
return typeof value === "object" ? JSON.stringify(value) : String(value);
|
|
453
|
+
}
|
|
454
|
+
return match;
|
|
455
|
+
});
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
// src/lib/pipeline/processor.ts
|
|
459
|
+
function generateTaskId() {
|
|
460
|
+
return "task_" + Math.random().toString(36).slice(2, 10);
|
|
461
|
+
}
|
|
462
|
+
async function processWebhook(db2, trigger, payload, headers) {
|
|
463
|
+
const run = createRun(db2, {
|
|
464
|
+
trigger_id: trigger.id,
|
|
465
|
+
trigger_type: "webhook",
|
|
466
|
+
webhook_payload: payload,
|
|
467
|
+
webhook_headers: headers
|
|
468
|
+
});
|
|
469
|
+
logEvent(db2, {
|
|
470
|
+
type: "trigger.received",
|
|
471
|
+
trigger_id: trigger.id,
|
|
472
|
+
run_id: run.id,
|
|
473
|
+
data: { payload_size: JSON.stringify(payload).length }
|
|
474
|
+
});
|
|
475
|
+
updateRun(db2, run.id, { status: "processing" });
|
|
476
|
+
return processPipeline(db2, run.id, trigger, payload, headers);
|
|
477
|
+
}
|
|
478
|
+
async function processCron(db2, trigger) {
|
|
479
|
+
const run = createRun(db2, {
|
|
480
|
+
trigger_id: trigger.id,
|
|
481
|
+
trigger_type: "cron"
|
|
482
|
+
});
|
|
483
|
+
logEvent(db2, {
|
|
484
|
+
type: "trigger.scheduled",
|
|
485
|
+
trigger_id: trigger.id,
|
|
486
|
+
run_id: run.id,
|
|
487
|
+
data: { scheduled_time: (/* @__PURE__ */ new Date()).toISOString() }
|
|
488
|
+
});
|
|
489
|
+
updateRun(db2, run.id, { status: "processing" });
|
|
490
|
+
const cronContext = {
|
|
491
|
+
trigger_time: (/* @__PURE__ */ new Date()).toISOString(),
|
|
492
|
+
trigger_id: trigger.id,
|
|
493
|
+
...trigger.context
|
|
494
|
+
};
|
|
495
|
+
return processPipeline(db2, run.id, trigger, cronContext, {});
|
|
496
|
+
}
|
|
497
|
+
async function processPipeline(db2, runId, trigger, payload, headers) {
|
|
498
|
+
const startTime = Date.now();
|
|
499
|
+
try {
|
|
500
|
+
let context;
|
|
501
|
+
if (trigger.sanitizer) {
|
|
502
|
+
const sanitizerStart = Date.now();
|
|
503
|
+
const sanitizer = await loadSanitizer(trigger.sanitizer);
|
|
504
|
+
const result = await executeSanitizer(sanitizer, payload, headers, trigger);
|
|
505
|
+
if (result === null) {
|
|
506
|
+
updateRun(db2, runId, {
|
|
507
|
+
status: "skipped",
|
|
508
|
+
completed_at: (/* @__PURE__ */ new Date()).toISOString(),
|
|
509
|
+
sanitizer_result: {
|
|
510
|
+
success: true,
|
|
511
|
+
skipped: true,
|
|
512
|
+
context: null,
|
|
513
|
+
duration_ms: Date.now() - sanitizerStart
|
|
514
|
+
}
|
|
515
|
+
});
|
|
516
|
+
logEvent(db2, {
|
|
517
|
+
type: "trigger.skipped",
|
|
518
|
+
trigger_id: trigger.id,
|
|
519
|
+
run_id: runId,
|
|
520
|
+
data: { reason: "sanitizer_null" }
|
|
521
|
+
});
|
|
522
|
+
return { run: getRun(db2, runId), task: null };
|
|
523
|
+
}
|
|
524
|
+
context = { ...trigger.context, ...result };
|
|
525
|
+
updateRun(db2, runId, {
|
|
526
|
+
sanitizer_result: {
|
|
527
|
+
success: true,
|
|
528
|
+
skipped: false,
|
|
529
|
+
context: result,
|
|
530
|
+
duration_ms: Date.now() - sanitizerStart
|
|
531
|
+
}
|
|
532
|
+
});
|
|
533
|
+
logEvent(db2, {
|
|
534
|
+
type: "trigger.sanitized",
|
|
535
|
+
trigger_id: trigger.id,
|
|
536
|
+
run_id: runId,
|
|
537
|
+
data: { context_keys: Object.keys(result) }
|
|
538
|
+
});
|
|
539
|
+
} else {
|
|
540
|
+
context = typeof payload === "object" && payload !== null ? { ...trigger.context, ...payload } : { ...trigger.context, payload };
|
|
541
|
+
}
|
|
542
|
+
const composerStart = Date.now();
|
|
543
|
+
const description = compose(trigger.template, context);
|
|
544
|
+
updateRun(db2, runId, {
|
|
545
|
+
composer_result: {
|
|
546
|
+
success: true,
|
|
547
|
+
description,
|
|
548
|
+
duration_ms: Date.now() - composerStart
|
|
549
|
+
}
|
|
550
|
+
});
|
|
551
|
+
logEvent(db2, {
|
|
552
|
+
type: "trigger.composed",
|
|
553
|
+
trigger_id: trigger.id,
|
|
554
|
+
run_id: runId,
|
|
555
|
+
data: { description_length: description.length }
|
|
556
|
+
});
|
|
557
|
+
const task = {
|
|
558
|
+
id: generateTaskId(),
|
|
559
|
+
run_id: runId,
|
|
560
|
+
trigger_id: trigger.id,
|
|
561
|
+
trigger_type: trigger.type,
|
|
562
|
+
triggered_at: (/* @__PURE__ */ new Date()).toISOString(),
|
|
563
|
+
task: { description, context }
|
|
564
|
+
};
|
|
565
|
+
const egress = await loadEgress(trigger.egress);
|
|
566
|
+
if (egress) {
|
|
567
|
+
await executeEgress(egress, task, trigger);
|
|
568
|
+
}
|
|
569
|
+
updateRun(db2, runId, {
|
|
570
|
+
status: "completed",
|
|
571
|
+
completed_at: (/* @__PURE__ */ new Date()).toISOString(),
|
|
572
|
+
task_descriptor: task
|
|
573
|
+
});
|
|
574
|
+
logEvent(db2, {
|
|
575
|
+
type: "trigger.completed",
|
|
576
|
+
trigger_id: trigger.id,
|
|
577
|
+
run_id: runId,
|
|
578
|
+
data: { task_id: task.id, total_duration_ms: Date.now() - startTime }
|
|
579
|
+
});
|
|
580
|
+
return { run: getRun(db2, runId), task };
|
|
581
|
+
} catch (error) {
|
|
582
|
+
const message = error instanceof Error ? error.message : "Unknown error";
|
|
583
|
+
updateRun(db2, runId, {
|
|
584
|
+
status: "error",
|
|
585
|
+
completed_at: (/* @__PURE__ */ new Date()).toISOString(),
|
|
586
|
+
error: { stage: "unknown", message }
|
|
587
|
+
});
|
|
588
|
+
logEvent(db2, {
|
|
589
|
+
type: "trigger.error",
|
|
590
|
+
trigger_id: trigger.id,
|
|
591
|
+
run_id: runId,
|
|
592
|
+
data: { error: message }
|
|
593
|
+
});
|
|
594
|
+
return { run: getRun(db2, runId), task: null };
|
|
595
|
+
}
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
// src/lib/scheduler/cron.ts
|
|
599
|
+
var scheduledJobs = /* @__PURE__ */ new Map();
|
|
600
|
+
function initScheduler(db2, triggers) {
|
|
601
|
+
stopScheduler();
|
|
602
|
+
for (const trigger of triggers) {
|
|
603
|
+
if (trigger.type !== "cron" || !trigger.enabled || !trigger.schedule) {
|
|
604
|
+
continue;
|
|
605
|
+
}
|
|
606
|
+
const task = cron.schedule(trigger.schedule, async () => {
|
|
607
|
+
console.log(`[scheduler] Firing trigger: ${trigger.id}`);
|
|
608
|
+
await processCron(db2, trigger);
|
|
609
|
+
});
|
|
610
|
+
scheduledJobs.set(trigger.id, task);
|
|
611
|
+
}
|
|
612
|
+
console.log(`[scheduler] Initialized ${scheduledJobs.size} cron jobs`);
|
|
613
|
+
}
|
|
614
|
+
function stopScheduler() {
|
|
615
|
+
for (const task of scheduledJobs.values()) {
|
|
616
|
+
task.stop();
|
|
617
|
+
}
|
|
618
|
+
scheduledJobs.clear();
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
// src/lib/tunnel/ngrok.ts
|
|
622
|
+
import ngrok from "@ngrok/ngrok";
|
|
623
|
+
|
|
624
|
+
// src/lib/db/tunnel.ts
|
|
625
|
+
function getTunnelState(db2) {
|
|
626
|
+
const row = db2.prepare(`
|
|
627
|
+
SELECT configured, enabled, connected, url, domain, expose_ui, error
|
|
628
|
+
FROM tunnel_state WHERE id = 1
|
|
629
|
+
`).get();
|
|
630
|
+
if (!row) {
|
|
631
|
+
return {
|
|
632
|
+
configured: false,
|
|
633
|
+
enabled: false,
|
|
634
|
+
connected: false,
|
|
635
|
+
url: null,
|
|
636
|
+
domain: null,
|
|
637
|
+
expose_ui: false,
|
|
638
|
+
error: null
|
|
639
|
+
};
|
|
640
|
+
}
|
|
641
|
+
return {
|
|
642
|
+
configured: Boolean(row.configured),
|
|
643
|
+
enabled: Boolean(row.enabled),
|
|
644
|
+
connected: Boolean(row.connected),
|
|
645
|
+
url: row.url,
|
|
646
|
+
domain: row.domain,
|
|
647
|
+
expose_ui: Boolean(row.expose_ui),
|
|
648
|
+
error: row.error
|
|
649
|
+
};
|
|
650
|
+
}
|
|
651
|
+
function setTunnelState(db2, state) {
|
|
652
|
+
db2.prepare(`
|
|
653
|
+
UPDATE tunnel_state SET
|
|
654
|
+
configured = ?,
|
|
655
|
+
enabled = ?,
|
|
656
|
+
connected = ?,
|
|
657
|
+
url = ?,
|
|
658
|
+
domain = ?,
|
|
659
|
+
expose_ui = ?,
|
|
660
|
+
error = ?,
|
|
661
|
+
updated_at = datetime('now')
|
|
662
|
+
WHERE id = 1
|
|
663
|
+
`).run(
|
|
664
|
+
state.configured ? 1 : 0,
|
|
665
|
+
state.enabled ? 1 : 0,
|
|
666
|
+
state.connected ? 1 : 0,
|
|
667
|
+
state.url,
|
|
668
|
+
state.domain,
|
|
669
|
+
state.expose_ui ? 1 : 0,
|
|
670
|
+
state.error
|
|
671
|
+
);
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
// src/lib/tunnel/ngrok.ts
|
|
675
|
+
var listener = null;
|
|
676
|
+
async function startTunnel(config, port = 3e3) {
|
|
677
|
+
const db2 = getDb();
|
|
678
|
+
if (!config.enabled) {
|
|
679
|
+
try {
|
|
680
|
+
await ngrok.disconnect();
|
|
681
|
+
console.log("[tunnel] Tunnel disabled in config");
|
|
682
|
+
} catch {
|
|
683
|
+
}
|
|
684
|
+
listener = null;
|
|
685
|
+
const state = {
|
|
686
|
+
configured: true,
|
|
687
|
+
// Config exists but disabled
|
|
688
|
+
enabled: false,
|
|
689
|
+
connected: false,
|
|
690
|
+
url: null,
|
|
691
|
+
domain: null,
|
|
692
|
+
expose_ui: false,
|
|
693
|
+
error: null
|
|
694
|
+
};
|
|
695
|
+
setTunnelState(db2, state);
|
|
696
|
+
return state;
|
|
697
|
+
}
|
|
698
|
+
try {
|
|
699
|
+
const currentState = getTunnelState(db2);
|
|
700
|
+
const targetDomain = config.domain || null;
|
|
701
|
+
if (currentState.connected && currentState.domain === targetDomain) {
|
|
702
|
+
if (currentState.expose_ui !== (config.expose_ui ?? false)) {
|
|
703
|
+
console.log(`[tunnel] Updating expose_ui to ${config.expose_ui ?? false}`);
|
|
704
|
+
const updatedState = {
|
|
705
|
+
...currentState,
|
|
706
|
+
expose_ui: config.expose_ui ?? false
|
|
707
|
+
};
|
|
708
|
+
setTunnelState(db2, updatedState);
|
|
709
|
+
return updatedState;
|
|
710
|
+
}
|
|
711
|
+
console.log("[tunnel] Tunnel already connected with same config");
|
|
712
|
+
return currentState;
|
|
713
|
+
}
|
|
714
|
+
try {
|
|
715
|
+
await ngrok.disconnect();
|
|
716
|
+
await new Promise((resolve3) => setTimeout(resolve3, 1e3));
|
|
717
|
+
} catch {
|
|
718
|
+
}
|
|
719
|
+
listener = null;
|
|
720
|
+
console.log("[tunnel] Starting ngrok tunnel...");
|
|
721
|
+
const forwardOptions = {
|
|
722
|
+
addr: port,
|
|
723
|
+
authtoken: config.authtoken
|
|
724
|
+
};
|
|
725
|
+
if (config.domain) {
|
|
726
|
+
forwardOptions.domain = config.domain;
|
|
727
|
+
console.log(`[tunnel] Using static domain: ${config.domain}`);
|
|
728
|
+
}
|
|
729
|
+
listener = await ngrok.forward(forwardOptions);
|
|
730
|
+
const url = listener.url();
|
|
731
|
+
const domain = url ? new URL(url).host : null;
|
|
732
|
+
const state = {
|
|
733
|
+
configured: true,
|
|
734
|
+
enabled: true,
|
|
735
|
+
connected: true,
|
|
736
|
+
url,
|
|
737
|
+
domain,
|
|
738
|
+
expose_ui: config.expose_ui ?? false,
|
|
739
|
+
error: null
|
|
740
|
+
};
|
|
741
|
+
setTunnelState(db2, state);
|
|
742
|
+
console.log(`[tunnel] Connected! Public URL: ${url}`);
|
|
743
|
+
if (!state.expose_ui) {
|
|
744
|
+
console.log("[tunnel] UI not exposed - only /api/webhook/* routes accessible via tunnel");
|
|
745
|
+
}
|
|
746
|
+
return state;
|
|
747
|
+
} catch (error) {
|
|
748
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
749
|
+
const state = {
|
|
750
|
+
configured: true,
|
|
751
|
+
enabled: true,
|
|
752
|
+
connected: false,
|
|
753
|
+
url: null,
|
|
754
|
+
domain: null,
|
|
755
|
+
expose_ui: false,
|
|
756
|
+
error: message
|
|
757
|
+
};
|
|
758
|
+
setTunnelState(db2, state);
|
|
759
|
+
console.error(`[tunnel] Failed to start: ${message}`);
|
|
760
|
+
return state;
|
|
761
|
+
}
|
|
762
|
+
}
|
|
763
|
+
async function stopTunnel() {
|
|
764
|
+
const db2 = getDb();
|
|
765
|
+
try {
|
|
766
|
+
await ngrok.disconnect();
|
|
767
|
+
console.log("[tunnel] All tunnels disconnected");
|
|
768
|
+
} catch (error) {
|
|
769
|
+
console.error("[tunnel] Error disconnecting:", error);
|
|
770
|
+
}
|
|
771
|
+
listener = null;
|
|
772
|
+
const state = {
|
|
773
|
+
configured: false,
|
|
774
|
+
// No tunnel config
|
|
775
|
+
enabled: false,
|
|
776
|
+
connected: false,
|
|
777
|
+
url: null,
|
|
778
|
+
domain: null,
|
|
779
|
+
expose_ui: false,
|
|
780
|
+
error: null
|
|
781
|
+
};
|
|
782
|
+
setTunnelState(db2, state);
|
|
783
|
+
}
|
|
784
|
+
function getTunnelStatus() {
|
|
785
|
+
const db2 = getDb();
|
|
786
|
+
return getTunnelState(db2);
|
|
787
|
+
}
|
|
788
|
+
|
|
789
|
+
// src/lib/config/loader.ts
|
|
790
|
+
var cachedConfig = null;
|
|
791
|
+
var configWatcher = null;
|
|
792
|
+
var reloadTimeout = null;
|
|
793
|
+
var watchedConfigPath = null;
|
|
794
|
+
var DEFAULT_CONFIG = `# Charon trigger configuration
|
|
795
|
+
# See docs/SPEC.md for configuration options
|
|
796
|
+
|
|
797
|
+
triggers: []
|
|
798
|
+
`;
|
|
799
|
+
async function loadConfig(path = "config/triggers.yaml") {
|
|
800
|
+
if (!existsSync2(path)) {
|
|
801
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
802
|
+
writeFileSync(path, DEFAULT_CONFIG, "utf-8");
|
|
803
|
+
console.log(`[config] Created default config at ${path}`);
|
|
804
|
+
}
|
|
805
|
+
const content = readFileSync2(path, "utf-8");
|
|
806
|
+
const result = parseConfig(content);
|
|
807
|
+
if (!result.success) {
|
|
808
|
+
throw new Error(`Invalid config: ${result.error.message}`);
|
|
809
|
+
}
|
|
810
|
+
cachedConfig = result.data;
|
|
811
|
+
return result.data;
|
|
812
|
+
}
|
|
813
|
+
async function initializeApp(configPath = "config/triggers.yaml") {
|
|
814
|
+
const config = await loadConfig(configPath);
|
|
815
|
+
const db2 = getDb();
|
|
816
|
+
initScheduler(db2, config.triggers);
|
|
817
|
+
if (config.tunnel) {
|
|
818
|
+
await startTunnel(config.tunnel);
|
|
819
|
+
} else {
|
|
820
|
+
await stopTunnel();
|
|
821
|
+
}
|
|
822
|
+
startConfigWatcher(configPath);
|
|
823
|
+
return {
|
|
824
|
+
config,
|
|
825
|
+
tunnel: getTunnelStatus()
|
|
826
|
+
};
|
|
827
|
+
}
|
|
828
|
+
function getConfig() {
|
|
829
|
+
if (!cachedConfig) {
|
|
830
|
+
throw new Error("Config not loaded. Call initializeApp() first.");
|
|
831
|
+
}
|
|
832
|
+
return cachedConfig;
|
|
833
|
+
}
|
|
834
|
+
function getTrigger(id) {
|
|
835
|
+
const config = getConfig();
|
|
836
|
+
return config.triggers.find((t) => t.id === id);
|
|
837
|
+
}
|
|
838
|
+
function startConfigWatcher(configPath = "config/triggers.yaml") {
|
|
839
|
+
if (configWatcher && watchedConfigPath === configPath) {
|
|
840
|
+
return;
|
|
841
|
+
}
|
|
842
|
+
stopConfigWatcher();
|
|
843
|
+
if (!existsSync2(configPath)) {
|
|
844
|
+
console.warn(`[config] Config file not found: ${configPath}, skipping watcher`);
|
|
845
|
+
return;
|
|
846
|
+
}
|
|
847
|
+
watchedConfigPath = configPath;
|
|
848
|
+
configWatcher = watch(configPath, (eventType) => {
|
|
849
|
+
if (eventType === "change") {
|
|
850
|
+
if (reloadTimeout) {
|
|
851
|
+
clearTimeout(reloadTimeout);
|
|
852
|
+
}
|
|
853
|
+
reloadTimeout = setTimeout(async () => {
|
|
854
|
+
console.log("[config] File changed, reloading...");
|
|
855
|
+
try {
|
|
856
|
+
await reloadConfig(configPath);
|
|
857
|
+
console.log("[config] Reload complete");
|
|
858
|
+
} catch (err) {
|
|
859
|
+
console.error("[config] Reload failed:", err instanceof Error ? err.message : err);
|
|
860
|
+
}
|
|
861
|
+
}, 300);
|
|
862
|
+
}
|
|
863
|
+
});
|
|
864
|
+
console.log(`[config] Watching ${configPath} for changes`);
|
|
865
|
+
}
|
|
866
|
+
function stopConfigWatcher() {
|
|
867
|
+
if (reloadTimeout) {
|
|
868
|
+
clearTimeout(reloadTimeout);
|
|
869
|
+
reloadTimeout = null;
|
|
870
|
+
}
|
|
871
|
+
if (configWatcher) {
|
|
872
|
+
configWatcher.close();
|
|
873
|
+
configWatcher = null;
|
|
874
|
+
watchedConfigPath = null;
|
|
875
|
+
console.log("[config] Stopped watching config file");
|
|
876
|
+
}
|
|
877
|
+
}
|
|
878
|
+
async function reloadConfig(configPath) {
|
|
879
|
+
const config = await loadConfig(configPath);
|
|
880
|
+
const db2 = getDb();
|
|
881
|
+
initScheduler(db2, config.triggers);
|
|
882
|
+
if (config.tunnel) {
|
|
883
|
+
await startTunnel(config.tunnel);
|
|
884
|
+
} else {
|
|
885
|
+
await stopTunnel();
|
|
886
|
+
}
|
|
887
|
+
}
|
|
888
|
+
|
|
889
|
+
// src/lib/config/writer.ts
|
|
890
|
+
import { readFileSync as readFileSync3, writeFileSync as writeFileSync2 } from "fs";
|
|
891
|
+
import { parse as parseYaml2, stringify as stringifyYaml } from "yaml";
|
|
892
|
+
var DEFAULT_CONFIG_PATH = "config/triggers.yaml";
|
|
893
|
+
async function writeTrigger(trigger, configPath = DEFAULT_CONFIG_PATH) {
|
|
894
|
+
const validation = validateTrigger(trigger);
|
|
895
|
+
if (!validation.success) {
|
|
896
|
+
const messages = validation.error.issues.map((i) => `${i.path.join(".")}: ${i.message}`).join(", ");
|
|
897
|
+
return { success: false, error: `Validation failed: ${messages}` };
|
|
898
|
+
}
|
|
899
|
+
try {
|
|
900
|
+
const content = readFileSync3(configPath, "utf-8");
|
|
901
|
+
const config = parseYaml2(content) || {};
|
|
902
|
+
if (!config.triggers) {
|
|
903
|
+
config.triggers = [];
|
|
904
|
+
}
|
|
905
|
+
const existingIndex = config.triggers.findIndex((t) => t.id === trigger.id);
|
|
906
|
+
const triggerObj = {
|
|
907
|
+
id: trigger.id,
|
|
908
|
+
name: trigger.name,
|
|
909
|
+
type: trigger.type,
|
|
910
|
+
enabled: trigger.enabled,
|
|
911
|
+
template: trigger.template,
|
|
912
|
+
egress: trigger.egress
|
|
913
|
+
};
|
|
914
|
+
if (trigger.schedule) {
|
|
915
|
+
triggerObj.schedule = trigger.schedule;
|
|
916
|
+
}
|
|
917
|
+
if (trigger.sanitizer) {
|
|
918
|
+
triggerObj.sanitizer = trigger.sanitizer;
|
|
919
|
+
}
|
|
920
|
+
if (trigger.context && Object.keys(trigger.context).length > 0) {
|
|
921
|
+
triggerObj.context = trigger.context;
|
|
922
|
+
}
|
|
923
|
+
if (existingIndex >= 0) {
|
|
924
|
+
config.triggers[existingIndex] = triggerObj;
|
|
925
|
+
} else {
|
|
926
|
+
config.triggers.push(triggerObj);
|
|
927
|
+
}
|
|
928
|
+
const yamlOutput = stringifyYaml(config, {
|
|
929
|
+
lineWidth: 0,
|
|
930
|
+
// Don't wrap long lines
|
|
931
|
+
defaultStringType: "PLAIN",
|
|
932
|
+
defaultKeyType: "PLAIN"
|
|
933
|
+
});
|
|
934
|
+
writeFileSync2(configPath, yamlOutput);
|
|
935
|
+
return { success: true };
|
|
936
|
+
} catch (err) {
|
|
937
|
+
return { success: false, error: `Write failed: ${err instanceof Error ? err.message : String(err)}` };
|
|
938
|
+
}
|
|
939
|
+
}
|
|
940
|
+
async function deleteTrigger(id, configPath = DEFAULT_CONFIG_PATH) {
|
|
941
|
+
try {
|
|
942
|
+
const content = readFileSync3(configPath, "utf-8");
|
|
943
|
+
const config = parseYaml2(content) || {};
|
|
944
|
+
if (!config.triggers || !Array.isArray(config.triggers)) {
|
|
945
|
+
return { success: false, error: `Trigger '${id}' not found` };
|
|
946
|
+
}
|
|
947
|
+
const existingIndex = config.triggers.findIndex((t) => t.id === id);
|
|
948
|
+
if (existingIndex < 0) {
|
|
949
|
+
return { success: false, error: `Trigger '${id}' not found` };
|
|
950
|
+
}
|
|
951
|
+
config.triggers.splice(existingIndex, 1);
|
|
952
|
+
const yamlOutput = stringifyYaml(config, {
|
|
953
|
+
lineWidth: 0,
|
|
954
|
+
defaultStringType: "PLAIN",
|
|
955
|
+
defaultKeyType: "PLAIN"
|
|
956
|
+
});
|
|
957
|
+
writeFileSync2(configPath, yamlOutput);
|
|
958
|
+
return { success: true };
|
|
959
|
+
} catch (err) {
|
|
960
|
+
return { success: false, error: `Delete failed: ${err instanceof Error ? err.message : String(err)}` };
|
|
961
|
+
}
|
|
962
|
+
}
|
|
963
|
+
async function listTriggerIds(configPath = DEFAULT_CONFIG_PATH) {
|
|
964
|
+
try {
|
|
965
|
+
const content = readFileSync3(configPath, "utf-8");
|
|
966
|
+
const config = parseYaml2(content) || {};
|
|
967
|
+
if (!config.triggers || !Array.isArray(config.triggers)) {
|
|
968
|
+
return [];
|
|
969
|
+
}
|
|
970
|
+
return config.triggers.map((t) => t.id).filter((id) => !!id);
|
|
971
|
+
} catch {
|
|
972
|
+
return [];
|
|
973
|
+
}
|
|
974
|
+
}
|
|
975
|
+
async function writeTunnelConfig(tunnel, configPath = DEFAULT_CONFIG_PATH) {
|
|
976
|
+
try {
|
|
977
|
+
const content = readFileSync3(configPath, "utf-8");
|
|
978
|
+
const config = parseYaml2(content) || {};
|
|
979
|
+
const tunnelObj = {
|
|
980
|
+
enabled: tunnel.enabled,
|
|
981
|
+
provider: tunnel.provider,
|
|
982
|
+
authtoken: tunnel.authtoken
|
|
983
|
+
};
|
|
984
|
+
if (tunnel.domain) {
|
|
985
|
+
tunnelObj.domain = tunnel.domain;
|
|
986
|
+
}
|
|
987
|
+
if (tunnel.expose_ui !== void 0) {
|
|
988
|
+
tunnelObj.expose_ui = tunnel.expose_ui;
|
|
989
|
+
}
|
|
990
|
+
config.tunnel = tunnelObj;
|
|
991
|
+
const yamlOutput = stringifyYaml(config, {
|
|
992
|
+
lineWidth: 0,
|
|
993
|
+
defaultStringType: "PLAIN",
|
|
994
|
+
defaultKeyType: "PLAIN"
|
|
995
|
+
});
|
|
996
|
+
writeFileSync2(configPath, yamlOutput);
|
|
997
|
+
return { success: true };
|
|
998
|
+
} catch (err) {
|
|
999
|
+
return { success: false, error: `Write failed: ${err instanceof Error ? err.message : String(err)}` };
|
|
1000
|
+
}
|
|
1001
|
+
}
|
|
1002
|
+
async function getTunnelConfig(configPath = DEFAULT_CONFIG_PATH) {
|
|
1003
|
+
try {
|
|
1004
|
+
const content = readFileSync3(configPath, "utf-8");
|
|
1005
|
+
const config = parseYaml2(content) || {};
|
|
1006
|
+
if (!config.tunnel) {
|
|
1007
|
+
return null;
|
|
1008
|
+
}
|
|
1009
|
+
return {
|
|
1010
|
+
enabled: config.tunnel.enabled ?? false,
|
|
1011
|
+
provider: config.tunnel.provider ?? "ngrok",
|
|
1012
|
+
authtoken: config.tunnel.authtoken ?? "",
|
|
1013
|
+
domain: config.tunnel.domain,
|
|
1014
|
+
expose_ui: config.tunnel.expose_ui
|
|
1015
|
+
};
|
|
1016
|
+
} catch {
|
|
1017
|
+
return null;
|
|
1018
|
+
}
|
|
1019
|
+
}
|
|
1020
|
+
|
|
1021
|
+
// src/server/routes/triggers.ts
|
|
1022
|
+
var triggersRoutes = new Hono();
|
|
1023
|
+
async function createTriggerInternal(trigger, configPath) {
|
|
1024
|
+
const validation = validateTrigger(trigger);
|
|
1025
|
+
if (!validation.success) {
|
|
1026
|
+
return {
|
|
1027
|
+
success: false,
|
|
1028
|
+
error: "Validation failed",
|
|
1029
|
+
details: validation.error.issues,
|
|
1030
|
+
status: 400
|
|
1031
|
+
};
|
|
1032
|
+
}
|
|
1033
|
+
const existingIds = await listTriggerIds(configPath);
|
|
1034
|
+
if (existingIds.includes(trigger.id)) {
|
|
1035
|
+
return {
|
|
1036
|
+
success: false,
|
|
1037
|
+
error: `Trigger '${trigger.id}' already exists`,
|
|
1038
|
+
status: 409
|
|
1039
|
+
};
|
|
1040
|
+
}
|
|
1041
|
+
const result = await writeTrigger(trigger, configPath);
|
|
1042
|
+
if (!result.success) {
|
|
1043
|
+
return {
|
|
1044
|
+
success: false,
|
|
1045
|
+
error: result.error,
|
|
1046
|
+
status: 500
|
|
1047
|
+
};
|
|
1048
|
+
}
|
|
1049
|
+
return {
|
|
1050
|
+
success: true,
|
|
1051
|
+
trigger,
|
|
1052
|
+
status: 201
|
|
1053
|
+
};
|
|
1054
|
+
}
|
|
1055
|
+
async function updateTriggerInternal(id, trigger, configPath) {
|
|
1056
|
+
const validation = validateTrigger(trigger);
|
|
1057
|
+
if (!validation.success) {
|
|
1058
|
+
return {
|
|
1059
|
+
success: false,
|
|
1060
|
+
error: "Validation failed",
|
|
1061
|
+
details: validation.error.issues,
|
|
1062
|
+
status: 400
|
|
1063
|
+
};
|
|
1064
|
+
}
|
|
1065
|
+
const existingIds = await listTriggerIds(configPath);
|
|
1066
|
+
if (!existingIds.includes(id)) {
|
|
1067
|
+
return {
|
|
1068
|
+
success: false,
|
|
1069
|
+
error: `Trigger '${id}' not found`,
|
|
1070
|
+
status: 404
|
|
1071
|
+
};
|
|
1072
|
+
}
|
|
1073
|
+
const idChanged = trigger.id !== id;
|
|
1074
|
+
if (idChanged && existingIds.includes(trigger.id)) {
|
|
1075
|
+
return {
|
|
1076
|
+
success: false,
|
|
1077
|
+
error: `Trigger '${trigger.id}' already exists`,
|
|
1078
|
+
status: 409
|
|
1079
|
+
};
|
|
1080
|
+
}
|
|
1081
|
+
if (idChanged) {
|
|
1082
|
+
const deleteResult = await deleteTrigger(id, configPath);
|
|
1083
|
+
if (!deleteResult.success) {
|
|
1084
|
+
return {
|
|
1085
|
+
success: false,
|
|
1086
|
+
error: deleteResult.error,
|
|
1087
|
+
status: 500
|
|
1088
|
+
};
|
|
1089
|
+
}
|
|
1090
|
+
}
|
|
1091
|
+
const result = await writeTrigger(trigger, configPath);
|
|
1092
|
+
if (!result.success) {
|
|
1093
|
+
return {
|
|
1094
|
+
success: false,
|
|
1095
|
+
error: result.error,
|
|
1096
|
+
status: 500
|
|
1097
|
+
};
|
|
1098
|
+
}
|
|
1099
|
+
return {
|
|
1100
|
+
success: true,
|
|
1101
|
+
trigger,
|
|
1102
|
+
id_changed: idChanged || void 0,
|
|
1103
|
+
old_id: idChanged ? id : void 0,
|
|
1104
|
+
status: 200
|
|
1105
|
+
};
|
|
1106
|
+
}
|
|
1107
|
+
async function deleteTriggerInternal(id, configPath) {
|
|
1108
|
+
const existingIds = await listTriggerIds(configPath);
|
|
1109
|
+
if (!existingIds.includes(id)) {
|
|
1110
|
+
return {
|
|
1111
|
+
success: false,
|
|
1112
|
+
error: `Trigger '${id}' not found`,
|
|
1113
|
+
status: 404
|
|
1114
|
+
};
|
|
1115
|
+
}
|
|
1116
|
+
const result = await deleteTrigger(id, configPath);
|
|
1117
|
+
if (!result.success) {
|
|
1118
|
+
return {
|
|
1119
|
+
success: false,
|
|
1120
|
+
error: result.error,
|
|
1121
|
+
status: 500
|
|
1122
|
+
};
|
|
1123
|
+
}
|
|
1124
|
+
return {
|
|
1125
|
+
success: true,
|
|
1126
|
+
deleted_id: id,
|
|
1127
|
+
status: 200
|
|
1128
|
+
};
|
|
1129
|
+
}
|
|
1130
|
+
var configLoaded = false;
|
|
1131
|
+
async function ensureConfig() {
|
|
1132
|
+
if (!configLoaded) {
|
|
1133
|
+
await loadConfig();
|
|
1134
|
+
configLoaded = true;
|
|
1135
|
+
}
|
|
1136
|
+
}
|
|
1137
|
+
async function testTriggerInternal(id, payload, configPath) {
|
|
1138
|
+
if (configPath) {
|
|
1139
|
+
await loadConfig(configPath);
|
|
1140
|
+
} else {
|
|
1141
|
+
await ensureConfig();
|
|
1142
|
+
}
|
|
1143
|
+
const config = getConfig();
|
|
1144
|
+
const trigger = config.triggers.find((t) => t.id === id);
|
|
1145
|
+
if (!trigger) {
|
|
1146
|
+
return {
|
|
1147
|
+
success: false,
|
|
1148
|
+
error: `Trigger '${id}' not found`,
|
|
1149
|
+
status: 404
|
|
1150
|
+
};
|
|
1151
|
+
}
|
|
1152
|
+
const db2 = getDb();
|
|
1153
|
+
try {
|
|
1154
|
+
let result;
|
|
1155
|
+
if (trigger.type === "cron") {
|
|
1156
|
+
result = await processCron(db2, trigger);
|
|
1157
|
+
} else {
|
|
1158
|
+
const webhookPayload = payload || {};
|
|
1159
|
+
result = await processWebhook(db2, trigger, webhookPayload, {});
|
|
1160
|
+
}
|
|
1161
|
+
return {
|
|
1162
|
+
success: true,
|
|
1163
|
+
run: result.run,
|
|
1164
|
+
task_descriptor: result.task,
|
|
1165
|
+
status: 200
|
|
1166
|
+
};
|
|
1167
|
+
} catch (error) {
|
|
1168
|
+
return {
|
|
1169
|
+
success: false,
|
|
1170
|
+
error: error instanceof Error ? error.message : "Test execution failed",
|
|
1171
|
+
status: 500
|
|
1172
|
+
};
|
|
1173
|
+
}
|
|
1174
|
+
}
|
|
1175
|
+
triggersRoutes.get("/", async (c) => {
|
|
1176
|
+
const config = await loadConfig();
|
|
1177
|
+
const db2 = getDb();
|
|
1178
|
+
const triggers = config.triggers.map((trigger) => {
|
|
1179
|
+
const runs = listRuns(db2, { trigger_id: trigger.id, limit: 100 });
|
|
1180
|
+
const now = Date.now();
|
|
1181
|
+
const oneDayAgo = now - 24 * 60 * 60 * 1e3;
|
|
1182
|
+
const recentRuns = runs.filter(
|
|
1183
|
+
(r) => new Date(r.started_at).getTime() > oneDayAgo
|
|
1184
|
+
);
|
|
1185
|
+
return {
|
|
1186
|
+
config: trigger,
|
|
1187
|
+
stats: {
|
|
1188
|
+
last_triggered: runs[0]?.started_at ?? null,
|
|
1189
|
+
runs_24h: recentRuns.length,
|
|
1190
|
+
completed_24h: recentRuns.filter((r) => r.status === "completed").length,
|
|
1191
|
+
skipped_24h: recentRuns.filter((r) => r.status === "skipped").length,
|
|
1192
|
+
errors_24h: recentRuns.filter((r) => r.status === "error").length
|
|
1193
|
+
}
|
|
1194
|
+
};
|
|
1195
|
+
});
|
|
1196
|
+
return c.json({ triggers });
|
|
1197
|
+
});
|
|
1198
|
+
triggersRoutes.post("/", async (c) => {
|
|
1199
|
+
const body = await c.req.json();
|
|
1200
|
+
const result = await createTriggerInternal(body);
|
|
1201
|
+
if (!result.success) {
|
|
1202
|
+
return c.json({
|
|
1203
|
+
success: false,
|
|
1204
|
+
error: result.error,
|
|
1205
|
+
details: result.details
|
|
1206
|
+
}, result.status);
|
|
1207
|
+
}
|
|
1208
|
+
return c.json({
|
|
1209
|
+
success: true,
|
|
1210
|
+
trigger: result.trigger
|
|
1211
|
+
}, 201);
|
|
1212
|
+
});
|
|
1213
|
+
triggersRoutes.put("/:id", async (c) => {
|
|
1214
|
+
const id = c.req.param("id");
|
|
1215
|
+
const body = await c.req.json();
|
|
1216
|
+
const result = await updateTriggerInternal(id, body);
|
|
1217
|
+
if (!result.success) {
|
|
1218
|
+
return c.json({
|
|
1219
|
+
success: false,
|
|
1220
|
+
error: result.error,
|
|
1221
|
+
details: result.details
|
|
1222
|
+
}, result.status);
|
|
1223
|
+
}
|
|
1224
|
+
const response = {
|
|
1225
|
+
success: true,
|
|
1226
|
+
trigger: result.trigger
|
|
1227
|
+
};
|
|
1228
|
+
if (result.id_changed) {
|
|
1229
|
+
response.id_changed = true;
|
|
1230
|
+
response.old_id = result.old_id;
|
|
1231
|
+
}
|
|
1232
|
+
return c.json(response, 200);
|
|
1233
|
+
});
|
|
1234
|
+
triggersRoutes.delete("/:id", async (c) => {
|
|
1235
|
+
const id = c.req.param("id");
|
|
1236
|
+
const result = await deleteTriggerInternal(id);
|
|
1237
|
+
if (!result.success) {
|
|
1238
|
+
return c.json({
|
|
1239
|
+
success: false,
|
|
1240
|
+
error: result.error
|
|
1241
|
+
}, result.status);
|
|
1242
|
+
}
|
|
1243
|
+
return c.json({
|
|
1244
|
+
success: true,
|
|
1245
|
+
deleted_id: result.deleted_id
|
|
1246
|
+
}, 200);
|
|
1247
|
+
});
|
|
1248
|
+
triggersRoutes.post("/:id/test", async (c) => {
|
|
1249
|
+
const id = c.req.param("id");
|
|
1250
|
+
const body = await c.req.json().catch(() => ({}));
|
|
1251
|
+
const result = await testTriggerInternal(id, body.payload);
|
|
1252
|
+
if (!result.success) {
|
|
1253
|
+
return c.json({
|
|
1254
|
+
success: false,
|
|
1255
|
+
error: result.error
|
|
1256
|
+
}, result.status);
|
|
1257
|
+
}
|
|
1258
|
+
return c.json({
|
|
1259
|
+
run: result.run,
|
|
1260
|
+
task_descriptor: result.task_descriptor
|
|
1261
|
+
}, 200);
|
|
1262
|
+
});
|
|
1263
|
+
|
|
1264
|
+
// src/server/routes/runs.ts
|
|
1265
|
+
import { Hono as Hono2 } from "hono";
|
|
1266
|
+
var runsRoutes = new Hono2();
|
|
1267
|
+
runsRoutes.get("/", async (c) => {
|
|
1268
|
+
const trigger_id = c.req.query("trigger_id") ?? void 0;
|
|
1269
|
+
const status = c.req.query("status") ?? void 0;
|
|
1270
|
+
const limit = parseInt(c.req.query("limit") ?? "50", 10);
|
|
1271
|
+
const offset = parseInt(c.req.query("offset") ?? "0", 10);
|
|
1272
|
+
const db2 = getDb();
|
|
1273
|
+
const runs = listRuns(db2, { trigger_id, status, limit: limit + 1, offset });
|
|
1274
|
+
const hasMore = runs.length > limit;
|
|
1275
|
+
if (hasMore) runs.pop();
|
|
1276
|
+
return c.json({
|
|
1277
|
+
runs,
|
|
1278
|
+
total: runs.length,
|
|
1279
|
+
has_more: hasMore
|
|
1280
|
+
});
|
|
1281
|
+
});
|
|
1282
|
+
runsRoutes.get("/:id", async (c) => {
|
|
1283
|
+
const id = c.req.param("id");
|
|
1284
|
+
const db2 = getDb();
|
|
1285
|
+
const run = getRun(db2, id);
|
|
1286
|
+
if (!run) {
|
|
1287
|
+
return c.json(
|
|
1288
|
+
{ error: "run_not_found", message: `Run '${id}' does not exist` },
|
|
1289
|
+
404
|
|
1290
|
+
);
|
|
1291
|
+
}
|
|
1292
|
+
const events = listEvents(db2, { run_id: id });
|
|
1293
|
+
return c.json({ run, events });
|
|
1294
|
+
});
|
|
1295
|
+
|
|
1296
|
+
// src/server/routes/sanitizers.ts
|
|
1297
|
+
import { Hono as Hono3 } from "hono";
|
|
1298
|
+
import { readdirSync, existsSync as existsSync3, writeFileSync as writeFileSync3, mkdirSync as mkdirSync2 } from "fs";
|
|
1299
|
+
import { join as join2 } from "path";
|
|
1300
|
+
var sanitizersRoutes = new Hono3();
|
|
1301
|
+
var DEFAULT_SANITIZERS_DIR = process.env.CHARON_SANITIZERS_DIR || "sanitizers";
|
|
1302
|
+
var BOILERPLATE = `/**
|
|
1303
|
+
* Sanitizer function for processing webhook payloads.
|
|
1304
|
+
*
|
|
1305
|
+
* @param payload - The raw webhook payload
|
|
1306
|
+
* @param headers - HTTP headers from the webhook request
|
|
1307
|
+
* @param trigger - The trigger configuration
|
|
1308
|
+
* @returns Extracted context object, or null to skip this trigger
|
|
1309
|
+
*/
|
|
1310
|
+
const sanitize = (payload: unknown, headers: Record<string, string>, trigger: { id: string }) => {
|
|
1311
|
+
// Return null to skip this trigger
|
|
1312
|
+
// Return extracted context to continue processing
|
|
1313
|
+
return { payload };
|
|
1314
|
+
};
|
|
1315
|
+
|
|
1316
|
+
export default sanitize;
|
|
1317
|
+
`;
|
|
1318
|
+
function sanitizeName(name) {
|
|
1319
|
+
return name.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").replace(/-+/g, "-");
|
|
1320
|
+
}
|
|
1321
|
+
function listSanitizersInternal(sanitizersDir = DEFAULT_SANITIZERS_DIR) {
|
|
1322
|
+
if (!existsSync3(sanitizersDir)) {
|
|
1323
|
+
return [];
|
|
1324
|
+
}
|
|
1325
|
+
const files = readdirSync(sanitizersDir);
|
|
1326
|
+
return files.filter((f) => f.endsWith(".ts")).map((f) => ({
|
|
1327
|
+
name: f.replace(".ts", ""),
|
|
1328
|
+
path: join2(sanitizersDir, f)
|
|
1329
|
+
}));
|
|
1330
|
+
}
|
|
1331
|
+
function createSanitizerInternal(rawName, sanitizersDir = DEFAULT_SANITIZERS_DIR) {
|
|
1332
|
+
if (!rawName || typeof rawName !== "string") {
|
|
1333
|
+
return { success: false, error: "Name is required", status: 400 };
|
|
1334
|
+
}
|
|
1335
|
+
const name = sanitizeName(rawName);
|
|
1336
|
+
if (!name) {
|
|
1337
|
+
return { success: false, error: "Invalid name", status: 400 };
|
|
1338
|
+
}
|
|
1339
|
+
const filePath = join2(sanitizersDir, `${name}.ts`);
|
|
1340
|
+
if (existsSync3(filePath)) {
|
|
1341
|
+
return { success: false, error: `Sanitizer '${name}' already exists`, status: 409 };
|
|
1342
|
+
}
|
|
1343
|
+
if (!existsSync3(sanitizersDir)) {
|
|
1344
|
+
mkdirSync2(sanitizersDir, { recursive: true });
|
|
1345
|
+
}
|
|
1346
|
+
writeFileSync3(filePath, BOILERPLATE);
|
|
1347
|
+
return { success: true, name, path: filePath, status: 201 };
|
|
1348
|
+
}
|
|
1349
|
+
sanitizersRoutes.get("/", async (c) => {
|
|
1350
|
+
try {
|
|
1351
|
+
const sanitizers = listSanitizersInternal();
|
|
1352
|
+
return c.json({ sanitizers });
|
|
1353
|
+
} catch (error) {
|
|
1354
|
+
return c.json({
|
|
1355
|
+
success: false,
|
|
1356
|
+
error: error instanceof Error ? error.message : "Failed to list sanitizers"
|
|
1357
|
+
}, 500);
|
|
1358
|
+
}
|
|
1359
|
+
});
|
|
1360
|
+
sanitizersRoutes.post("/", async (c) => {
|
|
1361
|
+
try {
|
|
1362
|
+
const body = await c.req.json();
|
|
1363
|
+
const result = createSanitizerInternal(body.name);
|
|
1364
|
+
if (!result.success) {
|
|
1365
|
+
return c.json({
|
|
1366
|
+
success: false,
|
|
1367
|
+
error: result.error
|
|
1368
|
+
}, result.status);
|
|
1369
|
+
}
|
|
1370
|
+
return c.json({
|
|
1371
|
+
success: true,
|
|
1372
|
+
name: result.name,
|
|
1373
|
+
path: result.path
|
|
1374
|
+
}, 201);
|
|
1375
|
+
} catch (error) {
|
|
1376
|
+
return c.json({
|
|
1377
|
+
success: false,
|
|
1378
|
+
error: error instanceof Error ? error.message : "Failed to create sanitizer"
|
|
1379
|
+
}, 500);
|
|
1380
|
+
}
|
|
1381
|
+
});
|
|
1382
|
+
|
|
1383
|
+
// src/server/routes/tunnel.ts
|
|
1384
|
+
import { Hono as Hono4 } from "hono";
|
|
1385
|
+
var tunnelRoutes = new Hono4();
|
|
1386
|
+
tunnelRoutes.get("/", async (c) => {
|
|
1387
|
+
const status = getTunnelStatus();
|
|
1388
|
+
const config = await getTunnelConfig();
|
|
1389
|
+
return c.json({ ...status, config });
|
|
1390
|
+
});
|
|
1391
|
+
tunnelRoutes.post("/", async (c) => {
|
|
1392
|
+
try {
|
|
1393
|
+
const config = getConfig();
|
|
1394
|
+
if (!config.tunnel?.enabled) {
|
|
1395
|
+
return c.json(
|
|
1396
|
+
{ success: false, error: "Tunnel not enabled in config" },
|
|
1397
|
+
400
|
|
1398
|
+
);
|
|
1399
|
+
}
|
|
1400
|
+
const status = await startTunnel(config.tunnel);
|
|
1401
|
+
return c.json({
|
|
1402
|
+
success: status.connected,
|
|
1403
|
+
...status
|
|
1404
|
+
});
|
|
1405
|
+
} catch (error) {
|
|
1406
|
+
const message = error instanceof Error ? error.message : "Unknown error";
|
|
1407
|
+
return c.json(
|
|
1408
|
+
{ success: false, error: message },
|
|
1409
|
+
500
|
|
1410
|
+
);
|
|
1411
|
+
}
|
|
1412
|
+
});
|
|
1413
|
+
tunnelRoutes.put("/", async (c) => {
|
|
1414
|
+
try {
|
|
1415
|
+
const body = await c.req.json();
|
|
1416
|
+
if (!body.provider) {
|
|
1417
|
+
return c.json(
|
|
1418
|
+
{ success: false, error: "Provider is required" },
|
|
1419
|
+
400
|
|
1420
|
+
);
|
|
1421
|
+
}
|
|
1422
|
+
if (!body.authtoken) {
|
|
1423
|
+
return c.json(
|
|
1424
|
+
{ success: false, error: "Auth token is required" },
|
|
1425
|
+
400
|
|
1426
|
+
);
|
|
1427
|
+
}
|
|
1428
|
+
const result = await writeTunnelConfig(body);
|
|
1429
|
+
if (!result.success) {
|
|
1430
|
+
return c.json(
|
|
1431
|
+
{ success: false, error: result.error },
|
|
1432
|
+
500
|
|
1433
|
+
);
|
|
1434
|
+
}
|
|
1435
|
+
return c.json({ success: true });
|
|
1436
|
+
} catch (error) {
|
|
1437
|
+
const message = error instanceof Error ? error.message : "Unknown error";
|
|
1438
|
+
return c.json(
|
|
1439
|
+
{ success: false, error: message },
|
|
1440
|
+
500
|
|
1441
|
+
);
|
|
1442
|
+
}
|
|
1443
|
+
});
|
|
1444
|
+
tunnelRoutes.delete("/", async (c) => {
|
|
1445
|
+
await stopTunnel();
|
|
1446
|
+
return c.json({ success: true, message: "Tunnel stopped" });
|
|
1447
|
+
});
|
|
1448
|
+
|
|
1449
|
+
// src/server/routes/webhook.ts
|
|
1450
|
+
import { Hono as Hono5 } from "hono";
|
|
1451
|
+
var webhookRoutes = new Hono5();
|
|
1452
|
+
var configLoaded2 = false;
|
|
1453
|
+
async function ensureConfig2() {
|
|
1454
|
+
if (!configLoaded2) {
|
|
1455
|
+
await loadConfig();
|
|
1456
|
+
configLoaded2 = true;
|
|
1457
|
+
}
|
|
1458
|
+
}
|
|
1459
|
+
webhookRoutes.post("/:id", async (c) => {
|
|
1460
|
+
await ensureConfig2();
|
|
1461
|
+
const id = c.req.param("id");
|
|
1462
|
+
const trigger = getTrigger(id);
|
|
1463
|
+
if (!trigger) {
|
|
1464
|
+
return c.json(
|
|
1465
|
+
{ error: "trigger_not_found", message: `Trigger '${id}' does not exist` },
|
|
1466
|
+
404
|
|
1467
|
+
);
|
|
1468
|
+
}
|
|
1469
|
+
if (!trigger.enabled) {
|
|
1470
|
+
return c.json(
|
|
1471
|
+
{ error: "trigger_disabled", message: `Trigger '${id}' is disabled` },
|
|
1472
|
+
400
|
|
1473
|
+
);
|
|
1474
|
+
}
|
|
1475
|
+
if (trigger.type !== "webhook") {
|
|
1476
|
+
return c.json(
|
|
1477
|
+
{ error: "invalid_trigger_type", message: `Trigger '${id}' is not a webhook` },
|
|
1478
|
+
400
|
|
1479
|
+
);
|
|
1480
|
+
}
|
|
1481
|
+
const payload = await c.req.json().catch(() => ({}));
|
|
1482
|
+
const headers = {};
|
|
1483
|
+
c.req.raw.headers.forEach((value, key) => {
|
|
1484
|
+
headers[key] = value;
|
|
1485
|
+
});
|
|
1486
|
+
const db2 = getDb();
|
|
1487
|
+
const result = await processWebhook(db2, trigger, payload, headers);
|
|
1488
|
+
return c.json(
|
|
1489
|
+
{ run_id: result.run.id, status: result.run.status },
|
|
1490
|
+
202
|
|
1491
|
+
);
|
|
1492
|
+
});
|
|
1493
|
+
|
|
1494
|
+
// src/server/routes/task.ts
|
|
1495
|
+
import { Hono as Hono6 } from "hono";
|
|
1496
|
+
var taskRoutes = new Hono6();
|
|
1497
|
+
taskRoutes.post("/complete", async (c) => {
|
|
1498
|
+
try {
|
|
1499
|
+
const body = await c.req.json();
|
|
1500
|
+
const { summary, pid: _pid } = body;
|
|
1501
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
1502
|
+
const db2 = getDb();
|
|
1503
|
+
const recentRuns = listRuns(db2, { status: "completed", limit: 10 });
|
|
1504
|
+
const run = recentRuns.find((r) => {
|
|
1505
|
+
if (!r.task_descriptor) return false;
|
|
1506
|
+
if (r.egress_result) return false;
|
|
1507
|
+
const startedAt = new Date(r.started_at).getTime();
|
|
1508
|
+
const hourAgo = Date.now() - 60 * 60 * 1e3;
|
|
1509
|
+
return startedAt > hourAgo;
|
|
1510
|
+
});
|
|
1511
|
+
if (!run) {
|
|
1512
|
+
console.log("[task/complete] No matching run found, logging completion anyway");
|
|
1513
|
+
return c.json({
|
|
1514
|
+
success: true,
|
|
1515
|
+
message: "Completion logged (no matching run found)",
|
|
1516
|
+
summary
|
|
1517
|
+
});
|
|
1518
|
+
}
|
|
1519
|
+
updateRun(db2, run.id, {
|
|
1520
|
+
egress_result: {
|
|
1521
|
+
completed_at: now,
|
|
1522
|
+
summary
|
|
1523
|
+
}
|
|
1524
|
+
});
|
|
1525
|
+
console.log(`[task/complete] Run ${run.id} updated with egress result. Summary: ${summary ?? "(none)"}`);
|
|
1526
|
+
return c.json({
|
|
1527
|
+
success: true,
|
|
1528
|
+
run_id: run.id,
|
|
1529
|
+
summary
|
|
1530
|
+
});
|
|
1531
|
+
} catch (error) {
|
|
1532
|
+
console.error("[task/complete] Error:", error);
|
|
1533
|
+
return c.json(
|
|
1534
|
+
{ success: false, error: String(error) },
|
|
1535
|
+
500
|
|
1536
|
+
);
|
|
1537
|
+
}
|
|
1538
|
+
});
|
|
1539
|
+
|
|
1540
|
+
// src/server/middleware/tunnel-proxy.ts
|
|
1541
|
+
var TUNNEL_PATTERNS = [
|
|
1542
|
+
".ngrok-free.app",
|
|
1543
|
+
".ngrok-free.dev",
|
|
1544
|
+
".ngrok.io",
|
|
1545
|
+
".ngrok.app"
|
|
1546
|
+
];
|
|
1547
|
+
function isTunnelHost(host) {
|
|
1548
|
+
return TUNNEL_PATTERNS.some((pattern) => host.includes(pattern));
|
|
1549
|
+
}
|
|
1550
|
+
async function tunnelProxyMiddleware(c, next) {
|
|
1551
|
+
const host = c.req.header("host") || "";
|
|
1552
|
+
const pathname = new URL(c.req.url).pathname;
|
|
1553
|
+
if (!isTunnelHost(host)) {
|
|
1554
|
+
return next();
|
|
1555
|
+
}
|
|
1556
|
+
if (pathname.startsWith("/api/webhook/")) {
|
|
1557
|
+
return next();
|
|
1558
|
+
}
|
|
1559
|
+
const status = getTunnelStatus();
|
|
1560
|
+
if (!status.expose_ui) {
|
|
1561
|
+
return c.text("Not Found", 404);
|
|
1562
|
+
}
|
|
1563
|
+
return next();
|
|
1564
|
+
}
|
|
1565
|
+
|
|
1566
|
+
// src/server/app.ts
|
|
1567
|
+
var app = new Hono7();
|
|
1568
|
+
app.use("*", logger());
|
|
1569
|
+
app.use("*", tunnelProxyMiddleware);
|
|
1570
|
+
app.route("/api/triggers", triggersRoutes);
|
|
1571
|
+
app.route("/api/runs", runsRoutes);
|
|
1572
|
+
app.route("/api/sanitizers", sanitizersRoutes);
|
|
1573
|
+
app.route("/api/tunnel", tunnelRoutes);
|
|
1574
|
+
app.route("/api/webhook", webhookRoutes);
|
|
1575
|
+
app.route("/api/task", taskRoutes);
|
|
1576
|
+
app.use("/*", serveStatic({ root: "./dist/client" }));
|
|
1577
|
+
app.get("*", serveStatic({ path: "./dist/client/index.html" }));
|
|
1578
|
+
|
|
1579
|
+
// src/server/init.ts
|
|
1580
|
+
async function initializeServices() {
|
|
1581
|
+
try {
|
|
1582
|
+
const { config, tunnel } = await initializeApp();
|
|
1583
|
+
console.log(`[init] Loaded ${config.triggers.length} triggers`);
|
|
1584
|
+
if (tunnel.connected) {
|
|
1585
|
+
console.log(`[init] Tunnel connected: ${tunnel.url}`);
|
|
1586
|
+
}
|
|
1587
|
+
} catch (error) {
|
|
1588
|
+
console.error("[init] Failed to initialize:", error);
|
|
1589
|
+
throw error;
|
|
1590
|
+
}
|
|
1591
|
+
}
|
|
1592
|
+
|
|
1593
|
+
// src/server/index.ts
|
|
1594
|
+
var PORT = parseInt(process.env.PORT || "3000", 10);
|
|
1595
|
+
await initializeServices();
|
|
1596
|
+
if (typeof Bun !== "undefined") {
|
|
1597
|
+
const server = Bun.serve({
|
|
1598
|
+
fetch: app.fetch,
|
|
1599
|
+
port: PORT
|
|
1600
|
+
});
|
|
1601
|
+
console.log(`[server] Charon running at http://localhost:${server.port}`);
|
|
1602
|
+
} else {
|
|
1603
|
+
const { serve } = await import("@hono/node-server");
|
|
1604
|
+
serve({
|
|
1605
|
+
fetch: app.fetch,
|
|
1606
|
+
port: PORT
|
|
1607
|
+
}, (info) => {
|
|
1608
|
+
console.log(`[server] Charon running at http://localhost:${info.port}`);
|
|
1609
|
+
});
|
|
1610
|
+
}
|