@zap-studio/webhooks 0.2.2 → 0.4.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/CHANGELOG.md +24 -0
- package/LICENSE +1 -1
- package/README.md +93 -61
- package/dist/errors.d.ts +23 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/{errors.mjs → errors.js} +11 -1
- package/dist/errors.js.map +1 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.js +5 -0
- package/dist/router.d.ts +80 -0
- package/dist/router.d.ts.map +1 -0
- package/dist/router.js +149 -0
- package/dist/router.js.map +1 -0
- package/dist/types.d.ts +93 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +0 -0
- package/dist/utils.d.ts +18 -0
- package/dist/utils.d.ts.map +1 -0
- package/dist/{utils/index.mjs → utils.js} +9 -4
- package/dist/utils.js.map +1 -0
- package/dist/verify.d.ts +49 -0
- package/dist/verify.d.ts.map +1 -0
- package/dist/{verify.mjs → verify.js} +23 -21
- package/dist/verify.js.map +1 -0
- package/package.json +15 -19
- package/dist/adapters/base.d.mts +0 -58
- package/dist/adapters/base.d.mts.map +0 -1
- package/dist/adapters/base.mjs +0 -26
- package/dist/adapters/base.mjs.map +0 -1
- package/dist/errors.d.mts +0 -13
- package/dist/errors.d.mts.map +0 -1
- package/dist/errors.mjs.map +0 -1
- package/dist/index.d.mts +0 -70
- package/dist/index.d.mts.map +0 -1
- package/dist/index.mjs +0 -159
- package/dist/index.mjs.map +0 -1
- package/dist/types/index.d.mts +0 -93
- package/dist/types/index.d.mts.map +0 -1
- package/dist/types/index.mjs +0 -1
- package/dist/utils/index.d.mts +0 -13
- package/dist/utils/index.d.mts.map +0 -1
- package/dist/utils/index.mjs.map +0 -1
- package/dist/verify.d.mts +0 -54
- package/dist/verify.d.mts.map +0 -1
- package/dist/verify.mjs.map +0 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,27 @@
|
|
|
1
|
+
## @zap-studio/webhooks@0.4.0
|
|
2
|
+
|
|
3
|
+
### Rework the package on Web API `Request`/`Response` (breaking)
|
|
4
|
+
|
|
5
|
+
The custom `NormalizedRequest`/`NormalizedResponse` contract is gone. `router.handle` now takes a standard Web API `Request` and returns a standard `Response`, so the router plugs directly into fetch-native runtimes (Bun, Deno, Cloudflare Workers, Next.js route handlers, Hono) with no adapter layer.
|
|
6
|
+
|
|
7
|
+
Breaking changes:
|
|
8
|
+
|
|
9
|
+
- `handle(req: NormalizedRequest): Promise<NormalizedResponse>` → `handle(request: Request): Promise<Response>`.
|
|
10
|
+
- Handlers receive `{ request, rawBody, path, payload }` (a `WebhookContext` plus the validated `payload`) and return a `Response` or `undefined` (default `200` `"ok"`). The `ack` helper is removed — use `Response.json(body, init)`.
|
|
11
|
+
- Hooks and `verify` are retyped against the context: `BeforeHook(ctx)`, `AfterHook(ctx, response)`, `ErrorHook(error, ctx)`, `VerifyFn(ctx)`. After hooks must `clone()` the response before reading its body.
|
|
12
|
+
- `Adapter`, `BaseAdapter`, and the `./adapters/base` export are removed. Node `http` users can bridge with `srvx` or `@hono/node-server`.
|
|
13
|
+
- The prefix is normalized to a trailing slash and only matches on a path boundary: `prefix: "/api"` now behaves as `/api/`, so `/apihello` no longer matches a route (previously it matched `ihello`).
|
|
14
|
+
|
|
15
|
+
Behavior kept: hook execution order, prefix semantics (default `/webhooks/`), exact-match routing, HMAC verification, and the `404`/`400`/`500` error body shapes. Unknown routes now return `404` without reading the request body.
|
|
16
|
+
|
|
17
|
+
## @zap-studio/webhooks@0.3.0
|
|
18
|
+
|
|
19
|
+
### Migrate to ultracite lint/format; make the adapter contract generic
|
|
20
|
+
|
|
21
|
+
`Adapter` and `BaseAdapter` are now generic over the framework request/response types (`Adapter<TReq, TRes>`, `BaseAdapter<TReq, TRes>`), replacing the previous per-method generics. The mapping members (`toNormalizedRequest`, `toFrameworkResponse`, `handleWebhook`) are now arrow properties, so custom adapters must override them with property syntax rather than method syntax.
|
|
22
|
+
|
|
23
|
+
Also: `register()` now returns `this`, error hooks always receive a real `Error` instance, and `rawBody` is typed as `Uint8Array`.
|
|
24
|
+
|
|
1
25
|
# @zap-studio/webhooks
|
|
2
26
|
|
|
3
27
|
## 0.2.2
|
package/LICENSE
CHANGED
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# @zap-studio/webhooks
|
|
2
2
|
|
|
3
|
-
Schema-first, type-safe webhook routing with runtime-agnostic signature verification support.
|
|
3
|
+
Schema-first, type-safe webhook routing built on the standard Web API [`Request`](https://developer.mozilla.org/en-US/docs/Web/API/Request) and [`Response`](https://developer.mozilla.org/en-US/docs/Web/API/Response) primitives, with runtime-agnostic signature verification support.
|
|
4
4
|
|
|
5
5
|
Works with any validation library that implements [Standard Schema](https://github.com/standard-schema/standard-schema), including Zod, Valibot, and ArkType.
|
|
6
6
|
|
|
@@ -20,7 +20,7 @@ Schemas are the source of truth, and payload types are inferred from them.
|
|
|
20
20
|
## Install
|
|
21
21
|
|
|
22
22
|
```bash
|
|
23
|
-
|
|
23
|
+
npm install @zap-studio/webhooks
|
|
24
24
|
```
|
|
25
25
|
|
|
26
26
|
## Quickstart
|
|
@@ -30,27 +30,70 @@ import { createWebhookRouter } from "@zap-studio/webhooks";
|
|
|
30
30
|
import { z } from "zod";
|
|
31
31
|
|
|
32
32
|
const router = createWebhookRouter({
|
|
33
|
-
prefix: "/webhooks
|
|
33
|
+
prefix: "/webhooks", // default
|
|
34
34
|
});
|
|
35
35
|
|
|
36
|
-
router.register("payments/succeeded", {
|
|
36
|
+
router.register("/payments/succeeded", {
|
|
37
37
|
schema: z.object({
|
|
38
38
|
id: z.string(),
|
|
39
39
|
amount: z.number().positive(),
|
|
40
40
|
currency: z.string().length(3),
|
|
41
41
|
}),
|
|
42
|
-
handler:
|
|
42
|
+
handler: ({ payload }) => {
|
|
43
43
|
// payload is inferred from schema
|
|
44
|
-
return
|
|
44
|
+
return Response.json(`processed ${payload.id}`);
|
|
45
45
|
},
|
|
46
46
|
});
|
|
47
|
+
|
|
48
|
+
// Any fetch-compatible runtime: Bun, Deno, Cloudflare Workers, ...
|
|
49
|
+
export default {
|
|
50
|
+
fetch: (request: Request) => router.handle(request),
|
|
51
|
+
};
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
`router.handle` takes a standard `Request` and returns a standard `Response`, so the router plugs directly into any fetch-native runtime — no adapter layer needed.
|
|
55
|
+
|
|
56
|
+
Handlers can return a `Response`, or `undefined` to let the router reply with its default `200` acknowledgement.
|
|
57
|
+
|
|
58
|
+
### Paths and the prefix
|
|
59
|
+
|
|
60
|
+
Routes are registered with a leading slash (`"/payments/succeeded"`) and matched relative to the router's `prefix` (default `"/webhooks"`, no trailing slash) — so the example above answers on `/webhooks/payments/succeeded`. Paths are normalized internally: missing leading slashes are added, trailing slashes stripped, and duplicate slashes collapsed, on both registered routes and incoming request URLs. Set `prefix: ""` (or `"/"`) to mount routes at the root.
|
|
61
|
+
|
|
62
|
+
## Runtime integration
|
|
63
|
+
|
|
64
|
+
Because `handle(request)` speaks fetch, integration is one line in most environments:
|
|
65
|
+
|
|
66
|
+
```ts
|
|
67
|
+
// Bun / Deno / Cloudflare Workers
|
|
68
|
+
export default { fetch: (request: Request) => router.handle(request) };
|
|
69
|
+
|
|
70
|
+
// Next.js route handler (app/webhooks/[...path]/route.ts)
|
|
71
|
+
export const POST = (request: Request) => router.handle(request);
|
|
72
|
+
|
|
73
|
+
// Hono
|
|
74
|
+
app.all("/webhooks/*", (c) => router.handle(c.req.raw));
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
For raw Node `http` servers, use a fetch-to-Node bridge such as [`srvx`](https://srvx.h3.dev) or [`@hono/node-server`](https://github.com/honojs/node-server).
|
|
78
|
+
|
|
79
|
+
## The webhook context
|
|
80
|
+
|
|
81
|
+
Hooks, verifiers, and handlers all receive a context object instead of the raw request stream. The router reads the request body exactly once, so the exact bytes stay available for signature verification:
|
|
82
|
+
|
|
83
|
+
```ts
|
|
84
|
+
interface WebhookContext {
|
|
85
|
+
request: Request; // headers, method, url — body already consumed
|
|
86
|
+
rawBody: Uint8Array; // exact request body bytes
|
|
87
|
+
path: string; // matched route key, e.g. "/payments/succeeded"
|
|
88
|
+
}
|
|
47
89
|
```
|
|
48
90
|
|
|
91
|
+
Handlers additionally receive `payload`, the schema-validated body.
|
|
92
|
+
|
|
49
93
|
## GitHub webhook example
|
|
50
94
|
|
|
51
95
|
```ts
|
|
52
|
-
import { createWebhookRouter } from "@zap-studio/webhooks";
|
|
53
|
-
import { createHmacVerifier } from "@zap-studio/webhooks/verify";
|
|
96
|
+
import { createHmacVerifier, createWebhookRouter } from "@zap-studio/webhooks";
|
|
54
97
|
import { z } from "zod";
|
|
55
98
|
|
|
56
99
|
const router = createWebhookRouter({
|
|
@@ -60,16 +103,16 @@ const router = createWebhookRouter({
|
|
|
60
103
|
}),
|
|
61
104
|
});
|
|
62
105
|
|
|
63
|
-
router.register("github/push", {
|
|
106
|
+
router.register("/github/push", {
|
|
64
107
|
schema: z.object({
|
|
65
108
|
ref: z.string(),
|
|
66
109
|
repository: z.object({
|
|
67
110
|
full_name: z.string(),
|
|
68
111
|
}),
|
|
69
112
|
}),
|
|
70
|
-
handler:
|
|
113
|
+
handler: ({ payload }) => {
|
|
71
114
|
console.log(`[github] ${payload.repository.full_name} ${payload.ref}`);
|
|
72
|
-
return
|
|
115
|
+
return undefined; // default 200 "ok"
|
|
73
116
|
},
|
|
74
117
|
});
|
|
75
118
|
```
|
|
@@ -84,25 +127,29 @@ import { z } from "zod";
|
|
|
84
127
|
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
|
|
85
128
|
|
|
86
129
|
const router = createWebhookRouter({
|
|
87
|
-
verify:
|
|
88
|
-
const signature =
|
|
130
|
+
verify: ({ request, rawBody }) => {
|
|
131
|
+
const signature = request.headers.get("stripe-signature");
|
|
89
132
|
if (!signature) {
|
|
90
133
|
throw new Error("Missing Stripe signature");
|
|
91
134
|
}
|
|
92
135
|
|
|
93
|
-
stripe.webhooks.constructEvent(
|
|
136
|
+
stripe.webhooks.constructEvent(
|
|
137
|
+
Buffer.from(rawBody),
|
|
138
|
+
signature,
|
|
139
|
+
process.env.STRIPE_WEBHOOK_SECRET!
|
|
140
|
+
);
|
|
94
141
|
},
|
|
95
142
|
});
|
|
96
143
|
|
|
97
|
-
router.register("stripe/payment_intent.succeeded", {
|
|
144
|
+
router.register("/stripe/payment_intent.succeeded", {
|
|
98
145
|
schema: z.object({
|
|
99
146
|
id: z.string(),
|
|
100
147
|
object: z.literal("event"),
|
|
101
148
|
type: z.literal("payment_intent.succeeded"),
|
|
102
149
|
}),
|
|
103
|
-
handler:
|
|
150
|
+
handler: ({ payload }) => {
|
|
104
151
|
console.log(`[stripe] event ${payload.id} (${payload.type})`);
|
|
105
|
-
return
|
|
152
|
+
return Response.json("received");
|
|
106
153
|
},
|
|
107
154
|
});
|
|
108
155
|
```
|
|
@@ -117,27 +164,35 @@ Lifecycle hooks let you apply cross-cutting behavior without duplicating code in
|
|
|
117
164
|
|
|
118
165
|
```ts
|
|
119
166
|
const router = createWebhookRouter({
|
|
120
|
-
before: (
|
|
121
|
-
console.log("incoming",
|
|
167
|
+
before: (ctx) => {
|
|
168
|
+
console.log("incoming", ctx.path);
|
|
122
169
|
},
|
|
123
|
-
after: (
|
|
124
|
-
console.log("status",
|
|
170
|
+
after: (_ctx, response) => {
|
|
171
|
+
console.log("status", response.status);
|
|
172
|
+
},
|
|
173
|
+
onError: (error) => Response.json({ error: error.message }, { status: 500 }),
|
|
174
|
+
});
|
|
175
|
+
```
|
|
176
|
+
|
|
177
|
+
After-hooks receive the outgoing `Response` as-is. If a hook needs to read the body, call `response.clone()` first so the stream sent to the client stays readable:
|
|
178
|
+
|
|
179
|
+
```ts
|
|
180
|
+
const router = createWebhookRouter({
|
|
181
|
+
after: async (_ctx, response) => {
|
|
182
|
+
const body = await response.clone().json();
|
|
183
|
+
console.log("responded with", body);
|
|
125
184
|
},
|
|
126
|
-
onError: (error) => ({
|
|
127
|
-
status: 500,
|
|
128
|
-
body: { error: error.message },
|
|
129
|
-
}),
|
|
130
185
|
});
|
|
131
186
|
```
|
|
132
187
|
|
|
133
188
|
## Verification helper
|
|
134
189
|
|
|
135
|
-
`@zap-studio/webhooks
|
|
190
|
+
`@zap-studio/webhooks` exports `createHmacVerifier`, a small helper that builds a `verify` function for HMAC-signed webhook providers.
|
|
136
191
|
|
|
137
192
|
It does not depend on Node APIs. The verifier uses the Web Crypto API, so it works in any runtime that provides `globalThis.crypto.subtle`.
|
|
138
193
|
|
|
139
194
|
- reads a signature from the header you choose
|
|
140
|
-
- computes an HMAC from `
|
|
195
|
+
- computes an HMAC from `ctx.rawBody`
|
|
141
196
|
- compares signatures in constant time
|
|
142
197
|
- uses the Web Crypto API instead of Node `crypto`
|
|
143
198
|
- works across runtimes that provide `globalThis.crypto.subtle`
|
|
@@ -145,8 +200,7 @@ It does not depend on Node APIs. The verifier uses the Web Crypto API, so it wor
|
|
|
145
200
|
- throws `VerificationError` on verifier setup or signature failures
|
|
146
201
|
|
|
147
202
|
```ts
|
|
148
|
-
import { createHmacVerifier } from "@zap-studio/webhooks
|
|
149
|
-
import { VerificationError } from "@zap-studio/webhooks/errors";
|
|
203
|
+
import { createHmacVerifier, VerificationError } from "@zap-studio/webhooks";
|
|
150
204
|
|
|
151
205
|
const verify = createHmacVerifier({
|
|
152
206
|
headerName: "x-hub-signature-256",
|
|
@@ -155,7 +209,7 @@ const verify = createHmacVerifier({
|
|
|
155
209
|
});
|
|
156
210
|
|
|
157
211
|
try {
|
|
158
|
-
await verify(
|
|
212
|
+
await verify(ctx);
|
|
159
213
|
} catch (error) {
|
|
160
214
|
if (error instanceof VerificationError) {
|
|
161
215
|
console.error("webhook verification failed", error.message);
|
|
@@ -165,39 +219,17 @@ try {
|
|
|
165
219
|
|
|
166
220
|
Use this when your provider uses standard HMAC signatures. For providers with custom signing formats, pass your own `verify` function.
|
|
167
221
|
|
|
168
|
-
##
|
|
222
|
+
## Runtime Support
|
|
169
223
|
|
|
170
|
-
|
|
224
|
+
| Runtime | Minimum version |
|
|
225
|
+
| ------------------ | ------------------------------------------------ |
|
|
226
|
+
| Node.js | 18.0.0 (router), 19.0.0 (verification helper) |
|
|
227
|
+
| Bun | 1.0.0 |
|
|
228
|
+
| Deno | 1.42 |
|
|
229
|
+
| Cloudflare Workers | Any current release |
|
|
230
|
+
| Browsers | Latest evergreen (Chrome, Edge, Firefox, Safari) |
|
|
171
231
|
|
|
172
|
-
`
|
|
173
|
-
|
|
174
|
-
- you only implement `toNormalizedRequest` and `toFrameworkResponse`
|
|
175
|
-
- `BaseAdapter` handles the common `handleWebhook()` flow
|
|
176
|
-
- teams can reuse one adapter implementation across all webhook routes
|
|
177
|
-
|
|
178
|
-
```ts
|
|
179
|
-
import { BaseAdapter } from "@zap-studio/webhooks/adapters/base";
|
|
180
|
-
import type { NormalizedRequest, NormalizedResponse } from "@zap-studio/webhooks/types";
|
|
181
|
-
|
|
182
|
-
class MyHttpAdapter extends BaseAdapter {
|
|
183
|
-
async toNormalizedRequest(req: any): Promise<NormalizedRequest> {
|
|
184
|
-
return {
|
|
185
|
-
method: req.method,
|
|
186
|
-
path: req.url,
|
|
187
|
-
headers: new Headers(req.headers),
|
|
188
|
-
rawBody: req.rawBody,
|
|
189
|
-
};
|
|
190
|
-
}
|
|
191
|
-
|
|
192
|
-
async toFrameworkResponse(res: any, normalized: NormalizedResponse): Promise<any> {
|
|
193
|
-
res.statusCode = normalized.status;
|
|
194
|
-
res.end(
|
|
195
|
-
typeof normalized.body === "string" ? normalized.body : JSON.stringify(normalized.body),
|
|
196
|
-
);
|
|
197
|
-
return res;
|
|
198
|
-
}
|
|
199
|
-
}
|
|
200
|
-
```
|
|
232
|
+
The router only needs the standard `Request`/`Response` APIs, available globally since Node.js 18. The verification helper additionally needs `globalThis.crypto.subtle`, which is global by default from Node.js 19 (on Node.js 18, pass the `--experimental-global-webcrypto` flag). In browsers, Web Crypto requires a secure context (HTTPS). Deno 1.42 is the first release that can install packages from JSR (`deno add jsr:@zap-studio/webhooks`).
|
|
201
233
|
|
|
202
234
|
## License
|
|
203
235
|
|
package/dist/errors.d.ts
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
//#region src/errors.d.ts
|
|
2
|
+
/**
|
|
3
|
+
* Error primitives for webhook verification failures.
|
|
4
|
+
*
|
|
5
|
+
* @module @zap-studio/webhooks/errors
|
|
6
|
+
*/
|
|
7
|
+
/**
|
|
8
|
+
* Error thrown when webhook request verification fails.
|
|
9
|
+
*
|
|
10
|
+
* This error is used by verifier helpers such as `createHmacVerifier` so
|
|
11
|
+
* callers can distinguish verification failures from other webhook errors.
|
|
12
|
+
*/
|
|
13
|
+
declare class VerificationError extends Error {
|
|
14
|
+
/**
|
|
15
|
+
* Creates a verification error with a human-readable message.
|
|
16
|
+
*
|
|
17
|
+
* @param message - Error message describing the verification failure.
|
|
18
|
+
*/
|
|
19
|
+
constructor(message: string);
|
|
20
|
+
}
|
|
21
|
+
//#endregion
|
|
22
|
+
export { VerificationError };
|
|
23
|
+
//# sourceMappingURL=errors.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"errors.d.ts","names":[],"sources":["../src/errors.ts"],"mappings":";;;;;;;;;;;;cAYa,0BAA0B;;;;;;EAMrC,YAAY"}
|
|
@@ -1,11 +1,21 @@
|
|
|
1
1
|
//#region src/errors.ts
|
|
2
2
|
/**
|
|
3
|
+
* Error primitives for webhook verification failures.
|
|
4
|
+
*
|
|
5
|
+
* @module @zap-studio/webhooks/errors
|
|
6
|
+
*/
|
|
7
|
+
/**
|
|
3
8
|
* Error thrown when webhook request verification fails.
|
|
4
9
|
*
|
|
5
10
|
* This error is used by verifier helpers such as `createHmacVerifier` so
|
|
6
11
|
* callers can distinguish verification failures from other webhook errors.
|
|
7
12
|
*/
|
|
8
13
|
var VerificationError = class extends Error {
|
|
14
|
+
/**
|
|
15
|
+
* Creates a verification error with a human-readable message.
|
|
16
|
+
*
|
|
17
|
+
* @param message - Error message describing the verification failure.
|
|
18
|
+
*/
|
|
9
19
|
constructor(message) {
|
|
10
20
|
super(message);
|
|
11
21
|
this.name = "VerificationError";
|
|
@@ -14,4 +24,4 @@ var VerificationError = class extends Error {
|
|
|
14
24
|
//#endregion
|
|
15
25
|
export { VerificationError };
|
|
16
26
|
|
|
17
|
-
//# sourceMappingURL=errors.
|
|
27
|
+
//# sourceMappingURL=errors.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"errors.js","names":[],"sources":["../src/errors.ts"],"sourcesContent":["/**\n * Error primitives for webhook verification failures.\n *\n * @module @zap-studio/webhooks/errors\n */\n\n/**\n * Error thrown when webhook request verification fails.\n *\n * This error is used by verifier helpers such as `createHmacVerifier` so\n * callers can distinguish verification failures from other webhook errors.\n */\nexport class VerificationError extends Error {\n /**\n * Creates a verification error with a human-readable message.\n *\n * @param message - Error message describing the verification failure.\n */\n constructor(message: string) {\n super(message);\n this.name = \"VerificationError\";\n }\n}\n"],"mappings":";;;;;;;;;;;;AAYA,IAAa,oBAAb,cAAuC,MAAM;;;;;;CAM3C,YAAY,SAAiB;EAC3B,MAAM,OAAO;EACb,KAAK,OAAO;CACd;AACF"}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import { VerificationError } from "./errors.js";
|
|
2
|
+
import { AfterHook, BeforeHook, ErrorHook, HandlerContext, HandlerMap, InferSchemaOutput, InferWebhookMapFromRoutes, RegisterOptions, SchemaRouteOptions, SchemaRoutes, VerifyFn, WebhookContext, WebhookHandler } from "./types.js";
|
|
3
|
+
import { WebhookRouter, WebhookRouterOptions, createWebhookRouter } from "./router.js";
|
|
4
|
+
import { constantTimeEquals } from "./utils.js";
|
|
5
|
+
import { createHmacVerifier } from "./verify.js";
|
|
6
|
+
export { type AfterHook, type BeforeHook, type ErrorHook, type HandlerContext, type HandlerMap, type InferSchemaOutput, type InferWebhookMapFromRoutes, type RegisterOptions, type SchemaRouteOptions, type SchemaRoutes, VerificationError, type VerifyFn, type WebhookContext, type WebhookHandler, WebhookRouter, type WebhookRouterOptions, constantTimeEquals, createHmacVerifier, createWebhookRouter };
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import { VerificationError } from "./errors.js";
|
|
2
|
+
import { WebhookRouter, createWebhookRouter } from "./router.js";
|
|
3
|
+
import { constantTimeEquals } from "./utils.js";
|
|
4
|
+
import { createHmacVerifier } from "./verify.js";
|
|
5
|
+
export { VerificationError, WebhookRouter, constantTimeEquals, createHmacVerifier, createWebhookRouter };
|
package/dist/router.d.ts
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { AfterHook, BeforeHook, ErrorHook, InferSchemaOutput, RegisterOptions, SchemaRouteOptions, VerifyFn, WebhookHandler } from "./types.js";
|
|
2
|
+
import { StandardSchemaV1 } from "@zap-studio/validation";
|
|
3
|
+
//#region src/router.d.ts
|
|
4
|
+
interface WebhookRouterOptions {
|
|
5
|
+
/** Global hooks executed after successful route handler completion. */
|
|
6
|
+
after?: AfterHook | AfterHook[];
|
|
7
|
+
/** Global hooks executed before route-level hooks and verification. */
|
|
8
|
+
before?: BeforeHook | BeforeHook[];
|
|
9
|
+
/** Global error hook used to override the default `500` response. */
|
|
10
|
+
onError?: ErrorHook;
|
|
11
|
+
/**
|
|
12
|
+
* Required path prefix for all webhook routes. Defaults to `"/webhooks"`.
|
|
13
|
+
*
|
|
14
|
+
* Normalized internally: leading slash added, trailing slash stripped,
|
|
15
|
+
* duplicate slashes collapsed. Use `""` or `"/"` to mount at the root.
|
|
16
|
+
*/
|
|
17
|
+
prefix?: string;
|
|
18
|
+
/** Optional request verification function (for signature checks, auth, etc.). */
|
|
19
|
+
verify?: VerifyFn;
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Main webhook router class.
|
|
23
|
+
*
|
|
24
|
+
* Register routes with typed schemas and call `handle` with a Web API `Request`.
|
|
25
|
+
*/
|
|
26
|
+
declare class WebhookRouter<TMap = unknown> {
|
|
27
|
+
private readonly handlers;
|
|
28
|
+
private readonly verify;
|
|
29
|
+
private readonly globalBeforeHooks;
|
|
30
|
+
private readonly globalAfterHooks;
|
|
31
|
+
private readonly globalErrorHook;
|
|
32
|
+
private readonly prefix;
|
|
33
|
+
private readonly prefixWithSlash;
|
|
34
|
+
/**
|
|
35
|
+
* Creates a webhook router with optional global hooks and verification behavior.
|
|
36
|
+
*
|
|
37
|
+
* @param opts - Router-level options.
|
|
38
|
+
*/
|
|
39
|
+
constructor(opts?: WebhookRouterOptions);
|
|
40
|
+
/**
|
|
41
|
+
* Register a webhook handler for a specific path.
|
|
42
|
+
*
|
|
43
|
+
* When a schema is provided, `payload` is inferred from the schema output type.
|
|
44
|
+
*
|
|
45
|
+
* @param path - Route path relative to configured prefix, starting with `/` (e.g. `"/stripe"`).
|
|
46
|
+
* @param handlerOrOptions - Handler function or schema-based registration options.
|
|
47
|
+
* @returns The same router instance with an updated internal route type map.
|
|
48
|
+
*/
|
|
49
|
+
register<Path extends `/${string}`, TSchema extends StandardSchemaV1<unknown, unknown>>(path: Path, handlerOrOptions: SchemaRouteOptions<TSchema>): WebhookRouter<TMap & Record<Path, InferSchemaOutput<TSchema>>>;
|
|
50
|
+
register<Path extends `/${string}`, TPayload>(path: Path, handlerOrOptions: RegisterOptions<TPayload>): WebhookRouter<TMap & Record<Path, TPayload>>;
|
|
51
|
+
register<Path extends `/${string}`>(path: Path, handlerOrOptions: WebhookHandler): WebhookRouter<TMap & Record<Path, unknown>>;
|
|
52
|
+
/**
|
|
53
|
+
* Handles an incoming webhook request.
|
|
54
|
+
*
|
|
55
|
+
* The request body is read exactly once; hooks and handlers receive the raw
|
|
56
|
+
* bytes through the webhook context instead of the request stream.
|
|
57
|
+
*
|
|
58
|
+
* @param request - Incoming Web API request.
|
|
59
|
+
* @returns Web API response for the runtime to send back.
|
|
60
|
+
*/
|
|
61
|
+
handle(request: Request): Promise<Response>;
|
|
62
|
+
private matchPath;
|
|
63
|
+
private static runBeforeHooks;
|
|
64
|
+
private static runAfterHooks;
|
|
65
|
+
private static createHandlerEntry;
|
|
66
|
+
private static parseRequestBody;
|
|
67
|
+
private static validatePayload;
|
|
68
|
+
private static executeHandler;
|
|
69
|
+
private handleError;
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* Factory helper for creating a webhook router instance.
|
|
73
|
+
*
|
|
74
|
+
* @param opts - Optional global router options.
|
|
75
|
+
* @returns A new webhook router.
|
|
76
|
+
*/
|
|
77
|
+
declare const createWebhookRouter: (opts?: WebhookRouterOptions) => WebhookRouter;
|
|
78
|
+
//#endregion
|
|
79
|
+
export { WebhookRouter, WebhookRouterOptions, createWebhookRouter };
|
|
80
|
+
//# sourceMappingURL=router.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"router.d.ts","names":[],"sources":["../src/router.ts"],"mappings":";;;UAiCiB;;EAEf,QAAQ,YAAY;;EAEpB,SAAS,aAAa;;EAEtB,UAAU;;;;;;;EAOV;;EAEA,SAAS;;;;;;;cAoCE,cAAc;mBACR;mBACA;mBACA;mBACA;mBACA;mBACA;mBACA;;;;;;EAOjB,YAAY,OAAM;;;;;;;;;;EAkBlB,SACE,2BACA,gBAAgB,oCAEhB,MAAM,MACN,kBAAkB,mBAAmB,WACpC,cAAc,OAAO,OAAO,MAAM,kBAAkB;EACvD,SAAS,2BAA2B,UAClC,MAAM,MACN,kBAAkB,gBAAgB,YACjC,cAAc,OAAO,OAAO,MAAM;EACrC,SAAS,2BACP,MAAM,MACN,kBAAkB,iBACjB,cAAc,OAAO,OAAO;;;;;;;;;;EAwB/B,OAAa,SAAS,UAAU,QAAQ;UAoDhC;iBAqBa;iBAcA;iBAeN;iBA0BA;iBAQM;iBA+BA;UAaP;;;;;;;;cA4BH,sBACX,OAAO,yBACN"}
|
package/dist/router.js
ADDED
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
import { standardValidate } from "@zap-studio/validation";
|
|
2
|
+
//#region src/router.ts
|
|
3
|
+
const toArray = (value) => {
|
|
4
|
+
if (value === void 0) return [];
|
|
5
|
+
return Array.isArray(value) ? value : [value];
|
|
6
|
+
};
|
|
7
|
+
const notFoundResponse = () => Response.json({ error: "not found" }, { status: 404 });
|
|
8
|
+
const bodyDecoder = new TextDecoder();
|
|
9
|
+
/**
|
|
10
|
+
* Normalizes a path to its canonical form: leading slash, no trailing slash,
|
|
11
|
+
* duplicate slashes collapsed. The root path is `"/"`.
|
|
12
|
+
*/
|
|
13
|
+
const normalizePath = (path) => {
|
|
14
|
+
const withLeadingSlash = path.startsWith("/") ? path : `/${path}`;
|
|
15
|
+
const collapsed = withLeadingSlash.includes("//") ? withLeadingSlash.replaceAll(/\/{2,}/gu, "/") : withLeadingSlash;
|
|
16
|
+
return collapsed.length > 1 && collapsed.endsWith("/") ? collapsed.slice(0, -1) : collapsed;
|
|
17
|
+
};
|
|
18
|
+
/**
|
|
19
|
+
* Main webhook router class.
|
|
20
|
+
*
|
|
21
|
+
* Register routes with typed schemas and call `handle` with a Web API `Request`.
|
|
22
|
+
*/
|
|
23
|
+
var WebhookRouter = class WebhookRouter {
|
|
24
|
+
handlers = /* @__PURE__ */ new Map();
|
|
25
|
+
verify;
|
|
26
|
+
globalBeforeHooks = [];
|
|
27
|
+
globalAfterHooks = [];
|
|
28
|
+
globalErrorHook;
|
|
29
|
+
prefix;
|
|
30
|
+
prefixWithSlash;
|
|
31
|
+
/**
|
|
32
|
+
* Creates a webhook router with optional global hooks and verification behavior.
|
|
33
|
+
*
|
|
34
|
+
* @param opts - Router-level options.
|
|
35
|
+
*/
|
|
36
|
+
constructor(opts = {}) {
|
|
37
|
+
this.prefix = normalizePath(opts.prefix ?? "/webhooks");
|
|
38
|
+
this.prefixWithSlash = `${this.prefix}/`;
|
|
39
|
+
this.verify = opts.verify;
|
|
40
|
+
this.globalBeforeHooks = toArray(opts.before);
|
|
41
|
+
this.globalAfterHooks = toArray(opts.after);
|
|
42
|
+
this.globalErrorHook = opts.onError;
|
|
43
|
+
}
|
|
44
|
+
register(path, handlerOrOptions) {
|
|
45
|
+
this.handlers.set(normalizePath(path), typeof handlerOrOptions === "function" ? { handler: handlerOrOptions } : WebhookRouter.createHandlerEntry(handlerOrOptions));
|
|
46
|
+
return this;
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Handles an incoming webhook request.
|
|
50
|
+
*
|
|
51
|
+
* The request body is read exactly once; hooks and handlers receive the raw
|
|
52
|
+
* bytes through the webhook context instead of the request stream.
|
|
53
|
+
*
|
|
54
|
+
* @param request - Incoming Web API request.
|
|
55
|
+
* @returns Web API response for the runtime to send back.
|
|
56
|
+
*/
|
|
57
|
+
async handle(request) {
|
|
58
|
+
const path = this.matchPath(request);
|
|
59
|
+
if (path === null) return notFoundResponse();
|
|
60
|
+
const handlerEntry = this.handlers.get(path);
|
|
61
|
+
if (!handlerEntry) return notFoundResponse();
|
|
62
|
+
const ctx = {
|
|
63
|
+
path,
|
|
64
|
+
rawBody: /* @__PURE__ */ new Uint8Array(0),
|
|
65
|
+
request
|
|
66
|
+
};
|
|
67
|
+
try {
|
|
68
|
+
ctx.rawBody = new Uint8Array(await request.arrayBuffer());
|
|
69
|
+
await WebhookRouter.runBeforeHooks(ctx, this.globalBeforeHooks);
|
|
70
|
+
await WebhookRouter.runBeforeHooks(ctx, handlerEntry.before);
|
|
71
|
+
if (this.verify) await this.verify(ctx);
|
|
72
|
+
const parsedJson = WebhookRouter.parseRequestBody(ctx);
|
|
73
|
+
const validationResult = await WebhookRouter.validatePayload(parsedJson, handlerEntry.schema);
|
|
74
|
+
if (validationResult instanceof Response) return validationResult;
|
|
75
|
+
const response = await WebhookRouter.executeHandler(handlerEntry.handler, ctx, validationResult);
|
|
76
|
+
await WebhookRouter.runAfterHooks(ctx, response, handlerEntry.after);
|
|
77
|
+
await WebhookRouter.runAfterHooks(ctx, response, this.globalAfterHooks);
|
|
78
|
+
return response;
|
|
79
|
+
} catch (error) {
|
|
80
|
+
return await this.handleError(error, ctx);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
matchPath(request) {
|
|
84
|
+
const pathname = normalizePath(new URL(request.url).pathname);
|
|
85
|
+
if (this.prefix === "/") return pathname;
|
|
86
|
+
if (pathname === this.prefix) return "/";
|
|
87
|
+
if (!pathname.startsWith(this.prefixWithSlash)) return null;
|
|
88
|
+
return pathname.slice(this.prefix.length);
|
|
89
|
+
}
|
|
90
|
+
static async runBeforeHooks(ctx, hooks) {
|
|
91
|
+
if (!hooks || hooks.length === 0) return;
|
|
92
|
+
for (const hook of hooks) await hook(ctx);
|
|
93
|
+
}
|
|
94
|
+
static async runAfterHooks(ctx, response, hooks) {
|
|
95
|
+
if (!hooks || hooks.length === 0) return;
|
|
96
|
+
for (const hook of hooks) await hook(ctx, response);
|
|
97
|
+
}
|
|
98
|
+
static createHandlerEntry(options) {
|
|
99
|
+
const entry = { handler: options.handler };
|
|
100
|
+
if (options.schema !== void 0) entry.schema = options.schema;
|
|
101
|
+
if (options.before !== void 0) entry.before = Array.isArray(options.before) ? options.before : [options.before];
|
|
102
|
+
if (options.after !== void 0) entry.after = Array.isArray(options.after) ? options.after : [options.after];
|
|
103
|
+
return entry;
|
|
104
|
+
}
|
|
105
|
+
static parseRequestBody(ctx) {
|
|
106
|
+
try {
|
|
107
|
+
return JSON.parse(bodyDecoder.decode(ctx.rawBody));
|
|
108
|
+
} catch {
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
static async validatePayload(parsedJson, schema) {
|
|
113
|
+
if (!schema) return parsedJson;
|
|
114
|
+
const result = await standardValidate(schema, parsedJson, { throwOnError: false });
|
|
115
|
+
if (result.issues) return Response.json({
|
|
116
|
+
error: "validation failed",
|
|
117
|
+
issues: result.issues.map((issue) => ({
|
|
118
|
+
message: issue.message,
|
|
119
|
+
path: issue.path?.map((p) => typeof p === "object" && "key" in p ? String(p.key) : String(p))
|
|
120
|
+
}))
|
|
121
|
+
}, { status: 400 });
|
|
122
|
+
return result.value;
|
|
123
|
+
}
|
|
124
|
+
static async executeHandler(handler, ctx, validatedPayload) {
|
|
125
|
+
return await handler({
|
|
126
|
+
...ctx,
|
|
127
|
+
payload: validatedPayload
|
|
128
|
+
}) ?? Response.json("ok");
|
|
129
|
+
}
|
|
130
|
+
async handleError(error, ctx) {
|
|
131
|
+
if (this.globalErrorHook) {
|
|
132
|
+
const normalizedError = error instanceof Error ? error : /* @__PURE__ */ new Error("Internal server error");
|
|
133
|
+
const errorResponse = await this.globalErrorHook(normalizedError, ctx);
|
|
134
|
+
if (errorResponse) return errorResponse;
|
|
135
|
+
}
|
|
136
|
+
return Response.json({ error: error instanceof Error ? error.message : "Internal server error" }, { status: 500 });
|
|
137
|
+
}
|
|
138
|
+
};
|
|
139
|
+
/**
|
|
140
|
+
* Factory helper for creating a webhook router instance.
|
|
141
|
+
*
|
|
142
|
+
* @param opts - Optional global router options.
|
|
143
|
+
* @returns A new webhook router.
|
|
144
|
+
*/
|
|
145
|
+
const createWebhookRouter = (opts) => new WebhookRouter(opts);
|
|
146
|
+
//#endregion
|
|
147
|
+
export { WebhookRouter, createWebhookRouter };
|
|
148
|
+
|
|
149
|
+
//# sourceMappingURL=router.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"router.js","names":[],"sources":["../src/router.ts"],"sourcesContent":["/**\n * Schema-first webhook router primitives.\n *\n * @module @zap-studio/webhooks/router\n */\n\nimport type { StandardSchemaV1 } from \"@zap-studio/validation\";\nimport { standardValidate } from \"@zap-studio/validation\";\n\nimport type {\n AfterHook,\n BeforeHook,\n ErrorHook,\n InferSchemaOutput,\n RegisterOptions,\n SchemaRouteOptions,\n VerifyFn,\n WebhookContext,\n WebhookHandler,\n} from \"./types.js\";\n\n/**\n * Schema-first webhook router with path dispatching, validation, and optional verification.\n *\n * @template TMap - Internal route payload map built incrementally via `register`.\n */\ninterface HandlerEntry<TPayload = unknown> {\n after?: AfterHook[];\n before?: BeforeHook[];\n handler: WebhookHandler<TPayload>;\n schema?: StandardSchemaV1<unknown, TPayload>;\n}\n\nexport interface WebhookRouterOptions {\n /** Global hooks executed after successful route handler completion. */\n after?: AfterHook | AfterHook[];\n /** Global hooks executed before route-level hooks and verification. */\n before?: BeforeHook | BeforeHook[];\n /** Global error hook used to override the default `500` response. */\n onError?: ErrorHook;\n /**\n * Required path prefix for all webhook routes. Defaults to `\"/webhooks\"`.\n *\n * Normalized internally: leading slash added, trailing slash stripped,\n * duplicate slashes collapsed. Use `\"\"` or `\"/\"` to mount at the root.\n */\n prefix?: string;\n /** Optional request verification function (for signature checks, auth, etc.). */\n verify?: VerifyFn;\n}\n\nconst toArray = <T>(value: T | T[] | undefined): T[] => {\n if (value === undefined) {\n return [];\n }\n\n return Array.isArray(value) ? value : [value];\n};\n\nconst notFoundResponse = (): Response =>\n Response.json({ error: \"not found\" }, { status: 404 });\n\nconst bodyDecoder = new TextDecoder();\n\n/**\n * Normalizes a path to its canonical form: leading slash, no trailing slash,\n * duplicate slashes collapsed. The root path is `\"/\"`.\n */\nconst normalizePath = (path: string): string => {\n const withLeadingSlash = path.startsWith(\"/\") ? path : `/${path}`;\n const collapsed = withLeadingSlash.includes(\"//\")\n ? withLeadingSlash.replaceAll(/\\/{2,}/gu, \"/\")\n : withLeadingSlash;\n\n return collapsed.length > 1 && collapsed.endsWith(\"/\")\n ? collapsed.slice(0, -1)\n : collapsed;\n};\n\n/**\n * Main webhook router class.\n *\n * Register routes with typed schemas and call `handle` with a Web API `Request`.\n */\nexport class WebhookRouter<TMap = unknown> {\n private readonly handlers = new Map<string, HandlerEntry>();\n private readonly verify: VerifyFn | undefined;\n private readonly globalBeforeHooks: BeforeHook[] = [];\n private readonly globalAfterHooks: AfterHook[] = [];\n private readonly globalErrorHook: ErrorHook | undefined;\n private readonly prefix: string;\n private readonly prefixWithSlash: string;\n\n /**\n * Creates a webhook router with optional global hooks and verification behavior.\n *\n * @param opts - Router-level options.\n */\n constructor(opts: WebhookRouterOptions = {}) {\n this.prefix = normalizePath(opts.prefix ?? \"/webhooks\");\n this.prefixWithSlash = `${this.prefix}/`;\n this.verify = opts.verify;\n this.globalBeforeHooks = toArray(opts.before);\n this.globalAfterHooks = toArray(opts.after);\n this.globalErrorHook = opts.onError;\n }\n\n /**\n * Register a webhook handler for a specific path.\n *\n * When a schema is provided, `payload` is inferred from the schema output type.\n *\n * @param path - Route path relative to configured prefix, starting with `/` (e.g. `\"/stripe\"`).\n * @param handlerOrOptions - Handler function or schema-based registration options.\n * @returns The same router instance with an updated internal route type map.\n */\n register<\n Path extends `/${string}`,\n TSchema extends StandardSchemaV1<unknown, unknown>,\n >(\n path: Path,\n handlerOrOptions: SchemaRouteOptions<TSchema>\n ): WebhookRouter<TMap & Record<Path, InferSchemaOutput<TSchema>>>;\n register<Path extends `/${string}`, TPayload>(\n path: Path,\n handlerOrOptions: RegisterOptions<TPayload>\n ): WebhookRouter<TMap & Record<Path, TPayload>>;\n register<Path extends `/${string}`>(\n path: Path,\n handlerOrOptions: WebhookHandler\n ): WebhookRouter<TMap & Record<Path, unknown>>;\n register(\n path: string,\n handlerOrOptions: WebhookHandler | RegisterOptions<unknown>\n ): this {\n this.handlers.set(\n normalizePath(path),\n typeof handlerOrOptions === \"function\"\n ? { handler: handlerOrOptions }\n : WebhookRouter.createHandlerEntry(handlerOrOptions)\n );\n\n return this;\n }\n\n /**\n * Handles an incoming webhook request.\n *\n * The request body is read exactly once; hooks and handlers receive the raw\n * bytes through the webhook context instead of the request stream.\n *\n * @param request - Incoming Web API request.\n * @returns Web API response for the runtime to send back.\n */\n async handle(request: Request): Promise<Response> {\n const path = this.matchPath(request);\n if (path === null) {\n return notFoundResponse();\n }\n\n const handlerEntry = this.handlers.get(path);\n if (!handlerEntry) {\n return notFoundResponse();\n }\n\n const ctx: WebhookContext = {\n path,\n rawBody: new Uint8Array(0),\n request,\n };\n\n try {\n ctx.rawBody = new Uint8Array(await request.arrayBuffer());\n\n await WebhookRouter.runBeforeHooks(ctx, this.globalBeforeHooks);\n await WebhookRouter.runBeforeHooks(ctx, handlerEntry.before);\n\n if (this.verify) {\n await this.verify(ctx);\n }\n\n const parsedJson = WebhookRouter.parseRequestBody(ctx);\n const validationResult = await WebhookRouter.validatePayload(\n parsedJson,\n handlerEntry.schema\n );\n\n if (validationResult instanceof Response) {\n return validationResult;\n }\n\n const response = await WebhookRouter.executeHandler(\n handlerEntry.handler,\n ctx,\n validationResult\n );\n\n await WebhookRouter.runAfterHooks(ctx, response, handlerEntry.after);\n await WebhookRouter.runAfterHooks(ctx, response, this.globalAfterHooks);\n\n return response;\n } catch (error) {\n return await this.handleError(error, ctx);\n }\n }\n\n private matchPath(request: Request): string | null {\n const pathname = normalizePath(new URL(request.url).pathname);\n\n // Root mount: the whole pathname is the route path.\n if (this.prefix === \"/\") {\n return pathname;\n }\n\n if (pathname === this.prefix) {\n return \"/\";\n }\n\n // Require prefix followed by a segment boundary, then match handlers on\n // the remainder (e.g. /webhooks/stripe -> /stripe).\n if (!pathname.startsWith(this.prefixWithSlash)) {\n return null;\n }\n\n return pathname.slice(this.prefix.length);\n }\n\n private static async runBeforeHooks(\n ctx: WebhookContext,\n hooks?: BeforeHook[]\n ): Promise<void> {\n if (!hooks || hooks.length === 0) {\n return;\n }\n\n for (const hook of hooks) {\n // oxlint-disable-next-line no-await-in-loop -- hooks run sequentially; order + short-circuit matter.\n await hook(ctx);\n }\n }\n\n private static async runAfterHooks(\n ctx: WebhookContext,\n response: Response,\n hooks?: AfterHook[]\n ): Promise<void> {\n if (!hooks || hooks.length === 0) {\n return;\n }\n\n for (const hook of hooks) {\n // oxlint-disable-next-line no-await-in-loop -- hooks run sequentially; order + short-circuit matter.\n await hook(ctx, response);\n }\n }\n\n private static createHandlerEntry(\n options: RegisterOptions<unknown>\n ): HandlerEntry {\n const entry: HandlerEntry = {\n handler: options.handler,\n };\n\n if (options.schema !== undefined) {\n entry.schema = options.schema;\n }\n\n if (options.before !== undefined) {\n entry.before = Array.isArray(options.before)\n ? options.before\n : [options.before];\n }\n\n if (options.after !== undefined) {\n entry.after = Array.isArray(options.after)\n ? options.after\n : [options.after];\n }\n\n return entry;\n }\n\n private static parseRequestBody(ctx: WebhookContext): unknown {\n try {\n return JSON.parse(bodyDecoder.decode(ctx.rawBody)) as unknown;\n } catch {\n return undefined;\n }\n }\n\n private static async validatePayload<TPayload>(\n parsedJson: unknown,\n schema?: StandardSchemaV1<unknown, TPayload>\n ): Promise<TPayload | Response> {\n if (!schema) {\n // oxlint-disable-next-line typescript/no-unsafe-type-assertion -- Without a schema, caller-declared payload type is the route contract.\n return parsedJson as TPayload;\n }\n\n const result = await standardValidate(schema, parsedJson, {\n throwOnError: false,\n });\n\n if (result.issues) {\n return Response.json(\n {\n error: \"validation failed\",\n issues: result.issues.map((issue) => ({\n message: issue.message,\n path: issue.path?.map((p) =>\n typeof p === \"object\" && \"key\" in p ? String(p.key) : String(p)\n ),\n })),\n },\n { status: 400 }\n );\n }\n\n return result.value;\n }\n\n private static async executeHandler<TPayload = unknown>(\n handler: WebhookHandler<TPayload>,\n ctx: WebhookContext,\n validatedPayload: TPayload\n ): Promise<Response> {\n const responded = await handler({\n ...ctx,\n payload: validatedPayload,\n });\n\n return responded ?? Response.json(\"ok\");\n }\n\n private async handleError(\n error: unknown,\n ctx: WebhookContext\n ): Promise<Response> {\n if (this.globalErrorHook) {\n const normalizedError =\n error instanceof Error ? error : new Error(\"Internal server error\");\n const errorResponse = await this.globalErrorHook(normalizedError, ctx);\n if (errorResponse) {\n return errorResponse;\n }\n }\n\n return Response.json(\n {\n error: error instanceof Error ? error.message : \"Internal server error\",\n },\n { status: 500 }\n );\n }\n}\n\n/**\n * Factory helper for creating a webhook router instance.\n *\n * @param opts - Optional global router options.\n * @returns A new webhook router.\n */\nexport const createWebhookRouter = (\n opts?: WebhookRouterOptions\n): WebhookRouter => new WebhookRouter(opts);\n"],"mappings":"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"}
|