hookherald 0.4.0 → 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/bin/hh +5 -1
- package/package.json +1 -1
- package/src/observability.ts +1 -0
- package/src/webhook-channel.ts +15 -11
- package/src/webhook-router.ts +38 -2
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
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";
|
|
@@ -146,16 +146,16 @@ function readConfig(): { slug: string; router_url: string; watchers: WatcherConf
|
|
|
146
146
|
}
|
|
147
147
|
}
|
|
148
148
|
|
|
149
|
-
function executeCommand(cmd: string): string {
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
}
|
|
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
155
|
}
|
|
156
156
|
|
|
157
157
|
async function runWatcher(watcher: WatcherConfig) {
|
|
158
|
-
const output = executeCommand(watcher.command);
|
|
158
|
+
const output = await executeCommand(watcher.command);
|
|
159
159
|
if (!output) return;
|
|
160
160
|
|
|
161
161
|
let parsed: any;
|
|
@@ -206,8 +206,8 @@ function startWatchers(watchers: WatcherConfig[]) {
|
|
|
206
206
|
const key = watcherKey(w);
|
|
207
207
|
if (watcherIntervals.has(key)) continue;
|
|
208
208
|
|
|
209
|
-
// Run immediately, then on interval
|
|
210
|
-
runWatcher(w);
|
|
209
|
+
// Run immediately (if registered), then on interval
|
|
210
|
+
if (registered) runWatcher(w);
|
|
211
211
|
const timer = setInterval(() => runWatcher(w), w.interval * 1000);
|
|
212
212
|
timer.unref();
|
|
213
213
|
watcherIntervals.set(key, timer);
|
|
@@ -217,10 +217,12 @@ function startWatchers(watchers: WatcherConfig[]) {
|
|
|
217
217
|
currentWatcherConfigs = watchers;
|
|
218
218
|
}
|
|
219
219
|
|
|
220
|
-
function loadAndStartWatchers() {
|
|
220
|
+
async function loadAndStartWatchers() {
|
|
221
221
|
const config = readConfig();
|
|
222
222
|
const watchers = config?.watchers || [];
|
|
223
223
|
startWatchers(watchers);
|
|
224
|
+
// Re-register so the router sees the updated watcher list
|
|
225
|
+
if (registered) await register();
|
|
224
226
|
}
|
|
225
227
|
|
|
226
228
|
function startConfigWatcher() {
|
|
@@ -259,6 +261,8 @@ httpServer.listen(0, "127.0.0.1", async () => {
|
|
|
259
261
|
loadAndStartWatchers();
|
|
260
262
|
startConfigWatcher();
|
|
261
263
|
await register();
|
|
264
|
+
// Run watchers that were deferred during startup (before registration)
|
|
265
|
+
for (const w of currentWatcherConfigs) runWatcher(w);
|
|
262
266
|
startHeartbeat();
|
|
263
267
|
});
|
|
264
268
|
|
package/src/webhook-router.ts
CHANGED
|
@@ -24,8 +24,14 @@ const HOST = process.env.ROUTER_HOST || "127.0.0.1";
|
|
|
24
24
|
const SECRET = process.env.WEBHOOK_SECRET || "";
|
|
25
25
|
|
|
26
26
|
function safeEqual(a: string, b: string): boolean {
|
|
27
|
-
|
|
28
|
-
|
|
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);
|
|
29
35
|
}
|
|
30
36
|
|
|
31
37
|
const log = createLogger("router");
|
|
@@ -85,6 +91,7 @@ async function handleRegister(req: IncomingMessage, res: ServerResponse) {
|
|
|
85
91
|
// Heartbeat: if same slug and same port, treat as keepalive
|
|
86
92
|
const existing = routes.get(project_slug);
|
|
87
93
|
if (existing && existing.port === port) {
|
|
94
|
+
existing.lastHeartbeatAt = Date.now();
|
|
88
95
|
// Update watchers on heartbeat (supports hot reload)
|
|
89
96
|
if (watchers && JSON.stringify(watchers) !== JSON.stringify(existing.watchers)) {
|
|
90
97
|
existing.watchers = watchers;
|
|
@@ -98,6 +105,7 @@ async function handleRegister(req: IncomingMessage, res: ServerResponse) {
|
|
|
98
105
|
const info: RouteInfo = {
|
|
99
106
|
port,
|
|
100
107
|
registeredAt: new Date().toISOString(),
|
|
108
|
+
lastHeartbeatAt: Date.now(),
|
|
101
109
|
lastEventAt: null,
|
|
102
110
|
eventCount: 0,
|
|
103
111
|
errorCount: 0,
|
|
@@ -435,6 +443,34 @@ const statsInterval = setInterval(() => {
|
|
|
435
443
|
}, 5000);
|
|
436
444
|
statsInterval.unref();
|
|
437
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
|
+
|
|
438
474
|
function handleApiHealth(_req: IncomingMessage, res: ServerResponse) {
|
|
439
475
|
res.writeHead(200, { "Content-Type": "application/json" });
|
|
440
476
|
res.end(JSON.stringify({
|