diff --git a/packages/react-email/package.json b/packages/react-email/package.json index fa7b30c6f2..f7743eb6ae 100644 --- a/packages/react-email/package.json +++ b/packages/react-email/package.json @@ -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": { diff --git a/packages/react-email/src/commands/export-worker.ts b/packages/react-email/src/commands/export-worker.ts new file mode 100644 index 0000000000..3dd08c187b --- /dev/null +++ b/packages/react-email/src/commands/export-worker.ts @@ -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; +} + +async function renderTemplate(input: WorkerInput): Promise { + 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, + ) => Promise; + 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); +} diff --git a/packages/react-email/src/commands/export.ts b/packages/react-email/src/commands/export.ts index 42d8e1db81..6c3443db02 100644 --- a/packages/react-email/src/commands/export.ts +++ b/packages/react-email/src/commands/export.ts @@ -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'; @@ -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, @@ -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` @@ -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, - ) => Promise; - 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((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'); diff --git a/packages/react-email/src/commands/testing/.gitignore b/packages/react-email/src/commands/testing/functional/.gitignore similarity index 100% rename from packages/react-email/src/commands/testing/.gitignore rename to packages/react-email/src/commands/testing/functional/.gitignore diff --git a/packages/react-email/src/commands/testing/emails/vercel-invite-user.tsx b/packages/react-email/src/commands/testing/functional/emails/vercel-invite-user.tsx similarity index 100% rename from packages/react-email/src/commands/testing/emails/vercel-invite-user.tsx rename to packages/react-email/src/commands/testing/functional/emails/vercel-invite-user.tsx diff --git a/packages/react-email/src/commands/testing/export.spec.ts b/packages/react-email/src/commands/testing/functional/export.spec.ts similarity index 99% rename from packages/react-email/src/commands/testing/export.spec.ts rename to packages/react-email/src/commands/testing/functional/export.spec.ts index 0126a4dcd3..48b0de88e8 100644 --- a/packages/react-email/src/commands/testing/export.spec.ts +++ b/packages/react-email/src/commands/testing/functional/export.spec.ts @@ -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'); diff --git a/packages/react-email/src/commands/testing/stress/.gitignore b/packages/react-email/src/commands/testing/stress/.gitignore new file mode 100644 index 0000000000..d133599749 --- /dev/null +++ b/packages/react-email/src/commands/testing/stress/.gitignore @@ -0,0 +1,3 @@ +# for the `email export` command +out +emails diff --git a/packages/react-email/src/commands/testing/stress/export.spec.ts b/packages/react-email/src/commands/testing/stress/export.spec.ts new file mode 100644 index 0000000000..53ab10316a --- /dev/null +++ b/packages/react-email/src/commands/testing/stress/export.spec.ts @@ -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); +}); diff --git a/packages/react-email/src/commands/testing/stress/src/vercel-invite-user.tsx b/packages/react-email/src/commands/testing/stress/src/vercel-invite-user.tsx new file mode 100644 index 0000000000..4e5877b5c6 --- /dev/null +++ b/packages/react-email/src/commands/testing/stress/src/vercel-invite-user.tsx @@ -0,0 +1,160 @@ +import { + Body, + Button, + Column, + Container, + Head, + Heading, + Hr, + Html, + Img, + Link, + Preview, + pixelBasedPreset, + Row, + Section, + Tailwind, + Text, +} from '@react-email/components'; + +interface VercelInviteUserEmailProps { + username?: string; + userImage?: string; + invitedByUsername?: string; + invitedByEmail?: string; + teamName?: string; + teamImage?: string; + inviteLink?: string; + inviteFromIp?: string; + inviteFromLocation?: string; +} + +const baseUrl = process.env.VERCEL_URL + ? `https://${process.env.VERCEL_URL}` + : ''; + +export const VercelInviteUserEmail = ({ + username, + userImage, + invitedByUsername, + invitedByEmail, + teamName, + teamImage, + inviteLink, + inviteFromIp, + inviteFromLocation, +}: VercelInviteUserEmailProps) => { + const previewText = `Join ${invitedByUsername} on Vercel`; + + return ( + + + + + {previewText} + +
+ Vercel Logo +
+ + Join {teamName} on Vercel + + + Hello {username}, + + + {invitedByUsername} ( + + {invitedByEmail} + + ) has invited you to the {teamName} team on{' '} + Vercel. + +
+ + + {`${username}'s + + + Arrow indicating invitation + + + {`${teamName} + + +
+
+ +
+ + or copy and paste this URL into your browser:{' '} + + {inviteLink} + + +
+ + This invitation was intended for{' '} + {username}. This invite was + sent from {inviteFromIp}{' '} + located in{' '} + {inviteFromLocation}. If you + were not expecting this invitation, you can ignore this email. If + you are concerned about your account's safety, please reply to + this email to get in touch with us. + +
+ +
+ + ); +}; + +VercelInviteUserEmail.PreviewProps = { + username: 'alanturing', + userImage: `${baseUrl}/static/vercel-user.png`, + invitedByUsername: 'Alan', + invitedByEmail: 'alan.turing@example.com', + teamName: 'Enigma', + teamImage: `${baseUrl}/static/vercel-team.png`, + inviteLink: 'https://vercel.com', + inviteFromIp: '204.13.186.218', + inviteFromLocation: 'São Paulo, Brazil', +} as VercelInviteUserEmailProps; + +export default VercelInviteUserEmail; diff --git a/packages/react-email/tsdown.config.ts b/packages/react-email/tsdown.config.ts index 2102dbfa15..1a18e2d021 100644 --- a/packages/react-email/tsdown.config.ts +++ b/packages/react-email/tsdown.config.ts @@ -2,7 +2,7 @@ import { defineConfig } from 'tsdown'; export default defineConfig({ dts: false, - entry: ['./src/index.ts'], + entry: ['./src/index.ts', './src/commands/export-worker.ts'], format: ['esm'], outDir: 'dist', }); diff --git a/packages/react-email/vitest.config.ts b/packages/react-email/vitest.config.ts index bf6076a810..2799f50bae 100644 --- a/packages/react-email/vitest.config.ts +++ b/packages/react-email/vitest.config.ts @@ -4,6 +4,20 @@ export default defineConfig({ test: { globals: true, environment: 'happy-dom', + projects: [ + { + test: { + name: 'functional', + include: ['./**/functional/**/*.spec.ts'], + }, + }, + { + test: { + name: 'stress', + include: ['./**/stress/**/*.spec.ts'], + }, + }, + ], }, esbuild: { tsconfigRaw: {