@zap-studio/webhooks 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +13 -0
- package/LICENSE +21 -0
- package/README.md +198 -0
- package/dist/adapters/base.d.mts +58 -0
- package/dist/adapters/base.mjs +25 -0
- package/dist/index.d.mts +68 -0
- package/dist/index.mjs +156 -0
- package/dist/types/index.d.mts +92 -0
- package/dist/types/index.mjs +1 -0
- package/dist/utils/index.d.mts +12 -0
- package/dist/utils/index.mjs +18 -0
- package/dist/verify.d.mts +49 -0
- package/dist/verify.mjs +47 -0
- package/package.json +70 -0
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# @zap-studio/webhooks
|
|
2
|
+
|
|
3
|
+
## 0.1.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- 0d6254c: Initial public release of `@zap-studio/webhooks`.
|
|
8
|
+
|
|
9
|
+
- Introduces a schema-first webhook router with inferred payload types.
|
|
10
|
+
- Adds request verification support, including `createHmacVerifier`.
|
|
11
|
+
- Provides lifecycle hooks (`before`, `after`, `onError`) for cross-cutting concerns.
|
|
12
|
+
- Exposes framework-agnostic adapter contracts via `Adapter` and `BaseAdapter`.
|
|
13
|
+
- Includes comprehensive test coverage and documentation.
|
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 Alexandre Trotel
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
# @zap-studio/webhooks
|
|
2
|
+
|
|
3
|
+
Schema-first, type-safe webhook routing with signature verification support.
|
|
4
|
+
|
|
5
|
+
Works with any validation library that implements [Standard Schema](https://github.com/standard-schema/standard-schema), including Zod, Valibot, and ArkType.
|
|
6
|
+
|
|
7
|
+
## Why this package exists
|
|
8
|
+
|
|
9
|
+
Webhook handlers usually repeat the same plumbing:
|
|
10
|
+
|
|
11
|
+
- verify request authenticity
|
|
12
|
+
- parse and validate payloads
|
|
13
|
+
- route by event path
|
|
14
|
+
- normalize success/error responses
|
|
15
|
+
|
|
16
|
+
`@zap-studio/webhooks` isolates that plumbing so your handler code stays focused on business logic.
|
|
17
|
+
|
|
18
|
+
Schemas are the source of truth, and payload types are inferred from them.
|
|
19
|
+
|
|
20
|
+
## Install
|
|
21
|
+
|
|
22
|
+
```bash
|
|
23
|
+
pnpm add @zap-studio/webhooks
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
## Quickstart
|
|
27
|
+
|
|
28
|
+
```ts
|
|
29
|
+
import { createWebhookRouter } from "@zap-studio/webhooks";
|
|
30
|
+
import { z } from "zod";
|
|
31
|
+
|
|
32
|
+
const router = createWebhookRouter({
|
|
33
|
+
prefix: "/webhooks/",
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
router.register("payments/succeeded", {
|
|
37
|
+
schema: z.object({
|
|
38
|
+
id: z.string(),
|
|
39
|
+
amount: z.number().positive(),
|
|
40
|
+
currency: z.string().length(3),
|
|
41
|
+
}),
|
|
42
|
+
handler: async ({ payload, ack }) => {
|
|
43
|
+
// payload is inferred from schema
|
|
44
|
+
return ack({ status: 200, body: `processed ${payload.id}` });
|
|
45
|
+
},
|
|
46
|
+
});
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
## GitHub webhook example
|
|
50
|
+
|
|
51
|
+
```ts
|
|
52
|
+
import { createWebhookRouter } from "@zap-studio/webhooks";
|
|
53
|
+
import { createHmacVerifier } from "@zap-studio/webhooks/verify";
|
|
54
|
+
import { z } from "zod";
|
|
55
|
+
|
|
56
|
+
const router = createWebhookRouter({
|
|
57
|
+
verify: createHmacVerifier({
|
|
58
|
+
headerName: "x-hub-signature-256",
|
|
59
|
+
secret: process.env.GITHUB_WEBHOOK_SECRET!,
|
|
60
|
+
}),
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
router.register("github/push", {
|
|
64
|
+
schema: z.object({
|
|
65
|
+
ref: z.string(),
|
|
66
|
+
repository: z.object({
|
|
67
|
+
full_name: z.string(),
|
|
68
|
+
}),
|
|
69
|
+
}),
|
|
70
|
+
handler: async ({ payload, ack }) => {
|
|
71
|
+
console.log(`[github] ${payload.repository.full_name} ${payload.ref}`);
|
|
72
|
+
return ack();
|
|
73
|
+
},
|
|
74
|
+
});
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
## Stripe webhook example
|
|
78
|
+
|
|
79
|
+
```ts
|
|
80
|
+
import Stripe from "stripe";
|
|
81
|
+
import { createWebhookRouter } from "@zap-studio/webhooks";
|
|
82
|
+
import { z } from "zod";
|
|
83
|
+
|
|
84
|
+
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
|
|
85
|
+
|
|
86
|
+
const router = createWebhookRouter({
|
|
87
|
+
verify: async (req) => {
|
|
88
|
+
const signature = req.headers.get("stripe-signature");
|
|
89
|
+
if (!signature) {
|
|
90
|
+
throw new Error("Missing Stripe signature");
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
stripe.webhooks.constructEvent(
|
|
94
|
+
req.rawBody,
|
|
95
|
+
signature,
|
|
96
|
+
process.env.STRIPE_WEBHOOK_SECRET!
|
|
97
|
+
);
|
|
98
|
+
},
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
router.register("stripe/payment_intent.succeeded", {
|
|
102
|
+
schema: z.object({
|
|
103
|
+
id: z.string(),
|
|
104
|
+
object: z.literal("event"),
|
|
105
|
+
type: z.literal("payment_intent.succeeded"),
|
|
106
|
+
}),
|
|
107
|
+
handler: async ({ payload, ack }) => {
|
|
108
|
+
console.log(`[stripe] event ${payload.id} (${payload.type})`);
|
|
109
|
+
return ack({ status: 200 });
|
|
110
|
+
},
|
|
111
|
+
});
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
## Lifecycle hooks
|
|
115
|
+
|
|
116
|
+
Lifecycle hooks let you apply cross-cutting behavior without duplicating code in each handler:
|
|
117
|
+
|
|
118
|
+
- `before`: run logic before verify/validation/handler (logging, tracing, rate-limit checks)
|
|
119
|
+
- `after`: run logic after successful handler execution (metrics, audit logs)
|
|
120
|
+
- `onError`: map thrown errors to consistent responses and centralize error reporting
|
|
121
|
+
|
|
122
|
+
```ts
|
|
123
|
+
const router = createWebhookRouter({
|
|
124
|
+
before: (req) => {
|
|
125
|
+
console.log("incoming", req.path);
|
|
126
|
+
},
|
|
127
|
+
after: (_req, res) => {
|
|
128
|
+
console.log("status", res.status);
|
|
129
|
+
},
|
|
130
|
+
onError: (error) => ({
|
|
131
|
+
status: 500,
|
|
132
|
+
body: { error: error.message },
|
|
133
|
+
}),
|
|
134
|
+
});
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
## Verification helper
|
|
138
|
+
|
|
139
|
+
`@zap-studio/webhooks/verify` exports `createHmacVerifier`, a small helper that builds a `verify` function for HMAC-signed webhook providers.
|
|
140
|
+
|
|
141
|
+
- reads a signature from the header you choose
|
|
142
|
+
- computes an HMAC from `req.rawBody`
|
|
143
|
+
- compares signatures in constant time
|
|
144
|
+
|
|
145
|
+
```ts
|
|
146
|
+
import { createHmacVerifier } from "@zap-studio/webhooks/verify";
|
|
147
|
+
|
|
148
|
+
const verify = createHmacVerifier({
|
|
149
|
+
headerName: "x-hub-signature-256",
|
|
150
|
+
secret: process.env.WEBHOOK_SECRET!,
|
|
151
|
+
algo: "sha256", // optional, defaults to sha256
|
|
152
|
+
});
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
Use this when your provider uses standard HMAC signatures. For providers with custom signing formats, pass your own `verify` function.
|
|
156
|
+
|
|
157
|
+
## Why `BaseAdapter` exists
|
|
158
|
+
|
|
159
|
+
This package is framework-agnostic by design. It does not include Express/Next/Hono/Elysia adapters.
|
|
160
|
+
|
|
161
|
+
`BaseAdapter` exists to help consumers implement adapters consistently:
|
|
162
|
+
|
|
163
|
+
- you only implement `toNormalizedRequest` and `toFrameworkResponse`
|
|
164
|
+
- `BaseAdapter` handles the common `handleWebhook()` flow
|
|
165
|
+
- teams can reuse one adapter implementation across all webhook routes
|
|
166
|
+
|
|
167
|
+
```ts
|
|
168
|
+
import { BaseAdapter } from "@zap-studio/webhooks/adapters/base";
|
|
169
|
+
import type { NormalizedRequest, NormalizedResponse } from "@zap-studio/webhooks/types";
|
|
170
|
+
|
|
171
|
+
class MyHttpAdapter extends BaseAdapter {
|
|
172
|
+
async toNormalizedRequest(req: any): Promise<NormalizedRequest> {
|
|
173
|
+
return {
|
|
174
|
+
method: req.method,
|
|
175
|
+
path: req.url,
|
|
176
|
+
headers: new Headers(req.headers),
|
|
177
|
+
rawBody: req.rawBody,
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
async toFrameworkResponse(
|
|
182
|
+
res: any,
|
|
183
|
+
normalized: NormalizedResponse
|
|
184
|
+
): Promise<any> {
|
|
185
|
+
res.statusCode = normalized.status;
|
|
186
|
+
res.end(
|
|
187
|
+
typeof normalized.body === "string"
|
|
188
|
+
? normalized.body
|
|
189
|
+
: JSON.stringify(normalized.body)
|
|
190
|
+
);
|
|
191
|
+
return res;
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
```
|
|
195
|
+
|
|
196
|
+
## License
|
|
197
|
+
|
|
198
|
+
MIT
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { NormalizedRequest, NormalizedResponse } from "../types/index.mjs";
|
|
2
|
+
|
|
3
|
+
//#region src/adapters/base.d.ts
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Minimal framework adapter contract.
|
|
7
|
+
*
|
|
8
|
+
* Implement this when integrating the webhook router with an HTTP framework.
|
|
9
|
+
*/
|
|
10
|
+
interface Adapter {
|
|
11
|
+
/**
|
|
12
|
+
* Creates a framework handler that:
|
|
13
|
+
* 1. normalizes the incoming framework request
|
|
14
|
+
* 2. executes the webhook router
|
|
15
|
+
* 3. writes the normalized response back to the framework response
|
|
16
|
+
*/
|
|
17
|
+
handleWebhook<TFrameworkReq = unknown, TFrameworkRes = unknown>(router: {
|
|
18
|
+
handle(req: NormalizedRequest): Promise<NormalizedResponse>;
|
|
19
|
+
}): (req: TFrameworkReq, res: TFrameworkRes) => Promise<void>;
|
|
20
|
+
/**
|
|
21
|
+
* Maps a normalized router response to the framework response object.
|
|
22
|
+
*
|
|
23
|
+
* @param frameworkRes - Framework-specific response object (e.g. `res`)
|
|
24
|
+
* @param res - Normalized response returned by the webhook router
|
|
25
|
+
*/
|
|
26
|
+
toFrameworkResponse<TFrameworkRes = unknown>(frameworkRes: TFrameworkRes, res: NormalizedResponse): Promise<TFrameworkRes>;
|
|
27
|
+
/**
|
|
28
|
+
* Maps a framework request into the normalized request contract.
|
|
29
|
+
*
|
|
30
|
+
* The returned object must include `rawBody` to support signature verification.
|
|
31
|
+
*
|
|
32
|
+
* @param req - Framework-specific request object
|
|
33
|
+
*/
|
|
34
|
+
toNormalizedRequest<TReq = unknown>(req: TReq): Promise<NormalizedRequest>;
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Base adapter helper.
|
|
38
|
+
*
|
|
39
|
+
* Extend this class in consumers to keep framework integration boilerplate
|
|
40
|
+
* in one place while relying on the package router contract.
|
|
41
|
+
*/
|
|
42
|
+
declare abstract class BaseAdapter implements Adapter {
|
|
43
|
+
/** @inheritdoc */
|
|
44
|
+
abstract toNormalizedRequest<TReq = unknown>(req: TReq): Promise<NormalizedRequest>;
|
|
45
|
+
/** @inheritdoc */
|
|
46
|
+
abstract toFrameworkResponse<TFrameworkRes = unknown>(frameworkRes: TFrameworkRes, res: NormalizedResponse): Promise<TFrameworkRes>;
|
|
47
|
+
/**
|
|
48
|
+
* Shared adapter pipeline implementation.
|
|
49
|
+
*
|
|
50
|
+
* Most consumers only need to implement request/response mapping methods and
|
|
51
|
+
* can reuse this default orchestration.
|
|
52
|
+
*/
|
|
53
|
+
handleWebhook<TFrameworkReq = unknown, TFrameworkRes = unknown>(router: {
|
|
54
|
+
handle(req: NormalizedRequest): Promise<NormalizedResponse>;
|
|
55
|
+
}): (req: TFrameworkReq, res: TFrameworkRes) => Promise<void>;
|
|
56
|
+
}
|
|
57
|
+
//#endregion
|
|
58
|
+
export { Adapter, BaseAdapter };
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
//#region src/adapters/base.ts
|
|
2
|
+
/**
|
|
3
|
+
* Base adapter helper.
|
|
4
|
+
*
|
|
5
|
+
* Extend this class in consumers to keep framework integration boilerplate
|
|
6
|
+
* in one place while relying on the package router contract.
|
|
7
|
+
*/
|
|
8
|
+
var BaseAdapter = class {
|
|
9
|
+
/**
|
|
10
|
+
* Shared adapter pipeline implementation.
|
|
11
|
+
*
|
|
12
|
+
* Most consumers only need to implement request/response mapping methods and
|
|
13
|
+
* can reuse this default orchestration.
|
|
14
|
+
*/
|
|
15
|
+
handleWebhook(router) {
|
|
16
|
+
return async (req, res) => {
|
|
17
|
+
const normalizedReq = await this.toNormalizedRequest(req);
|
|
18
|
+
const normalizedRes = await router.handle(normalizedReq);
|
|
19
|
+
await this.toFrameworkResponse(res, normalizedRes);
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
//#endregion
|
|
25
|
+
export { BaseAdapter };
|
package/dist/index.d.mts
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { AfterHook, BeforeHook, ErrorHook, InferSchemaOutput, NormalizedRequest, NormalizedResponse, RegisterOptions, SchemaRouteOptions, WebhookHandler } from "./types/index.mjs";
|
|
2
|
+
import { StandardSchemaV1 } from "@standard-schema/spec";
|
|
3
|
+
|
|
4
|
+
//#region src/index.d.ts
|
|
5
|
+
interface WebhookRouterOptions {
|
|
6
|
+
/** Global hooks executed after successful route handler completion. */
|
|
7
|
+
after?: AfterHook | AfterHook[];
|
|
8
|
+
/** Global hooks executed before route-level hooks and verification. */
|
|
9
|
+
before?: BeforeHook | BeforeHook[];
|
|
10
|
+
/** Global error hook used to override the default `500` response. */
|
|
11
|
+
onError?: ErrorHook;
|
|
12
|
+
/** Required path prefix for all webhook routes. Defaults to `"/webhooks/"`. */
|
|
13
|
+
prefix?: string;
|
|
14
|
+
/** Optional request verification function (for signature checks, auth, etc.). */
|
|
15
|
+
verify?: (req: NormalizedRequest) => Promise<void> | void;
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* Main webhook router class.
|
|
19
|
+
*
|
|
20
|
+
* Register routes with typed schemas and call `handle` with a normalized request.
|
|
21
|
+
*/
|
|
22
|
+
declare class WebhookRouter<TMap = unknown> {
|
|
23
|
+
private readonly handlers;
|
|
24
|
+
private readonly verify?;
|
|
25
|
+
private readonly globalBeforeHooks;
|
|
26
|
+
private readonly globalAfterHooks;
|
|
27
|
+
private readonly globalErrorHook?;
|
|
28
|
+
private readonly prefix;
|
|
29
|
+
constructor(opts?: WebhookRouterOptions);
|
|
30
|
+
/**
|
|
31
|
+
* Register a webhook handler for a specific path.
|
|
32
|
+
*
|
|
33
|
+
* When a schema is provided, `payload` is inferred from the schema output type.
|
|
34
|
+
*
|
|
35
|
+
* @param path - Route path relative to configured prefix.
|
|
36
|
+
* @param handlerOrOptions - Handler function or schema-based registration options.
|
|
37
|
+
* @returns The same router instance with an updated internal route type map.
|
|
38
|
+
*/
|
|
39
|
+
register<Path extends string, TSchema extends StandardSchemaV1<unknown, unknown>>(path: Path, handlerOrOptions: SchemaRouteOptions<TSchema>): WebhookRouter<TMap & Record<Path, InferSchemaOutput<TSchema>>>;
|
|
40
|
+
register<Path extends string, TPayload>(path: Path, handlerOrOptions: RegisterOptions<TPayload>): WebhookRouter<TMap & Record<Path, TPayload>>;
|
|
41
|
+
register<Path extends string>(path: Path, handlerOrOptions: WebhookHandler<unknown>): WebhookRouter<TMap & Record<Path, unknown>>;
|
|
42
|
+
/**
|
|
43
|
+
* Handles a normalized incoming webhook request.
|
|
44
|
+
*
|
|
45
|
+
* @param req - Normalized request object.
|
|
46
|
+
* @returns Normalized response for the adapter/framework layer.
|
|
47
|
+
*/
|
|
48
|
+
handle(req: NormalizedRequest): Promise<NormalizedResponse>;
|
|
49
|
+
private normalizePath;
|
|
50
|
+
private runGlobalBeforeHooks;
|
|
51
|
+
private runRouteBeforeHooks;
|
|
52
|
+
private parseRequestBody;
|
|
53
|
+
private isErrorResponse;
|
|
54
|
+
private validatePayload;
|
|
55
|
+
private executeHandler;
|
|
56
|
+
private runRouteAfterHooks;
|
|
57
|
+
private runGlobalAfterHooks;
|
|
58
|
+
private handleError;
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* Factory helper for creating a webhook router instance.
|
|
62
|
+
*
|
|
63
|
+
* @param opts - Optional global router options.
|
|
64
|
+
* @returns A new webhook router.
|
|
65
|
+
*/
|
|
66
|
+
declare function createWebhookRouter(opts?: WebhookRouterOptions): WebhookRouter;
|
|
67
|
+
//#endregion
|
|
68
|
+
export { WebhookRouter, WebhookRouterOptions, createWebhookRouter };
|
package/dist/index.mjs
ADDED
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
import { standardValidate } from "@zap-studio/validation";
|
|
2
|
+
|
|
3
|
+
//#region src/index.ts
|
|
4
|
+
/**
|
|
5
|
+
* Main webhook router class.
|
|
6
|
+
*
|
|
7
|
+
* Register routes with typed schemas and call `handle` with a normalized request.
|
|
8
|
+
*/
|
|
9
|
+
var WebhookRouter = class {
|
|
10
|
+
handlers = {};
|
|
11
|
+
verify;
|
|
12
|
+
globalBeforeHooks = [];
|
|
13
|
+
globalAfterHooks = [];
|
|
14
|
+
globalErrorHook;
|
|
15
|
+
prefix;
|
|
16
|
+
constructor(opts) {
|
|
17
|
+
this.prefix = opts?.prefix ?? "/webhooks/";
|
|
18
|
+
if (opts?.verify) this.verify = opts.verify;
|
|
19
|
+
if (opts?.before) this.globalBeforeHooks = Array.isArray(opts.before) ? opts.before : [opts.before];
|
|
20
|
+
if (opts?.after) this.globalAfterHooks = Array.isArray(opts.after) ? opts.after : [opts.after];
|
|
21
|
+
if (opts?.onError) this.globalErrorHook = opts.onError;
|
|
22
|
+
}
|
|
23
|
+
register(path, handlerOrOptions) {
|
|
24
|
+
if (typeof handlerOrOptions === "function") this.handlers[path] = { handler: handlerOrOptions };
|
|
25
|
+
else {
|
|
26
|
+
let beforeHooks;
|
|
27
|
+
if (handlerOrOptions.before) beforeHooks = Array.isArray(handlerOrOptions.before) ? handlerOrOptions.before : [handlerOrOptions.before];
|
|
28
|
+
let afterHooks;
|
|
29
|
+
if (handlerOrOptions.after) afterHooks = Array.isArray(handlerOrOptions.after) ? handlerOrOptions.after : [handlerOrOptions.after];
|
|
30
|
+
this.handlers[path] = {
|
|
31
|
+
handler: handlerOrOptions.handler,
|
|
32
|
+
schema: handlerOrOptions.schema,
|
|
33
|
+
before: beforeHooks,
|
|
34
|
+
after: afterHooks
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
return this;
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Handles a normalized incoming webhook request.
|
|
41
|
+
*
|
|
42
|
+
* @param req - Normalized request object.
|
|
43
|
+
* @returns Normalized response for the adapter/framework layer.
|
|
44
|
+
*/
|
|
45
|
+
async handle(req) {
|
|
46
|
+
try {
|
|
47
|
+
const normalizedPath = this.normalizePath(req);
|
|
48
|
+
if (normalizedPath === null) return {
|
|
49
|
+
status: 404,
|
|
50
|
+
body: { error: "not found" }
|
|
51
|
+
};
|
|
52
|
+
const handlerEntry = this.handlers[normalizedPath];
|
|
53
|
+
if (!handlerEntry) return {
|
|
54
|
+
status: 404,
|
|
55
|
+
body: { error: "not found" }
|
|
56
|
+
};
|
|
57
|
+
await this.runGlobalBeforeHooks(req);
|
|
58
|
+
await this.runRouteBeforeHooks(req, handlerEntry.before);
|
|
59
|
+
if (this.verify) await this.verify(req);
|
|
60
|
+
const parsedJson = this.parseRequestBody(req);
|
|
61
|
+
const validationResult = await this.validatePayload(parsedJson, handlerEntry.schema);
|
|
62
|
+
if (this.isErrorResponse(validationResult)) return validationResult;
|
|
63
|
+
const response = await this.executeHandler(handlerEntry.handler, req, validationResult);
|
|
64
|
+
await this.runRouteAfterHooks(req, response, handlerEntry.after);
|
|
65
|
+
await this.runGlobalAfterHooks(req, response);
|
|
66
|
+
return response;
|
|
67
|
+
} catch (error) {
|
|
68
|
+
return this.handleError(error, req);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
normalizePath(req) {
|
|
72
|
+
let pathname = req.path;
|
|
73
|
+
try {
|
|
74
|
+
pathname = new URL(req.path).pathname;
|
|
75
|
+
} catch {}
|
|
76
|
+
if (!pathname.startsWith(this.prefix)) return null;
|
|
77
|
+
pathname = pathname.slice(this.prefix.length - 1);
|
|
78
|
+
req.path = pathname;
|
|
79
|
+
return pathname.startsWith("/") ? pathname.slice(1) : pathname;
|
|
80
|
+
}
|
|
81
|
+
async runGlobalBeforeHooks(req) {
|
|
82
|
+
for (const hook of this.globalBeforeHooks) await hook(req);
|
|
83
|
+
}
|
|
84
|
+
async runRouteBeforeHooks(req, before) {
|
|
85
|
+
if (before) for (const hook of before) await hook(req);
|
|
86
|
+
}
|
|
87
|
+
parseRequestBody(req) {
|
|
88
|
+
try {
|
|
89
|
+
const parsed = JSON.parse(req.rawBody.toString());
|
|
90
|
+
req.json = parsed;
|
|
91
|
+
return parsed;
|
|
92
|
+
} catch {
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
isErrorResponse(value) {
|
|
97
|
+
return typeof value === "object" && value !== null && "status" in value && typeof value.status === "number";
|
|
98
|
+
}
|
|
99
|
+
async validatePayload(parsedJson, schema) {
|
|
100
|
+
if (!schema) return parsedJson;
|
|
101
|
+
const result = await standardValidate(schema, parsedJson, false);
|
|
102
|
+
if (result.issues) return {
|
|
103
|
+
status: 400,
|
|
104
|
+
body: {
|
|
105
|
+
error: "validation failed",
|
|
106
|
+
issues: result.issues.map((issue) => ({
|
|
107
|
+
path: issue.path?.map((p) => typeof p === "object" && "key" in p ? String(p.key) : String(p)),
|
|
108
|
+
message: issue.message
|
|
109
|
+
}))
|
|
110
|
+
}
|
|
111
|
+
};
|
|
112
|
+
return result.value;
|
|
113
|
+
}
|
|
114
|
+
async executeHandler(handler, req, validatedPayload) {
|
|
115
|
+
return await handler({
|
|
116
|
+
req,
|
|
117
|
+
payload: validatedPayload,
|
|
118
|
+
ack: async (r) => ({
|
|
119
|
+
status: r?.status ?? 200,
|
|
120
|
+
body: r?.body ?? "ok",
|
|
121
|
+
headers: r?.headers
|
|
122
|
+
})
|
|
123
|
+
}) ?? {
|
|
124
|
+
status: 200,
|
|
125
|
+
body: "ok"
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
async runRouteAfterHooks(req, response, after) {
|
|
129
|
+
if (after) for (const hook of after) await hook(req, response);
|
|
130
|
+
}
|
|
131
|
+
async runGlobalAfterHooks(req, response) {
|
|
132
|
+
for (const hook of this.globalAfterHooks) await hook(req, response);
|
|
133
|
+
}
|
|
134
|
+
async handleError(error, req) {
|
|
135
|
+
if (this.globalErrorHook) {
|
|
136
|
+
const errorResponse = await this.globalErrorHook(error, req);
|
|
137
|
+
if (errorResponse) return errorResponse;
|
|
138
|
+
}
|
|
139
|
+
return {
|
|
140
|
+
status: 500,
|
|
141
|
+
body: { error: error instanceof Error ? error.message : "Internal server error" }
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
};
|
|
145
|
+
/**
|
|
146
|
+
* Factory helper for creating a webhook router instance.
|
|
147
|
+
*
|
|
148
|
+
* @param opts - Optional global router options.
|
|
149
|
+
* @returns A new webhook router.
|
|
150
|
+
*/
|
|
151
|
+
function createWebhookRouter(opts) {
|
|
152
|
+
return new WebhookRouter(opts);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
//#endregion
|
|
156
|
+
export { WebhookRouter, createWebhookRouter };
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { StandardSchemaV1 } from "@standard-schema/spec";
|
|
2
|
+
|
|
3
|
+
//#region src/types/index.d.ts
|
|
4
|
+
/** Framework-agnostic request shape consumed by the webhook router. */
|
|
5
|
+
interface NormalizedRequest {
|
|
6
|
+
/** The headers of the request (e.g. { "Authorization": "Bearer token" }) */
|
|
7
|
+
headers: Headers;
|
|
8
|
+
/** The parsed JSON body of the request if applicable */
|
|
9
|
+
json?: unknown;
|
|
10
|
+
/** The HTTP method of the request */
|
|
11
|
+
method: Request["method"];
|
|
12
|
+
/** The route parameters of the request */
|
|
13
|
+
params?: Record<string, string>;
|
|
14
|
+
/** The path of the request you registered in the router (e.g. "payment", "subscription") */
|
|
15
|
+
path: string;
|
|
16
|
+
/** The query parameters of the request */
|
|
17
|
+
query?: Record<string, string | string[]>;
|
|
18
|
+
/** The raw body of the request (for signature) */
|
|
19
|
+
rawBody: Buffer;
|
|
20
|
+
/** The parsed text body of the request if applicable */
|
|
21
|
+
text?: string;
|
|
22
|
+
}
|
|
23
|
+
/** Framework-agnostic response shape returned by the webhook router. */
|
|
24
|
+
interface NormalizedResponse<TBody = unknown> {
|
|
25
|
+
/** The body of the response */
|
|
26
|
+
body?: TBody;
|
|
27
|
+
/** The headers of the response */
|
|
28
|
+
headers?: Headers;
|
|
29
|
+
/** The HTTP status code of the response */
|
|
30
|
+
status: number;
|
|
31
|
+
}
|
|
32
|
+
/** Route registration options for a webhook handler. */
|
|
33
|
+
interface RegisterOptions<T> {
|
|
34
|
+
/** Hooks that run after successful processing (before global after hooks) */
|
|
35
|
+
after?: AfterHook | AfterHook[];
|
|
36
|
+
/** Hooks that run before request processing (after global before hooks) */
|
|
37
|
+
before?: BeforeHook | BeforeHook[];
|
|
38
|
+
/** The handler function to process the webhook */
|
|
39
|
+
handler: WebhookHandler<T>;
|
|
40
|
+
/** Optional Standard Schema validator to validate the webhook payload */
|
|
41
|
+
schema?: StandardSchemaV1<unknown, T>;
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Infers the output type from a Standard Schema instance.
|
|
45
|
+
*
|
|
46
|
+
* @typeParam TSchema - A Standard Schema type.
|
|
47
|
+
*/
|
|
48
|
+
type InferSchemaOutput<TSchema> = TSchema extends StandardSchemaV1<unknown, infer TOutput> ? TOutput : never;
|
|
49
|
+
/**
|
|
50
|
+
* Route options where schema is required and handler payload is inferred.
|
|
51
|
+
*
|
|
52
|
+
* @typeParam TSchema - Schema used to infer handler payload type.
|
|
53
|
+
*/
|
|
54
|
+
type SchemaRouteOptions<TSchema extends StandardSchemaV1<unknown, unknown>> = Omit<RegisterOptions<InferSchemaOutput<TSchema>>, "schema"> & {
|
|
55
|
+
schema: TSchema;
|
|
56
|
+
};
|
|
57
|
+
interface RouteLike {
|
|
58
|
+
after?: AfterHook | AfterHook[];
|
|
59
|
+
before?: BeforeHook | BeforeHook[];
|
|
60
|
+
handler: WebhookHandler<unknown>;
|
|
61
|
+
schema: StandardSchemaV1<unknown, unknown>;
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* Applies schema-driven payload inference to each route entry.
|
|
65
|
+
*
|
|
66
|
+
* @typeParam TRoutes - Route dictionary keyed by webhook path.
|
|
67
|
+
*/
|
|
68
|
+
type SchemaRoutes<TRoutes extends Record<string, RouteLike>> = { [P in keyof TRoutes]: SchemaRouteOptions<TRoutes[P]["schema"]> };
|
|
69
|
+
/** The webhook handler function, responsible for processing incoming webhook events. */
|
|
70
|
+
type WebhookHandler<TPayload = unknown> = (ctx: {
|
|
71
|
+
req: NormalizedRequest;
|
|
72
|
+
payload: TPayload;
|
|
73
|
+
ack: (res?: Partial<NormalizedResponse>) => Promise<NormalizedResponse>;
|
|
74
|
+
}) => Promise<NormalizedResponse | undefined> | NormalizedResponse | undefined;
|
|
75
|
+
/** Maps route keys to their payload-specific webhook handlers. */
|
|
76
|
+
type HandlerMap<TMap extends Record<string, unknown>> = { [P in keyof TMap]: WebhookHandler<TMap[P]> };
|
|
77
|
+
/**
|
|
78
|
+
* Builds a webhook payload map from a schema-based route dictionary.
|
|
79
|
+
*
|
|
80
|
+
* @typeParam TRoutes - Route dictionary keyed by webhook path.
|
|
81
|
+
*/
|
|
82
|
+
type InferWebhookMapFromRoutes<TRoutes extends Record<string, RouteLike>> = { [P in keyof TRoutes]: InferSchemaOutput<TRoutes[P]["schema"]> };
|
|
83
|
+
/** Verification function for incoming requests */
|
|
84
|
+
type VerifyFn = (req: NormalizedRequest) => Promise<void> | void;
|
|
85
|
+
/** Hook function that runs before request processing */
|
|
86
|
+
type BeforeHook = (req: NormalizedRequest) => Promise<void> | void;
|
|
87
|
+
/** Hook function that runs after successful request processing */
|
|
88
|
+
type AfterHook = (req: NormalizedRequest, res: NormalizedResponse) => Promise<void> | void;
|
|
89
|
+
/** Hook function that runs when an error occurs */
|
|
90
|
+
type ErrorHook = (error: Error, req: NormalizedRequest) => Promise<NormalizedResponse | undefined> | NormalizedResponse | undefined;
|
|
91
|
+
//#endregion
|
|
92
|
+
export { AfterHook, BeforeHook, ErrorHook, HandlerMap, InferSchemaOutput, InferWebhookMapFromRoutes, NormalizedRequest, NormalizedResponse, RegisterOptions, SchemaRouteOptions, SchemaRoutes, VerifyFn, WebhookHandler };
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { };
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
//#region src/utils/index.d.ts
|
|
2
|
+
/**
|
|
3
|
+
* Compares two strings in constant time to prevent timing attacks.
|
|
4
|
+
*
|
|
5
|
+
* @example
|
|
6
|
+
* ```ts
|
|
7
|
+
* const isEqual = constantTimeEquals("string1", "string2"); // returns false
|
|
8
|
+
* ```
|
|
9
|
+
*/
|
|
10
|
+
declare function constantTimeEquals(a: string, b: string): boolean;
|
|
11
|
+
//#endregion
|
|
12
|
+
export { constantTimeEquals };
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
//#region src/utils/index.ts
|
|
2
|
+
/**
|
|
3
|
+
* Compares two strings in constant time to prevent timing attacks.
|
|
4
|
+
*
|
|
5
|
+
* @example
|
|
6
|
+
* ```ts
|
|
7
|
+
* const isEqual = constantTimeEquals("string1", "string2"); // returns false
|
|
8
|
+
* ```
|
|
9
|
+
*/
|
|
10
|
+
function constantTimeEquals(a, b) {
|
|
11
|
+
if (a.length !== b.length) return false;
|
|
12
|
+
let result = 0;
|
|
13
|
+
for (let i = 0; i < a.length; i += 1) result |= a.charCodeAt(i) ^ b.charCodeAt(i);
|
|
14
|
+
return result === 0;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
//#endregion
|
|
18
|
+
export { constantTimeEquals };
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { VerifyFn } from "./types/index.mjs";
|
|
2
|
+
import { BinaryLike, KeyObject } from "node:crypto";
|
|
3
|
+
|
|
4
|
+
//#region src/verify.d.ts
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Creates an HMAC-based request verification function.
|
|
8
|
+
*
|
|
9
|
+
* The returned verifier reads the configured signature header, computes the
|
|
10
|
+
* expected HMAC from `req.rawBody`, and compares them in constant time.
|
|
11
|
+
*
|
|
12
|
+
* @note Uses Node.js `crypto` at runtime.
|
|
13
|
+
*
|
|
14
|
+
* @example
|
|
15
|
+
* ```ts
|
|
16
|
+
* import { createWebhookRouter } from "@zap-studio/webhooks";
|
|
17
|
+
* import { createHmacVerifier } from "@zap-studio/webhooks/verify";
|
|
18
|
+
* import { z } from "zod";
|
|
19
|
+
*
|
|
20
|
+
* const router = createWebhookRouter({
|
|
21
|
+
* verify: createHmacVerifier({
|
|
22
|
+
* headerName: "x-hub-signature-256",
|
|
23
|
+
* secret: process.env.GITHUB_WEBHOOK_SECRET!,
|
|
24
|
+
* }),
|
|
25
|
+
* });
|
|
26
|
+
*
|
|
27
|
+
* router.register("github/push", {
|
|
28
|
+
* schema: z.object({ ref: z.string() }),
|
|
29
|
+
* handler: async ({ ack }) => ack(),
|
|
30
|
+
* });
|
|
31
|
+
* ```
|
|
32
|
+
*
|
|
33
|
+
* @param options - HMAC verifier options.
|
|
34
|
+
* @param options.headerName - Header containing provider signature.
|
|
35
|
+
* @param options.secret - HMAC secret key.
|
|
36
|
+
* @param options.algo - Hash algorithm used for HMAC generation.
|
|
37
|
+
* @returns A verifier function compatible with router `verify`.
|
|
38
|
+
*/
|
|
39
|
+
declare function createHmacVerifier({
|
|
40
|
+
headerName,
|
|
41
|
+
secret,
|
|
42
|
+
algo
|
|
43
|
+
}: {
|
|
44
|
+
headerName: string;
|
|
45
|
+
secret: BinaryLike | KeyObject;
|
|
46
|
+
algo?: string;
|
|
47
|
+
}): VerifyFn;
|
|
48
|
+
//#endregion
|
|
49
|
+
export { createHmacVerifier };
|
package/dist/verify.mjs
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { constantTimeEquals } from "./utils/index.mjs";
|
|
2
|
+
|
|
3
|
+
//#region src/verify.ts
|
|
4
|
+
const SIGNATURE_REGEX = /^sha256=/;
|
|
5
|
+
/**
|
|
6
|
+
* Creates an HMAC-based request verification function.
|
|
7
|
+
*
|
|
8
|
+
* The returned verifier reads the configured signature header, computes the
|
|
9
|
+
* expected HMAC from `req.rawBody`, and compares them in constant time.
|
|
10
|
+
*
|
|
11
|
+
* @note Uses Node.js `crypto` at runtime.
|
|
12
|
+
*
|
|
13
|
+
* @example
|
|
14
|
+
* ```ts
|
|
15
|
+
* import { createWebhookRouter } from "@zap-studio/webhooks";
|
|
16
|
+
* import { createHmacVerifier } from "@zap-studio/webhooks/verify";
|
|
17
|
+
* import { z } from "zod";
|
|
18
|
+
*
|
|
19
|
+
* const router = createWebhookRouter({
|
|
20
|
+
* verify: createHmacVerifier({
|
|
21
|
+
* headerName: "x-hub-signature-256",
|
|
22
|
+
* secret: process.env.GITHUB_WEBHOOK_SECRET!,
|
|
23
|
+
* }),
|
|
24
|
+
* });
|
|
25
|
+
*
|
|
26
|
+
* router.register("github/push", {
|
|
27
|
+
* schema: z.object({ ref: z.string() }),
|
|
28
|
+
* handler: async ({ ack }) => ack(),
|
|
29
|
+
* });
|
|
30
|
+
* ```
|
|
31
|
+
*
|
|
32
|
+
* @param options - HMAC verifier options.
|
|
33
|
+
* @param options.headerName - Header containing provider signature.
|
|
34
|
+
* @param options.secret - HMAC secret key.
|
|
35
|
+
* @param options.algo - Hash algorithm used for HMAC generation.
|
|
36
|
+
* @returns A verifier function compatible with router `verify`.
|
|
37
|
+
*/
|
|
38
|
+
function createHmacVerifier({ headerName, secret, algo = "sha256" }) {
|
|
39
|
+
return async (req) => {
|
|
40
|
+
const sig = req.headers.get(headerName.toLowerCase()) || "";
|
|
41
|
+
if (!sig) throw Object.assign(/* @__PURE__ */ new Error("missing signature"), { name: "SignatureError" });
|
|
42
|
+
if (!constantTimeEquals((await import("node:crypto")).createHmac(algo, secret).update(req.rawBody).digest("hex"), sig.replace(SIGNATURE_REGEX, ""))) throw Object.assign(/* @__PURE__ */ new Error("invalid signature"), { name: "SignatureError" });
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
//#endregion
|
|
47
|
+
export { createHmacVerifier };
|
package/package.json
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@zap-studio/webhooks",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"private": false,
|
|
7
|
+
"homepage": "https://www.zapstudio.dev/packages/webhooks",
|
|
8
|
+
"repository": {
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "https://github.com/zap-studio/monorepo.git",
|
|
11
|
+
"directory": "packages/webhooks"
|
|
12
|
+
},
|
|
13
|
+
"description": "A lightweight, type-safe webhook router with Standard Schema validation, signature verification, and lifecycle hooks.",
|
|
14
|
+
"keywords": [
|
|
15
|
+
"webhooks",
|
|
16
|
+
"webhook router",
|
|
17
|
+
"type-safe",
|
|
18
|
+
"typescript",
|
|
19
|
+
"validation",
|
|
20
|
+
"schema",
|
|
21
|
+
"standard schema",
|
|
22
|
+
"zod",
|
|
23
|
+
"valibot",
|
|
24
|
+
"arktype",
|
|
25
|
+
"signature verification",
|
|
26
|
+
"lifecycle hooks",
|
|
27
|
+
"payload validation"
|
|
28
|
+
],
|
|
29
|
+
"publishConfig": {
|
|
30
|
+
"access": "public"
|
|
31
|
+
},
|
|
32
|
+
"files": [
|
|
33
|
+
"dist",
|
|
34
|
+
"CHANGELOG.md",
|
|
35
|
+
"LICENSE",
|
|
36
|
+
"README.md"
|
|
37
|
+
],
|
|
38
|
+
"dependencies": {
|
|
39
|
+
"@standard-schema/spec": "^1.1.0",
|
|
40
|
+
"@zap-studio/validation": "0.2.1"
|
|
41
|
+
},
|
|
42
|
+
"devDependencies": {
|
|
43
|
+
"@types/node": "^25.0.2",
|
|
44
|
+
"@vitest/coverage-v8": "^4.0.15",
|
|
45
|
+
"tsdown": "^0.18.0",
|
|
46
|
+
"typescript": "^5.9.3",
|
|
47
|
+
"vitest": "^4.0.18",
|
|
48
|
+
"zod": "^4.2.0",
|
|
49
|
+
"@zap-studio/vitest-config": "0.0.0",
|
|
50
|
+
"@zap-studio/typescript-config": "0.0.0",
|
|
51
|
+
"@zap-studio/tsdown-config": "0.0.0"
|
|
52
|
+
},
|
|
53
|
+
"exports": {
|
|
54
|
+
".": "./dist/index.mjs",
|
|
55
|
+
"./adapters/base": "./dist/adapters/base.mjs",
|
|
56
|
+
"./types": "./dist/types/index.mjs",
|
|
57
|
+
"./utils": "./dist/utils/index.mjs",
|
|
58
|
+
"./verify": "./dist/verify.mjs",
|
|
59
|
+
"./package.json": "./package.json"
|
|
60
|
+
},
|
|
61
|
+
"main": "./dist/index.mjs",
|
|
62
|
+
"module": "./dist/index.mjs",
|
|
63
|
+
"types": "./dist/index.d.mts",
|
|
64
|
+
"scripts": {
|
|
65
|
+
"build": "tsdown --config tsdown.config.ts",
|
|
66
|
+
"check-types": "tsc --noEmit",
|
|
67
|
+
"test": "vitest run",
|
|
68
|
+
"test:watch": "vitest --watch"
|
|
69
|
+
}
|
|
70
|
+
}
|