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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 727de1abdcab5dab75ca38cf157b919b01a74fc6e8c1aef873a658ebed812a68
4
- data.tar.gz: 023f5e0bd2ac88a010e8db0a3f1bcdaac28b2cf9424106ad45d2a6ab68a8411a
3
+ metadata.gz: e2dc04e0c361e5517d66cf3a60a274b090bc5978b82ee4a77c07758ac8ca82e7
4
+ data.tar.gz: 027c7e6c6e4510621b6738c271f273a1a89a248e6de59772bc0af6fec7eebe4d
5
5
  SHA512:
6
- metadata.gz: ad68609561536c4eb37215e578d1b94ec37fb77ef80c9334b68e2277a29c10d5d13040fe5817810e430ff067fe7d2ac03960815649897ab424b0fade017981bb
7
- data.tar.gz: 77e460b82033c452fd7a4baaa2b711dba701dce6f7c2e572b10f2ddd4b7ad5b87b0b8572b3cd47382adc72a247227a57a8a9e2109ed717e7b23f7d44619ba08a
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, with 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.
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 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.
165
165
 
166
- 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.
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. 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:
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.mailerName` | `"account_mailer"` |
340
- | `mailer.actionName` | `"welcome"` |
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.replyTo` | `["app@example.com"]` |
344
+ | `message.from`, `message.reply_to` | `["app@example.com"]` |
345
345
 
346
- 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.
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
- 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.
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, rendering mode, timeouts, and error hooks.
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.render_mode = :persistent
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
- | `transform_props` | `:lower_camel` |
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
- 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).
440
439
 
441
- | Value | Example |
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.transform_props = :none
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
- Only prop keys are transformed. Values are always serialized with `as_json`.
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
- 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
460
453
 
461
- `: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.
462
455
 
463
- 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:
464
457
 
465
458
  ```ruby
466
459
  ReactEmailRails.configure do |config|
467
- config.render_mode = :persistent
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). Use `html` and `text` keys to configure each output. Option keys are camelized before they cross into JavaScript, so `html_to_text_options` becomes `htmlToTextOptions`.
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
- 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
@@ -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.11.1"
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.11.1
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