@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 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 };
@@ -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 };
@@ -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
+ }