@zap-studio/webhooks 0.4.0 → 1.0.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/dist/router.js CHANGED
@@ -1,5 +1,10 @@
1
1
  import { standardValidate } from "@zap-studio/validation";
2
2
  //#region src/router.ts
3
+ /**
4
+ * Schema-first webhook router with path dispatching, validation, and optional verification.
5
+ *
6
+ * @template TMap - Internal route payload map built incrementally via `register`.
7
+ */
3
8
  const toArray = (value) => {
4
9
  if (value === void 0) return [];
5
10
  return Array.isArray(value) ? value : [value];
@@ -15,12 +20,74 @@ const normalizePath = (path) => {
15
20
  const collapsed = withLeadingSlash.includes("//") ? withLeadingSlash.replaceAll(/\/{2,}/gu, "/") : withLeadingSlash;
16
21
  return collapsed.length > 1 && collapsed.endsWith("/") ? collapsed.slice(0, -1) : collapsed;
17
22
  };
23
+ /** Runs the given before-hooks in order against the request context. */
24
+ const runBeforeHooks = async (ctx, hooks) => {
25
+ if (!hooks || hooks.length === 0) return;
26
+ for (const hook of hooks) await hook(ctx);
27
+ };
28
+ /** Runs the given after-hooks in order against the request context and response. */
29
+ const runAfterHooks = async (ctx, response, hooks) => {
30
+ if (!hooks || hooks.length === 0) return;
31
+ for (const hook of hooks) await hook(ctx, response);
32
+ };
33
+ /** Builds an internal handler entry from route registration options. */
34
+ const createHandlerEntry = (options) => {
35
+ const entry = { handler: options.handler };
36
+ if (options.schema !== void 0) entry.schema = options.schema;
37
+ if (options.before !== void 0) entry.before = toArray(options.before);
38
+ if (options.after !== void 0) entry.after = toArray(options.after);
39
+ return entry;
40
+ };
41
+ /** Parses the request's raw body bytes as JSON, returning `undefined` on invalid JSON. */
42
+ const parseRequestBody = (ctx) => {
43
+ try {
44
+ return JSON.parse(bodyDecoder.decode(ctx.rawBody));
45
+ } catch {
46
+ return;
47
+ }
48
+ };
49
+ /** Validates the parsed payload against the route schema, returning either the validated value or a `400` response. */
50
+ const validatePayload = async (parsedJson, schema) => {
51
+ if (!schema) return parsedJson;
52
+ const result = await standardValidate(parsedJson, schema, { throwOnError: false });
53
+ if (result.issues) return Response.json({
54
+ error: "validation failed",
55
+ issues: result.issues.map((issue) => ({
56
+ message: issue.message,
57
+ path: issue.path?.map((p) => typeof p === "object" && "key" in p ? String(p.key) : String(p))
58
+ }))
59
+ }, { status: 400 });
60
+ return result.value;
61
+ };
62
+ /** Invokes the route handler with the validated payload, defaulting to a `200 "ok"` response. */
63
+ const executeHandler = async (handler, ctx, validatedPayload) => {
64
+ return await handler({
65
+ ...ctx,
66
+ payload: validatedPayload
67
+ }) ?? Response.json("ok");
68
+ };
18
69
  /**
19
70
  * Main webhook router class.
20
71
  *
21
72
  * Register routes with typed schemas and call `handle` with a Web API `Request`.
73
+ *
74
+ * @example
75
+ * ```ts
76
+ * import { WebhookRouter } from "@zap-studio/webhooks";
77
+ *
78
+ * const router = new WebhookRouter({ prefix: "/webhooks" });
79
+ *
80
+ * router.register("/stripe", {
81
+ * schema: stripeEventSchema,
82
+ * handler: async ({ payload }) => {
83
+ * console.log("Stripe event:", payload.type);
84
+ * },
85
+ * });
86
+ *
87
+ * export default { fetch: (request: Request) => router.handle(request) };
88
+ * ```
22
89
  */
23
- var WebhookRouter = class WebhookRouter {
90
+ var WebhookRouter = class {
24
91
  handlers = /* @__PURE__ */ new Map();
25
92
  verify;
26
93
  globalBeforeHooks = [];
@@ -32,6 +99,15 @@ var WebhookRouter = class WebhookRouter {
32
99
  * Creates a webhook router with optional global hooks and verification behavior.
33
100
  *
34
101
  * @param opts - Router-level options.
102
+ *
103
+ * @example
104
+ * ```ts
105
+ * const router = new WebhookRouter({
106
+ * prefix: "/webhooks",
107
+ * verify: createHmacVerifier({ headerName: "x-signature", secret }),
108
+ * onError: (error) => Response.json({ error: error.message }, { status: 500 }),
109
+ * });
110
+ * ```
35
111
  */
36
112
  constructor(opts = {}) {
37
113
  this.prefix = normalizePath(opts.prefix ?? "/webhooks");
@@ -42,7 +118,7 @@ var WebhookRouter = class WebhookRouter {
42
118
  this.globalErrorHook = opts.onError;
43
119
  }
44
120
  register(path, handlerOrOptions) {
45
- this.handlers.set(normalizePath(path), typeof handlerOrOptions === "function" ? { handler: handlerOrOptions } : WebhookRouter.createHandlerEntry(handlerOrOptions));
121
+ this.handlers.set(normalizePath(path), typeof handlerOrOptions === "function" ? { handler: handlerOrOptions } : createHandlerEntry(handlerOrOptions));
46
122
  return this;
47
123
  }
48
124
  /**
@@ -53,6 +129,14 @@ var WebhookRouter = class WebhookRouter {
53
129
  *
54
130
  * @param request - Incoming Web API request.
55
131
  * @returns Web API response for the runtime to send back.
132
+ *
133
+ * @example
134
+ * ```ts
135
+ * // Framework-agnostic: works with any Web API Request/Response runtime.
136
+ * export async function POST(request: Request): Promise<Response> {
137
+ * return router.handle(request);
138
+ * }
139
+ * ```
56
140
  */
57
141
  async handle(request) {
58
142
  const path = this.matchPath(request);
@@ -66,20 +150,21 @@ var WebhookRouter = class WebhookRouter {
66
150
  };
67
151
  try {
68
152
  ctx.rawBody = new Uint8Array(await request.arrayBuffer());
69
- await WebhookRouter.runBeforeHooks(ctx, this.globalBeforeHooks);
70
- await WebhookRouter.runBeforeHooks(ctx, handlerEntry.before);
153
+ await runBeforeHooks(ctx, this.globalBeforeHooks);
154
+ await runBeforeHooks(ctx, handlerEntry.before);
71
155
  if (this.verify) await this.verify(ctx);
72
- const parsedJson = WebhookRouter.parseRequestBody(ctx);
73
- const validationResult = await WebhookRouter.validatePayload(parsedJson, handlerEntry.schema);
156
+ const parsedJson = parseRequestBody(ctx);
157
+ const validationResult = await validatePayload(parsedJson, handlerEntry.schema);
74
158
  if (validationResult instanceof Response) return validationResult;
75
- const response = await WebhookRouter.executeHandler(handlerEntry.handler, ctx, validationResult);
76
- await WebhookRouter.runAfterHooks(ctx, response, handlerEntry.after);
77
- await WebhookRouter.runAfterHooks(ctx, response, this.globalAfterHooks);
159
+ const response = await executeHandler(handlerEntry.handler, ctx, validationResult);
160
+ await runAfterHooks(ctx, response, handlerEntry.after);
161
+ await runAfterHooks(ctx, response, this.globalAfterHooks);
78
162
  return response;
79
163
  } catch (error) {
80
164
  return await this.handleError(error, ctx);
81
165
  }
82
166
  }
167
+ /** Resolves the incoming request's URL to a registered route key, or `null` if it doesn't match the configured prefix. */
83
168
  matchPath(request) {
84
169
  const pathname = normalizePath(new URL(request.url).pathname);
85
170
  if (this.prefix === "/") return pathname;
@@ -87,46 +172,7 @@ var WebhookRouter = class WebhookRouter {
87
172
  if (!pathname.startsWith(this.prefixWithSlash)) return null;
88
173
  return pathname.slice(this.prefix.length);
89
174
  }
90
- static async runBeforeHooks(ctx, hooks) {
91
- if (!hooks || hooks.length === 0) return;
92
- for (const hook of hooks) await hook(ctx);
93
- }
94
- static async runAfterHooks(ctx, response, hooks) {
95
- if (!hooks || hooks.length === 0) return;
96
- for (const hook of hooks) await hook(ctx, response);
97
- }
98
- static createHandlerEntry(options) {
99
- const entry = { handler: options.handler };
100
- if (options.schema !== void 0) entry.schema = options.schema;
101
- if (options.before !== void 0) entry.before = Array.isArray(options.before) ? options.before : [options.before];
102
- if (options.after !== void 0) entry.after = Array.isArray(options.after) ? options.after : [options.after];
103
- return entry;
104
- }
105
- static parseRequestBody(ctx) {
106
- try {
107
- return JSON.parse(bodyDecoder.decode(ctx.rawBody));
108
- } catch {
109
- return;
110
- }
111
- }
112
- static async validatePayload(parsedJson, schema) {
113
- if (!schema) return parsedJson;
114
- const result = await standardValidate(schema, parsedJson, { throwOnError: false });
115
- if (result.issues) return Response.json({
116
- error: "validation failed",
117
- issues: result.issues.map((issue) => ({
118
- message: issue.message,
119
- path: issue.path?.map((p) => typeof p === "object" && "key" in p ? String(p.key) : String(p))
120
- }))
121
- }, { status: 400 });
122
- return result.value;
123
- }
124
- static async executeHandler(handler, ctx, validatedPayload) {
125
- return await handler({
126
- ...ctx,
127
- payload: validatedPayload
128
- }) ?? Response.json("ok");
129
- }
175
+ /** Builds the error response for a failed request, deferring to the global error hook when set. */
130
176
  async handleError(error, ctx) {
131
177
  if (this.globalErrorHook) {
132
178
  const normalizedError = error instanceof Error ? error : /* @__PURE__ */ new Error("Internal server error");
@@ -141,6 +187,14 @@ var WebhookRouter = class WebhookRouter {
141
187
  *
142
188
  * @param opts - Optional global router options.
143
189
  * @returns A new webhook router.
190
+ *
191
+ * @example
192
+ * ```ts
193
+ * import { createWebhookRouter } from "@zap-studio/webhooks";
194
+ *
195
+ * const router = createWebhookRouter({ prefix: "/webhooks" });
196
+ * router.register("/stripe", { schema: stripeEventSchema, handler });
197
+ * ```
144
198
  */
145
199
  const createWebhookRouter = (opts) => new WebhookRouter(opts);
146
200
  //#endregion
@@ -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 InferSchemaOutput,\n RegisterOptions,\n SchemaRouteOptions,\n VerifyFn,\n WebhookContext,\n WebhookHandler,\n} from \"./types.js\";\n\n/**\n * Schema-first webhook router with path dispatching, validation, and optional verification.\n *\n * @template TMap - Internal route payload map built incrementally via `register`.\n */\ninterface HandlerEntry<TPayload = unknown> {\n after?: AfterHook[];\n before?: BeforeHook[];\n handler: WebhookHandler<TPayload>;\n schema?: StandardSchemaV1<unknown, TPayload>;\n}\n\nexport interface WebhookRouterOptions {\n /** Global hooks executed after successful route handler completion. */\n after?: AfterHook | AfterHook[];\n /** Global hooks executed before route-level hooks and verification. */\n before?: BeforeHook | BeforeHook[];\n /** Global error hook used to override the default `500` response. */\n onError?: ErrorHook;\n /**\n * Required path prefix for all webhook routes. Defaults to `\"/webhooks\"`.\n *\n * Normalized internally: leading slash added, trailing slash stripped,\n * duplicate slashes collapsed. Use `\"\"` or `\"/\"` to mount at the root.\n */\n prefix?: string;\n /** Optional request verification function (for signature checks, auth, etc.). */\n verify?: VerifyFn;\n}\n\nconst toArray = <T>(value: T | T[] | undefined): T[] => {\n if (value === undefined) {\n return [];\n }\n\n return Array.isArray(value) ? value : [value];\n};\n\nconst notFoundResponse = (): Response =>\n Response.json({ error: \"not found\" }, { status: 404 });\n\nconst bodyDecoder = new TextDecoder();\n\n/**\n * Normalizes a path to its canonical form: leading slash, no trailing slash,\n * duplicate slashes collapsed. The root path is `\"/\"`.\n */\nconst normalizePath = (path: string): string => {\n const withLeadingSlash = path.startsWith(\"/\") ? path : `/${path}`;\n const collapsed = withLeadingSlash.includes(\"//\")\n ? withLeadingSlash.replaceAll(/\\/{2,}/gu, \"/\")\n : withLeadingSlash;\n\n return collapsed.length > 1 && collapsed.endsWith(\"/\")\n ? collapsed.slice(0, -1)\n : collapsed;\n};\n\n/**\n * Main webhook router class.\n *\n * Register routes with typed schemas and call `handle` with a Web API `Request`.\n */\nexport class WebhookRouter<TMap = unknown> {\n private readonly handlers = new Map<string, HandlerEntry>();\n private readonly verify: VerifyFn | undefined;\n private readonly globalBeforeHooks: BeforeHook[] = [];\n private readonly globalAfterHooks: AfterHook[] = [];\n private readonly globalErrorHook: ErrorHook | undefined;\n private readonly prefix: string;\n private readonly prefixWithSlash: string;\n\n /**\n * Creates a webhook router with optional global hooks and verification behavior.\n *\n * @param opts - Router-level options.\n */\n constructor(opts: WebhookRouterOptions = {}) {\n this.prefix = normalizePath(opts.prefix ?? \"/webhooks\");\n this.prefixWithSlash = `${this.prefix}/`;\n this.verify = opts.verify;\n this.globalBeforeHooks = toArray(opts.before);\n this.globalAfterHooks = toArray(opts.after);\n this.globalErrorHook = opts.onError;\n }\n\n /**\n * Register a webhook handler for a specific path.\n *\n * When a schema is provided, `payload` is inferred from the schema output type.\n *\n * @param path - Route path relative to configured prefix, starting with `/` (e.g. `\"/stripe\"`).\n * @param handlerOrOptions - Handler function or schema-based registration options.\n * @returns The same router instance with an updated internal route type map.\n */\n register<\n Path extends `/${string}`,\n TSchema extends StandardSchemaV1<unknown, unknown>,\n >(\n path: Path,\n handlerOrOptions: SchemaRouteOptions<TSchema>\n ): WebhookRouter<TMap & Record<Path, InferSchemaOutput<TSchema>>>;\n register<Path extends `/${string}`, TPayload>(\n path: Path,\n handlerOrOptions: RegisterOptions<TPayload>\n ): WebhookRouter<TMap & Record<Path, TPayload>>;\n register<Path extends `/${string}`>(\n path: Path,\n handlerOrOptions: WebhookHandler\n ): WebhookRouter<TMap & Record<Path, unknown>>;\n register(\n path: string,\n handlerOrOptions: WebhookHandler | RegisterOptions<unknown>\n ): this {\n this.handlers.set(\n normalizePath(path),\n typeof handlerOrOptions === \"function\"\n ? { handler: handlerOrOptions }\n : WebhookRouter.createHandlerEntry(handlerOrOptions)\n );\n\n return this;\n }\n\n /**\n * Handles an incoming webhook request.\n *\n * The request body is read exactly once; hooks and handlers receive the raw\n * bytes through the webhook context instead of the request stream.\n *\n * @param request - Incoming Web API request.\n * @returns Web API response for the runtime to send back.\n */\n async handle(request: Request): Promise<Response> {\n const path = this.matchPath(request);\n if (path === null) {\n return notFoundResponse();\n }\n\n const handlerEntry = this.handlers.get(path);\n if (!handlerEntry) {\n return notFoundResponse();\n }\n\n const ctx: WebhookContext = {\n path,\n rawBody: new Uint8Array(0),\n request,\n };\n\n try {\n ctx.rawBody = new Uint8Array(await request.arrayBuffer());\n\n await WebhookRouter.runBeforeHooks(ctx, this.globalBeforeHooks);\n await WebhookRouter.runBeforeHooks(ctx, handlerEntry.before);\n\n if (this.verify) {\n await this.verify(ctx);\n }\n\n const parsedJson = WebhookRouter.parseRequestBody(ctx);\n const validationResult = await WebhookRouter.validatePayload(\n parsedJson,\n handlerEntry.schema\n );\n\n if (validationResult instanceof Response) {\n return validationResult;\n }\n\n const response = await WebhookRouter.executeHandler(\n handlerEntry.handler,\n ctx,\n validationResult\n );\n\n await WebhookRouter.runAfterHooks(ctx, response, handlerEntry.after);\n await WebhookRouter.runAfterHooks(ctx, response, this.globalAfterHooks);\n\n return response;\n } catch (error) {\n return await this.handleError(error, ctx);\n }\n }\n\n private matchPath(request: Request): string | null {\n const pathname = normalizePath(new URL(request.url).pathname);\n\n // Root mount: the whole pathname is the route path.\n if (this.prefix === \"/\") {\n return pathname;\n }\n\n if (pathname === this.prefix) {\n return \"/\";\n }\n\n // Require prefix followed by a segment boundary, then match handlers on\n // the remainder (e.g. /webhooks/stripe -> /stripe).\n if (!pathname.startsWith(this.prefixWithSlash)) {\n return null;\n }\n\n return pathname.slice(this.prefix.length);\n }\n\n private static async runBeforeHooks(\n ctx: WebhookContext,\n hooks?: BeforeHook[]\n ): Promise<void> {\n if (!hooks || hooks.length === 0) {\n return;\n }\n\n for (const hook of hooks) {\n // oxlint-disable-next-line no-await-in-loop -- hooks run sequentially; order + short-circuit matter.\n await hook(ctx);\n }\n }\n\n private static async runAfterHooks(\n ctx: WebhookContext,\n response: Response,\n hooks?: AfterHook[]\n ): Promise<void> {\n if (!hooks || hooks.length === 0) {\n return;\n }\n\n for (const hook of hooks) {\n // oxlint-disable-next-line no-await-in-loop -- hooks run sequentially; order + short-circuit matter.\n await hook(ctx, response);\n }\n }\n\n private static createHandlerEntry(\n options: RegisterOptions<unknown>\n ): HandlerEntry {\n const entry: HandlerEntry = {\n handler: options.handler,\n };\n\n if (options.schema !== undefined) {\n entry.schema = options.schema;\n }\n\n if (options.before !== undefined) {\n entry.before = Array.isArray(options.before)\n ? options.before\n : [options.before];\n }\n\n if (options.after !== undefined) {\n entry.after = Array.isArray(options.after)\n ? options.after\n : [options.after];\n }\n\n return entry;\n }\n\n private static parseRequestBody(ctx: WebhookContext): unknown {\n try {\n return JSON.parse(bodyDecoder.decode(ctx.rawBody)) as unknown;\n } catch {\n return undefined;\n }\n }\n\n private static async validatePayload<TPayload>(\n parsedJson: unknown,\n schema?: StandardSchemaV1<unknown, TPayload>\n ): Promise<TPayload | Response> {\n if (!schema) {\n // oxlint-disable-next-line typescript/no-unsafe-type-assertion -- Without a schema, caller-declared payload type is the route contract.\n return parsedJson as TPayload;\n }\n\n const result = await standardValidate(schema, parsedJson, {\n throwOnError: false,\n });\n\n if (result.issues) {\n return Response.json(\n {\n error: \"validation failed\",\n issues: result.issues.map((issue) => ({\n message: issue.message,\n path: issue.path?.map((p) =>\n typeof p === \"object\" && \"key\" in p ? String(p.key) : String(p)\n ),\n })),\n },\n { status: 400 }\n );\n }\n\n return result.value;\n }\n\n private static async executeHandler<TPayload = unknown>(\n handler: WebhookHandler<TPayload>,\n ctx: WebhookContext,\n validatedPayload: TPayload\n ): Promise<Response> {\n const responded = await handler({\n ...ctx,\n payload: validatedPayload,\n });\n\n return responded ?? Response.json(\"ok\");\n }\n\n private async handleError(\n error: unknown,\n ctx: WebhookContext\n ): Promise<Response> {\n if (this.globalErrorHook) {\n const normalizedError =\n error instanceof Error ? error : new Error(\"Internal server error\");\n const errorResponse = await this.globalErrorHook(normalizedError, ctx);\n if (errorResponse) {\n return errorResponse;\n }\n }\n\n return Response.json(\n {\n error: error instanceof Error ? error.message : \"Internal server error\",\n },\n { status: 500 }\n );\n }\n}\n\n/**\n * Factory helper for creating a webhook router instance.\n *\n * @param opts - Optional global router options.\n * @returns A new webhook router.\n */\nexport const createWebhookRouter = (\n opts?: WebhookRouterOptions\n): WebhookRouter => new WebhookRouter(opts);\n"],"mappings":"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"}
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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"}
package/dist/types.d.ts CHANGED
@@ -5,6 +5,13 @@ import { StandardSchemaV1 } from "@zap-studio/validation";
5
5
  *
6
6
  * The router consumes the request body exactly once, so `request.body` is
7
7
  * already used by the time hooks or handlers run — read `rawBody` instead.
8
+ *
9
+ * @example
10
+ * ```ts
11
+ * const before: BeforeHook = (ctx: WebhookContext) => {
12
+ * console.log("received", ctx.path);
13
+ * };
14
+ * ```
8
15
  */
9
16
  interface WebhookContext {
10
17
  /** The matched route key registered on the router (e.g. "stripe") */
@@ -18,12 +25,64 @@ interface WebhookContext {
18
25
  * Handler context extending the shared webhook context with the validated payload.
19
26
  *
20
27
  * @template TPayload - Validated payload type for the matched route.
28
+ *
29
+ * @example
30
+ * ```ts
31
+ * const handler: WebhookHandler<{ type: string }> = ({ payload }: HandlerContext<{ type: string }>) => {
32
+ * console.log(payload.type);
33
+ * };
34
+ * ```
21
35
  */
22
36
  interface HandlerContext<TPayload = unknown> extends WebhookContext {
23
37
  /** The validated webhook payload */
24
38
  payload: TPayload;
25
39
  }
26
- /** Route registration options for a webhook handler. */
40
+ /** Internal handler entry stored per registered route. */
41
+ interface HandlerEntry<TPayload = unknown> {
42
+ after?: AfterHook[];
43
+ before?: BeforeHook[];
44
+ handler: WebhookHandler<TPayload>;
45
+ schema?: StandardSchemaV1<unknown, TPayload>;
46
+ }
47
+ /**
48
+ * Configuration options for creating a `WebhookRouter`.
49
+ *
50
+ * @example
51
+ * ```ts
52
+ * const options: WebhookRouterOptions = {
53
+ * prefix: "/webhooks",
54
+ * verify: createHmacVerifier({ headerName: "x-signature", secret }),
55
+ * };
56
+ * ```
57
+ */
58
+ interface WebhookRouterOptions {
59
+ /** Global hooks executed after successful route handler completion. */
60
+ after?: AfterHook | AfterHook[];
61
+ /** Global hooks executed before route-level hooks and verification. */
62
+ before?: BeforeHook | BeforeHook[];
63
+ /** Global error hook used to override the default `500` response. */
64
+ onError?: ErrorHook;
65
+ /**
66
+ * Required path prefix for all webhook routes. Defaults to `"/webhooks"`.
67
+ *
68
+ * Normalized internally: leading slash added, trailing slash stripped,
69
+ * duplicate slashes collapsed. Use `""` or `"/"` to mount at the root.
70
+ */
71
+ prefix?: string;
72
+ /** Optional request verification function (for signature checks, auth, etc.). */
73
+ verify?: VerifyFn;
74
+ }
75
+ /**
76
+ * Route registration options for a webhook handler.
77
+ *
78
+ * @example
79
+ * ```ts
80
+ * const options: RegisterOptions<{ type: string }> = {
81
+ * schema: stripeEventSchema,
82
+ * handler: ({ payload }) => console.log(payload.type),
83
+ * };
84
+ * ```
85
+ */
27
86
  interface RegisterOptions<T> {
28
87
  /** Hooks that run after successful processing (before global after hooks) */
29
88
  after?: AfterHook | AfterHook[];
@@ -44,10 +103,19 @@ type InferSchemaOutput<TSchema> = TSchema extends StandardSchemaV1<unknown, infe
44
103
  * Route options where schema is required and handler payload is inferred.
45
104
  *
46
105
  * @template TSchema - Schema used to infer handler payload type.
106
+ *
107
+ * @example
108
+ * ```ts
109
+ * const stripeRoute: SchemaRouteOptions<typeof stripeEventSchema> = {
110
+ * schema: stripeEventSchema,
111
+ * handler: ({ payload }) => console.log(payload.type),
112
+ * };
113
+ * ```
47
114
  */
48
115
  type SchemaRouteOptions<TSchema extends StandardSchemaV1<unknown, unknown>> = Omit<RegisterOptions<InferSchemaOutput<TSchema>>, "schema"> & {
49
116
  schema: TSchema;
50
117
  };
118
+ /** A single route's registration shape, as used by schema-driven route dictionaries. */
51
119
  interface RouteLike {
52
120
  after?: AfterHook | AfterHook[];
53
121
  before?: BeforeHook | BeforeHook[];
@@ -58,6 +126,13 @@ interface RouteLike {
58
126
  * Applies schema-driven payload inference to each route entry.
59
127
  *
60
128
  * @template TRoutes - Route dictionary keyed by webhook path.
129
+ *
130
+ * @example
131
+ * ```ts
132
+ * const routes: SchemaRoutes<{ "/stripe": { handler: WebhookHandler; schema: typeof stripeEventSchema } }> = {
133
+ * "/stripe": { schema: stripeEventSchema, handler: ({ payload }) => console.log(payload.type) },
134
+ * };
135
+ * ```
61
136
  */
62
137
  type SchemaRoutes<TRoutes extends Record<string, RouteLike>> = { [P in keyof TRoutes]: SchemaRouteOptions<TRoutes[P]["schema"]>; };
63
138
  /**
@@ -65,29 +140,78 @@ type SchemaRoutes<TRoutes extends Record<string, RouteLike>> = { [P in keyof TRo
65
140
  *
66
141
  * Return a `Response` to control the reply, or `undefined` to let the router
67
142
  * respond with its default `200` acknowledgement.
143
+ *
144
+ * @example
145
+ * ```ts
146
+ * const handler: WebhookHandler<{ type: string }> = ({ payload }) => {
147
+ * console.log(payload.type);
148
+ * };
149
+ * ```
68
150
  */
69
151
  type WebhookHandler<TPayload = unknown> = (ctx: HandlerContext<TPayload>) => Promise<Response | undefined> | Response | undefined;
70
- /** Maps route keys to their payload-specific webhook handlers. */
152
+ /**
153
+ * Maps route keys to their payload-specific webhook handlers.
154
+ *
155
+ * @example
156
+ * ```ts
157
+ * const handlers: HandlerMap<{ "/stripe": { type: string } }> = {
158
+ * "/stripe": ({ payload }) => console.log(payload.type),
159
+ * };
160
+ * ```
161
+ */
71
162
  type HandlerMap<TMap extends Record<string, unknown>> = { [P in keyof TMap]: WebhookHandler<TMap[P]>; };
72
163
  /**
73
164
  * Builds a webhook payload map from a schema-based route dictionary.
74
165
  *
75
166
  * @template TRoutes - Route dictionary keyed by webhook path.
167
+ *
168
+ * @example
169
+ * ```ts
170
+ * type Payloads = InferWebhookMapFromRoutes<{
171
+ * "/stripe": { handler: WebhookHandler; schema: typeof stripeEventSchema };
172
+ * }>;
173
+ * ```
76
174
  */
77
175
  type InferWebhookMapFromRoutes<TRoutes extends Record<string, RouteLike>> = { [P in keyof TRoutes]: InferSchemaOutput<TRoutes[P]["schema"]>; };
78
- /** Verification function for incoming requests. Throws to reject the request. */
176
+ /**
177
+ * Verification function for incoming requests. Throws to reject the request.
178
+ *
179
+ * @example
180
+ * ```ts
181
+ * const verify: VerifyFn = createHmacVerifier({ headerName: "x-signature", secret });
182
+ * ```
183
+ */
79
184
  type VerifyFn = (ctx: WebhookContext) => Promise<void> | void;
80
- /** Hook function that runs before request processing */
185
+ /**
186
+ * Hook function that runs before request processing
187
+ *
188
+ * @example
189
+ * ```ts
190
+ * const before: BeforeHook = (ctx) => console.log("received", ctx.path);
191
+ * ```
192
+ */
81
193
  type BeforeHook = (ctx: WebhookContext) => Promise<void> | void;
82
194
  /**
83
195
  * Hook function that runs after successful request processing.
84
196
  *
85
197
  * The hook receives the outgoing response as-is; call `response.clone()`
86
198
  * before reading its body to avoid consuming the stream sent to the client.
199
+ *
200
+ * @example
201
+ * ```ts
202
+ * const after: AfterHook = (ctx, response) => console.log(response.status);
203
+ * ```
87
204
  */
88
205
  type AfterHook = (ctx: WebhookContext, response: Response) => Promise<void> | void;
89
- /** Hook function that runs when an error occurs */
206
+ /**
207
+ * Hook function that runs when an error occurs
208
+ *
209
+ * @example
210
+ * ```ts
211
+ * const onError: ErrorHook = (error) => Response.json({ error: error.message }, { status: 500 });
212
+ * ```
213
+ */
90
214
  type ErrorHook = (error: Error, ctx: WebhookContext) => Promise<Response | undefined> | Response | undefined;
91
215
  //#endregion
92
- export { AfterHook, BeforeHook, ErrorHook, HandlerContext, HandlerMap, InferSchemaOutput, InferWebhookMapFromRoutes, RegisterOptions, SchemaRouteOptions, SchemaRoutes, VerifyFn, WebhookContext, WebhookHandler };
216
+ export { AfterHook, BeforeHook, ErrorHook, HandlerContext, HandlerEntry, HandlerMap, InferSchemaOutput, InferWebhookMapFromRoutes, RegisterOptions, RouteLike, SchemaRouteOptions, SchemaRoutes, VerifyFn, WebhookContext, WebhookHandler, WebhookRouterOptions };
93
217
  //# sourceMappingURL=types.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"types.d.ts","names":[],"sources":["../src/types.ts"],"mappings":";;;;;;;;UAciB;;EAEf;;EAEA,SAAS;;EAET,SAAS;;;;;;;UAQM,eAAe,4BAA4B;;EAE1D,SAAS;;;UAIM,gBAAgB;;EAE/B,QAAQ,YAAY;;EAEpB,SAAS,aAAa;;EAEtB,SAAS,eAAe;;EAExB,SAAS,0BAA0B;;;;;;;KAQzB,kBAAkB,WAC5B,gBAAgB,gCAAgC,WAAW;;;;;;KAOjD,mBACV,gBAAgB,sCACd,KAAK,gBAAgB,kBAAkB;EACzC,QAAQ;;UAGA;EACR,QAAQ,YAAY;EACpB,SAAS,aAAa;EACtB,SAAS;EACT,QAAQ;;;;;;;KAQE,aAAa,gBAAgB,eAAe,iBACrD,WAAW,UAAU,mBAAmB,QAAQ;;;;;;;KASvC,eAAe,uBACzB,KAAK,eAAe,cACjB,QAAQ,wBAAwB;;KAGzB,WAAW,aAAa,8BACjC,WAAW,OAAO,eAAe,KAAK;;;;;;KAQ7B,0BACV,gBAAgB,eAAe,iBAE9B,WAAW,UAAU,kBAAkB,QAAQ;;KAItC,YAAY,KAAK,mBAAmB;;KAGpC,cAAc,KAAK,mBAAmB;;;;;;;KAQtC,aACV,KAAK,gBACL,UAAU,aACP;;KAGO,aACV,OAAO,OACP,KAAK,mBACF,QAAQ,wBAAwB"}
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"}
package/dist/verify.d.ts CHANGED
@@ -1,5 +1,9 @@
1
1
  import { VerifyFn } from "./types.js";
2
2
  //#region src/verify.d.ts
3
+ /**
4
+ * Compares two byte arrays in constant time to prevent timing attacks.
5
+ */
6
+ declare const constantTimeEquals: (a: Uint8Array, b: Uint8Array) => boolean;
3
7
  declare const HMAC_HASH: {
4
8
  readonly sha1: "SHA-1";
5
9
  readonly sha256: "SHA-256";
@@ -45,5 +49,5 @@ declare const createHmacVerifier: ({ headerName, secret, algo }: {
45
49
  algo?: HmacAlgorithm;
46
50
  }) => VerifyFn;
47
51
  //#endregion
48
- export { createHmacVerifier };
52
+ export { constantTimeEquals, createHmacVerifier };
49
53
  //# sourceMappingURL=verify.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"verify.d.ts","names":[],"sources":["../src/verify.ts"],"mappings":";;cAUM;WACJ;WACA;WACA;WACA;;KAGG,6BAA6B;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;cA2CrB,uBACX,YACA,QACA;EAEA;EACA;EACA,OAAO;MACL"}
1
+ {"version":3,"file":"verify.d.ts","names":[],"sources":["../src/verify.ts"],"mappings":";;;;;cAYa,qBAAsB,GAAG,YAAY,GAAG;cAc/C;WACJ;WACA;WACA;WACA;;KAGG,6BAA6B;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;cAuDrB,uBACX,YACA,QACA;EAEA;EACA;EACA,OAAO;MACL"}
package/dist/verify.js CHANGED
@@ -1,19 +1,38 @@
1
1
  import { VerificationError } from "./errors.js";
2
- import { constantTimeEquals } from "./utils.js";
3
2
  //#region src/verify.ts
4
3
  /**
5
4
  * Signature verification helpers for webhook requests.
6
5
  *
7
6
  * @module @zap-studio/webhooks/verify
8
7
  */
8
+ /**
9
+ * Compares two byte arrays in constant time to prevent timing attacks.
10
+ */
11
+ const constantTimeEquals = (a, b) => {
12
+ if (a.length !== b.length) return false;
13
+ let result = 0;
14
+ for (let i = 0; i < a.length; i += 1)
15
+ // v8 ignore next -- `?? 0` fallback is unreachable: a Uint8Array never holds `undefined` at an in-bounds index, this exists only to satisfy noUncheckedIndexedAccess.
16
+ result |= (a[i] ?? 0) ^ (b[i] ?? 0);
17
+ return result === 0;
18
+ };
9
19
  const HMAC_HASH = {
10
20
  sha1: "SHA-1",
11
21
  sha256: "SHA-256",
12
22
  sha384: "SHA-384",
13
23
  sha512: "SHA-512"
14
24
  };
15
- const toHex = (bytes) => Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join("");
16
- const normalizeSignature = (signature) => signature.replace(/^[a-z0-9-]+=/iu, "").trim().toLowerCase();
25
+ const HEX_PATTERN = /^[0-9a-f]*$/iu;
26
+ /**
27
+ * Decodes a hex string into bytes, or `undefined` when it is not valid hex.
28
+ */
29
+ const hexToBytes = (hex) => {
30
+ if (hex.length % 2 !== 0 || !HEX_PATTERN.test(hex)) return;
31
+ const bytes = new Uint8Array(hex.length / 2);
32
+ for (let i = 0; i < bytes.length; i += 1) bytes[i] = Number.parseInt(hex.slice(i * 2, i * 2 + 2), 16);
33
+ return bytes;
34
+ };
35
+ const normalizeSignature = (signature) => signature.replace(/^[a-z0-9-]+=/iu, "").trim();
17
36
  /**
18
37
  * Creates a webhook verifier that validates an HMAC signature from a request header.
19
38
  *
@@ -60,11 +79,12 @@ const createHmacVerifier = ({ headerName, secret, algo = "sha256" }) => {
60
79
  if (actual === null || actual.length === 0) throw new VerificationError(`Missing signature header: ${headerName}`);
61
80
  const key = await keyPromise;
62
81
  const signature = await subtle.sign("HMAC", key, new Uint8Array(ctx.rawBody));
63
- const expected = toHex(new Uint8Array(signature));
64
- if (!constantTimeEquals(expected, normalizeSignature(actual))) throw new VerificationError(`Invalid signature for header: ${headerName}`);
82
+ const expected = new Uint8Array(signature);
83
+ const provided = hexToBytes(normalizeSignature(actual));
84
+ if (provided === void 0 || !constantTimeEquals(expected, provided)) throw new VerificationError(`Invalid signature for header: ${headerName}`);
65
85
  };
66
86
  };
67
87
  //#endregion
68
- export { createHmacVerifier };
88
+ export { constantTimeEquals, createHmacVerifier };
69
89
 
70
90
  //# sourceMappingURL=verify.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"verify.js","names":[],"sources":["../src/verify.ts"],"sourcesContent":["/**\n * Signature verification helpers for webhook requests.\n *\n * @module @zap-studio/webhooks/verify\n */\n\nimport { VerificationError } from \"./errors.js\";\nimport type { VerifyFn } from \"./types.js\";\nimport { constantTimeEquals } from \"./utils.js\";\n\nconst HMAC_HASH = {\n sha1: \"SHA-1\",\n sha256: \"SHA-256\",\n sha384: \"SHA-384\",\n sha512: \"SHA-512\",\n} as const;\n\ntype HmacAlgorithm = keyof typeof HMAC_HASH;\n\nconst toHex = (bytes: Uint8Array): string =>\n Array.from(bytes, (byte) => byte.toString(16).padStart(2, \"0\")).join(\"\");\n\nconst normalizeSignature = (signature: string): string =>\n signature\n .replace(/^[a-z0-9-]+=/iu, \"\")\n .trim()\n .toLowerCase();\n\n/**\n * Creates a webhook verifier that validates an HMAC signature from a request header.\n *\n * The verifier imports the provided string secret once, computes an HMAC from\n * `ctx.rawBody`, normalizes the incoming header value, and compares both\n * signatures in constant time.\n *\n * Header values like `sha256=<hex>` are supported so common provider formats\n * such as GitHub work without extra parsing.\n *\n * @example\n * ```ts\n * import { createWebhookRouter } from \"@zap-studio/webhooks\";\n * import { createHmacVerifier } from \"@zap-studio/webhooks/verify\";\n *\n * const router = createWebhookRouter({\n * verify: createHmacVerifier({\n * headerName: \"x-hub-signature-256\",\n * secret: process.env.GITHUB_WEBHOOK_SECRET!,\n * }),\n * });\n * ```\n *\n * @param options - Verifier configuration.\n * @param options.headerName - Header containing the provider signature.\n * @param options.secret - Shared HMAC secret as a string.\n * @param options.algo - HMAC hash algorithm. Defaults to `\"sha256\"`.\n * @returns A router-compatible request verifier.\n *\n * @throws {VerificationError}\n * Thrown when verifier setup fails or request verification does not pass.\n */\nexport const createHmacVerifier = ({\n headerName,\n secret,\n algo = \"sha256\",\n}: {\n headerName: string;\n secret: string;\n algo?: HmacAlgorithm;\n}): VerifyFn => {\n if (globalThis.crypto?.subtle === undefined) {\n throw new VerificationError(\n \"Web Crypto API is unavailable in this runtime\"\n );\n }\n\n const { subtle } = globalThis.crypto;\n\n const hash = HMAC_HASH[algo];\n if (!hash) {\n throw new VerificationError(`Unsupported HMAC algorithm: ${algo}`);\n }\n\n const keyPromise = subtle.importKey(\n \"raw\",\n new TextEncoder().encode(secret),\n { hash, name: \"HMAC\" },\n false,\n [\"sign\"]\n );\n\n return async (ctx) => {\n const actual = ctx.request.headers.get(headerName);\n if (actual === null || actual.length === 0) {\n throw new VerificationError(`Missing signature header: ${headerName}`);\n }\n\n const key = await keyPromise;\n const signature = await subtle.sign(\n \"HMAC\",\n key,\n new Uint8Array(ctx.rawBody)\n );\n const expected = toHex(new Uint8Array(signature));\n\n if (!constantTimeEquals(expected, normalizeSignature(actual))) {\n throw new VerificationError(\n `Invalid signature for header: ${headerName}`\n );\n }\n };\n};\n"],"mappings":";;;;;;;;AAUA,MAAM,YAAY;CAChB,MAAM;CACN,QAAQ;CACR,QAAQ;CACR,QAAQ;AACV;AAIA,MAAM,SAAS,UACb,MAAM,KAAK,QAAQ,SAAS,KAAK,SAAS,EAAE,CAAC,CAAC,SAAS,GAAG,GAAG,CAAC,CAAC,CAAC,KAAK,EAAE;AAEzE,MAAM,sBAAsB,cAC1B,UACG,QAAQ,kBAAkB,EAAE,CAAC,CAC7B,KAAK,CAAC,CACN,YAAY;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAkCjB,MAAa,sBAAsB,EACjC,YACA,QACA,OAAO,eAKO;CACd,IAAI,WAAW,QAAQ,WAAW,KAAA,GAChC,MAAM,IAAI,kBACR,+CACF;CAGF,MAAM,EAAE,WAAW,WAAW;CAE9B,MAAM,OAAO,UAAU;CACvB,IAAI,CAAC,MACH,MAAM,IAAI,kBAAkB,+BAA+B,MAAM;CAGnE,MAAM,aAAa,OAAO,UACxB,OACA,IAAI,YAAY,CAAC,CAAC,OAAO,MAAM,GAC/B;EAAE;EAAM,MAAM;CAAO,GACrB,OACA,CAAC,MAAM,CACT;CAEA,OAAO,OAAO,QAAQ;EACpB,MAAM,SAAS,IAAI,QAAQ,QAAQ,IAAI,UAAU;EACjD,IAAI,WAAW,QAAQ,OAAO,WAAW,GACvC,MAAM,IAAI,kBAAkB,6BAA6B,YAAY;EAGvE,MAAM,MAAM,MAAM;EAClB,MAAM,YAAY,MAAM,OAAO,KAC7B,QACA,KACA,IAAI,WAAW,IAAI,OAAO,CAC5B;EACA,MAAM,WAAW,MAAM,IAAI,WAAW,SAAS,CAAC;EAEhD,IAAI,CAAC,mBAAmB,UAAU,mBAAmB,MAAM,CAAC,GAC1D,MAAM,IAAI,kBACR,iCAAiC,YACnC;CAEJ;AACF"}
1
+ {"version":3,"file":"verify.js","names":[],"sources":["../src/verify.ts"],"sourcesContent":["/**\n * Signature verification helpers for webhook requests.\n *\n * @module @zap-studio/webhooks/verify\n */\n\nimport { VerificationError } from \"./errors.js\";\nimport type { VerifyFn } from \"./types.js\";\n\n/**\n * Compares two byte arrays in constant time to prevent timing attacks.\n */\nexport const constantTimeEquals = (a: Uint8Array, b: Uint8Array): boolean => {\n if (a.length !== b.length) {\n return false;\n }\n\n let result = 0;\n for (let i = 0; i < a.length; i += 1) {\n // v8 ignore next -- `?? 0` fallback is unreachable: a Uint8Array never holds `undefined` at an in-bounds index, this exists only to satisfy noUncheckedIndexedAccess.\n result |= (a[i] ?? 0) ^ (b[i] ?? 0); // oxlint-disable-line no-bitwise -- XOR is the constant-time compare trick.\n }\n\n return result === 0;\n};\n\nconst HMAC_HASH = {\n sha1: \"SHA-1\",\n sha256: \"SHA-256\",\n sha384: \"SHA-384\",\n sha512: \"SHA-512\",\n} as const;\n\ntype HmacAlgorithm = keyof typeof HMAC_HASH;\n\nconst HEX_PATTERN = /^[0-9a-f]*$/iu;\n\n/**\n * Decodes a hex string into bytes, or `undefined` when it is not valid hex.\n */\nconst hexToBytes = (hex: string): Uint8Array | undefined => {\n if (hex.length % 2 !== 0 || !HEX_PATTERN.test(hex)) {\n return undefined;\n }\n\n const bytes = new Uint8Array(hex.length / 2);\n for (let i = 0; i < bytes.length; i += 1) {\n bytes[i] = Number.parseInt(hex.slice(i * 2, i * 2 + 2), 16);\n }\n\n return bytes;\n};\n\nconst normalizeSignature = (signature: string): string =>\n signature.replace(/^[a-z0-9-]+=/iu, \"\").trim();\n\n/**\n * Creates a webhook verifier that validates an HMAC signature from a request header.\n *\n * The verifier imports the provided string secret once, computes an HMAC from\n * `ctx.rawBody`, normalizes the incoming header value, and compares both\n * signatures in constant time.\n *\n * Header values like `sha256=<hex>` are supported so common provider formats\n * such as GitHub work without extra parsing.\n *\n * @example\n * ```ts\n * import { createWebhookRouter } from \"@zap-studio/webhooks\";\n * import { createHmacVerifier } from \"@zap-studio/webhooks/verify\";\n *\n * const router = createWebhookRouter({\n * verify: createHmacVerifier({\n * headerName: \"x-hub-signature-256\",\n * secret: process.env.GITHUB_WEBHOOK_SECRET!,\n * }),\n * });\n * ```\n *\n * @param options - Verifier configuration.\n * @param options.headerName - Header containing the provider signature.\n * @param options.secret - Shared HMAC secret as a string.\n * @param options.algo - HMAC hash algorithm. Defaults to `\"sha256\"`.\n * @returns A router-compatible request verifier.\n *\n * @throws {VerificationError}\n * Thrown when verifier setup fails or request verification does not pass.\n */\nexport const createHmacVerifier = ({\n headerName,\n secret,\n algo = \"sha256\",\n}: {\n headerName: string;\n secret: string;\n algo?: HmacAlgorithm;\n}): VerifyFn => {\n if (globalThis.crypto?.subtle === undefined) {\n throw new VerificationError(\n \"Web Crypto API is unavailable in this runtime\"\n );\n }\n\n const { subtle } = globalThis.crypto;\n\n const hash = HMAC_HASH[algo];\n if (!hash) {\n throw new VerificationError(`Unsupported HMAC algorithm: ${algo}`);\n }\n\n const keyPromise = subtle.importKey(\n \"raw\",\n new TextEncoder().encode(secret),\n { hash, name: \"HMAC\" },\n false,\n [\"sign\"]\n );\n\n return async (ctx) => {\n const actual = ctx.request.headers.get(headerName);\n if (actual === null || actual.length === 0) {\n throw new VerificationError(`Missing signature header: ${headerName}`);\n }\n\n const key = await keyPromise;\n const signature = await subtle.sign(\n \"HMAC\",\n key,\n new Uint8Array(ctx.rawBody)\n );\n const expected = new Uint8Array(signature);\n const provided = hexToBytes(normalizeSignature(actual));\n\n if (provided === undefined || !constantTimeEquals(expected, provided)) {\n throw new VerificationError(\n `Invalid signature for header: ${headerName}`\n );\n }\n };\n};\n"],"mappings":";;;;;;;;;;AAYA,MAAa,sBAAsB,GAAe,MAA2B;CAC3E,IAAI,EAAE,WAAW,EAAE,QACjB,OAAO;CAGT,IAAI,SAAS;CACb,KAAK,IAAI,IAAI,GAAG,IAAI,EAAE,QAAQ,KAAK;;CAEjC,WAAW,EAAE,MAAM,MAAM,EAAE,MAAM;CAGnC,OAAO,WAAW;AACpB;AAEA,MAAM,YAAY;CAChB,MAAM;CACN,QAAQ;CACR,QAAQ;CACR,QAAQ;AACV;AAIA,MAAM,cAAc;;;;AAKpB,MAAM,cAAc,QAAwC;CAC1D,IAAI,IAAI,SAAS,MAAM,KAAK,CAAC,YAAY,KAAK,GAAG,GAC/C;CAGF,MAAM,QAAQ,IAAI,WAAW,IAAI,SAAS,CAAC;CAC3C,KAAK,IAAI,IAAI,GAAG,IAAI,MAAM,QAAQ,KAAK,GACrC,MAAM,KAAK,OAAO,SAAS,IAAI,MAAM,IAAI,GAAG,IAAI,IAAI,CAAC,GAAG,EAAE;CAG5D,OAAO;AACT;AAEA,MAAM,sBAAsB,cAC1B,UAAU,QAAQ,kBAAkB,EAAE,CAAC,CAAC,KAAK;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAkC/C,MAAa,sBAAsB,EACjC,YACA,QACA,OAAO,eAKO;CACd,IAAI,WAAW,QAAQ,WAAW,KAAA,GAChC,MAAM,IAAI,kBACR,+CACF;CAGF,MAAM,EAAE,WAAW,WAAW;CAE9B,MAAM,OAAO,UAAU;CACvB,IAAI,CAAC,MACH,MAAM,IAAI,kBAAkB,+BAA+B,MAAM;CAGnE,MAAM,aAAa,OAAO,UACxB,OACA,IAAI,YAAY,CAAC,CAAC,OAAO,MAAM,GAC/B;EAAE;EAAM,MAAM;CAAO,GACrB,OACA,CAAC,MAAM,CACT;CAEA,OAAO,OAAO,QAAQ;EACpB,MAAM,SAAS,IAAI,QAAQ,QAAQ,IAAI,UAAU;EACjD,IAAI,WAAW,QAAQ,OAAO,WAAW,GACvC,MAAM,IAAI,kBAAkB,6BAA6B,YAAY;EAGvE,MAAM,MAAM,MAAM;EAClB,MAAM,YAAY,MAAM,OAAO,KAC7B,QACA,KACA,IAAI,WAAW,IAAI,OAAO,CAC5B;EACA,MAAM,WAAW,IAAI,WAAW,SAAS;EACzC,MAAM,WAAW,WAAW,mBAAmB,MAAM,CAAC;EAEtD,IAAI,aAAa,KAAA,KAAa,CAAC,mBAAmB,UAAU,QAAQ,GAClE,MAAM,IAAI,kBACR,iCAAiC,YACnC;CAEJ;AACF"}
package/package.json CHANGED
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "name": "@zap-studio/webhooks",
3
- "version": "0.4.0",
3
+ "version": "1.0.0",
4
4
  "private": false,
5
- "description": "A lightweight, type-safe webhook router with Standard Schema validation, signature verification, and lifecycle hooks.",
5
+ "description": "A lightweight, type-safe, tree-shakeable webhook router with Standard Schema validation, signature verification, and lifecycle hooks.",
6
6
  "keywords": [
7
7
  "arktype",
8
8
  "lifecycle hooks",
@@ -39,7 +39,6 @@
39
39
  "./errors": "./dist/errors.js",
40
40
  "./router": "./dist/router.js",
41
41
  "./types": "./dist/types.js",
42
- "./utils": "./dist/utils.js",
43
42
  "./verify": "./dist/verify.js",
44
43
  "./package.json": "./package.json"
45
44
  },
@@ -47,16 +46,16 @@
47
46
  "access": "public"
48
47
  },
49
48
  "dependencies": {
50
- "@zap-studio/validation": "workspace:*"
49
+ "@zap-studio/validation": "1.0.0"
51
50
  },
52
51
  "devDependencies": {
53
- "@zap-studio/typescript": "workspace:*",
54
- "tsdown": "catalog:",
55
- "typescript": "catalog:",
56
- "vitest": "catalog:",
57
- "zod": "catalog:"
52
+ "tsdown": "^0.22.14",
53
+ "typescript": "^7.0.2",
54
+ "vitest": "^4.1.10",
55
+ "zod": "^4.4.3",
56
+ "@zap-studio/typescript": "0.0.0"
58
57
  },
59
58
  "engines": {
60
59
  "node": ">=18.0.0"
61
60
  }
62
- }
61
+ }