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,279 @@
1
+ import { randomUUID } from "node:crypto";
2
+
3
+ // --- Types ---
4
+
5
+ export type LogLevel = "debug" | "info" | "warn" | "error";
6
+
7
+ const LOG_LEVELS: Record<LogLevel, number> = { debug: 0, info: 1, warn: 2, error: 3 };
8
+ const CURRENT_LEVEL = LOG_LEVELS[(process.env.LOG_LEVEL as LogLevel) || "info"] ?? 1;
9
+
10
+ export interface TraceSpan {
11
+ name: string;
12
+ startMs: number;
13
+ endMs: number;
14
+ durationMs: number;
15
+ }
16
+
17
+ export interface RouterEvent {
18
+ id: string;
19
+ timestamp: string;
20
+ type: "webhook" | "register" | "unregister" | "error";
21
+ slug: string;
22
+ routingDecision: "forwarded" | "no_route" | "unauthorized" | "invalid" | null;
23
+ downstreamPort?: number;
24
+ downstreamStatus?: number;
25
+ payload?: any;
26
+ durationMs: number;
27
+ forwardDurationMs?: number;
28
+ responseStatus: number;
29
+ traceSpans?: TraceSpan[];
30
+ error?: string;
31
+ }
32
+
33
+ export interface RouteInfo {
34
+ port: number;
35
+ registeredAt: string;
36
+ lastEventAt: string | null;
37
+ eventCount: number;
38
+ errorCount: number;
39
+ status: "up" | "down" | "unknown";
40
+ }
41
+
42
+ interface RouteMetrics {
43
+ total: number;
44
+ success: number;
45
+ failed: number;
46
+ lastEventAt: string | null;
47
+ latencySum: number;
48
+ avgLatencyMs: number;
49
+ }
50
+
51
+ // --- Structured Logger ---
52
+
53
+ export function createLogger(component: string, toStderr = false) {
54
+ const write = toStderr
55
+ ? (line: string) => process.stderr.write(line + "\n")
56
+ : (line: string) => console.log(line);
57
+
58
+ function emit(level: LogLevel, msg: string, fields?: Record<string, any>) {
59
+ if (LOG_LEVELS[level] < CURRENT_LEVEL) return;
60
+ const entry = { ts: new Date().toISOString(), level, component, msg, ...fields };
61
+ write(JSON.stringify(entry));
62
+ }
63
+
64
+ return {
65
+ debug: (msg: string, fields?: Record<string, any>) => emit("debug", msg, fields),
66
+ info: (msg: string, fields?: Record<string, any>) => emit("info", msg, fields),
67
+ warn: (msg: string, fields?: Record<string, any>) => emit("warn", msg, fields),
68
+ error: (msg: string, fields?: Record<string, any>) => emit("error", msg, fields),
69
+ };
70
+ }
71
+
72
+ // --- Event Store (ring buffer) ---
73
+
74
+ export class EventStore {
75
+ private events: RouterEvent[] = [];
76
+ private readonly maxSize: number;
77
+
78
+ constructor(maxSize = 1000) {
79
+ this.maxSize = maxSize;
80
+ }
81
+
82
+ push(event: RouterEvent) {
83
+ if (this.events.length >= this.maxSize) {
84
+ this.events.shift();
85
+ }
86
+ this.events.push(event);
87
+ }
88
+
89
+ getRecent(limit = 50, offset = 0): RouterEvent[] {
90
+ const reversed = [...this.events].reverse();
91
+ return reversed.slice(offset, offset + limit);
92
+ }
93
+
94
+ getById(id: string): RouterEvent | undefined {
95
+ return this.events.find((e) => e.id === id);
96
+ }
97
+
98
+ getBySlug(slug: string, limit = 50): RouterEvent[] {
99
+ return [...this.events]
100
+ .reverse()
101
+ .filter((e) => e.slug === slug)
102
+ .slice(0, limit);
103
+ }
104
+
105
+ get count(): number {
106
+ return this.events.length;
107
+ }
108
+ }
109
+
110
+ // --- Metrics Collector ---
111
+
112
+ export class MetricsCollector {
113
+ startTime = Date.now();
114
+ requests = { total: 0, byStatus: new Map<number, number>() };
115
+ webhooks = {
116
+ total: 0,
117
+ forwarded: 0,
118
+ noRoute: 0,
119
+ unauthorized: 0,
120
+ invalidPayload: 0,
121
+ downstreamErrors: 0,
122
+ };
123
+ perRoute = new Map<string, RouteMetrics>();
124
+ registrations = 0;
125
+ unregistrations = 0;
126
+
127
+ recordRequest(status: number) {
128
+ this.requests.total++;
129
+ this.requests.byStatus.set(status, (this.requests.byStatus.get(status) || 0) + 1);
130
+ }
131
+
132
+ recordWebhook(
133
+ outcome: "forwarded" | "no_route" | "unauthorized" | "invalid" | "downstream_error",
134
+ slug?: string,
135
+ durationMs?: number,
136
+ ) {
137
+ this.webhooks.total++;
138
+ switch (outcome) {
139
+ case "forwarded":
140
+ this.webhooks.forwarded++;
141
+ break;
142
+ case "no_route":
143
+ this.webhooks.noRoute++;
144
+ break;
145
+ case "unauthorized":
146
+ this.webhooks.unauthorized++;
147
+ break;
148
+ case "invalid":
149
+ this.webhooks.invalidPayload++;
150
+ break;
151
+ case "downstream_error":
152
+ this.webhooks.downstreamErrors++;
153
+ break;
154
+ }
155
+
156
+ if (slug && durationMs !== undefined) {
157
+ const rm = this.perRoute.get(slug) || {
158
+ total: 0,
159
+ success: 0,
160
+ failed: 0,
161
+ lastEventAt: null,
162
+ latencySum: 0,
163
+ avgLatencyMs: 0,
164
+ };
165
+ rm.total++;
166
+ if (outcome === "forwarded") rm.success++;
167
+ else rm.failed++;
168
+ rm.lastEventAt = new Date().toISOString();
169
+ rm.latencySum += durationMs;
170
+ rm.avgLatencyMs = Math.round(rm.latencySum / rm.total);
171
+ this.perRoute.set(slug, rm);
172
+ }
173
+ }
174
+
175
+ formatPrometheus(extra?: { routesActive?: number }): string {
176
+ const uptime = Math.floor((Date.now() - this.startTime) / 1000);
177
+ const lines: string[] = [];
178
+
179
+ lines.push("# HELP hookherald_uptime_seconds Seconds since router started");
180
+ lines.push("# TYPE hookherald_uptime_seconds gauge");
181
+ lines.push(`hookherald_uptime_seconds ${uptime}`);
182
+ lines.push("");
183
+
184
+ lines.push("# HELP hookherald_requests_total Total HTTP requests");
185
+ lines.push("# TYPE hookherald_requests_total counter");
186
+ for (const [status, count] of this.requests.byStatus) {
187
+ lines.push(`hookherald_requests_total{status="${status}"} ${count}`);
188
+ }
189
+ lines.push("");
190
+
191
+ lines.push("# HELP hookherald_webhooks_total Webhook events by outcome");
192
+ lines.push("# TYPE hookherald_webhooks_total counter");
193
+ lines.push(`hookherald_webhooks_total{outcome="forwarded"} ${this.webhooks.forwarded}`);
194
+ lines.push(`hookherald_webhooks_total{outcome="no_route"} ${this.webhooks.noRoute}`);
195
+ lines.push(`hookherald_webhooks_total{outcome="unauthorized"} ${this.webhooks.unauthorized}`);
196
+ lines.push(`hookherald_webhooks_total{outcome="invalid"} ${this.webhooks.invalidPayload}`);
197
+ lines.push(
198
+ `hookherald_webhooks_total{outcome="downstream_error"} ${this.webhooks.downstreamErrors}`,
199
+ );
200
+ lines.push("");
201
+
202
+ lines.push("# HELP hookherald_webhook_avg_duration_ms Average webhook handling latency");
203
+ lines.push("# TYPE hookherald_webhook_avg_duration_ms gauge");
204
+ for (const [slug, rm] of this.perRoute) {
205
+ lines.push(`hookherald_webhook_avg_duration_ms{slug="${slug}"} ${rm.avgLatencyMs}`);
206
+ }
207
+ lines.push("");
208
+
209
+ lines.push("# HELP hookherald_routes_active Currently registered routes");
210
+ lines.push("# TYPE hookherald_routes_active gauge");
211
+ lines.push(`hookherald_routes_active ${extra?.routesActive ?? 0}`);
212
+ lines.push("");
213
+
214
+ lines.push("# HELP hookherald_registrations_total Total registrations");
215
+ lines.push("# TYPE hookherald_registrations_total counter");
216
+ lines.push(`hookherald_registrations_total ${this.registrations}`);
217
+ lines.push("");
218
+
219
+ lines.push("# HELP hookherald_unregistrations_total Total unregistrations");
220
+ lines.push("# TYPE hookherald_unregistrations_total counter");
221
+ lines.push(`hookherald_unregistrations_total ${this.unregistrations}`);
222
+
223
+ return lines.join("\n") + "\n";
224
+ }
225
+
226
+ getStats() {
227
+ return {
228
+ uptimeSeconds: Math.floor((Date.now() - this.startTime) / 1000),
229
+ requests: {
230
+ total: this.requests.total,
231
+ byStatus: Object.fromEntries(this.requests.byStatus),
232
+ },
233
+ webhooks: { ...this.webhooks },
234
+ perRoute: Object.fromEntries(this.perRoute),
235
+ registrations: this.registrations,
236
+ unregistrations: this.unregistrations,
237
+ };
238
+ }
239
+ }
240
+
241
+ // --- Trace Helpers ---
242
+
243
+ export function createTrace() {
244
+ const spans: TraceSpan[] = [];
245
+ const origin = performance.now();
246
+
247
+ return {
248
+ spans,
249
+ span(name: string): TraceSpan {
250
+ const startMs = Math.round(performance.now() - origin);
251
+ const s: TraceSpan = { name, startMs, endMs: startMs, durationMs: 0 };
252
+ spans.push(s);
253
+ return s;
254
+ },
255
+ end(span: TraceSpan) {
256
+ span.endMs = Math.round(performance.now() - origin);
257
+ span.durationMs = span.endMs - span.startMs;
258
+ },
259
+ elapsed(): number {
260
+ return Math.round(performance.now() - origin);
261
+ },
262
+ };
263
+ }
264
+
265
+ // --- Payload truncation ---
266
+
267
+ const MAX_PAYLOAD_SIZE = 10 * 1024; // 10KB
268
+
269
+ export function truncatePayload(payload: any): any {
270
+ const str = JSON.stringify(payload);
271
+ if (str.length <= MAX_PAYLOAD_SIZE) return payload;
272
+ return { _truncated: true, _originalSize: str.length, preview: str.slice(0, MAX_PAYLOAD_SIZE) };
273
+ }
274
+
275
+ // --- UUID helper ---
276
+
277
+ export function newEventId(): string {
278
+ return randomUUID();
279
+ }
@@ -0,0 +1,164 @@
1
+ import { Server } from "@modelcontextprotocol/sdk/server/index.js";
2
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
3
+ import { createServer } from "node:http";
4
+ import { createLogger } from "./observability.js";
5
+
6
+ const PROJECT_SLUG = process.env.PROJECT_SLUG || "unknown/project";
7
+ const ROUTER_URL = process.env.ROUTER_URL || "http://127.0.0.1:9000";
8
+
9
+ const log = createLogger(`channel:${PROJECT_SLUG}`, true); // stderr for MCP
10
+
11
+ // --- MCP Server setup ---
12
+ const mcp = new Server(
13
+ { name: "webhook-channel", version: "0.2.0" },
14
+ {
15
+ capabilities: {
16
+ experimental: { "claude/channel": {} },
17
+ },
18
+ instructions:
19
+ 'CI pipeline events arrive as <channel source="webhook-channel" ...>. ' +
20
+ "They are one-way notifications. Read them and act on the CI results — " +
21
+ "check logs, inspect code, fix issues, etc.",
22
+ }
23
+ );
24
+
25
+ // --- HTTP Server to receive forwarded webhooks ---
26
+ function formatMessage(payload: any): string {
27
+ return JSON.stringify(payload, null, 2);
28
+ }
29
+
30
+ let assignedPort: number;
31
+
32
+ const httpServer = createServer(async (req, res) => {
33
+ if (req.method !== "POST") {
34
+ res.writeHead(405).end("Method not allowed");
35
+ return;
36
+ }
37
+
38
+ // Remote shutdown endpoint
39
+ if (req.url === "/shutdown") {
40
+ log.info("received remote shutdown signal");
41
+ res.writeHead(200, { "Content-Type": "application/json" });
42
+ res.end(JSON.stringify({ ok: true }));
43
+ shutdown();
44
+ return;
45
+ }
46
+
47
+ const traceId = req.headers["x-trace-id"] as string | undefined;
48
+
49
+ const chunks: Buffer[] = [];
50
+ for await (const chunk of req) chunks.push(chunk as Buffer);
51
+ const rawBody = Buffer.concat(chunks).toString();
52
+
53
+ let payload: any;
54
+ try {
55
+ payload = JSON.parse(rawBody);
56
+ } catch {
57
+ res.writeHead(400).end("Invalid JSON");
58
+ return;
59
+ }
60
+
61
+ const content = formatMessage(payload);
62
+ const meta: Record<string, string> = {
63
+ project: String(payload.project_slug || ""),
64
+ traceId: traceId || "",
65
+ };
66
+
67
+ log.info("received event", {
68
+ status: payload.pipeline_status,
69
+ slug: payload.project_slug,
70
+ traceId,
71
+ });
72
+
73
+ try {
74
+ await mcp.notification({
75
+ method: "notifications/claude/channel",
76
+ params: { content, meta },
77
+ });
78
+ log.info("emitted channel notification", { traceId });
79
+ } catch (err: any) {
80
+ log.error("failed to emit notification", { error: err.message, traceId });
81
+ }
82
+
83
+ res.writeHead(200, { "Content-Type": "application/json" });
84
+ res.end(JSON.stringify({ ok: true }));
85
+ });
86
+
87
+ // --- Registration with heartbeat ---
88
+
89
+ const HEARTBEAT_INTERVAL = parseInt(process.env.HH_HEARTBEAT_MS || "30000", 10);
90
+ let registered = false;
91
+ let heartbeatTimer: ReturnType<typeof setInterval> | null = null;
92
+
93
+ async function register(): Promise<boolean> {
94
+ try {
95
+ const resp = await fetch(`${ROUTER_URL}/register`, {
96
+ method: "POST",
97
+ headers: { "Content-Type": "application/json" },
98
+ body: JSON.stringify({ project_slug: PROJECT_SLUG, port: assignedPort }),
99
+ });
100
+ if (resp.ok) {
101
+ if (!registered) log.info("registered with router", { router: ROUTER_URL });
102
+ registered = true;
103
+ return true;
104
+ }
105
+ log.warn("registration failed", { status: resp.status });
106
+ registered = false;
107
+ return false;
108
+ } catch {
109
+ if (registered) log.warn("lost connection to router", { router: ROUTER_URL });
110
+ registered = false;
111
+ return false;
112
+ }
113
+ }
114
+
115
+ function startHeartbeat() {
116
+ heartbeatTimer = setInterval(register, HEARTBEAT_INTERVAL);
117
+ heartbeatTimer.unref();
118
+ }
119
+
120
+ // Bind to port 0 for auto-assignment
121
+ httpServer.listen(0, "127.0.0.1", async () => {
122
+ const addr = httpServer.address();
123
+ assignedPort = typeof addr === "object" && addr ? addr.port : 0;
124
+ log.info("HTTP server listening", { host: "127.0.0.1", port: assignedPort });
125
+
126
+ await register();
127
+ startHeartbeat();
128
+ });
129
+
130
+ // Graceful shutdown: unregister from router
131
+ async function shutdown() {
132
+ log.info("shutting down");
133
+ if (heartbeatTimer) clearInterval(heartbeatTimer);
134
+ try {
135
+ await fetch(`${ROUTER_URL}/unregister`, {
136
+ method: "POST",
137
+ headers: { "Content-Type": "application/json" },
138
+ body: JSON.stringify({ project_slug: PROJECT_SLUG }),
139
+ });
140
+ log.info("unregistered from router");
141
+ } catch {
142
+ // Router may already be down
143
+ }
144
+ httpServer.close();
145
+ process.exit(0);
146
+ }
147
+
148
+ process.on("SIGTERM", shutdown);
149
+ process.on("SIGINT", shutdown);
150
+
151
+ // --- Connect MCP over stdio ---
152
+ const transport = new StdioServerTransport();
153
+ transport.onclose = () => {
154
+ log.info("MCP transport closed, shutting down");
155
+ shutdown();
156
+ };
157
+ await mcp.connect(transport);
158
+ log.info("MCP server connected via stdio");
159
+
160
+ // Fallback: if stdin closes (parent died), shut down even if transport doesn't notice
161
+ process.stdin.on("end", () => {
162
+ log.info("stdin closed, shutting down");
163
+ shutdown();
164
+ });