hookherald 0.5.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "hookherald",
3
- "version": "0.5.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",
@@ -284,3 +284,22 @@ export function truncatePayload(payload: any): any {
284
284
  export function newEventId(): string {
285
285
  return randomUUID();
286
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
+ }
@@ -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;
@@ -258,11 +267,17 @@ httpServer.listen(0, "127.0.0.1", async () => {
258
267
  assignedPort = typeof addr === "object" && addr ? addr.port : 0;
259
268
  log.info("HTTP server listening", { host: "127.0.0.1", port: assignedPort });
260
269
 
261
- loadAndStartWatchers();
262
- 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
263
275
  await register();
264
- // Run watchers that were deferred during startup (before registration)
265
- for (const w of currentWatcherConfigs) runWatcher(w);
276
+
277
+ // Now start watchers they'll run immediately since we're registered
278
+ startWatchers(currentWatcherConfigs);
279
+
280
+ startConfigWatcher();
266
281
  startHeartbeat();
267
282
  });
268
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";
@@ -64,6 +65,15 @@ async function readBody(req: IncomingMessage): Promise<string> {
64
65
  return Buffer.concat(chunks).toString();
65
66
  }
66
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
+
67
77
  // --- Route helpers ---
68
78
 
69
79
  function getRoutesSnapshot() {
@@ -87,6 +97,11 @@ async function handleRegister(req: IncomingMessage, res: ServerResponse) {
87
97
  res.end(JSON.stringify({ error: "missing project_slug or port" }));
88
98
  return;
89
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
+ }
90
105
 
91
106
  // Heartbeat: if same slug and same port, treat as keepalive
92
107
  const existing = routes.get(project_slug);
@@ -116,16 +131,7 @@ async function handleRegister(req: IncomingMessage, res: ServerResponse) {
116
131
  metrics.registrations++;
117
132
  log.info("registered", { slug: project_slug, port });
118
133
 
119
- const event: RouterEvent = {
120
- id: newEventId(),
121
- timestamp: new Date().toISOString(),
122
- type: "register",
123
- slug: project_slug,
124
- routingDecision: null,
125
- downstreamPort: port,
126
- durationMs: 0,
127
- responseStatus: 200,
128
- };
134
+ const event = createRouterEvent("register", project_slug, { downstreamPort: port });
129
135
  events.push(event);
130
136
  broadcast("session", { sessions: getSessionsData() });
131
137
  broadcast("webhook", event);
@@ -154,15 +160,7 @@ async function handleUnregister(req: IncomingMessage, res: ServerResponse) {
154
160
  metrics.unregistrations++;
155
161
  log.info("unregistered", { slug: project_slug });
156
162
 
157
- const event: RouterEvent = {
158
- id: newEventId(),
159
- timestamp: new Date().toISOString(),
160
- type: "unregister",
161
- slug: project_slug,
162
- routingDecision: null,
163
- durationMs: 0,
164
- responseStatus: 200,
165
- };
163
+ const event = createRouterEvent("unregister", project_slug);
166
164
  events.push(event);
167
165
  broadcast("session", { sessions: getSessionsData() });
168
166
  broadcast("webhook", event);
@@ -190,17 +188,14 @@ async function handleWebhook(req: IncomingMessage, res: ServerResponse) {
190
188
  metrics.recordRequest(401);
191
189
  metrics.recordWebhook("unauthorized");
192
190
 
193
- const ev: RouterEvent = {
191
+ const ev = createRouterEvent("webhook", "unknown", {
194
192
  id: traceId,
195
- timestamp: new Date().toISOString(),
196
- type: "webhook",
197
- slug: "unknown",
198
193
  routingDecision: "unauthorized",
199
194
  durationMs: trace.elapsed(),
200
195
  responseStatus: 401,
201
196
  traceSpans: trace.spans,
202
197
  error: "invalid token",
203
- };
198
+ });
204
199
  events.push(ev);
205
200
  broadcast("webhook", ev);
206
201
 
@@ -222,17 +217,14 @@ async function handleWebhook(req: IncomingMessage, res: ServerResponse) {
222
217
  metrics.recordRequest(400);
223
218
  metrics.recordWebhook("invalid");
224
219
 
225
- const ev: RouterEvent = {
220
+ const ev = createRouterEvent("webhook", "unknown", {
226
221
  id: traceId,
227
- timestamp: new Date().toISOString(),
228
- type: "webhook",
229
- slug: "unknown",
230
222
  routingDecision: "invalid",
231
223
  durationMs: trace.elapsed(),
232
224
  responseStatus: 400,
233
225
  traceSpans: trace.spans,
234
226
  error: "invalid JSON",
235
- };
227
+ });
236
228
  events.push(ev);
237
229
  broadcast("webhook", ev);
238
230
 
@@ -247,18 +239,15 @@ async function handleWebhook(req: IncomingMessage, res: ServerResponse) {
247
239
  metrics.recordRequest(400);
248
240
  metrics.recordWebhook("invalid");
249
241
 
250
- const ev: RouterEvent = {
242
+ const ev = createRouterEvent("webhook", "unknown", {
251
243
  id: traceId,
252
- timestamp: new Date().toISOString(),
253
- type: "webhook",
254
- slug: "unknown",
255
244
  routingDecision: "invalid",
256
245
  payload: truncatePayload(payload),
257
246
  durationMs: trace.elapsed(),
258
247
  responseStatus: 400,
259
248
  traceSpans: trace.spans,
260
249
  error: "missing project_slug",
261
- };
250
+ });
262
251
  events.push(ev);
263
252
  broadcast("webhook", ev);
264
253
 
@@ -267,6 +256,27 @@ async function handleWebhook(req: IncomingMessage, res: ServerResponse) {
267
256
  return;
268
257
  }
269
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
+
270
280
  // Route lookup
271
281
  const routeSpan = trace.span("route_lookup");
272
282
  const routeInfo = routes.get(slug);
@@ -278,17 +288,14 @@ async function handleWebhook(req: IncomingMessage, res: ServerResponse) {
278
288
  metrics.recordRequest(404);
279
289
  metrics.recordWebhook("no_route", slug, durationMs);
280
290
 
281
- const ev: RouterEvent = {
291
+ const ev = createRouterEvent("webhook", slug, {
282
292
  id: traceId,
283
- timestamp: new Date().toISOString(),
284
- type: "webhook",
285
- slug,
286
293
  routingDecision: "no_route",
287
294
  payload: truncatePayload(payload),
288
295
  durationMs,
289
296
  responseStatus: 404,
290
297
  traceSpans: trace.spans,
291
- };
298
+ });
292
299
  events.push(ev);
293
300
  broadcast("webhook", ev);
294
301
 
@@ -319,11 +326,8 @@ async function handleWebhook(req: IncomingMessage, res: ServerResponse) {
319
326
  metrics.recordWebhook("forwarded", slug, durationMs);
320
327
  log.info("forwarded", { slug, port: routeInfo.port, status: resp.status, durationMs, traceId });
321
328
 
322
- const ev: RouterEvent = {
329
+ const ev = createRouterEvent("webhook", slug, {
323
330
  id: traceId,
324
- timestamp: new Date().toISOString(),
325
- type: "webhook",
326
- slug,
327
331
  routingDecision: "forwarded",
328
332
  downstreamPort: routeInfo.port,
329
333
  downstreamStatus: resp.status,
@@ -332,7 +336,7 @@ async function handleWebhook(req: IncomingMessage, res: ServerResponse) {
332
336
  forwardDurationMs: fwdSpan.durationMs,
333
337
  responseStatus: 200,
334
338
  traceSpans: trace.spans,
335
- };
339
+ });
336
340
  events.push(ev);
337
341
  broadcast("webhook", ev);
338
342
  broadcast("session", { sessions: getSessionsData() });
@@ -349,11 +353,8 @@ async function handleWebhook(req: IncomingMessage, res: ServerResponse) {
349
353
  metrics.recordWebhook("downstream_error", slug, durationMs);
350
354
  log.error("forward failed", { slug, port: routeInfo.port, error: err.message, traceId });
351
355
 
352
- const ev: RouterEvent = {
356
+ const ev = createRouterEvent("webhook", slug, {
353
357
  id: traceId,
354
- timestamp: new Date().toISOString(),
355
- type: "webhook",
356
- slug,
357
358
  routingDecision: "forwarded",
358
359
  downstreamPort: routeInfo.port,
359
360
  payload: truncatePayload(payload),
@@ -362,7 +363,7 @@ async function handleWebhook(req: IncomingMessage, res: ServerResponse) {
362
363
  responseStatus: 502,
363
364
  traceSpans: trace.spans,
364
365
  error: err.message,
365
- };
366
+ });
366
367
  events.push(ev);
367
368
  broadcast("webhook", ev);
368
369
  broadcast("session", { sessions: getSessionsData() });
@@ -455,15 +456,7 @@ const staleInterval = setInterval(() => {
455
456
  routes.delete(slug);
456
457
  metrics.unregistrations++;
457
458
  log.warn("reaped stale route", { slug, lastHeartbeatAgoMs: now - info.lastHeartbeatAt });
458
- events.push({
459
- id: newEventId(),
460
- timestamp: new Date().toISOString(),
461
- type: "unregister",
462
- slug,
463
- routingDecision: null,
464
- durationMs: 0,
465
- responseStatus: 200,
466
- });
459
+ events.push(createRouterEvent("unregister", slug));
467
460
  changed = true;
468
461
  }
469
462
  }
@@ -517,15 +510,7 @@ async function handleApiKill(req: IncomingMessage, res: ServerResponse) {
517
510
  routes.delete(project_slug);
518
511
  log.info("killed", { slug: project_slug, port: routeInfo.port });
519
512
 
520
- events.push({
521
- id: newEventId(),
522
- timestamp: new Date().toISOString(),
523
- type: "unregister",
524
- slug: project_slug,
525
- routingDecision: null,
526
- durationMs: 0,
527
- responseStatus: 200,
528
- });
513
+ events.push(createRouterEvent("unregister", project_slug));
529
514
  broadcast("session", { sessions: getSessionsData() });
530
515
 
531
516
  res.writeHead(200, { "Content-Type": "application/json" });