@zap-studio/webhooks 0.2.0 → 0.2.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 +16 -0
- package/dist/index.d.mts +3 -2
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +18 -17
- package/dist/index.mjs.map +1 -1
- package/dist/types/index.d.mts +4 -4
- package/package.json +10 -14
- package/bin/intent.js +0 -4
- package/skills/zap-webhooks-routing-and-verification/SKILL.md +0 -172
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,21 @@
|
|
|
1
1
|
# @zap-studio/webhooks
|
|
2
2
|
|
|
3
|
+
## 0.2.1
|
|
4
|
+
|
|
5
|
+
### Fixed
|
|
6
|
+
|
|
7
|
+
- 3a950dc: Preserve registered hook assignment types while keeping the schema-first router API unchanged.
|
|
8
|
+
|
|
9
|
+
### Changed
|
|
10
|
+
|
|
11
|
+
- 5fa58b1: Reduced webhook router complexity by consolidating hook normalization and handler entry creation.
|
|
12
|
+
- 7004e9f: Allow explicit `undefined` in option handling, then follow with d707800 to remove redundant `| undefined` unions from public types.
|
|
13
|
+
- 9f31f87: Switched the package build to ESNext-aligned output and updated package tooling and publish metadata.
|
|
14
|
+
|
|
15
|
+
### Dependencies
|
|
16
|
+
|
|
17
|
+
- Updated dependency `@zap-studio/validation` to `0.3.3`.
|
|
18
|
+
|
|
3
19
|
## 0.2.0
|
|
4
20
|
|
|
5
21
|
### Minor Changes
|
package/dist/index.d.mts
CHANGED
|
@@ -21,10 +21,10 @@ interface WebhookRouterOptions {
|
|
|
21
21
|
*/
|
|
22
22
|
declare class WebhookRouter<TMap = unknown> {
|
|
23
23
|
private readonly handlers;
|
|
24
|
-
private readonly verify
|
|
24
|
+
private readonly verify;
|
|
25
25
|
private readonly globalBeforeHooks;
|
|
26
26
|
private readonly globalAfterHooks;
|
|
27
|
-
private readonly globalErrorHook
|
|
27
|
+
private readonly globalErrorHook;
|
|
28
28
|
private readonly prefix;
|
|
29
29
|
constructor(opts?: WebhookRouterOptions);
|
|
30
30
|
/**
|
|
@@ -48,6 +48,7 @@ declare class WebhookRouter<TMap = unknown> {
|
|
|
48
48
|
handle(req: NormalizedRequest): Promise<NormalizedResponse>;
|
|
49
49
|
private normalizePath;
|
|
50
50
|
private runGlobalBeforeHooks;
|
|
51
|
+
private createHandlerEntry;
|
|
51
52
|
private runRouteBeforeHooks;
|
|
52
53
|
private parseRequestBody;
|
|
53
54
|
private isErrorResponse;
|
package/dist/index.d.mts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.mts","names":[],"sources":["../src/index.ts"],"mappings":";;;;UA6BiB,oBAAA;;EAEf,KAAA,GAAQ,SAAA,GAAY,SAAA;EAFL;EAIf,MAAA,GAAS,UAAA,GAAa,UAAA;;EAEtB,OAAA,GAAU,SAAA;;EAEV,MAAA;;EAEA,MAAA,IAAU,GAAA,EAAK,iBAAA,KAAsB,OAAA;AAAA;;;;;;
|
|
1
|
+
{"version":3,"file":"index.d.mts","names":[],"sources":["../src/index.ts"],"mappings":";;;;UA6BiB,oBAAA;;EAEf,KAAA,GAAQ,SAAA,GAAY,SAAA;EAFL;EAIf,MAAA,GAAS,UAAA,GAAa,UAAA;;EAEtB,OAAA,GAAU,SAAA;;EAEV,MAAA;;EAEA,MAAA,IAAU,GAAA,EAAK,iBAAA,KAAsB,OAAA;AAAA;;;;;;cAgB1B,aAAA;EAAA,iBACM,QAAA;EAAA,iBACA,MAAA;EAAA,iBACA,iBAAA;EAAA,iBACA,gBAAA;EAAA,iBACA,eAAA;EAAA,iBACA,MAAA;EAEjB,WAAA,CAAY,IAAA,GAAM,oBAAA;;;;;AARpB;;;;;EAyBE,QAAA,sCAA8C,gBAAA,mBAAA,CAC5C,IAAA,EAAM,IAAA,EACN,gBAAA,EAAkB,kBAAA,CAAmB,OAAA,IACpC,aAAA,CAAc,IAAA,GAAO,MAAA,CAAO,IAAA,EAAM,iBAAA,CAAkB,OAAA;EACvD,QAAA,+BAAA,CACE,IAAA,EAAM,IAAA,EACN,gBAAA,EAAkB,eAAA,CAAgB,QAAA,IACjC,aAAA,CAAc,IAAA,GAAO,MAAA,CAAO,IAAA,EAAM,QAAA;EACrC,QAAA,qBAAA,CACE,IAAA,EAAM,IAAA,EACN,gBAAA,EAAkB,cAAA,YACjB,aAAA,CAAc,IAAA,GAAO,MAAA,CAAO,IAAA;;;;;;;EAoB/B,MAAA,CAAa,GAAA,EAAK,iBAAA,GAAoB,OAAA,CAAQ,kBAAA;EAAA,QAsCtC,aAAA;EAAA,QA0BM,oBAAA;EAAA,QAMN,kBAAA;EAAA,QAoBM,mBAAA;EAAA,QAQN,gBAAA;EAAA,QAUA,eAAA;EAAA,QASM,eAAA;EAAA,QA8BA,cAAA;EAAA,QAyBA,kBAAA;EAAA,QAYA,mBAAA;EAAA,QASA,WAAA;AAAA;;;;;;;iBA0BA,mBAAA,CAAoB,IAAA,GAAO,oBAAA,GAAuB,aAAA"}
|
package/dist/index.mjs
CHANGED
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
import { standardValidate } from "@zap-studio/validation";
|
|
2
2
|
//#region src/index.ts
|
|
3
|
+
function toArray(value) {
|
|
4
|
+
if (value === void 0) return [];
|
|
5
|
+
return Array.isArray(value) ? value : [value];
|
|
6
|
+
}
|
|
3
7
|
/**
|
|
4
8
|
* Main webhook router class.
|
|
5
9
|
*
|
|
@@ -12,26 +16,16 @@ var WebhookRouter = class {
|
|
|
12
16
|
globalAfterHooks = [];
|
|
13
17
|
globalErrorHook;
|
|
14
18
|
prefix;
|
|
15
|
-
constructor(opts) {
|
|
16
|
-
this.prefix = opts
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
19
|
+
constructor(opts = {}) {
|
|
20
|
+
this.prefix = opts.prefix ?? "/webhooks/";
|
|
21
|
+
this.verify = opts.verify;
|
|
22
|
+
this.globalBeforeHooks = toArray(opts.before);
|
|
23
|
+
this.globalAfterHooks = toArray(opts.after);
|
|
24
|
+
this.globalErrorHook = opts.onError;
|
|
21
25
|
}
|
|
22
26
|
register(path, handlerOrOptions) {
|
|
23
27
|
if (typeof handlerOrOptions === "function") this.handlers[path] = { handler: handlerOrOptions };
|
|
24
|
-
else
|
|
25
|
-
let beforeHooks;
|
|
26
|
-
if (handlerOrOptions.before) beforeHooks = Array.isArray(handlerOrOptions.before) ? handlerOrOptions.before : [handlerOrOptions.before];
|
|
27
|
-
let afterHooks;
|
|
28
|
-
if (handlerOrOptions.after) afterHooks = Array.isArray(handlerOrOptions.after) ? handlerOrOptions.after : [handlerOrOptions.after];
|
|
29
|
-
const entry = { handler: handlerOrOptions.handler };
|
|
30
|
-
if (handlerOrOptions.schema !== void 0) entry.schema = handlerOrOptions.schema;
|
|
31
|
-
if (beforeHooks !== void 0) entry.before = beforeHooks;
|
|
32
|
-
if (afterHooks !== void 0) entry.after = afterHooks;
|
|
33
|
-
this.handlers[path] = entry;
|
|
34
|
-
}
|
|
28
|
+
else this.handlers[path] = this.createHandlerEntry(handlerOrOptions);
|
|
35
29
|
return this;
|
|
36
30
|
}
|
|
37
31
|
/**
|
|
@@ -79,6 +73,13 @@ var WebhookRouter = class {
|
|
|
79
73
|
async runGlobalBeforeHooks(req) {
|
|
80
74
|
for (const hook of this.globalBeforeHooks) await hook(req);
|
|
81
75
|
}
|
|
76
|
+
createHandlerEntry(options) {
|
|
77
|
+
const entry = { handler: options.handler };
|
|
78
|
+
if (options.schema !== void 0) entry.schema = options.schema;
|
|
79
|
+
if (options.before !== void 0) entry.before = Array.isArray(options.before) ? options.before : [options.before];
|
|
80
|
+
if (options.after !== void 0) entry.after = Array.isArray(options.after) ? options.after : [options.after];
|
|
81
|
+
return entry;
|
|
82
|
+
}
|
|
82
83
|
async runRouteBeforeHooks(req, before) {
|
|
83
84
|
if (before) for (const hook of before) await hook(req);
|
|
84
85
|
}
|
package/dist/index.mjs.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.mjs","names":[],"sources":["../src/index.ts"],"sourcesContent":["import type { StandardSchemaV1 } from \"@zap-studio/validation\";\nimport { standardValidate } from \"@zap-studio/validation\";\nimport type {\n AfterHook,\n BeforeHook,\n ErrorHook,\n InferSchemaOutput,\n NormalizedRequest,\n NormalizedResponse,\n RegisterOptions,\n SchemaRouteOptions,\n WebhookHandler,\n} from \"./types/index.js\";\n\n/**\n * Schema-first webhook router with path dispatching, validation, and optional verification.\n *\n * @typeParam TMap - Internal route payload map built incrementally via `register`.\n */\n\ninterface HandlerEntry<TPayload = unknown> {\n after?: AfterHook[];\n before?: BeforeHook[];\n handler: WebhookHandler<TPayload>;\n schema?: StandardSchemaV1<unknown, TPayload>;\n}\n\ntype HandlerStore = Record<string, HandlerEntry<unknown>>;\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 /** Required path prefix for all webhook routes. Defaults to `\"/webhooks/\"`. */\n prefix?: string;\n /** Optional request verification function (for signature checks, auth, etc.). */\n verify?: (req: NormalizedRequest) => Promise<void> | void;\n}\n\n/**\n * Main webhook router class.\n *\n * Register routes with typed schemas and call `handle` with a normalized request.\n */\nexport class WebhookRouter<TMap = unknown> {\n private readonly handlers: HandlerStore = {};\n private readonly verify?: (req: NormalizedRequest) => Promise<void> | void;\n private readonly globalBeforeHooks: BeforeHook[] = [];\n private readonly globalAfterHooks: AfterHook[] = [];\n private readonly globalErrorHook?: ErrorHook;\n private readonly prefix: string;\n\n constructor(opts?: WebhookRouterOptions) {\n this.prefix = opts?.prefix ?? \"/webhooks/\";\n\n if (opts?.verify) {\n this.verify = opts.verify;\n }\n if (opts?.before) {\n this.globalBeforeHooks = Array.isArray(opts.before) ? opts.before : [opts.before];\n }\n if (opts?.after) {\n this.globalAfterHooks = Array.isArray(opts.after) ? opts.after : [opts.after];\n }\n if (opts?.onError) {\n this.globalErrorHook = opts.onError;\n }\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.\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<Path extends string, TSchema extends StandardSchemaV1<unknown, unknown>>(\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<unknown>,\n ): WebhookRouter<TMap & Record<Path, unknown>>;\n register(\n path: string,\n handlerOrOptions: WebhookHandler<unknown> | RegisterOptions<unknown>,\n ): WebhookRouter<TMap> {\n if (typeof handlerOrOptions === \"function\") {\n this.handlers[path] = {\n handler: handlerOrOptions,\n };\n } else {\n let beforeHooks: BeforeHook[] | undefined;\n if (handlerOrOptions.before) {\n beforeHooks = Array.isArray(handlerOrOptions.before)\n ? handlerOrOptions.before\n : [handlerOrOptions.before];\n }\n\n let afterHooks: AfterHook[] | undefined;\n if (handlerOrOptions.after) {\n afterHooks = Array.isArray(handlerOrOptions.after)\n ? handlerOrOptions.after\n : [handlerOrOptions.after];\n }\n\n const entry: HandlerEntry<unknown> = {\n handler: handlerOrOptions.handler,\n };\n\n if (handlerOrOptions.schema !== undefined) {\n entry.schema = handlerOrOptions.schema;\n }\n if (beforeHooks !== undefined) {\n entry.before = beforeHooks;\n }\n if (afterHooks !== undefined) {\n entry.after = afterHooks;\n }\n\n this.handlers[path] = entry;\n }\n\n return this;\n }\n\n /**\n * Handles a normalized incoming webhook request.\n *\n * @param req - Normalized request object.\n * @returns Normalized response for the adapter/framework layer.\n */\n async handle(req: NormalizedRequest): Promise<NormalizedResponse> {\n try {\n const normalizedPath = this.normalizePath(req);\n\n if (normalizedPath === null) {\n return { status: 404, body: { error: \"not found\" } };\n }\n\n const handlerEntry = this.handlers[normalizedPath];\n if (!handlerEntry) {\n return { status: 404, body: { error: \"not found\" } };\n }\n\n await this.runGlobalBeforeHooks(req);\n await this.runRouteBeforeHooks(req, handlerEntry.before);\n\n if (this.verify) {\n await this.verify(req);\n }\n\n const parsedJson = this.parseRequestBody(req);\n const validationResult = await this.validatePayload(parsedJson, handlerEntry.schema);\n\n if (this.isErrorResponse(validationResult)) {\n return validationResult;\n }\n\n const response = await this.executeHandler(handlerEntry.handler, req, validationResult);\n\n await this.runRouteAfterHooks(req, response, handlerEntry.after);\n await this.runGlobalAfterHooks(req, response);\n\n return response;\n } catch (error) {\n return this.handleError(error, req);\n }\n }\n\n private normalizePath(req: NormalizedRequest): string | null {\n let pathname = req.path;\n try {\n // Try to parse as URL (e.g. handles full URLs like https://example.com/webhooks/path -> /webhooks/path)\n const url = new URL(req.path);\n pathname = url.pathname;\n } catch {\n // Not a full URL, use the path as-is\n }\n\n // Require prefix (e.g. /webhooks/path -> /path)\n if (!pathname.startsWith(this.prefix)) {\n // Path doesn't start with the required prefix - not a webhook route\n return null;\n }\n\n // Strip prefix and keep the leading slash\n pathname = pathname.slice(this.prefix.length - 1);\n req.path = pathname;\n\n // Normalize path by removing leading slash for handler matching (e.g. /path -> path)\n const normalizedPath = pathname.startsWith(\"/\") ? pathname.slice(1) : pathname;\n\n return normalizedPath;\n }\n\n private async runGlobalBeforeHooks(req: NormalizedRequest): Promise<void> {\n for (const hook of this.globalBeforeHooks) {\n await hook(req);\n }\n }\n\n private async runRouteBeforeHooks(req: NormalizedRequest, before?: BeforeHook[]): Promise<void> {\n if (before) {\n for (const hook of before) {\n await hook(req);\n }\n }\n }\n\n private parseRequestBody<TParsed = unknown>(req: NormalizedRequest): TParsed | undefined {\n try {\n const parsed = JSON.parse(new TextDecoder().decode(req.rawBody));\n req.json = parsed;\n return parsed as TParsed;\n } catch {\n return;\n }\n }\n\n private isErrorResponse(value: unknown): value is NormalizedResponse {\n return (\n typeof value === \"object\" &&\n value !== null &&\n \"status\" in value &&\n typeof value.status === \"number\"\n );\n }\n\n private async validatePayload<TPayload>(\n parsedJson: unknown,\n schema?: StandardSchemaV1<unknown, TPayload>,\n ): Promise<TPayload | NormalizedResponse> {\n if (!schema) {\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 {\n status: 400,\n body: {\n error: \"validation failed\",\n issues: result.issues.map((issue) => ({\n path: issue.path?.map((p) =>\n typeof p === \"object\" && \"key\" in p ? String(p.key) : String(p),\n ),\n message: issue.message,\n })),\n },\n };\n }\n\n return result.value as TPayload;\n }\n\n private async executeHandler<TPayload = unknown>(\n handler: WebhookHandler<TPayload>,\n req: NormalizedRequest,\n validatedPayload: TPayload,\n ): Promise<NormalizedResponse> {\n const responded = await handler({\n req,\n payload: validatedPayload,\n ack: async (r?: Partial<NormalizedResponse>) => {\n const response: NormalizedResponse = {\n status: r?.status ?? 200,\n body: r?.body ?? \"ok\",\n };\n\n if (r?.headers !== undefined) {\n response.headers = r.headers;\n }\n\n return response;\n },\n });\n\n return responded ?? { status: 200, body: \"ok\" };\n }\n\n private async runRouteAfterHooks(\n req: NormalizedRequest,\n response: NormalizedResponse,\n after?: AfterHook[],\n ): Promise<void> {\n if (after) {\n for (const hook of after) {\n await hook(req, response);\n }\n }\n }\n\n private async runGlobalAfterHooks(\n req: NormalizedRequest,\n response: NormalizedResponse,\n ): Promise<void> {\n for (const hook of this.globalAfterHooks) {\n await hook(req, response);\n }\n }\n\n private async handleError<TError = unknown>(\n error: TError,\n req: NormalizedRequest,\n ): Promise<NormalizedResponse> {\n if (this.globalErrorHook) {\n const errorResponse = await this.globalErrorHook(error as Error, req);\n if (errorResponse) {\n return errorResponse;\n }\n }\n\n return {\n status: 500,\n body: {\n error: error instanceof Error ? error.message : \"Internal server error\",\n },\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 function createWebhookRouter(opts?: WebhookRouterOptions): WebhookRouter {\n return new WebhookRouter(opts);\n}\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"index.mjs","names":[],"sources":["../src/index.ts"],"sourcesContent":["import type { StandardSchemaV1 } from \"@zap-studio/validation\";\nimport { standardValidate } from \"@zap-studio/validation\";\n\nimport type {\n AfterHook,\n BeforeHook,\n ErrorHook,\n InferSchemaOutput,\n NormalizedRequest,\n NormalizedResponse,\n RegisterOptions,\n SchemaRouteOptions,\n WebhookHandler,\n} from \"./types/index.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\ntype HandlerStore = Record<string, HandlerEntry<unknown>>;\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 /** Required path prefix for all webhook routes. Defaults to `\"/webhooks/\"`. */\n prefix?: string;\n /** Optional request verification function (for signature checks, auth, etc.). */\n verify?: (req: NormalizedRequest) => Promise<void> | void;\n}\n\nfunction toArray<T>(value: T | T[] | undefined): T[] {\n if (value === undefined) {\n return [];\n }\n\n return Array.isArray(value) ? value : [value];\n}\n\n/**\n * Main webhook router class.\n *\n * Register routes with typed schemas and call `handle` with a normalized request.\n */\nexport class WebhookRouter<TMap = unknown> {\n private readonly handlers: HandlerStore = {};\n private readonly verify: ((req: NormalizedRequest) => Promise<void> | void) | undefined;\n private readonly globalBeforeHooks: BeforeHook[] = [];\n private readonly globalAfterHooks: AfterHook[] = [];\n private readonly globalErrorHook: ErrorHook | undefined;\n private readonly prefix: string;\n\n constructor(opts: WebhookRouterOptions = {}) {\n this.prefix = opts.prefix ?? \"/webhooks/\";\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.\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<Path extends string, TSchema extends StandardSchemaV1<unknown, unknown>>(\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<unknown>,\n ): WebhookRouter<TMap & Record<Path, unknown>>;\n register(\n path: string,\n handlerOrOptions: WebhookHandler<unknown> | RegisterOptions<unknown>,\n ): WebhookRouter<TMap> {\n if (typeof handlerOrOptions === \"function\") {\n this.handlers[path] = { handler: handlerOrOptions };\n } else {\n this.handlers[path] = this.createHandlerEntry(handlerOrOptions);\n }\n\n return this;\n }\n\n /**\n * Handles a normalized incoming webhook request.\n *\n * @param req - Normalized request object.\n * @returns Normalized response for the adapter/framework layer.\n */\n async handle(req: NormalizedRequest): Promise<NormalizedResponse> {\n try {\n const normalizedPath = this.normalizePath(req);\n\n if (normalizedPath === null) {\n return { status: 404, body: { error: \"not found\" } };\n }\n\n const handlerEntry = this.handlers[normalizedPath];\n if (!handlerEntry) {\n return { status: 404, body: { error: \"not found\" } };\n }\n\n await this.runGlobalBeforeHooks(req);\n await this.runRouteBeforeHooks(req, handlerEntry.before);\n\n if (this.verify) {\n await this.verify(req);\n }\n\n const parsedJson = this.parseRequestBody(req);\n const validationResult = await this.validatePayload(parsedJson, handlerEntry.schema);\n\n if (this.isErrorResponse(validationResult)) {\n return validationResult;\n }\n\n const response = await this.executeHandler(handlerEntry.handler, req, validationResult);\n\n await this.runRouteAfterHooks(req, response, handlerEntry.after);\n await this.runGlobalAfterHooks(req, response);\n\n return response;\n } catch (error) {\n return this.handleError(error, req);\n }\n }\n\n private normalizePath(req: NormalizedRequest): string | null {\n let pathname = req.path;\n try {\n // Try to parse as URL (e.g. handles full URLs like https://example.com/webhooks/path -> /webhooks/path)\n const url = new URL(req.path);\n pathname = url.pathname;\n } catch {\n // Not a full URL, use the path as-is\n }\n\n // Require prefix (e.g. /webhooks/path -> /path)\n if (!pathname.startsWith(this.prefix)) {\n // Path doesn't start with the required prefix - not a webhook route\n return null;\n }\n\n // Strip prefix and keep the leading slash\n pathname = pathname.slice(this.prefix.length - 1);\n req.path = pathname;\n\n // Normalize path by removing leading slash for handler matching (e.g. /path -> path)\n const normalizedPath = pathname.startsWith(\"/\") ? pathname.slice(1) : pathname;\n\n return normalizedPath;\n }\n\n private async runGlobalBeforeHooks(req: NormalizedRequest): Promise<void> {\n for (const hook of this.globalBeforeHooks) {\n await hook(req);\n }\n }\n\n private createHandlerEntry(options: RegisterOptions<unknown>): HandlerEntry<unknown> {\n const entry: HandlerEntry<unknown> = {\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) ? options.before : [options.before];\n }\n\n if (options.after !== undefined) {\n entry.after = Array.isArray(options.after) ? options.after : [options.after];\n }\n\n return entry;\n }\n\n private async runRouteBeforeHooks(req: NormalizedRequest, before?: BeforeHook[]): Promise<void> {\n if (before) {\n for (const hook of before) {\n await hook(req);\n }\n }\n }\n\n private parseRequestBody<TParsed = unknown>(req: NormalizedRequest): TParsed | undefined {\n try {\n const parsed = JSON.parse(new TextDecoder().decode(req.rawBody));\n req.json = parsed;\n return parsed as TParsed;\n } catch {\n return;\n }\n }\n\n private isErrorResponse(value: unknown): value is NormalizedResponse {\n return (\n typeof value === \"object\" &&\n value !== null &&\n \"status\" in value &&\n typeof value.status === \"number\"\n );\n }\n\n private async validatePayload<TPayload>(\n parsedJson: unknown,\n schema?: StandardSchemaV1<unknown, TPayload>,\n ): Promise<TPayload | NormalizedResponse> {\n if (!schema) {\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 {\n status: 400,\n body: {\n error: \"validation failed\",\n issues: result.issues.map((issue) => ({\n path: issue.path?.map((p) =>\n typeof p === \"object\" && \"key\" in p ? String(p.key) : String(p),\n ),\n message: issue.message,\n })),\n },\n };\n }\n\n return result.value as TPayload;\n }\n\n private async executeHandler<TPayload = unknown>(\n handler: WebhookHandler<TPayload>,\n req: NormalizedRequest,\n validatedPayload: TPayload,\n ): Promise<NormalizedResponse> {\n const responded = await handler({\n req,\n payload: validatedPayload,\n ack: async (r?: Partial<NormalizedResponse>) => {\n const response: NormalizedResponse = {\n status: r?.status ?? 200,\n body: r?.body ?? \"ok\",\n };\n\n if (r?.headers !== undefined) {\n response.headers = r.headers;\n }\n\n return response;\n },\n });\n\n return responded ?? { status: 200, body: \"ok\" };\n }\n\n private async runRouteAfterHooks(\n req: NormalizedRequest,\n response: NormalizedResponse,\n after?: AfterHook[],\n ): Promise<void> {\n if (after) {\n for (const hook of after) {\n await hook(req, response);\n }\n }\n }\n\n private async runGlobalAfterHooks(\n req: NormalizedRequest,\n response: NormalizedResponse,\n ): Promise<void> {\n for (const hook of this.globalAfterHooks) {\n await hook(req, response);\n }\n }\n\n private async handleError<TError = unknown>(\n error: TError,\n req: NormalizedRequest,\n ): Promise<NormalizedResponse> {\n if (this.globalErrorHook) {\n const errorResponse = await this.globalErrorHook(error as Error, req);\n if (errorResponse) {\n return errorResponse;\n }\n }\n\n return {\n status: 500,\n body: {\n error: error instanceof Error ? error.message : \"Internal server error\",\n },\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 function createWebhookRouter(opts?: WebhookRouterOptions): WebhookRouter {\n return new WebhookRouter(opts);\n}\n"],"mappings":";;AA0CA,SAAS,QAAW,OAAiC;AACnD,KAAI,UAAU,KAAA,EACZ,QAAO,EAAE;AAGX,QAAO,MAAM,QAAQ,MAAM,GAAG,QAAQ,CAAC,MAAM;;;;;;;AAQ/C,IAAa,gBAAb,MAA2C;CACzC,WAA0C,EAAE;CAC5C;CACA,oBAAmD,EAAE;CACrD,mBAAiD,EAAE;CACnD;CACA;CAEA,YAAY,OAA6B,EAAE,EAAE;AAC3C,OAAK,SAAS,KAAK,UAAU;AAC7B,OAAK,SAAS,KAAK;AACnB,OAAK,oBAAoB,QAAQ,KAAK,OAAO;AAC7C,OAAK,mBAAmB,QAAQ,KAAK,MAAM;AAC3C,OAAK,kBAAkB,KAAK;;CAwB9B,SACE,MACA,kBACqB;AACrB,MAAI,OAAO,qBAAqB,WAC9B,MAAK,SAAS,QAAQ,EAAE,SAAS,kBAAkB;MAEnD,MAAK,SAAS,QAAQ,KAAK,mBAAmB,iBAAiB;AAGjE,SAAO;;;;;;;;CAST,MAAM,OAAO,KAAqD;AAChE,MAAI;GACF,MAAM,iBAAiB,KAAK,cAAc,IAAI;AAE9C,OAAI,mBAAmB,KACrB,QAAO;IAAE,QAAQ;IAAK,MAAM,EAAE,OAAO,aAAa;IAAE;GAGtD,MAAM,eAAe,KAAK,SAAS;AACnC,OAAI,CAAC,aACH,QAAO;IAAE,QAAQ;IAAK,MAAM,EAAE,OAAO,aAAa;IAAE;AAGtD,SAAM,KAAK,qBAAqB,IAAI;AACpC,SAAM,KAAK,oBAAoB,KAAK,aAAa,OAAO;AAExD,OAAI,KAAK,OACP,OAAM,KAAK,OAAO,IAAI;GAGxB,MAAM,aAAa,KAAK,iBAAiB,IAAI;GAC7C,MAAM,mBAAmB,MAAM,KAAK,gBAAgB,YAAY,aAAa,OAAO;AAEpF,OAAI,KAAK,gBAAgB,iBAAiB,CACxC,QAAO;GAGT,MAAM,WAAW,MAAM,KAAK,eAAe,aAAa,SAAS,KAAK,iBAAiB;AAEvF,SAAM,KAAK,mBAAmB,KAAK,UAAU,aAAa,MAAM;AAChE,SAAM,KAAK,oBAAoB,KAAK,SAAS;AAE7C,UAAO;WACA,OAAO;AACd,UAAO,KAAK,YAAY,OAAO,IAAI;;;CAIvC,cAAsB,KAAuC;EAC3D,IAAI,WAAW,IAAI;AACnB,MAAI;AAGF,cADY,IAAI,IAAI,IAAI,KAAK,CACd;UACT;AAKR,MAAI,CAAC,SAAS,WAAW,KAAK,OAAO,CAEnC,QAAO;AAIT,aAAW,SAAS,MAAM,KAAK,OAAO,SAAS,EAAE;AACjD,MAAI,OAAO;AAKX,SAFuB,SAAS,WAAW,IAAI,GAAG,SAAS,MAAM,EAAE,GAAG;;CAKxE,MAAc,qBAAqB,KAAuC;AACxE,OAAK,MAAM,QAAQ,KAAK,kBACtB,OAAM,KAAK,IAAI;;CAInB,mBAA2B,SAA0D;EACnF,MAAM,QAA+B,EACnC,SAAS,QAAQ,SAClB;AAED,MAAI,QAAQ,WAAW,KAAA,EACrB,OAAM,SAAS,QAAQ;AAGzB,MAAI,QAAQ,WAAW,KAAA,EACrB,OAAM,SAAS,MAAM,QAAQ,QAAQ,OAAO,GAAG,QAAQ,SAAS,CAAC,QAAQ,OAAO;AAGlF,MAAI,QAAQ,UAAU,KAAA,EACpB,OAAM,QAAQ,MAAM,QAAQ,QAAQ,MAAM,GAAG,QAAQ,QAAQ,CAAC,QAAQ,MAAM;AAG9E,SAAO;;CAGT,MAAc,oBAAoB,KAAwB,QAAsC;AAC9F,MAAI,OACF,MAAK,MAAM,QAAQ,OACjB,OAAM,KAAK,IAAI;;CAKrB,iBAA4C,KAA6C;AACvF,MAAI;GACF,MAAM,SAAS,KAAK,MAAM,IAAI,aAAa,CAAC,OAAO,IAAI,QAAQ,CAAC;AAChE,OAAI,OAAO;AACX,UAAO;UACD;AACN;;;CAIJ,gBAAwB,OAA6C;AACnE,SACE,OAAO,UAAU,YACjB,UAAU,QACV,YAAY,SACZ,OAAO,MAAM,WAAW;;CAI5B,MAAc,gBACZ,YACA,QACwC;AACxC,MAAI,CAAC,OACH,QAAO;EAGT,MAAM,SAAS,MAAM,iBAAiB,QAAQ,YAAY,EACxD,cAAc,OACf,CAAC;AAEF,MAAI,OAAO,OACT,QAAO;GACL,QAAQ;GACR,MAAM;IACJ,OAAO;IACP,QAAQ,OAAO,OAAO,KAAK,WAAW;KACpC,MAAM,MAAM,MAAM,KAAK,MACrB,OAAO,MAAM,YAAY,SAAS,IAAI,OAAO,EAAE,IAAI,GAAG,OAAO,EAAE,CAChE;KACD,SAAS,MAAM;KAChB,EAAE;IACJ;GACF;AAGH,SAAO,OAAO;;CAGhB,MAAc,eACZ,SACA,KACA,kBAC6B;AAkB7B,SAjBkB,MAAM,QAAQ;GAC9B;GACA,SAAS;GACT,KAAK,OAAO,MAAoC;IAC9C,MAAM,WAA+B;KACnC,QAAQ,GAAG,UAAU;KACrB,MAAM,GAAG,QAAQ;KAClB;AAED,QAAI,GAAG,YAAY,KAAA,EACjB,UAAS,UAAU,EAAE;AAGvB,WAAO;;GAEV,CAAC,IAEkB;GAAE,QAAQ;GAAK,MAAM;GAAM;;CAGjD,MAAc,mBACZ,KACA,UACA,OACe;AACf,MAAI,MACF,MAAK,MAAM,QAAQ,MACjB,OAAM,KAAK,KAAK,SAAS;;CAK/B,MAAc,oBACZ,KACA,UACe;AACf,OAAK,MAAM,QAAQ,KAAK,iBACtB,OAAM,KAAK,KAAK,SAAS;;CAI7B,MAAc,YACZ,OACA,KAC6B;AAC7B,MAAI,KAAK,iBAAiB;GACxB,MAAM,gBAAgB,MAAM,KAAK,gBAAgB,OAAgB,IAAI;AACrE,OAAI,cACF,QAAO;;AAIX,SAAO;GACL,QAAQ;GACR,MAAM,EACJ,OAAO,iBAAiB,QAAQ,MAAM,UAAU,yBACjD;GACF;;;;;;;;;AAUL,SAAgB,oBAAoB,MAA4C;AAC9E,QAAO,IAAI,cAAc,KAAK"}
|
package/dist/types/index.d.mts
CHANGED
|
@@ -43,13 +43,13 @@ interface RegisterOptions<T> {
|
|
|
43
43
|
/**
|
|
44
44
|
* Infers the output type from a Standard Schema instance.
|
|
45
45
|
*
|
|
46
|
-
* @
|
|
46
|
+
* @template TSchema - A Standard Schema type.
|
|
47
47
|
*/
|
|
48
48
|
type InferSchemaOutput<TSchema> = TSchema extends StandardSchemaV1<unknown, infer TOutput> ? TOutput : never;
|
|
49
49
|
/**
|
|
50
50
|
* Route options where schema is required and handler payload is inferred.
|
|
51
51
|
*
|
|
52
|
-
* @
|
|
52
|
+
* @template TSchema - Schema used to infer handler payload type.
|
|
53
53
|
*/
|
|
54
54
|
type SchemaRouteOptions<TSchema extends StandardSchemaV1<unknown, unknown>> = Omit<RegisterOptions<InferSchemaOutput<TSchema>>, "schema"> & {
|
|
55
55
|
schema: TSchema;
|
|
@@ -63,7 +63,7 @@ interface RouteLike {
|
|
|
63
63
|
/**
|
|
64
64
|
* Applies schema-driven payload inference to each route entry.
|
|
65
65
|
*
|
|
66
|
-
* @
|
|
66
|
+
* @template TRoutes - Route dictionary keyed by webhook path.
|
|
67
67
|
*/
|
|
68
68
|
type SchemaRoutes<TRoutes extends Record<string, RouteLike>> = { [P in keyof TRoutes]: SchemaRouteOptions<TRoutes[P]["schema"]> };
|
|
69
69
|
/** The webhook handler function, responsible for processing incoming webhook events. */
|
|
@@ -77,7 +77,7 @@ type HandlerMap<TMap extends Record<string, unknown>> = { [P in keyof TMap]: Web
|
|
|
77
77
|
/**
|
|
78
78
|
* Builds a webhook payload map from a schema-based route dictionary.
|
|
79
79
|
*
|
|
80
|
-
* @
|
|
80
|
+
* @template TRoutes - Route dictionary keyed by webhook path.
|
|
81
81
|
*/
|
|
82
82
|
type InferWebhookMapFromRoutes<TRoutes extends Record<string, RouteLike>> = { [P in keyof TRoutes]: InferSchemaOutput<TRoutes[P]["schema"]> };
|
|
83
83
|
/** Verification function for incoming requests */
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zap-studio/webhooks",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.1",
|
|
4
4
|
"private": false,
|
|
5
5
|
"description": "A lightweight, type-safe webhook router with Standard Schema validation, signature verification, and lifecycle hooks.",
|
|
6
6
|
"keywords": [
|
|
@@ -22,20 +22,14 @@
|
|
|
22
22
|
"license": "MIT",
|
|
23
23
|
"repository": {
|
|
24
24
|
"type": "git",
|
|
25
|
-
"url": "https://github.com/zap-studio/monorepo.git",
|
|
25
|
+
"url": "git+https://github.com/zap-studio/monorepo.git",
|
|
26
26
|
"directory": "packages/webhooks"
|
|
27
27
|
},
|
|
28
|
-
"bin": {
|
|
29
|
-
"intent": "./bin/intent.js"
|
|
30
|
-
},
|
|
31
28
|
"files": [
|
|
32
29
|
"dist",
|
|
33
30
|
"CHANGELOG.md",
|
|
34
31
|
"LICENSE",
|
|
35
|
-
"README.md"
|
|
36
|
-
"skills",
|
|
37
|
-
"bin",
|
|
38
|
-
"!skills/_artifacts"
|
|
32
|
+
"README.md"
|
|
39
33
|
],
|
|
40
34
|
"type": "module",
|
|
41
35
|
"sideEffects": false,
|
|
@@ -53,15 +47,17 @@
|
|
|
53
47
|
"access": "public"
|
|
54
48
|
},
|
|
55
49
|
"dependencies": {
|
|
56
|
-
"@zap-studio/validation": "0.3.
|
|
50
|
+
"@zap-studio/validation": "0.3.3"
|
|
57
51
|
},
|
|
58
52
|
"devDependencies": {
|
|
59
|
-
"
|
|
60
|
-
"
|
|
61
|
-
"
|
|
62
|
-
"zod": "^4.2.0",
|
|
53
|
+
"typescript": "^6.0.3",
|
|
54
|
+
"vite-plus": "^0.1.19",
|
|
55
|
+
"zod": "^4.3.6",
|
|
63
56
|
"@zap-studio/typescript": "0.0.0"
|
|
64
57
|
},
|
|
58
|
+
"engines": {
|
|
59
|
+
"node": ">=18.0.0"
|
|
60
|
+
},
|
|
65
61
|
"scripts": {
|
|
66
62
|
"build": "vp pack",
|
|
67
63
|
"test": "vp test run",
|
package/bin/intent.js
DELETED
|
@@ -1,172 +0,0 @@
|
|
|
1
|
-
---
|
|
2
|
-
name: zap-webhooks-routing-and-verification
|
|
3
|
-
description: >
|
|
4
|
-
Build webhook ingestion with @zap-studio/webhooks using createWebhookRouter,
|
|
5
|
-
register path keys, prefix normalization, schema validation, lifecycle hooks,
|
|
6
|
-
createHmacVerifier, and BaseAdapter request/response mapping.
|
|
7
|
-
type: core
|
|
8
|
-
library: "@zap-studio/webhooks"
|
|
9
|
-
library_version: "0.1.3"
|
|
10
|
-
sources:
|
|
11
|
-
- "zap-studio/monorepo:packages/webhooks/README.md"
|
|
12
|
-
- "zap-studio/monorepo:packages/webhooks/src/index.ts"
|
|
13
|
-
- "zap-studio/monorepo:packages/webhooks/src/verify.ts"
|
|
14
|
-
- "zap-studio/monorepo:packages/webhooks/src/adapters/base.ts"
|
|
15
|
-
---
|
|
16
|
-
|
|
17
|
-
# @zap-studio/webhooks — Routing and Verification
|
|
18
|
-
|
|
19
|
-
## Setup
|
|
20
|
-
|
|
21
|
-
```ts
|
|
22
|
-
import { createWebhookRouter } from "@zap-studio/webhooks";
|
|
23
|
-
import { createHmacVerifier } from "@zap-studio/webhooks/verify";
|
|
24
|
-
import { z } from "zod";
|
|
25
|
-
|
|
26
|
-
const router = createWebhookRouter({
|
|
27
|
-
prefix: "/webhooks/",
|
|
28
|
-
verify: createHmacVerifier({
|
|
29
|
-
headerName: "x-hub-signature-256",
|
|
30
|
-
secret: process.env.WEBHOOK_SECRET!,
|
|
31
|
-
}),
|
|
32
|
-
});
|
|
33
|
-
|
|
34
|
-
router.register("github/push", {
|
|
35
|
-
schema: z.object({ ref: z.string() }),
|
|
36
|
-
handler: async ({ payload, ack }) => {
|
|
37
|
-
console.log(payload.ref);
|
|
38
|
-
return ack({ status: 200, body: "ok" });
|
|
39
|
-
},
|
|
40
|
-
});
|
|
41
|
-
```
|
|
42
|
-
|
|
43
|
-
## Core Patterns
|
|
44
|
-
|
|
45
|
-
### Add global hooks for observability and error shaping
|
|
46
|
-
|
|
47
|
-
```ts
|
|
48
|
-
const router = createWebhookRouter({
|
|
49
|
-
before: (req) => {
|
|
50
|
-
console.log("incoming", req.path);
|
|
51
|
-
},
|
|
52
|
-
after: (_req, res) => {
|
|
53
|
-
console.log("status", res.status);
|
|
54
|
-
},
|
|
55
|
-
onError: (error) => ({
|
|
56
|
-
status: 500,
|
|
57
|
-
body: { error: error.message },
|
|
58
|
-
}),
|
|
59
|
-
});
|
|
60
|
-
```
|
|
61
|
-
|
|
62
|
-
### Register route-specific hooks
|
|
63
|
-
|
|
64
|
-
```ts
|
|
65
|
-
router.register("payments/succeeded", {
|
|
66
|
-
schema: PaymentSchema,
|
|
67
|
-
before: (req) => {
|
|
68
|
-
req.headers.set("x-processed", "1");
|
|
69
|
-
},
|
|
70
|
-
after: (_req, res) => {
|
|
71
|
-
console.log("finished", res.status);
|
|
72
|
-
},
|
|
73
|
-
handler: async ({ payload, ack }) => ack({ body: { id: payload.id } }),
|
|
74
|
-
});
|
|
75
|
-
```
|
|
76
|
-
|
|
77
|
-
### Implement an adapter with `BaseAdapter`
|
|
78
|
-
|
|
79
|
-
```ts
|
|
80
|
-
import { BaseAdapter } from "@zap-studio/webhooks/adapters/base";
|
|
81
|
-
|
|
82
|
-
class MyAdapter extends BaseAdapter {
|
|
83
|
-
async toNormalizedRequest(req: Request) {
|
|
84
|
-
return {
|
|
85
|
-
method: req.method,
|
|
86
|
-
path: req.url,
|
|
87
|
-
headers: req.headers,
|
|
88
|
-
rawBody: Buffer.from(await req.text()),
|
|
89
|
-
};
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
async toFrameworkResponse(res: Response, normalized) {
|
|
93
|
-
return new Response(JSON.stringify(normalized.body), {
|
|
94
|
-
status: normalized.status,
|
|
95
|
-
headers: normalized.headers,
|
|
96
|
-
}) as unknown as Response;
|
|
97
|
-
}
|
|
98
|
-
}
|
|
99
|
-
```
|
|
100
|
-
|
|
101
|
-
## Common Mistakes
|
|
102
|
-
|
|
103
|
-
### HIGH Registering paths with leading slash
|
|
104
|
-
|
|
105
|
-
Wrong:
|
|
106
|
-
|
|
107
|
-
```ts
|
|
108
|
-
router.register("/github/push", {
|
|
109
|
-
schema: PushSchema,
|
|
110
|
-
handler,
|
|
111
|
-
});
|
|
112
|
-
```
|
|
113
|
-
|
|
114
|
-
Correct:
|
|
115
|
-
|
|
116
|
-
```ts
|
|
117
|
-
router.register("github/push", {
|
|
118
|
-
schema: PushSchema,
|
|
119
|
-
handler,
|
|
120
|
-
});
|
|
121
|
-
```
|
|
122
|
-
|
|
123
|
-
Incoming paths normalize to slashless keys; leading slash route keys do not match and return 404.
|
|
124
|
-
|
|
125
|
-
Source: zap-studio/monorepo:packages/webhooks/src/index.ts
|
|
126
|
-
|
|
127
|
-
### CRITICAL Verifying a transformed payload instead of raw body
|
|
128
|
-
|
|
129
|
-
Wrong:
|
|
130
|
-
|
|
131
|
-
```ts
|
|
132
|
-
const parsed = JSON.parse(req.rawBody.toString());
|
|
133
|
-
await verifyProvider(JSON.stringify(parsed));
|
|
134
|
-
```
|
|
135
|
-
|
|
136
|
-
Correct:
|
|
137
|
-
|
|
138
|
-
```ts
|
|
139
|
-
await verify(req); // uses req.rawBody bytes
|
|
140
|
-
const parsed = JSON.parse(req.rawBody.toString());
|
|
141
|
-
```
|
|
142
|
-
|
|
143
|
-
Signature checks must run on exact raw bytes; any parse/serialize transformation can invalidate signatures.
|
|
144
|
-
|
|
145
|
-
Source: zap-studio/monorepo:packages/webhooks/src/verify.ts
|
|
146
|
-
|
|
147
|
-
### HIGH Assuming `createHmacVerifier` is Node-only
|
|
148
|
-
|
|
149
|
-
Wrong:
|
|
150
|
-
|
|
151
|
-
```ts
|
|
152
|
-
const verify = createHmacVerifier({
|
|
153
|
-
headerName: "x-signature",
|
|
154
|
-
secret: env.WEBHOOK_SECRET,
|
|
155
|
-
});
|
|
156
|
-
// used in edge runtime
|
|
157
|
-
```
|
|
158
|
-
|
|
159
|
-
Correct:
|
|
160
|
-
|
|
161
|
-
```ts
|
|
162
|
-
const verify = createHmacVerifier({
|
|
163
|
-
headerName: "x-signature",
|
|
164
|
-
secret: env.WEBHOOK_SECRET,
|
|
165
|
-
});
|
|
166
|
-
```
|
|
167
|
-
|
|
168
|
-
`createHmacVerifier` uses the Web Crypto API and works across runtimes that expose `globalThis.crypto.subtle`.
|
|
169
|
-
|
|
170
|
-
Source: zap-studio/monorepo:packages/webhooks/src/verify.ts
|
|
171
|
-
|
|
172
|
-
See also: zap-validation-standard-schema/SKILL.md — payload validation result handling.
|