hooklens 0.1.1 → 1.0.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/dist/index.js CHANGED
@@ -1,11 +1,380 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  // src/cli/index.ts
4
- import { Command as Command4 } from "commander";
4
+ import { Command as Command7 } from "commander";
5
5
 
6
- // src/cli/listen.ts
6
+ // src/errors.ts
7
+ function toError(value) {
8
+ return value instanceof Error ? value : new Error(String(value));
9
+ }
10
+ function errorMessage(value) {
11
+ return value instanceof Error ? value.message : String(value);
12
+ }
13
+
14
+ // src/cli/clear.ts
7
15
  import { Command } from "commander";
8
16
 
17
+ // src/storage/index.ts
18
+ import os from "os";
19
+ import fs from "fs";
20
+ import { createRequire } from "module";
21
+ import path from "path";
22
+
23
+ // src/types.ts
24
+ import { z } from "zod";
25
+ var verificationResultSchema = z.object({
26
+ valid: z.boolean(),
27
+ provider: z.string(),
28
+ message: z.string(),
29
+ code: z.enum([
30
+ "valid",
31
+ "missing_header",
32
+ "malformed_header",
33
+ "expired_timestamp",
34
+ "signature_mismatch",
35
+ "body_mutated"
36
+ ])
37
+ });
38
+ var webhookEventSchema = z.object({
39
+ id: z.string(),
40
+ timestamp: z.string(),
41
+ method: z.string(),
42
+ path: z.string(),
43
+ headers: z.record(z.string(), z.string()),
44
+ body: z.string(),
45
+ verification: verificationResultSchema.nullable().optional()
46
+ });
47
+ var eventRowSchema = z.object({
48
+ id: z.string(),
49
+ timestamp: z.string(),
50
+ method: z.string(),
51
+ path: z.string(),
52
+ headers: z.string(),
53
+ body: z.string(),
54
+ verification: z.string().nullable().optional()
55
+ });
56
+ var replayResultSchema = z.object({
57
+ status: z.number().int(),
58
+ body: z.string()
59
+ });
60
+
61
+ // src/storage/index.ts
62
+ var require2 = createRequire(import.meta.url);
63
+ var { DatabaseSync } = require2("node:sqlite");
64
+ function defaultDbPath() {
65
+ return path.join(os.homedir(), ".hooklens", "events.db");
66
+ }
67
+ function rowToEvent(row) {
68
+ const verification = row.verification ? verificationResultSchema.parse(JSON.parse(row.verification)) : null;
69
+ return webhookEventSchema.parse({
70
+ id: row.id,
71
+ timestamp: row.timestamp,
72
+ method: row.method,
73
+ path: row.path,
74
+ headers: JSON.parse(row.headers),
75
+ body: row.body,
76
+ verification
77
+ });
78
+ }
79
+ function createStorage(dbPath) {
80
+ fs.mkdirSync(path.dirname(dbPath), { recursive: true });
81
+ const db = new DatabaseSync(dbPath);
82
+ db.exec(`
83
+ CREATE TABLE IF NOT EXISTS events (
84
+ id TEXT PRIMARY KEY,
85
+ timestamp TEXT NOT NULL,
86
+ method TEXT NOT NULL,
87
+ path TEXT NOT NULL,
88
+ headers TEXT NOT NULL,
89
+ body TEXT NOT NULL,
90
+ verification TEXT
91
+ )
92
+ `);
93
+ try {
94
+ db.exec(`ALTER TABLE events ADD COLUMN verification TEXT`);
95
+ } catch (error) {
96
+ if (!(error instanceof Error && /duplicate column/i.test(error.message))) {
97
+ throw error;
98
+ }
99
+ }
100
+ const insertStmt = db.prepare(
101
+ `INSERT OR REPLACE INTO events (id, timestamp, method, path, headers, body, verification)
102
+ VALUES (?, ?, ?, ?, ?, ?, ?)`
103
+ );
104
+ const getStmt = db.prepare(`SELECT * FROM events WHERE id = ?`);
105
+ const listAllStmt = db.prepare(`SELECT * FROM events ORDER BY timestamp DESC`);
106
+ const listLimitedStmt = db.prepare(`SELECT * FROM events ORDER BY timestamp DESC LIMIT ?`);
107
+ const deleteStmt = db.prepare(`DELETE FROM events WHERE id = ?`);
108
+ const clearStmt = db.prepare(`DELETE FROM events`);
109
+ return {
110
+ save(event) {
111
+ insertStmt.run(
112
+ event.id,
113
+ event.timestamp,
114
+ event.method,
115
+ event.path,
116
+ JSON.stringify(event.headers),
117
+ event.body,
118
+ event.verification ? JSON.stringify(event.verification) : null
119
+ );
120
+ },
121
+ load(id) {
122
+ const raw = getStmt.get(id);
123
+ if (!raw) return null;
124
+ const row = eventRowSchema.parse(raw);
125
+ return rowToEvent(row);
126
+ },
127
+ list(limit) {
128
+ if (limit !== void 0 && (!Number.isInteger(limit) || limit <= 0)) {
129
+ throw new Error(`Invalid limit: must be a positive integer, got ${limit}`);
130
+ }
131
+ const raw = limit === void 0 ? listAllStmt.all() : listLimitedStmt.all(limit);
132
+ const rows = raw.map((r) => eventRowSchema.parse(r));
133
+ return rows.map(rowToEvent);
134
+ },
135
+ delete(id) {
136
+ const result = deleteStmt.run(id);
137
+ return result.changes > 0;
138
+ },
139
+ clear() {
140
+ const result = clearStmt.run();
141
+ return Number(result.changes);
142
+ },
143
+ close() {
144
+ db.close();
145
+ }
146
+ };
147
+ }
148
+
149
+ // src/ui/terminal.ts
150
+ import chalk from "chalk";
151
+ function writeLine(stream, line) {
152
+ stream.write(`${line}
153
+ `);
154
+ }
155
+ function verificationLabel(result) {
156
+ if (!result) return chalk.cyan("RECV");
157
+ return result.valid ? chalk.green("PASS") : chalk.red("FAIL");
158
+ }
159
+ function createTerminal(stdout = process.stdout, stderr = process.stderr) {
160
+ return {
161
+ printListenStarted(info) {
162
+ writeLine(
163
+ stdout,
164
+ `${chalk.bold("Listening on")} ${chalk.cyan(`http://127.0.0.1:${info.port}`)}`
165
+ );
166
+ writeLine(stdout, `Verifier: ${info.verifier ?? "none"}`);
167
+ writeLine(stdout, `Forwarding to: ${info.forwardTo ?? "disabled"}`);
168
+ writeLine(stdout, `Storage: ${info.dbPath}`);
169
+ },
170
+ printEventCaptured(event, result) {
171
+ const label = verificationLabel(result);
172
+ const summary = `${label} ${chalk.bold(event.id)} ${event.method} ${event.path}`;
173
+ if (!result) {
174
+ writeLine(stdout, summary);
175
+ return;
176
+ }
177
+ writeLine(stdout, `${summary} ${result.message}`);
178
+ },
179
+ printForwardError(eventId, reason) {
180
+ writeLine(stdout, `${chalk.red("FWD")} ${chalk.bold(eventId)} ${reason}`);
181
+ },
182
+ printForwardRetry(eventId, attempt, maxRetries, reason) {
183
+ writeLine(
184
+ stdout,
185
+ `${chalk.yellow("RETRY")} ${chalk.bold(eventId)} attempt ${attempt}/${maxRetries} ${reason}`
186
+ );
187
+ },
188
+ printEventList(events) {
189
+ if (!events.length) {
190
+ writeLine(stdout, chalk.dim("No stored events."));
191
+ return;
192
+ }
193
+ for (const event of events) {
194
+ const row = `${chalk.dim(event.timestamp)} ${chalk.cyan(event.method)} ${chalk.bold(event.id)} ${event.path}`;
195
+ writeLine(stdout, row);
196
+ }
197
+ },
198
+ printEventDetail(event) {
199
+ writeLine(stdout, `${chalk.bold("Event:")} ${event.id}`);
200
+ writeLine(stdout, `${chalk.bold("Time:")} ${event.timestamp}`);
201
+ writeLine(stdout, `${chalk.bold("Method:")} ${event.method}`);
202
+ writeLine(stdout, `${chalk.bold("Path:")} ${event.path}`);
203
+ if (event.verification) {
204
+ const v = event.verification;
205
+ const label = v.valid ? chalk.green("PASS") : chalk.red("FAIL");
206
+ writeLine(stdout, "");
207
+ writeLine(stdout, chalk.bold("Verification:"));
208
+ writeLine(stdout, ` Result: ${label}`);
209
+ writeLine(stdout, ` Provider: ${v.provider}`);
210
+ writeLine(stdout, ` Message: ${v.message}`);
211
+ }
212
+ writeLine(stdout, "");
213
+ writeLine(stdout, chalk.bold("Headers:"));
214
+ for (const [key, value] of Object.entries(event.headers)) {
215
+ writeLine(stdout, ` ${key}: ${value}`);
216
+ }
217
+ writeLine(stdout, "");
218
+ writeLine(stdout, chalk.bold("Body:"));
219
+ let bodyText;
220
+ try {
221
+ bodyText = JSON.stringify(JSON.parse(event.body), null, 2);
222
+ } catch {
223
+ bodyText = event.body;
224
+ }
225
+ for (const line of bodyText.split("\n")) {
226
+ writeLine(stdout, ` ${line}`);
227
+ }
228
+ },
229
+ printReplayResult(result) {
230
+ const summary = `${chalk.bold("Replay response:")} ${chalk.cyan(String(result.status))}`;
231
+ if (!result.body) {
232
+ writeLine(stdout, summary);
233
+ return;
234
+ }
235
+ writeLine(stdout, `${summary} ${result.body}`);
236
+ },
237
+ printDeleted(eventId) {
238
+ writeLine(stdout, `Deleted ${chalk.bold(eventId)}`);
239
+ },
240
+ printCleared(count) {
241
+ writeLine(stdout, `Cleared ${chalk.bold(String(count))} events`);
242
+ },
243
+ printListenStopped() {
244
+ writeLine(stdout, chalk.dim("Stopped listening."));
245
+ },
246
+ printError(message) {
247
+ writeLine(stderr, chalk.red(message));
248
+ }
249
+ };
250
+ }
251
+
252
+ // src/cli/clear.ts
253
+ async function defaultConfirm() {
254
+ const { createInterface } = await import("readline");
255
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
256
+ return new Promise((resolve) => {
257
+ rl.question("Delete all stored events? [y/N] ", (answer) => {
258
+ rl.close();
259
+ resolve(answer.trim().toLowerCase() === "y");
260
+ });
261
+ });
262
+ }
263
+ async function runClear(flags, deps = {}) {
264
+ const terminal = deps.terminal ?? createTerminal();
265
+ if (!flags.yes) {
266
+ const confirm = deps.confirm ?? defaultConfirm;
267
+ const confirmed = await confirm();
268
+ if (!confirmed) {
269
+ return;
270
+ }
271
+ }
272
+ const storage = createStorage(defaultDbPath());
273
+ try {
274
+ const count = storage.clear();
275
+ terminal.printCleared(count);
276
+ } finally {
277
+ storage.close();
278
+ }
279
+ }
280
+ var clearCommand = new Command("clear").description("Delete all stored webhook events").option("--yes", "Skip confirmation prompt").addHelpText(
281
+ "after",
282
+ `
283
+ Examples:
284
+ hooklens clear --yes
285
+ hooklens clear`
286
+ ).action(async (options) => {
287
+ const terminal = createTerminal();
288
+ try {
289
+ await runClear(options, { terminal });
290
+ } catch (error) {
291
+ terminal.printError(errorMessage(error));
292
+ process.exitCode = 1;
293
+ }
294
+ });
295
+
296
+ // src/cli/delete.ts
297
+ import { Command as Command2 } from "commander";
298
+ async function runDelete(eventId, deps = {}) {
299
+ const terminal = deps.terminal ?? createTerminal();
300
+ const storage = createStorage(defaultDbPath());
301
+ try {
302
+ const deleted = storage.delete(eventId);
303
+ if (!deleted) {
304
+ throw new Error(`Event "${eventId}" not found.`);
305
+ }
306
+ terminal.printDeleted(eventId);
307
+ } finally {
308
+ storage.close();
309
+ }
310
+ }
311
+ var deleteCommand = new Command2("delete").description("Delete a stored webhook event").argument("<event-id>", "ID of the event to delete").addHelpText(
312
+ "after",
313
+ `
314
+ Examples:
315
+ hooklens delete evt_abc123`
316
+ ).action(async (eventId) => {
317
+ const terminal = createTerminal();
318
+ try {
319
+ await runDelete(eventId, { terminal });
320
+ } catch (error) {
321
+ terminal.printError(errorMessage(error));
322
+ process.exitCode = 1;
323
+ }
324
+ });
325
+
326
+ // src/cli/inspect.ts
327
+ import { Command as Command3 } from "commander";
328
+
329
+ // src/cli/json-output.ts
330
+ function writeJsonLine(stdout, data) {
331
+ const json = JSON.stringify(data);
332
+ if (json === void 0) {
333
+ throw new Error(
334
+ "Cannot serialize value to JSON \u2013 received a non-serializable type (undefined, function, or symbol)"
335
+ );
336
+ }
337
+ stdout.write(json + "\n");
338
+ }
339
+
340
+ // src/cli/inspect.ts
341
+ async function runInspect(eventId, flags, deps = {}) {
342
+ const terminal = deps.terminal ?? createTerminal();
343
+ const storage = createStorage(defaultDbPath());
344
+ try {
345
+ const event = storage.load(eventId);
346
+ if (!event) {
347
+ throw new Error(`Event "${eventId}" not found.`);
348
+ }
349
+ if (flags.json) {
350
+ const out = deps.stdout ?? process.stdout;
351
+ writeJsonLine(out, event);
352
+ } else {
353
+ terminal.printEventDetail(event);
354
+ }
355
+ } finally {
356
+ storage.close();
357
+ }
358
+ }
359
+ var inspectCommand = new Command3("inspect").description("View full details of a stored webhook event").argument("<event-id>", "ID of the event to inspect").option("--json", "Output as JSON").addHelpText(
360
+ "after",
361
+ `
362
+ Examples:
363
+ hooklens inspect evt_abc123
364
+ hooklens inspect evt_abc123 --json`
365
+ ).action(async (eventId, options) => {
366
+ const terminal = createTerminal();
367
+ try {
368
+ await runInspect(eventId, options, { terminal });
369
+ } catch (error) {
370
+ terminal.printError(errorMessage(error));
371
+ process.exitCode = 1;
372
+ }
373
+ });
374
+
375
+ // src/cli/listen.ts
376
+ import { Command as Command4 } from "commander";
377
+
9
378
  // src/server/index.ts
10
379
  import http from "http";
11
380
  import crypto from "crypto";
@@ -23,6 +392,8 @@ var FORWARD_STRIP = /* @__PURE__ */ new Set([
23
392
  ]);
24
393
  var DEFAULT_MAX_BODY_BYTES = 1024 * 1024;
25
394
  var DEFAULT_FORWARD_TIMEOUT_MS = 5e3;
395
+ var DEFAULT_RETRY_BASE_DELAY_MS = 100;
396
+ var RETRY_BACKOFF_MULTIPLIER = 4;
26
397
  function forwardedStripSet(headers) {
27
398
  const strip = new Set(FORWARD_STRIP);
28
399
  for (const [key, value] of Object.entries(headers)) {
@@ -156,7 +527,29 @@ function mergeForwardSearch(targetSearch, incomingSearch) {
156
527
  function isAbortError(error) {
157
528
  return error instanceof Error && error.name === "AbortError";
158
529
  }
159
- async function forwardEvent(targetUrl, event, timeoutMs = DEFAULT_FORWARD_TIMEOUT_MS) {
530
+ async function readResponseBody(response, maxBytes, controller) {
531
+ const reader = response.body?.getReader();
532
+ if (!reader) return "";
533
+ const chunks = [];
534
+ let totalBytes = 0;
535
+ try {
536
+ for (; ; ) {
537
+ const { done, value } = await reader.read();
538
+ if (done) break;
539
+ totalBytes += value.byteLength;
540
+ if (totalBytes > maxBytes) {
541
+ await reader.cancel();
542
+ controller.abort();
543
+ throw new Error(`forward response too large: max ${maxBytes} bytes`);
544
+ }
545
+ chunks.push(value);
546
+ }
547
+ } finally {
548
+ reader.releaseLock();
549
+ }
550
+ return Buffer.concat(chunks, totalBytes).toString("utf8");
551
+ }
552
+ async function forwardEvent(targetUrl, event, timeoutMs = DEFAULT_FORWARD_TIMEOUT_MS, maxResponseBytes = DEFAULT_MAX_BODY_BYTES) {
160
553
  const target = new URL(targetUrl);
161
554
  const destination = new URL(target.href);
162
555
  const parsedEventPath = parseEventPath(event.path);
@@ -174,23 +567,46 @@ async function forwardEvent(targetUrl, event, timeoutMs = DEFAULT_FORWARD_TIMEOU
174
567
  });
175
568
  return {
176
569
  status: response.status,
177
- body: await response.text()
570
+ body: await readResponseBody(response, maxResponseBytes, controller)
178
571
  };
179
572
  } catch (error) {
180
573
  if (isAbortError(error)) {
181
574
  throw new Error(`forward timed out after ${timeoutMs}ms`);
182
575
  }
183
- throw error;
576
+ const cause = error instanceof Error ? error.cause : void 0;
577
+ const code = cause instanceof Error ? cause.code : void 0;
578
+ const message = cause instanceof Error && cause.message ? cause.message : code;
579
+ throw new Error(message ?? errorMessage(error));
184
580
  } finally {
185
581
  clearTimeout(timeout);
186
582
  }
187
583
  }
584
+ function cancellableDelay(ms, req) {
585
+ if (ms <= 0) return Promise.resolve();
586
+ return new Promise((resolve) => {
587
+ const timer = setTimeout(resolve, ms);
588
+ const onAbort = () => {
589
+ clearTimeout(timer);
590
+ resolve();
591
+ };
592
+ req.once("close", onAbort);
593
+ timer.unref();
594
+ });
595
+ }
596
+ function clampRetryCount(value) {
597
+ const n = value ?? 0;
598
+ if (!Number.isFinite(n) || !Number.isInteger(n)) return 0;
599
+ return Math.max(0, Math.min(n, 10));
600
+ }
188
601
  function createServer(opts) {
189
602
  let boundPort = opts.port;
190
603
  let httpServer = null;
191
604
  let isStarting = false;
192
605
  const maxBodyBytes = opts.maxBodyBytes ?? DEFAULT_MAX_BODY_BYTES;
606
+ const maxForwardResponseBytes = opts.maxForwardResponseBytes ?? DEFAULT_MAX_BODY_BYTES;
193
607
  const forwardTimeoutMs = opts.forwardTimeoutMs ?? DEFAULT_FORWARD_TIMEOUT_MS;
608
+ const retryCount = clampRetryCount(opts.retryCount);
609
+ const retryBaseDelayMs = opts.retryBaseDelayMs ?? DEFAULT_RETRY_BASE_DELAY_MS;
194
610
  const handleRequest = async (req, res) => {
195
611
  const body = await readBody(req, maxBodyBytes);
196
612
  const event = {
@@ -201,22 +617,45 @@ function createServer(opts) {
201
617
  headers: headersToRecord(req.headers),
202
618
  body
203
619
  };
204
- opts.storage.save(event);
205
620
  const verification = opts.verifier?.verify({ headers: event.headers, body: event.body }) ?? null;
621
+ event.verification = verification;
622
+ opts.storage.save(event);
206
623
  opts.onEvent?.(event, verification);
207
624
  if (!opts.forwardTo) {
208
625
  res.statusCode = 200;
209
626
  res.end("ok");
210
627
  return;
211
628
  }
629
+ let lastError = new Error("forward failed");
630
+ for (let attempt = 0; attempt <= retryCount; attempt++) {
631
+ if (attempt > 0) {
632
+ const delayMs = retryBaseDelayMs * Math.pow(RETRY_BACKOFF_MULTIPLIER, attempt - 1);
633
+ await cancellableDelay(delayMs, req);
634
+ try {
635
+ opts.onForwardRetry?.(event, attempt, retryCount, lastError);
636
+ } catch {
637
+ }
638
+ }
639
+ try {
640
+ const forwarded = await forwardEvent(
641
+ opts.forwardTo,
642
+ event,
643
+ forwardTimeoutMs,
644
+ maxForwardResponseBytes
645
+ );
646
+ res.statusCode = forwarded.status;
647
+ res.end(forwarded.body);
648
+ return;
649
+ } catch (error) {
650
+ lastError = toError(error);
651
+ }
652
+ }
212
653
  try {
213
- const forwarded = await forwardEvent(opts.forwardTo, event, forwardTimeoutMs);
214
- res.statusCode = forwarded.status;
215
- res.end(forwarded.body);
654
+ opts.onForwardError?.(event, lastError);
216
655
  } catch {
217
- res.statusCode = 502;
218
- res.end("bad gateway");
219
656
  }
657
+ res.statusCode = 502;
658
+ res.end(`bad gateway: ${lastError.message}`);
220
659
  };
221
660
  return {
222
661
  get port() {
@@ -235,7 +674,7 @@ function createServer(opts) {
235
674
  return;
236
675
  }
237
676
  res.statusCode = 500;
238
- res.end(err instanceof Error ? err.message : String(err));
677
+ res.end(errorMessage(err));
239
678
  });
240
679
  });
241
680
  httpServer = server;
@@ -285,177 +724,7 @@ function createServer(opts) {
285
724
  };
286
725
  }
287
726
 
288
- // src/storage/index.ts
289
- import os from "os";
290
- import fs from "fs";
291
- import { createRequire } from "module";
292
- import path from "path";
293
-
294
- // src/types.ts
295
- import { z } from "zod";
296
- var webhookEventSchema = z.object({
297
- id: z.string(),
298
- timestamp: z.string(),
299
- method: z.string(),
300
- path: z.string(),
301
- headers: z.record(z.string(), z.string()),
302
- body: z.string()
303
- });
304
- var eventRowSchema = z.object({
305
- id: z.string(),
306
- timestamp: z.string(),
307
- method: z.string(),
308
- path: z.string(),
309
- headers: z.string(),
310
- body: z.string()
311
- });
312
- var verificationResultSchema = z.object({
313
- valid: z.boolean(),
314
- provider: z.string(),
315
- message: z.string(),
316
- code: z.enum([
317
- "valid",
318
- "missing_header",
319
- "malformed_header",
320
- "expired_timestamp",
321
- "signature_mismatch",
322
- "body_mutated"
323
- ])
324
- });
325
- var replayResultSchema = z.object({
326
- status: z.number().int(),
327
- body: z.string()
328
- });
329
-
330
- // src/storage/index.ts
331
- var require2 = createRequire(import.meta.url);
332
- var { DatabaseSync } = require2("node:sqlite");
333
- function defaultDbPath() {
334
- return path.join(os.homedir(), ".hooklens", "events.db");
335
- }
336
- function rowToEvent(row) {
337
- return webhookEventSchema.parse({
338
- id: row.id,
339
- timestamp: row.timestamp,
340
- method: row.method,
341
- path: row.path,
342
- headers: JSON.parse(row.headers),
343
- body: row.body
344
- });
345
- }
346
- function createStorage(dbPath) {
347
- fs.mkdirSync(path.dirname(dbPath), { recursive: true });
348
- const db = new DatabaseSync(dbPath);
349
- db.exec(`
350
- CREATE TABLE IF NOT EXISTS events (
351
- id TEXT PRIMARY KEY,
352
- timestamp TEXT NOT NULL,
353
- method TEXT NOT NULL,
354
- path TEXT NOT NULL,
355
- headers TEXT NOT NULL,
356
- body TEXT NOT NULL
357
- )
358
- `);
359
- const insertStmt = db.prepare(
360
- `INSERT OR REPLACE INTO events (id, timestamp, method, path, headers, body)
361
- VALUES (?, ?, ?, ?, ?, ?)`
362
- );
363
- const getStmt = db.prepare(`SELECT * FROM events WHERE id = ?`);
364
- const listAllStmt = db.prepare(`SELECT * FROM events ORDER BY timestamp DESC`);
365
- const listLimitedStmt = db.prepare(`SELECT * FROM events ORDER BY timestamp DESC LIMIT ?`);
366
- const clearStmt = db.prepare(`DELETE FROM events`);
367
- return {
368
- save(event) {
369
- insertStmt.run(
370
- event.id,
371
- event.timestamp,
372
- event.method,
373
- event.path,
374
- JSON.stringify(event.headers),
375
- event.body
376
- );
377
- },
378
- load(id) {
379
- const raw = getStmt.get(id);
380
- if (!raw) return null;
381
- const row = eventRowSchema.parse(raw);
382
- return rowToEvent(row);
383
- },
384
- list(limit) {
385
- if (limit !== void 0 && (!Number.isInteger(limit) || limit <= 0)) {
386
- throw new Error(`Invalid limit: must be a positive integer, got ${limit}`);
387
- }
388
- const raw = limit === void 0 ? listAllStmt.all() : listLimitedStmt.all(limit);
389
- const rows = raw.map((r) => eventRowSchema.parse(r));
390
- return rows.map(rowToEvent);
391
- },
392
- clear() {
393
- clearStmt.run();
394
- },
395
- close() {
396
- db.close();
397
- }
398
- };
399
- }
400
-
401
- // src/ui/terminal.ts
402
- import chalk from "chalk";
403
- function writeLine(stream, line) {
404
- stream.write(`${line}
405
- `);
406
- }
407
- function verificationLabel(result) {
408
- if (!result) return chalk.cyan("RECV");
409
- return result.valid ? chalk.green("PASS") : chalk.red("FAIL");
410
- }
411
- function createTerminal(stdout = process.stdout, stderr = process.stderr) {
412
- return {
413
- printListenStarted(info) {
414
- writeLine(
415
- stdout,
416
- `${chalk.bold("Listening on")} ${chalk.cyan(`http://127.0.0.1:${info.port}`)}`
417
- );
418
- writeLine(stdout, `Verifier: ${info.verifier ?? "none"}`);
419
- writeLine(stdout, `Forwarding to: ${info.forwardTo ?? "disabled"}`);
420
- writeLine(stdout, `Storage: ${info.dbPath}`);
421
- },
422
- printEventCaptured(event, result) {
423
- const label = verificationLabel(result);
424
- const summary = `${label} ${chalk.bold(event.id)} ${event.method} ${event.path}`;
425
- if (!result) {
426
- writeLine(stdout, summary);
427
- return;
428
- }
429
- writeLine(stdout, `${summary} ${result.message}`);
430
- },
431
- printEventList(events) {
432
- if (!events.length) {
433
- writeLine(stdout, chalk.dim("No stored events."));
434
- return;
435
- }
436
- for (const event of events) {
437
- const row = `${chalk.dim(event.timestamp)} ${chalk.cyan(event.method)} ${chalk.bold(event.id)} ${event.path}`;
438
- writeLine(stdout, row);
439
- }
440
- },
441
- printReplayResult(result) {
442
- const summary = `${chalk.bold("Replay response:")} ${chalk.cyan(String(result.status))}`;
443
- if (!result.body) {
444
- writeLine(stdout, summary);
445
- return;
446
- }
447
- writeLine(stdout, `${summary} ${result.body}`);
448
- },
449
- printListenStopped() {
450
- writeLine(stdout, chalk.dim("Stopped listening."));
451
- },
452
- printError(message) {
453
- writeLine(stderr, chalk.red(message));
454
- }
455
- };
456
- }
457
-
458
- // src/verify/stripe.ts
727
+ // src/verify/github.ts
459
728
  import crypto2 from "crypto";
460
729
 
461
730
  // src/verify/headers.ts
@@ -466,10 +735,86 @@ function getHeaderCaseInsensitive(headers, name) {
466
735
  }
467
736
  return void 0;
468
737
  }
738
+ function tryCanonicalForm(payload) {
739
+ try {
740
+ const canonical = JSON.stringify(JSON.parse(payload));
741
+ return canonical === payload ? null : canonical;
742
+ } catch {
743
+ return null;
744
+ }
745
+ }
746
+
747
+ // src/verify/github.ts
748
+ var PROVIDER = "github";
749
+ var PREFIX = "sha256=";
750
+ var SHA256_HEX = /^[0-9a-fA-F]{64}$/;
751
+ function computeHmac(secret, payload) {
752
+ return crypto2.createHmac("sha256", secret).update(payload).digest("hex");
753
+ }
754
+ function constantTimeMatch(expected, actual) {
755
+ if (expected.length !== actual.length) return false;
756
+ const expectedBuf = Buffer.from(expected, "utf8");
757
+ const actualBuf = Buffer.from(actual, "utf8");
758
+ return crypto2.timingSafeEqual(expectedBuf, actualBuf);
759
+ }
760
+ function success(message) {
761
+ return { valid: true, provider: PROVIDER, code: "valid", message };
762
+ }
763
+ function failure(code, message) {
764
+ return { valid: false, provider: PROVIDER, code, message };
765
+ }
766
+ function verifyGitHubSignature(opts) {
767
+ if (!opts.header) {
768
+ return failure(
769
+ "missing_header",
770
+ "x-hub-signature-256 header not found. Is this actually from GitHub?"
771
+ );
772
+ }
773
+ if (!opts.header.startsWith(PREFIX)) {
774
+ return failure("malformed_header", "x-hub-signature-256 header must start with sha256=");
775
+ }
776
+ const signature = opts.header.slice(PREFIX.length);
777
+ if (signature.length === 0) {
778
+ return failure("malformed_header", "x-hub-signature-256 header has no signature after sha256=");
779
+ }
780
+ if (!SHA256_HEX.test(signature)) {
781
+ return failure("malformed_header", "x-hub-signature-256 header has invalid sha256 hex digest");
782
+ }
783
+ const normalizedSignature = signature.toLowerCase();
784
+ const expected = computeHmac(opts.secret, opts.payload);
785
+ if (constantTimeMatch(expected, normalizedSignature)) {
786
+ return success("Signature verified.");
787
+ }
788
+ const canonical = tryCanonicalForm(opts.payload);
789
+ if (canonical !== null) {
790
+ const expectedCanonical = computeHmac(opts.secret, canonical);
791
+ if (constantTimeMatch(expectedCanonical, normalizedSignature)) {
792
+ return failure(
793
+ "body_mutated",
794
+ "Signature mismatch with correct secret. Body was likely parsed and re-serialized by your framework."
795
+ );
796
+ }
797
+ }
798
+ return failure(
799
+ "signature_mismatch",
800
+ "Signature mismatch. Check your webhook secret matches the GitHub settings."
801
+ );
802
+ }
803
+ function createGitHubVerifier(opts) {
804
+ return {
805
+ provider: PROVIDER,
806
+ verify: (event) => verifyGitHubSignature({
807
+ payload: event.body,
808
+ header: getHeaderCaseInsensitive(event.headers, "x-hub-signature-256"),
809
+ secret: opts.secret
810
+ })
811
+ };
812
+ }
469
813
 
470
814
  // src/verify/stripe.ts
815
+ import crypto3 from "crypto";
471
816
  var DEFAULT_TOLERANCE_SECONDS = 300;
472
- var PROVIDER = "stripe";
817
+ var PROVIDER2 = "stripe";
473
818
  function parseHeader(header) {
474
819
  let timestamp = null;
475
820
  const signatures = [];
@@ -489,42 +834,34 @@ function parseHeader(header) {
489
834
  if (timestamp === null || signatures.length === 0) return null;
490
835
  return { timestamp, signatures };
491
836
  }
492
- function computeHmac(secret, signedPayload) {
493
- return crypto2.createHmac("sha256", secret).update(signedPayload).digest("hex");
837
+ function computeHmac2(secret, signedPayload) {
838
+ return crypto3.createHmac("sha256", secret).update(signedPayload).digest("hex");
494
839
  }
495
- function constantTimeMatch(expected, candidates) {
840
+ function constantTimeMatch2(expected, candidates) {
496
841
  const expectedBuf = Buffer.from(expected, "utf8");
497
842
  for (const candidate of candidates) {
498
843
  if (candidate.length !== expected.length) continue;
499
844
  const candidateBuf = Buffer.from(candidate, "utf8");
500
- if (crypto2.timingSafeEqual(expectedBuf, candidateBuf)) return true;
845
+ if (crypto3.timingSafeEqual(expectedBuf, candidateBuf)) return true;
501
846
  }
502
847
  return false;
503
848
  }
504
- function tryCanonicalForm(payload) {
505
- try {
506
- const canonical = JSON.stringify(JSON.parse(payload));
507
- return canonical === payload ? null : canonical;
508
- } catch {
509
- return null;
510
- }
511
- }
512
- function success(message) {
513
- return { valid: true, provider: PROVIDER, code: "valid", message };
849
+ function success2(message) {
850
+ return { valid: true, provider: PROVIDER2, code: "valid", message };
514
851
  }
515
- function failure(code, message) {
516
- return { valid: false, provider: PROVIDER, code, message };
852
+ function failure2(code, message) {
853
+ return { valid: false, provider: PROVIDER2, code, message };
517
854
  }
518
855
  function verifyStripeSignature(opts) {
519
856
  if (!opts.header) {
520
- return failure(
857
+ return failure2(
521
858
  "missing_header",
522
859
  "stripe-signature header not found. Is this actually from Stripe?"
523
860
  );
524
861
  }
525
862
  const parsed = parseHeader(opts.header);
526
863
  if (!parsed) {
527
- return failure(
864
+ return failure2(
528
865
  "malformed_header",
529
866
  "stripe-signature header is malformed. Expected format: t=timestamp,v1=signature"
530
867
  );
@@ -534,34 +871,34 @@ function verifyStripeSignature(opts) {
534
871
  const ageSeconds = Math.floor(nowMs / 1e3) - parsed.timestamp;
535
872
  if (ageSeconds > tolerance) {
536
873
  const minutes = Math.floor(ageSeconds / 60);
537
- return failure(
874
+ return failure2(
538
875
  "expired_timestamp",
539
876
  `Timestamp is ${minutes} minutes old. Event expired or your clock is drifting.`
540
877
  );
541
878
  }
542
879
  const signedPayload = `${parsed.timestamp}.${opts.payload}`;
543
- const expected = computeHmac(opts.secret, signedPayload);
544
- if (constantTimeMatch(expected, parsed.signatures)) {
545
- return success("Signature verified.");
880
+ const expected = computeHmac2(opts.secret, signedPayload);
881
+ if (constantTimeMatch2(expected, parsed.signatures)) {
882
+ return success2("Signature verified.");
546
883
  }
547
884
  const canonical = tryCanonicalForm(opts.payload);
548
885
  if (canonical !== null) {
549
- const expectedCanonical = computeHmac(opts.secret, `${parsed.timestamp}.${canonical}`);
550
- if (constantTimeMatch(expectedCanonical, parsed.signatures)) {
551
- return failure(
886
+ const expectedCanonical = computeHmac2(opts.secret, `${parsed.timestamp}.${canonical}`);
887
+ if (constantTimeMatch2(expectedCanonical, parsed.signatures)) {
888
+ return failure2(
552
889
  "body_mutated",
553
890
  "Signature mismatch with correct secret. Body was likely parsed and re-serialized by your framework."
554
891
  );
555
892
  }
556
893
  }
557
- return failure(
894
+ return failure2(
558
895
  "signature_mismatch",
559
896
  "Signature mismatch. Check your webhook secret matches the Stripe dashboard."
560
897
  );
561
898
  }
562
899
  function createStripeVerifier(opts) {
563
900
  return {
564
- provider: PROVIDER,
901
+ provider: PROVIDER2,
565
902
  verify: (event) => verifyStripeSignature({
566
903
  payload: event.body,
567
904
  header: getHeaderCaseInsensitive(event.headers, "stripe-signature"),
@@ -580,6 +917,14 @@ function parsePort(port) {
580
917
  }
581
918
  return parsed;
582
919
  }
920
+ function parseRetryCount(retry) {
921
+ if (retry === void 0) return 0;
922
+ const parsed = typeof retry === "number" ? retry : Number(retry);
923
+ if (!Number.isInteger(parsed) || parsed < 0 || parsed > 10) {
924
+ throw new Error(`Invalid retry count "${retry}". Expected an integer between 0 and 10.`);
925
+ }
926
+ return parsed;
927
+ }
583
928
  function buildVerifier(flags) {
584
929
  if (!flags.verify) return void 0;
585
930
  switch (flags.verify) {
@@ -589,8 +934,14 @@ function buildVerifier(flags) {
589
934
  }
590
935
  return createStripeVerifier({ secret: flags.secret });
591
936
  }
937
+ case "github": {
938
+ if (!flags.secret) {
939
+ throw new Error("--secret is required when --verify github is set");
940
+ }
941
+ return createGitHubVerifier({ secret: flags.secret });
942
+ }
592
943
  default:
593
- throw new Error(`Unknown --verify provider "${flags.verify}". Supported: stripe`);
944
+ throw new Error(`Unknown --verify provider "${flags.verify}". Supported: stripe, github`);
594
945
  }
595
946
  }
596
947
  async function stopServer(server) {
@@ -601,12 +952,12 @@ function printEventCapturedBestEffort(terminal, event, result) {
601
952
  try {
602
953
  terminal.printEventCaptured(event, result);
603
954
  } catch (error) {
604
- const message = error instanceof Error ? error.message : String(error);
605
- console.error(`Failed to print captured event: ${message}`);
955
+ console.error(`Failed to print captured event: ${errorMessage(error)}`);
606
956
  }
607
957
  }
608
958
  async function runListen(flags, deps = {}) {
609
959
  const port = parsePort(flags.port);
960
+ const retryCount = parseRetryCount(flags.retry);
610
961
  const verifier = buildVerifier(flags);
611
962
  const dbPath = defaultDbPath();
612
963
  const signals = deps.signals ?? process;
@@ -656,7 +1007,10 @@ async function runListen(flags, deps = {}) {
656
1007
  storage,
657
1008
  verifier,
658
1009
  forwardTo: flags.forwardTo,
659
- onEvent: (event, result) => printEventCapturedBestEffort(terminal, event, result)
1010
+ retryCount,
1011
+ onEvent: (event, result) => printEventCapturedBestEffort(terminal, event, result),
1012
+ onForwardError: (event, error) => terminal.printForwardError(event.id, error.message),
1013
+ onForwardRetry: (event, attempt, maxRetries, error) => terminal.printForwardRetry(event.id, attempt, maxRetries, error.message)
660
1014
  });
661
1015
  signals.on("SIGINT", onSignal);
662
1016
  signals.on("SIGTERM", onSignal);
@@ -680,18 +1034,27 @@ async function runListen(flags, deps = {}) {
680
1034
  throw error;
681
1035
  }
682
1036
  }
683
- var listenCommand = new Command("listen").description("Start receiving webhooks").option("-p, --port <port>", "Port to listen on", "4400").option("--verify <provider>", "Verify signatures (stripe)").option("--secret <secret>", "Webhook signing secret").option("--forward-to <url>", "Forward received webhooks to this URL").action(async (options) => {
1037
+ var listenCommand = new Command4("listen").description("Start receiving webhooks").option("-p, --port <port>", "Port to listen on", "4400").option("--verify <provider>", "Verify signatures (stripe, github)").option("--secret <secret>", "Webhook signing secret").option("--forward-to <url>", "Forward received webhooks to this URL").option("--retry <count>", "Retry failed forwards with exponential backoff", "0").addHelpText(
1038
+ "after",
1039
+ `
1040
+ Examples:
1041
+ hooklens listen
1042
+ hooklens listen -p 8080 --forward-to http://localhost:3000/webhook
1043
+ hooklens listen --verify stripe --secret whsec_xxx
1044
+ hooklens listen --verify github --secret ghsecret_xxx
1045
+ hooklens listen --forward-to http://localhost:3000/webhook --retry 3`
1046
+ ).action(async (options) => {
684
1047
  const terminal = createTerminal();
685
1048
  try {
686
1049
  await runListen(options, { terminal });
687
1050
  } catch (error) {
688
- terminal.printError(error instanceof Error ? error.message : String(error));
1051
+ terminal.printError(errorMessage(error));
689
1052
  process.exitCode = 1;
690
1053
  }
691
1054
  });
692
1055
 
693
1056
  // src/cli/list.ts
694
- import { Command as Command2 } from "commander";
1057
+ import { Command as Command5 } from "commander";
695
1058
  function parseLimit(limit) {
696
1059
  const raw = limit;
697
1060
  const parsed = typeof raw === "number" ? raw : Number(raw);
@@ -706,23 +1069,42 @@ async function runList(flags, deps = {}) {
706
1069
  const storage = createStorage(defaultDbPath());
707
1070
  try {
708
1071
  const events = storage.list(limit);
709
- terminal.printEventList(events);
1072
+ if (flags.json) {
1073
+ const out = deps.stdout ?? process.stdout;
1074
+ for (const event of events) {
1075
+ writeJsonLine(out, {
1076
+ id: event.id,
1077
+ timestamp: event.timestamp,
1078
+ method: event.method,
1079
+ path: event.path
1080
+ });
1081
+ }
1082
+ } else {
1083
+ terminal.printEventList(events);
1084
+ }
710
1085
  } finally {
711
1086
  storage.close();
712
1087
  }
713
1088
  }
714
- var listCommand = new Command2("list").description("Show received webhook events").option("-n, --limit <count>", "Number of events to show", "20").action(async (options) => {
1089
+ var listCommand = new Command5("list").description("Show received webhook events").option("-n, --limit <count>", "Number of events to show", "20").option("--json", "Output as newline-delimited JSON").addHelpText(
1090
+ "after",
1091
+ `
1092
+ Examples:
1093
+ hooklens list
1094
+ hooklens list -n 5
1095
+ hooklens list --json`
1096
+ ).action(async (options) => {
715
1097
  const terminal = createTerminal();
716
1098
  try {
717
1099
  await runList(options, { terminal });
718
1100
  } catch (error) {
719
- terminal.printError(error instanceof Error ? error.message : String(error));
1101
+ terminal.printError(errorMessage(error));
720
1102
  process.exitCode = 1;
721
1103
  }
722
1104
  });
723
1105
 
724
1106
  // src/cli/replay.ts
725
- import { Command as Command3 } from "commander";
1107
+ import { Command as Command6 } from "commander";
726
1108
  var DEFAULT_REPLAY_TARGET_URL = "http://localhost:3000/webhook";
727
1109
  function parseTargetUrl(targetUrl) {
728
1110
  const raw = targetUrl ?? DEFAULT_REPLAY_TARGET_URL;
@@ -743,40 +1125,53 @@ async function runReplay(eventId, flags, deps = {}) {
743
1125
  }
744
1126
  try {
745
1127
  const result = await forwardEvent(targetUrl, event);
746
- const body = result.body.length <= 200 ? result.body : `${result.body.slice(0, 197)}...`;
747
- terminal.printReplayResult({
748
- status: result.status,
749
- body
750
- });
1128
+ if (flags.json) {
1129
+ const out = deps.stdout ?? process.stdout;
1130
+ writeJsonLine(out, { status: result.status, body: result.body });
1131
+ } else {
1132
+ const body = result.body.length <= 200 ? result.body : `${result.body.slice(0, 197)}...`;
1133
+ terminal.printReplayResult({
1134
+ status: result.status,
1135
+ body
1136
+ });
1137
+ }
751
1138
  } catch (error) {
752
- const message = error instanceof Error ? error.message : String(error);
753
- throw new Error(`Failed to replay "${eventId}" to ${targetUrl}: ${message}`);
1139
+ throw new Error(`Failed to replay "${eventId}" to ${targetUrl}: ${errorMessage(error)}`);
754
1140
  }
755
1141
  } finally {
756
1142
  storage.close();
757
1143
  }
758
1144
  }
759
- var replayCommand = new Command3("replay").description("Replay a stored webhook event").argument("<event-id>", "ID of the event to replay").option("--to <url>", "Target URL to send the event to", DEFAULT_REPLAY_TARGET_URL).action(async (eventId, options) => {
1145
+ var replayCommand = new Command6("replay").description("Replay a stored webhook event").argument("<event-id>", "ID of the event to replay").option("--to <url>", "Target URL to send the event to", DEFAULT_REPLAY_TARGET_URL).option("--json", "Output as JSON").addHelpText(
1146
+ "after",
1147
+ `
1148
+ Examples:
1149
+ hooklens replay evt_abc123
1150
+ hooklens replay evt_abc123 --to http://localhost:8080/hook
1151
+ hooklens replay evt_abc123 --json`
1152
+ ).action(async (eventId, options) => {
760
1153
  const terminal = createTerminal();
761
1154
  try {
762
1155
  await runReplay(eventId, options, { terminal });
763
1156
  } catch (error) {
764
- terminal.printError(error instanceof Error ? error.message : String(error));
1157
+ terminal.printError(errorMessage(error));
765
1158
  process.exitCode = 1;
766
1159
  }
767
1160
  });
768
1161
 
769
1162
  // src/cli/index.ts
770
- var program = new Command4();
1163
+ var program = new Command7();
771
1164
  program.name("hooklens").description("Inspect, verify, and replay webhooks from your terminal").version("0.1.0");
772
1165
  program.addCommand(listenCommand);
773
1166
  program.addCommand(listCommand);
1167
+ program.addCommand(inspectCommand);
774
1168
  program.addCommand(replayCommand);
1169
+ program.addCommand(deleteCommand);
1170
+ program.addCommand(clearCommand);
775
1171
  try {
776
1172
  await program.parseAsync(process.argv);
777
1173
  } catch (error) {
778
- const message = error instanceof Error ? error.message : String(error);
779
- console.error(message);
1174
+ console.error(errorMessage(error));
780
1175
  process.exitCode = 1;
781
1176
  }
782
1177
  //# sourceMappingURL=index.js.map