react-email-rails 0.11.1 → 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 +13 -0
- data/README.md +25 -39
- data/lib/react_email_rails/configuration.rb +23 -40
- 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,18 @@
|
|
|
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
|
+
|
|
3
16
|
## 0.11.1
|
|
4
17
|
|
|
5
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.
|
data/README.md
CHANGED
|
@@ -8,7 +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: components render through Vite in development and a prebuilt renderer bundle in production,
|
|
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.
|
|
12
12
|
|
|
13
13
|
## Status
|
|
14
14
|
|
|
@@ -159,11 +159,11 @@ React Email also provides primitives like [`<Button>`, `<Heading>`, `<Tailwind>`
|
|
|
159
159
|
|
|
160
160
|
## Rendering
|
|
161
161
|
|
|
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.
|
|
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.
|
|
163
163
|
|
|
164
|
-
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.
|
|
165
165
|
|
|
166
|
-
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.
|
|
167
167
|
|
|
168
168
|
### Live-Reloading Previews
|
|
169
169
|
|
|
@@ -322,7 +322,7 @@ Set `config.deep_merge_shared_props = true` to make deep merging the default for
|
|
|
322
322
|
|
|
323
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.
|
|
324
324
|
|
|
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.
|
|
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:
|
|
326
326
|
|
|
327
327
|
```tsx
|
|
328
328
|
import type { Mailer, Message } from "react-email-rails"
|
|
@@ -336,14 +336,14 @@ type WelcomeProps = {
|
|
|
336
336
|
|
|
337
337
|
| Prop | Example |
|
|
338
338
|
|------|---------|
|
|
339
|
-
| `mailer.
|
|
340
|
-
| `mailer.
|
|
339
|
+
| `mailer.mailer_name` | `"account_mailer"` |
|
|
340
|
+
| `mailer.action_name` | `"welcome"` |
|
|
341
341
|
| `message.subject` | `"Welcome"` |
|
|
342
342
|
| `message.to` | `["account@example.com"]` |
|
|
343
343
|
| `message.cc`, `message.bcc` | `["…"]` or `null` |
|
|
344
|
-
| `message.from`, `message.
|
|
344
|
+
| `message.from`, `message.reply_to` | `["app@example.com"]` |
|
|
345
345
|
|
|
346
|
-
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.
|
|
347
347
|
|
|
348
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.
|
|
349
349
|
|
|
@@ -351,7 +351,7 @@ Per-mail and shared props win on conflict, so a prop named `mailer` or `message`
|
|
|
351
351
|
|
|
352
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).
|
|
353
353
|
|
|
354
|
-
|
|
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.
|
|
355
355
|
|
|
356
356
|
### Component Files
|
|
357
357
|
|
|
@@ -409,7 +409,7 @@ export default function Welcome() {
|
|
|
409
409
|
|
|
410
410
|
Configuration lives in two places:
|
|
411
411
|
|
|
412
|
-
- Rails configuration controls mailer behavior, prop handling,
|
|
412
|
+
- Rails configuration controls mailer behavior, prop handling, timeouts, and error hooks.
|
|
413
413
|
- Vite configuration controls email component discovery and the renderer bundle.
|
|
414
414
|
|
|
415
415
|
### Rails Configuration
|
|
@@ -418,17 +418,16 @@ Override Rails-side defaults in `config/initializers/react_email_rails.rb`:
|
|
|
418
418
|
|
|
419
419
|
```ruby
|
|
420
420
|
ReactEmailRails.configure do |config|
|
|
421
|
-
# config.
|
|
421
|
+
# config.render_timeout = 10
|
|
422
422
|
end
|
|
423
423
|
```
|
|
424
424
|
|
|
425
425
|
| Option | Default |
|
|
426
426
|
|--------|---------|
|
|
427
427
|
| `component_path_resolver` | `->(mailer:, action:) { "#{mailer}/#{action}" }` |
|
|
428
|
-
| `
|
|
429
|
-
| `render_mode` | `:subprocess` |
|
|
428
|
+
| `prop_transformer` | `->(props:) { props }` |
|
|
430
429
|
| `render_options` | `{}` |
|
|
431
|
-
| `render_timeout` | `10` seconds |
|
|
430
|
+
| `render_timeout` | `30` seconds in development, `10` seconds otherwise |
|
|
432
431
|
| `render_process_max_requests` | `1_000` |
|
|
433
432
|
| `on_render_error` | `nil` |
|
|
434
433
|
| `deep_merge_shared_props` | `false` |
|
|
@@ -436,48 +435,35 @@ end
|
|
|
436
435
|
|
|
437
436
|
### Prop Transformation
|
|
438
437
|
|
|
439
|
-
|
|
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).
|
|
440
439
|
|
|
441
|
-
|
|
442
|
-
|-------|---------|
|
|
443
|
-
| `:camel` | `AccountName` |
|
|
444
|
-
| `:lower_camel` | `accountName` |
|
|
445
|
-
| `:dash` | `account-name` |
|
|
446
|
-
| `:snake` | `account_name` |
|
|
447
|
-
| `:none` | preserves serialized keys |
|
|
440
|
+
To work with `snake_case` in Ruby and `camelCase` in components:
|
|
448
441
|
|
|
449
442
|
```ruby
|
|
450
443
|
ReactEmailRails.configure do |config|
|
|
451
|
-
config.
|
|
444
|
+
config.prop_transformer = lambda do |props:|
|
|
445
|
+
props.deep_transform_keys { |key| key.to_s.camelize(:lower) }
|
|
446
|
+
end
|
|
452
447
|
end
|
|
453
448
|
```
|
|
454
449
|
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
### 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.
|
|
458
451
|
|
|
459
|
-
|
|
452
|
+
### Render Process
|
|
460
453
|
|
|
461
|
-
|
|
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.
|
|
462
455
|
|
|
463
|
-
|
|
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:
|
|
464
457
|
|
|
465
458
|
```ruby
|
|
466
459
|
ReactEmailRails.configure do |config|
|
|
467
|
-
config.
|
|
460
|
+
config.render_process_max_requests = 1
|
|
468
461
|
end
|
|
469
462
|
```
|
|
470
463
|
|
|
471
|
-
Persistent mode details:
|
|
472
|
-
|
|
473
|
-
- Renders are newline-delimited JSON and processed one at a time.
|
|
474
|
-
- Throughput scales with more worker processes.
|
|
475
|
-
- It is fork-safe; clustered Puma and forking job runners spawn one Node child per worker.
|
|
476
|
-
- The child recycles after `render_process_max_requests` renders. Set that option to `nil` to disable recycling.
|
|
477
|
-
|
|
478
464
|
### Render Options
|
|
479
465
|
|
|
480
|
-
`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`.
|
|
481
467
|
|
|
482
468
|
```ruby
|
|
483
469
|
ReactEmailRails.configure do |config|
|
|
@@ -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
|
|
@@ -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
|