react-email-rails 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/bin/config.mjs ADDED
@@ -0,0 +1,19 @@
1
+ #!/usr/bin/env node
2
+ import { loadConfigFromFile } from "vite"
3
+
4
+ const loaded = await loadConfigFromFile({ command: "serve", mode: process.env.NODE_ENV ?? "development" })
5
+ const plugins = (loaded?.config?.plugins ?? []).flat(Infinity).filter(Boolean)
6
+ const plugin = plugins.find((plugin) => plugin.name === "react-email-rails")
7
+ const metadata = plugin?.[Symbol.for("react-email-rails.config")]
8
+
9
+ if (!plugin) {
10
+ process.stderr.write("react-email-rails: reactEmailRails() plugin not found in the Vite config\n")
11
+ process.exit(1)
12
+ }
13
+
14
+ if (!metadata) {
15
+ process.stderr.write("react-email-rails: reactEmailRails() plugin metadata not found\n")
16
+ process.exit(1)
17
+ }
18
+
19
+ process.stdout.write(JSON.stringify(metadata))
package/bin/dev.mjs ADDED
@@ -0,0 +1,63 @@
1
+ #!/usr/bin/env node
2
+ import { createServer, isRunnableDevEnvironment, loadConfigFromFile } from "vite"
3
+ import { RENDER_PROTOCOL_VERSION, VERSION } from "../dist/version.js"
4
+
5
+ if (process.argv.includes("--health")) {
6
+ process.stdout.write(JSON.stringify({ ok: true, protocolVersion: RENDER_PROTOCOL_VERSION, packageVersion: VERSION }))
7
+ process.exit(0)
8
+ }
9
+
10
+ const toStderr = (message) => process.stderr.write(`${message}\n`)
11
+ const logger = {
12
+ info: toStderr,
13
+ warn: toStderr,
14
+ warnOnce: toStderr,
15
+ error: toStderr,
16
+ clearScreen() {},
17
+ hasErrorLogged: () => false,
18
+ hasWarned: false,
19
+ }
20
+
21
+ // Load only this plugin and aliases; host dev-server plugins have global side effects.
22
+ const loaded = await loadConfigFromFile({ command: "serve", mode: "development" })
23
+ const userConfig = loaded?.config ?? {}
24
+ const emailPlugin = (userConfig.plugins ?? [])
25
+ .flat(Infinity)
26
+ .find((plugin) => plugin?.name === "react-email-rails")
27
+
28
+ if (!emailPlugin) {
29
+ process.stderr.write("react-email-rails: reactEmailRails() plugin not found in the Vite config\n")
30
+ process.exit(1)
31
+ }
32
+
33
+ // Forward config that affects how components resolve and compile (but not the
34
+ // host's dev-server plugins, which have global side effects), so dev rendering
35
+ // stays close to the production email bundle.
36
+ const server = await createServer({
37
+ configFile: false,
38
+ resolve: userConfig.resolve,
39
+ define: userConfig.define,
40
+ css: userConfig.css,
41
+ esbuild: { jsx: "automatic" },
42
+ plugins: [emailPlugin],
43
+ server: { middlewareMode: true },
44
+ appType: "custom",
45
+ clearScreen: false,
46
+ customLogger: logger,
47
+ })
48
+
49
+ // Render through the same `email` environment the production build uses, so dev
50
+ // and build resolve and compile components identically.
51
+ const environment = server.environments.email
52
+ if (!isRunnableDevEnvironment(environment)) {
53
+ await server.close()
54
+ process.stderr.write("react-email-rails: the email environment is not runnable\n")
55
+ process.exit(1)
56
+ }
57
+
58
+ try {
59
+ const { run } = await environment.runner.import("virtual:react-email-rails/server")
60
+ await run()
61
+ } finally {
62
+ await server.close()
63
+ }
@@ -0,0 +1,14 @@
1
+ import type { Plugin } from "vite";
2
+ export type EmailsOption = string | {
3
+ path?: string;
4
+ extension?: string | string[];
5
+ ignore?: string | string[];
6
+ };
7
+ export type ReactEmailRailsOptions = {
8
+ emails?: EmailsOption;
9
+ standalone?: boolean;
10
+ };
11
+ export declare const EMAIL_ENVIRONMENT = "email";
12
+ export declare function reactEmailRails(options?: ReactEmailRailsOptions): Plugin;
13
+ export type { EmailModule, EmailRegistry, EmailRenderOptions, RenderedEmail, RenderRequest, } from "./runtime.js";
14
+ export { RENDER_PROTOCOL_VERSION, VERSION } from "./version.js";
package/dist/index.js ADDED
@@ -0,0 +1,103 @@
1
+ const DEFAULT_IGNORE = ["**/_*", "**/_*/**"];
2
+ const DEFAULT_EXTENSIONS = [".tsx", ".jsx"];
3
+ const VIRTUAL_SERVER = "virtual:react-email-rails/server";
4
+ const VIRTUAL_MAIN = "virtual:react-email-rails/main";
5
+ const RESOLVED_SERVER = `\0${VIRTUAL_SERVER}`;
6
+ const RESOLVED_MAIN = `\0${VIRTUAL_MAIN}`;
7
+ // The dedicated build environment that emits the server-side email bundle.
8
+ export const EMAIL_ENVIRONMENT = "email";
9
+ const CONFIG_SYMBOL = Symbol.for("react-email-rails.config");
10
+ const OUT_DIR = "tmp/react-email-rails";
11
+ const BUNDLE_FILE = "emails.js";
12
+ export function reactEmailRails(options = {}) {
13
+ const emails = typeof options.emails === "string" ? { path: options.emails } : (options.emails ?? {});
14
+ const path = (emails.path ?? "app/javascript/emails").replace(/^\/|\/$/g, "");
15
+ const rawExtensions = emails.extension === undefined
16
+ ? DEFAULT_EXTENSIONS
17
+ : Array.isArray(emails.extension)
18
+ ? emails.extension
19
+ : [emails.extension];
20
+ const extensions = rawExtensions
21
+ .map((extension) => (extension.startsWith(".") ? extension : `.${extension}`))
22
+ .map((extension, index) => ({ extension, index }))
23
+ .sort((left, right) => right.extension.length - left.extension.length || left.index - right.index)
24
+ .map(({ extension }) => extension);
25
+ const standalone = options.standalone ?? true;
26
+ const root = `/${path}/`;
27
+ const pattern = extensions.length === 1 ? `${root}**/*${extensions[0]}` : `${root}**/*{${extensions.join(",")}}`;
28
+ const ignore = emails.ignore === undefined
29
+ ? DEFAULT_IGNORE
30
+ : Array.isArray(emails.ignore)
31
+ ? emails.ignore
32
+ : [emails.ignore];
33
+ const globPatterns = [pattern, ...ignore.map((glob) => `!${root}${glob}`)];
34
+ const globArg = JSON.stringify(globPatterns.length === 1 ? globPatterns[0] : globPatterns);
35
+ const plugin = {
36
+ name: "react-email-rails",
37
+ resolveId(id) {
38
+ if (id === VIRTUAL_SERVER)
39
+ return RESOLVED_SERVER;
40
+ if (id === VIRTUAL_MAIN)
41
+ return RESOLVED_MAIN;
42
+ },
43
+ load(id) {
44
+ if (id === RESOLVED_SERVER) {
45
+ return [
46
+ `import { serve, toComponentName } from "react-email-rails/runtime"`,
47
+ `const modules = import.meta.glob(${globArg})`,
48
+ `const extensions = ${JSON.stringify(extensions)}`,
49
+ `const registry = Object.create(null)`,
50
+ `for (const path in modules) {`,
51
+ ` const extension = extensions.find((extension) => path.endsWith(extension)) ?? path.slice(path.lastIndexOf("."))`,
52
+ ` registry[toComponentName(path, ${JSON.stringify(root)}, extension)] = modules[path]`,
53
+ `}`,
54
+ `export const run = () => serve(registry)`,
55
+ ].join("\n");
56
+ }
57
+ if (id === RESOLVED_MAIN) {
58
+ return `import { run } from ${JSON.stringify(VIRTUAL_SERVER)}\nrun()\n`;
59
+ }
60
+ },
61
+ config() {
62
+ // Register a dedicated `email` build environment and opt the app into
63
+ // building all environments, so a plain `vite build` emits the email
64
+ // bundle alongside the client assets — no separate build step required.
65
+ // The environment is a server consumer. Standalone builds inline Node
66
+ // dependencies by default so Rails runtime images do not need node_modules.
67
+ return {
68
+ builder: {},
69
+ environments: {
70
+ [EMAIL_ENVIRONMENT]: {
71
+ ...(standalone ? { resolve: { noExternal: true } } : {}),
72
+ build: {
73
+ ssr: true,
74
+ outDir: OUT_DIR,
75
+ emptyOutDir: true,
76
+ rollupOptions: {
77
+ input: VIRTUAL_MAIN,
78
+ output: { entryFileNames: BUNDLE_FILE },
79
+ },
80
+ },
81
+ },
82
+ },
83
+ };
84
+ },
85
+ };
86
+ const metadata = {
87
+ emails: {
88
+ path,
89
+ extensions,
90
+ ignore,
91
+ },
92
+ standalone,
93
+ outDir: OUT_DIR,
94
+ bundleFile: BUNDLE_FILE,
95
+ };
96
+ Object.defineProperty(plugin, CONFIG_SYMBOL, {
97
+ value: {
98
+ ...metadata,
99
+ },
100
+ });
101
+ return plugin;
102
+ }
103
+ export { RENDER_PROTOCOL_VERSION, VERSION } from "./version.js";
@@ -0,0 +1,26 @@
1
+ import { type Options as ReactEmailRenderOptions } from "@react-email/render";
2
+ import React from "react";
3
+ export type EmailModule = {
4
+ default: React.ComponentType<Record<string, unknown>>;
5
+ };
6
+ export type EmailLoader = EmailModule | (() => Promise<EmailModule>);
7
+ export type EmailRegistry = Record<string, EmailLoader>;
8
+ export type RenderRequest = {
9
+ component: string;
10
+ props?: Record<string, unknown>;
11
+ renderOptions?: EmailRenderOptions;
12
+ };
13
+ export type HealthRequest = {
14
+ health: true;
15
+ };
16
+ export type RenderedEmail = {
17
+ html: string;
18
+ text: string;
19
+ };
20
+ export type EmailRenderOptions = {
21
+ html?: ReactEmailRenderOptions;
22
+ text?: ReactEmailRenderOptions;
23
+ };
24
+ export declare function toComponentName(globPath: string, root: string, extension: string): string;
25
+ export declare function renderEmail(request: RenderRequest, registry: EmailRegistry): Promise<RenderedEmail>;
26
+ export declare function serve(registry: EmailRegistry): Promise<void>;
@@ -0,0 +1,98 @@
1
+ import { render } from "@react-email/render";
2
+ import React from "react";
3
+ import { RENDER_PROTOCOL_VERSION, VERSION } from "./version.js";
4
+ export function toComponentName(globPath, root, extension) {
5
+ const start = globPath.lastIndexOf(root) + root.length;
6
+ return globPath.slice(start, globPath.length - extension.length);
7
+ }
8
+ export async function renderEmail(request, registry) {
9
+ const loader = registry[request.component];
10
+ if (!loader)
11
+ throw new Error(`React email component not found: ${request.component}`);
12
+ const mod = typeof loader === "function" ? await loader() : loader;
13
+ const element = React.createElement(mod.default, request.props ?? {});
14
+ // @react-email/render re-renders the tree per call, so html and text are two passes.
15
+ return {
16
+ html: await render(element, { ...request.renderOptions?.html, plainText: false }),
17
+ text: await render(element, { ...request.renderOptions?.text, plainText: true }),
18
+ };
19
+ }
20
+ export async function serve(registry) {
21
+ if (process.argv.includes("--persistent")) {
22
+ await servePersistent(registry, isolateStdout());
23
+ return;
24
+ }
25
+ if (process.argv.includes("--health")) {
26
+ process.stdout.write(JSON.stringify(okResponse()));
27
+ return;
28
+ }
29
+ const write = isolateStdout();
30
+ try {
31
+ const request = JSON.parse(await readStdin());
32
+ write(JSON.stringify({ ...(await renderEmail(request, registry)), ...protocolMetadata() }));
33
+ }
34
+ catch (error) {
35
+ process.stderr.write(error instanceof Error ? error.message : "React Email render failed");
36
+ process.exitCode = 1;
37
+ }
38
+ }
39
+ // Reserve stdout for the JSON render protocol. Stray writes from email components
40
+ // or their dependencies — including console.log, which Node routes through
41
+ // process.stdout.write — are diverted to stderr so they cannot corrupt or desync
42
+ // a response frame. Returns the writer to use for protocol output.
43
+ function isolateStdout() {
44
+ const protocolWrite = process.stdout.write.bind(process.stdout);
45
+ process.stdout.write = ((chunk) => process.stderr.write(chunk));
46
+ return (chunk) => protocolWrite(chunk);
47
+ }
48
+ function readStdin() {
49
+ return new Promise((resolve, reject) => {
50
+ let data = "";
51
+ process.stdin.setEncoding("utf8");
52
+ process.stdin.on("data", (chunk) => {
53
+ data += chunk;
54
+ });
55
+ process.stdin.on("end", () => resolve(data));
56
+ process.stdin.on("error", reject);
57
+ });
58
+ }
59
+ async function servePersistent(registry, write) {
60
+ process.stdin.setEncoding("utf8");
61
+ let pending = "";
62
+ for await (const chunk of process.stdin) {
63
+ pending += chunk;
64
+ let separator = pending.indexOf("\n");
65
+ while (separator !== -1) {
66
+ const line = pending.slice(0, separator);
67
+ pending = pending.slice(separator + 1);
68
+ if (line.trim())
69
+ await writePersistentResponse(line, registry, write);
70
+ separator = pending.indexOf("\n");
71
+ }
72
+ }
73
+ }
74
+ async function writePersistentResponse(line, registry, write) {
75
+ try {
76
+ const request = JSON.parse(line);
77
+ if ("health" in request) {
78
+ write(`${JSON.stringify(okResponse())}\n`);
79
+ return;
80
+ }
81
+ write(`${JSON.stringify({ ok: true, ...(await renderEmail(request, registry)), ...protocolMetadata() })}\n`);
82
+ }
83
+ catch (error) {
84
+ write(`${JSON.stringify({
85
+ ok: false,
86
+ error: error instanceof Error ? error.message : "React Email render failed",
87
+ })}\n`);
88
+ }
89
+ }
90
+ function okResponse() {
91
+ return { ok: true, ...protocolMetadata() };
92
+ }
93
+ function protocolMetadata() {
94
+ return {
95
+ protocolVersion: RENDER_PROTOCOL_VERSION,
96
+ packageVersion: VERSION,
97
+ };
98
+ }
@@ -0,0 +1,2 @@
1
+ export declare const VERSION = "0.1.0";
2
+ export declare const RENDER_PROTOCOL_VERSION = 1;
@@ -0,0 +1,2 @@
1
+ export const VERSION = "0.1.0";
2
+ export const RENDER_PROTOCOL_VERSION = 1;
package/package.json ADDED
@@ -0,0 +1,76 @@
1
+ {
2
+ "name": "react-email-rails",
3
+ "version": "0.1.0",
4
+ "description": "Build and send emails using React and Rails",
5
+ "license": "MIT",
6
+ "type": "module",
7
+ "homepage": "https://github.com/heysupertape/react-email-rails#readme",
8
+ "bugs": {
9
+ "url": "https://github.com/heysupertape/react-email-rails/issues"
10
+ },
11
+ "keywords": [
12
+ "rails",
13
+ "react-email",
14
+ "actionmailer",
15
+ "vite",
16
+ "email"
17
+ ],
18
+ "repository": {
19
+ "type": "git",
20
+ "url": "git+https://github.com/heysupertape/react-email-rails.git",
21
+ "directory": "vite"
22
+ },
23
+ "files": [
24
+ "bin",
25
+ "dist",
26
+ "src"
27
+ ],
28
+ "exports": {
29
+ ".": {
30
+ "types": "./dist/index.d.ts",
31
+ "import": "./dist/index.js"
32
+ },
33
+ "./runtime": {
34
+ "types": "./dist/runtime.d.ts",
35
+ "import": "./dist/runtime.js"
36
+ }
37
+ },
38
+ "types": "./dist/index.d.ts",
39
+ "bin": {
40
+ "react-email-rails-config": "./bin/config.mjs",
41
+ "react-email-rails-dev": "./bin/dev.mjs"
42
+ },
43
+ "scripts": {
44
+ "build": "tsgo -p tsconfig.json",
45
+ "check:version": "ruby ../scripts/check_version_sync.rb",
46
+ "lint": "oxlint src test bin",
47
+ "format": "oxfmt src test bin",
48
+ "typecheck": "tsgo -p tsconfig.json --noEmit",
49
+ "test": "vitest run",
50
+ "sync:version": "ruby ../scripts/sync_version.rb",
51
+ "ci": "pnpm run check:version && pnpm build && pnpm lint && pnpm typecheck && pnpm test",
52
+ "prepack": "pnpm run sync:version && pnpm build",
53
+ "prepublishOnly": "pnpm run check:version"
54
+ },
55
+ "engines": {
56
+ "node": ">=20.19.0"
57
+ },
58
+ "peerDependencies": {
59
+ "@react-email/render": "^2.0.0",
60
+ "react": "^18.0 || ^19.0",
61
+ "vite": "^7.0.0 || ^8.0.0"
62
+ },
63
+ "devDependencies": {
64
+ "@react-email/render": "^2.0.8",
65
+ "@types/node": "^25.9.1",
66
+ "@types/react": "^19.2.15",
67
+ "@typescript/native-preview": "7.0.0-dev.20260527.2",
68
+ "oxfmt": "^0.52.0",
69
+ "oxlint": "^1.67.0",
70
+ "oxlint-tsgolint": "^0.23.0",
71
+ "react": "^19.2.6",
72
+ "react-dom": "^19.2.6",
73
+ "vite": "^8.0.14",
74
+ "vitest": "^4.1.7"
75
+ }
76
+ }
package/src/index.ts ADDED
@@ -0,0 +1,153 @@
1
+ import type { Plugin } from "vite"
2
+
3
+ export type EmailsOption =
4
+ | string
5
+ | {
6
+ path?: string
7
+ extension?: string | string[]
8
+ ignore?: string | string[]
9
+ }
10
+
11
+ export type ReactEmailRailsOptions = {
12
+ emails?: EmailsOption
13
+ standalone?: boolean
14
+ }
15
+
16
+ type PluginMetadata = {
17
+ emails: {
18
+ path: string
19
+ extensions: string[]
20
+ ignore: string[]
21
+ }
22
+ standalone: boolean
23
+ outDir: string
24
+ bundleFile: string
25
+ }
26
+
27
+ const DEFAULT_IGNORE = ["**/_*", "**/_*/**"]
28
+ const DEFAULT_EXTENSIONS = [".tsx", ".jsx"]
29
+
30
+ const VIRTUAL_SERVER = "virtual:react-email-rails/server"
31
+ const VIRTUAL_MAIN = "virtual:react-email-rails/main"
32
+ const RESOLVED_SERVER = `\0${VIRTUAL_SERVER}`
33
+ const RESOLVED_MAIN = `\0${VIRTUAL_MAIN}`
34
+
35
+ // The dedicated build environment that emits the server-side email bundle.
36
+ export const EMAIL_ENVIRONMENT = "email"
37
+ const CONFIG_SYMBOL = Symbol.for("react-email-rails.config")
38
+ const OUT_DIR = "tmp/react-email-rails"
39
+ const BUNDLE_FILE = "emails.js"
40
+
41
+ export function reactEmailRails(options: ReactEmailRailsOptions = {}): Plugin {
42
+ const emails =
43
+ typeof options.emails === "string" ? { path: options.emails } : (options.emails ?? {})
44
+ const path = (emails.path ?? "app/javascript/emails").replace(/^\/|\/$/g, "")
45
+ const rawExtensions =
46
+ emails.extension === undefined
47
+ ? DEFAULT_EXTENSIONS
48
+ : Array.isArray(emails.extension)
49
+ ? emails.extension
50
+ : [emails.extension]
51
+ const extensions = rawExtensions
52
+ .map((extension) => (extension.startsWith(".") ? extension : `.${extension}`))
53
+ .map((extension, index) => ({ extension, index }))
54
+ .sort(
55
+ (left, right) => right.extension.length - left.extension.length || left.index - right.index,
56
+ )
57
+ .map(({ extension }) => extension)
58
+ const standalone = options.standalone ?? true
59
+
60
+ const root = `/${path}/`
61
+ const pattern =
62
+ extensions.length === 1 ? `${root}**/*${extensions[0]}` : `${root}**/*{${extensions.join(",")}}`
63
+ const ignore =
64
+ emails.ignore === undefined
65
+ ? DEFAULT_IGNORE
66
+ : Array.isArray(emails.ignore)
67
+ ? emails.ignore
68
+ : [emails.ignore]
69
+ const globPatterns = [pattern, ...ignore.map((glob) => `!${root}${glob}`)]
70
+ const globArg = JSON.stringify(globPatterns.length === 1 ? globPatterns[0] : globPatterns)
71
+
72
+ const plugin: Plugin = {
73
+ name: "react-email-rails",
74
+
75
+ resolveId(id) {
76
+ if (id === VIRTUAL_SERVER) return RESOLVED_SERVER
77
+ if (id === VIRTUAL_MAIN) return RESOLVED_MAIN
78
+ },
79
+
80
+ load(id) {
81
+ if (id === RESOLVED_SERVER) {
82
+ return [
83
+ `import { serve, toComponentName } from "react-email-rails/runtime"`,
84
+ `const modules = import.meta.glob(${globArg})`,
85
+ `const extensions = ${JSON.stringify(extensions)}`,
86
+ `const registry = Object.create(null)`,
87
+ `for (const path in modules) {`,
88
+ ` const extension = extensions.find((extension) => path.endsWith(extension)) ?? path.slice(path.lastIndexOf("."))`,
89
+ ` registry[toComponentName(path, ${JSON.stringify(root)}, extension)] = modules[path]`,
90
+ `}`,
91
+ `export const run = () => serve(registry)`,
92
+ ].join("\n")
93
+ }
94
+
95
+ if (id === RESOLVED_MAIN) {
96
+ return `import { run } from ${JSON.stringify(VIRTUAL_SERVER)}\nrun()\n`
97
+ }
98
+ },
99
+
100
+ config() {
101
+ // Register a dedicated `email` build environment and opt the app into
102
+ // building all environments, so a plain `vite build` emits the email
103
+ // bundle alongside the client assets — no separate build step required.
104
+ // The environment is a server consumer. Standalone builds inline Node
105
+ // dependencies by default so Rails runtime images do not need node_modules.
106
+ return {
107
+ builder: {},
108
+ environments: {
109
+ [EMAIL_ENVIRONMENT]: {
110
+ ...(standalone ? { resolve: { noExternal: true } } : {}),
111
+ build: {
112
+ ssr: true,
113
+ outDir: OUT_DIR,
114
+ emptyOutDir: true,
115
+ rollupOptions: {
116
+ input: VIRTUAL_MAIN,
117
+ output: { entryFileNames: BUNDLE_FILE },
118
+ },
119
+ },
120
+ },
121
+ },
122
+ }
123
+ },
124
+ }
125
+
126
+ const metadata: PluginMetadata = {
127
+ emails: {
128
+ path,
129
+ extensions,
130
+ ignore,
131
+ },
132
+ standalone,
133
+ outDir: OUT_DIR,
134
+ bundleFile: BUNDLE_FILE,
135
+ }
136
+
137
+ Object.defineProperty(plugin, CONFIG_SYMBOL, {
138
+ value: {
139
+ ...metadata,
140
+ },
141
+ })
142
+
143
+ return plugin
144
+ }
145
+
146
+ export type {
147
+ EmailModule,
148
+ EmailRegistry,
149
+ EmailRenderOptions,
150
+ RenderedEmail,
151
+ RenderRequest,
152
+ } from "./runtime.js"
153
+ export { RENDER_PROTOCOL_VERSION, VERSION } from "./version.js"
package/src/runtime.ts ADDED
@@ -0,0 +1,157 @@
1
+ import { render, type Options as ReactEmailRenderOptions } from "@react-email/render"
2
+ import React from "react"
3
+
4
+ import { RENDER_PROTOCOL_VERSION, VERSION } from "./version.js"
5
+
6
+ export type EmailModule = {
7
+ default: React.ComponentType<Record<string, unknown>>
8
+ }
9
+
10
+ export type EmailLoader = EmailModule | (() => Promise<EmailModule>)
11
+ export type EmailRegistry = Record<string, EmailLoader>
12
+
13
+ export type RenderRequest = {
14
+ component: string
15
+ props?: Record<string, unknown>
16
+ renderOptions?: EmailRenderOptions
17
+ }
18
+
19
+ export type HealthRequest = {
20
+ health: true
21
+ }
22
+
23
+ export type RenderedEmail = {
24
+ html: string
25
+ text: string
26
+ }
27
+
28
+ type ProtocolMetadata = {
29
+ protocolVersion: number
30
+ packageVersion: string
31
+ }
32
+
33
+ export type EmailRenderOptions = {
34
+ html?: ReactEmailRenderOptions
35
+ text?: ReactEmailRenderOptions
36
+ }
37
+
38
+ export function toComponentName(globPath: string, root: string, extension: string): string {
39
+ const start = globPath.lastIndexOf(root) + root.length
40
+ return globPath.slice(start, globPath.length - extension.length)
41
+ }
42
+
43
+ export async function renderEmail(
44
+ request: RenderRequest,
45
+ registry: EmailRegistry,
46
+ ): Promise<RenderedEmail> {
47
+ const loader = registry[request.component]
48
+ if (!loader) throw new Error(`React email component not found: ${request.component}`)
49
+
50
+ const mod = typeof loader === "function" ? await loader() : loader
51
+ const element = React.createElement(mod.default, request.props ?? {})
52
+
53
+ // @react-email/render re-renders the tree per call, so html and text are two passes.
54
+ return {
55
+ html: await render(element, { ...request.renderOptions?.html, plainText: false }),
56
+ text: await render(element, { ...request.renderOptions?.text, plainText: true }),
57
+ }
58
+ }
59
+
60
+ export async function serve(registry: EmailRegistry): Promise<void> {
61
+ if (process.argv.includes("--persistent")) {
62
+ await servePersistent(registry, isolateStdout())
63
+ return
64
+ }
65
+
66
+ if (process.argv.includes("--health")) {
67
+ process.stdout.write(JSON.stringify(okResponse()))
68
+ return
69
+ }
70
+
71
+ const write = isolateStdout()
72
+ try {
73
+ const request = JSON.parse(await readStdin()) as RenderRequest
74
+ write(JSON.stringify({ ...(await renderEmail(request, registry)), ...protocolMetadata() }))
75
+ } catch (error) {
76
+ process.stderr.write(error instanceof Error ? error.message : "React Email render failed")
77
+ process.exitCode = 1
78
+ }
79
+ }
80
+
81
+ // Reserve stdout for the JSON render protocol. Stray writes from email components
82
+ // or their dependencies — including console.log, which Node routes through
83
+ // process.stdout.write — are diverted to stderr so they cannot corrupt or desync
84
+ // a response frame. Returns the writer to use for protocol output.
85
+ function isolateStdout(): (chunk: string) => boolean {
86
+ const protocolWrite = process.stdout.write.bind(process.stdout)
87
+ process.stdout.write = ((chunk: string | Uint8Array): boolean =>
88
+ process.stderr.write(chunk)) as typeof process.stdout.write
89
+ return (chunk) => protocolWrite(chunk)
90
+ }
91
+
92
+ function readStdin(): Promise<string> {
93
+ return new Promise((resolve, reject) => {
94
+ let data = ""
95
+ process.stdin.setEncoding("utf8")
96
+ process.stdin.on("data", (chunk) => {
97
+ data += chunk
98
+ })
99
+ process.stdin.on("end", () => resolve(data))
100
+ process.stdin.on("error", reject)
101
+ })
102
+ }
103
+
104
+ async function servePersistent(
105
+ registry: EmailRegistry,
106
+ write: (chunk: string) => boolean,
107
+ ): Promise<void> {
108
+ process.stdin.setEncoding("utf8")
109
+
110
+ let pending = ""
111
+ for await (const chunk of process.stdin) {
112
+ pending += chunk
113
+
114
+ let separator = pending.indexOf("\n")
115
+ while (separator !== -1) {
116
+ const line = pending.slice(0, separator)
117
+ pending = pending.slice(separator + 1)
118
+
119
+ if (line.trim()) await writePersistentResponse(line, registry, write)
120
+ separator = pending.indexOf("\n")
121
+ }
122
+ }
123
+ }
124
+
125
+ async function writePersistentResponse(
126
+ line: string,
127
+ registry: EmailRegistry,
128
+ write: (chunk: string) => boolean,
129
+ ): Promise<void> {
130
+ try {
131
+ const request = JSON.parse(line) as RenderRequest | HealthRequest
132
+ if ("health" in request) {
133
+ write(`${JSON.stringify(okResponse())}\n`)
134
+ return
135
+ }
136
+
137
+ write(`${JSON.stringify({ ok: true, ...(await renderEmail(request, registry)), ...protocolMetadata() })}\n`)
138
+ } catch (error) {
139
+ write(
140
+ `${JSON.stringify({
141
+ ok: false,
142
+ error: error instanceof Error ? error.message : "React Email render failed",
143
+ })}\n`,
144
+ )
145
+ }
146
+ }
147
+
148
+ function okResponse(): { ok: true } & ProtocolMetadata {
149
+ return { ok: true, ...protocolMetadata() }
150
+ }
151
+
152
+ function protocolMetadata(): ProtocolMetadata {
153
+ return {
154
+ protocolVersion: RENDER_PROTOCOL_VERSION,
155
+ packageVersion: VERSION,
156
+ }
157
+ }
package/src/version.ts ADDED
@@ -0,0 +1,2 @@
1
+ export const VERSION = "0.1.0"
2
+ export const RENDER_PROTOCOL_VERSION = 1