Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions .changeset/lazy-cameras-shake.md
Original file line number Diff line number Diff line change
@@ -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.
2 changes: 1 addition & 1 deletion bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

12 changes: 9 additions & 3 deletions packages/convex-email/src/component/lib.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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) {
Expand All @@ -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", {
Expand Down
2 changes: 1 addition & 1 deletion packages/email-sdk/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
133 changes: 133 additions & 0 deletions packages/email-sdk/scripts/minify-dist.ts
Original file line number Diff line number Diff line change
@@ -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<string, string>();

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<string[]> {
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 });
}
}
Loading