Skip to content

Adding svix for webhook validation adds almost 1 MB to the JS bundle #1483

Open
@mlafeldt

Description

@mlafeldt

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:

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.)

Metadata

Metadata

Assignees

Labels

Type

No type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions