Skip to content
Draft
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
5 changes: 3 additions & 2 deletions packages/react-email/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,9 @@
"build": "tsdown",
"build:watch": "tsdown --watch src",
"clean": "rm -rf dist",
"test": "vitest run",
"test:watch": "vitest"
"test": "vitest run --project functional",
"test:watch": "vitest",
"test:stress": "vitest "
},
"license": "MIT",
"repository": {
Expand Down
95 changes: 95 additions & 0 deletions packages/react-email/src/commands/export-worker.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
#!/usr/bin/env node

/**
* Worker script to render a single email template in a separate process.
* This helps isolate memory usage and prevents OOM errors when processing many templates.
*/

import fs from 'node:fs';
import { createRequire } from 'node:module';
import type React from 'react';

const require = createRequire(import.meta.url);

interface WorkerInput {
templatePath: string;
htmlPath: string;
options: Record<string, unknown>;
}

async function renderTemplate(input: WorkerInput): Promise<void> {
try {
// Clear require cache to ensure fresh module load
delete require.cache[input.templatePath];

const emailModule = require(input.templatePath) as {
default: React.FC;
render: (
element: React.ReactElement,
options: Record<string, unknown>,
) => Promise<string>;
reactEmailCreateReactElement: typeof React.createElement;
};

const rendered = await emailModule.render(
emailModule.reactEmailCreateReactElement(emailModule.default, {}),
input.options,
);

// Write the rendered HTML to file
fs.writeFileSync(input.htmlPath, rendered);

// Delete the .cjs file after successful render
fs.unlinkSync(input.templatePath);

// Send success message to parent process
process.stdout.write(
JSON.stringify({ success: true, templatePath: input.templatePath }),
);
} catch (error) {
// Send error message to parent process
process.stderr.write(
JSON.stringify({
success: false,
templatePath: input.templatePath,
error: error instanceof Error ? error.message : String(error),
}),
);
process.exit(1);
}
}

// Read input from command line arguments or stdin
const inputData = process.argv[2];

if (!inputData) {
process.stderr.write(
JSON.stringify({
success: false,
error: 'No input data provided',
}),
);
process.exit(1);
}

try {
const input: WorkerInput = JSON.parse(inputData);
renderTemplate(input).catch((error) => {
process.stderr.write(
JSON.stringify({
success: false,
templatePath: input.templatePath,
error: error instanceof Error ? error.message : String(error),
}),
);
process.exit(1);
});
} catch (error) {
process.stderr.write(
JSON.stringify({
success: false,
error: `Failed to parse input: ${error instanceof Error ? error.message : String(error)}`,
}),
);
process.exit(1);
}
157 changes: 118 additions & 39 deletions packages/react-email/src/commands/export.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import fs, { unlinkSync, writeFileSync } from 'node:fs';
import { createRequire } from 'node:module';
import { spawn } from 'node:child_process';
import fs from 'node:fs';
import path from 'node:path';
import url from 'node:url';
import type { Options } from '@react-email/components';
Expand All @@ -8,7 +8,6 @@ import { glob } from 'glob';
import logSymbols from 'log-symbols';
import normalize from 'normalize-path';
import ora, { type Ora } from 'ora';
import type React from 'react';
import { renderingUtilitiesExporter } from '../utils/esbuild/renderring-utilities-exporter.js';
import {
type EmailsDirectory,
Expand All @@ -35,8 +34,7 @@ type ExportTemplatesOptions = Options & {
};

const filename = url.fileURLToPath(import.meta.url);

const require = createRequire(filename);
const dirname = path.dirname(filename);

/*
This first builds all the templates using esbuild and then puts the output in the `.js`
Expand Down Expand Up @@ -115,41 +113,122 @@ export const exportTemplates = async (
},
);

for await (const template of allBuiltTemplates) {
try {
if (spinner) {
spinner.text = `rendering ${template.split('/').pop()}`;
spinner.render();
}
delete require.cache[template];
const emailModule = require(template) as {
default: React.FC;
render: (
element: React.ReactElement,
options: Record<string, unknown>,
) => Promise<string>;
reactEmailCreateReactElement: typeof React.createElement;
};
const rendered = await emailModule.render(
emailModule.reactEmailCreateReactElement(emailModule.default, {}),
options,
);
const htmlPath = template.replace(
'.cjs',
options.plainText ? '.txt' : '.html',
);
writeFileSync(htmlPath, rendered);
unlinkSync(template);
} catch (exception) {
if (spinner) {
spinner.stopAndPersist({
symbol: logSymbols.error,
text: `failed when rendering ${template.split('/').pop()}`,
});
}
console.error(exception);
process.exit(1);
// Render templates in separate processes to avoid memory issues
// The worker script is compiled to dist/commands/export-worker.js
const workerScriptPath = path.join(
dirname,
'..',
'..',
'dist',
'commands',
'export-worker.js',
);
const totalTemplates = allBuiltTemplates.length;

for (let i = 0; i < allBuiltTemplates.length; i++) {
const template = allBuiltTemplates[i];
if (!template) continue;

const templateName = template.split('/').pop() || 'unknown';

if (spinner) {
spinner.text = `Rendering ${templateName} (${i + 1}/${totalTemplates})...`;
spinner.render();
}

const htmlPath = template.replace(
'.cjs',
options.plainText ? '.txt' : '.html',
);

const workerInput = {
templatePath: template,
htmlPath,
options,
};

await new Promise<void>((resolve, reject) => {
// Spawn worker process using the compiled JavaScript file
const worker = spawn(
process.execPath,
[workerScriptPath, JSON.stringify(workerInput)],
{
cwd: process.cwd(),
stdio: ['ignore', 'pipe', 'pipe'],
},
);

let stdout = '';
let stderr = '';

worker.stdout?.on('data', (data) => {
stdout += data.toString();
});

worker.stderr?.on('data', (data) => {
stderr += data.toString();
});

worker.on('close', (code) => {
if (code === 0) {
// Try to parse success message
try {
const lines = stdout.trim().split('\n');
const lastLine = lines[lines.length - 1];
if (lastLine) {
const result = JSON.parse(lastLine);
if (result.success) {
resolve();
return;
}
}
} catch {
// If parsing fails but exit code is 0, assume success
resolve();
return;
}
resolve();
} else {
// Try to parse error message
let errorMessage = `Worker process exited with code ${code}`;
try {
const lines = stderr.trim().split('\n');
const lastLine = lines[lines.length - 1];
if (lastLine) {
const error = JSON.parse(lastLine);
if (error.error) {
errorMessage = error.error;
}
}
} catch {
// Use stderr as fallback
if (stderr) {
errorMessage = stderr;
}
}

if (spinner) {
spinner.stopAndPersist({
symbol: logSymbols.error,
text: `Failed when rendering ${templateName}`,
});
}
console.error(errorMessage);
reject(new Error(errorMessage));
}
});

worker.on('error', (error) => {
if (spinner) {
spinner.stopAndPersist({
symbol: logSymbols.error,
text: `Failed to spawn worker for ${templateName}`,
});
}
console.error(`Failed to spawn worker: ${error.message}`);
reject(error);
});
});
}
if (spinner) {
spinner.succeed('Rendered all files');
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import fs from 'node:fs';
import path from 'node:path';
import { exportTemplates } from '../export.js';
import { expect, test } from 'vitest';
import { exportTemplates } from '../../export.js';

test('email export', { retry: 3 }, async () => {
const pathToEmailsDirectory = path.resolve(__dirname, './emails');
Expand Down
3 changes: 3 additions & 0 deletions packages/react-email/src/commands/testing/stress/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
# for the `email export` command
out
emails
29 changes: 29 additions & 0 deletions packages/react-email/src/commands/testing/stress/export.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import fs from 'node:fs';
import path from 'node:path';
import { expect, test } from 'vitest';
import { exportTemplates } from '../../export.js';

test('email export', async () => {
const emailsQuantity = 100;
const emailsDir = path.join(__dirname, 'emails');
const srcDir = path.join(__dirname, 'src');
const outDir = path.join(__dirname, 'out');

fs.rmSync(emailsDir, { force: true });

const templates = fs.readdirSync(srcDir).filter((file) =>
file.endsWith('.tsx'),
);

for (let i = 0; i < emailsQuantity; i++) {
const template = String(templates[i % templates.length]);
const { name } = path.parse(template);
const source = path.join(srcDir, template);
const destination = path.join(emailsDir, `${i}_${name}.tsx`);
fs.cpSync(source, destination);
}

await exportTemplates(outDir, emailsDir, { silent: true, pretty: true });

expect(fs.existsSync(outDir)).toBe(true);
});
Loading
Loading