hookherald 0.3.0 → 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/LICENSE +21 -0
- package/README.md +140 -88
- package/bin/hh +9 -1
- package/package.json +14 -2
- package/src/cli.ts +20 -4
- package/src/dashboard.html +44 -7
- package/src/observability.ts +6 -0
- package/src/webhook-channel.ts +145 -3
- package/src/webhook-router.ts +87 -38
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Shoof
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
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
|
|
|
@@ -19,138 +17,192 @@ Payloads are forwarded as raw JSON — send whatever you want, the agent figures
|
|
|
19
17
|
npm install -g hookherald
|
|
20
18
|
```
|
|
21
19
|
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
## Usage
|
|
20
|
+
Requires Node.js >= 18.
|
|
25
21
|
|
|
26
|
-
|
|
22
|
+
## Quick Start
|
|
27
23
|
|
|
28
24
|
```bash
|
|
29
|
-
|
|
30
|
-
hh router --bg
|
|
31
|
-
|
|
25
|
+
# 1. Start the router
|
|
26
|
+
hh router --bg
|
|
27
|
+
|
|
28
|
+
# 2. Set up a project
|
|
29
|
+
cd ~/my-project
|
|
30
|
+
hh init
|
|
31
|
+
|
|
32
|
+
# 3. Start Claude Code (from the same directory — it needs .mcp.json and .hookherald.json)
|
|
33
|
+
claude --dangerously-load-development-channels server:webhook-channel
|
|
34
|
+
|
|
35
|
+
# 4. Send a webhook
|
|
36
|
+
curl -X POST http://127.0.0.1:9000/ \
|
|
37
|
+
-H "Content-Type: application/json" \
|
|
38
|
+
-d '{"project_slug":"my-group/my-project","status":"deployed","version":"1.2.3"}'
|
|
32
39
|
```
|
|
33
40
|
|
|
34
|
-
|
|
41
|
+
No auth by default — localhost is trusted. See [Auth](#auth) to enable it.
|
|
35
42
|
|
|
36
|
-
|
|
43
|
+
## Watchers
|
|
37
44
|
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
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
|
+
}
|
|
41
56
|
```
|
|
42
57
|
|
|
43
|
-
|
|
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.
|
|
44
64
|
|
|
45
|
-
###
|
|
65
|
+
### Example: Watch Kubernetes Pods
|
|
46
66
|
|
|
47
67
|
```bash
|
|
48
|
-
|
|
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
|
+
}'
|
|
49
90
|
```
|
|
50
91
|
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
### 4. Send webhooks
|
|
92
|
+
### Example: Watch GitLab Pipeline
|
|
54
93
|
|
|
55
94
|
```bash
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
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
|
|
60
118
|
```
|
|
61
119
|
|
|
62
|
-
|
|
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.
|
|
63
123
|
|
|
64
|
-
|
|
124
|
+
See [examples/README.md](examples/README.md) for detailed walkthroughs, more scripts, writing your own watchers, and troubleshooting.
|
|
65
125
|
|
|
66
|
-
## CLI
|
|
126
|
+
## CLI
|
|
67
127
|
|
|
68
128
|
```
|
|
69
|
-
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
|
|
70
130
|
hh status [--router-url <url>] Show active sessions
|
|
71
|
-
hh kill <slug> [--router-url <url>] Bounce a session
|
|
131
|
+
hh kill <slug> [--router-url <url>] Bounce a session
|
|
72
132
|
hh router [--port <port>] [--secret <secret>] Start the webhook router
|
|
73
133
|
[--bg] Run in background
|
|
74
134
|
hh router stop Stop background router
|
|
75
135
|
```
|
|
76
136
|
|
|
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.
|
|
154
|
+
|
|
155
|
+
For external sources (GitLab CI, GitHub Actions), start the router with `--secret` and configure the same secret in the webhook settings.
|
|
156
|
+
|
|
77
157
|
## Docker
|
|
78
158
|
|
|
79
|
-
The router can also run via Docker
|
|
159
|
+
The router can also run via Docker (requires `--network host` on Linux):
|
|
80
160
|
|
|
81
161
|
```bash
|
|
162
|
+
docker run -d --network host shoofio/hookherald
|
|
163
|
+
|
|
164
|
+
# With auth
|
|
82
165
|
docker run -d --network host -e WEBHOOK_SECRET=my-secret shoofio/hookherald
|
|
83
166
|
```
|
|
84
167
|
|
|
85
|
-
>
|
|
168
|
+
> For rootless Docker or Docker Desktop (Mac/Windows), use `hh router` instead.
|
|
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
|
|
86
178
|
|
|
87
179
|
## Router API
|
|
88
180
|
|
|
89
181
|
| Method | Path | Description |
|
|
90
182
|
|--------|------|-------------|
|
|
91
|
-
| `POST` | `/` | Receive webhook (
|
|
92
|
-
| `POST` | `/register` | Channel self-registration |
|
|
93
|
-
| `POST` | `/unregister` | Channel self-unregistration |
|
|
183
|
+
| `POST` | `/` | Receive webhook (auth required only if `WEBHOOK_SECRET` is set) |
|
|
94
184
|
| `POST` | `/api/kill` | Remove a session (signals channel to shut down) |
|
|
95
|
-
| `GET` | `/` |
|
|
96
|
-
| `GET` | `/api/health` |
|
|
97
|
-
| `GET` | `/api/sessions` |
|
|
185
|
+
| `GET` | `/` | Dashboard |
|
|
186
|
+
| `GET` | `/api/health` | Health check |
|
|
187
|
+
| `GET` | `/api/sessions` | Active sessions with metrics and watchers |
|
|
98
188
|
| `GET` | `/api/events` | Query events (`?slug=`, `?limit=`, `?offset=`) |
|
|
99
189
|
| `GET` | `/api/events/:id` | Single event by ID |
|
|
100
|
-
| `GET` | `/api/stats` | Aggregated statistics |
|
|
101
190
|
| `GET` | `/api/stream` | SSE live updates |
|
|
102
|
-
| `GET` | `/
|
|
103
|
-
| `GET` | `/metrics` | Prometheus format metrics |
|
|
104
|
-
|
|
105
|
-
## Dashboard
|
|
106
|
-
|
|
107
|
-
The router serves a live session management UI at `http://127.0.0.1:9000/`:
|
|
108
|
-
|
|
109
|
-
- **Sessions table** — status, port, event count, errors, latency, kill button
|
|
110
|
-
- **Event feed** — click sessions to filter, ctrl/shift+click for multi-select
|
|
111
|
-
- **Event detail** — expand inline for summary, trace waterfall, raw payload
|
|
112
|
-
- **Live updates** — SSE-powered, no refresh needed
|
|
113
|
-
|
|
114
|
-
## GitLab CI Integration
|
|
115
|
-
|
|
116
|
-
Add a webhook in your GitLab project/group settings:
|
|
117
|
-
- **URL**: `http://<your-machine>:9000/`
|
|
118
|
-
- **Secret token**: your `WEBHOOK_SECRET`
|
|
119
|
-
- **Trigger**: Pipeline events (or any events you want)
|
|
120
|
-
|
|
121
|
-
Or add a `curl` step in `.gitlab-ci.yml` for custom payloads:
|
|
122
|
-
|
|
123
|
-
```yaml
|
|
124
|
-
notify:
|
|
125
|
-
stage: .post
|
|
126
|
-
script:
|
|
127
|
-
- |
|
|
128
|
-
curl -s -X POST http://$ROUTER_HOST:9000/ \
|
|
129
|
-
-H "Content-Type: application/json" \
|
|
130
|
-
-H "X-Webhook-Token: $WEBHOOK_SECRET" \
|
|
131
|
-
-d "{\"project_slug\":\"$CI_PROJECT_PATH\",\"status\":\"$CI_PIPELINE_STATUS\",\"branch\":\"$CI_COMMIT_BRANCH\",\"sha\":\"$CI_COMMIT_SHA\",\"pipeline\":\"$CI_PIPELINE_URL\"}"
|
|
132
|
-
```
|
|
191
|
+
| `GET` | `/metrics` | Prometheus format |
|
|
133
192
|
|
|
134
193
|
## Environment Variables
|
|
135
194
|
|
|
136
195
|
| Variable | Default | Description |
|
|
137
196
|
|----------|---------|-------------|
|
|
138
197
|
| `ROUTER_PORT` | `9000` | Router listen port |
|
|
139
|
-
| `ROUTER_HOST` | `127.0.0.1` |
|
|
140
|
-
| `WEBHOOK_SECRET` |
|
|
141
|
-
| `PROJECT_SLUG` | `unknown/project` | Channel's project
|
|
142
|
-
| `ROUTER_URL` | `http://127.0.0.1:9000` |
|
|
143
|
-
| `LOG_LEVEL` | `info` |
|
|
198
|
+
| `ROUTER_HOST` | `127.0.0.1` | Bind address (`0.0.0.0` for Docker) |
|
|
199
|
+
| `WEBHOOK_SECRET` | *(none)* | Auth secret (opt-in) |
|
|
200
|
+
| `PROJECT_SLUG` | `unknown/project` | Channel's project ID |
|
|
201
|
+
| `ROUTER_URL` | `http://127.0.0.1:9000` | Router address |
|
|
202
|
+
| `LOG_LEVEL` | `info` | debug/info/warn/error |
|
|
144
203
|
| `HH_HEARTBEAT_MS` | `30000` | Channel heartbeat interval |
|
|
204
|
+
| `HH_CONFIG_PATH` | *(none)* | Path to `.hookherald.json` (set by `hh init`) |
|
|
145
205
|
|
|
146
|
-
##
|
|
147
|
-
|
|
148
|
-
A compiled Go binary is also available for faster startup (~5ms vs ~700ms). See `cmd/hh/` and [releases](https://github.com/Shoofio/HookHerald/releases).
|
|
149
|
-
|
|
150
|
-
## Testing
|
|
151
|
-
|
|
152
|
-
```bash
|
|
153
|
-
npm test # 79 tests across 4 suites
|
|
154
|
-
```
|
|
206
|
+
## License
|
|
155
207
|
|
|
156
|
-
|
|
208
|
+
MIT
|
package/bin/hh
CHANGED
|
@@ -1,2 +1,10 @@
|
|
|
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
|
+
exec npx tsx "$SCRIPT_DIR/../src/cli.ts" "$@"
|
package/package.json
CHANGED
|
@@ -1,8 +1,21 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "hookherald",
|
|
3
|
-
"version": "0.
|
|
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
|
+
"license": "MIT",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "https://github.com/Shoofio/HookHerald.git"
|
|
10
|
+
},
|
|
11
|
+
"keywords": [
|
|
12
|
+
"webhook",
|
|
13
|
+
"claude-code",
|
|
14
|
+
"mcp",
|
|
15
|
+
"notifications",
|
|
16
|
+
"ci",
|
|
17
|
+
"cli"
|
|
18
|
+
],
|
|
6
19
|
"bin": {
|
|
7
20
|
"hh": "bin/hh"
|
|
8
21
|
},
|
|
@@ -16,7 +29,6 @@
|
|
|
16
29
|
"scripts": {
|
|
17
30
|
"router": "npx tsx src/webhook-router.ts",
|
|
18
31
|
"channel": "npx tsx src/webhook-channel.ts",
|
|
19
|
-
"build:go": "go build -ldflags \"-X main.projectRoot=$(pwd)\" -o hh ./cmd/hh/",
|
|
20
32
|
"test": "npx tsx --test-force-exit --test tests/observability.test.ts && npx tsx --test-force-exit --test tests/channel.test.ts && npx tsx --test-force-exit --test tests/router.test.ts && npx tsx --test-force-exit --test tests/cli.test.ts"
|
|
21
33
|
},
|
|
22
34
|
"dependencies": {
|
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,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 {
|
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 { 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:
|
|
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,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",
|
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,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 || "
|
|
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
|
-
|
|
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
|
-
|
|
55
|
-
|
|
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
|
|
85
|
+
// Heartbeat: if same slug and same port, treat as keepalive
|
|
63
86
|
const existing = routes.get(project_slug);
|
|
64
|
-
if (existing) {
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
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
|
-
|
|
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
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
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:
|
|
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
|
-
|
|
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("
|
|
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
|
});
|