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 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
- exec npx tsx "$SCRIPT_DIR/../src/cli.ts" "$@"
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "hookherald",
3
- "version": "0.4.0",
3
+ "version": "0.5.0",
4
4
  "description": "Webhook relay for Claude Code — push notifications from any HTTP POST into running sessions",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -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;
@@ -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 { execSync } from "node:child_process";
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
- 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
- }
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
 
@@ -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
- if (a.length !== b.length) return false;
28
- return timingSafeEqual(Buffer.from(a), Buffer.from(b));
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({