hookherald 0.3.2 → 0.4.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,10 @@
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
+ exec npx tsx "$SCRIPT_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.4.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,6 +30,11 @@ 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;
@@ -37,6 +42,7 @@ export interface RouteInfo {
37
42
  eventCount: number;
38
43
  errorCount: number;
39
44
  status: "up" | "down" | "unknown";
45
+ watchers: WatcherConfig[];
40
46
  }
41
47
 
42
48
  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 { execSync } 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,148 @@ 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): 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
+ }
155
+ }
156
+
157
+ async function runWatcher(watcher: WatcherConfig) {
158
+ const output = 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, then on interval
210
+ 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
+ function loadAndStartWatchers() {
221
+ const config = readConfig();
222
+ const watchers = config?.watchers || [];
223
+ startWatchers(watchers);
224
+ }
225
+
226
+ function startConfigWatcher() {
227
+ if (!CONFIG_PATH) return;
228
+
229
+ let debounceTimer: ReturnType<typeof setTimeout> | null = null;
230
+
231
+ function watch() {
232
+ if (configWatcher) { try { configWatcher.close(); } catch {} }
233
+ try {
234
+ configWatcher = fsWatch(CONFIG_PATH, () => {
235
+ if (debounceTimer) clearTimeout(debounceTimer);
236
+ debounceTimer = setTimeout(() => {
237
+ log.info("config changed, reloading watchers");
238
+ loadAndStartWatchers();
239
+ // Re-establish watcher (inode may have changed)
240
+ watch();
241
+ }, 200);
242
+ });
243
+ configWatcher.unref();
244
+ } catch {
245
+ log.debug("could not watch config file, retrying in 5s", { path: CONFIG_PATH });
246
+ setTimeout(watch, 5000);
247
+ }
248
+ }
249
+
250
+ watch();
251
+ }
252
+
120
253
  // Bind to port 0 for auto-assignment
121
254
  httpServer.listen(0, "127.0.0.1", async () => {
122
255
  const addr = httpServer.address();
123
256
  assignedPort = typeof addr === "object" && addr ? addr.port : 0;
124
257
  log.info("HTTP server listening", { host: "127.0.0.1", port: assignedPort });
125
258
 
259
+ loadAndStartWatchers();
260
+ startConfigWatcher();
126
261
  await register();
127
262
  startHeartbeat();
128
263
  });
129
264
 
130
265
  // Graceful shutdown: unregister from router
266
+ let shutdownInProgress = false;
267
+
131
268
  async function shutdown() {
269
+ if (shutdownInProgress) return;
270
+ shutdownInProgress = true;
132
271
  log.info("shutting down");
133
272
  if (heartbeatTimer) clearInterval(heartbeatTimer);
273
+ for (const timer of watcherIntervals.values()) clearInterval(timer);
274
+ watcherIntervals.clear();
275
+ if (configWatcher) { configWatcher.close(); configWatcher = null; }
134
276
  try {
135
277
  await fetch(`${ROUTER_URL}/unregister`, {
136
278
  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,17 @@ 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
+ if (a.length !== b.length) return false;
28
+ return timingSafeEqual(Buffer.from(a), Buffer.from(b));
29
+ }
21
30
 
22
31
  const log = createLogger("router");
23
32
  const events = new EventStore();
@@ -36,9 +45,16 @@ try {
36
45
 
37
46
  // --- Request body helper ---
38
47
 
48
+ const MAX_BODY = 10 * 1024 * 1024; // 10MB
49
+
39
50
  async function readBody(req: IncomingMessage): Promise<string> {
40
51
  const chunks: Buffer[] = [];
41
- for await (const chunk of req) chunks.push(chunk as Buffer);
52
+ let size = 0;
53
+ for await (const chunk of req) {
54
+ size += (chunk as Buffer).length;
55
+ if (size > MAX_BODY) throw new Error("body too large");
56
+ chunks.push(chunk as Buffer);
57
+ }
42
58
  return Buffer.concat(chunks).toString();
43
59
  }
44
60
 
@@ -51,20 +67,27 @@ function getRoutesSnapshot() {
51
67
  // --- Handlers ---
52
68
 
53
69
  async function handleRegister(req: IncomingMessage, res: ServerResponse) {
54
- const body = JSON.parse(await readBody(req));
55
- const { project_slug, port } = body;
70
+ let body: any;
71
+ try {
72
+ body = JSON.parse(await readBody(req));
73
+ } catch {
74
+ res.writeHead(400, { "Content-Type": "application/json" });
75
+ res.end(JSON.stringify({ error: "invalid JSON" }));
76
+ return;
77
+ }
78
+ const { project_slug, port, watchers } = body;
56
79
  if (!project_slug || !port) {
57
80
  res.writeHead(400, { "Content-Type": "application/json" });
58
81
  res.end(JSON.stringify({ error: "missing project_slug or port" }));
59
82
  return;
60
83
  }
61
84
 
62
- // Heartbeat: if same slug already registered, treat as keepalive
85
+ // Heartbeat: if same slug and same port, treat as keepalive
63
86
  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 });
87
+ if (existing && existing.port === port) {
88
+ // Update watchers on heartbeat (supports hot reload)
89
+ if (watchers && JSON.stringify(watchers) !== JSON.stringify(existing.watchers)) {
90
+ existing.watchers = watchers;
68
91
  broadcast("session", { sessions: getSessionsData() });
69
92
  }
70
93
  res.writeHead(200, { "Content-Type": "application/json" });
@@ -79,6 +102,7 @@ async function handleRegister(req: IncomingMessage, res: ServerResponse) {
79
102
  eventCount: 0,
80
103
  errorCount: 0,
81
104
  status: "unknown",
105
+ watchers: watchers || [],
82
106
  };
83
107
  routes.set(project_slug, info);
84
108
  metrics.registrations++;
@@ -103,7 +127,14 @@ async function handleRegister(req: IncomingMessage, res: ServerResponse) {
103
127
  }
104
128
 
105
129
  async function handleUnregister(req: IncomingMessage, res: ServerResponse) {
106
- const body = JSON.parse(await readBody(req));
130
+ let body: any;
131
+ try {
132
+ body = JSON.parse(await readBody(req));
133
+ } catch {
134
+ res.writeHead(400, { "Content-Type": "application/json" });
135
+ res.end(JSON.stringify({ error: "invalid JSON" }));
136
+ return;
137
+ }
107
138
  const { project_slug } = body;
108
139
  if (!project_slug) {
109
140
  res.writeHead(400, { "Content-Type": "application/json" });
@@ -141,32 +172,34 @@ async function handleWebhook(req: IncomingMessage, res: ServerResponse) {
141
172
  const trace = createTrace();
142
173
  const traceId = newEventId();
143
174
 
144
- // Auth
175
+ // Auth (opt-in: only check if SECRET is configured)
145
176
  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;
177
+ if (SECRET) {
178
+ const token = req.headers["x-webhook-token"] || req.headers["x-gitlab-token"];
179
+ if (!safeEqual(String(token ?? ""), SECRET)) {
180
+ trace.end(authSpan);
181
+ log.warn("rejected: invalid token", { traceId });
182
+ metrics.recordRequest(401);
183
+ metrics.recordWebhook("unauthorized");
184
+
185
+ const ev: RouterEvent = {
186
+ id: traceId,
187
+ timestamp: new Date().toISOString(),
188
+ type: "webhook",
189
+ slug: "unknown",
190
+ routingDecision: "unauthorized",
191
+ durationMs: trace.elapsed(),
192
+ responseStatus: 401,
193
+ traceSpans: trace.spans,
194
+ error: "invalid token",
195
+ };
196
+ events.push(ev);
197
+ broadcast("webhook", ev);
198
+
199
+ res.writeHead(401, { "Content-Type": "application/json" });
200
+ res.end(JSON.stringify({ error: "unauthorized" }));
201
+ return;
202
+ }
170
203
  }
171
204
  trace.end(authSpan);
172
205
 
@@ -294,6 +327,7 @@ async function handleWebhook(req: IncomingMessage, res: ServerResponse) {
294
327
  };
295
328
  events.push(ev);
296
329
  broadcast("webhook", ev);
330
+ broadcast("session", { sessions: getSessionsData() });
297
331
 
298
332
  res.writeHead(200, { "Content-Type": "application/json" });
299
333
  res.end(JSON.stringify({ ok: true, forwarded_to: routeInfo.port }));
@@ -323,6 +357,7 @@ async function handleWebhook(req: IncomingMessage, res: ServerResponse) {
323
357
  };
324
358
  events.push(ev);
325
359
  broadcast("webhook", ev);
360
+ broadcast("session", { sessions: getSessionsData() });
326
361
 
327
362
  res.writeHead(502, { "Content-Type": "application/json" });
328
363
  res.end(JSON.stringify({ error: "downstream unreachable" }));
@@ -404,14 +439,21 @@ function handleApiHealth(_req: IncomingMessage, res: ServerResponse) {
404
439
  res.writeHead(200, { "Content-Type": "application/json" });
405
440
  res.end(JSON.stringify({
406
441
  status: "ok",
407
- version: "0.2.0",
442
+ version: VERSION,
408
443
  uptimeSeconds: Math.floor((Date.now() - metrics.startTime) / 1000),
409
444
  routesActive: routes.size,
410
445
  }));
411
446
  }
412
447
 
413
448
  async function handleApiKill(req: IncomingMessage, res: ServerResponse) {
414
- const body = JSON.parse(await readBody(req));
449
+ let body: any;
450
+ try {
451
+ body = JSON.parse(await readBody(req));
452
+ } catch {
453
+ res.writeHead(400, { "Content-Type": "application/json" });
454
+ res.end(JSON.stringify({ error: "invalid JSON" }));
455
+ return;
456
+ }
415
457
  const { project_slug } = body;
416
458
  if (!project_slug) {
417
459
  res.writeHead(400, { "Content-Type": "application/json" });
@@ -468,6 +510,7 @@ function getSessionsData() {
468
510
  avgLatencyMs: rm?.avgLatencyMs ?? 0,
469
511
  successCount: rm?.success ?? 0,
470
512
  failedCount: rm?.failed ?? 0,
513
+ watchers: info.watchers,
471
514
  };
472
515
  });
473
516
  }
@@ -530,6 +573,11 @@ const server = createServer(async (req, res) => {
530
573
  res.writeHead(404, { "Content-Type": "application/json" });
531
574
  res.end(JSON.stringify({ error: "not found" }));
532
575
  } catch (err: any) {
576
+ if (err.message === "body too large") {
577
+ res.writeHead(413, { "Content-Type": "application/json" });
578
+ res.end(JSON.stringify({ error: "payload too large" }));
579
+ return;
580
+ }
533
581
  log.error("unhandled error", { error: err.message, path: url.pathname });
534
582
  metrics.recordRequest(500);
535
583
  res.writeHead(500, { "Content-Type": "application/json" });
@@ -541,7 +589,8 @@ server.listen(PORT, HOST, () => {
541
589
  const addr = server.address();
542
590
  const actualPort = typeof addr === "object" && addr ? addr.port : PORT;
543
591
  log.info("listening", { host: HOST, port: actualPort });
544
- log.info("secret", { preview: SECRET.slice(0, 4) + "..." });
592
+ if (SECRET) log.info("auth enabled", { preview: SECRET.slice(0, 4) + "..." });
593
+ else log.info("auth disabled (no WEBHOOK_SECRET set)");
545
594
  // Machine-readable line for process spawners to discover the port
546
595
  process.stderr.write(`HOOKHERALD_PORT=${actualPort}\n`);
547
596
  });