hono-webhooks 0.1.1 → 0.2.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 +315 -91
- package/dist/cli.js +426 -20
- package/dist/index.d.ts +171 -18
- package/dist/index.js +122 -6
- package/dist/signatures.d.ts +40 -0
- package/dist/signatures.js +91 -0
- package/package.json +9 -3
- package/src/cli.ts +90 -25
- package/src/index.ts +361 -24
- package/src/signatures.ts +171 -0
package/dist/index.d.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
import type { StandardSchemaV1 } from '@standard-schema/spec';
|
|
1
|
+
import type { StandardJSONSchemaV1, StandardSchemaV1 } from '@standard-schema/spec';
|
|
2
2
|
import { type Context, type Env, Hono } from 'hono';
|
|
3
3
|
import { HTTPException } from 'hono/http-exception';
|
|
4
|
-
import type { ParamKeys
|
|
4
|
+
import type { ParamKeys } from 'hono/types';
|
|
5
5
|
export type { StandardSchemaV1 };
|
|
6
6
|
/** HEAD is not listed: Hono answers HEAD requests with the matching GET route. */
|
|
7
7
|
export type WebhookMethod = 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE' | 'OPTIONS';
|
|
@@ -13,17 +13,32 @@ export type WebhookMethod = 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE' | 'OPTIO
|
|
|
13
13
|
*/
|
|
14
14
|
export type BodyType = 'auto' | 'json' | 'form' | 'text';
|
|
15
15
|
/** The part of the request (or the response) a schema validates. */
|
|
16
|
-
export type ValidationTarget = 'params' | 'query' | 'headers' | 'body' | 'response';
|
|
16
|
+
export type ValidationTarget = 'params' | 'query' | 'headers' | 'signature' | 'body' | 'response';
|
|
17
17
|
type Schema = StandardSchemaV1;
|
|
18
18
|
type Out<S, Fallback> = S extends Schema ? StandardSchemaV1.InferOutput<S> : Fallback;
|
|
19
19
|
type MaybePromise<T> = T | Promise<T>;
|
|
20
20
|
type UnionToIntersection<U> = (U extends unknown ? (k: U) => void : never) extends (k: infer I) => void ? I : never;
|
|
21
|
+
type ParamRecord<K extends string> = K extends `${infer Name}?` ? {
|
|
22
|
+
[N in Name]?: string;
|
|
23
|
+
} : {
|
|
24
|
+
[N in K]: string;
|
|
25
|
+
};
|
|
21
26
|
/**
|
|
22
27
|
* Params typed from the path string: `/users/:id` gives `{ id: string }`,
|
|
23
|
-
* `/files/:name?` gives `{ name
|
|
28
|
+
* `/files/:name?` gives `{ name?: string }`.
|
|
24
29
|
*/
|
|
25
|
-
export type PathParams<P extends string> = [ParamKeys<P>] extends [never] ? Record<string, never> : UnionToIntersection<
|
|
26
|
-
|
|
30
|
+
export type PathParams<P extends string> = [ParamKeys<P>] extends [never] ? Record<string, never> : UnionToIntersection<ParamRecord<ParamKeys<P>>>;
|
|
31
|
+
/** What a `verify` function gets: the request before its body is parsed. */
|
|
32
|
+
export interface VerifyInput<E extends Env = any> {
|
|
33
|
+
c: Context<E>;
|
|
34
|
+
/** The body exactly as received. */
|
|
35
|
+
rawBody: string;
|
|
36
|
+
/** All request headers, lower-case names, not schema-validated. */
|
|
37
|
+
headers: Record<string, string>;
|
|
38
|
+
}
|
|
39
|
+
/** Checks a request's signature. `hono-webhooks/signatures` has ready-made ones. */
|
|
40
|
+
export type Verify<E extends Env = any> = (input: VerifyInput<E>) => MaybePromise<boolean>;
|
|
41
|
+
export interface WebhookConfig<P extends string = string, TParams extends Schema | undefined = Schema | undefined, TQuery extends Schema | undefined = Schema | undefined, THeaders extends Schema | undefined = Schema | undefined, TBody extends Schema | undefined = Schema | undefined, TResponse extends Schema | undefined = Schema | undefined, E extends Env = any> {
|
|
27
42
|
method: WebhookMethod;
|
|
28
43
|
/** Hono path pattern, relative to where the webhooks are mounted. */
|
|
29
44
|
path: P;
|
|
@@ -32,6 +47,13 @@ export interface WebhookConfig<P extends string = string, TParams extends Schema
|
|
|
32
47
|
/** Header names are lower-case. */
|
|
33
48
|
headers?: THeaders;
|
|
34
49
|
body?: TBody;
|
|
50
|
+
/**
|
|
51
|
+
* Checks the signature before the body is parsed or validated. Returning
|
|
52
|
+
* `false` answers `401 { error: 'Invalid signature' }`.
|
|
53
|
+
*
|
|
54
|
+
* @example verify: shopify.verify((c) => c.env.SHOPIFY_SECRET)
|
|
55
|
+
*/
|
|
56
|
+
verify?: Verify<E>;
|
|
35
57
|
/**
|
|
36
58
|
* Checks what the handler returns. A mismatch is reported on
|
|
37
59
|
* `WebhookEvent.validation` and the response is still sent.
|
|
@@ -59,7 +81,7 @@ export interface WebhookInput<E extends Env, P extends string, TParams, TQuery,
|
|
|
59
81
|
*/
|
|
60
82
|
export type WebhookResult<TResponse> = MaybePromise<Response | (TResponse extends Schema ? Out<TResponse, never> : unknown)>;
|
|
61
83
|
export interface Webhook<E extends Env = any, P extends string = string, TParams extends Schema | undefined = any, TQuery extends Schema | undefined = any, THeaders extends Schema | undefined = any, TBody extends Schema | undefined = any, TResponse extends Schema | undefined = any> {
|
|
62
|
-
config: WebhookConfig<P, TParams, TQuery, THeaders, TBody, TResponse>;
|
|
84
|
+
config: WebhookConfig<P, TParams, TQuery, THeaders, TBody, TResponse, E>;
|
|
63
85
|
handler(input: WebhookInput<E, P, TParams, TQuery, THeaders, TBody>): WebhookResult<TResponse>;
|
|
64
86
|
}
|
|
65
87
|
/** Reported once per request that reaches the webhooks, after the response is built. */
|
|
@@ -68,14 +90,21 @@ export interface WebhookEvent<E extends Env = any> {
|
|
|
68
90
|
/** The matched path pattern, e.g. `/users/:id`. `undefined` for a 404. */
|
|
69
91
|
route: string | undefined;
|
|
70
92
|
method: string;
|
|
71
|
-
/**
|
|
93
|
+
/**
|
|
94
|
+
* The requested path relative to the mount point, e.g. `/orders/42`. The
|
|
95
|
+
* full path is `c.req.path`.
|
|
96
|
+
*/
|
|
72
97
|
path: string;
|
|
73
98
|
status: number;
|
|
99
|
+
/** Fractional milliseconds from `performance.now()`. */
|
|
74
100
|
durationMs: number;
|
|
75
|
-
/**
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
101
|
+
/**
|
|
102
|
+
* Each part is set once it has been validated: the schema's output, or
|
|
103
|
+
* the raw string record when there is no schema.
|
|
104
|
+
*/
|
|
105
|
+
params?: Record<string, unknown>;
|
|
106
|
+
query?: Record<string, unknown>;
|
|
107
|
+
headers?: Record<string, unknown>;
|
|
79
108
|
body?: unknown;
|
|
80
109
|
rawBody?: string;
|
|
81
110
|
/** What the handler returned, unless it returned a `Response`. */
|
|
@@ -88,11 +117,27 @@ export interface WebhookEvent<E extends Env = any> {
|
|
|
88
117
|
issues: readonly StandardSchemaV1.Issue[];
|
|
89
118
|
};
|
|
90
119
|
}
|
|
120
|
+
/** What `around` knows about a request before it is handled. */
|
|
121
|
+
export interface WebhookRequestInfo<E extends Env = any> {
|
|
122
|
+
c: Context<E>;
|
|
123
|
+
/** The matched path pattern, e.g. `/users/:id`. `undefined` for a 404. */
|
|
124
|
+
route: string | undefined;
|
|
125
|
+
method: string;
|
|
126
|
+
}
|
|
91
127
|
export interface WebhooksOptions<E extends Env = any> {
|
|
92
128
|
/**
|
|
93
|
-
*
|
|
94
|
-
*
|
|
95
|
-
* the
|
|
129
|
+
* Wraps each request, 404s and 405s included, with the route already
|
|
130
|
+
* known: start a span, set up `AsyncLocalStorage`, time it. Call `next()`
|
|
131
|
+
* to handle the request (which also runs `onEvent`) and return its
|
|
132
|
+
* response.
|
|
133
|
+
*/
|
|
134
|
+
around?: (info: WebhookRequestInfo<E>, next: () => Promise<Response>) => Promise<Response>;
|
|
135
|
+
/**
|
|
136
|
+
* Called after every request: log it, store it, record metrics. Runs
|
|
137
|
+
* inside the request's async context (and inside `around`), so the active
|
|
138
|
+
* span and `AsyncLocalStorage` values are available. Not awaited; a
|
|
139
|
+
* returned promise is handed to `executionCtx.waitUntil` when the runtime
|
|
140
|
+
* has one, and its rejection is logged.
|
|
96
141
|
*/
|
|
97
142
|
onEvent?: (event: WebhookEvent<E>) => MaybePromise<void>;
|
|
98
143
|
/**
|
|
@@ -101,12 +146,21 @@ export interface WebhooksOptions<E extends Env = any> {
|
|
|
101
146
|
* and `500 { error: 'Internal Server Error' }` for anything else.
|
|
102
147
|
*/
|
|
103
148
|
onError?: (error: unknown, c: Context<E>) => MaybePromise<Response | void>;
|
|
149
|
+
/**
|
|
150
|
+
* Log unexpected errors with `console.error` when the default 500
|
|
151
|
+
* response is used. Turn off when `onEvent` already logs them.
|
|
152
|
+
* @default true
|
|
153
|
+
*/
|
|
154
|
+
logErrors?: boolean;
|
|
104
155
|
}
|
|
105
156
|
export type WebhooksApp<E extends Env = any> = Hono<E> & {
|
|
106
157
|
/** The definitions, in the order they were passed. */
|
|
107
158
|
readonly webhooks: readonly Webhook<E>[];
|
|
108
159
|
};
|
|
109
|
-
/**
|
|
160
|
+
/**
|
|
161
|
+
* Thrown when a request part fails its schema (400) or the signature check
|
|
162
|
+
* fails (401).
|
|
163
|
+
*/
|
|
110
164
|
export declare class WebhookValidationError extends HTTPException {
|
|
111
165
|
readonly target: Exclude<ValidationTarget, 'response'>;
|
|
112
166
|
readonly issues: readonly StandardSchemaV1.Issue[];
|
|
@@ -122,7 +176,7 @@ export declare class WebhookValidationError extends HTTPException {
|
|
|
122
176
|
* async ({ params, body }) => ({ id: params.id, total: body.total })
|
|
123
177
|
* )
|
|
124
178
|
*/
|
|
125
|
-
export declare const defineWebhook: <const P extends string, TParams extends Schema | undefined = undefined, TQuery extends Schema | undefined = undefined, THeaders extends Schema | undefined = undefined, TBody extends Schema | undefined = undefined, TResponse extends Schema | undefined = undefined>(config: WebhookConfig<P, TParams, TQuery, THeaders, TBody, TResponse>, handler: (input: WebhookInput<any, P, TParams, TQuery, THeaders, TBody>) => WebhookResult<TResponse>) => Webhook<any, P, TParams, TQuery, THeaders, TBody, TResponse>;
|
|
179
|
+
export declare const defineWebhook: <const P extends string, TParams extends Schema | undefined = undefined, TQuery extends Schema | undefined = undefined, THeaders extends Schema | undefined = undefined, TBody extends Schema | undefined = undefined, TResponse extends Schema | undefined = undefined>(config: WebhookConfig<P, TParams, TQuery, THeaders, TBody, TResponse, any>, handler: (input: WebhookInput<any, P, TParams, TQuery, THeaders, TBody>) => WebhookResult<TResponse>) => Webhook<any, P, TParams, TQuery, THeaders, TBody, TResponse>;
|
|
126
180
|
/**
|
|
127
181
|
* Builds a Hono app that serves the webhooks. Mount it under a prefix:
|
|
128
182
|
* `app.route('/webhooks', createWebhooks([...]))`. Unknown paths below the
|
|
@@ -138,6 +192,105 @@ export declare const createWebhooks: (webhooks: readonly Webhook<any, string, an
|
|
|
138
192
|
* createWebhookFactory<{ Bindings: { STRIPE_SECRET: string } }>()
|
|
139
193
|
*/
|
|
140
194
|
export declare function createWebhookFactory<E extends Env = any>(): {
|
|
141
|
-
defineWebhook: <const P extends string, TParams extends Schema | undefined = undefined, TQuery extends Schema | undefined = undefined, THeaders extends Schema | undefined = undefined, TBody extends Schema | undefined = undefined, TResponse extends Schema | undefined = undefined>(config: WebhookConfig<P, TParams, TQuery, THeaders, TBody, TResponse>, handler: (input: WebhookInput<E, P, TParams, TQuery, THeaders, TBody>) => WebhookResult<TResponse>) => Webhook<E, P, TParams, TQuery, THeaders, TBody, TResponse>;
|
|
195
|
+
defineWebhook: <const P extends string, TParams extends Schema | undefined = undefined, TQuery extends Schema | undefined = undefined, THeaders extends Schema | undefined = undefined, TBody extends Schema | undefined = undefined, TResponse extends Schema | undefined = undefined>(config: WebhookConfig<P, TParams, TQuery, THeaders, TBody, TResponse, E>, handler: (input: WebhookInput<E, P, TParams, TQuery, THeaders, TBody>) => WebhookResult<TResponse>) => Webhook<E, P, TParams, TQuery, THeaders, TBody, TResponse>;
|
|
142
196
|
createWebhooks: (webhooks: readonly Webhook<E, string, any, any, any, any, any>[], options?: WebhooksOptions<E>) => WebhooksApp<E>;
|
|
143
197
|
};
|
|
198
|
+
/**
|
|
199
|
+
* Validation issues as one line: `total: Expected number; items.0.id: Required`.
|
|
200
|
+
*/
|
|
201
|
+
export declare function formatIssues(issues: readonly StandardSchemaV1.Issue[]): string;
|
|
202
|
+
/** A flat, JSON-safe record of a `WebhookEvent`, ready to log or store. */
|
|
203
|
+
export interface WebhookEventSummary {
|
|
204
|
+
route: string | null;
|
|
205
|
+
method: string;
|
|
206
|
+
/** Relative to the mount point. */
|
|
207
|
+
path: string;
|
|
208
|
+
status: number;
|
|
209
|
+
/** Rounded to two decimals. */
|
|
210
|
+
durationMs: number;
|
|
211
|
+
/** From `x-forwarded-for`, `x-real-ip` or `cf-connecting-ip`; clients can spoof these. */
|
|
212
|
+
ip: string | null;
|
|
213
|
+
userAgent: string | null;
|
|
214
|
+
params: Record<string, unknown> | null;
|
|
215
|
+
query: Record<string, unknown> | null;
|
|
216
|
+
body: unknown;
|
|
217
|
+
response: unknown;
|
|
218
|
+
error: {
|
|
219
|
+
name: string;
|
|
220
|
+
message: string;
|
|
221
|
+
stack: string | null;
|
|
222
|
+
} | null;
|
|
223
|
+
validation: {
|
|
224
|
+
target: ValidationTarget;
|
|
225
|
+
/** `formatIssues(issues)` */
|
|
226
|
+
message: string;
|
|
227
|
+
issues: {
|
|
228
|
+
message: string;
|
|
229
|
+
path: string | null;
|
|
230
|
+
}[];
|
|
231
|
+
} | null;
|
|
232
|
+
}
|
|
233
|
+
/**
|
|
234
|
+
* Flattens an event for logging or storage. Request headers are left out on
|
|
235
|
+
* purpose: they carry signatures and credentials.
|
|
236
|
+
*
|
|
237
|
+
* @example
|
|
238
|
+
* onEvent: (e) => db.insert(webhookCalls).values(summarizeEvent(e))
|
|
239
|
+
*/
|
|
240
|
+
export declare function summarizeEvent(event: WebhookEvent): WebhookEventSummary;
|
|
241
|
+
export interface WebhookDescription {
|
|
242
|
+
method: WebhookMethod;
|
|
243
|
+
path: string;
|
|
244
|
+
description: string | null;
|
|
245
|
+
/** Whether the webhook has a `verify` function. */
|
|
246
|
+
verified: boolean;
|
|
247
|
+
bodyType: BodyType;
|
|
248
|
+
/**
|
|
249
|
+
* JSON Schema per part: the input side for request parts, the output side
|
|
250
|
+
* for `response`. `null` when the part has no schema or its library
|
|
251
|
+
* doesn't implement Standard JSON Schema.
|
|
252
|
+
*/
|
|
253
|
+
jsonSchema: Record<'params' | 'query' | 'headers' | 'body' | 'response', Record<string, unknown> | null>;
|
|
254
|
+
}
|
|
255
|
+
/**
|
|
256
|
+
* Describes webhooks for docs, admin pages or OpenAPI, with JSON Schemas from
|
|
257
|
+
* any library that implements Standard JSON Schema (Zod 4.2+, for example).
|
|
258
|
+
*/
|
|
259
|
+
export declare function describeWebhooks(source: WebhooksApp | readonly Webhook[], { target }?: {
|
|
260
|
+
target?: StandardJSONSchemaV1.Target;
|
|
261
|
+
}): WebhookDescription[];
|
|
262
|
+
type In<S, Fallback> = S extends Schema ? StandardSchemaV1.InferInput<S> : Fallback;
|
|
263
|
+
/** What `testWebhook` sends, typed from the webhook's schemas. */
|
|
264
|
+
export type TestWebhookInput<W> = W extends Webhook<infer E, infer P, infer TParams, any, any, infer TBody> ? {
|
|
265
|
+
/** Filled into the path pattern. */
|
|
266
|
+
params?: In<TParams, PathParams<P>>;
|
|
267
|
+
/** Overrides the path, e.g. for wildcard patterns. */
|
|
268
|
+
path?: string;
|
|
269
|
+
query?: Record<string, string>;
|
|
270
|
+
headers?: Record<string, string>;
|
|
271
|
+
/**
|
|
272
|
+
* A string is sent as is (`text/plain` unless you set a content
|
|
273
|
+
* type). Anything else is sent as JSON, or url-encoded when the
|
|
274
|
+
* webhook's `bodyType` is `'form'`.
|
|
275
|
+
*/
|
|
276
|
+
body?: In<TBody, unknown> | string;
|
|
277
|
+
/** Headers computed from the raw body, e.g. `(raw) => shopify.sign(raw, secret)`. */
|
|
278
|
+
sign?: (rawBody: string) => MaybePromise<Record<string, string>>;
|
|
279
|
+
/** Bindings, available as `c.env`. */
|
|
280
|
+
env?: E extends {
|
|
281
|
+
Bindings: infer B;
|
|
282
|
+
} ? B : unknown;
|
|
283
|
+
/** `onEvent`, `onError` and so on, as for `createWebhooks`. */
|
|
284
|
+
options?: WebhooksOptions<E>;
|
|
285
|
+
} : never;
|
|
286
|
+
/**
|
|
287
|
+
* Sends one request through a single webhook, with routing, signature check,
|
|
288
|
+
* validation and response handling, and returns the `Response`.
|
|
289
|
+
*
|
|
290
|
+
* @example
|
|
291
|
+
* const res = await testWebhook(orderPaid, {
|
|
292
|
+
* params: { orderId: '42' },
|
|
293
|
+
* body: { amount: 10, currency: 'DKK' }
|
|
294
|
+
* })
|
|
295
|
+
*/
|
|
296
|
+
export declare function testWebhook<W extends Webhook>(webhook: W, input?: TestWebhookInput<W>): Promise<Response>;
|
package/dist/index.js
CHANGED
|
@@ -1,12 +1,15 @@
|
|
|
1
1
|
// src/index.ts
|
|
2
2
|
import { Hono } from "hono";
|
|
3
3
|
import { HTTPException } from "hono/http-exception";
|
|
4
|
+
import { basePath } from "hono/route";
|
|
4
5
|
|
|
5
6
|
class WebhookValidationError extends HTTPException {
|
|
6
7
|
target;
|
|
7
8
|
issues;
|
|
8
9
|
constructor(target, issues) {
|
|
9
|
-
super(
|
|
10
|
+
super(target === "signature" ? 401 : 400, {
|
|
11
|
+
message: `Invalid ${target}`
|
|
12
|
+
});
|
|
10
13
|
this.target = target;
|
|
11
14
|
this.issues = issues;
|
|
12
15
|
this.name = "WebhookValidationError";
|
|
@@ -49,7 +52,7 @@ async function parse(schema, value, target) {
|
|
|
49
52
|
}
|
|
50
53
|
function defaultErrorResponse(error, c, log) {
|
|
51
54
|
if (error instanceof WebhookValidationError) {
|
|
52
|
-
return c.json({ error: error.message, issues: error.issues },
|
|
55
|
+
return c.json({ error: error.message, issues: error.issues }, error.status);
|
|
53
56
|
}
|
|
54
57
|
if (error instanceof HTTPException) {
|
|
55
58
|
return error.res ?? c.json({ error: error.message }, error.status);
|
|
@@ -82,13 +85,14 @@ function makeCreateWebhooks() {
|
|
|
82
85
|
methodsByPath.set(config.path, methods);
|
|
83
86
|
}
|
|
84
87
|
const handle = (route, run) => {
|
|
85
|
-
|
|
88
|
+
const execute = async (c) => {
|
|
86
89
|
const start = performance.now();
|
|
90
|
+
const base = basePath(c);
|
|
87
91
|
const event = {
|
|
88
92
|
c,
|
|
89
93
|
route,
|
|
90
94
|
method: c.req.method,
|
|
91
|
-
path: c.req.path,
|
|
95
|
+
path: base === "/" ? c.req.path : c.req.path.slice(base.length) || "/",
|
|
92
96
|
status: 0,
|
|
93
97
|
durationMs: 0
|
|
94
98
|
};
|
|
@@ -100,7 +104,7 @@ function makeCreateWebhooks() {
|
|
|
100
104
|
if (error instanceof WebhookValidationError) {
|
|
101
105
|
event.validation = { target: error.target, issues: error.issues };
|
|
102
106
|
}
|
|
103
|
-
res = await options.onError?.(error, c) ?? defaultErrorResponse(error, c,
|
|
107
|
+
res = await options.onError?.(error, c) ?? defaultErrorResponse(error, c, options.logErrors ?? true);
|
|
104
108
|
}
|
|
105
109
|
event.status = res.status;
|
|
106
110
|
event.durationMs = performance.now() - start;
|
|
@@ -108,6 +112,8 @@ function makeCreateWebhooks() {
|
|
|
108
112
|
report(c, options.onEvent(event));
|
|
109
113
|
return res;
|
|
110
114
|
};
|
|
115
|
+
const { around } = options;
|
|
116
|
+
return (c) => around ? around({ c, route, method: c.req.method }, () => execute(c)) : execute(c);
|
|
111
117
|
};
|
|
112
118
|
const ordered = [...webhooks].sort((a, b) => Number(isDynamic(a.config.path)) - Number(isDynamic(b.config.path)));
|
|
113
119
|
for (const { config, handler } of ordered) {
|
|
@@ -116,6 +122,15 @@ function makeCreateWebhooks() {
|
|
|
116
122
|
event.query = await parse(config.query, c.req.query(), "query");
|
|
117
123
|
event.headers = await parse(config.headers, c.req.header(), "headers");
|
|
118
124
|
event.rawBody = await c.req.text();
|
|
125
|
+
if (config.verify && !await config.verify({
|
|
126
|
+
c,
|
|
127
|
+
rawBody: event.rawBody,
|
|
128
|
+
headers: c.req.header()
|
|
129
|
+
})) {
|
|
130
|
+
throw new WebhookValidationError("signature", [
|
|
131
|
+
{ message: "Signature does not match" }
|
|
132
|
+
]);
|
|
133
|
+
}
|
|
119
134
|
event.body = await parse(config.body, parseBody(event.rawBody, config.bodyType ?? "auto", c.req.header("content-type")), "body");
|
|
120
135
|
const result = await handler({
|
|
121
136
|
c,
|
|
@@ -166,9 +181,110 @@ function createWebhookFactory() {
|
|
|
166
181
|
createWebhooks: makeCreateWebhooks()
|
|
167
182
|
};
|
|
168
183
|
}
|
|
184
|
+
function formatIssues(issues) {
|
|
185
|
+
return issues.map((issue) => {
|
|
186
|
+
const path = issuePath(issue);
|
|
187
|
+
return path ? `${path}: ${issue.message}` : issue.message;
|
|
188
|
+
}).join("; ");
|
|
189
|
+
}
|
|
190
|
+
function issuePath(issue) {
|
|
191
|
+
const path = issue.path?.map((segment) => String(typeof segment === "object" ? segment.key : segment)).join(".");
|
|
192
|
+
return path || null;
|
|
193
|
+
}
|
|
194
|
+
function summarizeEvent(event) {
|
|
195
|
+
const { c, error, validation } = event;
|
|
196
|
+
return {
|
|
197
|
+
route: event.route ?? null,
|
|
198
|
+
method: event.method,
|
|
199
|
+
path: event.path,
|
|
200
|
+
status: event.status,
|
|
201
|
+
durationMs: Math.round(event.durationMs * 100) / 100,
|
|
202
|
+
ip: c.req.header("x-forwarded-for")?.split(",")[0]?.trim() || c.req.header("x-real-ip") || c.req.header("cf-connecting-ip") || null,
|
|
203
|
+
userAgent: c.req.header("user-agent") ?? null,
|
|
204
|
+
params: event.params ?? null,
|
|
205
|
+
query: event.query ?? null,
|
|
206
|
+
body: event.body ?? null,
|
|
207
|
+
response: event.response ?? null,
|
|
208
|
+
error: error === undefined ? null : error instanceof Error ? {
|
|
209
|
+
name: error.name,
|
|
210
|
+
message: error.message,
|
|
211
|
+
stack: error.stack ?? null
|
|
212
|
+
} : { name: "Error", message: String(error), stack: null },
|
|
213
|
+
validation: validation ? {
|
|
214
|
+
target: validation.target,
|
|
215
|
+
message: formatIssues(validation.issues),
|
|
216
|
+
issues: validation.issues.map((issue) => ({
|
|
217
|
+
message: issue.message,
|
|
218
|
+
path: issuePath(issue)
|
|
219
|
+
}))
|
|
220
|
+
} : null
|
|
221
|
+
};
|
|
222
|
+
}
|
|
223
|
+
function toJsonSchema(schema, side, target) {
|
|
224
|
+
const props = schema?.["~standard"];
|
|
225
|
+
try {
|
|
226
|
+
return props?.jsonSchema?.[side]({ target }) ?? null;
|
|
227
|
+
} catch {
|
|
228
|
+
return null;
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
function describeWebhooks(source, { target = "draft-2020-12" } = {}) {
|
|
232
|
+
const webhooks = "webhooks" in source ? source.webhooks : source;
|
|
233
|
+
return webhooks.map(({ config }) => ({
|
|
234
|
+
method: config.method,
|
|
235
|
+
path: config.path,
|
|
236
|
+
description: config.description ?? null,
|
|
237
|
+
verified: Boolean(config.verify),
|
|
238
|
+
bodyType: config.bodyType ?? "auto",
|
|
239
|
+
jsonSchema: {
|
|
240
|
+
params: toJsonSchema(config.params, "input", target),
|
|
241
|
+
query: toJsonSchema(config.query, "input", target),
|
|
242
|
+
headers: toJsonSchema(config.headers, "input", target),
|
|
243
|
+
body: toJsonSchema(config.body, "input", target),
|
|
244
|
+
response: toJsonSchema(config.response, "output", target)
|
|
245
|
+
}
|
|
246
|
+
}));
|
|
247
|
+
}
|
|
248
|
+
function fillPath(pattern, params) {
|
|
249
|
+
return pattern.replace(/\/:(\w+)(?:\{[^}]*\})?\??/g, (_, name) => {
|
|
250
|
+
const value = params[name];
|
|
251
|
+
return value === undefined ? "" : `/${encodeURIComponent(String(value))}`;
|
|
252
|
+
});
|
|
253
|
+
}
|
|
254
|
+
async function testWebhook(webhook, input = {}) {
|
|
255
|
+
const { config } = webhook;
|
|
256
|
+
const { body, sign, env, options } = input;
|
|
257
|
+
const headers = new Headers(input.headers);
|
|
258
|
+
let rawBody;
|
|
259
|
+
if (typeof body === "string") {
|
|
260
|
+
rawBody = body;
|
|
261
|
+
if (!headers.has("content-type"))
|
|
262
|
+
headers.set("content-type", "text/plain");
|
|
263
|
+
} else if (body !== undefined && config.bodyType === "form") {
|
|
264
|
+
rawBody = new URLSearchParams(body).toString();
|
|
265
|
+
if (!headers.has("content-type")) {
|
|
266
|
+
headers.set("content-type", "application/x-www-form-urlencoded");
|
|
267
|
+
}
|
|
268
|
+
} else if (body !== undefined) {
|
|
269
|
+
rawBody = JSON.stringify(body);
|
|
270
|
+
if (!headers.has("content-type")) {
|
|
271
|
+
headers.set("content-type", "application/json");
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
for (const [name, value] of Object.entries(await sign?.(rawBody ?? "") ?? {})) {
|
|
275
|
+
headers.set(name, value);
|
|
276
|
+
}
|
|
277
|
+
const path = input.path ?? fillPath(config.path, input.params ?? {});
|
|
278
|
+
const query = new URLSearchParams(input.query).toString();
|
|
279
|
+
return createWebhooks([webhook], options).request(`${path || "/"}${query ? `?${query}` : ""}`, { method: config.method, headers, body: rawBody }, env);
|
|
280
|
+
}
|
|
169
281
|
export {
|
|
170
282
|
WebhookValidationError,
|
|
171
283
|
createWebhookFactory,
|
|
172
284
|
createWebhooks,
|
|
173
|
-
defineWebhook
|
|
285
|
+
defineWebhook,
|
|
286
|
+
describeWebhooks,
|
|
287
|
+
formatIssues,
|
|
288
|
+
summarizeEvent,
|
|
289
|
+
testWebhook
|
|
174
290
|
};
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import type { Context } from 'hono';
|
|
2
|
+
import type { Verify } from './index';
|
|
3
|
+
/** A signing secret, or a function reading it per request (e.g. from `c.env`). */
|
|
4
|
+
export type Secret = string | ((c: Context<any>) => string | Promise<string>);
|
|
5
|
+
export interface SignatureScheme<VerifyOptions = void, SignOptions = void> {
|
|
6
|
+
/** A `verify` function for `defineWebhook`. */
|
|
7
|
+
verify(secret: Secret, options?: VerifyOptions): Verify;
|
|
8
|
+
/** The headers a provider would send with `rawBody`. For tests and the CLI. */
|
|
9
|
+
sign(rawBody: string, secret: string, options?: SignOptions): Promise<Record<string, string>>;
|
|
10
|
+
}
|
|
11
|
+
export interface HmacOptions {
|
|
12
|
+
/** Header carrying the signature, e.g. `x-hub-signature-256`. */
|
|
13
|
+
header: string;
|
|
14
|
+
encoding: 'hex' | 'base64';
|
|
15
|
+
/** Text before the signature in the header, e.g. `sha256=`. */
|
|
16
|
+
prefix?: string;
|
|
17
|
+
/** @default 'SHA-256' */
|
|
18
|
+
hash?: 'SHA-1' | 'SHA-256' | 'SHA-512';
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* A scheme where one header carries an HMAC of the raw body. Covers most
|
|
22
|
+
* providers; `github` and `shopify` are built with it.
|
|
23
|
+
*
|
|
24
|
+
* @example
|
|
25
|
+
* const linear = hmac({ header: 'linear-signature', encoding: 'hex' })
|
|
26
|
+
*/
|
|
27
|
+
export declare function hmac(options: HmacOptions): SignatureScheme;
|
|
28
|
+
/** GitHub: `x-hub-signature-256: sha256=<hex>`. */
|
|
29
|
+
export declare const github: SignatureScheme<void, void>;
|
|
30
|
+
/** Shopify: `x-shopify-hmac-sha256: <base64>`. */
|
|
31
|
+
export declare const shopify: SignatureScheme<void, void>;
|
|
32
|
+
/**
|
|
33
|
+
* Stripe: `stripe-signature: t=<unix>,v1=<hex>`, signed over `<t>.<body>`.
|
|
34
|
+
* Rejects timestamps more than `toleranceSeconds` (default 300) away.
|
|
35
|
+
*/
|
|
36
|
+
export declare const stripe: SignatureScheme<{
|
|
37
|
+
toleranceSeconds?: number;
|
|
38
|
+
}, {
|
|
39
|
+
timestamp?: number;
|
|
40
|
+
}>;
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
// src/signatures.ts
|
|
2
|
+
var encoder = new TextEncoder;
|
|
3
|
+
function hmacKey(secret, hash) {
|
|
4
|
+
if (!secret)
|
|
5
|
+
throw new Error("hono-webhooks: the signing secret is empty");
|
|
6
|
+
return crypto.subtle.importKey("raw", encoder.encode(secret), { name: "HMAC", hash }, false, ["sign", "verify"]);
|
|
7
|
+
}
|
|
8
|
+
async function hmacSign(secret, payload, hash = "SHA-256") {
|
|
9
|
+
const key = await hmacKey(secret, hash);
|
|
10
|
+
return new Uint8Array(await crypto.subtle.sign("HMAC", key, encoder.encode(payload)));
|
|
11
|
+
}
|
|
12
|
+
async function hmacVerify(secret, payload, signature, hash = "SHA-256") {
|
|
13
|
+
const key = await hmacKey(secret, hash);
|
|
14
|
+
return crypto.subtle.verify("HMAC", key, signature, encoder.encode(payload));
|
|
15
|
+
}
|
|
16
|
+
var toHex = (bytes) => Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("");
|
|
17
|
+
var toBase64 = (bytes) => btoa(String.fromCharCode(...bytes));
|
|
18
|
+
function fromHex(hex) {
|
|
19
|
+
if (hex.length % 2 || /[^0-9a-f]/i.test(hex))
|
|
20
|
+
return;
|
|
21
|
+
return Uint8Array.from(hex.match(/../g) ?? [], (h) => Number.parseInt(h, 16));
|
|
22
|
+
}
|
|
23
|
+
function fromBase64(base64) {
|
|
24
|
+
try {
|
|
25
|
+
return Uint8Array.from(atob(base64), (ch) => ch.charCodeAt(0));
|
|
26
|
+
} catch {
|
|
27
|
+
return;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
var resolveSecret = async (secret, c) => typeof secret === "function" ? secret(c) : secret;
|
|
31
|
+
function hmac(options) {
|
|
32
|
+
const { header, encoding, prefix = "", hash = "SHA-256" } = options;
|
|
33
|
+
const name = header.toLowerCase();
|
|
34
|
+
return {
|
|
35
|
+
verify: (secret) => async ({ c, rawBody, headers }) => {
|
|
36
|
+
const value = headers[name];
|
|
37
|
+
if (!value?.startsWith(prefix))
|
|
38
|
+
return false;
|
|
39
|
+
const encoded = value.slice(prefix.length);
|
|
40
|
+
const signature = encoding === "hex" ? fromHex(encoded) : fromBase64(encoded);
|
|
41
|
+
if (!signature)
|
|
42
|
+
return false;
|
|
43
|
+
return hmacVerify(await resolveSecret(secret, c), rawBody, signature, hash);
|
|
44
|
+
},
|
|
45
|
+
async sign(rawBody, secret) {
|
|
46
|
+
const signature = await hmacSign(secret, rawBody, hash);
|
|
47
|
+
return {
|
|
48
|
+
[name]: `${prefix}${encoding === "hex" ? toHex(signature) : toBase64(signature)}`
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
var github = hmac({
|
|
54
|
+
header: "x-hub-signature-256",
|
|
55
|
+
encoding: "hex",
|
|
56
|
+
prefix: "sha256="
|
|
57
|
+
});
|
|
58
|
+
var shopify = hmac({
|
|
59
|
+
header: "x-shopify-hmac-sha256",
|
|
60
|
+
encoding: "base64"
|
|
61
|
+
});
|
|
62
|
+
var stripe = {
|
|
63
|
+
verify: (secret, { toleranceSeconds = 300 } = {}) => async ({ c, rawBody, headers }) => {
|
|
64
|
+
const parts = (headers["stripe-signature"] ?? "").split(",").map((part) => part.split("="));
|
|
65
|
+
const timestamp = parts.find(([k]) => k === "t")?.[1] ?? "";
|
|
66
|
+
if (!/^\d+$/.test(timestamp))
|
|
67
|
+
return false;
|
|
68
|
+
if (Math.abs(Date.now() / 1000 - Number(timestamp)) > toleranceSeconds) {
|
|
69
|
+
return false;
|
|
70
|
+
}
|
|
71
|
+
const key = await resolveSecret(secret, c);
|
|
72
|
+
for (const [k, v] of parts) {
|
|
73
|
+
const signature = k === "v1" && v ? fromHex(v) : undefined;
|
|
74
|
+
if (signature && await hmacVerify(key, `${timestamp}.${rawBody}`, signature)) {
|
|
75
|
+
return true;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
return false;
|
|
79
|
+
},
|
|
80
|
+
async sign(rawBody, secret, { timestamp } = {}) {
|
|
81
|
+
const t = timestamp ?? Math.floor(Date.now() / 1000);
|
|
82
|
+
const signature = await hmacSign(secret, `${t}.${rawBody}`);
|
|
83
|
+
return { "stripe-signature": `t=${t},v1=${toHex(signature)}` };
|
|
84
|
+
}
|
|
85
|
+
};
|
|
86
|
+
export {
|
|
87
|
+
github,
|
|
88
|
+
hmac,
|
|
89
|
+
shopify,
|
|
90
|
+
stripe
|
|
91
|
+
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "hono-webhooks",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "Type-safe webhook routes for Hono, validated with any Standard Schema library (Zod, Valibot, ArkType)",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -42,11 +42,17 @@
|
|
|
42
42
|
"bun": "./src/index.ts",
|
|
43
43
|
"import": "./dist/index.js",
|
|
44
44
|
"default": "./dist/index.js"
|
|
45
|
+
},
|
|
46
|
+
"./signatures": {
|
|
47
|
+
"types": "./dist/signatures.d.ts",
|
|
48
|
+
"bun": "./src/signatures.ts",
|
|
49
|
+
"import": "./dist/signatures.js",
|
|
50
|
+
"default": "./dist/signatures.js"
|
|
45
51
|
}
|
|
46
52
|
},
|
|
47
53
|
"scripts": {
|
|
48
54
|
"build": "rm -rf dist && bun run build:js && bun run build:types",
|
|
49
|
-
"build:js": "bun build ./src/index.ts ./src/cli.ts --outdir ./dist --target node --packages external",
|
|
55
|
+
"build:js": "bun build ./src/index.ts ./src/signatures.ts ./src/cli.ts --outdir ./dist --target node --packages external",
|
|
50
56
|
"build:types": "tsgo -p tsconfig.build.json",
|
|
51
57
|
"prepublishOnly": "bun run build",
|
|
52
58
|
"test": "bun test",
|
|
@@ -59,7 +65,7 @@
|
|
|
59
65
|
"@standard-schema/spec": "^1.1.0"
|
|
60
66
|
},
|
|
61
67
|
"peerDependencies": {
|
|
62
|
-
"hono": "^4.
|
|
68
|
+
"hono": "^4.8.0"
|
|
63
69
|
},
|
|
64
70
|
"devDependencies": {
|
|
65
71
|
"@biomejs/biome": "^2.5.14",
|