hono-webhooks 0.1.0 → 0.2.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/cli.js CHANGED
@@ -1,35 +1,421 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  // src/cli.ts
4
+ import { existsSync } from "node:fs";
4
5
  import { resolve } from "node:path";
5
6
  import { pathToFileURL } from "node:url";
6
7
  import { parseArgs } from "node:util";
8
+
9
+ // src/index.ts
10
+ import { Hono } from "hono";
11
+ import { HTTPException } from "hono/http-exception";
12
+ import { basePath } from "hono/route";
13
+
14
+ class WebhookValidationError extends HTTPException {
15
+ target;
16
+ issues;
17
+ constructor(target, issues) {
18
+ super(target === "signature" ? 401 : 400, {
19
+ message: `Invalid ${target}`
20
+ });
21
+ this.target = target;
22
+ this.issues = issues;
23
+ this.name = "WebhookValidationError";
24
+ }
25
+ }
26
+ function makeDefineWebhook() {
27
+ return (config, handler) => ({
28
+ config,
29
+ handler
30
+ });
31
+ }
32
+ function detectBodyType(contentType = "") {
33
+ if (/[/+]json\b/i.test(contentType))
34
+ return "json";
35
+ if (/x-www-form-urlencoded/i.test(contentType))
36
+ return "form";
37
+ return "text";
38
+ }
39
+ function parseBody(raw, type, contentType) {
40
+ if (!raw)
41
+ return;
42
+ const resolved = type === "auto" ? detectBodyType(contentType) : type;
43
+ if (resolved === "text")
44
+ return raw;
45
+ if (resolved === "form")
46
+ return Object.fromEntries(new URLSearchParams(raw));
47
+ try {
48
+ return JSON.parse(raw);
49
+ } catch {
50
+ throw new WebhookValidationError("body", [{ message: "Malformed JSON" }]);
51
+ }
52
+ }
53
+ async function parse(schema, value, target) {
54
+ if (!schema)
55
+ return value;
56
+ const result = await schema["~standard"].validate(value);
57
+ if (result.issues)
58
+ throw new WebhookValidationError(target, result.issues);
59
+ return result.value;
60
+ }
61
+ function defaultErrorResponse(error, c, log) {
62
+ if (error instanceof WebhookValidationError) {
63
+ return c.json({ error: error.message, issues: error.issues }, error.status);
64
+ }
65
+ if (error instanceof HTTPException) {
66
+ return error.res ?? c.json({ error: error.message }, error.status);
67
+ }
68
+ if (log)
69
+ console.error(error);
70
+ return c.json({ error: "Internal Server Error" }, 500);
71
+ }
72
+ function report(c, pending) {
73
+ if (!(pending instanceof Promise))
74
+ return;
75
+ const handled = pending.catch((error) => console.error("hono-webhooks: onEvent failed", error));
76
+ try {
77
+ c.executionCtx.waitUntil(handled);
78
+ } catch {}
79
+ }
80
+ function isDynamic(path) {
81
+ return path.includes(":") || path.includes("*");
82
+ }
83
+ function makeCreateWebhooks() {
84
+ return (webhooks, options = {}) => {
85
+ const app = new Hono;
86
+ const methodsByPath = new Map;
87
+ for (const { config } of webhooks) {
88
+ const methods = methodsByPath.get(config.path) ?? [];
89
+ if (methods.includes(config.method)) {
90
+ throw new Error(`hono-webhooks: ${config.method} ${config.path} is defined twice`);
91
+ }
92
+ methods.push(config.method);
93
+ methodsByPath.set(config.path, methods);
94
+ }
95
+ const handle = (route, run) => {
96
+ const execute = async (c) => {
97
+ const start = performance.now();
98
+ const base = basePath(c);
99
+ const event = {
100
+ c,
101
+ route,
102
+ method: c.req.method,
103
+ path: base === "/" ? c.req.path : c.req.path.slice(base.length) || "/",
104
+ status: 0,
105
+ durationMs: 0
106
+ };
107
+ let res;
108
+ try {
109
+ res = await run(c, event);
110
+ } catch (error) {
111
+ event.error = error;
112
+ if (error instanceof WebhookValidationError) {
113
+ event.validation = { target: error.target, issues: error.issues };
114
+ }
115
+ res = await options.onError?.(error, c) ?? defaultErrorResponse(error, c, options.logErrors ?? true);
116
+ }
117
+ event.status = res.status;
118
+ event.durationMs = performance.now() - start;
119
+ if (options.onEvent)
120
+ report(c, options.onEvent(event));
121
+ return res;
122
+ };
123
+ const { around } = options;
124
+ return (c) => around ? around({ c, route, method: c.req.method }, () => execute(c)) : execute(c);
125
+ };
126
+ const ordered = [...webhooks].sort((a, b) => Number(isDynamic(a.config.path)) - Number(isDynamic(b.config.path)));
127
+ for (const { config, handler } of ordered) {
128
+ app.on(config.method, config.path, handle(config.path, async (c, event) => {
129
+ event.params = await parse(config.params, c.req.param(), "params");
130
+ event.query = await parse(config.query, c.req.query(), "query");
131
+ event.headers = await parse(config.headers, c.req.header(), "headers");
132
+ event.rawBody = await c.req.text();
133
+ if (config.verify && !await config.verify({
134
+ c,
135
+ rawBody: event.rawBody,
136
+ headers: c.req.header()
137
+ })) {
138
+ throw new WebhookValidationError("signature", [
139
+ { message: "Signature does not match" }
140
+ ]);
141
+ }
142
+ event.body = await parse(config.body, parseBody(event.rawBody, config.bodyType ?? "auto", c.req.header("content-type")), "body");
143
+ const result = await handler({
144
+ c,
145
+ params: event.params,
146
+ query: event.query,
147
+ headers: event.headers,
148
+ body: event.body,
149
+ rawBody: event.rawBody
150
+ });
151
+ if (result instanceof Response)
152
+ return result;
153
+ event.response = result;
154
+ if (config.response) {
155
+ const checked = await config.response["~standard"].validate(result);
156
+ if (checked.issues) {
157
+ event.validation = { target: "response", issues: checked.issues };
158
+ }
159
+ }
160
+ if (result === undefined)
161
+ return c.body(null, 204);
162
+ if (typeof result === "string") {
163
+ return c.text(result, 200, {
164
+ "content-type": "text/plain; charset=UTF-8"
165
+ });
166
+ }
167
+ return c.json(result);
168
+ }));
169
+ }
170
+ for (const [path, methods] of methodsByPath) {
171
+ app.all(path, handle(path, async (c) => {
172
+ c.header("Allow", methods.join(", "));
173
+ throw new HTTPException(405, {
174
+ message: `Method ${c.req.method} not allowed`
175
+ });
176
+ }));
177
+ }
178
+ app.all("*", handle(undefined, async () => {
179
+ throw new HTTPException(404, { message: "Route not found" });
180
+ }));
181
+ return Object.assign(app, { webhooks });
182
+ };
183
+ }
184
+ var defineWebhook = makeDefineWebhook();
185
+ var createWebhooks = makeCreateWebhooks();
186
+ function createWebhookFactory() {
187
+ return {
188
+ defineWebhook: makeDefineWebhook(),
189
+ createWebhooks: makeCreateWebhooks()
190
+ };
191
+ }
192
+ function formatIssues(issues) {
193
+ return issues.map((issue) => {
194
+ const path = issuePath(issue);
195
+ return path ? `${path}: ${issue.message}` : issue.message;
196
+ }).join("; ");
197
+ }
198
+ function issuePath(issue) {
199
+ const path = issue.path?.map((segment) => String(typeof segment === "object" ? segment.key : segment)).join(".");
200
+ return path || null;
201
+ }
202
+ function summarizeEvent(event) {
203
+ const { c, error, validation } = event;
204
+ return {
205
+ route: event.route ?? null,
206
+ method: event.method,
207
+ path: event.path,
208
+ status: event.status,
209
+ durationMs: Math.round(event.durationMs * 100) / 100,
210
+ ip: c.req.header("x-forwarded-for")?.split(",")[0]?.trim() || c.req.header("x-real-ip") || c.req.header("cf-connecting-ip") || null,
211
+ userAgent: c.req.header("user-agent") ?? null,
212
+ params: event.params ?? null,
213
+ query: event.query ?? null,
214
+ body: event.body ?? null,
215
+ response: event.response ?? null,
216
+ error: error === undefined ? null : error instanceof Error ? {
217
+ name: error.name,
218
+ message: error.message,
219
+ stack: error.stack ?? null
220
+ } : { name: "Error", message: String(error), stack: null },
221
+ validation: validation ? {
222
+ target: validation.target,
223
+ message: formatIssues(validation.issues),
224
+ issues: validation.issues.map((issue) => ({
225
+ message: issue.message,
226
+ path: issuePath(issue)
227
+ }))
228
+ } : null
229
+ };
230
+ }
231
+ function toJsonSchema(schema, side, target) {
232
+ const props = schema?.["~standard"];
233
+ try {
234
+ return props?.jsonSchema?.[side]({ target }) ?? null;
235
+ } catch {
236
+ return null;
237
+ }
238
+ }
239
+ function describeWebhooks(source, { target = "draft-2020-12" } = {}) {
240
+ const webhooks = "webhooks" in source ? source.webhooks : source;
241
+ return webhooks.map(({ config }) => ({
242
+ method: config.method,
243
+ path: config.path,
244
+ description: config.description ?? null,
245
+ verified: Boolean(config.verify),
246
+ bodyType: config.bodyType ?? "auto",
247
+ jsonSchema: {
248
+ params: toJsonSchema(config.params, "input", target),
249
+ query: toJsonSchema(config.query, "input", target),
250
+ headers: toJsonSchema(config.headers, "input", target),
251
+ body: toJsonSchema(config.body, "input", target),
252
+ response: toJsonSchema(config.response, "output", target)
253
+ }
254
+ }));
255
+ }
256
+ function fillPath(pattern, params) {
257
+ return pattern.replace(/\/:(\w+)(?:\{[^}]*\})?\??/g, (_, name) => {
258
+ const value = params[name];
259
+ return value === undefined ? "" : `/${encodeURIComponent(String(value))}`;
260
+ });
261
+ }
262
+ async function testWebhook(webhook, input = {}) {
263
+ const { config } = webhook;
264
+ const { body, sign, env, options } = input;
265
+ const headers = new Headers(input.headers);
266
+ let rawBody;
267
+ if (typeof body === "string") {
268
+ rawBody = body;
269
+ if (!headers.has("content-type"))
270
+ headers.set("content-type", "text/plain");
271
+ } else if (body !== undefined && config.bodyType === "form") {
272
+ rawBody = new URLSearchParams(body).toString();
273
+ if (!headers.has("content-type")) {
274
+ headers.set("content-type", "application/x-www-form-urlencoded");
275
+ }
276
+ } else if (body !== undefined) {
277
+ rawBody = JSON.stringify(body);
278
+ if (!headers.has("content-type")) {
279
+ headers.set("content-type", "application/json");
280
+ }
281
+ }
282
+ for (const [name, value] of Object.entries(await sign?.(rawBody ?? "") ?? {})) {
283
+ headers.set(name, value);
284
+ }
285
+ const path = input.path ?? fillPath(config.path, input.params ?? {});
286
+ const query = new URLSearchParams(input.query).toString();
287
+ return createWebhooks([webhook], options).request(`${path || "/"}${query ? `?${query}` : ""}`, { method: config.method, headers, body: rawBody }, env);
288
+ }
289
+
290
+ // src/signatures.ts
291
+ var encoder = new TextEncoder;
292
+ function hmacKey(secret, hash) {
293
+ if (!secret)
294
+ throw new Error("hono-webhooks: the signing secret is empty");
295
+ return crypto.subtle.importKey("raw", encoder.encode(secret), { name: "HMAC", hash }, false, ["sign", "verify"]);
296
+ }
297
+ async function hmacSign(secret, payload, hash = "SHA-256") {
298
+ const key = await hmacKey(secret, hash);
299
+ return new Uint8Array(await crypto.subtle.sign("HMAC", key, encoder.encode(payload)));
300
+ }
301
+ async function hmacVerify(secret, payload, signature, hash = "SHA-256") {
302
+ const key = await hmacKey(secret, hash);
303
+ return crypto.subtle.verify("HMAC", key, signature, encoder.encode(payload));
304
+ }
305
+ var toHex = (bytes) => Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("");
306
+ var toBase64 = (bytes) => btoa(String.fromCharCode(...bytes));
307
+ function fromHex(hex) {
308
+ if (hex.length % 2 || /[^0-9a-f]/i.test(hex))
309
+ return;
310
+ return Uint8Array.from(hex.match(/../g) ?? [], (h) => Number.parseInt(h, 16));
311
+ }
312
+ function fromBase64(base64) {
313
+ try {
314
+ return Uint8Array.from(atob(base64), (ch) => ch.charCodeAt(0));
315
+ } catch {
316
+ return;
317
+ }
318
+ }
319
+ var resolveSecret = async (secret, c) => typeof secret === "function" ? secret(c) : secret;
320
+ function hmac(options) {
321
+ const { header, encoding, prefix = "", hash = "SHA-256" } = options;
322
+ const name = header.toLowerCase();
323
+ return {
324
+ verify: (secret) => async ({ c, rawBody, headers }) => {
325
+ const value = headers[name];
326
+ if (!value?.startsWith(prefix))
327
+ return false;
328
+ const encoded = value.slice(prefix.length);
329
+ const signature = encoding === "hex" ? fromHex(encoded) : fromBase64(encoded);
330
+ if (!signature)
331
+ return false;
332
+ return hmacVerify(await resolveSecret(secret, c), rawBody, signature, hash);
333
+ },
334
+ async sign(rawBody, secret) {
335
+ const signature = await hmacSign(secret, rawBody, hash);
336
+ return {
337
+ [name]: `${prefix}${encoding === "hex" ? toHex(signature) : toBase64(signature)}`
338
+ };
339
+ }
340
+ };
341
+ }
342
+ var github = hmac({
343
+ header: "x-hub-signature-256",
344
+ encoding: "hex",
345
+ prefix: "sha256="
346
+ });
347
+ var shopify = hmac({
348
+ header: "x-shopify-hmac-sha256",
349
+ encoding: "base64"
350
+ });
351
+ var stripe = {
352
+ verify: (secret, { toleranceSeconds = 300 } = {}) => async ({ c, rawBody, headers }) => {
353
+ const parts = (headers["stripe-signature"] ?? "").split(",").map((part) => part.split("="));
354
+ const timestamp = parts.find(([k]) => k === "t")?.[1] ?? "";
355
+ if (!/^\d+$/.test(timestamp))
356
+ return false;
357
+ if (Math.abs(Date.now() / 1000 - Number(timestamp)) > toleranceSeconds) {
358
+ return false;
359
+ }
360
+ const key = await resolveSecret(secret, c);
361
+ for (const [k, v] of parts) {
362
+ const signature = k === "v1" && v ? fromHex(v) : undefined;
363
+ if (signature && await hmacVerify(key, `${timestamp}.${rawBody}`, signature)) {
364
+ return true;
365
+ }
366
+ }
367
+ return false;
368
+ },
369
+ async sign(rawBody, secret, { timestamp } = {}) {
370
+ const t = timestamp ?? Math.floor(Date.now() / 1000);
371
+ const signature = await hmacSign(secret, `${t}.${rawBody}`);
372
+ return { "stripe-signature": `t=${t},v1=${toHex(signature)}` };
373
+ }
374
+ };
375
+
376
+ // src/cli.ts
377
+ var schemes = {
378
+ github,
379
+ shopify,
380
+ stripe
381
+ };
7
382
  var HELP = `Usage: hono-webhooks <module> <command> [options]
8
383
 
9
- <module> exports the result of createWebhooks(), as default or named export.
384
+ <module> exports a createWebhooks() app or an array of webhooks, as default
385
+ or named export.
10
386
 
11
387
  Commands:
12
388
  list List the webhooks
13
389
  schema <path> Print the JSON Schemas of the webhooks at <path>
14
390
  test <method> <path> Send a request through the webhooks app
15
391
 
392
+ Options:
393
+ -r, --preload <module> Import a module first (e.g. to load secrets), repeatable
394
+
16
395
  Options for test:
17
396
  -b, --body <text> Request body (JSON sets content-type: application/json)
18
397
  -q, --query <params> Query string, e.g. "a=1&b=2"
19
398
  -H, --header <k: v> Request header, repeatable
399
+ -s, --sign <scheme> Sign the body: ${Object.keys(schemes).join(", ")}
400
+ -e, --secret-env <name> Environment variable holding the signing secret
20
401
 
21
402
  Examples:
22
403
  hono-webhooks ./src/webhooks.ts list
23
404
  hono-webhooks ./src/webhooks.ts schema /orders/:id
24
405
  hono-webhooks ./src/webhooks.ts test POST /orders/42 -b '{"total":10}'
406
+ hono-webhooks ./src/webhooks.ts test POST /shopify -b '{"id":1}' \\
407
+ -r ./src/secrets.ts --sign shopify --secret-env SHOPIFY_WEBHOOK_SECRET
25
408
  `;
26
409
  var { values, positionals } = parseArgs({
27
410
  allowPositionals: true,
28
411
  options: {
29
412
  help: { type: "boolean", short: "h" },
413
+ preload: { type: "string", short: "r", multiple: true },
30
414
  body: { type: "string", short: "b" },
31
415
  query: { type: "string", short: "q" },
32
- header: { type: "string", short: "H", multiple: true }
416
+ header: { type: "string", short: "H", multiple: true },
417
+ sign: { type: "string", short: "s" },
418
+ "secret-env": { type: "string", short: "e" }
33
419
  }
34
420
  });
35
421
  var [modulePath, command, ...args] = positionals;
@@ -37,31 +423,46 @@ function fail(message) {
37
423
  console.error(message);
38
424
  process.exit(1);
39
425
  }
426
+ var importModule = (specifier) => existsSync(resolve(specifier)) ? import(pathToFileURL(resolve(specifier)).href) : import(specifier);
40
427
  function isWebhooksApp(value) {
41
428
  return typeof value === "object" && value !== null && Array.isArray(value.webhooks) && typeof value.request === "function";
42
429
  }
430
+ function isWebhookArray(value) {
431
+ return Array.isArray(value) && value.length > 0 && value.every((item) => typeof item?.config?.path === "string" && typeof item?.handler === "function");
432
+ }
43
433
  async function loadApp(path) {
44
- const mod = await import(pathToFileURL(resolve(path)).href);
45
- const app = [mod.default, ...Object.values(mod)].find(isWebhooksApp);
46
- return app ?? fail(`${path} does not export a createWebhooks() app`);
434
+ const mod = await importModule(path);
435
+ const candidates = [mod.default, ...Object.values(mod)];
436
+ const app = candidates.find(isWebhooksApp);
437
+ if (app)
438
+ return app;
439
+ const webhooks = candidates.find(isWebhookArray);
440
+ if (webhooks)
441
+ return createWebhooks(webhooks);
442
+ return fail(`${path} exports neither a createWebhooks() app nor an array of webhooks`);
47
443
  }
48
- function jsonSchemaOf(schema) {
49
- const props = schema["~standard"];
50
- try {
51
- return props?.jsonSchema?.input({ target: "draft-2020-12" });
52
- } catch {
53
- return;
54
- }
444
+ async function signatureHeaders(rawBody) {
445
+ if (!values.sign)
446
+ return {};
447
+ const scheme = schemes[values.sign] ?? fail(`Unknown scheme "${values.sign}", use one of: ${Object.keys(schemes).join(", ")}`);
448
+ const name = values["secret-env"] ?? fail("--sign needs --secret-env <name>");
449
+ const secret = process.env[name] ?? fail(`${name} is not set`);
450
+ return scheme.sign(rawBody, secret);
55
451
  }
56
452
  async function main() {
57
453
  if (values.help || !modulePath || !command) {
58
454
  console.log(HELP);
59
455
  return;
60
456
  }
457
+ for (const preload of values.preload ?? [])
458
+ await importModule(preload);
61
459
  const app = await loadApp(modulePath);
62
460
  if (command === "list") {
63
461
  for (const { config } of app.webhooks) {
64
- const validates = ["params", "query", "headers", "body"].filter((key) => config[key]).join(", ");
462
+ const validates = [
463
+ ...["params", "query", "headers", "body"].filter((key) => config[key]),
464
+ ...config.verify ? ["signature"] : []
465
+ ].join(", ");
65
466
  console.log(`${config.method.padEnd(7)} ${config.path}${config.description ? ` ${config.description}` : ""}`);
66
467
  if (validates)
67
468
  console.log(` validates ${validates}`);
@@ -70,22 +471,24 @@ async function main() {
70
471
  }
71
472
  if (command === "schema") {
72
473
  const [path] = args;
73
- const matches = app.webhooks.filter((w) => w.config.path === path);
74
- if (!matches.length)
474
+ const descriptions = describeWebhooks(app);
475
+ const indexes = app.webhooks.flatMap((w, i) => w.config.path === path ? [i] : []);
476
+ if (!indexes.length)
75
477
  fail(`No webhook at ${path}`);
76
- for (const { config } of matches) {
478
+ for (const i of indexes) {
479
+ const { config } = app.webhooks[i];
77
480
  console.log(`${config.method} ${config.path}`);
78
- for (const key of [
481
+ for (const part of [
79
482
  "params",
80
483
  "query",
81
484
  "headers",
82
485
  "body",
83
486
  "response"
84
487
  ]) {
85
- if (!config[key])
488
+ if (!config[part])
86
489
  continue;
87
- const json = jsonSchemaOf(config[key]);
88
- console.log(` ${key}: ${json ? JSON.stringify(json, null, 2).replaceAll(`
490
+ const schema = descriptions[i].jsonSchema[part];
491
+ console.log(` ${part}: ${schema ? JSON.stringify(schema, null, 2).replaceAll(`
89
492
  `, `
90
493
  `) : "(no JSON Schema support)"}`);
91
494
  }
@@ -111,6 +514,9 @@ async function main() {
111
514
  headers.set("content-type", "text/plain");
112
515
  }
113
516
  }
517
+ for (const [name, value] of Object.entries(await signatureHeaders(values.body ?? ""))) {
518
+ headers.set(name, value);
519
+ }
114
520
  const url = `${path.startsWith("/") ? path : `/${path}`}${values.query ? `?${values.query}` : ""}`;
115
521
  const start = performance.now();
116
522
  const res = await app.request(url, {