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.
@@ -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
+ }