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/README.md +344 -91
- package/dist/cli.js +491 -20
- package/dist/index.d.ts +172 -18
- package/dist/index.js +123 -7
- package/dist/signatures.d.ts +53 -0
- package/dist/signatures.js +132 -0
- package/package.json +9 -3
- package/src/cli.ts +129 -25
- package/src/index.ts +366 -24
- package/src/signatures.ts +240 -0
package/src/index.ts
CHANGED
|
@@ -1,7 +1,11 @@
|
|
|
1
|
-
import type {
|
|
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
|
|
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
|
|
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<
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
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
|
-
*
|
|
154
|
-
*
|
|
155
|
-
* the
|
|
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
|
-
/**
|
|
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(
|
|
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 },
|
|
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
|
-
|
|
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:
|
|
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,
|
|
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
|
-
|
|
320
|
-
|
|
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
|
+
}
|