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 +126 -39
- package/bin/hh +13 -1
- package/package.json +1 -1
- package/src/cli.ts +20 -4
- package/src/dashboard.html +44 -7
- package/src/observability.ts +7 -0
- package/src/webhook-channel.ts +149 -3
- package/src/webhook-router.ts +123 -38
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 —
|
|
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
|
-
|
|
9
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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 (
|
|
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` | `/` |
|
|
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` |
|
|
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
|
-
|
|
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
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.
|
|
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
|
-
|
|
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 ||
|
|
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], {
|
package/src/dashboard.html
CHANGED
|
@@ -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">⏱</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
|
-
|
|
480
|
-
|
|
481
|
-
|
|
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
|
}
|
package/src/observability.ts
CHANGED
|
@@ -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 {
|
package/src/webhook-channel.ts
CHANGED
|
@@ -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 {
|
|
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:
|
|
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({
|
|
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",
|
package/src/webhook-router.ts
CHANGED
|
@@ -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 || "
|
|
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
|
-
|
|
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
|
-
|
|
55
|
-
|
|
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
|
|
91
|
+
// Heartbeat: if same slug and same port, treat as keepalive
|
|
63
92
|
const existing = routes.get(project_slug);
|
|
64
|
-
if (existing) {
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
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
|
-
|
|
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
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
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:
|
|
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
|
-
|
|
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("
|
|
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
|
});
|