@zap-studio/webhooks 1.0.0 → 1.1.1

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 CHANGED
@@ -4,6 +4,18 @@ All notable changes to this project will be documented in this file.
4
4
 
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6
6
 
7
+ ## [1.1.1]
8
+
9
+ ### Changed
10
+
11
+ `@zap-studio/logger` is now an optional peer dependency instead of a regular dependency. Every import from it is type-only (`import type { Logger }`), so it was never pulled in at runtime — pass any object matching the `Logger` shape (including `pino`) with no install required. Existing consumers of `logger?: Logger` are unaffected.
12
+
13
+ ## [1.1.0]
14
+
15
+ ### Added
16
+
17
+ `WebhookRouter` (and `createWebhookRouter(...)`) gain an optional `logger?: Logger` option (from `@zap-studio/logger`). When provided, it logs each delivery attempt and handler dispatch at `debug`, and verification failures and unmatched routes at `warn`. Omitting it keeps zero logging overhead. See [Logging](https://www.zapstudio.dev/webhooks/logging).
18
+
7
19
  ## [1.0.0]
8
20
 
9
21
  ### Changed
package/README.md CHANGED
@@ -4,6 +4,16 @@ Schema-first, type-safe webhook routing built on the standard Web API [`Request`
4
4
 
5
5
  Full documentation: [zapstudio.dev/webhooks](https://www.zapstudio.dev/webhooks)
6
6
 
7
+ ## Motivation
8
+
9
+ Webhook endpoints are usually built by hand, per provider, and this tends to repeat the same two mistakes. First, signature checks often use Node's `crypto` module, which does not exist on Cloudflare Workers, Deno, or Bun's edge runtimes — so the same code cannot run everywhere the webhook needs to be received.
10
+
11
+ Second, signatures are often compared with `===`, which leaks timing information and opens a timing-attack risk that most teams do not know they have.
12
+
13
+ `@zap-studio/webhooks` fixes both. It is built on the standard `Request`/`Response` objects, so the same router works on Bun, Deno, Cloudflare Workers, or any framework that accepts a `Request`.
14
+
15
+ Signature verification is opt-in through the `verify` option, and the built-in HMAC verifier uses the Web Crypto API (`globalThis.crypto.subtle`) with a constant-time comparison, so once you turn it on, you get the safe comparison without writing it yourself. Payloads are validated and typed straight from your Standard Schema — no `any` from `JSON.parse`, no manual casts.
16
+
7
17
  ## Installation
8
18
 
9
19
  ```bash
@@ -20,15 +30,18 @@ You also need a schema library that implements [Standard Schema](https://github.
20
30
  - **Signature verification** — built-in HMAC verifier with constant-time comparison, or plug in your own `verify` function.
21
31
  - **Lifecycle hooks** — global `before`, `after`, and `onError` hooks for cross-cutting behavior.
22
32
  - **Runtime-agnostic** — uses the Web Crypto API, not Node-specific APIs.
33
+ - **Optional logging** through `createWebhookRouter({ logger })` ([`@zap-studio/logger`](https://www.npmjs.com/package/@zap-studio/logger)) — omit it and there's zero logging overhead.
23
34
  - **Tree-shakeable** — validation and hook-running internals are standalone functions; unused exports are dropped by any modern bundler.
24
35
 
25
36
  ## Quick Start
26
37
 
27
38
  ```ts
39
+ import { ConsoleLogger } from "@zap-studio/logger";
28
40
  import { createWebhookRouter } from "@zap-studio/webhooks";
29
41
  import { z } from "zod";
30
42
 
31
- const router = createWebhookRouter({ prefix: "/webhooks" });
43
+ const logger = new ConsoleLogger({ minLevel: "debug" });
44
+ const router = createWebhookRouter({ prefix: "/webhooks", logger });
32
45
 
33
46
  router.register("/payments/succeeded", {
34
47
  schema: z.object({ id: z.string(), amount: z.number().positive() }),
@@ -134,6 +147,20 @@ const verify = createHmacVerifier({
134
147
  });
135
148
  ```
136
149
 
150
+ ## Logging
151
+
152
+ Pass a `logger?: Logger` from [`@zap-studio/logger`](https://www.npmjs.com/package/@zap-studio/logger) to `createWebhookRouter(...)` to observe delivery attempts, dispatch, verification failures, and unmatched routes. Omit it and nothing is logged.
153
+
154
+ ```ts
155
+ import { ConsoleLogger } from "@zap-studio/logger";
156
+ import { createWebhookRouter } from "@zap-studio/webhooks";
157
+
158
+ const logger = new ConsoleLogger({ minLevel: "debug" });
159
+ const router = createWebhookRouter({ prefix: "/webhooks", logger });
160
+ ```
161
+
162
+ Each delivery attempt and handler dispatch logs at `debug`; verification failures and unmatched routes log at `warn`.
163
+
137
164
  ## Runtime Support
138
165
 
139
166
  | Runtime | Minimum version |
package/dist/router.d.ts CHANGED
@@ -28,6 +28,7 @@ declare class WebhookRouter<TMap = unknown> {
28
28
  private readonly globalBeforeHooks;
29
29
  private readonly globalAfterHooks;
30
30
  private readonly globalErrorHook;
31
+ private readonly logger;
31
32
  private readonly prefix;
32
33
  private readonly prefixWithSlash;
33
34
  /**
@@ -1 +1 @@
1
- {"version":3,"file":"router.d.ts","names":[],"sources":["../src/router.ts"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;cA2La,cAAc;mBACR;mBACA;mBACA;mBACA;mBACA;mBACA;mBACA;;;;;;;;;;;;;;;EAgBjB,YAAY,OAAM;;;;;;;;;;;;;;;;;;;;EA4BlB,SACE,2BACA,gBAAgB,oCAEhB,MAAM,MACN,kBAAkB,mBAAmB,WACpC,cAAc,OAAO,OAAO,MAAM,kBAAkB;;;;;;;;;;;;;;;;EAgBvD,SAAS,2BAA2B,UAClC,MAAM,MACN,kBAAkB,gBAAgB,YACjC,cAAc,OAAO,OAAO,MAAM;;;;;;;;;;;;;EAarC,SAAS,2BACP,MAAM,MACN,kBAAkB,iBACjB,cAAc,OAAO,OAAO;;;;;;;;;;;;;;;;;;EAgC/B,OAAa,SAAS,UAAU,QAAQ;;UAqDhC;;UAsBM;;;;;;;;;;;;;;;;cAoCH,sBACX,OAAO,yBACN"}
1
+ {"version":3,"file":"router.d.ts","names":[],"sources":["../src/router.ts"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;cA4La,cAAc;mBACR;mBACA;mBACA;mBACA;mBACA;mBACA;mBACA;mBACA;;;;;;;;;;;;;;;EAgBjB,YAAY,OAAM;;;;;;;;;;;;;;;;;;;;EA6BlB,SACE,2BACA,gBAAgB,oCAEhB,MAAM,MACN,kBAAkB,mBAAmB,WACpC,cAAc,OAAO,OAAO,MAAM,kBAAkB;;;;;;;;;;;;;;;;EAgBvD,SAAS,2BAA2B,UAClC,MAAM,MACN,kBAAkB,gBAAgB,YACjC,cAAc,OAAO,OAAO,MAAM;;;;;;;;;;;;;EAarC,SAAS,2BACP,MAAM,MACN,kBAAkB,iBACjB,cAAc,OAAO,OAAO;;;;;;;;;;;;;;;;;;EAgC/B,OAAa,SAAS,UAAU,QAAQ;;UAgEhC;;UAsBM;;;;;;;;;;;;;;;;cAoCH,sBACX,OAAO,yBACN"}
package/dist/router.js CHANGED
@@ -93,6 +93,7 @@ var WebhookRouter = class {
93
93
  globalBeforeHooks = [];
94
94
  globalAfterHooks = [];
95
95
  globalErrorHook;
96
+ logger;
96
97
  prefix;
97
98
  prefixWithSlash;
98
99
  /**
@@ -116,6 +117,7 @@ var WebhookRouter = class {
116
117
  this.globalBeforeHooks = toArray(opts.before);
117
118
  this.globalAfterHooks = toArray(opts.after);
118
119
  this.globalErrorHook = opts.onError;
120
+ this.logger = opts.logger;
119
121
  }
120
122
  register(path, handlerOrOptions) {
121
123
  this.handlers.set(normalizePath(path), typeof handlerOrOptions === "function" ? { handler: handlerOrOptions } : createHandlerEntry(handlerOrOptions));
@@ -139,10 +141,18 @@ var WebhookRouter = class {
139
141
  * ```
140
142
  */
141
143
  async handle(request) {
144
+ const requestPath = new URL(request.url).pathname;
145
+ this.logger?.debug("webhook delivery attempt", { path: requestPath });
142
146
  const path = this.matchPath(request);
143
- if (path === null) return notFoundResponse();
147
+ if (path === null) {
148
+ this.logger?.warn("webhook route not matched", { path: requestPath });
149
+ return notFoundResponse();
150
+ }
144
151
  const handlerEntry = this.handlers.get(path);
145
- if (!handlerEntry) return notFoundResponse();
152
+ if (!handlerEntry) {
153
+ this.logger?.warn("webhook route not matched", { path });
154
+ return notFoundResponse();
155
+ }
146
156
  const ctx = {
147
157
  path,
148
158
  rawBody: /* @__PURE__ */ new Uint8Array(0),
@@ -152,10 +162,19 @@ var WebhookRouter = class {
152
162
  ctx.rawBody = new Uint8Array(await request.arrayBuffer());
153
163
  await runBeforeHooks(ctx, this.globalBeforeHooks);
154
164
  await runBeforeHooks(ctx, handlerEntry.before);
155
- if (this.verify) await this.verify(ctx);
165
+ if (this.verify) try {
166
+ await this.verify(ctx);
167
+ } catch (error) {
168
+ this.logger?.warn("webhook verification failed", {
169
+ error,
170
+ path
171
+ });
172
+ throw error;
173
+ }
156
174
  const parsedJson = parseRequestBody(ctx);
157
175
  const validationResult = await validatePayload(parsedJson, handlerEntry.schema);
158
176
  if (validationResult instanceof Response) return validationResult;
177
+ this.logger?.debug("webhook handler dispatch", { path });
159
178
  const response = await executeHandler(handlerEntry.handler, ctx, validationResult);
160
179
  await runAfterHooks(ctx, response, handlerEntry.after);
161
180
  await runAfterHooks(ctx, response, this.globalAfterHooks);
@@ -1 +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 HandlerEntry,\n InferSchemaOutput,\n RegisterOptions,\n SchemaRouteOptions,\n VerifyFn,\n WebhookContext,\n WebhookHandler,\n WebhookRouterOptions,\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 */\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/** Runs the given before-hooks in order against the request context. */\nconst runBeforeHooks = async (\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/** Runs the given after-hooks in order against the request context and response. */\nconst runAfterHooks = async (\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/** Builds an internal handler entry from route registration options. */\nconst 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 = toArray(options.before);\n }\n\n if (options.after !== undefined) {\n entry.after = toArray(options.after);\n }\n\n return entry;\n};\n\n/** Parses the request's raw body bytes as JSON, returning `undefined` on invalid JSON. */\nconst parseRequestBody = (ctx: WebhookContext): unknown => {\n try {\n return JSON.parse(bodyDecoder.decode(ctx.rawBody));\n } catch {\n return undefined;\n }\n};\n\n/** Validates the parsed payload against the route schema, returning either the validated value or a `400` response. */\nconst validatePayload = async <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(parsedJson, schema, {\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/** Invokes the route handler with the validated payload, defaulting to a `200 \"ok\"` response. */\nconst executeHandler = async <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/**\n * Main webhook router class.\n *\n * Register routes with typed schemas and call `handle` with a Web API `Request`.\n *\n * @example\n * ```ts\n * import { WebhookRouter } from \"@zap-studio/webhooks\";\n *\n * const router = new WebhookRouter({ prefix: \"/webhooks\" });\n *\n * router.register(\"/stripe\", {\n * schema: stripeEventSchema,\n * handler: async ({ payload }) => {\n * console.log(\"Stripe event:\", payload.type);\n * },\n * });\n *\n * export default { fetch: (request: Request) => router.handle(request) };\n * ```\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 * @example\n * ```ts\n * const router = new WebhookRouter({\n * prefix: \"/webhooks\",\n * verify: createHmacVerifier({ headerName: \"x-signature\", secret }),\n * onError: (error) => Response.json({ error: error.message }, { status: 500 }),\n * });\n * ```\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 * @example\n * ```ts\n * router.register(\"/stripe\", {\n * schema: stripeEventSchema,\n * handler: async ({ payload }) => {\n * console.log(payload.type); // typed from stripeEventSchema\n * },\n * });\n * ```\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 /**\n * Register a webhook handler for a specific path, with schema-less registration options.\n *\n * @param path - Route path relative to configured prefix, starting with `/` (e.g. `\"/stripe\"`).\n * @param handlerOrOptions - Registration options without a schema.\n * @returns The same router instance with an updated internal route type map.\n *\n * @example\n * ```ts\n * router.register(\"/ping\", {\n * before: (ctx) => console.log(\"received\", ctx.path),\n * handler: () => Response.json({ ok: true }),\n * });\n * ```\n */\n register<Path extends `/${string}`, TPayload>(\n path: Path,\n handlerOrOptions: RegisterOptions<TPayload>\n ): WebhookRouter<TMap & Record<Path, TPayload>>;\n /**\n * Register a webhook handler for a specific path, using a plain handler function.\n *\n * @param path - Route path relative to configured prefix, starting with `/` (e.g. `\"/stripe\"`).\n * @param handlerOrOptions - Handler function to process the webhook.\n * @returns The same router instance with an updated internal route type map.\n *\n * @example\n * ```ts\n * router.register(\"/health\", () => Response.json({ status: \"ok\" }));\n * ```\n */\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 : 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 * @example\n * ```ts\n * // Framework-agnostic: works with any Web API Request/Response runtime.\n * export async function POST(request: Request): Promise<Response> {\n * return router.handle(request);\n * }\n * ```\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 runBeforeHooks(ctx, this.globalBeforeHooks);\n await runBeforeHooks(ctx, handlerEntry.before);\n\n if (this.verify) {\n await this.verify(ctx);\n }\n\n const parsedJson = parseRequestBody(ctx);\n const validationResult = await validatePayload(\n parsedJson,\n handlerEntry.schema\n );\n\n if (validationResult instanceof Response) {\n return validationResult;\n }\n\n const response = await executeHandler(\n handlerEntry.handler,\n ctx,\n validationResult\n );\n\n await runAfterHooks(ctx, response, handlerEntry.after);\n await runAfterHooks(ctx, response, this.globalAfterHooks);\n\n return response;\n } catch (error) {\n return await this.handleError(error, ctx);\n }\n }\n\n /** Resolves the incoming request's URL to a registered route key, or `null` if it doesn't match the configured prefix. */\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 /** Builds the error response for a failed request, deferring to the global error hook when set. */\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 *\n * @example\n * ```ts\n * import { createWebhookRouter } from \"@zap-studio/webhooks\";\n *\n * const router = createWebhookRouter({ prefix: \"/webhooks\" });\n * router.register(\"/stripe\", { schema: stripeEventSchema, handler });\n * ```\n */\nexport const createWebhookRouter = (\n opts?: WebhookRouterOptions\n): WebhookRouter => new WebhookRouter(opts);\n"],"mappings":"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"}
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 { Logger } from \"@zap-studio/logger\";\nimport type { StandardSchemaV1 } from \"@zap-studio/validation\";\nimport { standardValidate } from \"@zap-studio/validation\";\n\nimport type {\n AfterHook,\n BeforeHook,\n ErrorHook,\n HandlerEntry,\n InferSchemaOutput,\n RegisterOptions,\n SchemaRouteOptions,\n VerifyFn,\n WebhookContext,\n WebhookHandler,\n WebhookRouterOptions,\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 */\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/** Runs the given before-hooks in order against the request context. */\nconst runBeforeHooks = async (\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/** Runs the given after-hooks in order against the request context and response. */\nconst runAfterHooks = async (\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/** Builds an internal handler entry from route registration options. */\nconst 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 = toArray(options.before);\n }\n\n if (options.after !== undefined) {\n entry.after = toArray(options.after);\n }\n\n return entry;\n};\n\n/** Parses the request's raw body bytes as JSON, returning `undefined` on invalid JSON. */\nconst parseRequestBody = (ctx: WebhookContext): unknown => {\n try {\n return JSON.parse(bodyDecoder.decode(ctx.rawBody));\n } catch {\n return undefined;\n }\n};\n\n/** Validates the parsed payload against the route schema, returning either the validated value or a `400` response. */\nconst validatePayload = async <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(parsedJson, schema, {\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/** Invokes the route handler with the validated payload, defaulting to a `200 \"ok\"` response. */\nconst executeHandler = async <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/**\n * Main webhook router class.\n *\n * Register routes with typed schemas and call `handle` with a Web API `Request`.\n *\n * @example\n * ```ts\n * import { WebhookRouter } from \"@zap-studio/webhooks\";\n *\n * const router = new WebhookRouter({ prefix: \"/webhooks\" });\n *\n * router.register(\"/stripe\", {\n * schema: stripeEventSchema,\n * handler: async ({ payload }) => {\n * console.log(\"Stripe event:\", payload.type);\n * },\n * });\n *\n * export default { fetch: (request: Request) => router.handle(request) };\n * ```\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 logger: Logger | 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 * @example\n * ```ts\n * const router = new WebhookRouter({\n * prefix: \"/webhooks\",\n * verify: createHmacVerifier({ headerName: \"x-signature\", secret }),\n * onError: (error) => Response.json({ error: error.message }, { status: 500 }),\n * });\n * ```\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 this.logger = opts.logger;\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 * @example\n * ```ts\n * router.register(\"/stripe\", {\n * schema: stripeEventSchema,\n * handler: async ({ payload }) => {\n * console.log(payload.type); // typed from stripeEventSchema\n * },\n * });\n * ```\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 /**\n * Register a webhook handler for a specific path, with schema-less registration options.\n *\n * @param path - Route path relative to configured prefix, starting with `/` (e.g. `\"/stripe\"`).\n * @param handlerOrOptions - Registration options without a schema.\n * @returns The same router instance with an updated internal route type map.\n *\n * @example\n * ```ts\n * router.register(\"/ping\", {\n * before: (ctx) => console.log(\"received\", ctx.path),\n * handler: () => Response.json({ ok: true }),\n * });\n * ```\n */\n register<Path extends `/${string}`, TPayload>(\n path: Path,\n handlerOrOptions: RegisterOptions<TPayload>\n ): WebhookRouter<TMap & Record<Path, TPayload>>;\n /**\n * Register a webhook handler for a specific path, using a plain handler function.\n *\n * @param path - Route path relative to configured prefix, starting with `/` (e.g. `\"/stripe\"`).\n * @param handlerOrOptions - Handler function to process the webhook.\n * @returns The same router instance with an updated internal route type map.\n *\n * @example\n * ```ts\n * router.register(\"/health\", () => Response.json({ status: \"ok\" }));\n * ```\n */\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 : 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 * @example\n * ```ts\n * // Framework-agnostic: works with any Web API Request/Response runtime.\n * export async function POST(request: Request): Promise<Response> {\n * return router.handle(request);\n * }\n * ```\n */\n async handle(request: Request): Promise<Response> {\n const requestPath = new URL(request.url).pathname;\n this.logger?.debug(\"webhook delivery attempt\", { path: requestPath });\n\n const path = this.matchPath(request);\n if (path === null) {\n this.logger?.warn(\"webhook route not matched\", { path: requestPath });\n return notFoundResponse();\n }\n\n const handlerEntry = this.handlers.get(path);\n if (!handlerEntry) {\n this.logger?.warn(\"webhook route not matched\", { path });\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 runBeforeHooks(ctx, this.globalBeforeHooks);\n await runBeforeHooks(ctx, handlerEntry.before);\n\n if (this.verify) {\n try {\n await this.verify(ctx);\n } catch (error) {\n this.logger?.warn(\"webhook verification failed\", { error, path });\n throw error;\n }\n }\n\n const parsedJson = parseRequestBody(ctx);\n const validationResult = await validatePayload(\n parsedJson,\n handlerEntry.schema\n );\n\n if (validationResult instanceof Response) {\n return validationResult;\n }\n\n this.logger?.debug(\"webhook handler dispatch\", { path });\n const response = await executeHandler(\n handlerEntry.handler,\n ctx,\n validationResult\n );\n\n await runAfterHooks(ctx, response, handlerEntry.after);\n await runAfterHooks(ctx, response, this.globalAfterHooks);\n\n return response;\n } catch (error) {\n return await this.handleError(error, ctx);\n }\n }\n\n /** Resolves the incoming request's URL to a registered route key, or `null` if it doesn't match the configured prefix. */\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 /** Builds the error response for a failed request, deferring to the global error hook when set. */\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 *\n * @example\n * ```ts\n * import { createWebhookRouter } from \"@zap-studio/webhooks\";\n *\n * const router = createWebhookRouter({ prefix: \"/webhooks\" });\n * router.register(\"/stripe\", { schema: stripeEventSchema, handler });\n * ```\n */\nexport const createWebhookRouter = (\n opts?: WebhookRouterOptions\n): WebhookRouter => new WebhookRouter(opts);\n"],"mappings":"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"}
package/dist/types.d.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import { StandardSchemaV1 } from "@zap-studio/validation";
2
+ import { Logger } from "@zap-studio/logger";
2
3
  //#region src/types.d.ts
3
4
  /**
4
5
  * Context shared by hooks, verifiers, and handlers for a single webhook request.
@@ -39,9 +40,13 @@ interface HandlerContext<TPayload = unknown> extends WebhookContext {
39
40
  }
40
41
  /** Internal handler entry stored per registered route. */
41
42
  interface HandlerEntry<TPayload = unknown> {
43
+ /** Route-level hooks that run after successful processing. */
42
44
  after?: AfterHook[];
45
+ /** Route-level hooks that run before request processing. */
43
46
  before?: BeforeHook[];
47
+ /** The handler function to process the webhook. */
44
48
  handler: WebhookHandler<TPayload>;
49
+ /** Optional Standard Schema validator to validate the webhook payload. */
45
50
  schema?: StandardSchemaV1<unknown, TPayload>;
46
51
  }
47
52
  /**
@@ -62,6 +67,13 @@ interface WebhookRouterOptions {
62
67
  before?: BeforeHook | BeforeHook[];
63
68
  /** Global error hook used to override the default `500` response. */
64
69
  onError?: ErrorHook;
70
+ /**
71
+ * Optional logger for router internals. When omitted, nothing is logged.
72
+ *
73
+ * Logs each delivery attempt and handler dispatch at `debug`, and
74
+ * verification failures and unmatched routes at `warn`.
75
+ */
76
+ logger?: Logger;
65
77
  /**
66
78
  * Required path prefix for all webhook routes. Defaults to `"/webhooks"`.
67
79
  *
@@ -117,9 +129,13 @@ type SchemaRouteOptions<TSchema extends StandardSchemaV1<unknown, unknown>> = Om
117
129
  };
118
130
  /** A single route's registration shape, as used by schema-driven route dictionaries. */
119
131
  interface RouteLike {
132
+ /** Hooks that run after successful processing. */
120
133
  after?: AfterHook | AfterHook[];
134
+ /** Hooks that run before request processing. */
121
135
  before?: BeforeHook | BeforeHook[];
136
+ /** The handler function to process the webhook. */
122
137
  handler: WebhookHandler;
138
+ /** Standard Schema validator the route's payload type is inferred from. */
123
139
  schema: StandardSchemaV1<unknown, unknown>;
124
140
  }
125
141
  /**
@@ -1 +1 @@
1
- {"version":3,"file":"types.d.ts","names":[],"sources":["../src/types.ts"],"mappings":";;;;;;;;;;;;;;;UAqBiB;;EAEf;;EAEA,SAAS;;EAET,SAAS;;;;;;;;;;;;;;UAeM,eAAe,4BAA4B;;EAE1D,SAAS;;;UAIM,aAAa;EAC5B,QAAQ;EACR,SAAS;EACT,SAAS,eAAe;EACxB,SAAS,0BAA0B;;;;;;;;;;;;;UAcpB;;EAEf,QAAQ,YAAY;;EAEpB,SAAS,aAAa;;EAEtB,UAAU;;;;;;;EAOV;;EAEA,SAAS;;;;;;;;;;;;;UAcM,gBAAgB;;EAE/B,QAAQ,YAAY;;EAEpB,SAAS,aAAa;;EAEtB,SAAS,eAAe;;EAExB,SAAS,0BAA0B;;;;;;;KAQzB,kBAAkB,WAC5B,gBAAgB,gCAAgC,WAAW;;;;;;;;;;;;;;KAejD,mBACV,gBAAgB,sCACd,KAAK,gBAAgB,kBAAkB;EACzC,QAAQ;;;UAIO;EACf,QAAQ,YAAY;EACpB,SAAS,aAAa;EACtB,SAAS;EACT,QAAQ;;;;;;;;;;;;;;KAeE,aAAa,gBAAgB,eAAe,iBACrD,WAAW,UAAU,mBAAmB,QAAQ;;;;;;;;;;;;;;KAgBvC,eAAe,uBACzB,KAAK,eAAe,cACjB,QAAQ,wBAAwB;;;;;;;;;;;KAYzB,WAAW,aAAa,8BACjC,WAAW,OAAO,eAAe,KAAK;;;;;;;;;;;;;KAe7B,0BACV,gBAAgB,eAAe,iBAE9B,WAAW,UAAU,kBAAkB,QAAQ;;;;;;;;;KAWtC,YAAY,KAAK,mBAAmB;;;;;;;;;KAUpC,cAAc,KAAK,mBAAmB;;;;;;;;;;;;KAatC,aACV,KAAK,gBACL,UAAU,aACP;;;;;;;;;KAUO,aACV,OAAO,OACP,KAAK,mBACF,QAAQ,wBAAwB"}
1
+ {"version":3,"file":"types.d.ts","names":[],"sources":["../src/types.ts"],"mappings":";;;;;;;;;;;;;;;;UAsBiB;;EAEf;;EAEA,SAAS;;EAET,SAAS;;;;;;;;;;;;;;UAeM,eAAe,4BAA4B;;EAE1D,SAAS;;;UAIM,aAAa;;EAE5B,QAAQ;;EAER,SAAS;;EAET,SAAS,eAAe;;EAExB,SAAS,0BAA0B;;;;;;;;;;;;;UAcpB;;EAEf,QAAQ,YAAY;;EAEpB,SAAS,aAAa;;EAEtB,UAAU;;;;;;;EAOV,SAAS;;;;;;;EAOT;;EAEA,SAAS;;;;;;;;;;;;;UAcM,gBAAgB;;EAE/B,QAAQ,YAAY;;EAEpB,SAAS,aAAa;;EAEtB,SAAS,eAAe;;EAExB,SAAS,0BAA0B;;;;;;;KAQzB,kBAAkB,WAC5B,gBAAgB,gCAAgC,WAAW;;;;;;;;;;;;;;KAejD,mBACV,gBAAgB,sCACd,KAAK,gBAAgB,kBAAkB;EACzC,QAAQ;;;UAIO;;EAEf,QAAQ,YAAY;;EAEpB,SAAS,aAAa;;EAEtB,SAAS;;EAET,QAAQ;;;;;;;;;;;;;;KAeE,aAAa,gBAAgB,eAAe,iBACrD,WAAW,UAAU,mBAAmB,QAAQ;;;;;;;;;;;;;;KAgBvC,eAAe,uBACzB,KAAK,eAAe,cACjB,QAAQ,wBAAwB;;;;;;;;;;;KAYzB,WAAW,aAAa,8BACjC,WAAW,OAAO,eAAe,KAAK;;;;;;;;;;;;;KAe7B,0BACV,gBAAgB,eAAe,iBAE9B,WAAW,UAAU,kBAAkB,QAAQ;;;;;;;;;KAWtC,YAAY,KAAK,mBAAmB;;;;;;;;;KAUpC,cAAc,KAAK,mBAAmB;;;;;;;;;;;;KAatC,aACV,KAAK,gBACL,UAAU,aACP;;;;;;;;;KAUO,aACV,OAAO,OACP,KAAK,mBACF,QAAQ,wBAAwB"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zap-studio/webhooks",
3
- "version": "1.0.0",
3
+ "version": "1.1.1",
4
4
  "private": false,
5
5
  "description": "A lightweight, type-safe, tree-shakeable webhook router with Standard Schema validation, signature verification, and lifecycle hooks.",
6
6
  "keywords": [
@@ -53,7 +53,16 @@
53
53
  "typescript": "^7.0.2",
54
54
  "vitest": "^4.1.10",
55
55
  "zod": "^4.4.3",
56
- "@zap-studio/typescript": "0.0.0"
56
+ "@zap-studio/typescript": "0.0.0",
57
+ "@zap-studio/logger": "1.0.0"
58
+ },
59
+ "peerDependencies": {
60
+ "@zap-studio/logger": "1.0.0"
61
+ },
62
+ "peerDependenciesMeta": {
63
+ "@zap-studio/logger": {
64
+ "optional": true
65
+ }
57
66
  },
58
67
  "engines": {
59
68
  "node": ">=18.0.0"