hooklens 1.0.0 → 1.1.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 +38 -19
- package/dist/index.js +341 -205
- package/dist/index.js.map +1 -1
- package/package.json +6 -3
package/dist/index.js
CHANGED
|
@@ -3,6 +3,93 @@
|
|
|
3
3
|
// src/cli/index.ts
|
|
4
4
|
import { Command as Command7 } from "commander";
|
|
5
5
|
|
|
6
|
+
// package.json
|
|
7
|
+
var package_default = {
|
|
8
|
+
name: "hooklens",
|
|
9
|
+
version: "1.1.0",
|
|
10
|
+
description: "Debug webhook signature failures locally.",
|
|
11
|
+
type: "module",
|
|
12
|
+
bin: {
|
|
13
|
+
hooklens: "./dist/index.js"
|
|
14
|
+
},
|
|
15
|
+
scripts: {
|
|
16
|
+
build: "tsup",
|
|
17
|
+
dev: "tsup --watch",
|
|
18
|
+
"docs:build": "vitepress build docs",
|
|
19
|
+
"docs:dev": "vitepress dev docs",
|
|
20
|
+
"docs:preview": "vitepress preview docs",
|
|
21
|
+
start: "node dist/index.js",
|
|
22
|
+
test: "vitest run",
|
|
23
|
+
"test:watch": "vitest",
|
|
24
|
+
"test:coverage": "vitest run --coverage",
|
|
25
|
+
lint: "eslint src/ tests/",
|
|
26
|
+
"lint:fix": "eslint src/ tests/ --fix",
|
|
27
|
+
format: 'prettier --write "src/**/*.ts" "tests/**/*.ts"',
|
|
28
|
+
"format:check": 'prettier --check "src/**/*.ts" "tests/**/*.ts"',
|
|
29
|
+
typecheck: "tsc --noEmit",
|
|
30
|
+
prepublishOnly: "npm run build",
|
|
31
|
+
prepare: "husky"
|
|
32
|
+
},
|
|
33
|
+
keywords: [
|
|
34
|
+
"webhook",
|
|
35
|
+
"debug",
|
|
36
|
+
"stripe",
|
|
37
|
+
"stripe-webhooks",
|
|
38
|
+
"github-webhooks",
|
|
39
|
+
"signature",
|
|
40
|
+
"webhook-signature",
|
|
41
|
+
"replay",
|
|
42
|
+
"cli",
|
|
43
|
+
"developer-tools"
|
|
44
|
+
],
|
|
45
|
+
author: "Ilia Goginashvili",
|
|
46
|
+
license: "MIT",
|
|
47
|
+
repository: {
|
|
48
|
+
type: "git",
|
|
49
|
+
url: "https://github.com/Ilia01/hooklens.git"
|
|
50
|
+
},
|
|
51
|
+
bugs: {
|
|
52
|
+
url: "https://github.com/Ilia01/hooklens/issues"
|
|
53
|
+
},
|
|
54
|
+
homepage: "https://ilia01.github.io/hooklens/",
|
|
55
|
+
engines: {
|
|
56
|
+
node: ">=24.0.0"
|
|
57
|
+
},
|
|
58
|
+
files: [
|
|
59
|
+
"dist",
|
|
60
|
+
"README.md",
|
|
61
|
+
"LICENSE"
|
|
62
|
+
],
|
|
63
|
+
devDependencies: {
|
|
64
|
+
"@octokit/webhooks-methods": "^6.0.0",
|
|
65
|
+
"@types/node": "^22.0.0",
|
|
66
|
+
eslint: "^9.0.0",
|
|
67
|
+
husky: "^9.1.7",
|
|
68
|
+
"lint-staged": "^16.4.0",
|
|
69
|
+
prettier: "^3.4.0",
|
|
70
|
+
stripe: "^22.0.0",
|
|
71
|
+
tsup: "^8.0.0",
|
|
72
|
+
typescript: "^5.7.0",
|
|
73
|
+
"typescript-eslint": "^8.0.0",
|
|
74
|
+
vitepress: "^1.6.4",
|
|
75
|
+
vitest: "^3.0.0"
|
|
76
|
+
},
|
|
77
|
+
dependencies: {
|
|
78
|
+
chalk: "^5.4.0",
|
|
79
|
+
commander: "^13.0.0",
|
|
80
|
+
zod: "^4.3.6"
|
|
81
|
+
},
|
|
82
|
+
"lint-staged": {
|
|
83
|
+
"*.{ts,tsx}": [
|
|
84
|
+
"prettier --write",
|
|
85
|
+
"eslint --fix"
|
|
86
|
+
],
|
|
87
|
+
"*.{json,md,yml,yaml}": [
|
|
88
|
+
"prettier --write"
|
|
89
|
+
]
|
|
90
|
+
}
|
|
91
|
+
};
|
|
92
|
+
|
|
6
93
|
// src/errors.ts
|
|
7
94
|
function toError(value) {
|
|
8
95
|
return value instanceof Error ? value : new Error(String(value));
|
|
@@ -14,6 +101,115 @@ function errorMessage(value) {
|
|
|
14
101
|
// src/cli/clear.ts
|
|
15
102
|
import { Command } from "commander";
|
|
16
103
|
|
|
104
|
+
// src/ui/terminal.ts
|
|
105
|
+
import chalk from "chalk";
|
|
106
|
+
function writeLine(stream, line) {
|
|
107
|
+
stream.write(`${line}
|
|
108
|
+
`);
|
|
109
|
+
}
|
|
110
|
+
function formatBodyForDisplay(event) {
|
|
111
|
+
if (event.bodyText !== null) {
|
|
112
|
+
try {
|
|
113
|
+
return JSON.stringify(JSON.parse(event.bodyText), null, 2).split("\n");
|
|
114
|
+
} catch {
|
|
115
|
+
return event.bodyText.split("\n");
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
const hex = Buffer.from(event.bodyRaw).toString("hex");
|
|
119
|
+
const lines = hex.match(/.{1,32}/g) ?? [];
|
|
120
|
+
return [`<binary body: ${event.bodyRaw.byteLength} bytes>`, ...lines];
|
|
121
|
+
}
|
|
122
|
+
function verificationLabel(result) {
|
|
123
|
+
if (!result) return chalk.cyan("RECV");
|
|
124
|
+
return result.valid ? chalk.green("PASS") : chalk.red("FAIL");
|
|
125
|
+
}
|
|
126
|
+
function createTerminal(stdout = process.stdout, stderr = process.stderr) {
|
|
127
|
+
return {
|
|
128
|
+
printListenStarted(info) {
|
|
129
|
+
writeLine(
|
|
130
|
+
stdout,
|
|
131
|
+
`${chalk.bold("Listening on")} ${chalk.cyan(`http://127.0.0.1:${info.port}`)}`
|
|
132
|
+
);
|
|
133
|
+
writeLine(stdout, `Verifier: ${info.verifier ?? "none"}`);
|
|
134
|
+
writeLine(stdout, `Forwarding to: ${info.forwardTo ?? "disabled"}`);
|
|
135
|
+
writeLine(stdout, `Storage: ${info.dbPath}`);
|
|
136
|
+
},
|
|
137
|
+
printEventCaptured(event, result) {
|
|
138
|
+
const label = verificationLabel(result);
|
|
139
|
+
const summary = `${label} ${chalk.bold(event.id)} ${event.method} ${event.path}`;
|
|
140
|
+
if (!result) {
|
|
141
|
+
writeLine(stdout, summary);
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
writeLine(stdout, `${summary} ${result.message}`);
|
|
145
|
+
},
|
|
146
|
+
printForwardError(eventId, reason) {
|
|
147
|
+
writeLine(stdout, `${chalk.red("FWD")} ${chalk.bold(eventId)} ${reason}`);
|
|
148
|
+
},
|
|
149
|
+
printForwardRetry(eventId, attempt, maxRetries, reason) {
|
|
150
|
+
writeLine(
|
|
151
|
+
stdout,
|
|
152
|
+
`${chalk.yellow("RETRY")} ${chalk.bold(eventId)} attempt ${attempt}/${maxRetries} ${reason}`
|
|
153
|
+
);
|
|
154
|
+
},
|
|
155
|
+
printEventList(events) {
|
|
156
|
+
if (!events.length) {
|
|
157
|
+
writeLine(stdout, chalk.dim("No stored events."));
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
for (const event of events) {
|
|
161
|
+
const row = `${chalk.dim(event.timestamp)} ${chalk.cyan(event.method)} ${chalk.bold(event.id)} ${event.path}`;
|
|
162
|
+
writeLine(stdout, row);
|
|
163
|
+
}
|
|
164
|
+
},
|
|
165
|
+
printEventDetail(event) {
|
|
166
|
+
writeLine(stdout, `${chalk.bold("Event:")} ${event.id}`);
|
|
167
|
+
writeLine(stdout, `${chalk.bold("Time:")} ${event.timestamp}`);
|
|
168
|
+
writeLine(stdout, `${chalk.bold("Method:")} ${event.method}`);
|
|
169
|
+
writeLine(stdout, `${chalk.bold("Path:")} ${event.path}`);
|
|
170
|
+
if (event.verification) {
|
|
171
|
+
const v = event.verification;
|
|
172
|
+
const label = v.valid ? chalk.green("PASS") : chalk.red("FAIL");
|
|
173
|
+
writeLine(stdout, "");
|
|
174
|
+
writeLine(stdout, chalk.bold("Verification:"));
|
|
175
|
+
writeLine(stdout, ` Result: ${label}`);
|
|
176
|
+
writeLine(stdout, ` Provider: ${v.provider}`);
|
|
177
|
+
writeLine(stdout, ` Message: ${v.message}`);
|
|
178
|
+
}
|
|
179
|
+
writeLine(stdout, "");
|
|
180
|
+
writeLine(stdout, chalk.bold("Headers:"));
|
|
181
|
+
for (const [key, value] of Object.entries(event.headers)) {
|
|
182
|
+
writeLine(stdout, ` ${key}: ${value}`);
|
|
183
|
+
}
|
|
184
|
+
writeLine(stdout, "");
|
|
185
|
+
writeLine(stdout, chalk.bold("Body:"));
|
|
186
|
+
for (const line of formatBodyForDisplay(event)) {
|
|
187
|
+
writeLine(stdout, ` ${line}`);
|
|
188
|
+
}
|
|
189
|
+
},
|
|
190
|
+
printReplayResult(result) {
|
|
191
|
+
const summary = `${chalk.bold("Replay response:")} ${chalk.cyan(String(result.status))}`;
|
|
192
|
+
if (!result.body) {
|
|
193
|
+
writeLine(stdout, summary);
|
|
194
|
+
return;
|
|
195
|
+
}
|
|
196
|
+
writeLine(stdout, `${summary} ${result.body}`);
|
|
197
|
+
},
|
|
198
|
+
printDeleted(eventId) {
|
|
199
|
+
writeLine(stdout, `Deleted ${chalk.bold(eventId)}`);
|
|
200
|
+
},
|
|
201
|
+
printCleared(count) {
|
|
202
|
+
writeLine(stdout, `Cleared ${chalk.bold(String(count))} events`);
|
|
203
|
+
},
|
|
204
|
+
printListenStopped() {
|
|
205
|
+
writeLine(stdout, chalk.dim("Stopped listening."));
|
|
206
|
+
},
|
|
207
|
+
printError(message) {
|
|
208
|
+
writeLine(stderr, chalk.red(message));
|
|
209
|
+
}
|
|
210
|
+
};
|
|
211
|
+
}
|
|
212
|
+
|
|
17
213
|
// src/storage/index.ts
|
|
18
214
|
import os from "os";
|
|
19
215
|
import fs from "fs";
|
|
@@ -41,7 +237,9 @@ var webhookEventSchema = z.object({
|
|
|
41
237
|
method: z.string(),
|
|
42
238
|
path: z.string(),
|
|
43
239
|
headers: z.record(z.string(), z.string()),
|
|
44
|
-
|
|
240
|
+
bodyRaw: z.custom((value) => value instanceof Uint8Array),
|
|
241
|
+
bodyText: z.string().nullable(),
|
|
242
|
+
bodyExact: z.boolean(),
|
|
45
243
|
verification: verificationResultSchema.nullable().optional()
|
|
46
244
|
});
|
|
47
245
|
var eventRowSchema = z.object({
|
|
@@ -50,13 +248,22 @@ var eventRowSchema = z.object({
|
|
|
50
248
|
method: z.string(),
|
|
51
249
|
path: z.string(),
|
|
52
250
|
headers: z.string(),
|
|
53
|
-
body: z.string(),
|
|
251
|
+
body: z.string().nullable().optional(),
|
|
252
|
+
body_raw: z.custom((value) => value === null || value === void 0 || value instanceof Uint8Array).optional(),
|
|
54
253
|
verification: z.string().nullable().optional()
|
|
55
254
|
});
|
|
56
255
|
var replayResultSchema = z.object({
|
|
57
256
|
status: z.number().int(),
|
|
58
257
|
body: z.string()
|
|
59
258
|
});
|
|
259
|
+
function tryDecodeUtf8(raw) {
|
|
260
|
+
if (raw.length === 0) return "";
|
|
261
|
+
const buf = Buffer.from(raw.buffer, raw.byteOffset, raw.byteLength);
|
|
262
|
+
const decoded = buf.toString("utf8");
|
|
263
|
+
const reEncoded = Buffer.from(decoded, "utf8");
|
|
264
|
+
if (buf.length !== reEncoded.length || !buf.equals(reEncoded)) return null;
|
|
265
|
+
return decoded;
|
|
266
|
+
}
|
|
60
267
|
|
|
61
268
|
// src/storage/index.ts
|
|
62
269
|
var require2 = createRequire(import.meta.url);
|
|
@@ -64,15 +271,42 @@ var { DatabaseSync } = require2("node:sqlite");
|
|
|
64
271
|
function defaultDbPath() {
|
|
65
272
|
return path.join(os.homedir(), ".hooklens", "events.db");
|
|
66
273
|
}
|
|
274
|
+
function eventColumns(db) {
|
|
275
|
+
const rows = db.prepare(`PRAGMA table_info(events)`).all();
|
|
276
|
+
return new Map(rows.map((row) => [row.name, row]));
|
|
277
|
+
}
|
|
278
|
+
function migrateEventsTable(db) {
|
|
279
|
+
const columns = eventColumns(db);
|
|
280
|
+
if (!columns.has("body_raw")) {
|
|
281
|
+
db.exec(`ALTER TABLE events ADD COLUMN body_raw BLOB`);
|
|
282
|
+
}
|
|
283
|
+
if (!columns.has("verification")) {
|
|
284
|
+
db.exec(`ALTER TABLE events ADD COLUMN verification TEXT`);
|
|
285
|
+
}
|
|
286
|
+
if (!columns.has("body")) {
|
|
287
|
+
db.exec(`ALTER TABLE events ADD COLUMN body TEXT`);
|
|
288
|
+
}
|
|
289
|
+
if (columns.has("body_text")) {
|
|
290
|
+
db.exec(`
|
|
291
|
+
UPDATE events
|
|
292
|
+
SET body = COALESCE(body, body_text)
|
|
293
|
+
WHERE body_text IS NOT NULL
|
|
294
|
+
`);
|
|
295
|
+
}
|
|
296
|
+
}
|
|
67
297
|
function rowToEvent(row) {
|
|
68
298
|
const verification = row.verification ? verificationResultSchema.parse(JSON.parse(row.verification)) : null;
|
|
299
|
+
const bodyRaw = row.body_raw ?? Buffer.from(row.body ?? "", "utf8");
|
|
300
|
+
const bodyText = row.body_raw ? tryDecodeUtf8(row.body_raw) : row.body ?? "";
|
|
69
301
|
return webhookEventSchema.parse({
|
|
70
302
|
id: row.id,
|
|
71
303
|
timestamp: row.timestamp,
|
|
72
304
|
method: row.method,
|
|
73
305
|
path: row.path,
|
|
74
306
|
headers: JSON.parse(row.headers),
|
|
75
|
-
|
|
307
|
+
bodyRaw,
|
|
308
|
+
bodyText,
|
|
309
|
+
bodyExact: row.body_raw !== null && row.body_raw !== void 0,
|
|
76
310
|
verification
|
|
77
311
|
});
|
|
78
312
|
}
|
|
@@ -86,20 +320,15 @@ function createStorage(dbPath) {
|
|
|
86
320
|
method TEXT NOT NULL,
|
|
87
321
|
path TEXT NOT NULL,
|
|
88
322
|
headers TEXT NOT NULL,
|
|
89
|
-
body TEXT
|
|
323
|
+
body TEXT,
|
|
324
|
+
body_raw BLOB,
|
|
90
325
|
verification TEXT
|
|
91
326
|
)
|
|
92
327
|
`);
|
|
93
|
-
|
|
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
|
-
}
|
|
328
|
+
migrateEventsTable(db);
|
|
100
329
|
const insertStmt = db.prepare(
|
|
101
|
-
`INSERT OR REPLACE INTO events (id, timestamp, method, path, headers, body, verification)
|
|
102
|
-
VALUES (?, ?, ?, ?, ?, ?, ?)`
|
|
330
|
+
`INSERT OR REPLACE INTO events (id, timestamp, method, path, headers, body, body_raw, verification)
|
|
331
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`
|
|
103
332
|
);
|
|
104
333
|
const getStmt = db.prepare(`SELECT * FROM events WHERE id = ?`);
|
|
105
334
|
const listAllStmt = db.prepare(`SELECT * FROM events ORDER BY timestamp DESC`);
|
|
@@ -114,7 +343,8 @@ function createStorage(dbPath) {
|
|
|
114
343
|
event.method,
|
|
115
344
|
event.path,
|
|
116
345
|
JSON.stringify(event.headers),
|
|
117
|
-
event.
|
|
346
|
+
event.bodyText ?? "",
|
|
347
|
+
event.bodyRaw,
|
|
118
348
|
event.verification ? JSON.stringify(event.verification) : null
|
|
119
349
|
);
|
|
120
350
|
},
|
|
@@ -146,107 +376,26 @@ function createStorage(dbPath) {
|
|
|
146
376
|
};
|
|
147
377
|
}
|
|
148
378
|
|
|
149
|
-
// src/
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
}
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
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));
|
|
379
|
+
// src/cli/runtime.ts
|
|
380
|
+
async function withDefaultStorage(run) {
|
|
381
|
+
const storage = createStorage(defaultDbPath());
|
|
382
|
+
try {
|
|
383
|
+
return await run(storage);
|
|
384
|
+
} finally {
|
|
385
|
+
try {
|
|
386
|
+
storage.close();
|
|
387
|
+
} catch {
|
|
248
388
|
}
|
|
249
|
-
}
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
async function runCommandAction(run) {
|
|
392
|
+
const terminal = createTerminal();
|
|
393
|
+
try {
|
|
394
|
+
await run(terminal);
|
|
395
|
+
} catch (error) {
|
|
396
|
+
terminal.printError(errorMessage(error));
|
|
397
|
+
process.exitCode = 1;
|
|
398
|
+
}
|
|
250
399
|
}
|
|
251
400
|
|
|
252
401
|
// src/cli/clear.ts
|
|
@@ -269,13 +418,10 @@ async function runClear(flags, deps = {}) {
|
|
|
269
418
|
return;
|
|
270
419
|
}
|
|
271
420
|
}
|
|
272
|
-
|
|
273
|
-
try {
|
|
421
|
+
return withDefaultStorage((storage) => {
|
|
274
422
|
const count = storage.clear();
|
|
275
423
|
terminal.printCleared(count);
|
|
276
|
-
}
|
|
277
|
-
storage.close();
|
|
278
|
-
}
|
|
424
|
+
});
|
|
279
425
|
}
|
|
280
426
|
var clearCommand = new Command("clear").description("Delete all stored webhook events").option("--yes", "Skip confirmation prompt").addHelpText(
|
|
281
427
|
"after",
|
|
@@ -284,29 +430,20 @@ Examples:
|
|
|
284
430
|
hooklens clear --yes
|
|
285
431
|
hooklens clear`
|
|
286
432
|
).action(async (options) => {
|
|
287
|
-
|
|
288
|
-
try {
|
|
289
|
-
await runClear(options, { terminal });
|
|
290
|
-
} catch (error) {
|
|
291
|
-
terminal.printError(errorMessage(error));
|
|
292
|
-
process.exitCode = 1;
|
|
293
|
-
}
|
|
433
|
+
await runCommandAction((terminal) => runClear(options, { terminal }));
|
|
294
434
|
});
|
|
295
435
|
|
|
296
436
|
// src/cli/delete.ts
|
|
297
437
|
import { Command as Command2 } from "commander";
|
|
298
438
|
async function runDelete(eventId, deps = {}) {
|
|
299
439
|
const terminal = deps.terminal ?? createTerminal();
|
|
300
|
-
|
|
301
|
-
try {
|
|
440
|
+
return withDefaultStorage((storage) => {
|
|
302
441
|
const deleted = storage.delete(eventId);
|
|
303
442
|
if (!deleted) {
|
|
304
443
|
throw new Error(`Event "${eventId}" not found.`);
|
|
305
444
|
}
|
|
306
445
|
terminal.printDeleted(eventId);
|
|
307
|
-
}
|
|
308
|
-
storage.close();
|
|
309
|
-
}
|
|
446
|
+
});
|
|
310
447
|
}
|
|
311
448
|
var deleteCommand = new Command2("delete").description("Delete a stored webhook event").argument("<event-id>", "ID of the event to delete").addHelpText(
|
|
312
449
|
"after",
|
|
@@ -314,24 +451,38 @@ var deleteCommand = new Command2("delete").description("Delete a stored webhook
|
|
|
314
451
|
Examples:
|
|
315
452
|
hooklens delete evt_abc123`
|
|
316
453
|
).action(async (eventId) => {
|
|
317
|
-
|
|
318
|
-
try {
|
|
319
|
-
await runDelete(eventId, { terminal });
|
|
320
|
-
} catch (error) {
|
|
321
|
-
terminal.printError(errorMessage(error));
|
|
322
|
-
process.exitCode = 1;
|
|
323
|
-
}
|
|
454
|
+
await runCommandAction((terminal) => runDelete(eventId, { terminal }));
|
|
324
455
|
});
|
|
325
456
|
|
|
326
457
|
// src/cli/inspect.ts
|
|
327
458
|
import { Command as Command3 } from "commander";
|
|
328
459
|
|
|
329
460
|
// src/cli/json-output.ts
|
|
461
|
+
function toJsonValue(value) {
|
|
462
|
+
if (value instanceof Uint8Array) {
|
|
463
|
+
return Buffer.from(value).toString("base64");
|
|
464
|
+
}
|
|
465
|
+
if (Array.isArray(value)) {
|
|
466
|
+
return value.map(toJsonValue);
|
|
467
|
+
}
|
|
468
|
+
if (value && typeof value === "object") {
|
|
469
|
+
const input = value;
|
|
470
|
+
const output = {};
|
|
471
|
+
for (const [key, entry] of Object.entries(input)) {
|
|
472
|
+
output[key] = toJsonValue(entry);
|
|
473
|
+
}
|
|
474
|
+
if ("bodyRaw" in input && "bodyText" in input && "bodyExact" in input && !("body" in output)) {
|
|
475
|
+
output.body = output.bodyText ?? null;
|
|
476
|
+
}
|
|
477
|
+
return output;
|
|
478
|
+
}
|
|
479
|
+
return value;
|
|
480
|
+
}
|
|
330
481
|
function writeJsonLine(stdout, data) {
|
|
331
|
-
const json = JSON.stringify(data);
|
|
482
|
+
const json = JSON.stringify(toJsonValue(data));
|
|
332
483
|
if (json === void 0) {
|
|
333
484
|
throw new Error(
|
|
334
|
-
"Cannot serialize value to JSON
|
|
485
|
+
"Cannot serialize value to JSON - received a non-serializable type (undefined, function, or symbol)"
|
|
335
486
|
);
|
|
336
487
|
}
|
|
337
488
|
stdout.write(json + "\n");
|
|
@@ -340,8 +491,7 @@ function writeJsonLine(stdout, data) {
|
|
|
340
491
|
// src/cli/inspect.ts
|
|
341
492
|
async function runInspect(eventId, flags, deps = {}) {
|
|
342
493
|
const terminal = deps.terminal ?? createTerminal();
|
|
343
|
-
|
|
344
|
-
try {
|
|
494
|
+
return withDefaultStorage((storage) => {
|
|
345
495
|
const event = storage.load(eventId);
|
|
346
496
|
if (!event) {
|
|
347
497
|
throw new Error(`Event "${eventId}" not found.`);
|
|
@@ -352,9 +502,7 @@ async function runInspect(eventId, flags, deps = {}) {
|
|
|
352
502
|
} else {
|
|
353
503
|
terminal.printEventDetail(event);
|
|
354
504
|
}
|
|
355
|
-
}
|
|
356
|
-
storage.close();
|
|
357
|
-
}
|
|
505
|
+
});
|
|
358
506
|
}
|
|
359
507
|
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
508
|
"after",
|
|
@@ -363,13 +511,7 @@ Examples:
|
|
|
363
511
|
hooklens inspect evt_abc123
|
|
364
512
|
hooklens inspect evt_abc123 --json`
|
|
365
513
|
).action(async (eventId, options) => {
|
|
366
|
-
|
|
367
|
-
try {
|
|
368
|
-
await runInspect(eventId, options, { terminal });
|
|
369
|
-
} catch (error) {
|
|
370
|
-
terminal.printError(errorMessage(error));
|
|
371
|
-
process.exitCode = 1;
|
|
372
|
-
}
|
|
514
|
+
await runCommandAction((terminal) => runInspect(eventId, options, { terminal }));
|
|
373
515
|
});
|
|
374
516
|
|
|
375
517
|
// src/cli/listen.ts
|
|
@@ -462,7 +604,7 @@ function readBody(req, maxBytes) {
|
|
|
462
604
|
}
|
|
463
605
|
chunks.push(chunk);
|
|
464
606
|
};
|
|
465
|
-
const onEnd = () => resolveOnce(Buffer.concat(chunks, totalBytes)
|
|
607
|
+
const onEnd = () => resolveOnce(Buffer.concat(chunks, totalBytes));
|
|
466
608
|
const onError = (error) => rejectOnce(error);
|
|
467
609
|
const onSocketClose = () => rejectOnce(new Error("socket closed during request body"));
|
|
468
610
|
const onSocketError = (error) => rejectOnce(error);
|
|
@@ -562,7 +704,7 @@ async function forwardEvent(targetUrl, event, timeoutMs = DEFAULT_FORWARD_TIMEOU
|
|
|
562
704
|
const response = await fetch(destination, {
|
|
563
705
|
method: event.method,
|
|
564
706
|
headers: headersForForwarding(event.headers),
|
|
565
|
-
body: hasBody ? event.
|
|
707
|
+
body: hasBody ? event.bodyRaw : void 0,
|
|
566
708
|
signal: controller.signal
|
|
567
709
|
});
|
|
568
710
|
return {
|
|
@@ -593,8 +735,8 @@ function cancellableDelay(ms, req) {
|
|
|
593
735
|
timer.unref();
|
|
594
736
|
});
|
|
595
737
|
}
|
|
596
|
-
function clampRetryCount(value) {
|
|
597
|
-
const n = value
|
|
738
|
+
function clampRetryCount(value = 0) {
|
|
739
|
+
const n = value;
|
|
598
740
|
if (!Number.isFinite(n) || !Number.isInteger(n)) return 0;
|
|
599
741
|
return Math.max(0, Math.min(n, 10));
|
|
600
742
|
}
|
|
@@ -608,16 +750,19 @@ function createServer(opts) {
|
|
|
608
750
|
const retryCount = clampRetryCount(opts.retryCount);
|
|
609
751
|
const retryBaseDelayMs = opts.retryBaseDelayMs ?? DEFAULT_RETRY_BASE_DELAY_MS;
|
|
610
752
|
const handleRequest = async (req, res) => {
|
|
611
|
-
const
|
|
753
|
+
const bodyRaw = await readBody(req, maxBodyBytes);
|
|
754
|
+
const bodyText = tryDecodeUtf8(bodyRaw);
|
|
612
755
|
const event = {
|
|
613
756
|
id: generateEventId(),
|
|
614
757
|
timestamp: (/* @__PURE__ */ new Date()).toISOString(),
|
|
615
758
|
method: req.method ?? "GET",
|
|
616
759
|
path: req.url ?? "/",
|
|
617
760
|
headers: headersToRecord(req.headers),
|
|
618
|
-
|
|
761
|
+
bodyRaw,
|
|
762
|
+
bodyText,
|
|
763
|
+
bodyExact: true
|
|
619
764
|
};
|
|
620
|
-
const verification = opts.verifier?.verify({ headers: event.headers,
|
|
765
|
+
const verification = opts.verifier?.verify({ headers: event.headers, bodyRaw }) ?? null;
|
|
621
766
|
event.verification = verification;
|
|
622
767
|
opts.storage.save(event);
|
|
623
768
|
opts.onEvent?.(event, verification);
|
|
@@ -736,9 +881,11 @@ function getHeaderCaseInsensitive(headers, name) {
|
|
|
736
881
|
return void 0;
|
|
737
882
|
}
|
|
738
883
|
function tryCanonicalForm(payload) {
|
|
884
|
+
const text = typeof payload === "string" ? payload : tryDecodeUtf8(payload);
|
|
885
|
+
if (text === null) return null;
|
|
739
886
|
try {
|
|
740
|
-
const canonical = JSON.stringify(JSON.parse(
|
|
741
|
-
return canonical ===
|
|
887
|
+
const canonical = JSON.stringify(JSON.parse(text));
|
|
888
|
+
return canonical === text ? null : canonical;
|
|
742
889
|
} catch {
|
|
743
890
|
return null;
|
|
744
891
|
}
|
|
@@ -804,7 +951,7 @@ function createGitHubVerifier(opts) {
|
|
|
804
951
|
return {
|
|
805
952
|
provider: PROVIDER,
|
|
806
953
|
verify: (event) => verifyGitHubSignature({
|
|
807
|
-
payload: event.
|
|
954
|
+
payload: event.bodyRaw,
|
|
808
955
|
header: getHeaderCaseInsensitive(event.headers, "x-hub-signature-256"),
|
|
809
956
|
secret: opts.secret
|
|
810
957
|
})
|
|
@@ -834,8 +981,13 @@ function parseHeader(header) {
|
|
|
834
981
|
if (timestamp === null || signatures.length === 0) return null;
|
|
835
982
|
return { timestamp, signatures };
|
|
836
983
|
}
|
|
837
|
-
function computeHmac2(secret,
|
|
838
|
-
return crypto3.createHmac("sha256", secret).update(
|
|
984
|
+
function computeHmac2(secret, signedPayload2) {
|
|
985
|
+
return crypto3.createHmac("sha256", secret).update(signedPayload2).digest("hex");
|
|
986
|
+
}
|
|
987
|
+
function signedPayload(timestamp, payload) {
|
|
988
|
+
const prefix = Buffer.from(`${timestamp}.`, "utf8");
|
|
989
|
+
const body = typeof payload === "string" ? Buffer.from(payload, "utf8") : Buffer.from(payload);
|
|
990
|
+
return Buffer.concat([prefix, body]);
|
|
839
991
|
}
|
|
840
992
|
function constantTimeMatch2(expected, candidates) {
|
|
841
993
|
const expectedBuf = Buffer.from(expected, "utf8");
|
|
@@ -876,14 +1028,13 @@ function verifyStripeSignature(opts) {
|
|
|
876
1028
|
`Timestamp is ${minutes} minutes old. Event expired or your clock is drifting.`
|
|
877
1029
|
);
|
|
878
1030
|
}
|
|
879
|
-
const
|
|
880
|
-
const expected = computeHmac2(opts.secret, signedPayload);
|
|
1031
|
+
const expected = computeHmac2(opts.secret, signedPayload(parsed.timestamp, opts.payload));
|
|
881
1032
|
if (constantTimeMatch2(expected, parsed.signatures)) {
|
|
882
1033
|
return success2("Signature verified.");
|
|
883
1034
|
}
|
|
884
1035
|
const canonical = tryCanonicalForm(opts.payload);
|
|
885
1036
|
if (canonical !== null) {
|
|
886
|
-
const expectedCanonical = computeHmac2(opts.secret,
|
|
1037
|
+
const expectedCanonical = computeHmac2(opts.secret, signedPayload(parsed.timestamp, canonical));
|
|
887
1038
|
if (constantTimeMatch2(expectedCanonical, parsed.signatures)) {
|
|
888
1039
|
return failure2(
|
|
889
1040
|
"body_mutated",
|
|
@@ -900,7 +1051,7 @@ function createStripeVerifier(opts) {
|
|
|
900
1051
|
return {
|
|
901
1052
|
provider: PROVIDER2,
|
|
902
1053
|
verify: (event) => verifyStripeSignature({
|
|
903
|
-
payload: event.
|
|
1054
|
+
payload: event.bodyRaw,
|
|
904
1055
|
header: getHeaderCaseInsensitive(event.headers, "stripe-signature"),
|
|
905
1056
|
secret: opts.secret,
|
|
906
1057
|
tolerance: opts.tolerance
|
|
@@ -908,6 +1059,12 @@ function createStripeVerifier(opts) {
|
|
|
908
1059
|
};
|
|
909
1060
|
}
|
|
910
1061
|
|
|
1062
|
+
// src/cli/defaults.ts
|
|
1063
|
+
var DEFAULT_LISTEN_PORT = 4400;
|
|
1064
|
+
var DEFAULT_LIST_LIMIT = 20;
|
|
1065
|
+
var DEFAULT_REPLAY_TARGET_URL = "http://localhost:3000/webhook";
|
|
1066
|
+
var DEFAULT_RETRY_COUNT = 0;
|
|
1067
|
+
|
|
911
1068
|
// src/cli/listen.ts
|
|
912
1069
|
function parsePort(port) {
|
|
913
1070
|
const raw = port;
|
|
@@ -918,7 +1075,7 @@ function parsePort(port) {
|
|
|
918
1075
|
return parsed;
|
|
919
1076
|
}
|
|
920
1077
|
function parseRetryCount(retry) {
|
|
921
|
-
if (retry === void 0) return
|
|
1078
|
+
if (retry === void 0) return DEFAULT_RETRY_COUNT;
|
|
922
1079
|
const parsed = typeof retry === "number" ? retry : Number(retry);
|
|
923
1080
|
if (!Number.isInteger(parsed) || parsed < 0 || parsed > 10) {
|
|
924
1081
|
throw new Error(`Invalid retry count "${retry}". Expected an integer between 0 and 10.`);
|
|
@@ -1034,7 +1191,11 @@ async function runListen(flags, deps = {}) {
|
|
|
1034
1191
|
throw error;
|
|
1035
1192
|
}
|
|
1036
1193
|
}
|
|
1037
|
-
var listenCommand = new Command4("listen").description("Start receiving webhooks").option("-p, --port <port>", "Port to listen on",
|
|
1194
|
+
var listenCommand = new Command4("listen").description("Start receiving webhooks").option("-p, --port <port>", "Port to listen on", String(DEFAULT_LISTEN_PORT)).option("--verify <provider>", "Verify signatures (stripe, github)").option("--secret <secret>", "Webhook signing secret").option("--forward-to <url>", "Forward received webhooks to this URL").option(
|
|
1195
|
+
"--retry <count>",
|
|
1196
|
+
"Retry failed forwards with exponential backoff",
|
|
1197
|
+
String(DEFAULT_RETRY_COUNT)
|
|
1198
|
+
).addHelpText(
|
|
1038
1199
|
"after",
|
|
1039
1200
|
`
|
|
1040
1201
|
Examples:
|
|
@@ -1044,13 +1205,7 @@ Examples:
|
|
|
1044
1205
|
hooklens listen --verify github --secret ghsecret_xxx
|
|
1045
1206
|
hooklens listen --forward-to http://localhost:3000/webhook --retry 3`
|
|
1046
1207
|
).action(async (options) => {
|
|
1047
|
-
|
|
1048
|
-
try {
|
|
1049
|
-
await runListen(options, { terminal });
|
|
1050
|
-
} catch (error) {
|
|
1051
|
-
terminal.printError(errorMessage(error));
|
|
1052
|
-
process.exitCode = 1;
|
|
1053
|
-
}
|
|
1208
|
+
await runCommandAction((terminal) => runListen(options, { terminal }));
|
|
1054
1209
|
});
|
|
1055
1210
|
|
|
1056
1211
|
// src/cli/list.ts
|
|
@@ -1064,10 +1219,9 @@ function parseLimit(limit) {
|
|
|
1064
1219
|
return parsed;
|
|
1065
1220
|
}
|
|
1066
1221
|
async function runList(flags, deps = {}) {
|
|
1067
|
-
const limit = parseLimit(flags.limit ??
|
|
1222
|
+
const limit = parseLimit(flags.limit ?? DEFAULT_LIST_LIMIT);
|
|
1068
1223
|
const terminal = deps.terminal ?? createTerminal();
|
|
1069
|
-
|
|
1070
|
-
try {
|
|
1224
|
+
return withDefaultStorage((storage) => {
|
|
1071
1225
|
const events = storage.list(limit);
|
|
1072
1226
|
if (flags.json) {
|
|
1073
1227
|
const out = deps.stdout ?? process.stdout;
|
|
@@ -1082,11 +1236,9 @@ async function runList(flags, deps = {}) {
|
|
|
1082
1236
|
} else {
|
|
1083
1237
|
terminal.printEventList(events);
|
|
1084
1238
|
}
|
|
1085
|
-
}
|
|
1086
|
-
storage.close();
|
|
1087
|
-
}
|
|
1239
|
+
});
|
|
1088
1240
|
}
|
|
1089
|
-
var listCommand = new Command5("list").description("Show received webhook events").option("-n, --limit <count>", "Number of events to show",
|
|
1241
|
+
var listCommand = new Command5("list").description("Show received webhook events").option("-n, --limit <count>", "Number of events to show", String(DEFAULT_LIST_LIMIT)).option("--json", "Output as newline-delimited JSON").addHelpText(
|
|
1090
1242
|
"after",
|
|
1091
1243
|
`
|
|
1092
1244
|
Examples:
|
|
@@ -1094,18 +1246,11 @@ Examples:
|
|
|
1094
1246
|
hooklens list -n 5
|
|
1095
1247
|
hooklens list --json`
|
|
1096
1248
|
).action(async (options) => {
|
|
1097
|
-
|
|
1098
|
-
try {
|
|
1099
|
-
await runList(options, { terminal });
|
|
1100
|
-
} catch (error) {
|
|
1101
|
-
terminal.printError(errorMessage(error));
|
|
1102
|
-
process.exitCode = 1;
|
|
1103
|
-
}
|
|
1249
|
+
await runCommandAction((terminal) => runList(options, { terminal }));
|
|
1104
1250
|
});
|
|
1105
1251
|
|
|
1106
1252
|
// src/cli/replay.ts
|
|
1107
1253
|
import { Command as Command6 } from "commander";
|
|
1108
|
-
var DEFAULT_REPLAY_TARGET_URL = "http://localhost:3000/webhook";
|
|
1109
1254
|
function parseTargetUrl(targetUrl) {
|
|
1110
1255
|
const raw = targetUrl ?? DEFAULT_REPLAY_TARGET_URL;
|
|
1111
1256
|
try {
|
|
@@ -1117,8 +1262,7 @@ function parseTargetUrl(targetUrl) {
|
|
|
1117
1262
|
async function runReplay(eventId, flags, deps = {}) {
|
|
1118
1263
|
const targetUrl = parseTargetUrl(flags.to);
|
|
1119
1264
|
const terminal = deps.terminal ?? createTerminal();
|
|
1120
|
-
|
|
1121
|
-
try {
|
|
1265
|
+
return withDefaultStorage(async (storage) => {
|
|
1122
1266
|
const event = storage.load(eventId);
|
|
1123
1267
|
if (!event) {
|
|
1124
1268
|
throw new Error(`Event "${eventId}" not found.`);
|
|
@@ -1138,9 +1282,7 @@ async function runReplay(eventId, flags, deps = {}) {
|
|
|
1138
1282
|
} catch (error) {
|
|
1139
1283
|
throw new Error(`Failed to replay "${eventId}" to ${targetUrl}: ${errorMessage(error)}`);
|
|
1140
1284
|
}
|
|
1141
|
-
}
|
|
1142
|
-
storage.close();
|
|
1143
|
-
}
|
|
1285
|
+
});
|
|
1144
1286
|
}
|
|
1145
1287
|
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
1288
|
"after",
|
|
@@ -1150,18 +1292,12 @@ Examples:
|
|
|
1150
1292
|
hooklens replay evt_abc123 --to http://localhost:8080/hook
|
|
1151
1293
|
hooklens replay evt_abc123 --json`
|
|
1152
1294
|
).action(async (eventId, options) => {
|
|
1153
|
-
|
|
1154
|
-
try {
|
|
1155
|
-
await runReplay(eventId, options, { terminal });
|
|
1156
|
-
} catch (error) {
|
|
1157
|
-
terminal.printError(errorMessage(error));
|
|
1158
|
-
process.exitCode = 1;
|
|
1159
|
-
}
|
|
1295
|
+
await runCommandAction((terminal) => runReplay(eventId, options, { terminal }));
|
|
1160
1296
|
});
|
|
1161
1297
|
|
|
1162
1298
|
// src/cli/index.ts
|
|
1163
1299
|
var program = new Command7();
|
|
1164
|
-
program.name("hooklens").description(
|
|
1300
|
+
program.name("hooklens").description(package_default.description).version(package_default.version);
|
|
1165
1301
|
program.addCommand(listenCommand);
|
|
1166
1302
|
program.addCommand(listCommand);
|
|
1167
1303
|
program.addCommand(inspectCommand);
|