diff --git a/.changeset/lazy-cameras-shake.md b/.changeset/lazy-cameras-shake.md new file mode 100644 index 0000000..9328134 --- /dev/null +++ b/.changeset/lazy-cameras-shake.md @@ -0,0 +1,12 @@ +--- +"@opencoredev/email-sdk": patch +"@opencoredev/convex-email": patch +--- + +Performance and size improvements: + +- The CLI now lazily imports adapters, the client, and validation helpers, so every command only loads the modules it uses (1 module instead of 26 for `adapters`/`doctor`/`version`/`help`, ~5 for `send`). +- Published `dist/` JavaScript is minified (whitespace and syntax only — identifiers are kept so stack traces stay readable), and dead `.d.ts.map` files are no longer emitted. The npm package shrinks from 37.7 kB to 29.7 kB packed (188 kB to 125 kB unpacked). +- Hot-path send overhead is reduced: per-send config and event objects are only allocated when hooks or middleware are registered, retry config is no longer copied per send, and single-adapter sends skip fallback resolution. +- The SES adapter caches the AWS SigV4 signing key per provider instance instead of re-deriving it on every send, and JSON-based adapters reuse their static request URL and headers. +- Convex Email reads the config document once per `enqueueBatch` mutation instead of once per message. diff --git a/bun.lock b/bun.lock index a0257c6..337db68 100644 --- a/bun.lock +++ b/bun.lock @@ -69,7 +69,7 @@ }, "packages/email-sdk": { "name": "@opencoredev/email-sdk", - "version": "0.6.0", + "version": "0.6.1", "bin": { "email-sdk": "dist/cli.js", }, diff --git a/packages/convex-email/src/component/lib.ts b/packages/convex-email/src/component/lib.ts index 371a5a9..cd8a82a 100644 --- a/packages/convex-email/src/component/lib.ts +++ b/packages/convex-email/src/component/lib.ts @@ -49,10 +49,12 @@ export const enqueueBatch = mutation({ }); } + // Read the shared config once for the whole batch instead of once per message. + const config = await readConfig(ctx); const ids: string[] = []; for (const message of args.messages) { - ids.push(await enqueueEmail(ctx, message)); + ids.push(await enqueueEmail(ctx, message, config)); } return ids; @@ -360,7 +362,11 @@ export const recordWebhook = internalMutation({ }, }); -async function enqueueEmail(ctx: any, args: ConvexEmailSendArgs) { +async function enqueueEmail( + ctx: any, + args: ConvexEmailSendArgs, + preloadedConfig?: ConvexEmailConfig, +) { const idempotencyKey = args.idempotencyKey; if (idempotencyKey) { @@ -374,7 +380,7 @@ async function enqueueEmail(ctx: any, args: ConvexEmailSendArgs) { } } - const config = await readConfig(ctx); + const config = preloadedConfig ?? (await readConfig(ctx)); const now = Date.now(); const message = applyConfigToMessage(args, config); const emailId = await ctx.db.insert("emails", { diff --git a/packages/email-sdk/package.json b/packages/email-sdk/package.json index 32130d3..ebcc014 100644 --- a/packages/email-sdk/package.json +++ b/packages/email-sdk/package.json @@ -147,7 +147,7 @@ "access": "public" }, "scripts": { - "build": "rm -rf dist && tsc -p tsconfig.json && chmod +x dist/cli.js", + "build": "rm -rf dist && tsc -p tsconfig.json && bun scripts/minify-dist.ts && chmod +x dist/cli.js", "check-types": "tsc -p tsconfig.json --noEmit", "test": "bun test", "prepack": "bun run build" diff --git a/packages/email-sdk/scripts/minify-dist.ts b/packages/email-sdk/scripts/minify-dist.ts new file mode 100644 index 0000000..450b665 --- /dev/null +++ b/packages/email-sdk/scripts/minify-dist.ts @@ -0,0 +1,133 @@ +// Minifies the tsc output in dist/ in place to shrink the published package +// and the bytes the CLI parses at startup. Each module is processed with all +// imports external so the module graph (and the CLI's lazy loading) is +// preserved. Identifiers are kept so published stack traces stay readable. +// +// Bun's bundler can mangle some external re-export shapes (e.g. pure barrel +// files), so every minified module is link-checked in Node afterwards; any +// module that no longer links is restored to its original tsc output. +import { mkdtempSync, readdirSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { pathToFileURL } from "node:url"; + +const distDir = new URL("../dist", import.meta.url).pathname; +const files = readdirSync(distDir).filter((file) => file.endsWith(".js")); +const originals = new Map(); + +let before = 0; +let after = 0; + +for (const file of files) { + const path = join(distDir, file); + const source = await Bun.file(path).text(); + originals.set(file, source); + + const result = await Bun.build({ + entrypoints: [path], + external: ["*"], + target: "node", + minify: { whitespace: true, syntax: true, identifiers: false }, + }); + + if (!result.success) { + console.error(`Failed to minify ${file}:`); + for (const log of result.logs) { + console.error(log); + } + process.exit(1); + } + + const output = result.outputs[0]; + + if (!output) { + console.error(`Minifying ${file} produced no output.`); + process.exit(1); + } + + const minified = await output.text(); + before += source.length; + after += minified.length; + await Bun.write(path, minified); +} + +const broken = await brokenModules(); + +for (const file of broken) { + const original = originals.get(file); + + if (!original) { + console.error(`No original source recorded for broken module ${file}.`); + process.exit(1); + } + + await Bun.write(join(distDir, file), original); + console.warn(`Kept ${file} unminified: the minified output failed to link.`); +} + +if (broken.length > 0) { + const stillBroken = await brokenModules(); + + if (stillBroken.length > 0) { + console.error(`dist modules fail to link even unminified: ${stillBroken.join(", ")}`); + process.exit(1); + } +} + +console.log(`Minified ${files.length} dist modules: ${before} -> ${after} bytes`); + +// Imports every dist module in a real Node process and returns the files that +// fail to load. Importing dist/cli.js with no args just prints help, so a +// throwaway stdout is fine; the verdict travels through a result file. +async function brokenModules(): Promise { + const resultDir = mkdtempSync(join(tmpdir(), "email-sdk-linkcheck-")); + const resultPath = join(resultDir, "result.json"); + const checker = ` + import { writeFileSync } from "node:fs"; + const { files, distUrl, resultPath } = JSON.parse(process.env.LINK_CHECK ?? "{}"); + const failed = []; + for (const file of files) { + try { + await import(new URL(file, distUrl)); + } catch (error) { + failed.push([file, String(error?.message ?? error)]); + } + } + writeFileSync(resultPath, JSON.stringify(failed)); + `; + + try { + const proc = Bun.spawn({ + cmd: ["node", "--input-type=module", "-e", checker], + env: { + ...process.env, + LINK_CHECK: JSON.stringify({ + files, + distUrl: `${pathToFileURL(distDir).href}/`, + resultPath, + }), + }, + stdout: "ignore", + stderr: "pipe", + }); + const [exitCode, stderr] = await Promise.all([ + proc.exited, + new Response(proc.stderr).text(), + ]); + + if (exitCode !== 0) { + console.error(`dist link check crashed:\n${stderr}`); + process.exit(1); + } + + const failed = (await Bun.file(resultPath).json()) as [string, string][]; + + for (const [file, message] of failed) { + console.warn(`link check: ${file} failed to load: ${message}`); + } + + return failed.map(([file]) => file); + } finally { + rmSync(resultDir, { recursive: true, force: true }); + } +} diff --git a/packages/email-sdk/src/cli.ts b/packages/email-sdk/src/cli.ts index b9a4fcf..83c94b0 100644 --- a/packages/email-sdk/src/cli.ts +++ b/packages/email-sdk/src/cli.ts @@ -2,26 +2,9 @@ import { readFile } from "node:fs/promises"; import { basename } from "node:path"; -import { brevo } from "./brevo.js"; -import { assertCloudflareMessage, cloudflare } from "./cloudflare.js"; -import { createEmailClient } from "./core.js"; -import { EmailSdkError } from "./errors.js"; -import { iterable } from "./iterable.js"; -import { loops } from "./loops.js"; -import { mailchimp } from "./mailchimp.js"; -import { mailersend } from "./mailersend.js"; -import { mailgun } from "./mailgun.js"; -import { mailpace } from "./mailpace.js"; -import { mailtrap } from "./mailtrap.js"; -import { plunk } from "./plunk.js"; -import { postmark } from "./postmark.js"; -import { resend } from "./resend.js"; -import { scaleway } from "./scaleway.js"; -import { sequenzy } from "./sequenzy.js"; -import { ses } from "./ses.js"; -import { sendgrid } from "./sendgrid.js"; -import { smtp } from "./smtp.js"; -import { sparkpost } from "./sparkpost.js"; +// The CLI lazily imports adapters, validation helpers, and the client so each +// command only loads and parses the modules it actually uses. Keep this file +// free of static imports of other SDK runtime modules. import type { EmailAttachment, EmailHeader, @@ -29,18 +12,10 @@ import type { EmailProvider, EmailTag, } from "./types.js"; -import { - SUPPORTED_MESSAGE_FIELDS, - arrayify, - assertMaxItems, - assertMessage, - assertSupportedMessageFields, -} from "./utils.js"; -import { assertUnosendMessage, unosend } from "./unosend.js"; -import { zeptomail } from "./zeptomail.js"; +import type { SUPPORTED_MESSAGE_FIELDS } from "./utils.js"; type CliFlags = Record; -type ProviderFactory = (flags: CliFlags) => EmailProvider; +type ProviderFactory = (flags: CliFlags) => Promise; type SupportedAdapterName = keyof typeof SUPPORTED_MESSAGE_FIELDS; type PackageInfo = { name: string; @@ -97,26 +72,30 @@ const providerDocs = [ type ProviderName = (typeof providerDocs)[number]["name"]; const factories = { - resend: (flags) => resend({ apiKey: flagOrEnv(flags, "api-key", "RESEND_API_KEY") }), - postmark: (flags) => - postmark({ + resend: async (flags) => + (await import("./resend.js")).resend({ apiKey: flagOrEnv(flags, "api-key", "RESEND_API_KEY") }), + postmark: async (flags) => + (await import("./postmark.js")).postmark({ serverToken: flagOrEnv(flags, "server-token", "POSTMARK_SERVER_TOKEN"), messageStream: stringFlag(flags, "message-stream") ?? process.env.POSTMARK_MESSAGE_STREAM, }), - sendgrid: (flags) => sendgrid({ apiKey: flagOrEnv(flags, "api-key", "SENDGRID_API_KEY") }), - cloudflare: (flags) => - cloudflare({ + sendgrid: async (flags) => + (await import("./sendgrid.js")).sendgrid({ + apiKey: flagOrEnv(flags, "api-key", "SENDGRID_API_KEY"), + }), + cloudflare: async (flags) => + (await import("./cloudflare.js")).cloudflare({ apiToken: flagOrEnv(flags, "api-token", "CLOUDFLARE_API_TOKEN"), accountId: flagOrEnv(flags, "account-id", "CLOUDFLARE_ACCOUNT_ID"), baseUrl: stringFlag(flags, "base-url") ?? process.env.CLOUDFLARE_BASE_URL, }), - unosend: (flags) => - unosend({ + unosend: async (flags) => + (await import("./unosend.js")).unosend({ apiKey: flagOrEnv(flags, "api-key", "UNOSEND_API_KEY"), baseUrl: stringFlag(flags, "base-url") ?? process.env.UNOSEND_BASE_URL, }), - iterable: (flags) => - iterable({ + iterable: async (flags) => + (await import("./iterable.js")).iterable({ apiKey: flagOrEnv(flags, "api-key", "ITERABLE_API_KEY"), campaignId: numberFlagOrEnv(flags, "campaign-id", "ITERABLE_CAMPAIGN_ID"), baseUrl: stringFlag(flags, "base-url") ?? process.env.ITERABLE_BASE_URL, @@ -125,8 +104,8 @@ const factories = { booleanFlag(flags, "allow-repeat-marketing-sends") ?? booleanEnv("ITERABLE_ALLOW_REPEAT_MARKETING_SENDS"), }), - ses: (flags) => - ses({ + ses: async (flags) => + (await import("./ses.js")).ses({ accessKeyId: flagOrEnv(flags, "access-key-id", "AWS_ACCESS_KEY_ID"), secretAccessKey: flagOrEnv(flags, "secret-access-key", "AWS_SECRET_ACCESS_KEY"), sessionToken: stringFlag(flags, "session-token") ?? process.env.AWS_SESSION_TOKEN, @@ -135,38 +114,58 @@ const factories = { configurationSetName: stringFlag(flags, "configuration-set") ?? process.env.AWS_SES_CONFIGURATION_SET, }), - mailgun: (flags) => - mailgun({ + mailgun: async (flags) => + (await import("./mailgun.js")).mailgun({ apiKey: flagOrEnv(flags, "api-key", "MAILGUN_API_KEY"), domain: flagOrEnv(flags, "domain", "MAILGUN_DOMAIN"), baseUrl: stringFlag(flags, "base-url") ?? process.env.MAILGUN_BASE_URL, }), - mailersend: (flags) => mailersend({ apiKey: flagOrEnv(flags, "api-key", "MAILERSEND_API_KEY") }), - brevo: (flags) => brevo({ apiKey: flagOrEnv(flags, "api-key", "BREVO_API_KEY") }), - mailchimp: (flags) => mailchimp({ apiKey: flagOrEnv(flags, "api-key", "MAILCHIMP_API_KEY") }), - sparkpost: (flags) => sparkpost({ apiKey: flagOrEnv(flags, "api-key", "SPARKPOST_API_KEY") }), - loops: (flags) => - loops({ + mailersend: async (flags) => + (await import("./mailersend.js")).mailersend({ + apiKey: flagOrEnv(flags, "api-key", "MAILERSEND_API_KEY"), + }), + brevo: async (flags) => + (await import("./brevo.js")).brevo({ apiKey: flagOrEnv(flags, "api-key", "BREVO_API_KEY") }), + mailchimp: async (flags) => + (await import("./mailchimp.js")).mailchimp({ + apiKey: flagOrEnv(flags, "api-key", "MAILCHIMP_API_KEY"), + }), + sparkpost: async (flags) => + (await import("./sparkpost.js")).sparkpost({ + apiKey: flagOrEnv(flags, "api-key", "SPARKPOST_API_KEY"), + }), + loops: async (flags) => + (await import("./loops.js")).loops({ apiKey: flagOrEnv(flags, "api-key", "LOOPS_API_KEY"), transactionalId: flagOrEnv(flags, "transactional-id", "LOOPS_TRANSACTIONAL_ID"), }), - sequenzy: (flags) => - sequenzy({ + sequenzy: async (flags) => + (await import("./sequenzy.js")).sequenzy({ apiKey: flagOrEnv(flags, "api-key", "SEQUENZY_API_KEY"), baseUrl: stringFlag(flags, "base-url") ?? process.env.SEQUENZY_BASE_URL, }), - plunk: (flags) => plunk({ apiKey: flagOrEnv(flags, "api-key", "PLUNK_API_KEY") }), - mailtrap: (flags) => mailtrap({ apiKey: flagOrEnv(flags, "api-key", "MAILTRAP_API_KEY") }), - scaleway: (flags) => - scaleway({ + plunk: async (flags) => + (await import("./plunk.js")).plunk({ apiKey: flagOrEnv(flags, "api-key", "PLUNK_API_KEY") }), + mailtrap: async (flags) => + (await import("./mailtrap.js")).mailtrap({ + apiKey: flagOrEnv(flags, "api-key", "MAILTRAP_API_KEY"), + }), + scaleway: async (flags) => + (await import("./scaleway.js")).scaleway({ secretKey: flagOrEnv(flags, "secret-key", "SCALEWAY_SECRET_KEY"), projectId: flagOrEnv(flags, "project-id", "SCALEWAY_PROJECT_ID"), region: stringFlag(flags, "region") ?? process.env.SCALEWAY_REGION, }), - zeptomail: (flags) => zeptomail({ token: flagOrEnv(flags, "token", "ZEPTOMAIL_TOKEN") }), - mailpace: (flags) => mailpace({ apiKey: flagOrEnv(flags, "api-key", "MAILPACE_API_KEY") }), - smtp: (flags) => - smtp({ + zeptomail: async (flags) => + (await import("./zeptomail.js")).zeptomail({ + token: flagOrEnv(flags, "token", "ZEPTOMAIL_TOKEN"), + }), + mailpace: async (flags) => + (await import("./mailpace.js")).mailpace({ + apiKey: flagOrEnv(flags, "api-key", "MAILPACE_API_KEY"), + }), + smtp: async (flags) => + (await import("./smtp.js")).smtp({ host: flagOrEnv(flags, "host", "SMTP_HOST"), port: Number(stringFlag(flags, "port") ?? process.env.SMTP_PORT ?? 587), secure: truthyFlag(flags, "secure") || process.env.SMTP_SECURE === "true", @@ -245,7 +244,7 @@ async function main() { const message = await buildMessage(flags); if (truthyFlag(flags, "dry-run")) { - validateDryRun(providerName, message); + await validateDryRun(providerName, message); console.log( JSON.stringify( { @@ -260,14 +259,15 @@ async function main() { return; } - const provider = createProvider(providerName, flags); + const provider = await createProvider(providerName, flags); + const { createEmailClient } = await import("./core.js"); const client = createEmailClient({ adapters: [provider] }); const response = await client.send(message); console.log(JSON.stringify(response, null, 2)); } -function createProvider(name: string, flags: CliFlags): EmailProvider { +function createProvider(name: string, flags: CliFlags): Promise { if (!isProviderName(name)) { fail(`Unsupported adapter "${name}". Run \`email-sdk adapters\` to see supported adapters.`); } @@ -275,11 +275,14 @@ function createProvider(name: string, flags: CliFlags): EmailProvider { return factories[name](flags); } -function validateDryRun(name: string, message: EmailMessage) { +async function validateDryRun(name: string, message: EmailMessage) { if (!isProviderName(name)) { fail(`Unsupported adapter "${name}". Run \`email-sdk adapters\` to see supported adapters.`); } + const { SUPPORTED_MESSAGE_FIELDS, arrayify, assertMaxItems, assertMessage, assertSupportedMessageFields } = + await import("./utils.js"); + assertMessage(message); assertSupportedMessageFields(name, message, SUPPORTED_MESSAGE_FIELDS[name]); @@ -292,10 +295,12 @@ function validateDryRun(name: string, message: EmailMessage) { } if (name === "cloudflare") { + const { assertCloudflareMessage } = await import("./cloudflare.js"); assertCloudflareMessage(message); } if (name === "unosend") { + const { assertUnosendMessage } = await import("./unosend.js"); assertUnosendMessage(message); } } @@ -677,6 +682,8 @@ function fail(message: string): never { try { await main(); } catch (error) { + const { EmailSdkError } = await import("./errors.js"); + if (error instanceof EmailSdkError) { fail(error.message); } diff --git a/packages/email-sdk/src/core.ts b/packages/email-sdk/src/core.ts index 2e41f8c..37729cf 100644 --- a/packages/email-sdk/src/core.ts +++ b/packages/email-sdk/src/core.ts @@ -84,6 +84,13 @@ export function createEmailClient< } const hooks = [...pluginHooks, ...(options.hooks ? [options.hooks] : [])]; + const sendConfig = { + hookList: hooks, + middleware, + retry: options.retry, + defaultProvider, + fallback: options.fallback, + }; const client: EmailClient = { adapters, providers: adapters, @@ -105,13 +112,7 @@ export function createEmailClient< return sendWithAdapters({ adapters, message, - options: { - hookList: hooks, - middleware, - retry: options.retry, - defaultProvider, - fallback: options.fallback, - }, + options: sendConfig, sendOptions, }); }, @@ -183,10 +184,13 @@ async function sendWithAdapters(input: { }; sendOptions?: SendOptions; }): Promise { - const prepared = await applyBeforeSendMiddleware(input.options.middleware, { - message: input.message, - options: input.sendOptions, - }); + const prepared = + input.options.middleware.length === 0 + ? { message: input.message, options: input.sendOptions } + : await applyBeforeSendMiddleware(input.options.middleware, { + message: input.message, + options: input.sendOptions, + }); assertMessage(prepared.message); @@ -214,10 +218,10 @@ async function sendWithAdapters(input: { message: prepared.message, hookList: input.options.hookList, middleware: input.options.middleware, - retry: { - ...input.options.retry, - retries: prepared.options?.retries ?? input.options.retry?.retries, - }, + retry: + prepared.options?.retries === undefined + ? input.options.retry + : { ...input.options.retry, retries: prepared.options.retries }, sendOptions: prepared.options, }); } catch (error) { @@ -258,13 +262,18 @@ async function sendWithRetry(input: { const shouldRetry = input.retry?.shouldRetry ?? isRetryableEmailError; const delayFor = input.retry?.delay ?? defaultDelay; + const hasHooks = input.hookList.length > 0; + const hasMiddleware = input.middleware.length > 0; + for (let attempt = 1; attempt <= retries + 1; attempt += 1) { - await invokeHooks(input.hookList, "beforeSend", { - provider: input.provider.name, - message: input.message, - attempt, - metadata: input.sendOptions?.metadata, - }); + if (hasHooks) { + await invokeHooks(input.hookList, "beforeSend", { + provider: input.provider.name, + message: input.message, + attempt, + metadata: input.sendOptions?.metadata, + }); + } try { const response = await input.provider.send(input.message, { @@ -274,21 +283,22 @@ async function sendWithRetry(input: { metadata: input.sendOptions?.metadata, }); - const normalizedResponse = { - ...response, - provider: response.provider || input.provider.name, - }; - - const afterEvent = { - provider: input.provider.name, - message: input.message, - attempt, - metadata: input.sendOptions?.metadata, - response: normalizedResponse, - }; - - await invokeAfterSendMiddleware(input.middleware, afterEvent); - await invokeHooks(input.hookList, "afterSend", afterEvent); + const normalizedResponse = response.provider + ? response + : { ...response, provider: input.provider.name }; + + if (hasHooks || hasMiddleware) { + const afterEvent = { + provider: input.provider.name, + message: input.message, + attempt, + metadata: input.sendOptions?.metadata, + response: normalizedResponse, + }; + + await invokeAfterSendMiddleware(input.middleware, afterEvent); + await invokeHooks(input.hookList, "afterSend", afterEvent); + } return normalizedResponse; } catch (error) { @@ -467,7 +477,11 @@ async function invokeHooks( } function resolveAdapterOrder(input: { adapter: string; fallbackAdapters?: string[] }) { - return [input.adapter, ...(input.fallbackAdapters ?? [])].filter((adapter, index, adapters) => { + if (!input.fallbackAdapters || input.fallbackAdapters.length === 0) { + return [input.adapter]; + } + + return [input.adapter, ...input.fallbackAdapters].filter((adapter, index, adapters) => { return adapters.indexOf(adapter) === index; }); } diff --git a/packages/email-sdk/src/http.ts b/packages/email-sdk/src/http.ts index 7b04a5c..9be2cbd 100644 --- a/packages/email-sdk/src/http.ts +++ b/packages/email-sdk/src/http.ts @@ -15,18 +15,21 @@ export type JsonProviderOptions = { export function jsonProvider>( options: JsonProviderOptions, ): EmailProvider<{ baseUrl: string }> { + const url = `${options.baseUrl}${options.endpoint}`; + const headers = { + "Content-Type": "application/json", + ...options.headers, + }; + return { name: options.name, raw: { baseUrl: options.baseUrl }, async send(message, context) { const fetcher = options.fetch ?? fetch; - const response = await fetcher(`${options.baseUrl}${options.endpoint}`, { + const response = await fetcher(url, { method: "POST", signal: context.signal, - headers: { - "Content-Type": "application/json", - ...options.headers, - }, + headers, body: JSON.stringify(await options.buildPayload(message)), }); diff --git a/packages/email-sdk/src/ses.ts b/packages/email-sdk/src/ses.ts index 7205d12..c8bbf00 100644 --- a/packages/email-sdk/src/ses.ts +++ b/packages/email-sdk/src/ses.ts @@ -30,6 +30,7 @@ export function ses( options: SesProviderOptions, ): EmailProvider<{ baseUrl: string; region: string }> { const baseUrl = options.baseUrl ?? `https://email.${options.region}.amazonaws.com`; + const signingKeyCache: SigningKeyCache = {}; return { name: "ses", @@ -50,6 +51,7 @@ export function ses( headers: { "content-type": "application/json", }, + signingKeyCache, }); const response = await fetcher(endpoint, { @@ -139,6 +141,13 @@ async function toSesPayload(message: EmailMessage, options: SesProviderOptions) }; } +type SigningKeyCache = { + value?: { + dateStamp: string; + key: Uint8Array; + }; +}; + async function signAwsRequest(input: { accessKeyId: string; secretAccessKey: string; @@ -149,6 +158,7 @@ async function signAwsRequest(input: { url: URL; body: string; headers: Record; + signingKeyCache: SigningKeyCache; }) { const now = new Date(); const amzDate = toAmzDate(now); @@ -182,13 +192,20 @@ async function signAwsRequest(input: { credentialScope, await sha256Hex(canonicalRequest), ].join("\n"); - const signingKey = await getSigningKey( - input.secretAccessKey, - dateStamp, - input.region, - input.service, - ); - const signature = await hmacHex(signingKey, stringToSign); + // The SigV4 signing key only depends on the secret key, UTC date, region, and + // service. Region, service, and secret are fixed per provider instance, so the + // four-step HMAC derivation only needs to rerun when the UTC date rolls over. + let cached = input.signingKeyCache.value; + + if (!cached || cached.dateStamp !== dateStamp) { + cached = { + dateStamp, + key: await getSigningKey(input.secretAccessKey, dateStamp, input.region, input.service), + }; + input.signingKeyCache.value = cached; + } + + const signature = await hmacHex(cached.key, stringToSign); return { ...requestHeaders, @@ -255,8 +272,10 @@ async function getSigningKey( return hmac(serviceKey, "aws4_request"); } +const textEncoder = new TextEncoder(); + function textBytes(value: string) { - return new TextEncoder().encode(value); + return textEncoder.encode(value); } function toArrayBuffer(bytes: Uint8Array): ArrayBuffer { diff --git a/packages/email-sdk/tsconfig.json b/packages/email-sdk/tsconfig.json index 5f4455b..6af9da6 100644 --- a/packages/email-sdk/tsconfig.json +++ b/packages/email-sdk/tsconfig.json @@ -4,7 +4,7 @@ "rootDir": "src", "outDir": "dist", "declaration": true, - "declarationMap": true, + "declarationMap": false, "emitDeclarationOnly": false, "module": "NodeNext", "moduleResolution": "NodeNext",