hono-webhooks 0.5.1 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/index.ts CHANGED
@@ -3,6 +3,7 @@ import type {
3
3
  StandardSchemaV1
4
4
  } from '@standard-schema/spec'
5
5
  import { type Context, type Env, Hono } from 'hono'
6
+ import { bodyLimit } from 'hono/body-limit'
6
7
  import { HTTPException } from 'hono/http-exception'
7
8
  import { basePath } from 'hono/route'
8
9
  import type { ParamKeys } from 'hono/types'
@@ -67,9 +68,18 @@ export interface VerifyInput<E extends Env = any> {
67
68
  rawBody: string
68
69
  /** All request headers, lower-case names, not schema-validated. */
69
70
  headers: Record<string, string>
71
+ /**
72
+ * Records why the check failed and returns `false`:
73
+ * `return fail('timestamp-out-of-tolerance')`. The reason reaches
74
+ * `onEvent` (`validation.issues[0].message`), never the response.
75
+ */
76
+ fail(reason: string): false
70
77
  }
71
78
 
72
- /** Checks a request's signature. `hono-webhooks/signatures` has ready-made ones. */
79
+ /**
80
+ * Checks a request's signature. `hono-webhooks/signatures` has ready-made
81
+ * ones. Only `true` lets the request through.
82
+ */
73
83
  export type Verify<E extends Env = any> = (
74
84
  input: VerifyInput<E>
75
85
  ) => MaybePromise<boolean>
@@ -95,9 +105,14 @@ export interface WebhookConfig<
95
105
  * Checks the signature right after routing, before anything is validated
96
106
  * or parsed. Returning `false` answers `401 { error: 'Invalid signature' }`.
97
107
  *
108
+ * Required for every method but `GET`: pass `false` for a webhook that is
109
+ * public on purpose, or `createWebhooks` throws.
110
+ *
98
111
  * @example verify: shopify.verify((c) => c.env.SHOPIFY_SECRET)
99
112
  */
100
- verify?: Verify<E>
113
+ verify?: Verify<E> | false
114
+ /** Overrides `WebhooksOptions.maxBodyBytes` for this webhook. */
115
+ maxBodyBytes?: number
101
116
  /**
102
117
  * Checks what the handler returns. A mismatch is reported on
103
118
  * `WebhookEvent.validation` and the response is still sent.
@@ -225,8 +240,16 @@ export interface WebhooksOptions<E extends Env = any> {
225
240
  * @default true
226
241
  */
227
242
  logErrors?: boolean
243
+ /**
244
+ * Larger bodies answer `413 { error: 'Payload too large' }` before they
245
+ * are read. `Infinity` turns the limit off. Each webhook can override it.
246
+ * @default 1_048_576 (1 MiB)
247
+ */
248
+ maxBodyBytes?: number
228
249
  }
229
250
 
251
+ const DEFAULT_MAX_BODY_BYTES = 1024 * 1024
252
+
230
253
  export type WebhooksApp<E extends Env = any> = Hono<E> & {
231
254
  /** The definitions, in the order they were passed. */
232
255
  readonly webhooks: readonly Webhook<E>[]
@@ -298,7 +321,10 @@ async function parse(
298
321
 
299
322
  function defaultErrorResponse(error: unknown, c: Context, log: boolean) {
300
323
  if (error instanceof WebhookValidationError) {
301
- return c.json({ error: error.message, issues: error.issues }, error.status)
324
+ // Why a signature failed is for the operator (onEvent), not the caller.
325
+ return error.target === 'signature'
326
+ ? c.json({ error: error.message }, error.status)
327
+ : c.json({ error: error.message, issues: error.issues }, error.status)
302
328
  }
303
329
  if (error instanceof HTTPException) {
304
330
  return error.res ?? c.json({ error: error.message }, error.status)
@@ -332,6 +358,11 @@ function makeCreateWebhooks<E extends Env>() {
332
358
  const methodsByPath = new Map<string, WebhookMethod[]>()
333
359
 
334
360
  for (const { config } of webhooks) {
361
+ if (config.method !== 'GET' && config.verify === undefined) {
362
+ throw new Error(
363
+ `hono-webhooks: ${config.method} ${config.path} has no verify. Pass a verify function, or verify: false if it is public on purpose.`
364
+ )
365
+ }
335
366
  const methods = methodsByPath.get(config.path) ?? []
336
367
  if (methods.includes(config.method)) {
337
368
  throw new Error(
@@ -392,20 +423,37 @@ function makeCreateWebhooks<E extends Env>() {
392
423
  config.method,
393
424
  config.path,
394
425
  handle(config.path, async (c, event) => {
426
+ const maxSize =
427
+ config.maxBodyBytes ??
428
+ options.maxBodyBytes ??
429
+ DEFAULT_MAX_BODY_BYTES
430
+ if (maxSize !== Infinity) {
431
+ await bodyLimit({
432
+ maxSize,
433
+ onError: () => {
434
+ throw new HTTPException(413, { message: 'Payload too large' })
435
+ }
436
+ })(c, async () => {})
437
+ }
395
438
  // The signature is checked first, so unauthenticated callers only
396
439
  // ever see 401, never a description of the schemas.
397
440
  event.rawBody = await c.req.text()
398
- if (
399
- config.verify &&
400
- !(await config.verify({
441
+ if (config.verify) {
442
+ let reason = 'Signature does not match'
443
+ const ok = await config.verify({
401
444
  c,
402
445
  rawBody: event.rawBody,
403
- headers: c.req.header()
404
- }))
405
- ) {
406
- throw new WebhookValidationError('signature', [
407
- { message: 'Signature does not match' }
408
- ])
446
+ headers: c.req.header(),
447
+ fail: (why) => {
448
+ reason = why
449
+ return false
450
+ }
451
+ })
452
+ if (ok !== true) {
453
+ throw new WebhookValidationError('signature', [
454
+ { message: reason }
455
+ ])
456
+ }
409
457
  }
410
458
  type Parts = Record<string, unknown>
411
459
  event.params = (await parse(
@@ -492,7 +540,12 @@ function makeCreateWebhooks<E extends Env>() {
492
540
  *
493
541
  * @example
494
542
  * defineWebhook(
495
- * { method: 'POST', path: '/orders/:id', body: z.object({ total: z.number() }) },
543
+ * {
544
+ * method: 'POST',
545
+ * path: '/orders/:id',
546
+ * verify: shopify.verify((c) => c.env.SHOPIFY_SECRET),
547
+ * body: z.object({ total: z.number() })
548
+ * },
496
549
  * async ({ params, body }) => ({ id: params.id, total: body.total })
497
550
  * )
498
551
  */
@@ -501,7 +554,8 @@ export const defineWebhook = makeDefineWebhook<any>()
501
554
  /**
502
555
  * Builds a Hono app that serves the webhooks. Mount it under a prefix:
503
556
  * `app.route('/webhooks', createWebhooks([...]))`. Unknown paths below the
504
- * prefix answer 404, known paths with the wrong method answer 405.
557
+ * prefix answer 404, known paths with the wrong method answer 405. Throws
558
+ * for a duplicate route and for a non-GET webhook without `verify`.
505
559
  */
506
560
  export const createWebhooks = makeCreateWebhooks<any>()
507
561
 
@@ -555,6 +609,8 @@ export interface WebhookEventSummary {
555
609
  userAgent: string | null
556
610
  params: Record<string, unknown> | null
557
611
  query: Record<string, unknown> | null
612
+ /** Only the headers named in `SummaryOptions.headers`; absent without that option. */
613
+ headers?: Record<string, string>
558
614
  body: unknown
559
615
  response: unknown
560
616
  /** `null` for validation failures, which `validation` describes. */
@@ -567,15 +623,37 @@ export interface WebhookEventSummary {
567
623
  } | null
568
624
  }
569
625
 
626
+ /** What `summarizeEvent` keeps. Defaults keep everything but headers. */
627
+ export interface SummaryOptions {
628
+ /** `false` sets `body` and `response` to `null`. @default true */
629
+ body?: boolean
630
+ /** `false` sets `query` to `null`. @default true */
631
+ query?: boolean
632
+ /**
633
+ * Request headers to keep, lower-case, e.g. a delivery id such as
634
+ * `x-github-delivery` or `svix-id`. @default []
635
+ */
636
+ headers?: readonly string[]
637
+ }
638
+
570
639
  /**
571
- * Flattens an event for logging or storage. Request headers are left out on
572
- * purpose: they carry signatures and credentials.
640
+ * Flattens an event for logging or storage. Request headers are left out
641
+ * unless named in `options.headers`: they carry signatures and credentials.
573
642
  *
574
643
  * @example
575
- * onEvent: (e) => db.insert(webhookCalls).values(summarizeEvent(e))
644
+ * onEvent: (e) => db.insert(webhookCalls).values(summarizeEvent(e, { body: false }))
576
645
  */
577
- export function summarizeEvent(event: WebhookEvent): WebhookEventSummary {
646
+ export function summarizeEvent(
647
+ event: WebhookEvent,
648
+ options: SummaryOptions = {}
649
+ ): WebhookEventSummary {
578
650
  const { c, error, validation } = event
651
+ const keepBody = options.body ?? true
652
+ const headers: Record<string, string> = {}
653
+ for (const name of options.headers ?? []) {
654
+ const value = c.req.header(name)
655
+ if (value !== undefined) headers[name] = value
656
+ }
579
657
  return {
580
658
  route: event.route ?? null,
581
659
  method: event.method,
@@ -589,9 +667,10 @@ export function summarizeEvent(event: WebhookEvent): WebhookEventSummary {
589
667
  null,
590
668
  userAgent: c.req.header('user-agent') ?? null,
591
669
  params: event.params ?? null,
592
- query: event.query ?? null,
593
- body: event.body ?? null,
594
- response: event.response ?? null,
670
+ query: options.query === false ? null : (event.query ?? null),
671
+ ...(options.headers && { headers }),
672
+ body: keepBody ? (event.body ?? null) : null,
673
+ response: keepBody ? (event.response ?? null) : null,
595
674
  // A validation failure is described by `validation`; its error adds only
596
675
  // a library stack trace.
597
676
  error:
@@ -621,7 +700,7 @@ export interface WebhookDescription {
621
700
  method: WebhookMethod
622
701
  path: string
623
702
  description: string | null
624
- /** Whether the webhook has a `verify` function. */
703
+ /** Whether the webhook has a `verify` function; `false` for `verify: false`. */
625
704
  verified: boolean
626
705
  bodyType: BodyType
627
706
  /**
@@ -695,8 +774,20 @@ export type TestWebhookInput<W> =
695
774
  * webhook's `bodyType` is `'form'`.
696
775
  */
697
776
  body?: In<TBody, unknown> | string
698
- /** Headers computed from the raw body, e.g. `(raw) => shopify.sign(raw, secret)`. */
699
- sign?: (rawBody: string) => MaybePromise<Record<string, string>>
777
+ /**
778
+ * Headers computed from the raw body and the request, e.g.
779
+ * `(raw) => shopify.sign(raw, secret)` or
780
+ * `(raw, { url }) => twilio.sign(raw, token, { url })`.
781
+ */
782
+ sign?: (
783
+ rawBody: string,
784
+ request: { url: string; method: string }
785
+ ) => MaybePromise<Record<string, string>>
786
+ /**
787
+ * Origin the request is sent to, for schemes that sign the URL.
788
+ * @default 'http://localhost'
789
+ */
790
+ baseUrl?: string
700
791
  /** Bindings, available as `c.env`. */
701
792
  env?: E extends { Bindings: infer B } ? B : unknown
702
793
  /** `onEvent`, `onError` and so on, as for `createWebhooks`. */
@@ -726,7 +817,13 @@ export async function testWebhook<W extends Webhook>(
726
817
  input: TestWebhookInput<W> = {} as TestWebhookInput<W>
727
818
  ): Promise<Response> {
728
819
  const { config } = webhook
729
- const { body, sign, env, options } = input as TestWebhookInput<Webhook>
820
+ const {
821
+ body,
822
+ sign,
823
+ env,
824
+ options,
825
+ baseUrl = 'http://localhost'
826
+ } = input as TestWebhookInput<Webhook>
730
827
  const headers = new Headers(input.headers)
731
828
  let rawBody: string | undefined
732
829
  if (typeof body === 'string') {
@@ -743,18 +840,31 @@ export async function testWebhook<W extends Webhook>(
743
840
  headers.set('content-type', 'application/json')
744
841
  }
745
842
  }
746
- for (const [name, value] of Object.entries(
747
- (await sign?.(rawBody ?? '')) ?? {}
748
- )) {
749
- headers.set(name, value)
750
- }
751
843
  const path =
752
844
  input.path ??
753
845
  fillPath(config.path, (input.params ?? {}) as Record<string, unknown>)
754
846
  const query = new URLSearchParams(input.query).toString()
847
+ const url = `${baseUrl.replace(/\/$/, '')}${path || '/'}${query ? `?${query}` : ''}`
848
+ for (const [name, value] of Object.entries(
849
+ (await sign?.(rawBody ?? '', { url, method: config.method })) ?? {}
850
+ )) {
851
+ headers.set(name, value)
852
+ }
755
853
  return createWebhooks([webhook], options).request(
756
- `${path || '/'}${query ? `?${query}` : ''}`,
854
+ url,
757
855
  { method: config.method, headers, body: rawBody },
758
856
  env as object | undefined
759
857
  )
760
858
  }
859
+
860
+ /**
861
+ * The webhook's path with `params` filled in, relative to the mount point:
862
+ * `webhookPath(oauthCallback, { provider: 'gmail' })` gives
863
+ * `/channels/gmail/callback`. Prepend your origin and mount prefix for a URL.
864
+ */
865
+ export function webhookPath<P extends string>(
866
+ webhook: Webhook<any, P>,
867
+ ...[params]: [ParamKeys<P>] extends [never] ? [] : [PathParams<P>]
868
+ ): string {
869
+ return fillPath(webhook.config.path, params ?? {}) || '/'
870
+ }
package/src/otel.ts CHANGED
@@ -9,6 +9,7 @@ import {
9
9
  } from '@opentelemetry/api'
10
10
  import type { Context, Env } from 'hono'
11
11
  import {
12
+ type SummaryOptions,
12
13
  summarizeEvent,
13
14
  type WebhookEvent,
14
15
  type WebhookEventSummary,
@@ -58,6 +59,8 @@ export interface WebhookCall extends WebhookEventSummary {
58
59
  /** `error` for 5xx, `warn` for 4xx, else `info`. */
59
60
  level: 'info' | 'warn' | 'error'
60
61
  outcome: 'success' | 'failed' | 'validation_failed'
62
+ /** The route is in `ignoreRoutes`: no metrics or log were recorded. */
63
+ ignored: boolean
61
64
  }
62
65
 
63
66
  /** `console` fits, as does any logger with these three methods. */
@@ -73,10 +76,12 @@ export interface OtelWebhooksOptions<E extends Env = any> {
73
76
  meter?: Meter
74
77
  /** Routes that get a span but no metrics, e.g. `['/health']`. */
75
78
  ignoreRoutes?: readonly string[]
79
+ /** What `call` keeps of the request, as for `summarizeEvent`. */
80
+ summary?: SummaryOptions
76
81
  /**
77
- * Called after every request, ignored routes included, with the span
78
- * still active: log it, store it. A returned promise is handled like
79
- * `onEvent`'s.
82
+ * Called after every request, ignored routes included (`call.ignored`),
83
+ * with the span still active: log it, store it. A returned promise is
84
+ * handled like `onEvent`'s.
80
85
  */
81
86
  onWebhookCall?: (
82
87
  call: WebhookCall,
@@ -85,7 +90,7 @@ export interface OtelWebhooksOptions<E extends Env = any> {
85
90
  /**
86
91
  * Logs one `webhook.completed` event per request at `call.level`, with
87
92
  * `route`, `method`, `status_code`, `duration_ms`, `status` (the outcome)
88
- * and `error_type`. 404s and `ignoreRoutes` are skipped. Called with the
93
+ * `error_type` and `error_message`. 404s and `ignoreRoutes` are skipped. Called with the
89
94
  * request's span, so the logger can attach to it.
90
95
  *
91
96
  * @example
@@ -135,9 +140,10 @@ export function otelWebhooks<E extends Env = any>(
135
140
  )
136
141
  },
137
142
  onEvent: (event) => {
138
- const summary = summarizeEvent(event)
143
+ const summary = summarizeEvent(event, options.summary)
139
144
  const success = summary.status < 400
140
145
  const call: WebhookCall = {
146
+ ignored: summary.route !== null && ignored.has(summary.route),
141
147
  ...summary,
142
148
  level: summary.status >= 500 ? 'error' : success ? 'info' : 'warn',
143
149
  outcome: summary.validation
@@ -163,8 +169,7 @@ export function otelWebhooks<E extends Env = any>(
163
169
  code: success ? SpanStatusCode.OK : SpanStatusCode.ERROR
164
170
  })
165
171
 
166
- const isIgnored = call.route !== null && ignored.has(call.route)
167
- if (!isIgnored) {
172
+ if (!call.ignored) {
168
173
  // Created lazily so a meter provider registered after import still applies.
169
174
  instruments ??= createInstruments(
170
175
  options.meter ?? metrics.getMeter('webhooks')
@@ -177,7 +182,7 @@ export function otelWebhooks<E extends Env = any>(
177
182
  instruments.duration.record(event.durationMs, attributes)
178
183
  }
179
184
 
180
- if (span && options.logger && call.route !== null && !isIgnored) {
185
+ if (span && options.logger && call.route !== null && !call.ignored) {
181
186
  options.logger(span)[call.level]('webhook.completed', {
182
187
  event: 'webhook.completed',
183
188
  route: call.route,
@@ -185,7 +190,10 @@ export function otelWebhooks<E extends Env = any>(
185
190
  status_code: call.status,
186
191
  duration_ms: call.durationMs,
187
192
  status: call.outcome,
188
- ...(call.error && { error_type: call.error.name })
193
+ ...(call.error && {
194
+ error_type: call.error.name,
195
+ error_message: call.error.message
196
+ })
189
197
  })
190
198
  }
191
199