hookherald 0.4.0 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/hh +5 -1
- package/package.json +1 -1
- package/src/observability.ts +20 -0
- package/src/webhook-channel.ts +33 -14
- package/src/webhook-router.ts +81 -60
package/bin/hh
CHANGED
|
@@ -7,4 +7,8 @@ while [ -L "$SELF" ]; do
|
|
|
7
7
|
case "$SELF" in /*) ;; *) SELF="$DIR/$SELF" ;; esac
|
|
8
8
|
done
|
|
9
9
|
SCRIPT_DIR="$(cd "$(dirname "$SELF")" && pwd)"
|
|
10
|
-
|
|
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/observability.ts
CHANGED
|
@@ -38,6 +38,7 @@ export interface WatcherConfig {
|
|
|
38
38
|
export interface RouteInfo {
|
|
39
39
|
port: number;
|
|
40
40
|
registeredAt: string;
|
|
41
|
+
lastHeartbeatAt: number;
|
|
41
42
|
lastEventAt: string | null;
|
|
42
43
|
eventCount: number;
|
|
43
44
|
errorCount: number;
|
|
@@ -283,3 +284,22 @@ export function truncatePayload(payload: any): any {
|
|
|
283
284
|
export function newEventId(): string {
|
|
284
285
|
return randomUUID();
|
|
285
286
|
}
|
|
287
|
+
|
|
288
|
+
// --- Event factory ---
|
|
289
|
+
|
|
290
|
+
export function createRouterEvent(
|
|
291
|
+
type: RouterEvent["type"],
|
|
292
|
+
slug: string,
|
|
293
|
+
overrides: Partial<RouterEvent> = {},
|
|
294
|
+
): RouterEvent {
|
|
295
|
+
return {
|
|
296
|
+
id: newEventId(),
|
|
297
|
+
timestamp: new Date().toISOString(),
|
|
298
|
+
type,
|
|
299
|
+
slug,
|
|
300
|
+
routingDecision: null,
|
|
301
|
+
durationMs: 0,
|
|
302
|
+
responseStatus: 200,
|
|
303
|
+
...overrides,
|
|
304
|
+
};
|
|
305
|
+
}
|
package/src/webhook-channel.ts
CHANGED
|
@@ -2,7 +2,7 @@ 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
4
|
import { readFileSync, watch as fsWatch, type FSWatcher } from "node:fs";
|
|
5
|
-
import {
|
|
5
|
+
import { execFile } from "node:child_process";
|
|
6
6
|
import { resolve, dirname } from "node:path";
|
|
7
7
|
import { fileURLToPath } from "node:url";
|
|
8
8
|
import { createLogger, type WatcherConfig } from "./observability.js";
|
|
@@ -56,8 +56,17 @@ const httpServer = createServer(async (req, res) => {
|
|
|
56
56
|
|
|
57
57
|
const traceId = req.headers["x-trace-id"] as string | undefined;
|
|
58
58
|
|
|
59
|
+
const MAX_BODY = 1024 * 1024; // 1MB
|
|
59
60
|
const chunks: Buffer[] = [];
|
|
60
|
-
|
|
61
|
+
let size = 0;
|
|
62
|
+
for await (const chunk of req) {
|
|
63
|
+
size += (chunk as Buffer).length;
|
|
64
|
+
if (size > MAX_BODY) {
|
|
65
|
+
res.writeHead(413).end("Payload too large");
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
chunks.push(chunk as Buffer);
|
|
69
|
+
}
|
|
61
70
|
const rawBody = Buffer.concat(chunks).toString();
|
|
62
71
|
|
|
63
72
|
let payload: any;
|
|
@@ -146,16 +155,16 @@ function readConfig(): { slug: string; router_url: string; watchers: WatcherConf
|
|
|
146
155
|
}
|
|
147
156
|
}
|
|
148
157
|
|
|
149
|
-
function executeCommand(cmd: string): string {
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
}
|
|
158
|
+
function executeCommand(cmd: string): Promise<string> {
|
|
159
|
+
return new Promise((resolve) => {
|
|
160
|
+
execFile("sh", ["-c", cmd], { encoding: "utf-8", timeout: 60000 }, (err, stdout) => {
|
|
161
|
+
resolve((stdout || "").trim());
|
|
162
|
+
});
|
|
163
|
+
});
|
|
155
164
|
}
|
|
156
165
|
|
|
157
166
|
async function runWatcher(watcher: WatcherConfig) {
|
|
158
|
-
const output = executeCommand(watcher.command);
|
|
167
|
+
const output = await executeCommand(watcher.command);
|
|
159
168
|
if (!output) return;
|
|
160
169
|
|
|
161
170
|
let parsed: any;
|
|
@@ -206,8 +215,8 @@ function startWatchers(watchers: WatcherConfig[]) {
|
|
|
206
215
|
const key = watcherKey(w);
|
|
207
216
|
if (watcherIntervals.has(key)) continue;
|
|
208
217
|
|
|
209
|
-
// Run immediately, then on interval
|
|
210
|
-
runWatcher(w);
|
|
218
|
+
// Run immediately (if registered), then on interval
|
|
219
|
+
if (registered) runWatcher(w);
|
|
211
220
|
const timer = setInterval(() => runWatcher(w), w.interval * 1000);
|
|
212
221
|
timer.unref();
|
|
213
222
|
watcherIntervals.set(key, timer);
|
|
@@ -217,10 +226,12 @@ function startWatchers(watchers: WatcherConfig[]) {
|
|
|
217
226
|
currentWatcherConfigs = watchers;
|
|
218
227
|
}
|
|
219
228
|
|
|
220
|
-
function loadAndStartWatchers() {
|
|
229
|
+
async function loadAndStartWatchers() {
|
|
221
230
|
const config = readConfig();
|
|
222
231
|
const watchers = config?.watchers || [];
|
|
223
232
|
startWatchers(watchers);
|
|
233
|
+
// Re-register so the router sees the updated watcher list
|
|
234
|
+
if (registered) await register();
|
|
224
235
|
}
|
|
225
236
|
|
|
226
237
|
function startConfigWatcher() {
|
|
@@ -256,9 +267,17 @@ httpServer.listen(0, "127.0.0.1", async () => {
|
|
|
256
267
|
assignedPort = typeof addr === "object" && addr ? addr.port : 0;
|
|
257
268
|
log.info("HTTP server listening", { host: "127.0.0.1", port: assignedPort });
|
|
258
269
|
|
|
259
|
-
|
|
260
|
-
|
|
270
|
+
// Load watcher configs (but don't start them yet)
|
|
271
|
+
const config = readConfig();
|
|
272
|
+
currentWatcherConfigs = config?.watchers || [];
|
|
273
|
+
|
|
274
|
+
// Register first so watchers can POST immediately
|
|
261
275
|
await register();
|
|
276
|
+
|
|
277
|
+
// Now start watchers — they'll run immediately since we're registered
|
|
278
|
+
startWatchers(currentWatcherConfigs);
|
|
279
|
+
|
|
280
|
+
startConfigWatcher();
|
|
262
281
|
startHeartbeat();
|
|
263
282
|
});
|
|
264
283
|
|
package/src/webhook-router.ts
CHANGED
|
@@ -10,6 +10,7 @@ import {
|
|
|
10
10
|
createTrace,
|
|
11
11
|
truncatePayload,
|
|
12
12
|
newEventId,
|
|
13
|
+
createRouterEvent,
|
|
13
14
|
type RouteInfo,
|
|
14
15
|
type RouterEvent,
|
|
15
16
|
} from "./observability.js";
|
|
@@ -24,8 +25,14 @@ const HOST = process.env.ROUTER_HOST || "127.0.0.1";
|
|
|
24
25
|
const SECRET = process.env.WEBHOOK_SECRET || "";
|
|
25
26
|
|
|
26
27
|
function safeEqual(a: string, b: string): boolean {
|
|
27
|
-
|
|
28
|
-
|
|
28
|
+
const bufA = Buffer.from(a);
|
|
29
|
+
const bufB = Buffer.from(b);
|
|
30
|
+
if (bufA.length !== bufB.length) {
|
|
31
|
+
// Compare against self to burn constant time, then return false
|
|
32
|
+
timingSafeEqual(bufA, bufA);
|
|
33
|
+
return false;
|
|
34
|
+
}
|
|
35
|
+
return timingSafeEqual(bufA, bufB);
|
|
29
36
|
}
|
|
30
37
|
|
|
31
38
|
const log = createLogger("router");
|
|
@@ -58,6 +65,15 @@ async function readBody(req: IncomingMessage): Promise<string> {
|
|
|
58
65
|
return Buffer.concat(chunks).toString();
|
|
59
66
|
}
|
|
60
67
|
|
|
68
|
+
// --- Slug validation ---
|
|
69
|
+
|
|
70
|
+
const SLUG_PATTERN = /^[\w\-\.\/]+$/;
|
|
71
|
+
const MAX_SLUG_LENGTH = 200;
|
|
72
|
+
|
|
73
|
+
function isValidSlug(slug: string): boolean {
|
|
74
|
+
return slug.length <= MAX_SLUG_LENGTH && SLUG_PATTERN.test(slug);
|
|
75
|
+
}
|
|
76
|
+
|
|
61
77
|
// --- Route helpers ---
|
|
62
78
|
|
|
63
79
|
function getRoutesSnapshot() {
|
|
@@ -81,10 +97,16 @@ async function handleRegister(req: IncomingMessage, res: ServerResponse) {
|
|
|
81
97
|
res.end(JSON.stringify({ error: "missing project_slug or port" }));
|
|
82
98
|
return;
|
|
83
99
|
}
|
|
100
|
+
if (!isValidSlug(project_slug)) {
|
|
101
|
+
res.writeHead(400, { "Content-Type": "application/json" });
|
|
102
|
+
res.end(JSON.stringify({ error: "invalid project_slug format" }));
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
84
105
|
|
|
85
106
|
// Heartbeat: if same slug and same port, treat as keepalive
|
|
86
107
|
const existing = routes.get(project_slug);
|
|
87
108
|
if (existing && existing.port === port) {
|
|
109
|
+
existing.lastHeartbeatAt = Date.now();
|
|
88
110
|
// Update watchers on heartbeat (supports hot reload)
|
|
89
111
|
if (watchers && JSON.stringify(watchers) !== JSON.stringify(existing.watchers)) {
|
|
90
112
|
existing.watchers = watchers;
|
|
@@ -98,6 +120,7 @@ async function handleRegister(req: IncomingMessage, res: ServerResponse) {
|
|
|
98
120
|
const info: RouteInfo = {
|
|
99
121
|
port,
|
|
100
122
|
registeredAt: new Date().toISOString(),
|
|
123
|
+
lastHeartbeatAt: Date.now(),
|
|
101
124
|
lastEventAt: null,
|
|
102
125
|
eventCount: 0,
|
|
103
126
|
errorCount: 0,
|
|
@@ -108,16 +131,7 @@ async function handleRegister(req: IncomingMessage, res: ServerResponse) {
|
|
|
108
131
|
metrics.registrations++;
|
|
109
132
|
log.info("registered", { slug: project_slug, port });
|
|
110
133
|
|
|
111
|
-
const event
|
|
112
|
-
id: newEventId(),
|
|
113
|
-
timestamp: new Date().toISOString(),
|
|
114
|
-
type: "register",
|
|
115
|
-
slug: project_slug,
|
|
116
|
-
routingDecision: null,
|
|
117
|
-
downstreamPort: port,
|
|
118
|
-
durationMs: 0,
|
|
119
|
-
responseStatus: 200,
|
|
120
|
-
};
|
|
134
|
+
const event = createRouterEvent("register", project_slug, { downstreamPort: port });
|
|
121
135
|
events.push(event);
|
|
122
136
|
broadcast("session", { sessions: getSessionsData() });
|
|
123
137
|
broadcast("webhook", event);
|
|
@@ -146,15 +160,7 @@ async function handleUnregister(req: IncomingMessage, res: ServerResponse) {
|
|
|
146
160
|
metrics.unregistrations++;
|
|
147
161
|
log.info("unregistered", { slug: project_slug });
|
|
148
162
|
|
|
149
|
-
const event
|
|
150
|
-
id: newEventId(),
|
|
151
|
-
timestamp: new Date().toISOString(),
|
|
152
|
-
type: "unregister",
|
|
153
|
-
slug: project_slug,
|
|
154
|
-
routingDecision: null,
|
|
155
|
-
durationMs: 0,
|
|
156
|
-
responseStatus: 200,
|
|
157
|
-
};
|
|
163
|
+
const event = createRouterEvent("unregister", project_slug);
|
|
158
164
|
events.push(event);
|
|
159
165
|
broadcast("session", { sessions: getSessionsData() });
|
|
160
166
|
broadcast("webhook", event);
|
|
@@ -182,17 +188,14 @@ async function handleWebhook(req: IncomingMessage, res: ServerResponse) {
|
|
|
182
188
|
metrics.recordRequest(401);
|
|
183
189
|
metrics.recordWebhook("unauthorized");
|
|
184
190
|
|
|
185
|
-
const ev
|
|
191
|
+
const ev = createRouterEvent("webhook", "unknown", {
|
|
186
192
|
id: traceId,
|
|
187
|
-
timestamp: new Date().toISOString(),
|
|
188
|
-
type: "webhook",
|
|
189
|
-
slug: "unknown",
|
|
190
193
|
routingDecision: "unauthorized",
|
|
191
194
|
durationMs: trace.elapsed(),
|
|
192
195
|
responseStatus: 401,
|
|
193
196
|
traceSpans: trace.spans,
|
|
194
197
|
error: "invalid token",
|
|
195
|
-
};
|
|
198
|
+
});
|
|
196
199
|
events.push(ev);
|
|
197
200
|
broadcast("webhook", ev);
|
|
198
201
|
|
|
@@ -214,17 +217,14 @@ async function handleWebhook(req: IncomingMessage, res: ServerResponse) {
|
|
|
214
217
|
metrics.recordRequest(400);
|
|
215
218
|
metrics.recordWebhook("invalid");
|
|
216
219
|
|
|
217
|
-
const ev
|
|
220
|
+
const ev = createRouterEvent("webhook", "unknown", {
|
|
218
221
|
id: traceId,
|
|
219
|
-
timestamp: new Date().toISOString(),
|
|
220
|
-
type: "webhook",
|
|
221
|
-
slug: "unknown",
|
|
222
222
|
routingDecision: "invalid",
|
|
223
223
|
durationMs: trace.elapsed(),
|
|
224
224
|
responseStatus: 400,
|
|
225
225
|
traceSpans: trace.spans,
|
|
226
226
|
error: "invalid JSON",
|
|
227
|
-
};
|
|
227
|
+
});
|
|
228
228
|
events.push(ev);
|
|
229
229
|
broadcast("webhook", ev);
|
|
230
230
|
|
|
@@ -239,18 +239,15 @@ async function handleWebhook(req: IncomingMessage, res: ServerResponse) {
|
|
|
239
239
|
metrics.recordRequest(400);
|
|
240
240
|
metrics.recordWebhook("invalid");
|
|
241
241
|
|
|
242
|
-
const ev
|
|
242
|
+
const ev = createRouterEvent("webhook", "unknown", {
|
|
243
243
|
id: traceId,
|
|
244
|
-
timestamp: new Date().toISOString(),
|
|
245
|
-
type: "webhook",
|
|
246
|
-
slug: "unknown",
|
|
247
244
|
routingDecision: "invalid",
|
|
248
245
|
payload: truncatePayload(payload),
|
|
249
246
|
durationMs: trace.elapsed(),
|
|
250
247
|
responseStatus: 400,
|
|
251
248
|
traceSpans: trace.spans,
|
|
252
249
|
error: "missing project_slug",
|
|
253
|
-
};
|
|
250
|
+
});
|
|
254
251
|
events.push(ev);
|
|
255
252
|
broadcast("webhook", ev);
|
|
256
253
|
|
|
@@ -259,6 +256,27 @@ async function handleWebhook(req: IncomingMessage, res: ServerResponse) {
|
|
|
259
256
|
return;
|
|
260
257
|
}
|
|
261
258
|
|
|
259
|
+
if (!isValidSlug(slug)) {
|
|
260
|
+
metrics.recordRequest(400);
|
|
261
|
+
metrics.recordWebhook("invalid");
|
|
262
|
+
|
|
263
|
+
const ev = createRouterEvent("webhook", "unknown", {
|
|
264
|
+
id: traceId,
|
|
265
|
+
routingDecision: "invalid",
|
|
266
|
+
payload: truncatePayload(payload),
|
|
267
|
+
durationMs: trace.elapsed(),
|
|
268
|
+
responseStatus: 400,
|
|
269
|
+
traceSpans: trace.spans,
|
|
270
|
+
error: "invalid project_slug format",
|
|
271
|
+
});
|
|
272
|
+
events.push(ev);
|
|
273
|
+
broadcast("webhook", ev);
|
|
274
|
+
|
|
275
|
+
res.writeHead(400, { "Content-Type": "application/json" });
|
|
276
|
+
res.end(JSON.stringify({ error: "invalid project_slug format" }));
|
|
277
|
+
return;
|
|
278
|
+
}
|
|
279
|
+
|
|
262
280
|
// Route lookup
|
|
263
281
|
const routeSpan = trace.span("route_lookup");
|
|
264
282
|
const routeInfo = routes.get(slug);
|
|
@@ -270,17 +288,14 @@ async function handleWebhook(req: IncomingMessage, res: ServerResponse) {
|
|
|
270
288
|
metrics.recordRequest(404);
|
|
271
289
|
metrics.recordWebhook("no_route", slug, durationMs);
|
|
272
290
|
|
|
273
|
-
const ev
|
|
291
|
+
const ev = createRouterEvent("webhook", slug, {
|
|
274
292
|
id: traceId,
|
|
275
|
-
timestamp: new Date().toISOString(),
|
|
276
|
-
type: "webhook",
|
|
277
|
-
slug,
|
|
278
293
|
routingDecision: "no_route",
|
|
279
294
|
payload: truncatePayload(payload),
|
|
280
295
|
durationMs,
|
|
281
296
|
responseStatus: 404,
|
|
282
297
|
traceSpans: trace.spans,
|
|
283
|
-
};
|
|
298
|
+
});
|
|
284
299
|
events.push(ev);
|
|
285
300
|
broadcast("webhook", ev);
|
|
286
301
|
|
|
@@ -311,11 +326,8 @@ async function handleWebhook(req: IncomingMessage, res: ServerResponse) {
|
|
|
311
326
|
metrics.recordWebhook("forwarded", slug, durationMs);
|
|
312
327
|
log.info("forwarded", { slug, port: routeInfo.port, status: resp.status, durationMs, traceId });
|
|
313
328
|
|
|
314
|
-
const ev
|
|
329
|
+
const ev = createRouterEvent("webhook", slug, {
|
|
315
330
|
id: traceId,
|
|
316
|
-
timestamp: new Date().toISOString(),
|
|
317
|
-
type: "webhook",
|
|
318
|
-
slug,
|
|
319
331
|
routingDecision: "forwarded",
|
|
320
332
|
downstreamPort: routeInfo.port,
|
|
321
333
|
downstreamStatus: resp.status,
|
|
@@ -324,7 +336,7 @@ async function handleWebhook(req: IncomingMessage, res: ServerResponse) {
|
|
|
324
336
|
forwardDurationMs: fwdSpan.durationMs,
|
|
325
337
|
responseStatus: 200,
|
|
326
338
|
traceSpans: trace.spans,
|
|
327
|
-
};
|
|
339
|
+
});
|
|
328
340
|
events.push(ev);
|
|
329
341
|
broadcast("webhook", ev);
|
|
330
342
|
broadcast("session", { sessions: getSessionsData() });
|
|
@@ -341,11 +353,8 @@ async function handleWebhook(req: IncomingMessage, res: ServerResponse) {
|
|
|
341
353
|
metrics.recordWebhook("downstream_error", slug, durationMs);
|
|
342
354
|
log.error("forward failed", { slug, port: routeInfo.port, error: err.message, traceId });
|
|
343
355
|
|
|
344
|
-
const ev
|
|
356
|
+
const ev = createRouterEvent("webhook", slug, {
|
|
345
357
|
id: traceId,
|
|
346
|
-
timestamp: new Date().toISOString(),
|
|
347
|
-
type: "webhook",
|
|
348
|
-
slug,
|
|
349
358
|
routingDecision: "forwarded",
|
|
350
359
|
downstreamPort: routeInfo.port,
|
|
351
360
|
payload: truncatePayload(payload),
|
|
@@ -354,7 +363,7 @@ async function handleWebhook(req: IncomingMessage, res: ServerResponse) {
|
|
|
354
363
|
responseStatus: 502,
|
|
355
364
|
traceSpans: trace.spans,
|
|
356
365
|
error: err.message,
|
|
357
|
-
};
|
|
366
|
+
});
|
|
358
367
|
events.push(ev);
|
|
359
368
|
broadcast("webhook", ev);
|
|
360
369
|
broadcast("session", { sessions: getSessionsData() });
|
|
@@ -435,6 +444,26 @@ const statsInterval = setInterval(() => {
|
|
|
435
444
|
}, 5000);
|
|
436
445
|
statsInterval.unref();
|
|
437
446
|
|
|
447
|
+
// Stale route cleanup: remove routes that missed 3 heartbeat intervals
|
|
448
|
+
const HEARTBEAT_MS = parseInt(process.env.HH_HEARTBEAT_MS || "30000", 10);
|
|
449
|
+
const STALE_THRESHOLD_MS = HEARTBEAT_MS * 3;
|
|
450
|
+
|
|
451
|
+
const staleInterval = setInterval(() => {
|
|
452
|
+
const now = Date.now();
|
|
453
|
+
let changed = false;
|
|
454
|
+
for (const [slug, info] of routes) {
|
|
455
|
+
if (now - info.lastHeartbeatAt > STALE_THRESHOLD_MS) {
|
|
456
|
+
routes.delete(slug);
|
|
457
|
+
metrics.unregistrations++;
|
|
458
|
+
log.warn("reaped stale route", { slug, lastHeartbeatAgoMs: now - info.lastHeartbeatAt });
|
|
459
|
+
events.push(createRouterEvent("unregister", slug));
|
|
460
|
+
changed = true;
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
if (changed) broadcast("session", { sessions: getSessionsData() });
|
|
464
|
+
}, STALE_THRESHOLD_MS);
|
|
465
|
+
staleInterval.unref();
|
|
466
|
+
|
|
438
467
|
function handleApiHealth(_req: IncomingMessage, res: ServerResponse) {
|
|
439
468
|
res.writeHead(200, { "Content-Type": "application/json" });
|
|
440
469
|
res.end(JSON.stringify({
|
|
@@ -481,15 +510,7 @@ async function handleApiKill(req: IncomingMessage, res: ServerResponse) {
|
|
|
481
510
|
routes.delete(project_slug);
|
|
482
511
|
log.info("killed", { slug: project_slug, port: routeInfo.port });
|
|
483
512
|
|
|
484
|
-
events.push(
|
|
485
|
-
id: newEventId(),
|
|
486
|
-
timestamp: new Date().toISOString(),
|
|
487
|
-
type: "unregister",
|
|
488
|
-
slug: project_slug,
|
|
489
|
-
routingDecision: null,
|
|
490
|
-
durationMs: 0,
|
|
491
|
-
responseStatus: 200,
|
|
492
|
-
});
|
|
513
|
+
events.push(createRouterEvent("unregister", project_slug));
|
|
493
514
|
broadcast("session", { sessions: getSessionsData() });
|
|
494
515
|
|
|
495
516
|
res.writeHead(200, { "Content-Type": "application/json" });
|