hono-webhooks 0.1.1 → 0.3.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
@@ -1,7 +1,11 @@
1
- import type { StandardSchemaV1 } from '@standard-schema/spec'
1
+ import type {
2
+ StandardJSONSchemaV1,
3
+ StandardSchemaV1
4
+ } from '@standard-schema/spec'
2
5
  import { type Context, type Env, Hono } from 'hono'
3
6
  import { HTTPException } from 'hono/http-exception'
4
- import type { ParamKeys, ParamKeyToRecord } from 'hono/types'
7
+ import { basePath } from 'hono/route'
8
+ import type { ParamKeys } from 'hono/types'
5
9
 
6
10
  export type { StandardSchemaV1 }
7
11
 
@@ -27,6 +31,7 @@ export type ValidationTarget =
27
31
  | 'params'
28
32
  | 'query'
29
33
  | 'headers'
34
+ | 'signature'
30
35
  | 'body'
31
36
  | 'response'
32
37
 
@@ -43,13 +48,31 @@ type UnionToIntersection<U> = (
43
48
  ? I
44
49
  : never
45
50
 
51
+ type ParamRecord<K extends string> = K extends `${infer Name}?`
52
+ ? { [N in Name]?: string }
53
+ : { [N in K]: string }
54
+
46
55
  /**
47
56
  * Params typed from the path string: `/users/:id` gives `{ id: string }`,
48
- * `/files/:name?` gives `{ name: string | undefined }`.
57
+ * `/files/:name?` gives `{ name?: string }`.
49
58
  */
50
59
  export type PathParams<P extends string> = [ParamKeys<P>] extends [never]
51
60
  ? Record<string, never>
52
- : UnionToIntersection<ParamKeyToRecord<ParamKeys<P>>>
61
+ : UnionToIntersection<ParamRecord<ParamKeys<P>>>
62
+
63
+ /** What a `verify` function gets: the request before its body is parsed. */
64
+ export interface VerifyInput<E extends Env = any> {
65
+ c: Context<E>
66
+ /** The body exactly as received. */
67
+ rawBody: string
68
+ /** All request headers, lower-case names, not schema-validated. */
69
+ headers: Record<string, string>
70
+ }
71
+
72
+ /** Checks a request's signature. `hono-webhooks/signatures` has ready-made ones. */
73
+ export type Verify<E extends Env = any> = (
74
+ input: VerifyInput<E>
75
+ ) => MaybePromise<boolean>
53
76
 
54
77
  export interface WebhookConfig<
55
78
  P extends string = string,
@@ -57,7 +80,8 @@ export interface WebhookConfig<
57
80
  TQuery extends Schema | undefined = Schema | undefined,
58
81
  THeaders extends Schema | undefined = Schema | undefined,
59
82
  TBody extends Schema | undefined = Schema | undefined,
60
- TResponse extends Schema | undefined = Schema | undefined
83
+ TResponse extends Schema | undefined = Schema | undefined,
84
+ E extends Env = any
61
85
  > {
62
86
  method: WebhookMethod
63
87
  /** Hono path pattern, relative to where the webhooks are mounted. */
@@ -67,6 +91,13 @@ export interface WebhookConfig<
67
91
  /** Header names are lower-case. */
68
92
  headers?: THeaders
69
93
  body?: TBody
94
+ /**
95
+ * Checks the signature right after routing, before anything is validated
96
+ * or parsed. Returning `false` answers `401 { error: 'Invalid signature' }`.
97
+ *
98
+ * @example verify: shopify.verify((c) => c.env.SHOPIFY_SECRET)
99
+ */
100
+ verify?: Verify<E>
70
101
  /**
71
102
  * Checks what the handler returns. A mismatch is reported on
72
103
  * `WebhookEvent.validation` and the response is still sent.
@@ -114,7 +145,7 @@ export interface Webhook<
114
145
  TBody extends Schema | undefined = any,
115
146
  TResponse extends Schema | undefined = any
116
147
  > {
117
- config: WebhookConfig<P, TParams, TQuery, THeaders, TBody, TResponse>
148
+ config: WebhookConfig<P, TParams, TQuery, THeaders, TBody, TResponse, E>
118
149
  // Method syntax keeps the parameter bivariant, so any webhook fits Webhook<E>[].
119
150
  handler(
120
151
  input: WebhookInput<E, P, TParams, TQuery, THeaders, TBody>
@@ -127,14 +158,21 @@ export interface WebhookEvent<E extends Env = any> {
127
158
  /** The matched path pattern, e.g. `/users/:id`. `undefined` for a 404. */
128
159
  route: string | undefined
129
160
  method: string
130
- /** The requested path. */
161
+ /**
162
+ * The requested path relative to the mount point, e.g. `/orders/42`. The
163
+ * full path is `c.req.path`.
164
+ */
131
165
  path: string
132
166
  status: number
167
+ /** Fractional milliseconds from `performance.now()`. */
133
168
  durationMs: number
134
- /** Each part is set once it has been validated. */
135
- params?: unknown
136
- query?: unknown
137
- headers?: unknown
169
+ /**
170
+ * Each part is set once it has been validated: the schema's output, or
171
+ * the raw string record when there is no schema.
172
+ */
173
+ params?: Record<string, unknown>
174
+ query?: Record<string, unknown>
175
+ headers?: Record<string, unknown>
138
176
  body?: unknown
139
177
  rawBody?: string
140
178
  /** What the handler returned, unless it returned a `Response`. */
@@ -148,11 +186,31 @@ export interface WebhookEvent<E extends Env = any> {
148
186
  }
149
187
  }
150
188
 
189
+ /** What `around` knows about a request before it is handled. */
190
+ export interface WebhookRequestInfo<E extends Env = any> {
191
+ c: Context<E>
192
+ /** The matched path pattern, e.g. `/users/:id`. `undefined` for a 404. */
193
+ route: string | undefined
194
+ method: string
195
+ }
196
+
151
197
  export interface WebhooksOptions<E extends Env = any> {
152
198
  /**
153
- * Called after every request: log it, store it, record metrics. Not
154
- * awaited; a returned promise is handed to `executionCtx.waitUntil` when
155
- * the runtime has one, and its rejection is logged.
199
+ * Wraps each request, 404s and 405s included, with the route already
200
+ * known: start a span, set up `AsyncLocalStorage`, time it. Call `next()`
201
+ * to handle the request (which also runs `onEvent`) and return its
202
+ * response.
203
+ */
204
+ around?: (
205
+ info: WebhookRequestInfo<E>,
206
+ next: () => Promise<Response>
207
+ ) => Promise<Response>
208
+ /**
209
+ * Called after every request: log it, store it, record metrics. Runs
210
+ * inside the request's async context (and inside `around`), so the active
211
+ * span and `AsyncLocalStorage` values are available. Not awaited; a
212
+ * returned promise is handed to `executionCtx.waitUntil` when the runtime
213
+ * has one, and its rejection is logged.
156
214
  */
157
215
  onEvent?: (event: WebhookEvent<E>) => MaybePromise<void>
158
216
  /**
@@ -161,6 +219,12 @@ export interface WebhooksOptions<E extends Env = any> {
161
219
  * and `500 { error: 'Internal Server Error' }` for anything else.
162
220
  */
163
221
  onError?: (error: unknown, c: Context<E>) => MaybePromise<Response | void>
222
+ /**
223
+ * Log unexpected errors with `console.error` when the default 500
224
+ * response is used. Turn off when `onEvent` already logs them.
225
+ * @default true
226
+ */
227
+ logErrors?: boolean
164
228
  }
165
229
 
166
230
  export type WebhooksApp<E extends Env = any> = Hono<E> & {
@@ -168,13 +232,18 @@ export type WebhooksApp<E extends Env = any> = Hono<E> & {
168
232
  readonly webhooks: readonly Webhook<E>[]
169
233
  }
170
234
 
171
- /** Thrown when a request part fails its schema. Answers 400 by default. */
235
+ /**
236
+ * Thrown when a request part fails its schema (400) or the signature check
237
+ * fails (401).
238
+ */
172
239
  export class WebhookValidationError extends HTTPException {
173
240
  constructor(
174
241
  readonly target: Exclude<ValidationTarget, 'response'>,
175
242
  readonly issues: readonly StandardSchemaV1.Issue[]
176
243
  ) {
177
- super(400, { message: `Invalid ${target}` })
244
+ super(target === 'signature' ? 401 : 400, {
245
+ message: `Invalid ${target}`
246
+ })
178
247
  this.name = 'WebhookValidationError'
179
248
  }
180
249
  }
@@ -188,7 +257,7 @@ function makeDefineWebhook<E extends Env>() {
188
257
  TBody extends Schema | undefined = undefined,
189
258
  TResponse extends Schema | undefined = undefined
190
259
  >(
191
- config: WebhookConfig<P, TParams, TQuery, THeaders, TBody, TResponse>,
260
+ config: WebhookConfig<P, TParams, TQuery, THeaders, TBody, TResponse, E>,
192
261
  handler: (
193
262
  input: WebhookInput<E, P, TParams, TQuery, THeaders, TBody>
194
263
  ) => WebhookResult<TResponse>
@@ -229,7 +298,7 @@ async function parse(
229
298
 
230
299
  function defaultErrorResponse(error: unknown, c: Context, log: boolean) {
231
300
  if (error instanceof WebhookValidationError) {
232
- return c.json({ error: error.message, issues: error.issues }, 400)
301
+ return c.json({ error: error.message, issues: error.issues }, error.status)
233
302
  }
234
303
  if (error instanceof HTTPException) {
235
304
  return error.res ?? c.json({ error: error.message }, error.status)
@@ -275,13 +344,15 @@ function makeCreateWebhooks<E extends Env>() {
275
344
 
276
345
  type Run = (c: Context<E>, event: WebhookEvent<E>) => Promise<Response>
277
346
  const handle = (route: string | undefined, run: Run) => {
278
- return async (c: Context<E>) => {
347
+ const execute = async (c: Context<E>) => {
279
348
  const start = performance.now()
349
+ const base = basePath(c)
280
350
  const event: WebhookEvent<E> = {
281
351
  c,
282
352
  route,
283
353
  method: c.req.method,
284
- path: c.req.path,
354
+ path:
355
+ base === '/' ? c.req.path : c.req.path.slice(base.length) || '/',
285
356
  status: 0,
286
357
  durationMs: 0
287
358
  }
@@ -295,13 +366,18 @@ function makeCreateWebhooks<E extends Env>() {
295
366
  }
296
367
  res =
297
368
  (await options.onError?.(error, c)) ??
298
- defaultErrorResponse(error, c as Context, !options.onError)
369
+ defaultErrorResponse(error, c as Context, options.logErrors ?? true)
299
370
  }
300
371
  event.status = res.status
301
372
  event.durationMs = performance.now() - start
302
373
  if (options.onEvent) report(c as Context, options.onEvent(event))
303
374
  return res
304
375
  }
376
+ const { around } = options
377
+ return (c: Context<E>) =>
378
+ around
379
+ ? around({ c, route, method: c.req.method }, () => execute(c))
380
+ : execute(c)
305
381
  }
306
382
 
307
383
  // Hono tries handlers in registration order, so static paths go first
@@ -316,10 +392,37 @@ function makeCreateWebhooks<E extends Env>() {
316
392
  config.method,
317
393
  config.path,
318
394
  handle(config.path, async (c, event) => {
319
- event.params = await parse(config.params, c.req.param(), 'params')
320
- event.query = await parse(config.query, c.req.query(), 'query')
321
- event.headers = await parse(config.headers, c.req.header(), 'headers')
395
+ // The signature is checked first, so unauthenticated callers only
396
+ // ever see 401, never a description of the schemas.
322
397
  event.rawBody = await c.req.text()
398
+ if (
399
+ config.verify &&
400
+ !(await config.verify({
401
+ c,
402
+ rawBody: event.rawBody,
403
+ headers: c.req.header()
404
+ }))
405
+ ) {
406
+ throw new WebhookValidationError('signature', [
407
+ { message: 'Signature does not match' }
408
+ ])
409
+ }
410
+ type Parts = Record<string, unknown>
411
+ event.params = (await parse(
412
+ config.params,
413
+ c.req.param(),
414
+ 'params'
415
+ )) as Parts
416
+ event.query = (await parse(
417
+ config.query,
418
+ c.req.query(),
419
+ 'query'
420
+ )) as Parts
421
+ event.headers = (await parse(
422
+ config.headers,
423
+ c.req.header(),
424
+ 'headers'
425
+ )) as Parts
323
426
  event.body = await parse(
324
427
  config.body,
325
428
  parseBody(
@@ -416,3 +519,242 @@ export function createWebhookFactory<E extends Env = any>() {
416
519
  createWebhooks: makeCreateWebhooks<E>()
417
520
  }
418
521
  }
522
+
523
+ /**
524
+ * Validation issues as one line: `total: Expected number; items.0.id: Required`.
525
+ */
526
+ export function formatIssues(issues: readonly StandardSchemaV1.Issue[]) {
527
+ return issues
528
+ .map((issue) => {
529
+ const path = issuePath(issue)
530
+ return path ? `${path}: ${issue.message}` : issue.message
531
+ })
532
+ .join('; ')
533
+ }
534
+
535
+ function issuePath(issue: StandardSchemaV1.Issue) {
536
+ const path = issue.path
537
+ ?.map((segment) =>
538
+ String(typeof segment === 'object' ? segment.key : segment)
539
+ )
540
+ .join('.')
541
+ return path || null
542
+ }
543
+
544
+ /** A flat, JSON-safe record of a `WebhookEvent`, ready to log or store. */
545
+ export interface WebhookEventSummary {
546
+ route: string | null
547
+ method: string
548
+ /** Relative to the mount point. */
549
+ path: string
550
+ status: number
551
+ /** Rounded to two decimals. */
552
+ durationMs: number
553
+ /** From `x-forwarded-for`, `x-real-ip` or `cf-connecting-ip`; clients can spoof these. */
554
+ ip: string | null
555
+ userAgent: string | null
556
+ params: Record<string, unknown> | null
557
+ query: Record<string, unknown> | null
558
+ body: unknown
559
+ response: unknown
560
+ /** `null` for validation failures, which `validation` describes. */
561
+ error: { name: string; message: string; stack: string | null } | null
562
+ validation: {
563
+ target: ValidationTarget
564
+ /** `formatIssues(issues)` */
565
+ message: string
566
+ issues: { message: string; path: string | null }[]
567
+ } | null
568
+ }
569
+
570
+ /**
571
+ * Flattens an event for logging or storage. Request headers are left out on
572
+ * purpose: they carry signatures and credentials.
573
+ *
574
+ * @example
575
+ * onEvent: (e) => db.insert(webhookCalls).values(summarizeEvent(e))
576
+ */
577
+ export function summarizeEvent(event: WebhookEvent): WebhookEventSummary {
578
+ const { c, error, validation } = event
579
+ return {
580
+ route: event.route ?? null,
581
+ method: event.method,
582
+ path: event.path,
583
+ status: event.status,
584
+ durationMs: Math.round(event.durationMs * 100) / 100,
585
+ ip:
586
+ c.req.header('x-forwarded-for')?.split(',')[0]?.trim() ||
587
+ c.req.header('x-real-ip') ||
588
+ c.req.header('cf-connecting-ip') ||
589
+ null,
590
+ userAgent: c.req.header('user-agent') ?? null,
591
+ params: event.params ?? null,
592
+ query: event.query ?? null,
593
+ body: event.body ?? null,
594
+ response: event.response ?? null,
595
+ // A validation failure is described by `validation`; its error adds only
596
+ // a library stack trace.
597
+ error:
598
+ error === undefined || error instanceof WebhookValidationError
599
+ ? null
600
+ : error instanceof Error
601
+ ? {
602
+ name: error.name,
603
+ message: error.message,
604
+ stack: error.stack ?? null
605
+ }
606
+ : { name: 'Error', message: String(error), stack: null },
607
+ validation: validation
608
+ ? {
609
+ target: validation.target,
610
+ message: formatIssues(validation.issues),
611
+ issues: validation.issues.map((issue) => ({
612
+ message: issue.message,
613
+ path: issuePath(issue)
614
+ }))
615
+ }
616
+ : null
617
+ }
618
+ }
619
+
620
+ export interface WebhookDescription {
621
+ method: WebhookMethod
622
+ path: string
623
+ description: string | null
624
+ /** Whether the webhook has a `verify` function. */
625
+ verified: boolean
626
+ bodyType: BodyType
627
+ /**
628
+ * JSON Schema per part: the input side for request parts, the output side
629
+ * for `response`. `null` when the part has no schema or its library
630
+ * doesn't implement Standard JSON Schema.
631
+ */
632
+ jsonSchema: Record<
633
+ 'params' | 'query' | 'headers' | 'body' | 'response',
634
+ Record<string, unknown> | null
635
+ >
636
+ }
637
+
638
+ function toJsonSchema(
639
+ schema: Schema | undefined,
640
+ side: 'input' | 'output',
641
+ target: StandardJSONSchemaV1.Target
642
+ ) {
643
+ const props = (schema as Partial<StandardJSONSchemaV1> | undefined)?.[
644
+ '~standard'
645
+ ]
646
+ try {
647
+ return props?.jsonSchema?.[side]({ target }) ?? null
648
+ } catch {
649
+ return null
650
+ }
651
+ }
652
+
653
+ /**
654
+ * Describes webhooks for docs, admin pages or OpenAPI, with JSON Schemas from
655
+ * any library that implements Standard JSON Schema (Zod 4.2+, for example).
656
+ */
657
+ export function describeWebhooks(
658
+ source: WebhooksApp | readonly Webhook[],
659
+ { target = 'draft-2020-12' }: { target?: StandardJSONSchemaV1.Target } = {}
660
+ ): WebhookDescription[] {
661
+ const webhooks = 'webhooks' in source ? source.webhooks : source
662
+ return webhooks.map(({ config }) => ({
663
+ method: config.method,
664
+ path: config.path,
665
+ description: config.description ?? null,
666
+ verified: Boolean(config.verify),
667
+ bodyType: config.bodyType ?? 'auto',
668
+ jsonSchema: {
669
+ params: toJsonSchema(config.params, 'input', target),
670
+ query: toJsonSchema(config.query, 'input', target),
671
+ headers: toJsonSchema(config.headers, 'input', target),
672
+ body: toJsonSchema(config.body, 'input', target),
673
+ response: toJsonSchema(config.response, 'output', target)
674
+ }
675
+ }))
676
+ }
677
+
678
+ type In<S, Fallback> = S extends Schema
679
+ ? StandardSchemaV1.InferInput<S>
680
+ : Fallback
681
+
682
+ /** What `testWebhook` sends, typed from the webhook's schemas. */
683
+ export type TestWebhookInput<W> =
684
+ W extends Webhook<infer E, infer P, infer TParams, any, any, infer TBody>
685
+ ? {
686
+ /** Filled into the path pattern. */
687
+ params?: In<TParams, PathParams<P>>
688
+ /** Overrides the path, e.g. for wildcard patterns. */
689
+ path?: string
690
+ query?: Record<string, string>
691
+ headers?: Record<string, string>
692
+ /**
693
+ * A string is sent as is (`text/plain` unless you set a content
694
+ * type). Anything else is sent as JSON, or url-encoded when the
695
+ * webhook's `bodyType` is `'form'`.
696
+ */
697
+ 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>>
700
+ /** Bindings, available as `c.env`. */
701
+ env?: E extends { Bindings: infer B } ? B : unknown
702
+ /** `onEvent`, `onError` and so on, as for `createWebhooks`. */
703
+ options?: WebhooksOptions<E>
704
+ }
705
+ : never
706
+
707
+ function fillPath(pattern: string, params: Record<string, unknown>) {
708
+ return pattern.replace(/\/:(\w+)(?:\{[^}]*\})?\??/g, (_, name: string) => {
709
+ const value = params[name]
710
+ return value === undefined ? '' : `/${encodeURIComponent(String(value))}`
711
+ })
712
+ }
713
+
714
+ /**
715
+ * Sends one request through a single webhook, with routing, signature check,
716
+ * validation and response handling, and returns the `Response`.
717
+ *
718
+ * @example
719
+ * const res = await testWebhook(orderPaid, {
720
+ * params: { orderId: '42' },
721
+ * body: { amount: 10, currency: 'DKK' }
722
+ * })
723
+ */
724
+ export async function testWebhook<W extends Webhook>(
725
+ webhook: W,
726
+ input: TestWebhookInput<W> = {} as TestWebhookInput<W>
727
+ ): Promise<Response> {
728
+ const { config } = webhook
729
+ const { body, sign, env, options } = input as TestWebhookInput<Webhook>
730
+ const headers = new Headers(input.headers)
731
+ let rawBody: string | undefined
732
+ if (typeof body === 'string') {
733
+ rawBody = body
734
+ if (!headers.has('content-type')) headers.set('content-type', 'text/plain')
735
+ } else if (body !== undefined && config.bodyType === 'form') {
736
+ rawBody = new URLSearchParams(body as Record<string, string>).toString()
737
+ if (!headers.has('content-type')) {
738
+ headers.set('content-type', 'application/x-www-form-urlencoded')
739
+ }
740
+ } else if (body !== undefined) {
741
+ rawBody = JSON.stringify(body)
742
+ if (!headers.has('content-type')) {
743
+ headers.set('content-type', 'application/json')
744
+ }
745
+ }
746
+ for (const [name, value] of Object.entries(
747
+ (await sign?.(rawBody ?? '')) ?? {}
748
+ )) {
749
+ headers.set(name, value)
750
+ }
751
+ const path =
752
+ input.path ??
753
+ fillPath(config.path, (input.params ?? {}) as Record<string, unknown>)
754
+ const query = new URLSearchParams(input.query).toString()
755
+ return createWebhooks([webhook], options).request(
756
+ `${path || '/'}${query ? `?${query}` : ''}`,
757
+ { method: config.method, headers, body: rawBody },
758
+ env as object | undefined
759
+ )
760
+ }