hookherald 0.4.0 → 0.6.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/bin/hh CHANGED
@@ -7,4 +7,8 @@ while [ -L "$SELF" ]; do
7
7
  case "$SELF" in /*) ;; *) SELF="$DIR/$SELF" ;; esac
8
8
  done
9
9
  SCRIPT_DIR="$(cd "$(dirname "$SELF")" && pwd)"
10
- exec npx tsx "$SCRIPT_DIR/../src/cli.ts" "$@"
10
+ PKG_DIR="$SCRIPT_DIR/.."
11
+
12
+ # Use node --import directly instead of npx tsx for faster startup
13
+ TSX_LOADER="$PKG_DIR/node_modules/tsx/dist/esm/index.mjs"
14
+ exec node --import "$TSX_LOADER" "$PKG_DIR/src/cli.ts" "$@"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "hookherald",
3
- "version": "0.4.0",
3
+ "version": "0.6.0",
4
4
  "description": "Webhook relay for Claude Code — push notifications from any HTTP POST into running sessions",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -38,6 +38,7 @@ export interface WatcherConfig {
38
38
  export interface RouteInfo {
39
39
  port: number;
40
40
  registeredAt: string;
41
+ lastHeartbeatAt: number;
41
42
  lastEventAt: string | null;
42
43
  eventCount: number;
43
44
  errorCount: number;
@@ -283,3 +284,22 @@ export function truncatePayload(payload: any): any {
283
284
  export function newEventId(): string {
284
285
  return randomUUID();
285
286
  }
287
+
288
+ // --- Event factory ---
289
+
290
+ export function createRouterEvent(
291
+ type: RouterEvent["type"],
292
+ slug: string,
293
+ overrides: Partial<RouterEvent> = {},
294
+ ): RouterEvent {
295
+ return {
296
+ id: newEventId(),
297
+ timestamp: new Date().toISOString(),
298
+ type,
299
+ slug,
300
+ routingDecision: null,
301
+ durationMs: 0,
302
+ responseStatus: 200,
303
+ ...overrides,
304
+ };
305
+ }
@@ -2,7 +2,7 @@ import { Server } from "@modelcontextprotocol/sdk/server/index.js";
2
2
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
3
3
  import { createServer } from "node:http";
4
4
  import { readFileSync, watch as fsWatch, type FSWatcher } from "node:fs";
5
- import { execSync } from "node:child_process";
5
+ import { execFile } from "node:child_process";
6
6
  import { resolve, dirname } from "node:path";
7
7
  import { fileURLToPath } from "node:url";
8
8
  import { createLogger, type WatcherConfig } from "./observability.js";
@@ -56,8 +56,17 @@ const httpServer = createServer(async (req, res) => {
56
56
 
57
57
  const traceId = req.headers["x-trace-id"] as string | undefined;
58
58
 
59
+ const MAX_BODY = 1024 * 1024; // 1MB
59
60
  const chunks: Buffer[] = [];
60
- for await (const chunk of req) chunks.push(chunk as Buffer);
61
+ let size = 0;
62
+ for await (const chunk of req) {
63
+ size += (chunk as Buffer).length;
64
+ if (size > MAX_BODY) {
65
+ res.writeHead(413).end("Payload too large");
66
+ return;
67
+ }
68
+ chunks.push(chunk as Buffer);
69
+ }
61
70
  const rawBody = Buffer.concat(chunks).toString();
62
71
 
63
72
  let payload: any;
@@ -146,16 +155,16 @@ function readConfig(): { slug: string; router_url: string; watchers: WatcherConf
146
155
  }
147
156
  }
148
157
 
149
- function executeCommand(cmd: string): string {
150
- try {
151
- return execSync(cmd, { encoding: "utf-8", shell: true, stdio: ["pipe", "pipe", "pipe"], timeout: 60000 }).trim();
152
- } catch (err: any) {
153
- return (err.stdout || "").trim();
154
- }
158
+ function executeCommand(cmd: string): Promise<string> {
159
+ return new Promise((resolve) => {
160
+ execFile("sh", ["-c", cmd], { encoding: "utf-8", timeout: 60000 }, (err, stdout) => {
161
+ resolve((stdout || "").trim());
162
+ });
163
+ });
155
164
  }
156
165
 
157
166
  async function runWatcher(watcher: WatcherConfig) {
158
- const output = executeCommand(watcher.command);
167
+ const output = await executeCommand(watcher.command);
159
168
  if (!output) return;
160
169
 
161
170
  let parsed: any;
@@ -206,8 +215,8 @@ function startWatchers(watchers: WatcherConfig[]) {
206
215
  const key = watcherKey(w);
207
216
  if (watcherIntervals.has(key)) continue;
208
217
 
209
- // Run immediately, then on interval
210
- runWatcher(w);
218
+ // Run immediately (if registered), then on interval
219
+ if (registered) runWatcher(w);
211
220
  const timer = setInterval(() => runWatcher(w), w.interval * 1000);
212
221
  timer.unref();
213
222
  watcherIntervals.set(key, timer);
@@ -217,10 +226,12 @@ function startWatchers(watchers: WatcherConfig[]) {
217
226
  currentWatcherConfigs = watchers;
218
227
  }
219
228
 
220
- function loadAndStartWatchers() {
229
+ async function loadAndStartWatchers() {
221
230
  const config = readConfig();
222
231
  const watchers = config?.watchers || [];
223
232
  startWatchers(watchers);
233
+ // Re-register so the router sees the updated watcher list
234
+ if (registered) await register();
224
235
  }
225
236
 
226
237
  function startConfigWatcher() {
@@ -256,9 +267,17 @@ httpServer.listen(0, "127.0.0.1", async () => {
256
267
  assignedPort = typeof addr === "object" && addr ? addr.port : 0;
257
268
  log.info("HTTP server listening", { host: "127.0.0.1", port: assignedPort });
258
269
 
259
- loadAndStartWatchers();
260
- startConfigWatcher();
270
+ // Load watcher configs (but don't start them yet)
271
+ const config = readConfig();
272
+ currentWatcherConfigs = config?.watchers || [];
273
+
274
+ // Register first so watchers can POST immediately
261
275
  await register();
276
+
277
+ // Now start watchers — they'll run immediately since we're registered
278
+ startWatchers(currentWatcherConfigs);
279
+
280
+ startConfigWatcher();
262
281
  startHeartbeat();
263
282
  });
264
283
 
@@ -10,6 +10,7 @@ import {
10
10
  createTrace,
11
11
  truncatePayload,
12
12
  newEventId,
13
+ createRouterEvent,
13
14
  type RouteInfo,
14
15
  type RouterEvent,
15
16
  } from "./observability.js";
@@ -24,8 +25,14 @@ const HOST = process.env.ROUTER_HOST || "127.0.0.1";
24
25
  const SECRET = process.env.WEBHOOK_SECRET || "";
25
26
 
26
27
  function safeEqual(a: string, b: string): boolean {
27
- if (a.length !== b.length) return false;
28
- return timingSafeEqual(Buffer.from(a), Buffer.from(b));
28
+ const bufA = Buffer.from(a);
29
+ const bufB = Buffer.from(b);
30
+ if (bufA.length !== bufB.length) {
31
+ // Compare against self to burn constant time, then return false
32
+ timingSafeEqual(bufA, bufA);
33
+ return false;
34
+ }
35
+ return timingSafeEqual(bufA, bufB);
29
36
  }
30
37
 
31
38
  const log = createLogger("router");
@@ -58,6 +65,15 @@ async function readBody(req: IncomingMessage): Promise<string> {
58
65
  return Buffer.concat(chunks).toString();
59
66
  }
60
67
 
68
+ // --- Slug validation ---
69
+
70
+ const SLUG_PATTERN = /^[\w\-\.\/]+$/;
71
+ const MAX_SLUG_LENGTH = 200;
72
+
73
+ function isValidSlug(slug: string): boolean {
74
+ return slug.length <= MAX_SLUG_LENGTH && SLUG_PATTERN.test(slug);
75
+ }
76
+
61
77
  // --- Route helpers ---
62
78
 
63
79
  function getRoutesSnapshot() {
@@ -81,10 +97,16 @@ async function handleRegister(req: IncomingMessage, res: ServerResponse) {
81
97
  res.end(JSON.stringify({ error: "missing project_slug or port" }));
82
98
  return;
83
99
  }
100
+ if (!isValidSlug(project_slug)) {
101
+ res.writeHead(400, { "Content-Type": "application/json" });
102
+ res.end(JSON.stringify({ error: "invalid project_slug format" }));
103
+ return;
104
+ }
84
105
 
85
106
  // Heartbeat: if same slug and same port, treat as keepalive
86
107
  const existing = routes.get(project_slug);
87
108
  if (existing && existing.port === port) {
109
+ existing.lastHeartbeatAt = Date.now();
88
110
  // Update watchers on heartbeat (supports hot reload)
89
111
  if (watchers && JSON.stringify(watchers) !== JSON.stringify(existing.watchers)) {
90
112
  existing.watchers = watchers;
@@ -98,6 +120,7 @@ async function handleRegister(req: IncomingMessage, res: ServerResponse) {
98
120
  const info: RouteInfo = {
99
121
  port,
100
122
  registeredAt: new Date().toISOString(),
123
+ lastHeartbeatAt: Date.now(),
101
124
  lastEventAt: null,
102
125
  eventCount: 0,
103
126
  errorCount: 0,
@@ -108,16 +131,7 @@ async function handleRegister(req: IncomingMessage, res: ServerResponse) {
108
131
  metrics.registrations++;
109
132
  log.info("registered", { slug: project_slug, port });
110
133
 
111
- const event: RouterEvent = {
112
- id: newEventId(),
113
- timestamp: new Date().toISOString(),
114
- type: "register",
115
- slug: project_slug,
116
- routingDecision: null,
117
- downstreamPort: port,
118
- durationMs: 0,
119
- responseStatus: 200,
120
- };
134
+ const event = createRouterEvent("register", project_slug, { downstreamPort: port });
121
135
  events.push(event);
122
136
  broadcast("session", { sessions: getSessionsData() });
123
137
  broadcast("webhook", event);
@@ -146,15 +160,7 @@ async function handleUnregister(req: IncomingMessage, res: ServerResponse) {
146
160
  metrics.unregistrations++;
147
161
  log.info("unregistered", { slug: project_slug });
148
162
 
149
- const event: RouterEvent = {
150
- id: newEventId(),
151
- timestamp: new Date().toISOString(),
152
- type: "unregister",
153
- slug: project_slug,
154
- routingDecision: null,
155
- durationMs: 0,
156
- responseStatus: 200,
157
- };
163
+ const event = createRouterEvent("unregister", project_slug);
158
164
  events.push(event);
159
165
  broadcast("session", { sessions: getSessionsData() });
160
166
  broadcast("webhook", event);
@@ -182,17 +188,14 @@ async function handleWebhook(req: IncomingMessage, res: ServerResponse) {
182
188
  metrics.recordRequest(401);
183
189
  metrics.recordWebhook("unauthorized");
184
190
 
185
- const ev: RouterEvent = {
191
+ const ev = createRouterEvent("webhook", "unknown", {
186
192
  id: traceId,
187
- timestamp: new Date().toISOString(),
188
- type: "webhook",
189
- slug: "unknown",
190
193
  routingDecision: "unauthorized",
191
194
  durationMs: trace.elapsed(),
192
195
  responseStatus: 401,
193
196
  traceSpans: trace.spans,
194
197
  error: "invalid token",
195
- };
198
+ });
196
199
  events.push(ev);
197
200
  broadcast("webhook", ev);
198
201
 
@@ -214,17 +217,14 @@ async function handleWebhook(req: IncomingMessage, res: ServerResponse) {
214
217
  metrics.recordRequest(400);
215
218
  metrics.recordWebhook("invalid");
216
219
 
217
- const ev: RouterEvent = {
220
+ const ev = createRouterEvent("webhook", "unknown", {
218
221
  id: traceId,
219
- timestamp: new Date().toISOString(),
220
- type: "webhook",
221
- slug: "unknown",
222
222
  routingDecision: "invalid",
223
223
  durationMs: trace.elapsed(),
224
224
  responseStatus: 400,
225
225
  traceSpans: trace.spans,
226
226
  error: "invalid JSON",
227
- };
227
+ });
228
228
  events.push(ev);
229
229
  broadcast("webhook", ev);
230
230
 
@@ -239,18 +239,15 @@ async function handleWebhook(req: IncomingMessage, res: ServerResponse) {
239
239
  metrics.recordRequest(400);
240
240
  metrics.recordWebhook("invalid");
241
241
 
242
- const ev: RouterEvent = {
242
+ const ev = createRouterEvent("webhook", "unknown", {
243
243
  id: traceId,
244
- timestamp: new Date().toISOString(),
245
- type: "webhook",
246
- slug: "unknown",
247
244
  routingDecision: "invalid",
248
245
  payload: truncatePayload(payload),
249
246
  durationMs: trace.elapsed(),
250
247
  responseStatus: 400,
251
248
  traceSpans: trace.spans,
252
249
  error: "missing project_slug",
253
- };
250
+ });
254
251
  events.push(ev);
255
252
  broadcast("webhook", ev);
256
253
 
@@ -259,6 +256,27 @@ async function handleWebhook(req: IncomingMessage, res: ServerResponse) {
259
256
  return;
260
257
  }
261
258
 
259
+ if (!isValidSlug(slug)) {
260
+ metrics.recordRequest(400);
261
+ metrics.recordWebhook("invalid");
262
+
263
+ const ev = createRouterEvent("webhook", "unknown", {
264
+ id: traceId,
265
+ routingDecision: "invalid",
266
+ payload: truncatePayload(payload),
267
+ durationMs: trace.elapsed(),
268
+ responseStatus: 400,
269
+ traceSpans: trace.spans,
270
+ error: "invalid project_slug format",
271
+ });
272
+ events.push(ev);
273
+ broadcast("webhook", ev);
274
+
275
+ res.writeHead(400, { "Content-Type": "application/json" });
276
+ res.end(JSON.stringify({ error: "invalid project_slug format" }));
277
+ return;
278
+ }
279
+
262
280
  // Route lookup
263
281
  const routeSpan = trace.span("route_lookup");
264
282
  const routeInfo = routes.get(slug);
@@ -270,17 +288,14 @@ async function handleWebhook(req: IncomingMessage, res: ServerResponse) {
270
288
  metrics.recordRequest(404);
271
289
  metrics.recordWebhook("no_route", slug, durationMs);
272
290
 
273
- const ev: RouterEvent = {
291
+ const ev = createRouterEvent("webhook", slug, {
274
292
  id: traceId,
275
- timestamp: new Date().toISOString(),
276
- type: "webhook",
277
- slug,
278
293
  routingDecision: "no_route",
279
294
  payload: truncatePayload(payload),
280
295
  durationMs,
281
296
  responseStatus: 404,
282
297
  traceSpans: trace.spans,
283
- };
298
+ });
284
299
  events.push(ev);
285
300
  broadcast("webhook", ev);
286
301
 
@@ -311,11 +326,8 @@ async function handleWebhook(req: IncomingMessage, res: ServerResponse) {
311
326
  metrics.recordWebhook("forwarded", slug, durationMs);
312
327
  log.info("forwarded", { slug, port: routeInfo.port, status: resp.status, durationMs, traceId });
313
328
 
314
- const ev: RouterEvent = {
329
+ const ev = createRouterEvent("webhook", slug, {
315
330
  id: traceId,
316
- timestamp: new Date().toISOString(),
317
- type: "webhook",
318
- slug,
319
331
  routingDecision: "forwarded",
320
332
  downstreamPort: routeInfo.port,
321
333
  downstreamStatus: resp.status,
@@ -324,7 +336,7 @@ async function handleWebhook(req: IncomingMessage, res: ServerResponse) {
324
336
  forwardDurationMs: fwdSpan.durationMs,
325
337
  responseStatus: 200,
326
338
  traceSpans: trace.spans,
327
- };
339
+ });
328
340
  events.push(ev);
329
341
  broadcast("webhook", ev);
330
342
  broadcast("session", { sessions: getSessionsData() });
@@ -341,11 +353,8 @@ async function handleWebhook(req: IncomingMessage, res: ServerResponse) {
341
353
  metrics.recordWebhook("downstream_error", slug, durationMs);
342
354
  log.error("forward failed", { slug, port: routeInfo.port, error: err.message, traceId });
343
355
 
344
- const ev: RouterEvent = {
356
+ const ev = createRouterEvent("webhook", slug, {
345
357
  id: traceId,
346
- timestamp: new Date().toISOString(),
347
- type: "webhook",
348
- slug,
349
358
  routingDecision: "forwarded",
350
359
  downstreamPort: routeInfo.port,
351
360
  payload: truncatePayload(payload),
@@ -354,7 +363,7 @@ async function handleWebhook(req: IncomingMessage, res: ServerResponse) {
354
363
  responseStatus: 502,
355
364
  traceSpans: trace.spans,
356
365
  error: err.message,
357
- };
366
+ });
358
367
  events.push(ev);
359
368
  broadcast("webhook", ev);
360
369
  broadcast("session", { sessions: getSessionsData() });
@@ -435,6 +444,26 @@ const statsInterval = setInterval(() => {
435
444
  }, 5000);
436
445
  statsInterval.unref();
437
446
 
447
+ // Stale route cleanup: remove routes that missed 3 heartbeat intervals
448
+ const HEARTBEAT_MS = parseInt(process.env.HH_HEARTBEAT_MS || "30000", 10);
449
+ const STALE_THRESHOLD_MS = HEARTBEAT_MS * 3;
450
+
451
+ const staleInterval = setInterval(() => {
452
+ const now = Date.now();
453
+ let changed = false;
454
+ for (const [slug, info] of routes) {
455
+ if (now - info.lastHeartbeatAt > STALE_THRESHOLD_MS) {
456
+ routes.delete(slug);
457
+ metrics.unregistrations++;
458
+ log.warn("reaped stale route", { slug, lastHeartbeatAgoMs: now - info.lastHeartbeatAt });
459
+ events.push(createRouterEvent("unregister", slug));
460
+ changed = true;
461
+ }
462
+ }
463
+ if (changed) broadcast("session", { sessions: getSessionsData() });
464
+ }, STALE_THRESHOLD_MS);
465
+ staleInterval.unref();
466
+
438
467
  function handleApiHealth(_req: IncomingMessage, res: ServerResponse) {
439
468
  res.writeHead(200, { "Content-Type": "application/json" });
440
469
  res.end(JSON.stringify({
@@ -481,15 +510,7 @@ async function handleApiKill(req: IncomingMessage, res: ServerResponse) {
481
510
  routes.delete(project_slug);
482
511
  log.info("killed", { slug: project_slug, port: routeInfo.port });
483
512
 
484
- events.push({
485
- id: newEventId(),
486
- timestamp: new Date().toISOString(),
487
- type: "unregister",
488
- slug: project_slug,
489
- routingDecision: null,
490
- durationMs: 0,
491
- responseStatus: 200,
492
- });
513
+ events.push(createRouterEvent("unregister", project_slug));
493
514
  broadcast("session", { sessions: getSessionsData() });
494
515
 
495
516
  res.writeHead(200, { "Content-Type": "application/json" });