hookherald 0.3.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,547 @@
1
+ import { createServer, type IncomingMessage, type ServerResponse } from "node:http";
2
+ import { readFileSync } from "node:fs";
3
+ import { resolve, dirname } from "node:path";
4
+ import { fileURLToPath } from "node:url";
5
+ import {
6
+ createLogger,
7
+ EventStore,
8
+ MetricsCollector,
9
+ createTrace,
10
+ truncatePayload,
11
+ newEventId,
12
+ type RouteInfo,
13
+ type RouterEvent,
14
+ } from "./observability.js";
15
+
16
+ const __dirname = dirname(fileURLToPath(import.meta.url));
17
+
18
+ const PORT = parseInt(process.env.ROUTER_PORT || "9000", 10);
19
+ const HOST = process.env.ROUTER_HOST || "127.0.0.1";
20
+ const SECRET = process.env.WEBHOOK_SECRET || "dev-secret";
21
+
22
+ const log = createLogger("router");
23
+ const events = new EventStore();
24
+ const metrics = new MetricsCollector();
25
+
26
+ // Routing table: project_slug → RouteInfo
27
+ const routes = new Map<string, RouteInfo>();
28
+
29
+ // Dashboard HTML — cached at startup
30
+ let dashboardHtml = "";
31
+ try {
32
+ dashboardHtml = readFileSync(resolve(__dirname, "dashboard.html"), "utf-8");
33
+ } catch {
34
+ log.warn("dashboard.html not found — dashboard will be unavailable");
35
+ }
36
+
37
+ // --- Request body helper ---
38
+
39
+ async function readBody(req: IncomingMessage): Promise<string> {
40
+ const chunks: Buffer[] = [];
41
+ for await (const chunk of req) chunks.push(chunk as Buffer);
42
+ return Buffer.concat(chunks).toString();
43
+ }
44
+
45
+ // --- Route helpers ---
46
+
47
+ function getRoutesSnapshot() {
48
+ return Object.fromEntries([...routes.entries()].map(([slug, info]) => [slug, info]));
49
+ }
50
+
51
+ // --- Handlers ---
52
+
53
+ async function handleRegister(req: IncomingMessage, res: ServerResponse) {
54
+ const body = JSON.parse(await readBody(req));
55
+ const { project_slug, port } = body;
56
+ if (!project_slug || !port) {
57
+ res.writeHead(400, { "Content-Type": "application/json" });
58
+ res.end(JSON.stringify({ error: "missing project_slug or port" }));
59
+ return;
60
+ }
61
+
62
+ // Heartbeat: if same slug already registered, treat as keepalive
63
+ const existing = routes.get(project_slug);
64
+ if (existing) {
65
+ if (existing.port !== port) {
66
+ existing.port = port;
67
+ log.info("route updated", { slug: project_slug, port });
68
+ broadcast("session", { sessions: getSessionsData() });
69
+ }
70
+ res.writeHead(200, { "Content-Type": "application/json" });
71
+ res.end(JSON.stringify({ ok: true, heartbeat: true }));
72
+ return;
73
+ }
74
+
75
+ const info: RouteInfo = {
76
+ port,
77
+ registeredAt: new Date().toISOString(),
78
+ lastEventAt: null,
79
+ eventCount: 0,
80
+ errorCount: 0,
81
+ status: "unknown",
82
+ };
83
+ routes.set(project_slug, info);
84
+ metrics.registrations++;
85
+ log.info("registered", { slug: project_slug, port });
86
+
87
+ const event: RouterEvent = {
88
+ id: newEventId(),
89
+ timestamp: new Date().toISOString(),
90
+ type: "register",
91
+ slug: project_slug,
92
+ routingDecision: null,
93
+ downstreamPort: port,
94
+ durationMs: 0,
95
+ responseStatus: 200,
96
+ };
97
+ events.push(event);
98
+ broadcast("session", { sessions: getSessionsData() });
99
+ broadcast("webhook", event);
100
+
101
+ res.writeHead(200, { "Content-Type": "application/json" });
102
+ res.end(JSON.stringify({ ok: true }));
103
+ }
104
+
105
+ async function handleUnregister(req: IncomingMessage, res: ServerResponse) {
106
+ const body = JSON.parse(await readBody(req));
107
+ const { project_slug } = body;
108
+ if (!project_slug) {
109
+ res.writeHead(400, { "Content-Type": "application/json" });
110
+ res.end(JSON.stringify({ error: "missing project_slug" }));
111
+ return;
112
+ }
113
+
114
+ routes.delete(project_slug);
115
+ metrics.unregistrations++;
116
+ log.info("unregistered", { slug: project_slug });
117
+
118
+ const event: RouterEvent = {
119
+ id: newEventId(),
120
+ timestamp: new Date().toISOString(),
121
+ type: "unregister",
122
+ slug: project_slug,
123
+ routingDecision: null,
124
+ durationMs: 0,
125
+ responseStatus: 200,
126
+ };
127
+ events.push(event);
128
+ broadcast("session", { sessions: getSessionsData() });
129
+ broadcast("webhook", event);
130
+
131
+ res.writeHead(200, { "Content-Type": "application/json" });
132
+ res.end(JSON.stringify({ ok: true }));
133
+ }
134
+
135
+ function handleRoutes(_req: IncomingMessage, res: ServerResponse) {
136
+ res.writeHead(200, { "Content-Type": "application/json" });
137
+ res.end(JSON.stringify(getRoutesSnapshot(), null, 2));
138
+ }
139
+
140
+ async function handleWebhook(req: IncomingMessage, res: ServerResponse) {
141
+ const trace = createTrace();
142
+ const traceId = newEventId();
143
+
144
+ // Auth
145
+ const authSpan = trace.span("auth_validate");
146
+ const token = req.headers["x-webhook-token"] || req.headers["x-gitlab-token"];
147
+ if (token !== SECRET) {
148
+ trace.end(authSpan);
149
+ log.warn("rejected: invalid token", { traceId });
150
+ metrics.recordRequest(401);
151
+ metrics.recordWebhook("unauthorized");
152
+
153
+ const ev: RouterEvent = {
154
+ id: traceId,
155
+ timestamp: new Date().toISOString(),
156
+ type: "webhook",
157
+ slug: "unknown",
158
+ routingDecision: "unauthorized",
159
+ durationMs: trace.elapsed(),
160
+ responseStatus: 401,
161
+ traceSpans: trace.spans,
162
+ error: "invalid token",
163
+ };
164
+ events.push(ev);
165
+ broadcast("webhook", ev);
166
+
167
+ res.writeHead(401, { "Content-Type": "application/json" });
168
+ res.end(JSON.stringify({ error: "unauthorized" }));
169
+ return;
170
+ }
171
+ trace.end(authSpan);
172
+
173
+ // Parse
174
+ const parseSpan = trace.span("parse_payload");
175
+ const rawBody = await readBody(req);
176
+ let payload: any;
177
+ try {
178
+ payload = JSON.parse(rawBody);
179
+ } catch {
180
+ trace.end(parseSpan);
181
+ metrics.recordRequest(400);
182
+ metrics.recordWebhook("invalid");
183
+
184
+ const ev: RouterEvent = {
185
+ id: traceId,
186
+ timestamp: new Date().toISOString(),
187
+ type: "webhook",
188
+ slug: "unknown",
189
+ routingDecision: "invalid",
190
+ durationMs: trace.elapsed(),
191
+ responseStatus: 400,
192
+ traceSpans: trace.spans,
193
+ error: "invalid JSON",
194
+ };
195
+ events.push(ev);
196
+ broadcast("webhook", ev);
197
+
198
+ res.writeHead(400, { "Content-Type": "application/json" });
199
+ res.end(JSON.stringify({ error: "invalid JSON" }));
200
+ return;
201
+ }
202
+ trace.end(parseSpan);
203
+
204
+ const slug = payload.project_slug;
205
+ if (!slug) {
206
+ metrics.recordRequest(400);
207
+ metrics.recordWebhook("invalid");
208
+
209
+ const ev: RouterEvent = {
210
+ id: traceId,
211
+ timestamp: new Date().toISOString(),
212
+ type: "webhook",
213
+ slug: "unknown",
214
+ routingDecision: "invalid",
215
+ payload: truncatePayload(payload),
216
+ durationMs: trace.elapsed(),
217
+ responseStatus: 400,
218
+ traceSpans: trace.spans,
219
+ error: "missing project_slug",
220
+ };
221
+ events.push(ev);
222
+ broadcast("webhook", ev);
223
+
224
+ res.writeHead(400, { "Content-Type": "application/json" });
225
+ res.end(JSON.stringify({ error: "missing project_slug in payload" }));
226
+ return;
227
+ }
228
+
229
+ // Route lookup
230
+ const routeSpan = trace.span("route_lookup");
231
+ const routeInfo = routes.get(slug);
232
+ trace.end(routeSpan);
233
+
234
+ if (!routeInfo) {
235
+ const durationMs = trace.elapsed();
236
+ log.warn("no route", { slug, traceId });
237
+ metrics.recordRequest(404);
238
+ metrics.recordWebhook("no_route", slug, durationMs);
239
+
240
+ const ev: RouterEvent = {
241
+ id: traceId,
242
+ timestamp: new Date().toISOString(),
243
+ type: "webhook",
244
+ slug,
245
+ routingDecision: "no_route",
246
+ payload: truncatePayload(payload),
247
+ durationMs,
248
+ responseStatus: 404,
249
+ traceSpans: trace.spans,
250
+ };
251
+ events.push(ev);
252
+ broadcast("webhook", ev);
253
+
254
+ res.writeHead(404, { "Content-Type": "application/json" });
255
+ res.end(JSON.stringify({ error: `no route for ${slug}` }));
256
+ return;
257
+ }
258
+
259
+ // Forward
260
+ const fwdSpan = trace.span("forward_downstream");
261
+ log.info("routing", { slug, port: routeInfo.port, traceId });
262
+ try {
263
+ const resp = await fetch(`http://127.0.0.1:${routeInfo.port}`, {
264
+ method: "POST",
265
+ headers: {
266
+ "Content-Type": "application/json",
267
+ "X-Trace-Id": traceId,
268
+ },
269
+ body: rawBody,
270
+ });
271
+ trace.end(fwdSpan);
272
+ const durationMs = trace.elapsed();
273
+
274
+ routeInfo.lastEventAt = new Date().toISOString();
275
+ routeInfo.eventCount++;
276
+ routeInfo.status = "up";
277
+ metrics.recordRequest(200);
278
+ metrics.recordWebhook("forwarded", slug, durationMs);
279
+ log.info("forwarded", { slug, port: routeInfo.port, status: resp.status, durationMs, traceId });
280
+
281
+ const ev: RouterEvent = {
282
+ id: traceId,
283
+ timestamp: new Date().toISOString(),
284
+ type: "webhook",
285
+ slug,
286
+ routingDecision: "forwarded",
287
+ downstreamPort: routeInfo.port,
288
+ downstreamStatus: resp.status,
289
+ payload: truncatePayload(payload),
290
+ durationMs,
291
+ forwardDurationMs: fwdSpan.durationMs,
292
+ responseStatus: 200,
293
+ traceSpans: trace.spans,
294
+ };
295
+ events.push(ev);
296
+ broadcast("webhook", ev);
297
+
298
+ res.writeHead(200, { "Content-Type": "application/json" });
299
+ res.end(JSON.stringify({ ok: true, forwarded_to: routeInfo.port }));
300
+ } catch (err: any) {
301
+ trace.end(fwdSpan);
302
+ const durationMs = trace.elapsed();
303
+
304
+ routeInfo.errorCount++;
305
+ routeInfo.status = "down";
306
+ metrics.recordRequest(502);
307
+ metrics.recordWebhook("downstream_error", slug, durationMs);
308
+ log.error("forward failed", { slug, port: routeInfo.port, error: err.message, traceId });
309
+
310
+ const ev: RouterEvent = {
311
+ id: traceId,
312
+ timestamp: new Date().toISOString(),
313
+ type: "webhook",
314
+ slug,
315
+ routingDecision: "forwarded",
316
+ downstreamPort: routeInfo.port,
317
+ payload: truncatePayload(payload),
318
+ durationMs,
319
+ forwardDurationMs: fwdSpan.durationMs,
320
+ responseStatus: 502,
321
+ traceSpans: trace.spans,
322
+ error: err.message,
323
+ };
324
+ events.push(ev);
325
+ broadcast("webhook", ev);
326
+
327
+ res.writeHead(502, { "Content-Type": "application/json" });
328
+ res.end(JSON.stringify({ error: "downstream unreachable" }));
329
+ }
330
+ }
331
+
332
+ // --- API handlers ---
333
+
334
+ function handleApiEvents(req: IncomingMessage, res: ServerResponse) {
335
+ const url = new URL(req.url || "/", `http://localhost:${PORT}`);
336
+ const limit = parseInt(url.searchParams.get("limit") || "50", 10);
337
+ const offset = parseInt(url.searchParams.get("offset") || "0", 10);
338
+ const slug = url.searchParams.get("slug");
339
+
340
+ const result = slug ? events.getBySlug(slug, limit) : events.getRecent(limit, offset);
341
+ res.writeHead(200, { "Content-Type": "application/json" });
342
+ res.end(JSON.stringify(result));
343
+ }
344
+
345
+ function handleApiStats(_req: IncomingMessage, res: ServerResponse) {
346
+ const stats = {
347
+ ...metrics.getStats(),
348
+ routesActive: routes.size,
349
+ routes: getRoutesSnapshot(),
350
+ totalEvents: events.count,
351
+ };
352
+ res.writeHead(200, { "Content-Type": "application/json" });
353
+ res.end(JSON.stringify(stats));
354
+ }
355
+
356
+ function handleMetrics(_req: IncomingMessage, res: ServerResponse) {
357
+ const text = metrics.formatPrometheus({ routesActive: routes.size });
358
+ res.writeHead(200, { "Content-Type": "text/plain; version=0.0.4; charset=utf-8" });
359
+ res.end(text);
360
+ }
361
+
362
+ // --- SSE ---
363
+
364
+ const sseClients = new Set<ServerResponse>();
365
+
366
+ function broadcast(eventType: string, data: any) {
367
+ const msg = `event: ${eventType}\ndata: ${JSON.stringify(data)}\n\n`;
368
+ for (const client of sseClients) {
369
+ try {
370
+ client.write(msg);
371
+ } catch {
372
+ sseClients.delete(client);
373
+ }
374
+ }
375
+ }
376
+
377
+ function handleApiStream(req: IncomingMessage, res: ServerResponse) {
378
+ res.writeHead(200, {
379
+ "Content-Type": "text/event-stream",
380
+ "Cache-Control": "no-cache",
381
+ Connection: "keep-alive",
382
+ });
383
+
384
+ const initData = {
385
+ sessions: getSessionsData(),
386
+ stats: metrics.getStats(),
387
+ recentEvents: events.getRecent(50),
388
+ };
389
+ res.write(`event: init\ndata: ${JSON.stringify(initData)}\n\n`);
390
+
391
+ sseClients.add(res);
392
+ req.on("close", () => sseClients.delete(res));
393
+ }
394
+
395
+ // Periodic stats broadcast
396
+ const statsInterval = setInterval(() => {
397
+ if (sseClients.size > 0) {
398
+ broadcast("stats", metrics.getStats());
399
+ }
400
+ }, 5000);
401
+ statsInterval.unref();
402
+
403
+ function handleApiHealth(_req: IncomingMessage, res: ServerResponse) {
404
+ res.writeHead(200, { "Content-Type": "application/json" });
405
+ res.end(JSON.stringify({
406
+ status: "ok",
407
+ version: "0.2.0",
408
+ uptimeSeconds: Math.floor((Date.now() - metrics.startTime) / 1000),
409
+ routesActive: routes.size,
410
+ }));
411
+ }
412
+
413
+ async function handleApiKill(req: IncomingMessage, res: ServerResponse) {
414
+ const body = JSON.parse(await readBody(req));
415
+ const { project_slug } = body;
416
+ if (!project_slug) {
417
+ res.writeHead(400, { "Content-Type": "application/json" });
418
+ res.end(JSON.stringify({ error: "missing project_slug" }));
419
+ return;
420
+ }
421
+
422
+ const routeInfo = routes.get(project_slug);
423
+ if (!routeInfo) {
424
+ res.writeHead(404, { "Content-Type": "application/json" });
425
+ res.end(JSON.stringify({ error: `no route for ${project_slug}` }));
426
+ return;
427
+ }
428
+
429
+ const result = { ok: true, slug: project_slug, port: routeInfo.port, eventCount: routeInfo.eventCount };
430
+
431
+ // Signal the channel to shut down
432
+ try {
433
+ await fetch(`http://127.0.0.1:${routeInfo.port}/shutdown`, { method: "POST" });
434
+ log.info("sent shutdown to channel", { slug: project_slug, port: routeInfo.port });
435
+ } catch {
436
+ log.warn("could not reach channel for shutdown", { slug: project_slug, port: routeInfo.port });
437
+ }
438
+
439
+ routes.delete(project_slug);
440
+ log.info("killed", { slug: project_slug, port: routeInfo.port });
441
+
442
+ events.push({
443
+ id: newEventId(),
444
+ timestamp: new Date().toISOString(),
445
+ type: "unregister",
446
+ slug: project_slug,
447
+ routingDecision: null,
448
+ durationMs: 0,
449
+ responseStatus: 200,
450
+ });
451
+ broadcast("session", { sessions: getSessionsData() });
452
+
453
+ res.writeHead(200, { "Content-Type": "application/json" });
454
+ res.end(JSON.stringify(result));
455
+ }
456
+
457
+ function getSessionsData() {
458
+ return [...routes.entries()].map(([slug, info]) => {
459
+ const rm = metrics.perRoute.get(slug);
460
+ return {
461
+ slug,
462
+ port: info.port,
463
+ status: info.status,
464
+ registeredAt: info.registeredAt,
465
+ lastEventAt: info.lastEventAt,
466
+ eventCount: info.eventCount,
467
+ errorCount: info.errorCount,
468
+ avgLatencyMs: rm?.avgLatencyMs ?? 0,
469
+ successCount: rm?.success ?? 0,
470
+ failedCount: rm?.failed ?? 0,
471
+ };
472
+ });
473
+ }
474
+
475
+ function handleApiSessions(_req: IncomingMessage, res: ServerResponse) {
476
+ res.writeHead(200, { "Content-Type": "application/json" });
477
+ res.end(JSON.stringify(getSessionsData()));
478
+ }
479
+
480
+ function handleApiEventById(id: string, res: ServerResponse) {
481
+ const event = events.getById(id);
482
+ if (!event) {
483
+ res.writeHead(404, { "Content-Type": "application/json" });
484
+ res.end(JSON.stringify({ error: "event not found" }));
485
+ return;
486
+ }
487
+ res.writeHead(200, { "Content-Type": "application/json" });
488
+ res.end(JSON.stringify(event));
489
+ }
490
+
491
+ // --- Server ---
492
+
493
+ const server = createServer(async (req, res) => {
494
+ const url = new URL(req.url || "/", `http://localhost:${PORT}`);
495
+ const method = req.method?.toUpperCase();
496
+
497
+ try {
498
+ // POST routes
499
+ if (method === "POST") {
500
+ if (url.pathname === "/register") return await handleRegister(req, res);
501
+ if (url.pathname === "/unregister") return await handleUnregister(req, res);
502
+ if (url.pathname === "/api/kill") return await handleApiKill(req, res);
503
+ if (url.pathname === "/") return await handleWebhook(req, res);
504
+ }
505
+
506
+ // GET routes
507
+ if (method === "GET") {
508
+ if (url.pathname === "/" || url.pathname === "/dashboard") {
509
+ if (dashboardHtml) {
510
+ res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
511
+ res.end(dashboardHtml);
512
+ } else {
513
+ res.writeHead(503, { "Content-Type": "text/plain" });
514
+ res.end("Dashboard not available");
515
+ }
516
+ return;
517
+ }
518
+ if (url.pathname === "/routes") return handleRoutes(req, res);
519
+ if (url.pathname === "/metrics") return handleMetrics(req, res);
520
+ if (url.pathname === "/api/health") return handleApiHealth(req, res);
521
+ if (url.pathname === "/api/sessions") return handleApiSessions(req, res);
522
+ if (url.pathname === "/api/events") return handleApiEvents(req, res);
523
+ if (url.pathname === "/api/stats") return handleApiStats(req, res);
524
+ if (url.pathname === "/api/stream") return handleApiStream(req, res);
525
+ // /api/events/:id
526
+ const evMatch = url.pathname.match(/^\/api\/events\/(.+)$/);
527
+ if (evMatch) return handleApiEventById(evMatch[1], res);
528
+ }
529
+
530
+ res.writeHead(404, { "Content-Type": "application/json" });
531
+ res.end(JSON.stringify({ error: "not found" }));
532
+ } catch (err: any) {
533
+ log.error("unhandled error", { error: err.message, path: url.pathname });
534
+ metrics.recordRequest(500);
535
+ res.writeHead(500, { "Content-Type": "application/json" });
536
+ res.end(JSON.stringify({ error: "internal error" }));
537
+ }
538
+ });
539
+
540
+ server.listen(PORT, HOST, () => {
541
+ const addr = server.address();
542
+ const actualPort = typeof addr === "object" && addr ? addr.port : PORT;
543
+ log.info("listening", { host: HOST, port: actualPort });
544
+ log.info("secret", { preview: SECRET.slice(0, 4) + "..." });
545
+ // Machine-readable line for process spawners to discover the port
546
+ process.stderr.write(`HOOKHERALD_PORT=${actualPort}\n`);
547
+ });