hono-webhooks 0.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/src/index.ts ADDED
@@ -0,0 +1,418 @@
1
+ import type { StandardSchemaV1 } from '@standard-schema/spec'
2
+ import { type Context, type Env, Hono } from 'hono'
3
+ import { HTTPException } from 'hono/http-exception'
4
+ import type { ParamKeys, ParamKeyToRecord } from 'hono/types'
5
+
6
+ export type { StandardSchemaV1 }
7
+
8
+ /** HEAD is not listed: Hono answers HEAD requests with the matching GET route. */
9
+ export type WebhookMethod =
10
+ | 'GET'
11
+ | 'POST'
12
+ | 'PUT'
13
+ | 'PATCH'
14
+ | 'DELETE'
15
+ | 'OPTIONS'
16
+
17
+ /**
18
+ * How the request body is parsed before validation.
19
+ * - `auto` (default): by `Content-Type`. JSON types are parsed as JSON,
20
+ * `application/x-www-form-urlencoded` as a string record, anything else as text.
21
+ * - `json`, `form`, `text`: always parse that way, whatever the header says.
22
+ */
23
+ export type BodyType = 'auto' | 'json' | 'form' | 'text'
24
+
25
+ /** The part of the request (or the response) a schema validates. */
26
+ export type ValidationTarget =
27
+ | 'params'
28
+ | 'query'
29
+ | 'headers'
30
+ | 'body'
31
+ | 'response'
32
+
33
+ type Schema = StandardSchemaV1
34
+ type Out<S, Fallback> = S extends Schema
35
+ ? StandardSchemaV1.InferOutput<S>
36
+ : Fallback
37
+ type MaybePromise<T> = T | Promise<T>
38
+ type UnionToIntersection<U> = (
39
+ U extends unknown
40
+ ? (k: U) => void
41
+ : never
42
+ ) extends (k: infer I) => void
43
+ ? I
44
+ : never
45
+
46
+ /**
47
+ * Params typed from the path string: `/users/:id` gives `{ id: string }`,
48
+ * `/files/:name?` gives `{ name: string | undefined }`.
49
+ */
50
+ export type PathParams<P extends string> = [ParamKeys<P>] extends [never]
51
+ ? Record<string, never>
52
+ : UnionToIntersection<ParamKeyToRecord<ParamKeys<P>>>
53
+
54
+ export interface WebhookConfig<
55
+ P extends string = string,
56
+ TParams extends Schema | undefined = Schema | undefined,
57
+ TQuery extends Schema | undefined = Schema | undefined,
58
+ THeaders extends Schema | undefined = Schema | undefined,
59
+ TBody extends Schema | undefined = Schema | undefined,
60
+ TResponse extends Schema | undefined = Schema | undefined
61
+ > {
62
+ method: WebhookMethod
63
+ /** Hono path pattern, relative to where the webhooks are mounted. */
64
+ path: P
65
+ params?: TParams
66
+ query?: TQuery
67
+ /** Header names are lower-case. */
68
+ headers?: THeaders
69
+ body?: TBody
70
+ /**
71
+ * Checks what the handler returns. A mismatch is reported on
72
+ * `WebhookEvent.validation` and the response is still sent.
73
+ */
74
+ response?: TResponse
75
+ /** @default 'auto' */
76
+ bodyType?: BodyType
77
+ /** Shown by the CLI. */
78
+ description?: string
79
+ }
80
+
81
+ export interface WebhookInput<
82
+ E extends Env,
83
+ P extends string,
84
+ TParams,
85
+ TQuery,
86
+ THeaders,
87
+ TBody
88
+ > {
89
+ /** The Hono context: `c.env`, `c.var`, `c.header()`, `c.req.raw`, ... */
90
+ c: Context<E, P>
91
+ params: Out<TParams, PathParams<P>>
92
+ query: Out<TQuery, Record<string, string>>
93
+ headers: Out<THeaders, Record<string, string>>
94
+ /** `undefined` for an empty body when there is no body schema. */
95
+ body: Out<TBody, unknown>
96
+ /** The body exactly as received, for signature verification. */
97
+ rawBody: string
98
+ }
99
+
100
+ /**
101
+ * What a handler may return: a `Response` is sent as is, `undefined` becomes
102
+ * `204 No Content`, a string is sent as text and anything else as JSON.
103
+ */
104
+ export type WebhookResult<TResponse> = MaybePromise<
105
+ Response | (TResponse extends Schema ? Out<TResponse, never> : unknown)
106
+ >
107
+
108
+ export interface Webhook<
109
+ E extends Env = any,
110
+ P extends string = string,
111
+ TParams extends Schema | undefined = any,
112
+ TQuery extends Schema | undefined = any,
113
+ THeaders extends Schema | undefined = any,
114
+ TBody extends Schema | undefined = any,
115
+ TResponse extends Schema | undefined = any
116
+ > {
117
+ config: WebhookConfig<P, TParams, TQuery, THeaders, TBody, TResponse>
118
+ // Method syntax keeps the parameter bivariant, so any webhook fits Webhook<E>[].
119
+ handler(
120
+ input: WebhookInput<E, P, TParams, TQuery, THeaders, TBody>
121
+ ): WebhookResult<TResponse>
122
+ }
123
+
124
+ /** Reported once per request that reaches the webhooks, after the response is built. */
125
+ export interface WebhookEvent<E extends Env = any> {
126
+ c: Context<E>
127
+ /** The matched path pattern, e.g. `/users/:id`. `undefined` for a 404. */
128
+ route: string | undefined
129
+ method: string
130
+ /** The requested path. */
131
+ path: string
132
+ status: number
133
+ durationMs: number
134
+ /** Each part is set once it has been validated. */
135
+ params?: unknown
136
+ query?: unknown
137
+ headers?: unknown
138
+ body?: unknown
139
+ rawBody?: string
140
+ /** What the handler returned, unless it returned a `Response`. */
141
+ response?: unknown
142
+ /** Anything thrown: a `WebhookValidationError`, an `HTTPException` or a handler error. */
143
+ error?: unknown
144
+ /** Set when validation failed, including a response that failed its schema. */
145
+ validation?: {
146
+ target: ValidationTarget
147
+ issues: readonly StandardSchemaV1.Issue[]
148
+ }
149
+ }
150
+
151
+ export interface WebhooksOptions<E extends Env = any> {
152
+ /**
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.
156
+ */
157
+ onEvent?: (event: WebhookEvent<E>) => MaybePromise<void>
158
+ /**
159
+ * Turns an error into a response. Return nothing to use the default:
160
+ * `400 { error, issues }` for validation, the status of an `HTTPException`,
161
+ * and `500 { error: 'Internal Server Error' }` for anything else.
162
+ */
163
+ onError?: (error: unknown, c: Context<E>) => MaybePromise<Response | void>
164
+ }
165
+
166
+ export type WebhooksApp<E extends Env = any> = Hono<E> & {
167
+ /** The definitions, in the order they were passed. */
168
+ readonly webhooks: readonly Webhook<E>[]
169
+ }
170
+
171
+ /** Thrown when a request part fails its schema. Answers 400 by default. */
172
+ export class WebhookValidationError extends HTTPException {
173
+ constructor(
174
+ readonly target: Exclude<ValidationTarget, 'response'>,
175
+ readonly issues: readonly StandardSchemaV1.Issue[]
176
+ ) {
177
+ super(400, { message: `Invalid ${target}` })
178
+ this.name = 'WebhookValidationError'
179
+ }
180
+ }
181
+
182
+ function makeDefineWebhook<E extends Env>() {
183
+ return <
184
+ const P extends string,
185
+ TParams extends Schema | undefined = undefined,
186
+ TQuery extends Schema | undefined = undefined,
187
+ THeaders extends Schema | undefined = undefined,
188
+ TBody extends Schema | undefined = undefined,
189
+ TResponse extends Schema | undefined = undefined
190
+ >(
191
+ config: WebhookConfig<P, TParams, TQuery, THeaders, TBody, TResponse>,
192
+ handler: (
193
+ input: WebhookInput<E, P, TParams, TQuery, THeaders, TBody>
194
+ ) => WebhookResult<TResponse>
195
+ ): Webhook<E, P, TParams, TQuery, THeaders, TBody, TResponse> => ({
196
+ config,
197
+ handler
198
+ })
199
+ }
200
+
201
+ function detectBodyType(contentType = ''): Exclude<BodyType, 'auto'> {
202
+ if (/[/+]json\b/i.test(contentType)) return 'json'
203
+ if (/x-www-form-urlencoded/i.test(contentType)) return 'form'
204
+ return 'text'
205
+ }
206
+
207
+ function parseBody(raw: string, type: BodyType, contentType?: string) {
208
+ if (!raw) return undefined
209
+ const resolved = type === 'auto' ? detectBodyType(contentType) : type
210
+ if (resolved === 'text') return raw
211
+ if (resolved === 'form') return Object.fromEntries(new URLSearchParams(raw))
212
+ try {
213
+ return JSON.parse(raw)
214
+ } catch {
215
+ throw new WebhookValidationError('body', [{ message: 'Malformed JSON' }])
216
+ }
217
+ }
218
+
219
+ async function parse(
220
+ schema: Schema | undefined,
221
+ value: unknown,
222
+ target: Exclude<ValidationTarget, 'response'>
223
+ ) {
224
+ if (!schema) return value
225
+ const result = await schema['~standard'].validate(value)
226
+ if (result.issues) throw new WebhookValidationError(target, result.issues)
227
+ return result.value
228
+ }
229
+
230
+ function defaultErrorResponse(error: unknown, c: Context, log: boolean) {
231
+ if (error instanceof WebhookValidationError) {
232
+ return c.json({ error: error.message, issues: error.issues }, 400)
233
+ }
234
+ if (error instanceof HTTPException) {
235
+ return error.res ?? c.json({ error: error.message }, error.status)
236
+ }
237
+ if (log) console.error(error)
238
+ return c.json({ error: 'Internal Server Error' }, 500)
239
+ }
240
+
241
+ function report(c: Context, pending: MaybePromise<void>) {
242
+ if (!(pending instanceof Promise)) return
243
+ const handled = pending.catch((error) =>
244
+ console.error('hono-webhooks: onEvent failed', error)
245
+ )
246
+ try {
247
+ c.executionCtx.waitUntil(handled)
248
+ } catch {
249
+ // No execution context outside Workers-style runtimes; the promise still runs.
250
+ }
251
+ }
252
+
253
+ function isDynamic(path: string) {
254
+ return path.includes(':') || path.includes('*')
255
+ }
256
+
257
+ function makeCreateWebhooks<E extends Env>() {
258
+ return (
259
+ webhooks: readonly Webhook<E>[],
260
+ options: WebhooksOptions<E> = {}
261
+ ): WebhooksApp<E> => {
262
+ const app = new Hono<E>()
263
+ const methodsByPath = new Map<string, WebhookMethod[]>()
264
+
265
+ for (const { config } of webhooks) {
266
+ const methods = methodsByPath.get(config.path) ?? []
267
+ if (methods.includes(config.method)) {
268
+ throw new Error(
269
+ `hono-webhooks: ${config.method} ${config.path} is defined twice`
270
+ )
271
+ }
272
+ methods.push(config.method)
273
+ methodsByPath.set(config.path, methods)
274
+ }
275
+
276
+ type Run = (c: Context<E>, event: WebhookEvent<E>) => Promise<Response>
277
+ const handle = (route: string | undefined, run: Run) => {
278
+ return async (c: Context<E>) => {
279
+ const start = performance.now()
280
+ const event: WebhookEvent<E> = {
281
+ c,
282
+ route,
283
+ method: c.req.method,
284
+ path: c.req.path,
285
+ status: 0,
286
+ durationMs: 0
287
+ }
288
+ let res: Response
289
+ try {
290
+ res = await run(c, event)
291
+ } catch (error) {
292
+ event.error = error
293
+ if (error instanceof WebhookValidationError) {
294
+ event.validation = { target: error.target, issues: error.issues }
295
+ }
296
+ res =
297
+ (await options.onError?.(error, c)) ??
298
+ defaultErrorResponse(error, c as Context, !options.onError)
299
+ }
300
+ event.status = res.status
301
+ event.durationMs = performance.now() - start
302
+ if (options.onEvent) report(c as Context, options.onEvent(event))
303
+ return res
304
+ }
305
+ }
306
+
307
+ // Hono tries handlers in registration order, so static paths go first
308
+ // and win over a dynamic pattern that also matches.
309
+ const ordered = [...webhooks].sort(
310
+ (a, b) =>
311
+ Number(isDynamic(a.config.path)) - Number(isDynamic(b.config.path))
312
+ )
313
+
314
+ for (const { config, handler } of ordered) {
315
+ app.on(
316
+ config.method,
317
+ config.path,
318
+ 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')
322
+ event.rawBody = await c.req.text()
323
+ event.body = await parse(
324
+ config.body,
325
+ parseBody(
326
+ event.rawBody,
327
+ config.bodyType ?? 'auto',
328
+ c.req.header('content-type')
329
+ ),
330
+ 'body'
331
+ )
332
+
333
+ const result = await handler({
334
+ c,
335
+ params: event.params,
336
+ query: event.query,
337
+ headers: event.headers,
338
+ body: event.body,
339
+ rawBody: event.rawBody
340
+ } as WebhookInput<E, string, any, any, any, any>)
341
+
342
+ if (result instanceof Response) return result
343
+ event.response = result
344
+
345
+ if (config.response) {
346
+ const checked = await config.response['~standard'].validate(result)
347
+ if (checked.issues) {
348
+ event.validation = { target: 'response', issues: checked.issues }
349
+ }
350
+ }
351
+
352
+ if (result === undefined) return c.body(null, 204)
353
+ if (typeof result === 'string') {
354
+ return c.text(result, 200, {
355
+ 'content-type': 'text/plain; charset=UTF-8'
356
+ })
357
+ }
358
+ return c.json(result as object)
359
+ })
360
+ )
361
+ }
362
+
363
+ for (const [path, methods] of methodsByPath) {
364
+ app.all(
365
+ path,
366
+ handle(path, async (c) => {
367
+ c.header('Allow', methods.join(', '))
368
+ throw new HTTPException(405, {
369
+ message: `Method ${c.req.method} not allowed`
370
+ })
371
+ })
372
+ )
373
+ }
374
+
375
+ app.all(
376
+ '*',
377
+ handle(undefined, async () => {
378
+ throw new HTTPException(404, { message: 'Route not found' })
379
+ })
380
+ )
381
+
382
+ return Object.assign(app, { webhooks })
383
+ }
384
+ }
385
+
386
+ /**
387
+ * Defines one webhook. Types for `params`, `query`, `headers`, `body` and the
388
+ * return value come from the schemas; `params` falls back to the path.
389
+ *
390
+ * @example
391
+ * defineWebhook(
392
+ * { method: 'POST', path: '/orders/:id', body: z.object({ total: z.number() }) },
393
+ * async ({ params, body }) => ({ id: params.id, total: body.total })
394
+ * )
395
+ */
396
+ export const defineWebhook = makeDefineWebhook<any>()
397
+
398
+ /**
399
+ * Builds a Hono app that serves the webhooks. Mount it under a prefix:
400
+ * `app.route('/webhooks', createWebhooks([...]))`. Unknown paths below the
401
+ * prefix answer 404, known paths with the wrong method answer 405.
402
+ */
403
+ export const createWebhooks = makeCreateWebhooks<any>()
404
+
405
+ /**
406
+ * `defineWebhook` and `createWebhooks` bound to your Hono `Env`, so handlers
407
+ * see typed `c.env` and `c.var`.
408
+ *
409
+ * @example
410
+ * export const { defineWebhook, createWebhooks } =
411
+ * createWebhookFactory<{ Bindings: { STRIPE_SECRET: string } }>()
412
+ */
413
+ export function createWebhookFactory<E extends Env = any>() {
414
+ return {
415
+ defineWebhook: makeDefineWebhook<E>(),
416
+ createWebhooks: makeCreateWebhooks<E>()
417
+ }
418
+ }