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 +4 -4
- data/CHANGELOG.md +22 -0
- data/README.md +51 -143
- data/lib/generators/react_email_rails/install_generator.rb +1 -2
- data/lib/generators/react_email_rails/templates/email/component.tsx +1 -1
- data/lib/react_email_rails/configuration.rb +23 -40
- data/lib/react_email_rails/mailer_context.rb +2 -2
- data/lib/react_email_rails/render_protocol.rb +0 -4
- data/lib/react_email_rails/{render_modes/persistent/server.rb → renderer/child.rb} +48 -58
- data/lib/react_email_rails/{render_modes/subprocess.rb → renderer.rb} +64 -22
- data/lib/react_email_rails/version.rb +1 -1
- data/lib/react_email_rails.rb +9 -8
- metadata +3 -7
- data/lib/react_email_rails/render_modes/persistent/command_runner.rb +0 -40
- data/lib/react_email_rails/render_modes/persistent.rb +0 -26
- data/lib/react_email_rails/render_modes/subprocess/command_runner.rb +0 -56
- data/lib/react_email_rails/render_modes.rb +0 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: e2dc04e0c361e5517d66cf3a60a274b090bc5978b82ee4a77c07758ac8ca82e7
|
|
4
|
+
data.tar.gz: 027c7e6c6e4510621b6738c271f273a1a89a248e6de59772bc0af6fec7eebe4d
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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 |
|
|
47
|
-
| Rails |
|
|
48
|
-
| Node |
|
|
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
|
-
|
|
|
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
|
|
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
|
|
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 "
|
|
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/
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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.
|
|
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.
|
|
389
|
-
| `mailer.
|
|
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.
|
|
344
|
+
| `message.from`, `message.reply_to` | `["app@example.com"]` |
|
|
394
345
|
|
|
395
|
-
Context is merged before prop serialization, so keys follow `config.
|
|
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.
|
|
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
|
-
|
|
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
|
|
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 "
|
|
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 "
|
|
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,
|
|
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.
|
|
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
|
-
| `
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
452
|
+
### Render Process
|
|
509
453
|
|
|
510
|
-
|
|
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
|
-
|
|
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.
|
|
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).
|
|
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
|
|
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 `
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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,
|
|
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.
|
|
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
|
-
"
|
|
11
|
-
"@react-email/components",
|
|
10
|
+
"react-email",
|
|
12
11
|
"react",
|
|
13
12
|
"react-dom",
|
|
14
13
|
].freeze
|
|
@@ -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
|
-
|
|
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
|
-
:
|
|
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.
|
|
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
|
|
70
|
-
|
|
71
|
-
raise(ArgumentError, "
|
|
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
|
-
@
|
|
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
|
-
|
|
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
|
-
|
|
99
|
+
transform_serialized_props(props.as_json)
|
|
120
100
|
end
|
|
121
101
|
|
|
122
|
-
def
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
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
|
|
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.
|
|
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.
|
|
37
|
-
|
|
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::
|
|
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
|
|
13
|
+
def exchange(input, timeout:, max_requests:)
|
|
18
14
|
with_retry_on_broken_pipe do
|
|
19
|
-
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
174
|
-
|
|
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
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
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::
|
|
1
|
+
class ReactEmailRails::Renderer
|
|
2
2
|
class << self
|
|
3
3
|
def healthy?(command:, timeout:)
|
|
4
|
-
|
|
5
|
-
ReactEmailRails::RenderProtocol.
|
|
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
|
-
|
|
18
|
-
|
|
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
|
-
|
|
56
|
+
private
|
|
57
|
+
|
|
58
|
+
attr_reader(:payload, :label)
|
|
59
|
+
|
|
60
|
+
def exchange
|
|
38
61
|
with_capture_rescues do
|
|
39
62
|
validate_command!
|
|
40
|
-
|
|
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
|
-
|
|
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(
|
|
65
|
-
|
|
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 }
|
data/lib/react_email_rails.rb
CHANGED
|
@@ -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/
|
|
20
|
-
require_relative("react_email_rails/
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|