hookherald 0.5.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/package.json +1 -1
- package/src/observability.ts +19 -0
- package/src/webhook-channel.ts +20 -5
- package/src/webhook-router.ts +52 -67
package/package.json
CHANGED
package/src/observability.ts
CHANGED
|
@@ -284,3 +284,22 @@ export function truncatePayload(payload: any): any {
|
|
|
284
284
|
export function newEventId(): string {
|
|
285
285
|
return randomUUID();
|
|
286
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
|
@@ -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;
|
|
@@ -258,11 +267,17 @@ httpServer.listen(0, "127.0.0.1", async () => {
|
|
|
258
267
|
assignedPort = typeof addr === "object" && addr ? addr.port : 0;
|
|
259
268
|
log.info("HTTP server listening", { host: "127.0.0.1", port: assignedPort });
|
|
260
269
|
|
|
261
|
-
|
|
262
|
-
|
|
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
|
|
263
275
|
await register();
|
|
264
|
-
|
|
265
|
-
|
|
276
|
+
|
|
277
|
+
// Now start watchers — they'll run immediately since we're registered
|
|
278
|
+
startWatchers(currentWatcherConfigs);
|
|
279
|
+
|
|
280
|
+
startConfigWatcher();
|
|
266
281
|
startHeartbeat();
|
|
267
282
|
});
|
|
268
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";
|
|
@@ -64,6 +65,15 @@ async function readBody(req: IncomingMessage): Promise<string> {
|
|
|
64
65
|
return Buffer.concat(chunks).toString();
|
|
65
66
|
}
|
|
66
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
|
+
|
|
67
77
|
// --- Route helpers ---
|
|
68
78
|
|
|
69
79
|
function getRoutesSnapshot() {
|
|
@@ -87,6 +97,11 @@ async function handleRegister(req: IncomingMessage, res: ServerResponse) {
|
|
|
87
97
|
res.end(JSON.stringify({ error: "missing project_slug or port" }));
|
|
88
98
|
return;
|
|
89
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
|
+
}
|
|
90
105
|
|
|
91
106
|
// Heartbeat: if same slug and same port, treat as keepalive
|
|
92
107
|
const existing = routes.get(project_slug);
|
|
@@ -116,16 +131,7 @@ async function handleRegister(req: IncomingMessage, res: ServerResponse) {
|
|
|
116
131
|
metrics.registrations++;
|
|
117
132
|
log.info("registered", { slug: project_slug, port });
|
|
118
133
|
|
|
119
|
-
const event
|
|
120
|
-
id: newEventId(),
|
|
121
|
-
timestamp: new Date().toISOString(),
|
|
122
|
-
type: "register",
|
|
123
|
-
slug: project_slug,
|
|
124
|
-
routingDecision: null,
|
|
125
|
-
downstreamPort: port,
|
|
126
|
-
durationMs: 0,
|
|
127
|
-
responseStatus: 200,
|
|
128
|
-
};
|
|
134
|
+
const event = createRouterEvent("register", project_slug, { downstreamPort: port });
|
|
129
135
|
events.push(event);
|
|
130
136
|
broadcast("session", { sessions: getSessionsData() });
|
|
131
137
|
broadcast("webhook", event);
|
|
@@ -154,15 +160,7 @@ async function handleUnregister(req: IncomingMessage, res: ServerResponse) {
|
|
|
154
160
|
metrics.unregistrations++;
|
|
155
161
|
log.info("unregistered", { slug: project_slug });
|
|
156
162
|
|
|
157
|
-
const event
|
|
158
|
-
id: newEventId(),
|
|
159
|
-
timestamp: new Date().toISOString(),
|
|
160
|
-
type: "unregister",
|
|
161
|
-
slug: project_slug,
|
|
162
|
-
routingDecision: null,
|
|
163
|
-
durationMs: 0,
|
|
164
|
-
responseStatus: 200,
|
|
165
|
-
};
|
|
163
|
+
const event = createRouterEvent("unregister", project_slug);
|
|
166
164
|
events.push(event);
|
|
167
165
|
broadcast("session", { sessions: getSessionsData() });
|
|
168
166
|
broadcast("webhook", event);
|
|
@@ -190,17 +188,14 @@ async function handleWebhook(req: IncomingMessage, res: ServerResponse) {
|
|
|
190
188
|
metrics.recordRequest(401);
|
|
191
189
|
metrics.recordWebhook("unauthorized");
|
|
192
190
|
|
|
193
|
-
const ev
|
|
191
|
+
const ev = createRouterEvent("webhook", "unknown", {
|
|
194
192
|
id: traceId,
|
|
195
|
-
timestamp: new Date().toISOString(),
|
|
196
|
-
type: "webhook",
|
|
197
|
-
slug: "unknown",
|
|
198
193
|
routingDecision: "unauthorized",
|
|
199
194
|
durationMs: trace.elapsed(),
|
|
200
195
|
responseStatus: 401,
|
|
201
196
|
traceSpans: trace.spans,
|
|
202
197
|
error: "invalid token",
|
|
203
|
-
};
|
|
198
|
+
});
|
|
204
199
|
events.push(ev);
|
|
205
200
|
broadcast("webhook", ev);
|
|
206
201
|
|
|
@@ -222,17 +217,14 @@ async function handleWebhook(req: IncomingMessage, res: ServerResponse) {
|
|
|
222
217
|
metrics.recordRequest(400);
|
|
223
218
|
metrics.recordWebhook("invalid");
|
|
224
219
|
|
|
225
|
-
const ev
|
|
220
|
+
const ev = createRouterEvent("webhook", "unknown", {
|
|
226
221
|
id: traceId,
|
|
227
|
-
timestamp: new Date().toISOString(),
|
|
228
|
-
type: "webhook",
|
|
229
|
-
slug: "unknown",
|
|
230
222
|
routingDecision: "invalid",
|
|
231
223
|
durationMs: trace.elapsed(),
|
|
232
224
|
responseStatus: 400,
|
|
233
225
|
traceSpans: trace.spans,
|
|
234
226
|
error: "invalid JSON",
|
|
235
|
-
};
|
|
227
|
+
});
|
|
236
228
|
events.push(ev);
|
|
237
229
|
broadcast("webhook", ev);
|
|
238
230
|
|
|
@@ -247,18 +239,15 @@ async function handleWebhook(req: IncomingMessage, res: ServerResponse) {
|
|
|
247
239
|
metrics.recordRequest(400);
|
|
248
240
|
metrics.recordWebhook("invalid");
|
|
249
241
|
|
|
250
|
-
const ev
|
|
242
|
+
const ev = createRouterEvent("webhook", "unknown", {
|
|
251
243
|
id: traceId,
|
|
252
|
-
timestamp: new Date().toISOString(),
|
|
253
|
-
type: "webhook",
|
|
254
|
-
slug: "unknown",
|
|
255
244
|
routingDecision: "invalid",
|
|
256
245
|
payload: truncatePayload(payload),
|
|
257
246
|
durationMs: trace.elapsed(),
|
|
258
247
|
responseStatus: 400,
|
|
259
248
|
traceSpans: trace.spans,
|
|
260
249
|
error: "missing project_slug",
|
|
261
|
-
};
|
|
250
|
+
});
|
|
262
251
|
events.push(ev);
|
|
263
252
|
broadcast("webhook", ev);
|
|
264
253
|
|
|
@@ -267,6 +256,27 @@ async function handleWebhook(req: IncomingMessage, res: ServerResponse) {
|
|
|
267
256
|
return;
|
|
268
257
|
}
|
|
269
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
|
+
|
|
270
280
|
// Route lookup
|
|
271
281
|
const routeSpan = trace.span("route_lookup");
|
|
272
282
|
const routeInfo = routes.get(slug);
|
|
@@ -278,17 +288,14 @@ async function handleWebhook(req: IncomingMessage, res: ServerResponse) {
|
|
|
278
288
|
metrics.recordRequest(404);
|
|
279
289
|
metrics.recordWebhook("no_route", slug, durationMs);
|
|
280
290
|
|
|
281
|
-
const ev
|
|
291
|
+
const ev = createRouterEvent("webhook", slug, {
|
|
282
292
|
id: traceId,
|
|
283
|
-
timestamp: new Date().toISOString(),
|
|
284
|
-
type: "webhook",
|
|
285
|
-
slug,
|
|
286
293
|
routingDecision: "no_route",
|
|
287
294
|
payload: truncatePayload(payload),
|
|
288
295
|
durationMs,
|
|
289
296
|
responseStatus: 404,
|
|
290
297
|
traceSpans: trace.spans,
|
|
291
|
-
};
|
|
298
|
+
});
|
|
292
299
|
events.push(ev);
|
|
293
300
|
broadcast("webhook", ev);
|
|
294
301
|
|
|
@@ -319,11 +326,8 @@ async function handleWebhook(req: IncomingMessage, res: ServerResponse) {
|
|
|
319
326
|
metrics.recordWebhook("forwarded", slug, durationMs);
|
|
320
327
|
log.info("forwarded", { slug, port: routeInfo.port, status: resp.status, durationMs, traceId });
|
|
321
328
|
|
|
322
|
-
const ev
|
|
329
|
+
const ev = createRouterEvent("webhook", slug, {
|
|
323
330
|
id: traceId,
|
|
324
|
-
timestamp: new Date().toISOString(),
|
|
325
|
-
type: "webhook",
|
|
326
|
-
slug,
|
|
327
331
|
routingDecision: "forwarded",
|
|
328
332
|
downstreamPort: routeInfo.port,
|
|
329
333
|
downstreamStatus: resp.status,
|
|
@@ -332,7 +336,7 @@ async function handleWebhook(req: IncomingMessage, res: ServerResponse) {
|
|
|
332
336
|
forwardDurationMs: fwdSpan.durationMs,
|
|
333
337
|
responseStatus: 200,
|
|
334
338
|
traceSpans: trace.spans,
|
|
335
|
-
};
|
|
339
|
+
});
|
|
336
340
|
events.push(ev);
|
|
337
341
|
broadcast("webhook", ev);
|
|
338
342
|
broadcast("session", { sessions: getSessionsData() });
|
|
@@ -349,11 +353,8 @@ async function handleWebhook(req: IncomingMessage, res: ServerResponse) {
|
|
|
349
353
|
metrics.recordWebhook("downstream_error", slug, durationMs);
|
|
350
354
|
log.error("forward failed", { slug, port: routeInfo.port, error: err.message, traceId });
|
|
351
355
|
|
|
352
|
-
const ev
|
|
356
|
+
const ev = createRouterEvent("webhook", slug, {
|
|
353
357
|
id: traceId,
|
|
354
|
-
timestamp: new Date().toISOString(),
|
|
355
|
-
type: "webhook",
|
|
356
|
-
slug,
|
|
357
358
|
routingDecision: "forwarded",
|
|
358
359
|
downstreamPort: routeInfo.port,
|
|
359
360
|
payload: truncatePayload(payload),
|
|
@@ -362,7 +363,7 @@ async function handleWebhook(req: IncomingMessage, res: ServerResponse) {
|
|
|
362
363
|
responseStatus: 502,
|
|
363
364
|
traceSpans: trace.spans,
|
|
364
365
|
error: err.message,
|
|
365
|
-
};
|
|
366
|
+
});
|
|
366
367
|
events.push(ev);
|
|
367
368
|
broadcast("webhook", ev);
|
|
368
369
|
broadcast("session", { sessions: getSessionsData() });
|
|
@@ -455,15 +456,7 @@ const staleInterval = setInterval(() => {
|
|
|
455
456
|
routes.delete(slug);
|
|
456
457
|
metrics.unregistrations++;
|
|
457
458
|
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
|
-
});
|
|
459
|
+
events.push(createRouterEvent("unregister", slug));
|
|
467
460
|
changed = true;
|
|
468
461
|
}
|
|
469
462
|
}
|
|
@@ -517,15 +510,7 @@ async function handleApiKill(req: IncomingMessage, res: ServerResponse) {
|
|
|
517
510
|
routes.delete(project_slug);
|
|
518
511
|
log.info("killed", { slug: project_slug, port: routeInfo.port });
|
|
519
512
|
|
|
520
|
-
events.push(
|
|
521
|
-
id: newEventId(),
|
|
522
|
-
timestamp: new Date().toISOString(),
|
|
523
|
-
type: "unregister",
|
|
524
|
-
slug: project_slug,
|
|
525
|
-
routingDecision: null,
|
|
526
|
-
durationMs: 0,
|
|
527
|
-
responseStatus: 200,
|
|
528
|
-
});
|
|
513
|
+
events.push(createRouterEvent("unregister", project_slug));
|
|
529
514
|
broadcast("session", { sessions: getSessionsData() });
|
|
530
515
|
|
|
531
516
|
res.writeHead(200, { "Content-Type": "application/json" });
|