@zap-studio/webhooks 0.4.0 → 1.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +49 -28
- package/LICENSE +1 -1
- package/README.md +71 -139
- package/dist/errors.d.ts +16 -0
- package/dist/errors.d.ts.map +1 -1
- package/dist/errors.js +16 -0
- package/dist/errors.js.map +1 -1
- package/dist/index.d.ts +3 -4
- package/dist/index.js +1 -2
- package/dist/router.d.ts +83 -25
- package/dist/router.d.ts.map +1 -1
- package/dist/router.js +125 -52
- package/dist/router.js.map +1 -1
- package/dist/types.d.ts +146 -6
- package/dist/types.d.ts.map +1 -1
- package/dist/verify.d.ts +5 -1
- package/dist/verify.d.ts.map +1 -1
- package/dist/verify.js +26 -6
- package/dist/verify.js.map +1 -1
- package/package.json +10 -10
- package/dist/utils.d.ts +0 -18
- package/dist/utils.d.ts.map +0 -1
- package/dist/utils.js +0 -24
- package/dist/utils.js.map +0 -1
package/dist/router.d.ts
CHANGED
|
@@ -1,27 +1,26 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { InferSchemaOutput, RegisterOptions, SchemaRouteOptions, WebhookHandler, WebhookRouterOptions } from "./types.js";
|
|
2
2
|
import { StandardSchemaV1 } from "@zap-studio/validation";
|
|
3
3
|
//#region src/router.d.ts
|
|
4
|
-
interface WebhookRouterOptions {
|
|
5
|
-
/** Global hooks executed after successful route handler completion. */
|
|
6
|
-
after?: AfterHook | AfterHook[];
|
|
7
|
-
/** Global hooks executed before route-level hooks and verification. */
|
|
8
|
-
before?: BeforeHook | BeforeHook[];
|
|
9
|
-
/** Global error hook used to override the default `500` response. */
|
|
10
|
-
onError?: ErrorHook;
|
|
11
|
-
/**
|
|
12
|
-
* Required path prefix for all webhook routes. Defaults to `"/webhooks"`.
|
|
13
|
-
*
|
|
14
|
-
* Normalized internally: leading slash added, trailing slash stripped,
|
|
15
|
-
* duplicate slashes collapsed. Use `""` or `"/"` to mount at the root.
|
|
16
|
-
*/
|
|
17
|
-
prefix?: string;
|
|
18
|
-
/** Optional request verification function (for signature checks, auth, etc.). */
|
|
19
|
-
verify?: VerifyFn;
|
|
20
|
-
}
|
|
21
4
|
/**
|
|
22
5
|
* Main webhook router class.
|
|
23
6
|
*
|
|
24
7
|
* Register routes with typed schemas and call `handle` with a Web API `Request`.
|
|
8
|
+
*
|
|
9
|
+
* @example
|
|
10
|
+
* ```ts
|
|
11
|
+
* import { WebhookRouter } from "@zap-studio/webhooks";
|
|
12
|
+
*
|
|
13
|
+
* const router = new WebhookRouter({ prefix: "/webhooks" });
|
|
14
|
+
*
|
|
15
|
+
* router.register("/stripe", {
|
|
16
|
+
* schema: stripeEventSchema,
|
|
17
|
+
* handler: async ({ payload }) => {
|
|
18
|
+
* console.log("Stripe event:", payload.type);
|
|
19
|
+
* },
|
|
20
|
+
* });
|
|
21
|
+
*
|
|
22
|
+
* export default { fetch: (request: Request) => router.handle(request) };
|
|
23
|
+
* ```
|
|
25
24
|
*/
|
|
26
25
|
declare class WebhookRouter<TMap = unknown> {
|
|
27
26
|
private readonly handlers;
|
|
@@ -29,12 +28,22 @@ declare class WebhookRouter<TMap = unknown> {
|
|
|
29
28
|
private readonly globalBeforeHooks;
|
|
30
29
|
private readonly globalAfterHooks;
|
|
31
30
|
private readonly globalErrorHook;
|
|
31
|
+
private readonly logger;
|
|
32
32
|
private readonly prefix;
|
|
33
33
|
private readonly prefixWithSlash;
|
|
34
34
|
/**
|
|
35
35
|
* Creates a webhook router with optional global hooks and verification behavior.
|
|
36
36
|
*
|
|
37
37
|
* @param opts - Router-level options.
|
|
38
|
+
*
|
|
39
|
+
* @example
|
|
40
|
+
* ```ts
|
|
41
|
+
* const router = new WebhookRouter({
|
|
42
|
+
* prefix: "/webhooks",
|
|
43
|
+
* verify: createHmacVerifier({ headerName: "x-signature", secret }),
|
|
44
|
+
* onError: (error) => Response.json({ error: error.message }, { status: 500 }),
|
|
45
|
+
* });
|
|
46
|
+
* ```
|
|
38
47
|
*/
|
|
39
48
|
constructor(opts?: WebhookRouterOptions);
|
|
40
49
|
/**
|
|
@@ -45,9 +54,46 @@ declare class WebhookRouter<TMap = unknown> {
|
|
|
45
54
|
* @param path - Route path relative to configured prefix, starting with `/` (e.g. `"/stripe"`).
|
|
46
55
|
* @param handlerOrOptions - Handler function or schema-based registration options.
|
|
47
56
|
* @returns The same router instance with an updated internal route type map.
|
|
57
|
+
*
|
|
58
|
+
* @example
|
|
59
|
+
* ```ts
|
|
60
|
+
* router.register("/stripe", {
|
|
61
|
+
* schema: stripeEventSchema,
|
|
62
|
+
* handler: async ({ payload }) => {
|
|
63
|
+
* console.log(payload.type); // typed from stripeEventSchema
|
|
64
|
+
* },
|
|
65
|
+
* });
|
|
66
|
+
* ```
|
|
48
67
|
*/
|
|
49
68
|
register<Path extends `/${string}`, TSchema extends StandardSchemaV1<unknown, unknown>>(path: Path, handlerOrOptions: SchemaRouteOptions<TSchema>): WebhookRouter<TMap & Record<Path, InferSchemaOutput<TSchema>>>;
|
|
69
|
+
/**
|
|
70
|
+
* Register a webhook handler for a specific path, with schema-less registration options.
|
|
71
|
+
*
|
|
72
|
+
* @param path - Route path relative to configured prefix, starting with `/` (e.g. `"/stripe"`).
|
|
73
|
+
* @param handlerOrOptions - Registration options without a schema.
|
|
74
|
+
* @returns The same router instance with an updated internal route type map.
|
|
75
|
+
*
|
|
76
|
+
* @example
|
|
77
|
+
* ```ts
|
|
78
|
+
* router.register("/ping", {
|
|
79
|
+
* before: (ctx) => console.log("received", ctx.path),
|
|
80
|
+
* handler: () => Response.json({ ok: true }),
|
|
81
|
+
* });
|
|
82
|
+
* ```
|
|
83
|
+
*/
|
|
50
84
|
register<Path extends `/${string}`, TPayload>(path: Path, handlerOrOptions: RegisterOptions<TPayload>): WebhookRouter<TMap & Record<Path, TPayload>>;
|
|
85
|
+
/**
|
|
86
|
+
* Register a webhook handler for a specific path, using a plain handler function.
|
|
87
|
+
*
|
|
88
|
+
* @param path - Route path relative to configured prefix, starting with `/` (e.g. `"/stripe"`).
|
|
89
|
+
* @param handlerOrOptions - Handler function to process the webhook.
|
|
90
|
+
* @returns The same router instance with an updated internal route type map.
|
|
91
|
+
*
|
|
92
|
+
* @example
|
|
93
|
+
* ```ts
|
|
94
|
+
* router.register("/health", () => Response.json({ status: "ok" }));
|
|
95
|
+
* ```
|
|
96
|
+
*/
|
|
51
97
|
register<Path extends `/${string}`>(path: Path, handlerOrOptions: WebhookHandler): WebhookRouter<TMap & Record<Path, unknown>>;
|
|
52
98
|
/**
|
|
53
99
|
* Handles an incoming webhook request.
|
|
@@ -57,15 +103,19 @@ declare class WebhookRouter<TMap = unknown> {
|
|
|
57
103
|
*
|
|
58
104
|
* @param request - Incoming Web API request.
|
|
59
105
|
* @returns Web API response for the runtime to send back.
|
|
106
|
+
*
|
|
107
|
+
* @example
|
|
108
|
+
* ```ts
|
|
109
|
+
* // Framework-agnostic: works with any Web API Request/Response runtime.
|
|
110
|
+
* export async function POST(request: Request): Promise<Response> {
|
|
111
|
+
* return router.handle(request);
|
|
112
|
+
* }
|
|
113
|
+
* ```
|
|
60
114
|
*/
|
|
61
115
|
handle(request: Request): Promise<Response>;
|
|
116
|
+
/** Resolves the incoming request's URL to a registered route key, or `null` if it doesn't match the configured prefix. */
|
|
62
117
|
private matchPath;
|
|
63
|
-
|
|
64
|
-
private static runAfterHooks;
|
|
65
|
-
private static createHandlerEntry;
|
|
66
|
-
private static parseRequestBody;
|
|
67
|
-
private static validatePayload;
|
|
68
|
-
private static executeHandler;
|
|
118
|
+
/** Builds the error response for a failed request, deferring to the global error hook when set. */
|
|
69
119
|
private handleError;
|
|
70
120
|
}
|
|
71
121
|
/**
|
|
@@ -73,8 +123,16 @@ declare class WebhookRouter<TMap = unknown> {
|
|
|
73
123
|
*
|
|
74
124
|
* @param opts - Optional global router options.
|
|
75
125
|
* @returns A new webhook router.
|
|
126
|
+
*
|
|
127
|
+
* @example
|
|
128
|
+
* ```ts
|
|
129
|
+
* import { createWebhookRouter } from "@zap-studio/webhooks";
|
|
130
|
+
*
|
|
131
|
+
* const router = createWebhookRouter({ prefix: "/webhooks" });
|
|
132
|
+
* router.register("/stripe", { schema: stripeEventSchema, handler });
|
|
133
|
+
* ```
|
|
76
134
|
*/
|
|
77
135
|
declare const createWebhookRouter: (opts?: WebhookRouterOptions) => WebhookRouter;
|
|
78
136
|
//#endregion
|
|
79
|
-
export { WebhookRouter,
|
|
137
|
+
export { WebhookRouter, createWebhookRouter };
|
|
80
138
|
//# 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":";;;;;;;;;;;;;;;;;;;;;;;;cA4La,cAAc;mBACR;mBACA;mBACA;mBACA;mBACA;mBACA;mBACA;mBACA;;;;;;;;;;;;;;;EAgBjB,YAAY,OAAM;;;;;;;;;;;;;;;;;;;;EA6BlB,SACE,2BACA,gBAAgB,oCAEhB,MAAM,MACN,kBAAkB,mBAAmB,WACpC,cAAc,OAAO,OAAO,MAAM,kBAAkB;;;;;;;;;;;;;;;;EAgBvD,SAAS,2BAA2B,UAClC,MAAM,MACN,kBAAkB,gBAAgB,YACjC,cAAc,OAAO,OAAO,MAAM;;;;;;;;;;;;;EAarC,SAAS,2BACP,MAAM,MACN,kBAAkB,iBACjB,cAAc,OAAO,OAAO;;;;;;;;;;;;;;;;;;EAgC/B,OAAa,SAAS,UAAU,QAAQ;;UAgEhC;;UAsBM;;;;;;;;;;;;;;;;cAoCH,sBACX,OAAO,yBACN"}
|
package/dist/router.js
CHANGED
|
@@ -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,23 +20,95 @@ 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
|
|
90
|
+
var WebhookRouter = class {
|
|
24
91
|
handlers = /* @__PURE__ */ new Map();
|
|
25
92
|
verify;
|
|
26
93
|
globalBeforeHooks = [];
|
|
27
94
|
globalAfterHooks = [];
|
|
28
95
|
globalErrorHook;
|
|
96
|
+
logger;
|
|
29
97
|
prefix;
|
|
30
98
|
prefixWithSlash;
|
|
31
99
|
/**
|
|
32
100
|
* Creates a webhook router with optional global hooks and verification behavior.
|
|
33
101
|
*
|
|
34
102
|
* @param opts - Router-level options.
|
|
103
|
+
*
|
|
104
|
+
* @example
|
|
105
|
+
* ```ts
|
|
106
|
+
* const router = new WebhookRouter({
|
|
107
|
+
* prefix: "/webhooks",
|
|
108
|
+
* verify: createHmacVerifier({ headerName: "x-signature", secret }),
|
|
109
|
+
* onError: (error) => Response.json({ error: error.message }, { status: 500 }),
|
|
110
|
+
* });
|
|
111
|
+
* ```
|
|
35
112
|
*/
|
|
36
113
|
constructor(opts = {}) {
|
|
37
114
|
this.prefix = normalizePath(opts.prefix ?? "/webhooks");
|
|
@@ -40,9 +117,10 @@ var WebhookRouter = class WebhookRouter {
|
|
|
40
117
|
this.globalBeforeHooks = toArray(opts.before);
|
|
41
118
|
this.globalAfterHooks = toArray(opts.after);
|
|
42
119
|
this.globalErrorHook = opts.onError;
|
|
120
|
+
this.logger = opts.logger;
|
|
43
121
|
}
|
|
44
122
|
register(path, handlerOrOptions) {
|
|
45
|
-
this.handlers.set(normalizePath(path), typeof handlerOrOptions === "function" ? { handler: handlerOrOptions } :
|
|
123
|
+
this.handlers.set(normalizePath(path), typeof handlerOrOptions === "function" ? { handler: handlerOrOptions } : createHandlerEntry(handlerOrOptions));
|
|
46
124
|
return this;
|
|
47
125
|
}
|
|
48
126
|
/**
|
|
@@ -53,12 +131,28 @@ var WebhookRouter = class WebhookRouter {
|
|
|
53
131
|
*
|
|
54
132
|
* @param request - Incoming Web API request.
|
|
55
133
|
* @returns Web API response for the runtime to send back.
|
|
134
|
+
*
|
|
135
|
+
* @example
|
|
136
|
+
* ```ts
|
|
137
|
+
* // Framework-agnostic: works with any Web API Request/Response runtime.
|
|
138
|
+
* export async function POST(request: Request): Promise<Response> {
|
|
139
|
+
* return router.handle(request);
|
|
140
|
+
* }
|
|
141
|
+
* ```
|
|
56
142
|
*/
|
|
57
143
|
async handle(request) {
|
|
144
|
+
const requestPath = new URL(request.url).pathname;
|
|
145
|
+
this.logger?.debug("webhook delivery attempt", { path: requestPath });
|
|
58
146
|
const path = this.matchPath(request);
|
|
59
|
-
if (path === null)
|
|
147
|
+
if (path === null) {
|
|
148
|
+
this.logger?.warn("webhook route not matched", { path: requestPath });
|
|
149
|
+
return notFoundResponse();
|
|
150
|
+
}
|
|
60
151
|
const handlerEntry = this.handlers.get(path);
|
|
61
|
-
if (!handlerEntry)
|
|
152
|
+
if (!handlerEntry) {
|
|
153
|
+
this.logger?.warn("webhook route not matched", { path });
|
|
154
|
+
return notFoundResponse();
|
|
155
|
+
}
|
|
62
156
|
const ctx = {
|
|
63
157
|
path,
|
|
64
158
|
rawBody: /* @__PURE__ */ new Uint8Array(0),
|
|
@@ -66,20 +160,30 @@ var WebhookRouter = class WebhookRouter {
|
|
|
66
160
|
};
|
|
67
161
|
try {
|
|
68
162
|
ctx.rawBody = new Uint8Array(await request.arrayBuffer());
|
|
69
|
-
await
|
|
70
|
-
await
|
|
71
|
-
if (this.verify)
|
|
72
|
-
|
|
73
|
-
|
|
163
|
+
await runBeforeHooks(ctx, this.globalBeforeHooks);
|
|
164
|
+
await runBeforeHooks(ctx, handlerEntry.before);
|
|
165
|
+
if (this.verify) try {
|
|
166
|
+
await this.verify(ctx);
|
|
167
|
+
} catch (error) {
|
|
168
|
+
this.logger?.warn("webhook verification failed", {
|
|
169
|
+
error,
|
|
170
|
+
path
|
|
171
|
+
});
|
|
172
|
+
throw error;
|
|
173
|
+
}
|
|
174
|
+
const parsedJson = parseRequestBody(ctx);
|
|
175
|
+
const validationResult = await validatePayload(parsedJson, handlerEntry.schema);
|
|
74
176
|
if (validationResult instanceof Response) return validationResult;
|
|
75
|
-
|
|
76
|
-
await
|
|
77
|
-
await
|
|
177
|
+
this.logger?.debug("webhook handler dispatch", { path });
|
|
178
|
+
const response = await executeHandler(handlerEntry.handler, ctx, validationResult);
|
|
179
|
+
await runAfterHooks(ctx, response, handlerEntry.after);
|
|
180
|
+
await runAfterHooks(ctx, response, this.globalAfterHooks);
|
|
78
181
|
return response;
|
|
79
182
|
} catch (error) {
|
|
80
183
|
return await this.handleError(error, ctx);
|
|
81
184
|
}
|
|
82
185
|
}
|
|
186
|
+
/** Resolves the incoming request's URL to a registered route key, or `null` if it doesn't match the configured prefix. */
|
|
83
187
|
matchPath(request) {
|
|
84
188
|
const pathname = normalizePath(new URL(request.url).pathname);
|
|
85
189
|
if (this.prefix === "/") return pathname;
|
|
@@ -87,46 +191,7 @@ var WebhookRouter = class WebhookRouter {
|
|
|
87
191
|
if (!pathname.startsWith(this.prefixWithSlash)) return null;
|
|
88
192
|
return pathname.slice(this.prefix.length);
|
|
89
193
|
}
|
|
90
|
-
|
|
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
|
-
}
|
|
194
|
+
/** Builds the error response for a failed request, deferring to the global error hook when set. */
|
|
130
195
|
async handleError(error, ctx) {
|
|
131
196
|
if (this.globalErrorHook) {
|
|
132
197
|
const normalizedError = error instanceof Error ? error : /* @__PURE__ */ new Error("Internal server error");
|
|
@@ -141,6 +206,14 @@ var WebhookRouter = class WebhookRouter {
|
|
|
141
206
|
*
|
|
142
207
|
* @param opts - Optional global router options.
|
|
143
208
|
* @returns A new webhook router.
|
|
209
|
+
*
|
|
210
|
+
* @example
|
|
211
|
+
* ```ts
|
|
212
|
+
* import { createWebhookRouter } from "@zap-studio/webhooks";
|
|
213
|
+
*
|
|
214
|
+
* const router = createWebhookRouter({ prefix: "/webhooks" });
|
|
215
|
+
* router.register("/stripe", { schema: stripeEventSchema, handler });
|
|
216
|
+
* ```
|
|
144
217
|
*/
|
|
145
218
|
const createWebhookRouter = (opts) => new WebhookRouter(opts);
|
|
146
219
|
//#endregion
|
package/dist/router.js.map
CHANGED
|
@@ -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 { Logger } from \"@zap-studio/logger\";\nimport type { StandardSchemaV1 } from \"@zap-studio/validation\";\nimport { standardValidate } from \"@zap-studio/validation\";\n\nimport type {\n AfterHook,\n BeforeHook,\n ErrorHook,\n HandlerEntry,\n InferSchemaOutput,\n RegisterOptions,\n SchemaRouteOptions,\n VerifyFn,\n WebhookContext,\n WebhookHandler,\n WebhookRouterOptions,\n} from \"./types.js\";\n\n/**\n * Schema-first webhook router with path dispatching, validation, and optional verification.\n *\n * @template TMap - Internal route payload map built incrementally via `register`.\n */\n\nconst toArray = <T>(value: T | T[] | undefined): T[] => {\n if (value === undefined) {\n return [];\n }\n\n return Array.isArray(value) ? value : [value];\n};\n\nconst notFoundResponse = (): Response =>\n Response.json({ error: \"not found\" }, { status: 404 });\n\nconst bodyDecoder = new TextDecoder();\n\n/**\n * Normalizes a path to its canonical form: leading slash, no trailing slash,\n * duplicate slashes collapsed. The root path is `\"/\"`.\n */\nconst normalizePath = (path: string): string => {\n const withLeadingSlash = path.startsWith(\"/\") ? path : `/${path}`;\n const collapsed = withLeadingSlash.includes(\"//\")\n ? withLeadingSlash.replaceAll(/\\/{2,}/gu, \"/\")\n : withLeadingSlash;\n\n return collapsed.length > 1 && collapsed.endsWith(\"/\")\n ? collapsed.slice(0, -1)\n : collapsed;\n};\n\n/** Runs the given before-hooks in order against the request context. */\nconst runBeforeHooks = async (\n ctx: WebhookContext,\n hooks?: BeforeHook[]\n): Promise<void> => {\n if (!hooks || hooks.length === 0) {\n return;\n }\n\n for (const hook of hooks) {\n // oxlint-disable-next-line no-await-in-loop -- hooks run sequentially; order + short-circuit matter.\n await hook(ctx);\n }\n};\n\n/** Runs the given after-hooks in order against the request context and response. */\nconst runAfterHooks = async (\n ctx: WebhookContext,\n response: Response,\n hooks?: AfterHook[]\n): Promise<void> => {\n if (!hooks || hooks.length === 0) {\n return;\n }\n\n for (const hook of hooks) {\n // oxlint-disable-next-line no-await-in-loop -- hooks run sequentially; order + short-circuit matter.\n await hook(ctx, response);\n }\n};\n\n/** Builds an internal handler entry from route registration options. */\nconst createHandlerEntry = (\n options: RegisterOptions<unknown>\n): HandlerEntry => {\n const entry: HandlerEntry = {\n handler: options.handler,\n };\n\n if (options.schema !== undefined) {\n entry.schema = options.schema;\n }\n\n if (options.before !== undefined) {\n entry.before = toArray(options.before);\n }\n\n if (options.after !== undefined) {\n entry.after = toArray(options.after);\n }\n\n return entry;\n};\n\n/** Parses the request's raw body bytes as JSON, returning `undefined` on invalid JSON. */\nconst parseRequestBody = (ctx: WebhookContext): unknown => {\n try {\n return JSON.parse(bodyDecoder.decode(ctx.rawBody));\n } catch {\n return undefined;\n }\n};\n\n/** Validates the parsed payload against the route schema, returning either the validated value or a `400` response. */\nconst validatePayload = async <TPayload>(\n parsedJson: unknown,\n schema?: StandardSchemaV1<unknown, TPayload>\n): Promise<TPayload | Response> => {\n if (!schema) {\n // oxlint-disable-next-line typescript/no-unsafe-type-assertion -- Without a schema, caller-declared payload type is the route contract.\n return parsedJson as TPayload;\n }\n\n const result = await standardValidate(parsedJson, schema, {\n throwOnError: false,\n });\n\n if (result.issues) {\n return Response.json(\n {\n error: \"validation failed\",\n issues: result.issues.map((issue) => ({\n message: issue.message,\n path: issue.path?.map((p) =>\n typeof p === \"object\" && \"key\" in p ? String(p.key) : String(p)\n ),\n })),\n },\n { status: 400 }\n );\n }\n\n return result.value;\n};\n\n/** Invokes the route handler with the validated payload, defaulting to a `200 \"ok\"` response. */\nconst executeHandler = async <TPayload = unknown>(\n handler: WebhookHandler<TPayload>,\n ctx: WebhookContext,\n validatedPayload: TPayload\n): Promise<Response> => {\n const responded = await handler({\n ...ctx,\n payload: validatedPayload,\n });\n\n return responded ?? Response.json(\"ok\");\n};\n\n/**\n * Main webhook router class.\n *\n * Register routes with typed schemas and call `handle` with a Web API `Request`.\n *\n * @example\n * ```ts\n * import { WebhookRouter } from \"@zap-studio/webhooks\";\n *\n * const router = new WebhookRouter({ prefix: \"/webhooks\" });\n *\n * router.register(\"/stripe\", {\n * schema: stripeEventSchema,\n * handler: async ({ payload }) => {\n * console.log(\"Stripe event:\", payload.type);\n * },\n * });\n *\n * export default { fetch: (request: Request) => router.handle(request) };\n * ```\n */\nexport class WebhookRouter<TMap = unknown> {\n private readonly handlers = new Map<string, HandlerEntry>();\n private readonly verify: VerifyFn | undefined;\n private readonly globalBeforeHooks: BeforeHook[] = [];\n private readonly globalAfterHooks: AfterHook[] = [];\n private readonly globalErrorHook: ErrorHook | undefined;\n private readonly logger: Logger | undefined;\n private readonly prefix: string;\n private readonly prefixWithSlash: string;\n\n /**\n * Creates a webhook router with optional global hooks and verification behavior.\n *\n * @param opts - Router-level options.\n *\n * @example\n * ```ts\n * const router = new WebhookRouter({\n * prefix: \"/webhooks\",\n * verify: createHmacVerifier({ headerName: \"x-signature\", secret }),\n * onError: (error) => Response.json({ error: error.message }, { status: 500 }),\n * });\n * ```\n */\n constructor(opts: WebhookRouterOptions = {}) {\n this.prefix = normalizePath(opts.prefix ?? \"/webhooks\");\n this.prefixWithSlash = `${this.prefix}/`;\n this.verify = opts.verify;\n this.globalBeforeHooks = toArray(opts.before);\n this.globalAfterHooks = toArray(opts.after);\n this.globalErrorHook = opts.onError;\n this.logger = opts.logger;\n }\n\n /**\n * Register a webhook handler for a specific path.\n *\n * When a schema is provided, `payload` is inferred from the schema output type.\n *\n * @param path - Route path relative to configured prefix, starting with `/` (e.g. `\"/stripe\"`).\n * @param handlerOrOptions - Handler function or schema-based registration options.\n * @returns The same router instance with an updated internal route type map.\n *\n * @example\n * ```ts\n * router.register(\"/stripe\", {\n * schema: stripeEventSchema,\n * handler: async ({ payload }) => {\n * console.log(payload.type); // typed from stripeEventSchema\n * },\n * });\n * ```\n */\n register<\n Path extends `/${string}`,\n TSchema extends StandardSchemaV1<unknown, unknown>,\n >(\n path: Path,\n handlerOrOptions: SchemaRouteOptions<TSchema>\n ): WebhookRouter<TMap & Record<Path, InferSchemaOutput<TSchema>>>;\n /**\n * Register a webhook handler for a specific path, with schema-less registration options.\n *\n * @param path - Route path relative to configured prefix, starting with `/` (e.g. `\"/stripe\"`).\n * @param handlerOrOptions - Registration options without a schema.\n * @returns The same router instance with an updated internal route type map.\n *\n * @example\n * ```ts\n * router.register(\"/ping\", {\n * before: (ctx) => console.log(\"received\", ctx.path),\n * handler: () => Response.json({ ok: true }),\n * });\n * ```\n */\n register<Path extends `/${string}`, TPayload>(\n path: Path,\n handlerOrOptions: RegisterOptions<TPayload>\n ): WebhookRouter<TMap & Record<Path, TPayload>>;\n /**\n * Register a webhook handler for a specific path, using a plain handler function.\n *\n * @param path - Route path relative to configured prefix, starting with `/` (e.g. `\"/stripe\"`).\n * @param handlerOrOptions - Handler function to process the webhook.\n * @returns The same router instance with an updated internal route type map.\n *\n * @example\n * ```ts\n * router.register(\"/health\", () => Response.json({ status: \"ok\" }));\n * ```\n */\n register<Path extends `/${string}`>(\n path: Path,\n handlerOrOptions: WebhookHandler\n ): WebhookRouter<TMap & Record<Path, unknown>>;\n register(\n path: string,\n handlerOrOptions: WebhookHandler | RegisterOptions<unknown>\n ): this {\n this.handlers.set(\n normalizePath(path),\n typeof handlerOrOptions === \"function\"\n ? { handler: handlerOrOptions }\n : createHandlerEntry(handlerOrOptions)\n );\n\n return this;\n }\n\n /**\n * Handles an incoming webhook request.\n *\n * The request body is read exactly once; hooks and handlers receive the raw\n * bytes through the webhook context instead of the request stream.\n *\n * @param request - Incoming Web API request.\n * @returns Web API response for the runtime to send back.\n *\n * @example\n * ```ts\n * // Framework-agnostic: works with any Web API Request/Response runtime.\n * export async function POST(request: Request): Promise<Response> {\n * return router.handle(request);\n * }\n * ```\n */\n async handle(request: Request): Promise<Response> {\n const requestPath = new URL(request.url).pathname;\n this.logger?.debug(\"webhook delivery attempt\", { path: requestPath });\n\n const path = this.matchPath(request);\n if (path === null) {\n this.logger?.warn(\"webhook route not matched\", { path: requestPath });\n return notFoundResponse();\n }\n\n const handlerEntry = this.handlers.get(path);\n if (!handlerEntry) {\n this.logger?.warn(\"webhook route not matched\", { path });\n return notFoundResponse();\n }\n\n const ctx: WebhookContext = {\n path,\n rawBody: new Uint8Array(0),\n request,\n };\n\n try {\n ctx.rawBody = new Uint8Array(await request.arrayBuffer());\n\n await runBeforeHooks(ctx, this.globalBeforeHooks);\n await runBeforeHooks(ctx, handlerEntry.before);\n\n if (this.verify) {\n try {\n await this.verify(ctx);\n } catch (error) {\n this.logger?.warn(\"webhook verification failed\", { error, path });\n throw error;\n }\n }\n\n const parsedJson = parseRequestBody(ctx);\n const validationResult = await validatePayload(\n parsedJson,\n handlerEntry.schema\n );\n\n if (validationResult instanceof Response) {\n return validationResult;\n }\n\n this.logger?.debug(\"webhook handler dispatch\", { path });\n const response = await executeHandler(\n handlerEntry.handler,\n ctx,\n validationResult\n );\n\n await runAfterHooks(ctx, response, handlerEntry.after);\n await runAfterHooks(ctx, response, this.globalAfterHooks);\n\n return response;\n } catch (error) {\n return await this.handleError(error, ctx);\n }\n }\n\n /** Resolves the incoming request's URL to a registered route key, or `null` if it doesn't match the configured prefix. */\n private matchPath(request: Request): string | null {\n const pathname = normalizePath(new URL(request.url).pathname);\n\n // Root mount: the whole pathname is the route path.\n if (this.prefix === \"/\") {\n return pathname;\n }\n\n if (pathname === this.prefix) {\n return \"/\";\n }\n\n // Require prefix followed by a segment boundary, then match handlers on\n // the remainder (e.g. /webhooks/stripe -> /stripe).\n if (!pathname.startsWith(this.prefixWithSlash)) {\n return null;\n }\n\n return pathname.slice(this.prefix.length);\n }\n\n /** Builds the error response for a failed request, deferring to the global error hook when set. */\n private async handleError(\n error: unknown,\n ctx: WebhookContext\n ): Promise<Response> {\n if (this.globalErrorHook) {\n const normalizedError =\n error instanceof Error ? error : new Error(\"Internal server error\");\n const errorResponse = await this.globalErrorHook(normalizedError, ctx);\n if (errorResponse) {\n return errorResponse;\n }\n }\n\n return Response.json(\n {\n error: error instanceof Error ? error.message : \"Internal server error\",\n },\n { status: 500 }\n );\n }\n}\n\n/**\n * Factory helper for creating a webhook router instance.\n *\n * @param opts - Optional global router options.\n * @returns A new webhook router.\n *\n * @example\n * ```ts\n * import { createWebhookRouter } from \"@zap-studio/webhooks\";\n *\n * const router = createWebhookRouter({ prefix: \"/webhooks\" });\n * router.register(\"/stripe\", { schema: stripeEventSchema, handler });\n * ```\n */\nexport const createWebhookRouter = (\n opts?: WebhookRouterOptions\n): WebhookRouter => new WebhookRouter(opts);\n"],"mappings":"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"}
|