react-email-rails 0.10.0 → 0.12.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 2d0666badbedb79a20a1fec50afc5aedde5398a3777620f5c2f6c2e080a2a5e2
4
- data.tar.gz: f19f1dba07f2ad62bf5737e3fab859c33a526305963ee239a1eb63d3048cc7f9
3
+ metadata.gz: e2dc04e0c361e5517d66cf3a60a274b090bc5978b82ee4a77c07758ac8ca82e7
4
+ data.tar.gz: 027c7e6c6e4510621b6738c271f273a1a89a248e6de59772bc0af6fec7eebe4d
5
5
  SHA512:
6
- metadata.gz: 3905f2042d9932512be861bd7bf0be13f7dde1e9f481dae3ebbbe36f676f8562ecc29532a91649e1e64102454427057599d10baa47866ed9bbab6699163546a8
7
- data.tar.gz: 6236d1b78f000473a2bb696bbf204ce72143bd62abdc1008a0ce0fa05be88777ca2416101107894f92feaea79d16fbc7f9fc4e13d4c00e8b52ca31dff289fa64
6
+ metadata.gz: b044fc510c92a914031d4932586b12f2a16c306fee35f4cb4088f22093910456b3a8e3fe4a1909d1e8a67ba10a6235462f4531858a6f575e3516ef8bea20e260
7
+ data.tar.gz: d30686390cf1c1d435a24ec640d3a5873cf7137e211c67e6e316a660c3ffa67ae6c78787b90075676ddab90ded4282b2db6730195120ff954c919fd58a3610f3
data/CHANGELOG.md CHANGED
@@ -1,5 +1,27 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.12.0
4
+
5
+ - **Breaking:** Remove `config.render_mode`. There is one renderer: a long-lived Node child per Ruby process, speaking newline-delimited JSON. Delete `config.render_mode = :persistent` from initializers. Set `config.render_process_max_requests = 1` to recycle the child after every email.
6
+ - **Breaking:** Replace `config.transform_props` (`:camel`, `:lower_camel`, `:dash`, `:snake`, `:none`) with `config.prop_transformer`, matching [inertia-rails](https://inertia-rails.dev/guide/configuration#prop_transformer). The default is a no-op (`->(props:) { props }`), so keys stay as `as_json` produced them. Camelize in an initializer if you want the previous 0.11 behavior. The exported `Mailer` and `Message` TypeScript types now describe that default snake_case shape.
7
+ - Render HTML once and derive plain text with `toPlainText`, so `htmlToTextOptions` and `data-skip-in-text` apply to the same markup.
8
+ - Raise clearer errors when a component is missing (including a capped list of known names) or has no default export.
9
+ - Default `render_timeout` to 30 seconds in development so the first Vite-backed render can finish booting.
10
+ - Timeouts with no stdout mention matching gem and npm versions, so a gem 0.12 talking to an older package that waits for EOF is easier to diagnose.
11
+ - Recycle the Node child after invalid JSON so the next render can succeed.
12
+ - Include Node stderr when the child exits without a protocol line, instead of reporting only that it exited.
13
+ - Treat only `{ "health": true }` as a health check so a render payload that also sets `health` still renders.
14
+ - Apply `html.pretty` only to the HTML body so pretty-printing does not change plain text.
15
+
16
+ ## 0.11.1
17
+
18
+ - Republish of 0.11.0 with no code changes. The 0.11.0 npm package was never published: npm provenance publishing requires GitHub-hosted runners, so the release workflow's publish job now runs on one. Do not use gem 0.11.0; it has no matching npm package.
19
+
20
+ ## 0.11.0
21
+
22
+ - Generated applications now use React Email 6's unified `react-email` package for email components instead of `@react-email/components`.
23
+ - `@react-email/render` is now a runtime dependency of the `react-email-rails` npm package instead of a peer dependency, so applications no longer install it directly. The npm package now declares `react-dom` as a peer dependency alongside `react`, matching what the renderer already required at runtime.
24
+
3
25
  ## 0.10.0
4
26
 
5
27
  - **Breaking:** Remove the instance-level `react_share` helper (calling `react_share` from within an action before `mail`). Pass per-mail props directly in the `react:` hash instead; use the class-level `react_share` (with `only`/`except`/`if`/`unless`) for conditional sharing.
data/README.md CHANGED
@@ -8,16 +8,7 @@ react-email-rails lets Rails render React Email components into HTML and plain t
8
8
 
9
9
  ## Why
10
10
 
11
- HTML email is still awkward. React Email gives you a nicer component model, email-safe primitives, Tailwind support, and TypeScript. This gem connects that workflow to Rails without replacing Action Mailer.
12
-
13
- You get:
14
-
15
- - React Email components rendered from `mail(...)`
16
- - HTML and plain-text output from the same component
17
- - Rails mailer previews, tests, queues, callbacks, and delivery
18
- - Vite-powered development rendering
19
- - A production renderer bundle built during `assets:precompile`
20
- - Optional persistent rendering for high-volume workers
11
+ HTML email is still awkward. React Email gives you a nicer component model, email-safe primitives, Tailwind support, and TypeScript. This gem connects that workflow to Rails without replacing Action Mailer: components render through Vite in development and a prebuilt renderer bundle in production, using a long-lived Node process per Ruby process.
21
12
 
22
13
  ## Status
23
14
 
@@ -34,7 +25,6 @@ The supported Ruby, Rails, Node, React, and Vite versions are tested in CI. Plea
34
25
  - [Usage](#usage)
35
26
  - [Configuration](#configuration)
36
27
  - [Deployment](#deployment)
37
- - [Development](#development)
38
28
  - [Contributing](#contributing)
39
29
  - [Security](#security)
40
30
  - [License](#license)
@@ -43,12 +33,12 @@ The supported Ruby, Rails, Node, React, and Vite versions are tested in CI. Plea
43
33
 
44
34
  | Dependency | Version |
45
35
  |------------|---------|
46
- | Ruby | >= 3.3 |
47
- | Rails | Action Mailer, Active Support, and Railties >= 7.1 and < 9.0 |
48
- | Node | >= 20.19 |
36
+ | Ruby | 3.3+ |
37
+ | Rails | 7.1–8.x |
38
+ | Node | 20.19+ |
49
39
  | Vite | 7 or 8 |
50
40
  | React | 18 or 19 |
51
- | `@react-email/render` | 2.x |
41
+ | React Email | 6.x |
52
42
 
53
43
  We recommend [rails_vite](https://github.com/skryukov/rails_vite/) for Vite in Rails apps.
54
44
 
@@ -68,21 +58,20 @@ bundle install
68
58
  bin/rails generate react_email_rails:install
69
59
  ```
70
60
 
71
- The installer creates `config/initializers/react_email_rails.rb`, installs missing JavaScript dependencies when it can detect your package manager, adds `reactEmailRails()` to `vite.config.*`, and creates `app/javascript/emails`.
61
+ The installer creates `config/initializers/react_email_rails.rb`, installs missing JavaScript dependencies when it can detect your package manager, adds `reactEmailRails()` to `vite.config.*` (creating `vite.config.ts` if none exists), and creates `app/javascript/emails`.
72
62
 
73
63
  After installation, the normal Rails flow applies:
74
64
 
75
65
  - Generate mailers and components with `bin/rails generate react_email_rails:email ...`.
76
66
  - Run `bin/dev` in development; email components render through Vite on demand.
77
67
  - Run `bin/rails assets:precompile` for production; the renderer bundle builds automatically.
78
- - Run `bin/rails react_email_rails:build` directly when CI or tests need the bundle without the full asset task.
79
68
 
80
69
  ### Manual Setup
81
70
 
82
71
  If you prefer to wire things up yourself, install the npm package and React Email dependencies:
83
72
 
84
73
  ```sh
85
- npm i react-email-rails @react-email/render @react-email/components react react-dom
74
+ npm i react-email-rails react-email react react-dom
86
75
  ```
87
76
 
88
77
  Use the equivalent command for pnpm, Yarn, or Bun if your app uses a different package manager.
@@ -107,7 +96,7 @@ Generate a mailer and React Email component:
107
96
  bin/rails generate react_email_rails:email Account welcome
108
97
  ```
109
98
 
110
- The generator follows Rails' mailer generator shape: `NAME [method method]`. It creates a mailer, React component, mailer preview, and test. It also reads `emails.path` and `emails.extension` from `reactEmailRails()` when available.
99
+ The generator follows Rails' mailer generator shape: `NAME [method method]`. It creates a mailer, React component, mailer preview, and test. It also reads `emails.path` and `emails.extension` from your `reactEmailRails()` Vite options when available.
111
100
 
112
101
  Pass flags when you need to override the detected component directory or extension:
113
102
 
@@ -139,7 +128,7 @@ Then edit the generated component:
139
128
 
140
129
  ```tsx
141
130
  // app/javascript/emails/account_mailer/welcome.tsx
142
- import { Body, Container, Html, Text } from "@react-email/components"
131
+ import { Body, Container, Html, Text } from "react-email"
143
132
 
144
133
  type WelcomeProps = {
145
134
  account: {
@@ -166,21 +155,21 @@ Deliver it like any other Action Mailer email:
166
155
  AccountMailer.with(account: current_account).welcome.deliver_later
167
156
  ```
168
157
 
169
- React Email also provides primitives like [`<Button>`, `<Heading>`, `<Tailwind>`, and more](https://react.email/docs/components/html).
158
+ React Email also provides primitives like [`<Button>`, `<Heading>`, `<Tailwind>`, and more](https://react.email/components).
170
159
 
171
160
  ## Rendering
172
161
 
173
- In development, react-email-rails renders components through Vite's dev pipeline. Your email components get the same module resolution and transforms as the rest of your frontend.
162
+ In development, react-email-rails renders components through Vite's dev pipeline. Your email components get the same module resolution and transforms as the rest of your frontend. A long-lived Node child stays warm across previews and `deliver_now` calls so Vite is not cold-started for every email.
174
163
 
175
- In production, `assets:precompile` builds a server-side renderer bundle from your Vite config. Rails runs that bundle with Node whenever an email needs to render.
164
+ In production, `assets:precompile` builds a server-side renderer bundle from your Vite config. Rails keeps one Node child per Ruby process and renders through that bundle.
176
165
 
177
- Every `react:` email renders HTML and plain text from the same component. If rendering fails, the email is not sent and `ReactEmailRails::RenderError` is raised.
166
+ Every `react:` email renders HTML once and derives plain text from that markup. If rendering fails, the email is not sent and `ReactEmailRails::RenderError` is raised.
178
167
 
179
168
  ### Live-Reloading Previews
180
169
 
181
- In development, Action Mailer previews automatically reload themselves when you edit an email component. react-email-rails registers a [preview interceptor](https://api.rubyonrails.org/classes/ActionMailer/Base.html#class-ActionMailer::Base-label-Previewing+emails) that injects `@vite/client` into the preview, and the `reactEmailRails()` plugin broadcasts a full reload over Vite's websocket whenever a file under your emails directory changes.
170
+ In development, Action Mailer previews automatically reload themselves when you edit an email component. react-email-rails registers a [preview interceptor](https://api.rubyonrails.org/classes/ActionMailer/Base.html#class-ActionMailer::Base-label-Previewing+emails) that injects `@vite/client` into the preview, and the `reactEmailRails()` plugin broadcasts a full reload over Vite's WebSocket whenever a file under your emails directory changes.
182
171
 
183
- The `live_reload_url` defaults to Vite's `http://localhost:5173`, but you can point elsewhere if needed, or set it to a falsy value to disable live reload. (See [Configuration](#configuration))
172
+ `live_reload_url` defaults to Vite's `http://localhost:5173`. Point it elsewhere if your dev server runs on a different port, or set it to a falsy value to disable live reload:
184
173
 
185
174
  ```ruby
186
175
  ReactEmailRails.configure do |config|
@@ -192,31 +181,7 @@ end
192
181
 
193
182
  ### Passing Props
194
183
 
195
- Top-level keys passed to `react:` become props on the component's default export. The API is intentionally close to [inertia-rails](https://inertia-rails.dev), so apps using both libraries should feel consistent.
196
-
197
- ```ruby
198
- mail(
199
- to: account.email,
200
- subject: "Welcome",
201
- react: {
202
- account: {
203
- name: account.name,
204
- },
205
- },
206
- )
207
- ```
208
-
209
- ```tsx
210
- type WelcomeProps = {
211
- account: {
212
- name: string
213
- }
214
- }
215
-
216
- export default function Welcome({ account }: WelcomeProps) {
217
- // ...
218
- }
219
- ```
184
+ Top-level keys passed to `react:` become props on the component's default export, as shown in the [Quick Start](#quick-start). The API is intentionally close to [inertia-rails](https://inertia-rails.dev), so apps using both libraries should feel consistent.
220
185
 
221
186
  ### Component Inference
222
187
 
@@ -249,7 +214,7 @@ class AccountMailer < ApplicationMailer
249
214
  end
250
215
  ```
251
216
 
252
- Action Mailer's framework assigns, including `params` and `rendered_format`, are excluded from instance props.
217
+ Action Mailer's internal assigns, such as `params` and `rendered_format`, are excluded from instance props.
253
218
 
254
219
  To make React the default for every action, set `default react: true` on the mailer (or `ApplicationMailer`). Each `mail` call then renders the inferred component without repeating `react: true`, and a single action can opt back out with `react: false`:
255
220
 
@@ -357,54 +322,40 @@ Set `config.deep_merge_shared_props = true` to make deep merging the default for
357
322
 
358
323
  Every `react:` email receives `mailer` and `message` props, mirroring the [`mailer` and `message` view helpers](https://guides.rubyonrails.org/action_mailer_basics.html#action-mailer-view-helpers) available to Action Mailer ERB views.
359
324
 
360
- `mailer` identifies the mailer action. `message` reflects the email after Action Mailer has assigned headers and defaults, including default `from` and `reply_to` values. With the default prop transform, import the `Mailer` and `Message` types to annotate them:
325
+ `mailer` identifies the mailer action. `message` reflects the email after Action Mailer has assigned headers and defaults, including default `from` and `reply_to` values. Import the `Mailer` and `Message` types to annotate them:
361
326
 
362
327
  ```tsx
363
328
  import type { Mailer, Message } from "react-email-rails"
364
- import { Body, Container, Html, Text } from "@react-email/components"
365
329
 
366
330
  type WelcomeProps = {
367
331
  account: { name: string }
368
332
  mailer: Mailer
369
333
  message: Message
370
334
  }
371
-
372
- export default function Welcome({ account, mailer, message }: WelcomeProps) {
373
- return (
374
- <Html>
375
- <Body>
376
- <Container>
377
- <Text>Welcome, {account.name}</Text>
378
- <Text>Re: {message.subject}</Text>
379
- </Container>
380
- </Body>
381
- </Html>
382
- )
383
- }
384
335
  ```
385
336
 
386
337
  | Prop | Example |
387
338
  |------|---------|
388
- | `mailer.mailerName` | `"account_mailer"` |
389
- | `mailer.actionName` | `"welcome"` |
339
+ | `mailer.mailer_name` | `"account_mailer"` |
340
+ | `mailer.action_name` | `"welcome"` |
390
341
  | `message.subject` | `"Welcome"` |
391
342
  | `message.to` | `["account@example.com"]` |
392
343
  | `message.cc`, `message.bcc` | `["…"]` or `null` |
393
- | `message.from`, `message.replyTo` | `["app@example.com"]` |
344
+ | `message.from`, `message.reply_to` | `["app@example.com"]` |
394
345
 
395
- Context is merged before prop serialization, so keys follow `config.transform_props` just like your own props. The exported TypeScript types describe the default `:lower_camel` shape.
346
+ Context is merged before prop serialization, so keys follow `config.prop_transformer` just like your own props. The exported TypeScript types describe the default (untransformed) shape.
396
347
 
397
- Per-mail and shared props win on conflict, so a prop named `mailer` or `message` overrides the injected context. Serializer props receive the context when `as_json` returns a hash. Collections, arrays, and other non-object values pass through unchanged so their top-level shape is preserved.
348
+ Per-mail and shared props win on conflict, so a prop named `mailer` or `message` overrides the injected context. When props come from a serializer, the context is merged in as long as `as_json` returns a hash; collections, arrays, and other non-object values pass through unchanged so their top-level shape is preserved.
398
349
 
399
350
  ### Prop Serialization
400
351
 
401
352
  Props are serialized with `as_json`, just like `render json:`. You can pass hashes, arrays, Active Model objects, and serializer output from libraries such as [Alba](https://github.com/okuramasafumi/alba) or [ActiveModel::Serializer](https://github.com/rails-api/active_model_serializers).
402
353
 
403
- Prop keys are camelized by default, so `plan_name` arrives in React as `planName`. See [Prop Transformation](#prop-transformation) to change that behavior.
354
+ Keys are left as serialized by default, so `plan_name` arrives in React as `plan_name`. See [Prop Transformation](#prop-transformation) to camelize them.
404
355
 
405
356
  ### Component Files
406
357
 
407
- Files and directories starting with `_` are ignored as renderable email entries by default. Use them for shared components, layouts, and helpers:
358
+ Files and directories starting with `_` are excluded from the email component registry by default. Use them for shared components, layouts, and helpers:
408
359
 
409
360
  ```text
410
361
  app/javascript/emails/
@@ -422,7 +373,7 @@ Action Mailer layouts are not applied to `react:` emails. In React Email, layout
422
373
 
423
374
  ```tsx
424
375
  // app/javascript/emails/_components/email_layout.tsx
425
- import { Body, Container, Html } from "@react-email/components"
376
+ import { Body, Container, Html } from "react-email"
426
377
  import type { ReactNode } from "react"
427
378
 
428
379
  type EmailLayoutProps = {
@@ -442,7 +393,7 @@ export function EmailLayout({ children }: EmailLayoutProps) {
442
393
 
443
394
  ```tsx
444
395
  // app/javascript/emails/account_mailer/welcome.tsx
445
- import { Text } from "@react-email/components"
396
+ import { Text } from "react-email"
446
397
  import { EmailLayout } from "../_components/email_layout"
447
398
 
448
399
  export default function Welcome() {
@@ -458,7 +409,7 @@ export default function Welcome() {
458
409
 
459
410
  Configuration lives in two places:
460
411
 
461
- - Rails configuration controls mailer behavior, prop handling, rendering mode, timeouts, and error hooks.
412
+ - Rails configuration controls mailer behavior, prop handling, timeouts, and error hooks.
462
413
  - Vite configuration controls email component discovery and the renderer bundle.
463
414
 
464
415
  ### Rails Configuration
@@ -467,17 +418,16 @@ Override Rails-side defaults in `config/initializers/react_email_rails.rb`:
467
418
 
468
419
  ```ruby
469
420
  ReactEmailRails.configure do |config|
470
- # config.render_mode = :persistent
421
+ # config.render_timeout = 10
471
422
  end
472
423
  ```
473
424
 
474
425
  | Option | Default |
475
426
  |--------|---------|
476
427
  | `component_path_resolver` | `->(mailer:, action:) { "#{mailer}/#{action}" }` |
477
- | `transform_props` | `:lower_camel` |
478
- | `render_mode` | `:subprocess` |
428
+ | `prop_transformer` | `->(props:) { props }` |
479
429
  | `render_options` | `{}` |
480
- | `render_timeout` | `10` seconds |
430
+ | `render_timeout` | `30` seconds in development, `10` seconds otherwise |
481
431
  | `render_process_max_requests` | `1_000` |
482
432
  | `on_render_error` | `nil` |
483
433
  | `deep_merge_shared_props` | `false` |
@@ -485,48 +435,35 @@ end
485
435
 
486
436
  ### Prop Transformation
487
437
 
488
- Set `transform_props` to choose how Ruby prop keys are exposed to React:
438
+ Use `prop_transformer` to change props after `as_json` and before they reach React. The default is a no-op, matching [inertia-rails](https://inertia-rails.dev/guide/configuration#prop_transformer).
489
439
 
490
- | Value | Example |
491
- |-------|---------|
492
- | `:camel` | `AccountName` |
493
- | `:lower_camel` | `accountName` |
494
- | `:dash` | `account-name` |
495
- | `:snake` | `account_name` |
496
- | `:none` | preserves serialized keys |
440
+ To work with `snake_case` in Ruby and `camelCase` in components:
497
441
 
498
442
  ```ruby
499
443
  ReactEmailRails.configure do |config|
500
- config.transform_props = :none
444
+ config.prop_transformer = lambda do |props:|
445
+ props.deep_transform_keys { |key| key.to_s.camelize(:lower) }
446
+ end
501
447
  end
502
448
  ```
503
449
 
504
- Only prop keys are transformed. Values are always serialized with `as_json`.
505
-
506
- ### Render Modes
450
+ The transformer receives one hash at a time: the root props object, or each object in a top-level collection. Nested hashes inside a single object are left to the transformer — `deep_transform_keys` walks those.
507
451
 
508
- The default `:subprocess` mode starts a fresh Node process for each render. It is simple, isolated, and always uses the latest bundle, but it pays Node startup and bundle load time for each email.
452
+ ### Render Process
509
453
 
510
- `:persistent` mode keeps one long-lived Node child process per Ruby process. It is faster for render-heavy workers, but uses more memory and can serve a stale component until the child is recycled.
454
+ Each Ruby process keeps one long-lived Node child. Renders are newline-delimited JSON and processed one at a time. Throughput scales with more worker processes. The child is fork-safe: clustered Puma and forking job runners spawn one Node child per worker.
511
455
 
512
- Switch to persistent mode when Node startup shows up in traces or when a worker renders many emails from the same bundle:
456
+ The child recycles after `render_process_max_requests` renders so a leaked or stale renderer cannot live forever. Set that option to `nil` to disable recycling, or to `1` to start a fresh Node process for every email:
513
457
 
514
458
  ```ruby
515
459
  ReactEmailRails.configure do |config|
516
- config.render_mode = :persistent
460
+ config.render_process_max_requests = 1
517
461
  end
518
462
  ```
519
463
 
520
- Persistent mode details:
521
-
522
- - Renders are newline-delimited JSON and processed one at a time.
523
- - Throughput scales with more worker processes.
524
- - It is fork-safe; clustered Puma and forking job runners spawn one Node child per worker.
525
- - The child recycles after `render_process_max_requests` renders. Set that option to `nil` to disable recycling.
526
-
527
464
  ### Render Options
528
465
 
529
- `render_options` is passed to [@react-email/render](https://react.email/docs/utilities/render). Use `html` and `text` keys to configure each output. Option keys are camelized before they cross into JavaScript.
466
+ `render_options` is passed to [@react-email/render](https://react.email/docs/utilities/render). HTML is rendered once; plain text is derived from that markup with `toPlainText` (or `unstableToPlainText` when `unstable_text_conversion` is set). `html.pretty` is applied only to the HTML part, so it does not change the text body. `text.html_to_text_options` is forwarded to `toPlainText`. Option keys are camelized before they cross into JavaScript, so `html_to_text_options` becomes `htmlToTextOptions`.
530
467
 
531
468
  ```ruby
532
469
  ReactEmailRails.configure do |config|
@@ -561,7 +498,7 @@ The callback receives the error plus render context such as `component:`.
561
498
 
562
499
  ### Instrumentation
563
500
 
564
- Every render emits `render.react-email-rails` through [ActiveSupport::Notifications](https://guides.rubyonrails.org/active_support_instrumentation.html). Payloads include `component` and successful HTML size in `html_bytes`.
501
+ Every render emits `render.react-email-rails` through [ActiveSupport::Notifications](https://guides.rubyonrails.org/active_support_instrumentation.html). Payloads include the `component` and, on success, the rendered HTML size in `html_bytes`.
565
502
 
566
503
  ```ruby
567
504
  ActiveSupport::Notifications.subscribe("render.react-email-rails") do |event|
@@ -574,18 +511,9 @@ end
574
511
 
575
512
  ### Vite Configuration
576
513
 
577
- Most apps only need the plugin from [Installation](#installation):
578
-
579
- ```ts
580
- import { defineConfig } from "vite"
581
- import { reactEmailRails } from "react-email-rails"
582
-
583
- export default defineConfig({
584
- plugins: [reactEmailRails()],
585
- })
586
- ```
514
+ Most apps only need the bare `reactEmailRails()` plugin from [Installation](#installation).
587
515
 
588
- The isolated renderer loads `reactEmailRails()`, JSX support, and component-facing Vite config such as `resolve`, `define`, `css`, `json`, `assetsInclude`, `esbuild`, and `oxc`. It does not load your other app plugins unless you explicitly add them with `vite.plugins`.
516
+ The isolated renderer loads `reactEmailRails()`, JSX support, and component-facing Vite config such as `assetsInclude`, `css`, `define`, `esbuild`, `json`, `oxc`, and `resolve`. It does not load your other app plugins unless you explicitly add them with `vite.plugins`.
589
517
 
590
518
  Server, preview, dependency optimization, and build output settings stay owned by react-email-rails so Rails can always find the renderer bundle.
591
519
 
@@ -617,7 +545,7 @@ reactEmailRails({
617
545
  })
618
546
  ```
619
547
 
620
- Component names come from the Vite directory layout. To map mailer actions to a different layout, override `component_path_resolver` on the Ruby side so both halves stay in sync.
548
+ Component names come from the file layout under `emails.path`. To map mailer actions to a different layout, override `component_path_resolver` on the Ruby side so both halves stay in sync.
621
549
 
622
550
  ### Email-Only Vite Plugins
623
551
 
@@ -653,7 +581,7 @@ reactEmailRails({
653
581
  })
654
582
  ```
655
583
 
656
- Externalized bundles can be smaller and may build faster, but the externalized packages must be available at runtime.
584
+ Externalized bundles can be smaller and may build faster, but the packages they reference must be available at runtime.
657
585
 
658
586
  ## Deployment
659
587
 
@@ -663,7 +591,7 @@ Production deploys should run the normal Rails asset task:
663
591
  bin/rails assets:precompile
664
592
  ```
665
593
 
666
- react-email-rails hooks `react_email_rails:build` into `assets:precompile`. The build task loads `reactEmailRails()` options from your Vite config and writes `tmp/react-email-rails/emails.js` with the email component registry.
594
+ react-email-rails hooks `react_email_rails:build` into `assets:precompile` (and `react_email_rails:clobber` into `assets:clobber`). The build task loads `reactEmailRails()` options from your Vite config and writes `tmp/react-email-rails/emails.js` with the email component registry.
667
595
 
668
596
  You can run the renderer build directly:
669
597
 
@@ -673,9 +601,9 @@ bin/rails react_email_rails:build
673
601
 
674
602
  Production rendering requires that bundle. If it is missing, rendering raises `ReactEmailRails::RenderError` and Action Mailer does not send the email.
675
603
 
676
- Set `SKIP_REACT_EMAIL_RAILS_BUILD=1` to skip the automatic asset hook. Directly running `bin/rails react_email_rails:build` always attempts the build.
604
+ Set `SKIP_REACT_EMAIL_RAILS_BUILD=1` to skip the automatic asset hooks. Directly running `bin/rails react_email_rails:build` always attempts the build.
677
605
 
678
- The npm package, Vite, React, and `@react-email/render` must be available when Rails runs `assets:precompile`.
606
+ The npm package, Vite, and React must be available when Rails runs `assets:precompile`.
679
607
 
680
608
  The Ruby gem and npm package must stay on the same version. A protocol/version handshake catches mismatched installs and raises an actionable `ReactEmailRails::RenderError`.
681
609
 
@@ -701,29 +629,9 @@ end
701
629
 
702
630
  If you check at boot, scope it to processes that send mail so the rest of the app does not pay the cost.
703
631
 
704
- ## Development
705
-
706
- See [CONTRIBUTING.md](CONTRIBUTING.md) for local setup, checks, formatting, and release verification.
707
-
708
- The short version:
709
-
710
- ```sh
711
- bundle install
712
- cd vite && pnpm install
713
- ```
714
-
715
- Run the core checks before opening a pull request:
716
-
717
- ```sh
718
- ruby scripts/check_version_sync.rb
719
- bin/test
720
- bin/lint
721
- cd vite && pnpm run build
722
- ```
723
-
724
632
  ## Contributing
725
633
 
726
- Bug reports and pull requests are welcome. Please read [CONTRIBUTING.md](CONTRIBUTING.md) before opening a pull request so the local checks and release expectations are clear.
634
+ Bug reports and pull requests are welcome. See [CONTRIBUTING.md](CONTRIBUTING.md) for local setup, the checks to run before opening a pull request, and release expectations.
727
635
 
728
636
  ## Security
729
637
 
@@ -7,8 +7,7 @@ module ReactEmailRails::Generators; end
7
7
  class ReactEmailRails::Generators::InstallGenerator < Rails::Generators::Base
8
8
  JAVASCRIPT_PACKAGES = [
9
9
  "react-email-rails",
10
- "@react-email/render",
11
- "@react-email/components",
10
+ "react-email",
12
11
  "react",
13
12
  "react-dom",
14
13
  ].freeze
@@ -1,4 +1,4 @@
1
- import { Body, Container, Heading, Html, Text } from "@react-email/components"
1
+ import { Body, Container, Heading, Html, Text } from "react-email"
2
2
 
3
3
  export default function <%= @component_name %>() {
4
4
  return (
@@ -5,21 +5,11 @@ class ReactEmailRails::Configuration
5
5
  DEV_RENDER_BIN = "node_modules/.bin/react-email-rails-dev"
6
6
 
7
7
  DEFAULT_RENDER_TIMEOUT = 10
8
+ DEVELOPMENT_RENDER_TIMEOUT = 30
8
9
  DEFAULT_RENDER_PROCESS_MAX_REQUESTS = 1_000
9
10
  DEFAULT_LIVE_RELOAD_URL = "http://localhost:5173"
10
11
 
11
- RENDER_MODES = {
12
- subprocess: ReactEmailRails::RenderModes::Subprocess,
13
- persistent: ReactEmailRails::RenderModes::Persistent,
14
- }.freeze
15
-
16
- KEY_TRANSFORMS = {
17
- camel: ->(key) { key.to_s.camelize },
18
- lower_camel: ->(key) { key.to_s.camelize(:lower) },
19
- dash: ->(key) { key.to_s.underscore.dasherize },
20
- snake: ->(key) { key.to_s.underscore },
21
- none: ->(key) { key },
22
- }.freeze
12
+ DEFAULT_PROP_TRANSFORMER = ->(props:) { props }
23
13
 
24
14
  DEFAULT_RENDER_COMMAND = lambda do
25
15
  if Rails.env.development?
@@ -32,14 +22,13 @@ class ReactEmailRails::Configuration
32
22
  attr_accessor(
33
23
  :component_path_resolver,
34
24
  :render_options,
35
- :transform_props,
36
25
  :on_render_error,
37
26
  :deep_merge_shared_props,
38
27
  :live_reload_url,
39
28
  )
40
29
 
41
30
  attr_reader(
42
- :render_mode,
31
+ :prop_transformer,
43
32
  :render_timeout,
44
33
  :render_process_max_requests,
45
34
  )
@@ -48,11 +37,10 @@ class ReactEmailRails::Configuration
48
37
  def default
49
38
  new.tap do |config|
50
39
  config.component_path_resolver = ->(mailer:, action:) { "#{mailer}/#{action}" }
51
- config.render_mode = :subprocess
52
40
  config.render_options = {}
53
- config.render_timeout = DEFAULT_RENDER_TIMEOUT
41
+ config.render_timeout = Rails.env.development? ? DEVELOPMENT_RENDER_TIMEOUT : DEFAULT_RENDER_TIMEOUT
54
42
  config.render_process_max_requests = DEFAULT_RENDER_PROCESS_MAX_REQUESTS
55
- config.transform_props = :lower_camel
43
+ config.prop_transformer = DEFAULT_PROP_TRANSFORMER
56
44
  config.on_render_error = nil
57
45
  config.deep_merge_shared_props = false
58
46
  config.live_reload_url = DEFAULT_LIVE_RELOAD_URL
@@ -66,12 +54,12 @@ class ReactEmailRails::Configuration
66
54
  live_reload_url.to_s.chomp("/")
67
55
  end
68
56
 
69
- def render_mode=(value)
70
- if (value.is_a?(Symbol) || value.is_a?(String)) && !RENDER_MODES.key?(value.to_sym)
71
- raise(ArgumentError, "Unknown react-email-rails render mode: #{value.inspect}")
57
+ def prop_transformer=(value)
58
+ unless value.respond_to?(:call)
59
+ raise(ArgumentError, "react-email-rails prop_transformer must be callable")
72
60
  end
73
61
 
74
- @render_mode = value
62
+ @prop_transformer = value
75
63
  end
76
64
 
77
65
  def render_timeout=(value)
@@ -88,14 +76,6 @@ class ReactEmailRails::Configuration
88
76
  @render_process_max_requests = value
89
77
  end
90
78
 
91
- def resolved_render_mode
92
- return render_mode unless render_mode.is_a?(Symbol) || render_mode.is_a?(String)
93
-
94
- RENDER_MODES.fetch(render_mode.to_sym) do
95
- raise(ArgumentError, "Unknown react-email-rails render mode: #{render_mode.inspect}")
96
- end
97
- end
98
-
99
79
  def resolve_render_options(context = nil)
100
80
  value =
101
81
  if render_options.respond_to?(:call) && context
@@ -106,7 +86,7 @@ class ReactEmailRails::Configuration
106
86
  render_options
107
87
  end
108
88
 
109
- deep_transform_keys(value.as_json, KEY_TRANSFORMS.fetch(:lower_camel))
89
+ camelize_render_option_keys(value.as_json)
110
90
  end
111
91
 
112
92
  private
@@ -116,23 +96,26 @@ class ReactEmailRails::Configuration
116
96
  end
117
97
 
118
98
  def serialize_props(props)
119
- deep_transform_keys(props.as_json, key_transform)
99
+ transform_serialized_props(props.as_json)
120
100
  end
121
101
 
122
- def key_transform
123
- transform = transform_props.respond_to?(:to_sym) ? transform_props.to_sym : transform_props
124
-
125
- KEY_TRANSFORMS.fetch(transform) do
126
- raise(ArgumentError, "Unknown react-email-rails prop transform: #{transform_props.inspect}")
102
+ def transform_serialized_props(value)
103
+ case value
104
+ when Hash
105
+ prop_transformer.call(props: value)
106
+ when Array
107
+ value.map { |item| transform_serialized_props(item) }
108
+ else
109
+ value
127
110
  end
128
111
  end
129
112
 
130
- def deep_transform_keys(value, transform)
113
+ def camelize_render_option_keys(value)
131
114
  case value
132
- when Array
133
- value.map { |item| deep_transform_keys(item, transform) }
134
115
  when Hash
135
- value.transform_keys { |key| transform.call(key) }.transform_values { |item| deep_transform_keys(item, transform) }
116
+ value.deep_transform_keys { |key| key.to_s.camelize(:lower) }
117
+ when Array
118
+ value.map { |item| camelize_render_option_keys(item) }
136
119
  else
137
120
  value
138
121
  end
@@ -33,8 +33,8 @@ class ReactEmailRails::MailerContext
33
33
  def message_context
34
34
  message = mailer.message
35
35
 
36
- MESSAGE_FIELDS.each_with_object({}) do |field, context|
37
- context[field.to_s] = message.public_send(field)
36
+ MESSAGE_FIELDS.to_h do |field|
37
+ [field.to_s, message.public_send(field)]
38
38
  end
39
39
  end
40
40
  end
@@ -6,10 +6,6 @@ module ReactEmailRails
6
6
  module RenderProtocol
7
7
  extend(self)
8
8
 
9
- def healthy_result?(result)
10
- result.status.success? && compatible_response?(JSON.parse(result.stdout))
11
- end
12
-
13
9
  def compatible_response?(body)
14
10
  body["ok"] == true && compatible_metadata?(body)
15
11
  end
@@ -1,10 +1,6 @@
1
- class ReactEmailRails::RenderModes::Persistent::Server
1
+ class ReactEmailRails::Renderer::Child
2
2
  STDERR_LIMIT = 8 * 1024
3
3
 
4
- Status = Data.define(:success) do
5
- def success? = success
6
- end
7
-
8
4
  def initialize(command)
9
5
  @command = command
10
6
  @mutex = Mutex.new
@@ -14,17 +10,35 @@ class ReactEmailRails::RenderModes::Persistent::Server
14
10
  @requests = 0
15
11
  end
16
12
 
17
- def capture(input:, timeout:, max_requests:)
13
+ def exchange(input, timeout:, max_requests:)
18
14
  with_retry_on_broken_pipe do
19
- capture_once(input:, timeout:).tap { recycle_if_needed(max_requests) }
15
+ request(input, timeout:).tap { recycle_if_needed(max_requests) }
20
16
  end
21
17
  end
22
18
 
23
19
  def health_check(timeout:)
24
- with_retry_on_broken_pipe { health_check_once(timeout:) }
20
+ with_retry_on_broken_pipe { request(JSON.generate(health: true), timeout:) }
25
21
  end
26
22
 
27
23
  def stop
24
+ if @mutex.owned?
25
+ stop_unlocked
26
+ else
27
+ @mutex.synchronize { stop_unlocked }
28
+ end
29
+ end
30
+
31
+ def abandon
32
+ release_io
33
+ rescue IOError
34
+ nil
35
+ end
36
+
37
+ private
38
+
39
+ attr_reader(:command)
40
+
41
+ def stop_unlocked
28
42
  if @wait_thread&.alive?
29
43
  terminate_process("TERM", @wait_thread.pid)
30
44
  @wait_thread.join(1)
@@ -37,14 +51,6 @@ class ReactEmailRails::RenderModes::Persistent::Server
37
51
  release_io
38
52
  end
39
53
 
40
- def abandon
41
- release_io
42
- rescue IOError
43
- nil
44
- end
45
-
46
- private
47
-
48
54
  def release_io
49
55
  [@stdin, @stdout, @stderr].compact.each { |io| io.close unless io.closed? }
50
56
  @stdin = @stdout = @stderr = @wait_thread = @stderr_reader = nil
@@ -58,38 +64,13 @@ class ReactEmailRails::RenderModes::Persistent::Server
58
64
  begin
59
65
  @mutex.synchronize(&block)
60
66
  rescue Errno::EPIPE, IOError
61
- failure("render process exited before responding")
67
+ failed("render process exited before responding")
62
68
  end
63
69
  end
64
70
 
65
- attr_reader(:command)
66
-
67
- def capture_once(input:, timeout:)
68
- response = request(input, timeout:)
69
- return failure(response["error"].to_s.presence || "render process failed") unless response["ok"]
70
-
71
- success(JSON.generate(
72
- {
73
- protocolVersion: response["protocolVersion"],
74
- packageVersion: response["packageVersion"],
75
- }.tap do |body|
76
- body[:html] = response["html"] if response.key?("html")
77
- body[:text] = response["text"] if response.key?("text")
78
- end,
79
- ))
80
- rescue JSON::ParserError => e
81
- failure("render process returned invalid JSON: #{e.message}")
82
- end
83
-
84
- def health_check_once(timeout:)
85
- response = request(JSON.generate(health: true), timeout:)
86
- response["ok"] ? success(JSON.generate(response)) : failure(response["error"].to_s.presence || "render process failed")
87
- rescue JSON::ParserError => e
88
- failure("render process returned invalid JSON: #{e.message}")
89
- end
90
-
91
71
  def start
92
- @stdin, @stdout, @stderr, @wait_thread = Open3.popen3(*command, "--persistent", pgroup: true)
72
+ release_io
73
+ @stdin, @stdout, @stderr, @wait_thread = Open3.popen3(*command, pgroup: true)
93
74
  @stderr_buffer = +""
94
75
  @stdout_buffer = +""
95
76
  @requests = 0
@@ -108,9 +89,12 @@ class ReactEmailRails::RenderModes::Persistent::Server
108
89
  @stdin.flush
109
90
 
110
91
  line = read_response_line(timeout)
111
- return { "ok" => false, "error" => "render process exited before responding" } unless line
92
+ return failed("render process exited before responding") unless line
112
93
 
113
94
  JSON.parse(line)
95
+ rescue JSON::ParserError => e
96
+ stop
97
+ failed("render process returned invalid JSON: #{e.message}")
114
98
  end
115
99
 
116
100
  def read_response_line(timeout)
@@ -128,7 +112,10 @@ class ReactEmailRails::RenderModes::Persistent::Server
128
112
 
129
113
  remaining = deadline - monotonic_time
130
114
  if remaining <= 0 || IO.select([@stdout], nil, nil, remaining).nil?
115
+ silent = line.empty? && @stdout_buffer.empty?
131
116
  stop
117
+ raise(Timeout::Error, unmatched_package_message) if silent
118
+
132
119
  raise(Timeout::Error)
133
120
  end
134
121
 
@@ -139,9 +126,14 @@ class ReactEmailRails::RenderModes::Persistent::Server
139
126
  end
140
127
  end
141
128
  rescue EOFError
129
+ wait_for_stderr
142
130
  line.presence
143
131
  end
144
132
 
133
+ def unmatched_package_message
134
+ "no response received; gem and npm package react-email-rails must both be #{ReactEmailRails::VERSION}"
135
+ end
136
+
145
137
  def consume_buffered_response_line
146
138
  separator = @stdout_buffer.index("\n")
147
139
  return unless separator
@@ -160,7 +152,8 @@ class ReactEmailRails::RenderModes::Persistent::Server
160
152
  end
161
153
 
162
154
  def drain_stderr
163
- @stderr.each do |chunk|
155
+ loop do
156
+ chunk = @stderr.readpartial(4096)
164
157
  @stderr_mutex.synchronize do
165
158
  @stderr_buffer << chunk
166
159
  @stderr_buffer = @stderr_buffer.byteslice(-STDERR_LIMIT, STDERR_LIMIT) if @stderr_buffer.bytesize > STDERR_LIMIT
@@ -170,20 +163,17 @@ class ReactEmailRails::RenderModes::Persistent::Server
170
163
  nil
171
164
  end
172
165
 
173
- def success(stdout)
174
- ReactEmailRails::RenderModes::Subprocess::CommandRunner::Result.new(
175
- stdout:,
176
- stderr: "",
177
- status: Status.new(true),
178
- )
166
+ def wait_for_stderr
167
+ @stderr_reader&.join(1)
179
168
  end
180
169
 
181
- def failure(message)
182
- ReactEmailRails::RenderModes::Subprocess::CommandRunner::Result.new(
183
- stdout: "",
184
- stderr: [message, stderr_buffer].reject(&:blank?).join("\n"),
185
- status: Status.new(false),
186
- )
170
+ def failed(message)
171
+ wait_for_stderr unless @wait_thread&.alive?
172
+
173
+ {
174
+ "ok" => false,
175
+ "error" => [message, stderr_buffer].reject(&:blank?).join("\n"),
176
+ }
187
177
  end
188
178
 
189
179
  def stderr_buffer
@@ -1,50 +1,86 @@
1
- class ReactEmailRails::RenderModes::Subprocess
1
+ class ReactEmailRails::Renderer
2
2
  class << self
3
3
  def healthy?(command:, timeout:)
4
- result = CommandRunner.capture([*command, "--health"], timeout:)
5
- ReactEmailRails::RenderProtocol.healthy_result?(result)
4
+ body = child_for(command).health_check(timeout:)
5
+ ReactEmailRails::RenderProtocol.compatible_response?(body)
6
6
  rescue StandardError
7
7
  false
8
8
  end
9
+
10
+ def stop_all
11
+ @mutex.synchronize do
12
+ @children&.each_value(&:stop)
13
+ @children&.clear
14
+ end
15
+ end
16
+
17
+ private
18
+
19
+ def exchange(command, input, timeout:, max_requests:)
20
+ child_for(command).exchange(input, timeout:, max_requests:)
21
+ end
22
+
23
+ def child_for(command)
24
+ @mutex.synchronize do
25
+ reset_after_fork
26
+ @children[command.map(&:to_s)] ||= Child.new(command)
27
+ end
28
+ end
29
+
30
+ def reset_after_fork
31
+ return if @owner_pid == Process.pid && @children
32
+
33
+ @children&.each_value(&:abandon)
34
+ @children = {}
35
+ @owner_pid = Process.pid
36
+ end
9
37
  end
10
38
 
39
+ @mutex = Mutex.new
40
+
11
41
  def initialize(payload:, label:)
12
42
  @payload = payload
13
43
  @label = label
14
44
  end
15
45
 
16
46
  def render
17
- run
18
- end
47
+ body = exchange
48
+ raise(render_error(error_message(body["error"]))) unless body["ok"]
19
49
 
20
- private
21
-
22
- attr_reader(:payload, :label)
23
-
24
- def run
25
- result = capture(payload_json)
26
- raise(render_error(error_message(result.stderr, result.status))) unless result.status.success?
27
-
28
- body = JSON.parse(result.stdout)
29
50
  validate_metadata!(body)
30
51
  build_rendered_email(body)
31
- rescue JSON::ParserError => e
32
- raise(render_error("render process returned invalid JSON: #{e.message}"))
33
52
  rescue KeyError => e
34
53
  raise(render_error("render process returned an invalid response: missing #{e.key.inspect}"))
35
54
  end
36
55
 
37
- def capture(input)
56
+ private
57
+
58
+ attr_reader(:payload, :label)
59
+
60
+ def exchange
38
61
  with_capture_rescues do
39
62
  validate_command!
40
- CommandRunner.capture(command, input:, timeout: render_timeout)
63
+ self.class.send(
64
+ :exchange,
65
+ command,
66
+ payload_json,
67
+ timeout: render_timeout,
68
+ max_requests: render_process_max_requests,
69
+ )
41
70
  end
42
71
  end
43
72
 
44
73
  def with_capture_rescues
45
74
  yield
46
- rescue Timeout::Error
47
- raise(render_error("render process timed out after #{render_timeout}s"))
75
+ rescue Timeout::Error => e
76
+ detail = e.message
77
+ suffix =
78
+ if detail.present? && detail != "Timeout::Error" && detail != "execution expired"
79
+ " (#{detail})"
80
+ else
81
+ ""
82
+ end
83
+ raise(render_error("render process timed out after #{render_timeout}s#{suffix}"))
48
84
  rescue Errno::ENOENT
49
85
  raise(render_error("render command not found: #{command.inspect}"))
50
86
  end
@@ -57,12 +93,16 @@ class ReactEmailRails::RenderModes::Subprocess
57
93
  ReactEmailRails.configuration.render_timeout
58
94
  end
59
95
 
96
+ def render_process_max_requests
97
+ ReactEmailRails.configuration.render_process_max_requests
98
+ end
99
+
60
100
  def payload_json
61
101
  @payload_json ||= JSON.generate(payload)
62
102
  end
63
103
 
64
- def error_message(stderr, status)
65
- stderr.to_s.strip.presence || "render process exited with #{status}"
104
+ def error_message(message)
105
+ message.to_s.strip.presence || "render process failed"
66
106
  end
67
107
 
68
108
  def validate_command!
@@ -97,3 +137,5 @@ class ReactEmailRails::RenderModes::Subprocess
97
137
  ReactEmailRails::RenderError.new("React Email render failed for #{label}: #{message}")
98
138
  end
99
139
  end
140
+
141
+ at_exit { ReactEmailRails::Renderer.stop_all }
@@ -1,3 +1,3 @@
1
1
  module ReactEmailRails
2
- VERSION = "0.10.0"
2
+ VERSION = "0.12.0"
3
3
  end
@@ -6,6 +6,7 @@ require("active_support/notifications")
6
6
  require("active_support/core_ext/object/blank")
7
7
  require("active_support/core_ext/object/json")
8
8
  require("active_support/core_ext/hash/deep_merge")
9
+ require("active_support/core_ext/hash/keys")
9
10
  require("active_support/inflector")
10
11
  require("rails/railtie")
11
12
 
@@ -16,12 +17,8 @@ require_relative("react_email_rails/render_protocol")
16
17
  require_relative("react_email_rails/action_mailer")
17
18
  require_relative("react_email_rails/render_error")
18
19
  require_relative("react_email_rails/rendered_email")
19
- require_relative("react_email_rails/render_modes")
20
- require_relative("react_email_rails/render_modes/subprocess")
21
- require_relative("react_email_rails/render_modes/subprocess/command_runner")
22
- require_relative("react_email_rails/render_modes/persistent")
23
- require_relative("react_email_rails/render_modes/persistent/server")
24
- require_relative("react_email_rails/render_modes/persistent/command_runner")
20
+ require_relative("react_email_rails/renderer")
21
+ require_relative("react_email_rails/renderer/child")
25
22
  require_relative("react_email_rails/configuration")
26
23
  require_relative("react_email_rails/tasks")
27
24
  require_relative("react_email_rails/props_resolver")
@@ -45,7 +42,7 @@ module ReactEmailRails
45
42
  payload[:renderOptions] = render_options if render_options.present?
46
43
 
47
44
  instrument(component:) do
48
- configuration.resolved_render_mode.new(payload:, label: component).render
45
+ renderer_class.new(payload:, label: component).render
49
46
  end
50
47
  rescue ReactEmailRails::RenderError => e
51
48
  configuration.on_render_error&.call(e, component:)
@@ -53,7 +50,7 @@ module ReactEmailRails
53
50
  end
54
51
 
55
52
  def healthy?
56
- configuration.resolved_render_mode.healthy?(
53
+ Renderer.healthy?(
57
54
  command: configuration.send(:resolved_render_command),
58
55
  timeout: configuration.render_timeout,
59
56
  )
@@ -63,6 +60,10 @@ module ReactEmailRails
63
60
 
64
61
  private
65
62
 
63
+ def renderer_class
64
+ Renderer
65
+ end
66
+
66
67
  def serialized_props(value)
67
68
  configuration.send(:serialize_props, value)
68
69
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: react-email-rails
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.10.0
4
+ version: 0.12.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Supertape
@@ -158,14 +158,10 @@ files:
158
158
  - lib/react_email_rails/props_resolver.rb
159
159
  - lib/react_email_rails/railtie.rb
160
160
  - lib/react_email_rails/render_error.rb
161
- - lib/react_email_rails/render_modes.rb
162
- - lib/react_email_rails/render_modes/persistent.rb
163
- - lib/react_email_rails/render_modes/persistent/command_runner.rb
164
- - lib/react_email_rails/render_modes/persistent/server.rb
165
- - lib/react_email_rails/render_modes/subprocess.rb
166
- - lib/react_email_rails/render_modes/subprocess/command_runner.rb
167
161
  - lib/react_email_rails/render_protocol.rb
168
162
  - lib/react_email_rails/rendered_email.rb
163
+ - lib/react_email_rails/renderer.rb
164
+ - lib/react_email_rails/renderer/child.rb
169
165
  - lib/react_email_rails/shared_props.rb
170
166
  - lib/react_email_rails/tasks.rb
171
167
  - lib/react_email_rails/version.rb
@@ -1,40 +0,0 @@
1
- class ReactEmailRails::RenderModes::Persistent::CommandRunner
2
- @mutex = Mutex.new
3
-
4
- class << self
5
- def capture(command, input:, timeout:, max_requests: nil)
6
- server_for(command).capture(input:, timeout:, max_requests:)
7
- end
8
-
9
- def healthy?(command, timeout:)
10
- result = server_for(command).health_check(timeout:)
11
- ReactEmailRails::RenderProtocol.healthy_result?(result)
12
- rescue StandardError
13
- false
14
- end
15
-
16
- def stop_all
17
- @mutex.synchronize do
18
- @servers&.each_value(&:stop)
19
- @servers&.clear
20
- end
21
- end
22
-
23
- private
24
-
25
- def server_for(command)
26
- @mutex.synchronize do
27
- reset_after_fork
28
- @servers[command.map(&:to_s)] ||= ReactEmailRails::RenderModes::Persistent::Server.new(command)
29
- end
30
- end
31
-
32
- def reset_after_fork
33
- return if @owner_pid == Process.pid && @servers
34
-
35
- @servers&.each_value(&:abandon)
36
- @servers = {}
37
- @owner_pid = Process.pid
38
- end
39
- end
40
- end
@@ -1,26 +0,0 @@
1
- class ReactEmailRails::RenderModes::Persistent < ReactEmailRails::RenderModes::Subprocess
2
- class << self
3
- def healthy?(command:, timeout:)
4
- CommandRunner.healthy?(command, timeout:)
5
- end
6
- end
7
-
8
- private
9
-
10
- def capture(input)
11
- with_capture_rescues do
12
- CommandRunner.capture(
13
- command,
14
- input:,
15
- timeout: render_timeout,
16
- max_requests: render_process_max_requests,
17
- )
18
- end
19
- end
20
-
21
- def render_process_max_requests
22
- ReactEmailRails.configuration.render_process_max_requests
23
- end
24
- end
25
-
26
- at_exit { ReactEmailRails::RenderModes::Persistent::CommandRunner.stop_all }
@@ -1,56 +0,0 @@
1
- class ReactEmailRails::RenderModes::Subprocess::CommandRunner
2
- Result = Data.define(:stdout, :stderr, :status)
3
-
4
- class << self
5
- def capture(command, input: nil, timeout:)
6
- new(command:, input:, timeout:).capture
7
- end
8
- end
9
-
10
- def initialize(command:, input:, timeout:)
11
- @command = command
12
- @input = input
13
- @timeout = timeout
14
- end
15
-
16
- def capture
17
- Open3.popen3(*command, pgroup: true) do |stdin, stdout, stderr, wait_thread|
18
- out_reader = read_async(stdout)
19
- err_reader = read_async(stderr)
20
-
21
- write_input(stdin)
22
-
23
- if wait_thread.join(timeout).nil?
24
- terminate_process("KILL", wait_thread.pid)
25
- wait_thread.join
26
- out_reader.kill
27
- err_reader.kill
28
- raise(Timeout::Error)
29
- end
30
-
31
- Result.new(stdout: out_reader.value, stderr: err_reader.value, status: wait_thread.value)
32
- end
33
- end
34
-
35
- private
36
-
37
- attr_reader(:command, :input, :timeout)
38
-
39
- def read_async(io)
40
- Thread.new { io.read }.tap { |thread| thread.report_on_exception = false }
41
- end
42
-
43
- def write_input(stdin)
44
- stdin.write(input) if input
45
- rescue Errno::EPIPE
46
- nil
47
- ensure
48
- stdin.close
49
- end
50
-
51
- def terminate_process(signal, pid)
52
- Process.kill(signal, -pid)
53
- rescue Errno::ESRCH, Errno::EPERM
54
- nil
55
- end
56
- end
@@ -1 +0,0 @@
1
- module ReactEmailRails::RenderModes; end