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/README.md +260 -37
- package/dist/cli.js +183 -35
- package/dist/index.d.ts +69 -11
- package/dist/index.js +60 -19
- package/dist/otel.d.ts +9 -5
- package/dist/otel.js +8 -5
- package/dist/signatures.d.ts +64 -0
- package/dist/signatures.js +171 -24
- package/package.json +2 -2
- package/src/cli-commands.ts +19 -4
- package/src/index.ts +141 -31
- package/src/otel.ts +17 -9
- package/src/signatures.ts +277 -33
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
|
-
/**
|
|
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
|
-
|
|
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
|
-
|
|
400
|
-
|
|
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
|
-
|
|
407
|
-
|
|
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
|
-
* {
|
|
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
|
|
572
|
-
*
|
|
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(
|
|
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
|
-
|
|
594
|
-
|
|
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
|
-
/**
|
|
699
|
-
|
|
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 {
|
|
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
|
-
|
|
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,
|
|
78
|
-
* still active: log it, store it. A returned promise is
|
|
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 `
|
|
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
|
-
|
|
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 && !
|
|
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 && {
|
|
193
|
+
...(call.error && {
|
|
194
|
+
error_type: call.error.name,
|
|
195
|
+
error_message: call.error.message
|
|
196
|
+
})
|
|
189
197
|
})
|
|
190
198
|
}
|
|
191
199
|
|