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/README.md +23 -125
- package/dist/index.js +629 -234
- package/dist/index.js.map +1 -1
- package/package.json +6 -1
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
|
|
4
|
+
import { Command as Command7 } from "commander";
|
|
5
5
|
|
|
6
|
-
// src/
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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/
|
|
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
|
|
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
|
|
493
|
-
return
|
|
837
|
+
function computeHmac2(secret, signedPayload) {
|
|
838
|
+
return crypto3.createHmac("sha256", secret).update(signedPayload).digest("hex");
|
|
494
839
|
}
|
|
495
|
-
function
|
|
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 (
|
|
845
|
+
if (crypto3.timingSafeEqual(expectedBuf, candidateBuf)) return true;
|
|
501
846
|
}
|
|
502
847
|
return false;
|
|
503
848
|
}
|
|
504
|
-
function
|
|
505
|
-
|
|
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
|
|
516
|
-
return { valid: false, provider:
|
|
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
|
|
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
|
|
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
|
|
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 =
|
|
544
|
-
if (
|
|
545
|
-
return
|
|
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 =
|
|
550
|
-
if (
|
|
551
|
-
return
|
|
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
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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(
|
|
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
|
|
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
|
-
|
|
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
|
|
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(
|
|
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
|
|
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
|
-
|
|
747
|
-
|
|
748
|
-
status: result.status,
|
|
749
|
-
|
|
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
|
-
|
|
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
|
|
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(
|
|
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
|
|
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
|
-
|
|
779
|
-
console.error(message);
|
|
1174
|
+
console.error(errorMessage(error));
|
|
780
1175
|
process.exitCode = 1;
|
|
781
1176
|
}
|
|
782
1177
|
//# sourceMappingURL=index.js.map
|