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/LICENSE +21 -0
- package/README.md +587 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +137 -0
- package/dist/index.d.ts +143 -0
- package/dist/index.js +174 -0
- package/package.json +74 -0
- package/src/cli.ts +151 -0
- package/src/index.ts +418 -0
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
|
+
}
|