hookherald 0.3.2 → 0.5.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/README.md CHANGED
@@ -1,17 +1,15 @@
1
1
  # HookHerald
2
2
 
3
- A webhook relay that pushes notifications into running [Claude Code](https://claude.ai/code) sessions. Any system that can fire an HTTP POST — CI pipelines, monitoring alerts, deployment hooks, chat bots, cron jobs — can send messages directly into your Claude conversation.
3
+ A webhook relay and watcher system that pushes notifications into running [Claude Code](https://claude.ai/code) sessions. Any system that can fire an HTTP POST — or any script that can print to stdout — can send messages directly into your Claude conversation.
4
4
 
5
5
  ## How It Works
6
6
 
7
7
  ```
8
- Webhook POST ──> Router (auth + route) ──> Channel ──> Claude Code session
9
- :9000 (MCP) <channel> notification
8
+ Webhooks: HTTP POST ──> Router ──> Channel ──> Claude Code
9
+ Watchers: Script stdout ──> Channel ──> Router ──> Channel ──> Claude Code
10
10
  ```
11
11
 
12
- The **router** is a central HTTP server that receives webhooks and forwards them by `project_slug` to the right session. Each Claude Code session runs a **channel** (MCP server) that auto-registers with the router. The **CLI** (`hh`) sets everything up.
13
-
14
- Payloads are forwarded as raw JSON — send whatever you want, the agent figures it out.
12
+ The **router** is a local HTTP server that receives webhooks and forwards them by `project_slug`. Each Claude Code session runs a **channel** (MCP server) that auto-registers with the router. **Watchers** are scripts that run on an interval — their stdout becomes notifications. The **CLI** (`hh`) sets everything up.
15
13
 
16
14
  ## Install
17
15
 
@@ -31,20 +29,104 @@ hh router --bg
31
29
  cd ~/my-project
32
30
  hh init
33
31
 
34
- # 3. Start Claude Code
32
+ # 3. Start Claude Code (from the same directory — it needs .mcp.json and .hookherald.json)
35
33
  claude --dangerously-load-development-channels server:webhook-channel
36
34
 
37
35
  # 4. Send a webhook
38
36
  curl -X POST http://127.0.0.1:9000/ \
39
37
  -H "Content-Type: application/json" \
40
- -H "X-Webhook-Token: dev-secret" \
41
38
  -d '{"project_slug":"my-group/my-project","status":"deployed","version":"1.2.3"}'
42
39
  ```
43
40
 
41
+ No auth by default — localhost is trusted. See [Auth](#auth) to enable it.
42
+
43
+ ## Watchers
44
+
45
+ Watchers poll external systems and push notifications into Claude Code. Configure them in `.hookherald.json` (created by `hh init`):
46
+
47
+ ```json
48
+ {
49
+ "slug": "mygroup/myapp",
50
+ "router_url": "http://127.0.0.1:9000",
51
+ "watchers": [
52
+ { "command": "./check-pipeline.sh", "interval": 30 },
53
+ { "command": "kubectl get pods -n default -o json", "interval": 60 }
54
+ ]
55
+ }
56
+ ```
57
+
58
+ The contract is simple — HookHerald runs your command and forwards whatever it prints:
59
+ - **stdout = send** — any non-empty stdout gets forwarded to Claude Code as a notification
60
+ - **no stdout = skip** — nothing happens, no notification
61
+ - **exit code doesn't matter** — only stdout counts, output is captured even on non-zero exit
62
+ - **JSON stdout is parsed** — valid JSON stays structured, plain text stays as a string
63
+ - **No diffing** — HookHerald doesn't compare outputs between runs. If your script prints something, it gets sent. The script decides when to fire and handles its own state/dedup.
64
+
65
+ ### Example: Watch Kubernetes Pods
66
+
67
+ ```bash
68
+ #!/bin/bash
69
+ # watch-pods.sh — notify when pod states change
70
+ STATE_FILE="/tmp/hh-pods-state"
71
+
72
+ CURRENT=$(kubectl get pods -n default -o json 2>/dev/null | jq -c \
73
+ '[.items[] | {name: .metadata.name, phase: .status.phase, ready: (.status.containerStatuses // [] | map(.ready) | all), restarts: (.status.containerStatuses // [] | map(.restartCount) | add // 0)}] | sort_by(.name)')
74
+
75
+ if [ -z "$CURRENT" ] || [ "$CURRENT" = "[]" ]; then exit 0; fi
76
+
77
+ LAST=$(cat "$STATE_FILE" 2>/dev/null)
78
+ if [ "$CURRENT" = "$LAST" ]; then exit 0; fi
79
+
80
+ echo "$CURRENT" > "$STATE_FILE"
81
+ echo "$CURRENT" | jq '{
82
+ pods: .,
83
+ summary: {
84
+ total: (. | length),
85
+ running: ([.[] | select(.phase == "Running")] | length),
86
+ not_ready: ([.[] | select(.ready == false)] | length),
87
+ crashing: ([.[] | select(.restarts > 3)] | length)
88
+ }
89
+ }'
90
+ ```
91
+
92
+ ### Example: Watch GitLab Pipeline
93
+
94
+ ```bash
95
+ #!/bin/bash
96
+ # check-pipeline.sh — notify on pipeline completion
97
+ STATE_FILE="/tmp/hh-pipeline-last"
98
+
99
+ PIPELINE=$(curl -s -H "PRIVATE-TOKEN: $GITLAB_TOKEN" \
100
+ "https://gitlab.com/api/v4/projects/mygroup%2Fmyapp/pipelines/latest")
101
+
102
+ ID=$(echo "$PIPELINE" | jq -r '.id')
103
+ STATUS=$(echo "$PIPELINE" | jq -r '.status')
104
+ LAST=$(cat "$STATE_FILE" 2>/dev/null)
105
+
106
+ case "$STATUS" in
107
+ failed|success|canceled)
108
+ [ "$ID" = "$LAST" ] && exit 0
109
+ echo "$ID" > "$STATE_FILE"
110
+ echo "$PIPELINE" | jq '{
111
+ pipeline_id: .id,
112
+ status: .status,
113
+ ref: .ref,
114
+ url: .web_url
115
+ }'
116
+ ;;
117
+ esac
118
+ ```
119
+
120
+ ### Hot Reload
121
+
122
+ Edit `.hookherald.json` while Claude Code is running — watchers are added/removed automatically. No restart needed. The dashboard updates within 30 seconds.
123
+
124
+ See [examples/README.md](examples/README.md) for detailed walkthroughs, more scripts, writing your own watchers, and troubleshooting.
125
+
44
126
  ## CLI
45
127
 
46
128
  ```
47
- hh init [--slug <slug>] [--router-url <url>] Set up .mcp.json in current directory
129
+ hh init [--slug <slug>] [--router-url <url>] Set up .mcp.json + .hookherald.json
48
130
  hh status [--router-url <url>] Show active sessions
49
131
  hh kill <slug> [--router-url <url>] Bounce a session
50
132
  hh router [--port <port>] [--secret <secret>] Start the webhook router
@@ -52,69 +134,74 @@ hh router [--port <port>] [--secret <secret>] Start the webhook router
52
134
  hh router stop Stop background router
53
135
  ```
54
136
 
55
- `hh init` auto-detects the project slug from `git remote origin`. Merges with existing `.mcp.json` if present.
137
+ `hh init` auto-detects the project slug from `git remote origin`. Creates `.hookherald.json` with an empty watchers array. Merges with existing `.mcp.json` if present. Won't overwrite an existing `.hookherald.json`.
138
+
139
+ `hh kill` signals the channel to shut down. Claude Code will respawn it.
140
+
141
+ ## Auth
142
+
143
+ Auth is opt-in. By default, no secret is needed — everything runs on localhost.
144
+
145
+ ```bash
146
+ # No auth (default)
147
+ hh router
148
+
149
+ # Enable auth on webhook ingestion
150
+ hh router --secret my-secret
151
+ ```
152
+
153
+ When a secret is set, `POST /` requires `X-Webhook-Token` (or `X-Gitlab-Token`). Internal endpoints (`/register`, `/unregister`, `/api/kill`) never require auth.
56
154
 
57
- `hh kill` signals the channel to shut down. Claude Code will respawn it use this to bounce a session, not permanently remove it.
155
+ For external sources (GitLab CI, GitHub Actions), start the router with `--secret` and configure the same secret in the webhook settings.
58
156
 
59
157
  ## Docker
60
158
 
61
159
  The router can also run via Docker (requires `--network host` on Linux):
62
160
 
63
161
  ```bash
162
+ docker run -d --network host shoofio/hookherald
163
+
164
+ # With auth
64
165
  docker run -d --network host -e WEBHOOK_SECRET=my-secret shoofio/hookherald
65
166
  ```
66
167
 
67
168
  > For rootless Docker or Docker Desktop (Mac/Windows), use `hh router` instead.
68
169
 
170
+ ## Dashboard
171
+
172
+ Live session management UI at `http://127.0.0.1:9000/`:
173
+
174
+ - **Sessions** — status, events, errors, latency, kill button
175
+ - **Watchers** — shown per session with command and interval, click to filter events by source
176
+ - **Events** — click sessions to filter, ctrl/shift for multi-select, expand for trace waterfall and payload
177
+ - **Live** — SSE-powered, no refresh needed
178
+
69
179
  ## Router API
70
180
 
71
181
  | Method | Path | Description |
72
182
  |--------|------|-------------|
73
- | `POST` | `/` | Receive webhook (requires `X-Webhook-Token` or `X-Gitlab-Token`) |
183
+ | `POST` | `/` | Receive webhook (auth required only if `WEBHOOK_SECRET` is set) |
74
184
  | `POST` | `/api/kill` | Remove a session (signals channel to shut down) |
75
- | `GET` | `/` | Session management dashboard |
185
+ | `GET` | `/` | Dashboard |
76
186
  | `GET` | `/api/health` | Health check |
77
- | `GET` | `/api/sessions` | Active sessions with metrics |
187
+ | `GET` | `/api/sessions` | Active sessions with metrics and watchers |
78
188
  | `GET` | `/api/events` | Query events (`?slug=`, `?limit=`, `?offset=`) |
79
189
  | `GET` | `/api/events/:id` | Single event by ID |
80
190
  | `GET` | `/api/stream` | SSE live updates |
81
191
  | `GET` | `/metrics` | Prometheus format |
82
192
 
83
- ## Dashboard
84
-
85
- Live session management UI at `http://127.0.0.1:9000/`:
86
-
87
- - **Sessions** — status, events, errors, latency, kill button
88
- - **Events** — click sessions to filter, ctrl/shift for multi-select
89
- - **Detail** — trace waterfall, raw payload
90
- - **Live** — SSE-powered, no refresh
91
-
92
- ## GitLab CI
93
-
94
- Add a webhook in project settings, or use a CI step:
95
-
96
- ```yaml
97
- notify:
98
- stage: .post
99
- script:
100
- - |
101
- curl -s -X POST http://$ROUTER_HOST:9000/ \
102
- -H "Content-Type: application/json" \
103
- -H "X-Webhook-Token: $WEBHOOK_SECRET" \
104
- -d "{\"project_slug\":\"$CI_PROJECT_PATH\",\"status\":\"$CI_PIPELINE_STATUS\",\"branch\":\"$CI_COMMIT_BRANCH\",\"sha\":\"$CI_COMMIT_SHA\"}"
105
- ```
106
-
107
193
  ## Environment Variables
108
194
 
109
195
  | Variable | Default | Description |
110
196
  |----------|---------|-------------|
111
197
  | `ROUTER_PORT` | `9000` | Router listen port |
112
198
  | `ROUTER_HOST` | `127.0.0.1` | Bind address (`0.0.0.0` for Docker) |
113
- | `WEBHOOK_SECRET` | `dev-secret` | Auth secret |
199
+ | `WEBHOOK_SECRET` | *(none)* | Auth secret (opt-in) |
114
200
  | `PROJECT_SLUG` | `unknown/project` | Channel's project ID |
115
201
  | `ROUTER_URL` | `http://127.0.0.1:9000` | Router address |
116
202
  | `LOG_LEVEL` | `info` | debug/info/warn/error |
117
203
  | `HH_HEARTBEAT_MS` | `30000` | Channel heartbeat interval |
204
+ | `HH_CONFIG_PATH` | *(none)* | Path to `.hookherald.json` (set by `hh init`) |
118
205
 
119
206
  ## License
120
207
 
package/bin/hh CHANGED
@@ -1,2 +1,14 @@
1
1
  #!/bin/sh
2
- exec npx tsx "$(dirname "$(readlink -f "$0")")/../src/cli.ts" "$@"
2
+ # Resolve symlinks to find the actual package directory
3
+ SELF="$0"
4
+ while [ -L "$SELF" ]; do
5
+ DIR="$(cd "$(dirname "$SELF")" && pwd)"
6
+ SELF="$(readlink "$SELF")"
7
+ case "$SELF" in /*) ;; *) SELF="$DIR/$SELF" ;; esac
8
+ done
9
+ SCRIPT_DIR="$(cd "$(dirname "$SELF")" && pwd)"
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.3.2",
3
+ "version": "0.5.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",
package/src/cli.ts CHANGED
@@ -16,7 +16,6 @@ const PID_FILE = resolve(PID_DIR, "router.pid");
16
16
 
17
17
  const DEFAULT_ROUTER_URL = "http://127.0.0.1:9000";
18
18
  const DEFAULT_PORT = "9000";
19
- const DEFAULT_SECRET = "dev-secret";
20
19
 
21
20
  const args = process.argv.slice(2);
22
21
  const command = args[0];
@@ -45,8 +44,9 @@ function detectSlug(): string {
45
44
  }).trim();
46
45
 
47
46
  // SSH: git@gitlab.com:group/project.git
47
+ // Skip if it looks like a URL scheme (e.g. https:)
48
48
  const lastColon = remote.lastIndexOf(":");
49
- if (lastColon !== -1 && !remote.slice(0, lastColon).includes("/")) {
49
+ if (lastColon !== -1 && !remote.includes("://")) {
50
50
  const slug = remote.slice(lastColon + 1).replace(/\.git$/, "");
51
51
  if (slug.includes("/")) return slug;
52
52
  }
@@ -81,17 +81,33 @@ async function cmdInit() {
81
81
 
82
82
  if (!config.mcpServers) config.mcpServers = {};
83
83
 
84
+ const hhConfigPath = resolve(process.cwd(), ".hookherald.json");
85
+
84
86
  config.mcpServers["webhook-channel"] = {
85
87
  command: "node",
86
88
  args: ["--import", resolve(TSX_PATH, "dist", "esm", "index.mjs"), CHANNEL_PATH],
87
89
  env: {
88
90
  PROJECT_SLUG: slug,
89
91
  ROUTER_URL: routerUrl,
92
+ HH_CONFIG_PATH: hhConfigPath,
90
93
  },
91
94
  };
92
95
 
93
96
  writeFileSync(mcpPath, JSON.stringify(config, null, 2) + "\n");
94
- console.log(`Initialized HookHerald for ${slug} in .mcp.json`);
97
+
98
+ // Write .hookherald.json if it doesn't exist
99
+ if (!existsSync(hhConfigPath)) {
100
+ const hhConfig = {
101
+ slug,
102
+ router_url: routerUrl,
103
+ watchers: [],
104
+ };
105
+ writeFileSync(hhConfigPath, JSON.stringify(hhConfig, null, 2) + "\n");
106
+ console.log(`Initialized HookHerald for ${slug}`);
107
+ console.log(` Config: ${hhConfigPath}`);
108
+ } else {
109
+ console.log(`Initialized HookHerald for ${slug} (config already exists)`);
110
+ }
95
111
  console.log(` Channel: ${CHANNEL_PATH}`);
96
112
  console.log(` Router: ${routerUrl}`);
97
113
  }
@@ -184,7 +200,7 @@ function cmdRouter() {
184
200
  }
185
201
 
186
202
  const port = getFlag("port") || process.env.ROUTER_PORT || DEFAULT_PORT;
187
- const secret = getFlag("secret") || process.env.WEBHOOK_SECRET || DEFAULT_SECRET;
203
+ const secret = getFlag("secret") || process.env.WEBHOOK_SECRET || "";
188
204
  const bg = hasFlag("bg");
189
205
 
190
206
  const child = spawn("node", ["--import", resolve(TSX_PATH, "dist", "esm", "index.mjs"), ROUTER_PATH], {
@@ -284,6 +284,28 @@
284
284
  border-radius: 4px;
285
285
  }
286
286
  .filter-bar label { color: var(--text-dim); }
287
+
288
+ /* Watcher rows */
289
+ .watcher-row td { padding: 0 12px 8px !important; border-bottom: 1px solid var(--border); }
290
+ .watcher-row.selected td { background: #1a2636; }
291
+ .watchers-list { display: flex; flex-wrap: wrap; gap: 6px; }
292
+ .watcher-tag {
293
+ display: inline-flex;
294
+ align-items: center;
295
+ gap: 4px;
296
+ font-family: var(--mono);
297
+ font-size: 11px;
298
+ padding: 2px 8px;
299
+ border-radius: 3px;
300
+ background: #1a2a3a;
301
+ color: var(--text-dim);
302
+ border: 1px solid var(--border);
303
+ }
304
+ .watcher-tag { cursor: pointer; }
305
+ .watcher-tag:hover { border-color: var(--blue); }
306
+ .watcher-tag.active { background: #1a3a5f; border-color: var(--blue); color: var(--text); }
307
+ .watcher-icon { font-size: 10px; }
308
+ .watcher-interval { color: var(--blue); font-weight: 600; }
287
309
  </style>
288
310
  </head>
289
311
  <body>
@@ -335,6 +357,7 @@ const state = {
335
357
  expandedEventId: null,
336
358
  selectedSlugs: new Set(),
337
359
  filterType: '',
360
+ filterSource: '',
338
361
  };
339
362
 
340
363
  // --- SSE ---
@@ -431,6 +454,14 @@ function renderSessions() {
431
454
  <td class="last-event-cell" data-ts="${s.lastEventAt || ''}">${s.lastEventAt ? timeAgo(s.lastEventAt) : 'never'}</td>
432
455
  <td><button class="btn-kill" onclick="event.stopPropagation(); killSession('${esc(s.slug)}')">kill</button></td>
433
456
  </tr>`;
457
+ if (s.watchers && s.watchers.length > 0) {
458
+ html += `<tr class="watcher-row${sel}"><td colspan="8"><div class="watchers-list">`;
459
+ for (const w of s.watchers) {
460
+ const active = state.filterSource === w.command ? ' active' : '';
461
+ html += `<span class="watcher-tag${active}" title="${esc(w.command)}" onclick="event.stopPropagation(); filterBySource('${esc(w.command)}')"><span class="watcher-icon">&#9201;</span>${esc(w.command.length > 40 ? w.command.slice(0, 37) + '...' : w.command)} <span class="watcher-interval">${w.interval}s</span></span>`;
462
+ }
463
+ html += '</div></td></tr>';
464
+ }
434
465
  }
435
466
 
436
467
  html += '</tbody></table>';
@@ -472,22 +503,28 @@ function selectSession(slug, ev) {
472
503
  updateFilterHint();
473
504
  }
474
505
 
506
+ function filterBySource(cmd) {
507
+ state.filterSource = state.filterSource === cmd ? '' : cmd;
508
+ renderSessions();
509
+ renderEvents();
510
+ updateFilterHint();
511
+ }
512
+
475
513
  function updateFilterHint() {
476
514
  const el = document.getElementById('filterHint');
515
+ const parts = [];
477
516
  const n = state.selectedSlugs.size;
478
- if (n === 0) {
479
- el.textContent = 'Showing all sessions';
480
- } else if (n === 1) {
481
- el.textContent = 'Filtered: ' + [...state.selectedSlugs][0];
482
- } else {
483
- el.textContent = 'Filtered: ' + n + ' sessions';
484
- }
517
+ if (n === 1) parts.push([...state.selectedSlugs][0]);
518
+ else if (n > 1) parts.push(n + ' sessions');
519
+ if (state.filterSource) parts.push('source: ' + state.filterSource);
520
+ el.textContent = parts.length ? 'Filtered: ' + parts.join(', ') : 'Showing all sessions';
485
521
  }
486
522
 
487
523
  function getFilteredEvents() {
488
524
  return state.events.filter(ev => {
489
525
  if (state.selectedSlugs.size > 0 && !state.selectedSlugs.has(ev.slug)) return false;
490
526
  if (state.filterType && ev.type !== state.filterType) return false;
527
+ if (state.filterSource && (!ev.payload || ev.payload.source !== state.filterSource)) return false;
491
528
  return true;
492
529
  });
493
530
  }
@@ -30,13 +30,20 @@ export interface RouterEvent {
30
30
  error?: string;
31
31
  }
32
32
 
33
+ export interface WatcherConfig {
34
+ command: string;
35
+ interval: number;
36
+ }
37
+
33
38
  export interface RouteInfo {
34
39
  port: number;
35
40
  registeredAt: string;
41
+ lastHeartbeatAt: number;
36
42
  lastEventAt: string | null;
37
43
  eventCount: number;
38
44
  errorCount: number;
39
45
  status: "up" | "down" | "unknown";
46
+ watchers: WatcherConfig[];
40
47
  }
41
48
 
42
49
  interface RouteMetrics {
@@ -1,16 +1,26 @@
1
1
  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
- import { createLogger } from "./observability.js";
4
+ import { readFileSync, watch as fsWatch, type FSWatcher } from "node:fs";
5
+ import { execFile } from "node:child_process";
6
+ import { resolve, dirname } from "node:path";
7
+ import { fileURLToPath } from "node:url";
8
+ import { createLogger, type WatcherConfig } from "./observability.js";
9
+
10
+ const __dirname = dirname(fileURLToPath(import.meta.url));
11
+ const pkg = JSON.parse(readFileSync(resolve(__dirname, "..", "package.json"), "utf-8"));
12
+ const VERSION = pkg.version;
5
13
 
6
14
  const PROJECT_SLUG = process.env.PROJECT_SLUG || "unknown/project";
7
15
  const ROUTER_URL = process.env.ROUTER_URL || "http://127.0.0.1:9000";
16
+ const CONFIG_PATH = process.env.HH_CONFIG_PATH || "";
17
+ const WEBHOOK_SECRET = process.env.WEBHOOK_SECRET || "";
8
18
 
9
19
  const log = createLogger(`channel:${PROJECT_SLUG}`, true); // stderr for MCP
10
20
 
11
21
  // --- MCP Server setup ---
12
22
  const mcp = new Server(
13
- { name: "webhook-channel", version: "0.2.0" },
23
+ { name: "webhook-channel", version: VERSION },
14
24
  {
15
25
  capabilities: {
16
26
  experimental: { "claude/channel": {} },
@@ -95,7 +105,11 @@ async function register(): Promise<boolean> {
95
105
  const resp = await fetch(`${ROUTER_URL}/register`, {
96
106
  method: "POST",
97
107
  headers: { "Content-Type": "application/json" },
98
- body: JSON.stringify({ project_slug: PROJECT_SLUG, port: assignedPort }),
108
+ body: JSON.stringify({
109
+ project_slug: PROJECT_SLUG,
110
+ port: assignedPort,
111
+ watchers: currentWatcherConfigs,
112
+ }),
99
113
  });
100
114
  if (resp.ok) {
101
115
  if (!registered) log.info("registered with router", { router: ROUTER_URL });
@@ -117,20 +131,152 @@ function startHeartbeat() {
117
131
  heartbeatTimer.unref();
118
132
  }
119
133
 
134
+ // --- Watcher system ---
135
+
136
+ let currentWatcherConfigs: WatcherConfig[] = [];
137
+ const watcherIntervals: Map<string, ReturnType<typeof setInterval>> = new Map();
138
+ let configWatcher: FSWatcher | null = null;
139
+
140
+ function readConfig(): { slug: string; router_url: string; watchers: WatcherConfig[] } | null {
141
+ if (!CONFIG_PATH) return null;
142
+ try {
143
+ return JSON.parse(readFileSync(CONFIG_PATH, "utf-8"));
144
+ } catch {
145
+ return null;
146
+ }
147
+ }
148
+
149
+ function executeCommand(cmd: string): Promise<string> {
150
+ return new Promise((resolve) => {
151
+ execFile("sh", ["-c", cmd], { encoding: "utf-8", timeout: 60000 }, (err, stdout) => {
152
+ resolve((stdout || "").trim());
153
+ });
154
+ });
155
+ }
156
+
157
+ async function runWatcher(watcher: WatcherConfig) {
158
+ const output = await executeCommand(watcher.command);
159
+ if (!output) return;
160
+
161
+ let parsed: any;
162
+ try {
163
+ parsed = JSON.parse(output);
164
+ } catch {
165
+ parsed = output;
166
+ }
167
+
168
+ const envelope = {
169
+ project_slug: PROJECT_SLUG,
170
+ source: watcher.command,
171
+ output: parsed,
172
+ };
173
+
174
+ const headers: Record<string, string> = { "Content-Type": "application/json" };
175
+ if (WEBHOOK_SECRET) headers["X-Webhook-Token"] = WEBHOOK_SECRET;
176
+
177
+ try {
178
+ await fetch(`${ROUTER_URL}/`, {
179
+ method: "POST",
180
+ headers,
181
+ body: JSON.stringify(envelope),
182
+ });
183
+ log.debug("watcher sent", { command: watcher.command });
184
+ } catch (err: any) {
185
+ log.warn("watcher POST failed", { command: watcher.command, error: err.message });
186
+ }
187
+ }
188
+
189
+ function watcherKey(w: WatcherConfig): string {
190
+ return `${w.command}::${w.interval}`;
191
+ }
192
+
193
+ function startWatchers(watchers: WatcherConfig[]) {
194
+ // Stop removed/changed watchers
195
+ const newKeys = new Set(watchers.map(watcherKey));
196
+ for (const [key, timer] of watcherIntervals) {
197
+ if (!newKeys.has(key)) {
198
+ clearInterval(timer);
199
+ watcherIntervals.delete(key);
200
+ log.info("watcher stopped", { key });
201
+ }
202
+ }
203
+
204
+ // Start new watchers
205
+ for (const w of watchers) {
206
+ const key = watcherKey(w);
207
+ if (watcherIntervals.has(key)) continue;
208
+
209
+ // Run immediately (if registered), then on interval
210
+ if (registered) runWatcher(w);
211
+ const timer = setInterval(() => runWatcher(w), w.interval * 1000);
212
+ timer.unref();
213
+ watcherIntervals.set(key, timer);
214
+ log.info("watcher started", { command: w.command, interval: w.interval });
215
+ }
216
+
217
+ currentWatcherConfigs = watchers;
218
+ }
219
+
220
+ async function loadAndStartWatchers() {
221
+ const config = readConfig();
222
+ const watchers = config?.watchers || [];
223
+ startWatchers(watchers);
224
+ // Re-register so the router sees the updated watcher list
225
+ if (registered) await register();
226
+ }
227
+
228
+ function startConfigWatcher() {
229
+ if (!CONFIG_PATH) return;
230
+
231
+ let debounceTimer: ReturnType<typeof setTimeout> | null = null;
232
+
233
+ function watch() {
234
+ if (configWatcher) { try { configWatcher.close(); } catch {} }
235
+ try {
236
+ configWatcher = fsWatch(CONFIG_PATH, () => {
237
+ if (debounceTimer) clearTimeout(debounceTimer);
238
+ debounceTimer = setTimeout(() => {
239
+ log.info("config changed, reloading watchers");
240
+ loadAndStartWatchers();
241
+ // Re-establish watcher (inode may have changed)
242
+ watch();
243
+ }, 200);
244
+ });
245
+ configWatcher.unref();
246
+ } catch {
247
+ log.debug("could not watch config file, retrying in 5s", { path: CONFIG_PATH });
248
+ setTimeout(watch, 5000);
249
+ }
250
+ }
251
+
252
+ watch();
253
+ }
254
+
120
255
  // Bind to port 0 for auto-assignment
121
256
  httpServer.listen(0, "127.0.0.1", async () => {
122
257
  const addr = httpServer.address();
123
258
  assignedPort = typeof addr === "object" && addr ? addr.port : 0;
124
259
  log.info("HTTP server listening", { host: "127.0.0.1", port: assignedPort });
125
260
 
261
+ loadAndStartWatchers();
262
+ startConfigWatcher();
126
263
  await register();
264
+ // Run watchers that were deferred during startup (before registration)
265
+ for (const w of currentWatcherConfigs) runWatcher(w);
127
266
  startHeartbeat();
128
267
  });
129
268
 
130
269
  // Graceful shutdown: unregister from router
270
+ let shutdownInProgress = false;
271
+
131
272
  async function shutdown() {
273
+ if (shutdownInProgress) return;
274
+ shutdownInProgress = true;
132
275
  log.info("shutting down");
133
276
  if (heartbeatTimer) clearInterval(heartbeatTimer);
277
+ for (const timer of watcherIntervals.values()) clearInterval(timer);
278
+ watcherIntervals.clear();
279
+ if (configWatcher) { configWatcher.close(); configWatcher = null; }
134
280
  try {
135
281
  await fetch(`${ROUTER_URL}/unregister`, {
136
282
  method: "POST",
@@ -2,6 +2,7 @@ import { createServer, type IncomingMessage, type ServerResponse } from "node:ht
2
2
  import { readFileSync } from "node:fs";
3
3
  import { resolve, dirname } from "node:path";
4
4
  import { fileURLToPath } from "node:url";
5
+ import { timingSafeEqual } from "node:crypto";
5
6
  import {
6
7
  createLogger,
7
8
  EventStore,
@@ -15,9 +16,23 @@ import {
15
16
 
16
17
  const __dirname = dirname(fileURLToPath(import.meta.url));
17
18
 
19
+ const pkg = JSON.parse(readFileSync(resolve(__dirname, "..", "package.json"), "utf-8"));
20
+ const VERSION = pkg.version;
21
+
18
22
  const PORT = parseInt(process.env.ROUTER_PORT || "9000", 10);
19
23
  const HOST = process.env.ROUTER_HOST || "127.0.0.1";
20
- const SECRET = process.env.WEBHOOK_SECRET || "dev-secret";
24
+ const SECRET = process.env.WEBHOOK_SECRET || "";
25
+
26
+ function safeEqual(a: string, b: string): boolean {
27
+ const bufA = Buffer.from(a);
28
+ const bufB = Buffer.from(b);
29
+ if (bufA.length !== bufB.length) {
30
+ // Compare against self to burn constant time, then return false
31
+ timingSafeEqual(bufA, bufA);
32
+ return false;
33
+ }
34
+ return timingSafeEqual(bufA, bufB);
35
+ }
21
36
 
22
37
  const log = createLogger("router");
23
38
  const events = new EventStore();
@@ -36,9 +51,16 @@ try {
36
51
 
37
52
  // --- Request body helper ---
38
53
 
54
+ const MAX_BODY = 10 * 1024 * 1024; // 10MB
55
+
39
56
  async function readBody(req: IncomingMessage): Promise<string> {
40
57
  const chunks: Buffer[] = [];
41
- for await (const chunk of req) chunks.push(chunk as Buffer);
58
+ let size = 0;
59
+ for await (const chunk of req) {
60
+ size += (chunk as Buffer).length;
61
+ if (size > MAX_BODY) throw new Error("body too large");
62
+ chunks.push(chunk as Buffer);
63
+ }
42
64
  return Buffer.concat(chunks).toString();
43
65
  }
44
66
 
@@ -51,20 +73,28 @@ function getRoutesSnapshot() {
51
73
  // --- Handlers ---
52
74
 
53
75
  async function handleRegister(req: IncomingMessage, res: ServerResponse) {
54
- const body = JSON.parse(await readBody(req));
55
- const { project_slug, port } = body;
76
+ let body: any;
77
+ try {
78
+ body = JSON.parse(await readBody(req));
79
+ } catch {
80
+ res.writeHead(400, { "Content-Type": "application/json" });
81
+ res.end(JSON.stringify({ error: "invalid JSON" }));
82
+ return;
83
+ }
84
+ const { project_slug, port, watchers } = body;
56
85
  if (!project_slug || !port) {
57
86
  res.writeHead(400, { "Content-Type": "application/json" });
58
87
  res.end(JSON.stringify({ error: "missing project_slug or port" }));
59
88
  return;
60
89
  }
61
90
 
62
- // Heartbeat: if same slug already registered, treat as keepalive
91
+ // Heartbeat: if same slug and same port, treat as keepalive
63
92
  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 });
93
+ if (existing && existing.port === port) {
94
+ existing.lastHeartbeatAt = Date.now();
95
+ // Update watchers on heartbeat (supports hot reload)
96
+ if (watchers && JSON.stringify(watchers) !== JSON.stringify(existing.watchers)) {
97
+ existing.watchers = watchers;
68
98
  broadcast("session", { sessions: getSessionsData() });
69
99
  }
70
100
  res.writeHead(200, { "Content-Type": "application/json" });
@@ -75,10 +105,12 @@ async function handleRegister(req: IncomingMessage, res: ServerResponse) {
75
105
  const info: RouteInfo = {
76
106
  port,
77
107
  registeredAt: new Date().toISOString(),
108
+ lastHeartbeatAt: Date.now(),
78
109
  lastEventAt: null,
79
110
  eventCount: 0,
80
111
  errorCount: 0,
81
112
  status: "unknown",
113
+ watchers: watchers || [],
82
114
  };
83
115
  routes.set(project_slug, info);
84
116
  metrics.registrations++;
@@ -103,7 +135,14 @@ async function handleRegister(req: IncomingMessage, res: ServerResponse) {
103
135
  }
104
136
 
105
137
  async function handleUnregister(req: IncomingMessage, res: ServerResponse) {
106
- const body = JSON.parse(await readBody(req));
138
+ let body: any;
139
+ try {
140
+ body = JSON.parse(await readBody(req));
141
+ } catch {
142
+ res.writeHead(400, { "Content-Type": "application/json" });
143
+ res.end(JSON.stringify({ error: "invalid JSON" }));
144
+ return;
145
+ }
107
146
  const { project_slug } = body;
108
147
  if (!project_slug) {
109
148
  res.writeHead(400, { "Content-Type": "application/json" });
@@ -141,32 +180,34 @@ async function handleWebhook(req: IncomingMessage, res: ServerResponse) {
141
180
  const trace = createTrace();
142
181
  const traceId = newEventId();
143
182
 
144
- // Auth
183
+ // Auth (opt-in: only check if SECRET is configured)
145
184
  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;
185
+ if (SECRET) {
186
+ const token = req.headers["x-webhook-token"] || req.headers["x-gitlab-token"];
187
+ if (!safeEqual(String(token ?? ""), SECRET)) {
188
+ trace.end(authSpan);
189
+ log.warn("rejected: invalid token", { traceId });
190
+ metrics.recordRequest(401);
191
+ metrics.recordWebhook("unauthorized");
192
+
193
+ const ev: RouterEvent = {
194
+ id: traceId,
195
+ timestamp: new Date().toISOString(),
196
+ type: "webhook",
197
+ slug: "unknown",
198
+ routingDecision: "unauthorized",
199
+ durationMs: trace.elapsed(),
200
+ responseStatus: 401,
201
+ traceSpans: trace.spans,
202
+ error: "invalid token",
203
+ };
204
+ events.push(ev);
205
+ broadcast("webhook", ev);
206
+
207
+ res.writeHead(401, { "Content-Type": "application/json" });
208
+ res.end(JSON.stringify({ error: "unauthorized" }));
209
+ return;
210
+ }
170
211
  }
171
212
  trace.end(authSpan);
172
213
 
@@ -294,6 +335,7 @@ async function handleWebhook(req: IncomingMessage, res: ServerResponse) {
294
335
  };
295
336
  events.push(ev);
296
337
  broadcast("webhook", ev);
338
+ broadcast("session", { sessions: getSessionsData() });
297
339
 
298
340
  res.writeHead(200, { "Content-Type": "application/json" });
299
341
  res.end(JSON.stringify({ ok: true, forwarded_to: routeInfo.port }));
@@ -323,6 +365,7 @@ async function handleWebhook(req: IncomingMessage, res: ServerResponse) {
323
365
  };
324
366
  events.push(ev);
325
367
  broadcast("webhook", ev);
368
+ broadcast("session", { sessions: getSessionsData() });
326
369
 
327
370
  res.writeHead(502, { "Content-Type": "application/json" });
328
371
  res.end(JSON.stringify({ error: "downstream unreachable" }));
@@ -400,18 +443,53 @@ const statsInterval = setInterval(() => {
400
443
  }, 5000);
401
444
  statsInterval.unref();
402
445
 
446
+ // Stale route cleanup: remove routes that missed 3 heartbeat intervals
447
+ const HEARTBEAT_MS = parseInt(process.env.HH_HEARTBEAT_MS || "30000", 10);
448
+ const STALE_THRESHOLD_MS = HEARTBEAT_MS * 3;
449
+
450
+ const staleInterval = setInterval(() => {
451
+ const now = Date.now();
452
+ let changed = false;
453
+ for (const [slug, info] of routes) {
454
+ if (now - info.lastHeartbeatAt > STALE_THRESHOLD_MS) {
455
+ routes.delete(slug);
456
+ metrics.unregistrations++;
457
+ 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
+ });
467
+ changed = true;
468
+ }
469
+ }
470
+ if (changed) broadcast("session", { sessions: getSessionsData() });
471
+ }, STALE_THRESHOLD_MS);
472
+ staleInterval.unref();
473
+
403
474
  function handleApiHealth(_req: IncomingMessage, res: ServerResponse) {
404
475
  res.writeHead(200, { "Content-Type": "application/json" });
405
476
  res.end(JSON.stringify({
406
477
  status: "ok",
407
- version: "0.2.0",
478
+ version: VERSION,
408
479
  uptimeSeconds: Math.floor((Date.now() - metrics.startTime) / 1000),
409
480
  routesActive: routes.size,
410
481
  }));
411
482
  }
412
483
 
413
484
  async function handleApiKill(req: IncomingMessage, res: ServerResponse) {
414
- const body = JSON.parse(await readBody(req));
485
+ let body: any;
486
+ try {
487
+ body = JSON.parse(await readBody(req));
488
+ } catch {
489
+ res.writeHead(400, { "Content-Type": "application/json" });
490
+ res.end(JSON.stringify({ error: "invalid JSON" }));
491
+ return;
492
+ }
415
493
  const { project_slug } = body;
416
494
  if (!project_slug) {
417
495
  res.writeHead(400, { "Content-Type": "application/json" });
@@ -468,6 +546,7 @@ function getSessionsData() {
468
546
  avgLatencyMs: rm?.avgLatencyMs ?? 0,
469
547
  successCount: rm?.success ?? 0,
470
548
  failedCount: rm?.failed ?? 0,
549
+ watchers: info.watchers,
471
550
  };
472
551
  });
473
552
  }
@@ -530,6 +609,11 @@ const server = createServer(async (req, res) => {
530
609
  res.writeHead(404, { "Content-Type": "application/json" });
531
610
  res.end(JSON.stringify({ error: "not found" }));
532
611
  } catch (err: any) {
612
+ if (err.message === "body too large") {
613
+ res.writeHead(413, { "Content-Type": "application/json" });
614
+ res.end(JSON.stringify({ error: "payload too large" }));
615
+ return;
616
+ }
533
617
  log.error("unhandled error", { error: err.message, path: url.pathname });
534
618
  metrics.recordRequest(500);
535
619
  res.writeHead(500, { "Content-Type": "application/json" });
@@ -541,7 +625,8 @@ server.listen(PORT, HOST, () => {
541
625
  const addr = server.address();
542
626
  const actualPort = typeof addr === "object" && addr ? addr.port : PORT;
543
627
  log.info("listening", { host: HOST, port: actualPort });
544
- log.info("secret", { preview: SECRET.slice(0, 4) + "..." });
628
+ if (SECRET) log.info("auth enabled", { preview: SECRET.slice(0, 4) + "..." });
629
+ else log.info("auth disabled (no WEBHOOK_SECRET set)");
545
630
  // Machine-readable line for process spawners to discover the port
546
631
  process.stderr.write(`HOOKHERALD_PORT=${actualPort}\n`);
547
632
  });