-
Notifications
You must be signed in to change notification settings - Fork 214
Description
Hey,
I've recently started using Svix to handle Clerk webhooks. For this, I tried adding the svix JS package to my Astro project to validate webhook signatures. Unfortunately, this ended up doubling my project's bundle size from 1 MB to 2 MB. 😱
As a workaround, I copied the following code and added @stablelib/base64 + fast-sha256 as dependencies:
svix-webhooks/javascript/src/index.ts
Lines 803 to 939 in d0443bd
| class ExtendableError extends Error { | |
| constructor(message: any) { | |
| super(message); | |
| Object.setPrototypeOf(this, ExtendableError.prototype); | |
| this.name = "ExtendableError"; | |
| this.stack = new Error(message).stack; | |
| } | |
| } | |
| export class WebhookVerificationError extends ExtendableError { | |
| constructor(message: string) { | |
| super(message); | |
| Object.setPrototypeOf(this, WebhookVerificationError.prototype); | |
| this.name = "WebhookVerificationError"; | |
| } | |
| } | |
| export interface WebhookRequiredHeaders { | |
| "svix-id": string; | |
| "svix-timestamp": string; | |
| "svix-signature": string; | |
| } | |
| export interface WebhookUnbrandedRequiredHeaders { | |
| "webhook-id": string; | |
| "webhook-timestamp": string; | |
| "webhook-signature": string; | |
| } | |
| export interface WebhookOptions { | |
| format?: "raw"; | |
| } | |
| export class Webhook { | |
| private static prefix = "whsec_"; | |
| private readonly key: Uint8Array; | |
| constructor(secret: string | Uint8Array, options?: WebhookOptions) { | |
| if (!secret) { | |
| throw new Error("Secret can't be empty."); | |
| } | |
| if (options?.format === "raw") { | |
| if (secret instanceof Uint8Array) { | |
| this.key = secret; | |
| } else { | |
| this.key = Uint8Array.from(secret, (c) => c.charCodeAt(0)); | |
| } | |
| } else { | |
| if (typeof secret !== "string") { | |
| throw new Error("Expected secret to be of type string"); | |
| } | |
| if (secret.startsWith(Webhook.prefix)) { | |
| secret = secret.substring(Webhook.prefix.length); | |
| } | |
| this.key = base64.decode(secret); | |
| } | |
| } | |
| public verify( | |
| payload: string | Buffer, | |
| headers_: | |
| | WebhookRequiredHeaders | |
| | WebhookUnbrandedRequiredHeaders | |
| | Record<string, string> | |
| ): unknown { | |
| const headers: Record<string, string> = {}; | |
| for (const key of Object.keys(headers_)) { | |
| headers[key.toLowerCase()] = (headers_ as Record<string, string>)[key]; | |
| } | |
| let msgId = headers["svix-id"]; | |
| let msgSignature = headers["svix-signature"]; | |
| let msgTimestamp = headers["svix-timestamp"]; | |
| if (!msgSignature || !msgId || !msgTimestamp) { | |
| msgId = headers["webhook-id"]; | |
| msgSignature = headers["webhook-signature"]; | |
| msgTimestamp = headers["webhook-timestamp"]; | |
| if (!msgSignature || !msgId || !msgTimestamp) { | |
| throw new WebhookVerificationError("Missing required headers"); | |
| } | |
| } | |
| const timestamp = this.verifyTimestamp(msgTimestamp); | |
| const computedSignature = this.sign(msgId, timestamp, payload); | |
| const expectedSignature = computedSignature.split(",")[1]; | |
| const passedSignatures = msgSignature.split(" "); | |
| const encoder = new globalThis.TextEncoder(); | |
| for (const versionedSignature of passedSignatures) { | |
| const [version, signature] = versionedSignature.split(","); | |
| if (version !== "v1") { | |
| continue; | |
| } | |
| if (timingSafeEqual(encoder.encode(signature), encoder.encode(expectedSignature))) { | |
| return JSON.parse(payload.toString()); | |
| } | |
| } | |
| throw new WebhookVerificationError("No matching signature found"); | |
| } | |
| public sign(msgId: string, timestamp: Date, payload: string | Buffer): string { | |
| if (typeof payload === "string") { | |
| // Do nothing, already a string | |
| } else if (payload.constructor.name === "Buffer") { | |
| payload = payload.toString(); | |
| } else { | |
| throw new Error("Expected payload to be of type string or Buffer. Please refer to https://docs.svix.com/receiving/verifying-payloads/how for more information."); | |
| } | |
| const encoder = new TextEncoder(); | |
| const timestampNumber = Math.floor(timestamp.getTime() / 1000); | |
| const toSign = encoder.encode(`${msgId}.${timestampNumber}.${payload}`); | |
| const expectedSignature = base64.encode(sha256.hmac(this.key, toSign)); | |
| return `v1,${expectedSignature}`; | |
| } | |
| private verifyTimestamp(timestampHeader: string): Date { | |
| const now = Math.floor(Date.now() / 1000); | |
| const timestamp = parseInt(timestampHeader, 10); | |
| if (isNaN(timestamp)) { | |
| throw new WebhookVerificationError("Invalid Signature Headers"); | |
| } | |
| if (now - timestamp > WEBHOOK_TOLERANCE_IN_SECONDS) { | |
| throw new WebhookVerificationError("Message timestamp too old"); | |
| } | |
| if (timestamp > now + WEBHOOK_TOLERANCE_IN_SECONDS) { | |
| throw new WebhookVerificationError("Message timestamp too new"); | |
| } | |
| return new Date(timestamp * 1000); | |
| } | |
| } |
Surprisingly, this had almost no impact on the bundle size.
I'm no expert on ESM or bundling, but there seems to be a problem with the code's structure that prevents proper tree shaking.
This is especially concerning in a constrained environment like Cloudflare Workers/Pages, where bundle size is (even more) important.
(Happy to provide more info if needed.)