@zap-studio/webhooks 2.0.1 → 2.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 +29 -0
- package/README.md +16 -0
- package/dist/errors.d.ts +1 -2
- package/dist/errors.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/{router-Ba9jU5pC.js → router-rax4SfyZ.js} +17 -4
- package/dist/router-rax4SfyZ.js.map +1 -0
- package/dist/router.d.ts +13 -3
- package/dist/router.d.ts.map +1 -1
- package/dist/router.js +1 -1
- package/dist/types.d.ts +30 -18
- package/dist/types.d.ts.map +1 -1
- package/dist/verify.d.ts +2 -3
- package/dist/verify.d.ts.map +1 -1
- package/package.json +8 -8
- package/dist/router-Ba9jU5pC.js.map +0 -1
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,35 @@ 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
|
+
|
|
8
|
+
## 2.1.1
|
|
9
|
+
<sub>2026-09-13</sub>
|
|
10
|
+
|
|
11
|
+
- [#613](https://github.com/zap-studio/monorepo/pull/613) *(patch)* Thanks [@alexandretrotel](https://github.com/alexandretrotel)!
|
|
12
|
+
Widen internal peer dependency ranges from an exact pin to a caret range, so consumers no longer resolve a duplicate copy of the peer on version skew.
|
|
13
|
+
|
|
14
|
+
## [2.1.0]
|
|
15
|
+
|
|
16
|
+
### Added
|
|
17
|
+
|
|
18
|
+
Route-level `verify` on `register()`. Previously `verify` was only a router-wide `createWebhookRouter({ verify })` option, applied to every route — a router handling more than one provider had to hand-roll a dispatcher keyed on `ctx.path` to give each route its own verifier. Now each route can carry its own `verify` directly:
|
|
19
|
+
|
|
20
|
+
```ts
|
|
21
|
+
router.register("/stripe", {
|
|
22
|
+
schema: stripeEventSchema,
|
|
23
|
+
verify: createHmacVerifier({ headerName: "stripe-signature", secret: stripeSecret }),
|
|
24
|
+
handler: stripeHandler,
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
router.register("/github", {
|
|
28
|
+
schema: githubEventSchema,
|
|
29
|
+
verify: createHmacVerifier({ headerName: "x-hub-signature-256", secret: githubSecret }),
|
|
30
|
+
handler: githubHandler,
|
|
31
|
+
});
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
A route's own `verify` overrides the router-level one when both are set; the router-level `verify` still applies as the default for routes that don't set their own. Fully backward compatible — existing single-provider setups using only the router-level option are unaffected.
|
|
35
|
+
|
|
7
36
|
## [2.0.1]
|
|
8
37
|
|
|
9
38
|
### Fixed
|
package/README.md
CHANGED
|
@@ -119,6 +119,22 @@ const router = createWebhookRouter({
|
|
|
119
119
|
});
|
|
120
120
|
```
|
|
121
121
|
|
|
122
|
+
`verify` also works per-route on `register()`, overriding the router-level one — useful for a router handling multiple providers, each with its own signing scheme:
|
|
123
|
+
|
|
124
|
+
```ts
|
|
125
|
+
router.register("/github", {
|
|
126
|
+
verify: createHmacVerifier({ headerName: "x-hub-signature-256", secret: githubSecret }),
|
|
127
|
+
schema: githubEventSchema,
|
|
128
|
+
handler: githubHandler,
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
router.register("/stripe", {
|
|
132
|
+
verify: stripeVerify, // a different scheme entirely
|
|
133
|
+
schema: stripeEventSchema,
|
|
134
|
+
handler: stripeHandler,
|
|
135
|
+
});
|
|
136
|
+
```
|
|
137
|
+
|
|
122
138
|
## Lifecycle Hooks
|
|
123
139
|
|
|
124
140
|
Global `before`, `after`, and `onError` hooks for cross-cutting behavior.
|
package/dist/errors.d.ts
CHANGED
|
@@ -26,7 +26,7 @@
|
|
|
26
26
|
* });
|
|
27
27
|
* ```
|
|
28
28
|
*/
|
|
29
|
-
declare class VerificationError extends Error {
|
|
29
|
+
export declare class VerificationError extends Error {
|
|
30
30
|
/**
|
|
31
31
|
* Creates a verification error with a human-readable message.
|
|
32
32
|
*
|
|
@@ -35,5 +35,4 @@ declare class VerificationError extends Error {
|
|
|
35
35
|
constructor(message: string);
|
|
36
36
|
}
|
|
37
37
|
//#endregion
|
|
38
|
-
export { VerificationError };
|
|
39
38
|
//# sourceMappingURL=errors.d.ts.map
|
package/dist/errors.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"errors.d.ts","names":[],"sources":["../src/errors.ts"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
|
1
|
+
{"version":3,"file":"errors.d.ts","names":[],"sources":["../src/errors.ts"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;qBA4Ba,0BAA0B;;;;;;EAMrC,YAAY"}
|
package/dist/index.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
1
|
import { VerificationError } from "./errors.js";
|
|
2
|
-
import { n as createWebhookRouter, t as WebhookRouter } from "./router-
|
|
2
|
+
import { n as createWebhookRouter, t as WebhookRouter } from "./router-rax4SfyZ.js";
|
|
3
3
|
import { constantTimeEquals, createHmacVerifier } from "./verify.js";
|
|
4
4
|
export { VerificationError, WebhookRouter, constantTimeEquals, createHmacVerifier, createWebhookRouter };
|
|
@@ -7,7 +7,7 @@ import { standardValidate } from "@zap-studio/validation";
|
|
|
7
7
|
* `TracerProvider`; a no-op provider (the default until an app registers an
|
|
8
8
|
* SDK) makes every span/propagation call below a no-op too.
|
|
9
9
|
*/
|
|
10
|
-
const tracer = trace.getTracer("@zap-studio/webhooks", "2.
|
|
10
|
+
const tracer = trace.getTracer("@zap-studio/webhooks", "2.1.1");
|
|
11
11
|
/**
|
|
12
12
|
* `TextMapGetter` for the Web `Headers` API, used to extract an inbound
|
|
13
13
|
* delivery's `traceparent` (and any other registered propagator fields) so
|
|
@@ -74,6 +74,7 @@ const createHandlerEntry = (options) => {
|
|
|
74
74
|
if (options.schema !== void 0) entry.schema = options.schema;
|
|
75
75
|
if (options.before !== void 0) entry.before = toArray(options.before);
|
|
76
76
|
if (options.after !== void 0) entry.after = toArray(options.after);
|
|
77
|
+
if (options.verify !== void 0) entry.verify = options.verify;
|
|
77
78
|
return entry;
|
|
78
79
|
};
|
|
79
80
|
/** Parses the request's raw body bytes as JSON, returning `undefined` on invalid JSON. */
|
|
@@ -129,11 +130,22 @@ const dispatchHandler = async (handlerEntry, ctx, validatedPayload, deliveryCont
|
|
|
129
130
|
*
|
|
130
131
|
* router.register("/stripe", {
|
|
131
132
|
* schema: stripeEventSchema,
|
|
133
|
+
* verify: createHmacVerifier({ headerName: "stripe-signature", secret: stripeSecret }),
|
|
132
134
|
* handler: async ({ payload }) => {
|
|
133
135
|
* console.log("Stripe event:", payload.type);
|
|
134
136
|
* },
|
|
135
137
|
* });
|
|
136
138
|
*
|
|
139
|
+
* // Each route can carry its own verifier for multi-provider setups —
|
|
140
|
+
* // no router-level `verify` needed unless routes should share a default.
|
|
141
|
+
* router.register("/github", {
|
|
142
|
+
* schema: githubEventSchema,
|
|
143
|
+
* verify: createHmacVerifier({ headerName: "x-hub-signature-256", secret: githubSecret }),
|
|
144
|
+
* handler: async ({ payload }) => {
|
|
145
|
+
* console.log("GitHub event:", payload.ref);
|
|
146
|
+
* },
|
|
147
|
+
* });
|
|
148
|
+
*
|
|
137
149
|
* export default { fetch: (request: Request) => router.handle(request) };
|
|
138
150
|
* ```
|
|
139
151
|
*/
|
|
@@ -231,8 +243,9 @@ var WebhookRouter = class {
|
|
|
231
243
|
ctx.rawBody = new Uint8Array(await request.arrayBuffer());
|
|
232
244
|
await runBeforeHooks(ctx, this.globalBeforeHooks);
|
|
233
245
|
await runBeforeHooks(ctx, handlerEntry.before);
|
|
234
|
-
|
|
235
|
-
|
|
246
|
+
const verify = handlerEntry.verify ?? this.verify;
|
|
247
|
+
if (verify) try {
|
|
248
|
+
await verify(ctx);
|
|
236
249
|
} catch (error) {
|
|
237
250
|
this.logger?.warn("webhook verification failed", {
|
|
238
251
|
error,
|
|
@@ -288,4 +301,4 @@ const createWebhookRouter = (opts) => new WebhookRouter(opts);
|
|
|
288
301
|
//#endregion
|
|
289
302
|
export { createWebhookRouter as n, WebhookRouter as t };
|
|
290
303
|
|
|
291
|
-
//# sourceMappingURL=router-
|
|
304
|
+
//# sourceMappingURL=router-rax4SfyZ.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"router-rax4SfyZ.js","names":["pkg.name","pkg.version","otelContext"],"sources":["../package.json","../src/_otel.ts","../src/router.ts"],"sourcesContent":["","/**\n * Internal OpenTelemetry wiring for the webhooks package: tracer resolution,\n * the `Headers` extraction carrier, and span error recording. Kept out of\n * `router.ts` so dispatch logic doesn't get tangled with tracing concerns.\n *\n * @module @zap-studio/webhooks/otel\n */\n\nimport type { Span, TextMapGetter, Tracer } from \"@opentelemetry/api\";\n\nimport { SpanStatusCode, trace } from \"@opentelemetry/api\";\n\nimport pkg from \"../package.json\" with { type: \"json\" };\n\n/**\n * OpenTelemetry tracer for this package. Resolved once against the global\n * `TracerProvider`; a no-op provider (the default until an app registers an\n * SDK) makes every span/propagation call below a no-op too.\n */\nexport const tracer: Tracer = trace.getTracer(pkg.name, pkg.version);\n\n/**\n * `TextMapGetter` for the Web `Headers` API, used to extract an inbound\n * delivery's `traceparent` (and any other registered propagator fields) so\n * the delivery span continues the sender's trace instead of starting a new one.\n */\nexport const HEADERS_GETTER: TextMapGetter<Headers> = {\n get(carrier, key) {\n return carrier.get(key) ?? undefined;\n },\n keys(carrier) {\n return [...carrier.keys()];\n },\n};\n\n/**\n * Records `error` on `span` and marks it as failed. `recordException` only\n * accepts an `Error` or `string`, so other thrown values just get the\n * `ERROR` status without an attached exception event.\n */\nexport const recordSpanError = (span: Span, error: unknown): void => {\n if (error instanceof Error || typeof error === \"string\") {\n span.recordException(error);\n }\n span.setStatus({ code: SpanStatusCode.ERROR });\n};\n","/**\n * Schema-first webhook router primitives.\n *\n * @module @zap-studio/webhooks/router\n */\n\nimport type { Context, Span } from \"@opentelemetry/api\";\nimport type { Logger } from \"@zap-studio/logger\";\nimport type { StandardSchemaV1 } from \"@zap-studio/validation\";\n\nimport {\n SpanKind,\n SpanStatusCode,\n context as otelContext,\n propagation,\n trace,\n} from \"@opentelemetry/api\";\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.ts\";\n\nimport { HEADERS_GETTER, recordSpanError, tracer } from \"./_otel.ts\";\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 => Response.json({ error: \"not found\" }, { status: 404 });\n\n/** Sets `http.response.status_code` and marks `span` `ERROR` on a non-2xx response. */\nconst finishDelivery = (span: Span, response: Response): Response => {\n span.setAttribute(\"http.response.status_code\", response.status);\n if (!response.ok) {\n span.setStatus({ code: SpanStatusCode.ERROR });\n }\n return response;\n};\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(\"/\") ? collapsed.slice(0, -1) : collapsed;\n};\n\n/** Runs the given before-hooks in order against the request context. */\nconst runBeforeHooks = async (ctx: WebhookContext, hooks?: BeforeHook[]): Promise<void> => {\n if (!hooks || hooks.length === 0) {\n return;\n }\n\n for (const hook of hooks) {\n // oxlint-disable-next-line react-doctor/async-await-in-loop -- false positive: before-hooks must run in the order they were added, because a later hook can depend on an earlier one. A throw must also stop the hooks that are left. Promise.all would run them at the same time and lose both rules.\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 react-doctor/async-await-in-loop -- false positive: same rules as runBeforeHooks above. After-hooks must run in the order they were added, and a throw must stop the hooks that are left.\n await hook(ctx, response);\n }\n};\n\n/** Builds an internal handler entry from route registration options. */\nconst createHandlerEntry = (options: RegisterOptions<unknown>): 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 if (options.verify !== undefined) {\n entry.verify = options.verify;\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 // SAFETY: no schema was given, so there is nothing to validate against. The `TPayload` type declared by the caller is the route's only 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/** Runs the route handler inside its own `INTERNAL` span, nested under the delivery span. */\nconst dispatchHandler = async (\n handlerEntry: HandlerEntry,\n ctx: WebhookContext,\n validatedPayload: unknown,\n deliveryContext: Context,\n): Promise<Response> => {\n const handlerSpan = tracer.startSpan(\n `webhook.handler ${ctx.path}`,\n { kind: SpanKind.INTERNAL },\n deliveryContext,\n );\n\n try {\n return await otelContext.with(\n trace.setSpan(deliveryContext, handlerSpan),\n async () => await executeHandler(handlerEntry.handler, ctx, validatedPayload),\n );\n } catch (error) {\n recordSpanError(handlerSpan, error);\n throw error;\n } finally {\n handlerSpan.end();\n }\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 * verify: createHmacVerifier({ headerName: \"stripe-signature\", secret: stripeSecret }),\n * handler: async ({ payload }) => {\n * console.log(\"Stripe event:\", payload.type);\n * },\n * });\n *\n * // Each route can carry its own verifier for multi-provider setups —\n * // no router-level `verify` needed unless routes should share a default.\n * router.register(\"/github\", {\n * schema: githubEventSchema,\n * verify: createHmacVerifier({ headerName: \"x-hub-signature-256\", secret: githubSecret }),\n * handler: async ({ payload }) => {\n * console.log(\"GitHub event:\", payload.ref);\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<Path extends `/${string}`, TSchema extends StandardSchemaV1<unknown, unknown>>(\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(path: string, handlerOrOptions: WebhookHandler | RegisterOptions<unknown>): 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 const { method } = request;\n this.logger?.debug(\"webhook delivery attempt\", { path: requestPath });\n\n const parentContext = propagation.extract(\n otelContext.active(),\n request.headers,\n HEADERS_GETTER,\n );\n const deliverySpan = tracer.startSpan(\n `${method} ${requestPath}`,\n {\n attributes: {\n \"http.request.method\": method,\n \"url.path\": requestPath,\n },\n kind: SpanKind.SERVER,\n },\n parentContext,\n );\n const deliveryContext = trace.setSpan(parentContext, deliverySpan);\n\n try {\n const response = await otelContext.with(\n deliveryContext,\n async () => await this.dispatch(request, requestPath, deliveryContext),\n );\n return finishDelivery(deliverySpan, response);\n } finally {\n deliverySpan.end();\n }\n }\n\n /** Matches the route, runs hooks/verification/validation, and dispatches the handler. */\n private async dispatch(\n request: Request,\n requestPath: string,\n deliveryContext: Context,\n ): Promise<Response> {\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 const verify = handlerEntry.verify ?? this.verify;\n if (verify) {\n try {\n await 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(parsedJson, handlerEntry.schema);\n\n if (validationResult instanceof Response) {\n return validationResult;\n }\n\n this.logger?.debug(\"webhook handler dispatch\", { path });\n const response = await dispatchHandler(handlerEntry, ctx, validationResult, deliveryContext);\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(error: unknown, ctx: WebhookContext): Promise<Response> {\n if (this.globalErrorHook) {\n const normalizedError = 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 = (opts?: WebhookRouterOptions): WebhookRouter =>\n new WebhookRouter(opts);\n"],"mappings":";;;;;;;;;ACmBA,MAAa,SAAiB,MAAM,UAAUA,wBAAUC,OAAW;;;;;;AAOnE,MAAa,iBAAyC;CACpD,IAAI,SAAS,KAAK;EAChB,OAAO,QAAQ,IAAI,GAAG,KAAK,KAAA;CAC7B;CACA,KAAK,SAAS;EACZ,OAAO,CAAC,GAAG,QAAQ,KAAK,CAAC;CAC3B;AACF;;;;;;AAOA,MAAa,mBAAmB,MAAY,UAAyB;CACnE,IAAI,iBAAiB,SAAS,OAAO,UAAU,UAC7C,KAAK,gBAAgB,KAAK;CAE5B,KAAK,UAAU,EAAE,MAAM,eAAe,MAAM,CAAC;AAC/C;;;;;;;;ACJA,MAAM,WAAc,UAAoC;CACtD,IAAI,UAAU,KAAA,GACZ,OAAO,CAAC;CAGV,OAAO,MAAM,QAAQ,KAAK,IAAI,QAAQ,CAAC,KAAK;AAC9C;AAEA,MAAM,yBAAmC,SAAS,KAAK,EAAE,OAAO,YAAY,GAAG,EAAE,QAAQ,IAAI,CAAC;;AAG9F,MAAM,kBAAkB,MAAY,aAAiC;CACnE,KAAK,aAAa,6BAA6B,SAAS,MAAM;CAC9D,IAAI,CAAC,SAAS,IACZ,KAAK,UAAU,EAAE,MAAM,eAAe,MAAM,CAAC;CAE/C,OAAO;AACT;AAEA,MAAM,cAAc,IAAI,YAAY;;;;;AAMpC,MAAM,iBAAiB,SAAyB;CAC9C,MAAM,mBAAmB,KAAK,WAAW,GAAG,IAAI,OAAO,IAAI;CAC3D,MAAM,YAAY,iBAAiB,SAAS,IAAI,IAC5C,iBAAiB,WAAW,YAAY,GAAG,IAC3C;CAEJ,OAAO,UAAU,SAAS,KAAK,UAAU,SAAS,GAAG,IAAI,UAAU,MAAM,GAAG,EAAE,IAAI;AACpF;;AAGA,MAAM,iBAAiB,OAAO,KAAqB,UAAwC;CACzF,IAAI,CAAC,SAAS,MAAM,WAAW,GAC7B;CAGF,KAAK,MAAM,QAAQ,OAEjB,MAAM,KAAK,GAAG;AAElB;;AAGA,MAAM,gBAAgB,OACpB,KACA,UACA,UACkB;CAClB,IAAI,CAAC,SAAS,MAAM,WAAW,GAC7B;CAGF,KAAK,MAAM,QAAQ,OAEjB,MAAM,KAAK,KAAK,QAAQ;AAE5B;;AAGA,MAAM,sBAAsB,YAAoD;CAC9E,MAAM,QAAsB,EAC1B,SAAS,QAAQ,QACnB;CAEA,IAAI,QAAQ,WAAW,KAAA,GACrB,MAAM,SAAS,QAAQ;CAGzB,IAAI,QAAQ,WAAW,KAAA,GACrB,MAAM,SAAS,QAAQ,QAAQ,MAAM;CAGvC,IAAI,QAAQ,UAAU,KAAA,GACpB,MAAM,QAAQ,QAAQ,QAAQ,KAAK;CAGrC,IAAI,QAAQ,WAAW,KAAA,GACrB,MAAM,SAAS,QAAQ;CAGzB,OAAO;AACT;;AAGA,MAAM,oBAAoB,QAAiC;CACzD,IAAI;EACF,OAAO,KAAK,MAAM,YAAY,OAAO,IAAI,OAAO,CAAC;CACnD,QAAQ;EACN;CACF;AACF;;AAGA,MAAM,kBAAkB,OACtB,YACA,WACiC;CACjC,IAAI,CAAC,QAEH,OAAO;CAGT,MAAM,SAAS,MAAM,iBAAiB,YAAY,QAAQ,EACxD,cAAc,MAChB,CAAC;CAED,IAAI,OAAO,QACT,OAAO,SAAS,KACd;EACE,OAAO;EACP,QAAQ,OAAO,OAAO,KAAK,WAAW;GACpC,SAAS,MAAM;GACf,MAAM,MAAM,MAAM,KAAK,MACrB,OAAO,MAAM,YAAY,SAAS,IAAI,OAAO,EAAE,GAAG,IAAI,OAAO,CAAC,CAChE;EACF,EAAE;CACJ,GACA,EAAE,QAAQ,IAAI,CAChB;CAGF,OAAO,OAAO;AAChB;;AAGA,MAAM,iBAAiB,OACrB,SACA,KACA,qBACsB;CAMtB,OAAO,MALiB,QAAQ;EAC9B,GAAG;EACH,SAAS;CACX,CAAC,KAEmB,SAAS,KAAK,IAAI;AACxC;;AAGA,MAAM,kBAAkB,OACtB,cACA,KACA,kBACA,oBACsB;CACtB,MAAM,cAAc,OAAO,UACzB,mBAAmB,IAAI,QACvB,EAAE,MAAM,SAAS,SAAS,GAC1B,eACF;CAEA,IAAI;EACF,OAAO,MAAMC,QAAY,KACvB,MAAM,QAAQ,iBAAiB,WAAW,GAC1C,YAAY,MAAM,eAAe,aAAa,SAAS,KAAK,gBAAgB,CAC9E;CACF,SAAS,OAAO;EACd,gBAAgB,aAAa,KAAK;EAClC,MAAM;CACR,UAAU;EACR,YAAY,IAAI;CAClB;AACF;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAkCA,IAAa,gBAAb,MAA2C;CACzC,2BAA4B,IAAI,IAA0B;CAC1D;CACA,oBAAmD,CAAC;CACpD,mBAAiD,CAAC;CAClD;CACA;CACA;CACA;;;;;;;;;;;;;;;CAgBA,YAAY,OAA6B,CAAC,GAAG;EAC3C,KAAK,SAAS,cAAc,KAAK,UAAU,WAAW;EACtD,KAAK,kBAAkB,GAAG,KAAK,OAAO;EACtC,KAAK,SAAS,KAAK;EACnB,KAAK,oBAAoB,QAAQ,KAAK,MAAM;EAC5C,KAAK,mBAAmB,QAAQ,KAAK,KAAK;EAC1C,KAAK,kBAAkB,KAAK;EAC5B,KAAK,SAAS,KAAK;CACrB;CA4DA,SAAS,MAAc,kBAAmE;EACxF,KAAK,SAAS,IACZ,cAAc,IAAI,GAClB,OAAO,qBAAqB,aACxB,EAAE,SAAS,iBAAiB,IAC5B,mBAAmB,gBAAgB,CACzC;EAEA,OAAO;CACT;;;;;;;;;;;;;;;;;;CAmBA,MAAM,OAAO,SAAqC;EAChD,MAAM,cAAc,IAAI,IAAI,QAAQ,GAAG,CAAC,CAAC;EACzC,MAAM,EAAE,WAAW;EACnB,KAAK,QAAQ,MAAM,4BAA4B,EAAE,MAAM,YAAY,CAAC;EAEpE,MAAM,gBAAgB,YAAY,QAChCA,QAAY,OAAO,GACnB,QAAQ,SACR,cACF;EACA,MAAM,eAAe,OAAO,UAC1B,GAAG,OAAO,GAAG,eACb;GACE,YAAY;IACV,uBAAuB;IACvB,YAAY;GACd;GACA,MAAM,SAAS;EACjB,GACA,aACF;EACA,MAAM,kBAAkB,MAAM,QAAQ,eAAe,YAAY;EAEjE,IAAI;GACF,MAAM,WAAW,MAAMA,QAAY,KACjC,iBACA,YAAY,MAAM,KAAK,SAAS,SAAS,aAAa,eAAe,CACvE;GACA,OAAO,eAAe,cAAc,QAAQ;EAC9C,UAAU;GACR,aAAa,IAAI;EACnB;CACF;;CAGA,MAAc,SACZ,SACA,aACA,iBACmB;EACnB,MAAM,OAAO,KAAK,UAAU,OAAO;EACnC,IAAI,SAAS,MAAM;GACjB,KAAK,QAAQ,KAAK,6BAA6B,EAAE,MAAM,YAAY,CAAC;GACpE,OAAO,iBAAiB;EAC1B;EAEA,MAAM,eAAe,KAAK,SAAS,IAAI,IAAI;EAC3C,IAAI,CAAC,cAAc;GACjB,KAAK,QAAQ,KAAK,6BAA6B,EAAE,KAAK,CAAC;GACvD,OAAO,iBAAiB;EAC1B;EAEA,MAAM,MAAsB;GAC1B;GACA,yBAAS,IAAI,WAAW,CAAC;GACzB;EACF;EAEA,IAAI;GACF,IAAI,UAAU,IAAI,WAAW,MAAM,QAAQ,YAAY,CAAC;GAExD,MAAM,eAAe,KAAK,KAAK,iBAAiB;GAChD,MAAM,eAAe,KAAK,aAAa,MAAM;GAE7C,MAAM,SAAS,aAAa,UAAU,KAAK;GAC3C,IAAI,QACF,IAAI;IACF,MAAM,OAAO,GAAG;GAClB,SAAS,OAAO;IACd,KAAK,QAAQ,KAAK,+BAA+B;KAAE;KAAO;IAAK,CAAC;IAChE,MAAM;GACR;GAGF,MAAM,aAAa,iBAAiB,GAAG;GACvC,MAAM,mBAAmB,MAAM,gBAAgB,YAAY,aAAa,MAAM;GAE9E,IAAI,4BAA4B,UAC9B,OAAO;GAGT,KAAK,QAAQ,MAAM,4BAA4B,EAAE,KAAK,CAAC;GACvD,MAAM,WAAW,MAAM,gBAAgB,cAAc,KAAK,kBAAkB,eAAe;GAE3F,MAAM,cAAc,KAAK,UAAU,aAAa,KAAK;GACrD,MAAM,cAAc,KAAK,UAAU,KAAK,gBAAgB;GAExD,OAAO;EACT,SAAS,OAAO;GACd,OAAO,MAAM,KAAK,YAAY,OAAO,GAAG;EAC1C;CACF;;CAGA,UAAkB,SAAiC;EACjD,MAAM,WAAW,cAAc,IAAI,IAAI,QAAQ,GAAG,CAAC,CAAC,QAAQ;EAG5D,IAAI,KAAK,WAAW,KAClB,OAAO;EAGT,IAAI,aAAa,KAAK,QACpB,OAAO;EAKT,IAAI,CAAC,SAAS,WAAW,KAAK,eAAe,GAC3C,OAAO;EAGT,OAAO,SAAS,MAAM,KAAK,OAAO,MAAM;CAC1C;;CAGA,MAAc,YAAY,OAAgB,KAAwC;EAChF,IAAI,KAAK,iBAAiB;GACxB,MAAM,kBAAkB,iBAAiB,QAAQ,wBAAQ,IAAI,MAAM,uBAAuB;GAC1F,MAAM,gBAAgB,MAAM,KAAK,gBAAgB,iBAAiB,GAAG;GACrE,IAAI,eACF,OAAO;EAEX;EAEA,OAAO,SAAS,KACd,EACE,OAAO,iBAAiB,QAAQ,MAAM,UAAU,wBAClD,GACA,EAAE,QAAQ,IAAI,CAChB;CACF;AACF;;;;;;;;;;;;;;;AAgBA,MAAa,uBAAuB,SAClC,IAAI,cAAc,IAAI"}
|
package/dist/router.d.ts
CHANGED
|
@@ -14,15 +14,26 @@ import { StandardSchemaV1 } from "@zap-studio/validation";
|
|
|
14
14
|
*
|
|
15
15
|
* router.register("/stripe", {
|
|
16
16
|
* schema: stripeEventSchema,
|
|
17
|
+
* verify: createHmacVerifier({ headerName: "stripe-signature", secret: stripeSecret }),
|
|
17
18
|
* handler: async ({ payload }) => {
|
|
18
19
|
* console.log("Stripe event:", payload.type);
|
|
19
20
|
* },
|
|
20
21
|
* });
|
|
21
22
|
*
|
|
23
|
+
* // Each route can carry its own verifier for multi-provider setups —
|
|
24
|
+
* // no router-level `verify` needed unless routes should share a default.
|
|
25
|
+
* router.register("/github", {
|
|
26
|
+
* schema: githubEventSchema,
|
|
27
|
+
* verify: createHmacVerifier({ headerName: "x-hub-signature-256", secret: githubSecret }),
|
|
28
|
+
* handler: async ({ payload }) => {
|
|
29
|
+
* console.log("GitHub event:", payload.ref);
|
|
30
|
+
* },
|
|
31
|
+
* });
|
|
32
|
+
*
|
|
22
33
|
* export default { fetch: (request: Request) => router.handle(request) };
|
|
23
34
|
* ```
|
|
24
35
|
*/
|
|
25
|
-
declare class WebhookRouter<TMap = unknown> {
|
|
36
|
+
export declare class WebhookRouter<TMap = unknown> {
|
|
26
37
|
private readonly handlers;
|
|
27
38
|
private readonly verify;
|
|
28
39
|
private readonly globalBeforeHooks;
|
|
@@ -134,7 +145,6 @@ declare class WebhookRouter<TMap = unknown> {
|
|
|
134
145
|
* router.register("/stripe", { schema: stripeEventSchema, handler });
|
|
135
146
|
* ```
|
|
136
147
|
*/
|
|
137
|
-
declare const createWebhookRouter: (opts?: WebhookRouterOptions) => WebhookRouter;
|
|
148
|
+
export declare const createWebhookRouter: (opts?: WebhookRouterOptions) => WebhookRouter;
|
|
138
149
|
//#endregion
|
|
139
|
-
export { WebhookRouter, createWebhookRouter };
|
|
140
150
|
//# sourceMappingURL=router.d.ts.map
|
package/dist/router.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"router.d.ts","names":[],"sources":["../src/router.ts"],"mappings":"
|
|
1
|
+
{"version":3,"file":"router.d.ts","names":[],"sources":["../src/router.ts"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;qBAiPa,cAAc;mBACR;mBACA;mBACA;mBACA;mBACA;mBACA;mBACA;mBACA;;;;;;;;;;;;;;;EAgBjB,YAAY,OAAM;;;;;;;;;;;;;;;;;;;;EA6BlB,SAAS,2BAA2B,gBAAgB,oCAClD,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;;;;;;;;;;;;;;;;;;EA6B/B,OAAa,SAAS,UAAU,QAAQ;;UAmC1B;;UA2DN;;UAsBM;;;;;;;;;;;;;;;;qBAgCH,sBAAuB,OAAO,yBAAuB"}
|
package/dist/router.js
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
import { n as createWebhookRouter, t as WebhookRouter } from "./router-
|
|
1
|
+
import { n as createWebhookRouter, t as WebhookRouter } from "./router-rax4SfyZ.js";
|
|
2
2
|
export { WebhookRouter, createWebhookRouter };
|
package/dist/types.d.ts
CHANGED
|
@@ -14,7 +14,7 @@ import { Logger } from "@zap-studio/logger";
|
|
|
14
14
|
* };
|
|
15
15
|
* ```
|
|
16
16
|
*/
|
|
17
|
-
interface WebhookContext {
|
|
17
|
+
export interface WebhookContext {
|
|
18
18
|
/** The matched route key registered on the router (e.g. "stripe") */
|
|
19
19
|
path: string;
|
|
20
20
|
/** The exact request body bytes (for signature verification) */
|
|
@@ -34,12 +34,12 @@ interface WebhookContext {
|
|
|
34
34
|
* };
|
|
35
35
|
* ```
|
|
36
36
|
*/
|
|
37
|
-
interface HandlerContext<TPayload = unknown> extends WebhookContext {
|
|
37
|
+
export interface HandlerContext<TPayload = unknown> extends WebhookContext {
|
|
38
38
|
/** The validated webhook payload */
|
|
39
39
|
payload: TPayload;
|
|
40
40
|
}
|
|
41
41
|
/** Internal handler entry stored per registered route. */
|
|
42
|
-
interface HandlerEntry<TPayload = unknown> {
|
|
42
|
+
export interface HandlerEntry<TPayload = unknown> {
|
|
43
43
|
/** Route-level hooks that run after successful processing. */
|
|
44
44
|
after?: AfterHook[];
|
|
45
45
|
/** Route-level hooks that run before request processing. */
|
|
@@ -48,6 +48,8 @@ interface HandlerEntry<TPayload = unknown> {
|
|
|
48
48
|
handler: WebhookHandler<TPayload>;
|
|
49
49
|
/** Optional Standard Schema validator to validate the webhook payload. */
|
|
50
50
|
schema?: StandardSchemaV1<unknown, TPayload>;
|
|
51
|
+
/** Route-specific request verifier. Overrides the router-level `verify` for this route only. */
|
|
52
|
+
verify?: VerifyFn;
|
|
51
53
|
}
|
|
52
54
|
/**
|
|
53
55
|
* Configuration options for creating a `WebhookRouter`.
|
|
@@ -60,7 +62,7 @@ interface HandlerEntry<TPayload = unknown> {
|
|
|
60
62
|
* };
|
|
61
63
|
* ```
|
|
62
64
|
*/
|
|
63
|
-
interface WebhookRouterOptions {
|
|
65
|
+
export interface WebhookRouterOptions {
|
|
64
66
|
/** Global hooks executed after successful route handler completion. */
|
|
65
67
|
after?: AfterHook | AfterHook[];
|
|
66
68
|
/** Global hooks executed before route-level hooks and verification. */
|
|
@@ -81,7 +83,11 @@ interface WebhookRouterOptions {
|
|
|
81
83
|
* duplicate slashes collapsed. Use `""` or `"/"` to mount at the root.
|
|
82
84
|
*/
|
|
83
85
|
prefix?: string;
|
|
84
|
-
/**
|
|
86
|
+
/**
|
|
87
|
+
* Optional request verification function (for signature checks, auth, etc.),
|
|
88
|
+
* used as the default for any route that doesn't set its own `verify` in
|
|
89
|
+
* {@link RegisterOptions}.
|
|
90
|
+
*/
|
|
85
91
|
verify?: VerifyFn;
|
|
86
92
|
}
|
|
87
93
|
/**
|
|
@@ -91,11 +97,12 @@ interface WebhookRouterOptions {
|
|
|
91
97
|
* ```ts
|
|
92
98
|
* const options: RegisterOptions<{ type: string }> = {
|
|
93
99
|
* schema: stripeEventSchema,
|
|
100
|
+
* verify: createHmacVerifier({ headerName: "stripe-signature", secret }),
|
|
94
101
|
* handler: ({ payload }) => console.log(payload.type),
|
|
95
102
|
* };
|
|
96
103
|
* ```
|
|
97
104
|
*/
|
|
98
|
-
interface RegisterOptions<T> {
|
|
105
|
+
export interface RegisterOptions<T> {
|
|
99
106
|
/** Hooks that run after successful processing (before global after hooks) */
|
|
100
107
|
after?: AfterHook | AfterHook[];
|
|
101
108
|
/** Hooks that run before request processing (after global before hooks) */
|
|
@@ -104,13 +111,19 @@ interface RegisterOptions<T> {
|
|
|
104
111
|
handler: WebhookHandler<T>;
|
|
105
112
|
/** Optional Standard Schema validator to validate the webhook payload */
|
|
106
113
|
schema?: StandardSchemaV1<unknown, T>;
|
|
114
|
+
/**
|
|
115
|
+
* Route-specific request verifier. When set, overrides the router-level
|
|
116
|
+
* `verify` (`WebhookRouterOptions.verify`) for this route only — useful when
|
|
117
|
+
* different routes on the same router are signed by different providers.
|
|
118
|
+
*/
|
|
119
|
+
verify?: VerifyFn;
|
|
107
120
|
}
|
|
108
121
|
/**
|
|
109
122
|
* Infers the output type from a Standard Schema instance.
|
|
110
123
|
*
|
|
111
124
|
* @template TSchema - A Standard Schema type.
|
|
112
125
|
*/
|
|
113
|
-
type InferSchemaOutput<TSchema> = TSchema extends StandardSchemaV1<unknown, infer TOutput> ? TOutput : never;
|
|
126
|
+
export type InferSchemaOutput<TSchema> = TSchema extends StandardSchemaV1<unknown, infer TOutput> ? TOutput : never;
|
|
114
127
|
/**
|
|
115
128
|
* Route options where schema is required and handler payload is inferred.
|
|
116
129
|
*
|
|
@@ -124,11 +137,11 @@ type InferSchemaOutput<TSchema> = TSchema extends StandardSchemaV1<unknown, infe
|
|
|
124
137
|
* };
|
|
125
138
|
* ```
|
|
126
139
|
*/
|
|
127
|
-
type SchemaRouteOptions<TSchema extends StandardSchemaV1<unknown, unknown>> = Omit<RegisterOptions<InferSchemaOutput<TSchema>>, "schema"> & {
|
|
140
|
+
export type SchemaRouteOptions<TSchema extends StandardSchemaV1<unknown, unknown>> = Omit<RegisterOptions<InferSchemaOutput<TSchema>>, "schema"> & {
|
|
128
141
|
schema: TSchema;
|
|
129
142
|
};
|
|
130
143
|
/** A single route's registration shape, as used by schema-driven route dictionaries. */
|
|
131
|
-
interface RouteLike {
|
|
144
|
+
export interface RouteLike {
|
|
132
145
|
/** Hooks that run after successful processing. */
|
|
133
146
|
after?: AfterHook | AfterHook[];
|
|
134
147
|
/** Hooks that run before request processing. */
|
|
@@ -150,7 +163,7 @@ interface RouteLike {
|
|
|
150
163
|
* };
|
|
151
164
|
* ```
|
|
152
165
|
*/
|
|
153
|
-
type SchemaRoutes<TRoutes extends Record<string, RouteLike>> = { [P in keyof TRoutes]: SchemaRouteOptions<TRoutes[P]["schema"]>; };
|
|
166
|
+
export type SchemaRoutes<TRoutes extends Record<string, RouteLike>> = { [P in keyof TRoutes]: SchemaRouteOptions<TRoutes[P]["schema"]>; };
|
|
154
167
|
/**
|
|
155
168
|
* The webhook handler function, responsible for processing incoming webhook events.
|
|
156
169
|
*
|
|
@@ -164,7 +177,7 @@ type SchemaRoutes<TRoutes extends Record<string, RouteLike>> = { [P in keyof TRo
|
|
|
164
177
|
* };
|
|
165
178
|
* ```
|
|
166
179
|
*/
|
|
167
|
-
type WebhookHandler<TPayload = unknown> = (ctx: HandlerContext<TPayload>) => Promise<Response | undefined> | Response | undefined;
|
|
180
|
+
export type WebhookHandler<TPayload = unknown> = (ctx: HandlerContext<TPayload>) => Promise<Response | undefined> | Response | undefined;
|
|
168
181
|
/**
|
|
169
182
|
* Maps route keys to their payload-specific webhook handlers.
|
|
170
183
|
*
|
|
@@ -175,7 +188,7 @@ type WebhookHandler<TPayload = unknown> = (ctx: HandlerContext<TPayload>) => Pro
|
|
|
175
188
|
* };
|
|
176
189
|
* ```
|
|
177
190
|
*/
|
|
178
|
-
type HandlerMap<TMap extends Record<string, unknown>> = { [P in keyof TMap]: WebhookHandler<TMap[P]>; };
|
|
191
|
+
export type HandlerMap<TMap extends Record<string, unknown>> = { [P in keyof TMap]: WebhookHandler<TMap[P]>; };
|
|
179
192
|
/**
|
|
180
193
|
* Builds a webhook payload map from a schema-based route dictionary.
|
|
181
194
|
*
|
|
@@ -188,7 +201,7 @@ type HandlerMap<TMap extends Record<string, unknown>> = { [P in keyof TMap]: Web
|
|
|
188
201
|
* }>;
|
|
189
202
|
* ```
|
|
190
203
|
*/
|
|
191
|
-
type InferWebhookMapFromRoutes<TRoutes extends Record<string, RouteLike>> = { [P in keyof TRoutes]: InferSchemaOutput<TRoutes[P]["schema"]>; };
|
|
204
|
+
export type InferWebhookMapFromRoutes<TRoutes extends Record<string, RouteLike>> = { [P in keyof TRoutes]: InferSchemaOutput<TRoutes[P]["schema"]>; };
|
|
192
205
|
/**
|
|
193
206
|
* Verification function for incoming requests. Throws to reject the request.
|
|
194
207
|
*
|
|
@@ -197,7 +210,7 @@ type InferWebhookMapFromRoutes<TRoutes extends Record<string, RouteLike>> = { [P
|
|
|
197
210
|
* const verify: VerifyFn = createHmacVerifier({ headerName: "x-signature", secret });
|
|
198
211
|
* ```
|
|
199
212
|
*/
|
|
200
|
-
type VerifyFn = (ctx: WebhookContext) => Promise<void> | void;
|
|
213
|
+
export type VerifyFn = (ctx: WebhookContext) => Promise<void> | void;
|
|
201
214
|
/**
|
|
202
215
|
* Hook function that runs before request processing
|
|
203
216
|
*
|
|
@@ -206,7 +219,7 @@ type VerifyFn = (ctx: WebhookContext) => Promise<void> | void;
|
|
|
206
219
|
* const before: BeforeHook = (ctx) => console.log("received", ctx.path);
|
|
207
220
|
* ```
|
|
208
221
|
*/
|
|
209
|
-
type BeforeHook = (ctx: WebhookContext) => Promise<void> | void;
|
|
222
|
+
export type BeforeHook = (ctx: WebhookContext) => Promise<void> | void;
|
|
210
223
|
/**
|
|
211
224
|
* Hook function that runs after successful request processing.
|
|
212
225
|
*
|
|
@@ -218,7 +231,7 @@ type BeforeHook = (ctx: WebhookContext) => Promise<void> | void;
|
|
|
218
231
|
* const after: AfterHook = (ctx, response) => console.log(response.status);
|
|
219
232
|
* ```
|
|
220
233
|
*/
|
|
221
|
-
type AfterHook = (ctx: WebhookContext, response: Response) => Promise<void> | void;
|
|
234
|
+
export type AfterHook = (ctx: WebhookContext, response: Response) => Promise<void> | void;
|
|
222
235
|
/**
|
|
223
236
|
* Hook function that runs when an error occurs
|
|
224
237
|
*
|
|
@@ -227,7 +240,6 @@ type AfterHook = (ctx: WebhookContext, response: Response) => Promise<void> | vo
|
|
|
227
240
|
* const onError: ErrorHook = (error) => Response.json({ error: error.message }, { status: 500 });
|
|
228
241
|
* ```
|
|
229
242
|
*/
|
|
230
|
-
type ErrorHook = (error: Error, ctx: WebhookContext) => Promise<Response | undefined> | Response | undefined;
|
|
243
|
+
export type ErrorHook = (error: Error, ctx: WebhookContext) => Promise<Response | undefined> | Response | undefined;
|
|
231
244
|
//#endregion
|
|
232
|
-
export { AfterHook, BeforeHook, ErrorHook, HandlerContext, HandlerEntry, HandlerMap, InferSchemaOutput, InferWebhookMapFromRoutes, RegisterOptions, RouteLike, SchemaRouteOptions, SchemaRoutes, VerifyFn, WebhookContext, WebhookHandler, WebhookRouterOptions };
|
|
233
245
|
//# sourceMappingURL=types.d.ts.map
|
package/dist/types.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"types.d.ts","names":[],"sources":["../src/types.ts"],"mappings":";;;;;;;;;;;;;;;;
|
|
1
|
+
{"version":3,"file":"types.d.ts","names":[],"sources":["../src/types.ts"],"mappings":";;;;;;;;;;;;;;;;iBAsBiB;;EAEf;;EAEA,SAAS;;EAET,SAAS;;;;;;;;;;;;;;iBAeM,eAAe,4BAA4B;;EAE1D,SAAS;;;iBAIM,aAAa;;EAE5B,QAAQ;;EAER,SAAS;;EAET,SAAS,eAAe;;EAExB,SAAS,0BAA0B;;EAEnC,SAAS;;;;;;;;;;;;;iBAcM;;EAEf,QAAQ,YAAY;;EAEpB,SAAS,aAAa;;EAEtB,UAAU;;;;;;;EAOV,SAAS;;;;;;;EAOT;;;;;;EAMA,SAAS;;;;;;;;;;;;;;iBAeM,gBAAgB;;EAE/B,QAAQ,YAAY;;EAEpB,SAAS,aAAa;;EAEtB,SAAS,eAAe;;EAExB,SAAS,0BAA0B;;;;;;EAMnC,SAAS;;;;;;;YAQC,kBAAkB,WAC5B,gBAAgB,gCAAgC,WAAW;;;;;;;;;;;;;;YAejD,mBAAmB,gBAAgB,sCAAsC,KACnF,gBAAgB,kBAAkB;EAGlC,QAAQ;;;iBAIO;;EAEf,QAAQ,YAAY;;EAEpB,SAAS,aAAa;;EAEtB,SAAS;;EAET,QAAQ;;;;;;;;;;;;;;YAeE,aAAa,gBAAgB,eAAe,iBACrD,WAAW,UAAU,mBAAmB,QAAQ;;;;;;;;;;;;;;YAgBvC,eAAe,uBACzB,KAAK,eAAe,cACjB,QAAQ,wBAAwB;;;;;;;;;;;YAYzB,WAAW,aAAa,8BACjC,WAAW,OAAO,eAAe,KAAK;;;;;;;;;;;;;YAe7B,0BAA0B,gBAAgB,eAAe,iBAClE,WAAW,UAAU,kBAAkB,QAAQ;;;;;;;;;YAWtC,YAAY,KAAK,mBAAmB;;;;;;;;;YAUpC,cAAc,KAAK,mBAAmB;;;;;;;;;;;;YAatC,aAAa,KAAK,gBAAgB,UAAU,aAAa;;;;;;;;;YAUzD,aACV,OAAO,OACP,KAAK,mBACF,QAAQ,wBAAwB"}
|
package/dist/verify.d.ts
CHANGED
|
@@ -3,7 +3,7 @@ import { VerifyFn } from "./types.js";
|
|
|
3
3
|
/**
|
|
4
4
|
* Compares two byte arrays in constant time to prevent timing attacks.
|
|
5
5
|
*/
|
|
6
|
-
declare const constantTimeEquals: (a: Uint8Array, b: Uint8Array) => boolean;
|
|
6
|
+
export declare const constantTimeEquals: (a: Uint8Array, b: Uint8Array) => boolean;
|
|
7
7
|
declare const HMAC_HASH: {
|
|
8
8
|
readonly sha1: "SHA-1";
|
|
9
9
|
readonly sha256: "SHA-256";
|
|
@@ -43,11 +43,10 @@ type HmacAlgorithm = keyof typeof HMAC_HASH;
|
|
|
43
43
|
* @throws {VerificationError}
|
|
44
44
|
* Thrown when verifier setup fails or request verification does not pass.
|
|
45
45
|
*/
|
|
46
|
-
declare const createHmacVerifier: ({ headerName, secret, algo }: {
|
|
46
|
+
export declare const createHmacVerifier: ({ headerName, secret, algo }: {
|
|
47
47
|
headerName: string;
|
|
48
48
|
secret: string;
|
|
49
49
|
algo?: HmacAlgorithm;
|
|
50
50
|
}) => VerifyFn;
|
|
51
51
|
//#endregion
|
|
52
|
-
export { constantTimeEquals, createHmacVerifier };
|
|
53
52
|
//# sourceMappingURL=verify.d.ts.map
|
package/dist/verify.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"verify.d.ts","names":[],"sources":["../src/verify.ts"],"mappings":";;;;;
|
|
1
|
+
{"version":3,"file":"verify.d.ts","names":[],"sources":["../src/verify.ts"],"mappings":";;;;;qBAaa,qBAAsB,GAAG,YAAY,GAAG;cAc/C;WACJ;WACA;WACA;WACA;;KAGG,6BAA6B;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;qBAwDrB,uBACX,YACA,QACA;EAEA;EACA;EACA,OAAO;MACL"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zap-studio/webhooks",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.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": [
|
|
@@ -50,19 +50,19 @@
|
|
|
50
50
|
},
|
|
51
51
|
"devDependencies": {
|
|
52
52
|
"@opentelemetry/api": "^1.9.1",
|
|
53
|
-
"@opentelemetry/context-async-hooks": "^2.
|
|
54
|
-
"@opentelemetry/core": "^2.
|
|
55
|
-
"@opentelemetry/sdk-trace-base": "^2.
|
|
53
|
+
"@opentelemetry/context-async-hooks": "^2.11.0",
|
|
54
|
+
"@opentelemetry/core": "^2.11.0",
|
|
55
|
+
"@opentelemetry/sdk-trace-base": "^2.11.0",
|
|
56
56
|
"@zap-studio/logger": "2.1.0",
|
|
57
57
|
"@zap-studio/typescript": "0.0.0",
|
|
58
|
-
"tsdown": "^0.
|
|
58
|
+
"tsdown": "^0.23.0",
|
|
59
59
|
"typescript": "^7.0.2",
|
|
60
|
-
"vitest": "^
|
|
61
|
-
"zod": "^4.
|
|
60
|
+
"vitest": "^5.0.0",
|
|
61
|
+
"zod": "^4.6.2"
|
|
62
62
|
},
|
|
63
63
|
"peerDependencies": {
|
|
64
64
|
"@opentelemetry/api": "^1.9.1",
|
|
65
|
-
"@zap-studio/logger": "2.1.0"
|
|
65
|
+
"@zap-studio/logger": "^2.1.0"
|
|
66
66
|
},
|
|
67
67
|
"peerDependenciesMeta": {
|
|
68
68
|
"@zap-studio/logger": {
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"router-Ba9jU5pC.js","names":["pkg.name","pkg.version","otelContext"],"sources":["../package.json","../src/_otel.ts","../src/router.ts"],"sourcesContent":["","/**\n * Internal OpenTelemetry wiring for the webhooks package: tracer resolution,\n * the `Headers` extraction carrier, and span error recording. Kept out of\n * `router.ts` so dispatch logic doesn't get tangled with tracing concerns.\n *\n * @module @zap-studio/webhooks/otel\n */\n\nimport type { Span, TextMapGetter, Tracer } from \"@opentelemetry/api\";\n\nimport { SpanStatusCode, trace } from \"@opentelemetry/api\";\n\nimport pkg from \"../package.json\" with { type: \"json\" };\n\n/**\n * OpenTelemetry tracer for this package. Resolved once against the global\n * `TracerProvider`; a no-op provider (the default until an app registers an\n * SDK) makes every span/propagation call below a no-op too.\n */\nexport const tracer: Tracer = trace.getTracer(pkg.name, pkg.version);\n\n/**\n * `TextMapGetter` for the Web `Headers` API, used to extract an inbound\n * delivery's `traceparent` (and any other registered propagator fields) so\n * the delivery span continues the sender's trace instead of starting a new one.\n */\nexport const HEADERS_GETTER: TextMapGetter<Headers> = {\n get(carrier, key) {\n return carrier.get(key) ?? undefined;\n },\n keys(carrier) {\n return [...carrier.keys()];\n },\n};\n\n/**\n * Records `error` on `span` and marks it as failed. `recordException` only\n * accepts an `Error` or `string`, so other thrown values just get the\n * `ERROR` status without an attached exception event.\n */\nexport const recordSpanError = (span: Span, error: unknown): void => {\n if (error instanceof Error || typeof error === \"string\") {\n span.recordException(error);\n }\n span.setStatus({ code: SpanStatusCode.ERROR });\n};\n","/**\n * Schema-first webhook router primitives.\n *\n * @module @zap-studio/webhooks/router\n */\n\nimport type { Context, Span } from \"@opentelemetry/api\";\nimport type { Logger } from \"@zap-studio/logger\";\nimport type { StandardSchemaV1 } from \"@zap-studio/validation\";\n\nimport {\n SpanKind,\n SpanStatusCode,\n context as otelContext,\n propagation,\n trace,\n} from \"@opentelemetry/api\";\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.ts\";\n\nimport { HEADERS_GETTER, recordSpanError, tracer } from \"./_otel.ts\";\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 => Response.json({ error: \"not found\" }, { status: 404 });\n\n/** Sets `http.response.status_code` and marks `span` `ERROR` on a non-2xx response. */\nconst finishDelivery = (span: Span, response: Response): Response => {\n span.setAttribute(\"http.response.status_code\", response.status);\n if (!response.ok) {\n span.setStatus({ code: SpanStatusCode.ERROR });\n }\n return response;\n};\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(\"/\") ? collapsed.slice(0, -1) : collapsed;\n};\n\n/** Runs the given before-hooks in order against the request context. */\nconst runBeforeHooks = async (ctx: WebhookContext, hooks?: BeforeHook[]): Promise<void> => {\n if (!hooks || hooks.length === 0) {\n return;\n }\n\n for (const hook of hooks) {\n // oxlint-disable-next-line react-doctor/async-await-in-loop -- false positive: before-hooks must run in the order they were added, because a later hook can depend on an earlier one. A throw must also stop the hooks that are left. Promise.all would run them at the same time and lose both rules.\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 react-doctor/async-await-in-loop -- false positive: same rules as runBeforeHooks above. After-hooks must run in the order they were added, and a throw must stop the hooks that are left.\n await hook(ctx, response);\n }\n};\n\n/** Builds an internal handler entry from route registration options. */\nconst createHandlerEntry = (options: RegisterOptions<unknown>): 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 // SAFETY: no schema was given, so there is nothing to validate against. The `TPayload` type declared by the caller is the route's only 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/** Runs the route handler inside its own `INTERNAL` span, nested under the delivery span. */\nconst dispatchHandler = async (\n handlerEntry: HandlerEntry,\n ctx: WebhookContext,\n validatedPayload: unknown,\n deliveryContext: Context,\n): Promise<Response> => {\n const handlerSpan = tracer.startSpan(\n `webhook.handler ${ctx.path}`,\n { kind: SpanKind.INTERNAL },\n deliveryContext,\n );\n\n try {\n return await otelContext.with(\n trace.setSpan(deliveryContext, handlerSpan),\n async () => await executeHandler(handlerEntry.handler, ctx, validatedPayload),\n );\n } catch (error) {\n recordSpanError(handlerSpan, error);\n throw error;\n } finally {\n handlerSpan.end();\n }\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<Path extends `/${string}`, TSchema extends StandardSchemaV1<unknown, unknown>>(\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(path: string, handlerOrOptions: WebhookHandler | RegisterOptions<unknown>): 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 const { method } = request;\n this.logger?.debug(\"webhook delivery attempt\", { path: requestPath });\n\n const parentContext = propagation.extract(\n otelContext.active(),\n request.headers,\n HEADERS_GETTER,\n );\n const deliverySpan = tracer.startSpan(\n `${method} ${requestPath}`,\n {\n attributes: {\n \"http.request.method\": method,\n \"url.path\": requestPath,\n },\n kind: SpanKind.SERVER,\n },\n parentContext,\n );\n const deliveryContext = trace.setSpan(parentContext, deliverySpan);\n\n try {\n const response = await otelContext.with(\n deliveryContext,\n async () => await this.dispatch(request, requestPath, deliveryContext),\n );\n return finishDelivery(deliverySpan, response);\n } finally {\n deliverySpan.end();\n }\n }\n\n /** Matches the route, runs hooks/verification/validation, and dispatches the handler. */\n private async dispatch(\n request: Request,\n requestPath: string,\n deliveryContext: Context,\n ): Promise<Response> {\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(parsedJson, handlerEntry.schema);\n\n if (validationResult instanceof Response) {\n return validationResult;\n }\n\n this.logger?.debug(\"webhook handler dispatch\", { path });\n const response = await dispatchHandler(handlerEntry, ctx, validationResult, deliveryContext);\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(error: unknown, ctx: WebhookContext): Promise<Response> {\n if (this.globalErrorHook) {\n const normalizedError = 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 = (opts?: WebhookRouterOptions): WebhookRouter =>\n new WebhookRouter(opts);\n"],"mappings":"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"}
|