react-email-rails 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/CHANGELOG.md +5 -0
- data/CONTRIBUTING.md +68 -0
- data/LICENSE.md +21 -0
- data/README.md +492 -0
- data/SECURITY.md +5 -0
- data/lib/generators/react_email_rails/USAGE +19 -0
- data/lib/generators/react_email_rails/email_generator.rb +224 -0
- data/lib/generators/react_email_rails/install_generator.rb +211 -0
- data/lib/generators/react_email_rails/templates/USAGE +33 -0
- data/lib/generators/react_email_rails/templates/email/application_mailer.rb.tt +5 -0
- data/lib/generators/react_email_rails/templates/email/component.tsx +14 -0
- data/lib/generators/react_email_rails/templates/email/mailer.rb.tt +17 -0
- data/lib/generators/react_email_rails/templates/email/mailer_preview.rb.tt +14 -0
- data/lib/generators/react_email_rails/templates/email/mailer_test.rb.tt +28 -0
- data/lib/generators/react_email_rails/templates/initializer.rb +3 -0
- data/lib/generators/react_email_rails/templates/vite.config.ts +6 -0
- data/lib/react-email-rails.rb +1 -0
- data/lib/react_email_rails/action_mailer.rb +31 -0
- data/lib/react_email_rails/configuration.rb +145 -0
- data/lib/react_email_rails/props_resolver.rb +48 -0
- data/lib/react_email_rails/railtie.rb +16 -0
- data/lib/react_email_rails/render_error.rb +1 -0
- data/lib/react_email_rails/render_modes/persistent/command_runner.rb +44 -0
- data/lib/react_email_rails/render_modes/persistent/server.rb +204 -0
- data/lib/react_email_rails/render_modes/persistent.rb +28 -0
- data/lib/react_email_rails/render_modes/subprocess/command_runner.rb +56 -0
- data/lib/react_email_rails/render_modes/subprocess.rb +99 -0
- data/lib/react_email_rails/render_modes.rb +1 -0
- data/lib/react_email_rails/render_protocol.rb +21 -0
- data/lib/react_email_rails/rendered_email.rb +3 -0
- data/lib/react_email_rails/version.rb +3 -0
- data/lib/react_email_rails.rb +58 -0
- metadata +194 -0
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
class ReactEmailRails::Configuration
|
|
2
|
+
BUNDLE_PATH = "tmp/react-email-rails/emails.js"
|
|
3
|
+
DEV_RENDER_BIN = "node_modules/.bin/react-email-rails-dev"
|
|
4
|
+
|
|
5
|
+
DEFAULT_RENDER_TIMEOUT = 10
|
|
6
|
+
DEFAULT_RENDER_PROCESS_MAX_REQUESTS = 1_000
|
|
7
|
+
|
|
8
|
+
RENDER_MODES = {
|
|
9
|
+
subprocess: ReactEmailRails::RenderModes::Subprocess,
|
|
10
|
+
persistent: ReactEmailRails::RenderModes::Persistent,
|
|
11
|
+
}.freeze
|
|
12
|
+
|
|
13
|
+
KEY_TRANSFORMS = {
|
|
14
|
+
camel: ->(key) { key.to_s.camelize },
|
|
15
|
+
lower_camel: ->(key) { key.to_s.camelize(:lower) },
|
|
16
|
+
dash: ->(key) { key.to_s.underscore.dasherize },
|
|
17
|
+
snake: ->(key) { key.to_s.underscore },
|
|
18
|
+
none: ->(key) { key },
|
|
19
|
+
}.freeze
|
|
20
|
+
|
|
21
|
+
DEFAULT_RENDER_COMMAND = lambda do
|
|
22
|
+
if Rails.env.development?
|
|
23
|
+
[Rails.root.join(DEV_RENDER_BIN).to_s]
|
|
24
|
+
else
|
|
25
|
+
["node", Rails.root.join(BUNDLE_PATH).to_s]
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
DEFAULT_VERIFY_RENDER_ON_BOOT = false
|
|
30
|
+
|
|
31
|
+
attr_accessor(
|
|
32
|
+
:component_path_resolver,
|
|
33
|
+
:render_options,
|
|
34
|
+
:transform_props,
|
|
35
|
+
:on_render_error,
|
|
36
|
+
:verify_render_on_boot,
|
|
37
|
+
)
|
|
38
|
+
attr_reader(
|
|
39
|
+
:render_mode,
|
|
40
|
+
:render_timeout,
|
|
41
|
+
:render_process_max_requests,
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
class << self
|
|
45
|
+
def default
|
|
46
|
+
new.tap do |config|
|
|
47
|
+
config.component_path_resolver = ->(mailer:, action:) { "#{mailer}/#{action}" }
|
|
48
|
+
config.render_mode = :subprocess
|
|
49
|
+
config.render_options = {}
|
|
50
|
+
config.render_timeout = DEFAULT_RENDER_TIMEOUT
|
|
51
|
+
config.render_process_max_requests = DEFAULT_RENDER_PROCESS_MAX_REQUESTS
|
|
52
|
+
config.transform_props = :lower_camel
|
|
53
|
+
config.on_render_error = nil
|
|
54
|
+
config.verify_render_on_boot = DEFAULT_VERIFY_RENDER_ON_BOOT
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def verify_render_on_boot?
|
|
60
|
+
verify_render_on_boot.respond_to?(:call) ? !!verify_render_on_boot.call : !!verify_render_on_boot
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def render_mode=(value)
|
|
64
|
+
if (value.is_a?(Symbol) || value.is_a?(String)) && !RENDER_MODES.key?(value.to_sym)
|
|
65
|
+
raise(ArgumentError, "Unknown react-email-rails render mode: #{value.inspect}")
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
@render_mode = value
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def render_timeout=(value)
|
|
72
|
+
raise(ArgumentError, "react-email-rails render_timeout must be positive") unless value.is_a?(Numeric) && value.positive?
|
|
73
|
+
|
|
74
|
+
@render_timeout = value
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def render_process_max_requests=(value)
|
|
78
|
+
unless value.nil? || (value.is_a?(Integer) && value.positive?)
|
|
79
|
+
raise(ArgumentError, "react-email-rails render_process_max_requests must be a positive integer or nil")
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
@render_process_max_requests = value
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def resolved_render_mode
|
|
86
|
+
return render_mode unless render_mode.is_a?(Symbol) || render_mode.is_a?(String)
|
|
87
|
+
|
|
88
|
+
RENDER_MODES.fetch(render_mode.to_sym) do
|
|
89
|
+
raise(ArgumentError, "Unknown react-email-rails render mode: #{render_mode.inspect}")
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def resolve_render_options(context = nil)
|
|
94
|
+
value =
|
|
95
|
+
if render_options.respond_to?(:call) && context
|
|
96
|
+
context.instance_exec(&render_options)
|
|
97
|
+
elsif render_options.respond_to?(:call)
|
|
98
|
+
render_options.call
|
|
99
|
+
else
|
|
100
|
+
render_options
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
deep_camelize_keys(value.as_json)
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
private
|
|
107
|
+
|
|
108
|
+
def resolved_render_command
|
|
109
|
+
DEFAULT_RENDER_COMMAND.call
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def serialize_props(props)
|
|
113
|
+
deep_transform_keys(props.as_json, key_transform)
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def key_transform
|
|
117
|
+
transform = transform_props.respond_to?(:to_sym) ? transform_props.to_sym : transform_props
|
|
118
|
+
|
|
119
|
+
KEY_TRANSFORMS.fetch(transform) do
|
|
120
|
+
raise(ArgumentError, "Unknown react-email-rails prop transform: #{transform_props.inspect}")
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def deep_transform_keys(value, transform)
|
|
125
|
+
case value
|
|
126
|
+
when Array
|
|
127
|
+
value.map { |item| deep_transform_keys(item, transform) }
|
|
128
|
+
when Hash
|
|
129
|
+
value.transform_keys { |key| transform.call(key) }.transform_values { |item| deep_transform_keys(item, transform) }
|
|
130
|
+
else
|
|
131
|
+
value
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def deep_camelize_keys(value)
|
|
136
|
+
case value
|
|
137
|
+
when Array
|
|
138
|
+
value.map { |item| deep_camelize_keys(item) }
|
|
139
|
+
when Hash
|
|
140
|
+
value.transform_keys { |key| key.to_s.camelize(:lower) }.transform_values { |item| deep_camelize_keys(item) }
|
|
141
|
+
else
|
|
142
|
+
value
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
end
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
class ReactEmailRails::PropsResolver
|
|
2
|
+
INTERNAL_ASSIGN_PREFIX = "_"
|
|
3
|
+
# Some Action Mailer framework assigns do not use the internal `_` prefix.
|
|
4
|
+
RESERVED_ASSIGNS = ["params", "rendered_format"].freeze
|
|
5
|
+
|
|
6
|
+
def initialize(mailer)
|
|
7
|
+
@mailer = mailer
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def resolve(react, props)
|
|
11
|
+
case react
|
|
12
|
+
when String
|
|
13
|
+
[react, props || {}]
|
|
14
|
+
when Hash
|
|
15
|
+
raise(ArgumentError, "Parameter `props` is not allowed when passing a Hash to `react`") if props
|
|
16
|
+
|
|
17
|
+
[inferred_component, react]
|
|
18
|
+
when true
|
|
19
|
+
[inferred_component, assign_props]
|
|
20
|
+
else
|
|
21
|
+
raise(ArgumentError, "`react` must be a String, Hash, or true")
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
private
|
|
26
|
+
|
|
27
|
+
attr_reader(:mailer)
|
|
28
|
+
|
|
29
|
+
def inferred_component
|
|
30
|
+
ReactEmailRails.configuration.component_path_resolver.call(
|
|
31
|
+
mailer: mailer.class.mailer_name,
|
|
32
|
+
action: mailer.action_name,
|
|
33
|
+
)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def assign_props
|
|
37
|
+
# `react: true` infers the component name; instance vars become props only when the
|
|
38
|
+
# mailer opts in. Without it, the component renders with no props.
|
|
39
|
+
return {} unless mailer.class.react_email_use_instance_props
|
|
40
|
+
|
|
41
|
+
mailer.instance_variables.each_with_object({}) do |ivar, props|
|
|
42
|
+
name = ivar.to_s.delete_prefix("@")
|
|
43
|
+
next if name.start_with?(INTERNAL_ASSIGN_PREFIX) || RESERVED_ASSIGNS.include?(name)
|
|
44
|
+
|
|
45
|
+
props[name] = mailer.instance_variable_get(ivar)
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
class ReactEmailRails::Railtie < Rails::Railtie
|
|
2
|
+
initializer("react-email-rails.action_mailer") do
|
|
3
|
+
ActiveSupport.on_load(:action_mailer) do
|
|
4
|
+
prepend(ReactEmailRails::ActionMailer)
|
|
5
|
+
end
|
|
6
|
+
end
|
|
7
|
+
|
|
8
|
+
config.after_initialize do
|
|
9
|
+
if ReactEmailRails.configuration.verify_render_on_boot? && !ReactEmailRails.healthy?
|
|
10
|
+
Rails.logger.error(
|
|
11
|
+
"[react-email-rails] render verification failed for command: " \
|
|
12
|
+
"#{ReactEmailRails.configuration.send(:resolved_render_command).inspect}",
|
|
13
|
+
)
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
class ReactEmailRails::RenderError < StandardError; end
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
class ReactEmailRails::RenderModes::Persistent::CommandRunner
|
|
2
|
+
class << self
|
|
3
|
+
def capture(command, input:, timeout:, max_requests: nil)
|
|
4
|
+
server_for(command).capture(input:, timeout:, max_requests:)
|
|
5
|
+
end
|
|
6
|
+
|
|
7
|
+
def healthy?(command, timeout:)
|
|
8
|
+
result = server_for(command).health_check(timeout:)
|
|
9
|
+
result.status.success? && ReactEmailRails::RenderProtocol.compatible_response?(JSON.parse(result.stdout))
|
|
10
|
+
rescue StandardError
|
|
11
|
+
false
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def stop_all
|
|
15
|
+
@mutex&.synchronize do
|
|
16
|
+
@servers&.each_value(&:stop)
|
|
17
|
+
@servers&.clear
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
private
|
|
22
|
+
|
|
23
|
+
def server_for(command)
|
|
24
|
+
@mutex ||= Mutex.new
|
|
25
|
+
|
|
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
|
+
# A forked child inherits the parent's Server objects and the open pipes to
|
|
33
|
+
# the parent's render processes. Sharing those pipes interleaves requests
|
|
34
|
+
# and responses across processes, so drop them (without killing the
|
|
35
|
+
# parent-owned process) and let this process spawn its own on demand.
|
|
36
|
+
def reset_after_fork
|
|
37
|
+
return if @owner_pid == Process.pid && @servers
|
|
38
|
+
|
|
39
|
+
@servers&.each_value(&:abandon)
|
|
40
|
+
@servers = {}
|
|
41
|
+
@owner_pid = Process.pid
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
class ReactEmailRails::RenderModes::Persistent::Server
|
|
2
|
+
STDERR_LIMIT = 8 * 1024
|
|
3
|
+
|
|
4
|
+
Status = Data.define(:success) do
|
|
5
|
+
def success? = success
|
|
6
|
+
end
|
|
7
|
+
|
|
8
|
+
def initialize(command)
|
|
9
|
+
@command = command
|
|
10
|
+
@mutex = Mutex.new
|
|
11
|
+
@stderr_buffer = +""
|
|
12
|
+
@stderr_mutex = Mutex.new
|
|
13
|
+
@stdout_buffer = +""
|
|
14
|
+
@requests = 0
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def capture(input:, timeout:, max_requests:)
|
|
18
|
+
@mutex.synchronize do
|
|
19
|
+
capture_once(input:, timeout:).tap { recycle_if_needed(max_requests) }
|
|
20
|
+
end
|
|
21
|
+
rescue Errno::EPIPE, IOError
|
|
22
|
+
stop
|
|
23
|
+
begin
|
|
24
|
+
@mutex.synchronize do
|
|
25
|
+
capture_once(input:, timeout:).tap { recycle_if_needed(max_requests) }
|
|
26
|
+
end
|
|
27
|
+
rescue Errno::EPIPE, IOError
|
|
28
|
+
failure("render process exited before responding")
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def health_check(timeout:)
|
|
33
|
+
@mutex.synchronize { health_check_once(timeout:) }
|
|
34
|
+
rescue Errno::EPIPE, IOError
|
|
35
|
+
stop
|
|
36
|
+
begin
|
|
37
|
+
@mutex.synchronize { health_check_once(timeout:) }
|
|
38
|
+
rescue Errno::EPIPE, IOError
|
|
39
|
+
failure("render process exited before responding")
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def stop
|
|
44
|
+
if @wait_thread&.alive?
|
|
45
|
+
terminate_process("TERM", @wait_thread.pid)
|
|
46
|
+
@wait_thread.join(1)
|
|
47
|
+
terminate_process("KILL", @wait_thread.pid) if @wait_thread.alive?
|
|
48
|
+
end
|
|
49
|
+
rescue Errno::ESRCH, Errno::EPERM
|
|
50
|
+
nil
|
|
51
|
+
ensure
|
|
52
|
+
[@stdin, @stdout, @stderr].compact.each { |io| io.close unless io.closed? }
|
|
53
|
+
@stderr_reader&.kill
|
|
54
|
+
@stdin = @stdout = @stderr = @wait_thread = @stderr_reader = nil
|
|
55
|
+
@stdout_buffer.clear
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Release this process's copy of an inherited child's pipes without signalling
|
|
59
|
+
# the process itself, which is still owned by the parent that started it.
|
|
60
|
+
def abandon
|
|
61
|
+
[@stdin, @stdout, @stderr].compact.each { |io| io.close unless io.closed? }
|
|
62
|
+
@stdin = @stdout = @stderr = @wait_thread = @stderr_reader = nil
|
|
63
|
+
@stdout_buffer.clear
|
|
64
|
+
rescue IOError
|
|
65
|
+
nil
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
private
|
|
69
|
+
|
|
70
|
+
attr_reader(:command)
|
|
71
|
+
|
|
72
|
+
def capture_once(input:, timeout:)
|
|
73
|
+
response = request(input, timeout:)
|
|
74
|
+
return failure(response["error"].to_s.presence || "render process failed") unless response["ok"]
|
|
75
|
+
|
|
76
|
+
success(JSON.generate(
|
|
77
|
+
{
|
|
78
|
+
protocolVersion: response["protocolVersion"],
|
|
79
|
+
packageVersion: response["packageVersion"],
|
|
80
|
+
}.tap do |body|
|
|
81
|
+
body[:html] = response["html"] if response.key?("html")
|
|
82
|
+
body[:text] = response["text"] if response.key?("text")
|
|
83
|
+
end,
|
|
84
|
+
))
|
|
85
|
+
rescue JSON::ParserError => e
|
|
86
|
+
failure("render process returned invalid JSON: #{e.message}")
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def health_check_once(timeout:)
|
|
90
|
+
response = request(JSON.generate(health: true), timeout:)
|
|
91
|
+
response["ok"] ? success(JSON.generate(response)) : failure(response["error"].to_s.presence || "render process failed")
|
|
92
|
+
rescue JSON::ParserError => e
|
|
93
|
+
failure("render process returned invalid JSON: #{e.message}")
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def start
|
|
97
|
+
@stdin, @stdout, @stderr, @wait_thread = Open3.popen3(*command, "--persistent", pgroup: true)
|
|
98
|
+
@stderr_buffer = +""
|
|
99
|
+
@stdout_buffer = +""
|
|
100
|
+
@requests = 0
|
|
101
|
+
@stderr_reader = Thread.new { drain_stderr }
|
|
102
|
+
@stderr_reader.report_on_exception = false
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def running?
|
|
106
|
+
@wait_thread&.alive?
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def request(input, timeout:)
|
|
110
|
+
start unless running?
|
|
111
|
+
|
|
112
|
+
@stdin.write("#{input}\n")
|
|
113
|
+
@stdin.flush
|
|
114
|
+
|
|
115
|
+
line = read_response_line(timeout)
|
|
116
|
+
return { "ok" => false, "error" => "render process exited before responding" } unless line
|
|
117
|
+
|
|
118
|
+
JSON.parse(line)
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def read_response_line(timeout)
|
|
122
|
+
deadline = monotonic_time + timeout
|
|
123
|
+
line = +""
|
|
124
|
+
|
|
125
|
+
loop do
|
|
126
|
+
if (buffered_line = consume_buffered_response_line)
|
|
127
|
+
line << buffered_line
|
|
128
|
+
return line
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
line << @stdout_buffer
|
|
132
|
+
@stdout_buffer.clear
|
|
133
|
+
|
|
134
|
+
remaining = deadline - monotonic_time
|
|
135
|
+
if remaining <= 0 || IO.select([@stdout], nil, nil, remaining).nil?
|
|
136
|
+
stop
|
|
137
|
+
raise(Timeout::Error)
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
begin
|
|
141
|
+
@stdout_buffer << @stdout.read_nonblock(16 * 1024)
|
|
142
|
+
rescue IO::WaitReadable
|
|
143
|
+
next
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
rescue EOFError
|
|
147
|
+
line.presence
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
def consume_buffered_response_line
|
|
151
|
+
separator = @stdout_buffer.index("\n")
|
|
152
|
+
return unless separator
|
|
153
|
+
|
|
154
|
+
@stdout_buffer.slice!(0, separator + 1)
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
def monotonic_time
|
|
158
|
+
Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
def terminate_process(signal, pid)
|
|
162
|
+
Process.kill(signal, -pid)
|
|
163
|
+
rescue Errno::ESRCH, Errno::EPERM
|
|
164
|
+
nil
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
def drain_stderr
|
|
168
|
+
@stderr.each do |chunk|
|
|
169
|
+
@stderr_mutex.synchronize do
|
|
170
|
+
@stderr_buffer << chunk
|
|
171
|
+
@stderr_buffer = @stderr_buffer.byteslice(-STDERR_LIMIT, STDERR_LIMIT) if @stderr_buffer.bytesize > STDERR_LIMIT
|
|
172
|
+
end
|
|
173
|
+
end
|
|
174
|
+
rescue IOError
|
|
175
|
+
nil
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
def success(stdout)
|
|
179
|
+
ReactEmailRails::RenderModes::Subprocess::CommandRunner::Result.new(
|
|
180
|
+
stdout:,
|
|
181
|
+
stderr: "",
|
|
182
|
+
status: Status.new(true),
|
|
183
|
+
)
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
def failure(message)
|
|
187
|
+
ReactEmailRails::RenderModes::Subprocess::CommandRunner::Result.new(
|
|
188
|
+
stdout: "",
|
|
189
|
+
stderr: [message, stderr_buffer].reject(&:blank?).join("\n"),
|
|
190
|
+
status: Status.new(false),
|
|
191
|
+
)
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
def stderr_buffer
|
|
195
|
+
@stderr_mutex.synchronize { @stderr_buffer.dup }
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
def recycle_if_needed(max_requests)
|
|
199
|
+
return unless max_requests&.positive?
|
|
200
|
+
|
|
201
|
+
@requests += 1
|
|
202
|
+
stop if @requests >= max_requests
|
|
203
|
+
end
|
|
204
|
+
end
|
|
@@ -0,0 +1,28 @@
|
|
|
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
|
+
CommandRunner.capture(
|
|
12
|
+
command,
|
|
13
|
+
input:,
|
|
14
|
+
timeout: render_timeout,
|
|
15
|
+
max_requests: render_process_max_requests,
|
|
16
|
+
)
|
|
17
|
+
rescue Timeout::Error
|
|
18
|
+
raise(render_error("render process timed out after #{render_timeout}s"))
|
|
19
|
+
rescue Errno::ENOENT
|
|
20
|
+
raise(render_error("render command not found: #{command.inspect}"))
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def render_process_max_requests
|
|
24
|
+
ReactEmailRails.configuration.send(:render_process_max_requests)
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
at_exit { ReactEmailRails::RenderModes::Persistent::CommandRunner.stop_all }
|
|
@@ -0,0 +1,56 @@
|
|
|
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
|
|
54
|
+
nil
|
|
55
|
+
end
|
|
56
|
+
end
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
class ReactEmailRails::RenderModes::Subprocess
|
|
2
|
+
class << self
|
|
3
|
+
def healthy?(command:, timeout:)
|
|
4
|
+
result = CommandRunner.capture([*command, "--health"], timeout:)
|
|
5
|
+
result.status.success? && ReactEmailRails::RenderProtocol.compatible_response?(JSON.parse(result.stdout))
|
|
6
|
+
rescue StandardError
|
|
7
|
+
false
|
|
8
|
+
end
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def initialize(component:, props:, render_options: {})
|
|
12
|
+
@component = component
|
|
13
|
+
@props = props
|
|
14
|
+
@render_options = render_options
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def render
|
|
18
|
+
run
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
private
|
|
22
|
+
|
|
23
|
+
attr_reader(:component, :props, :render_options)
|
|
24
|
+
|
|
25
|
+
def run
|
|
26
|
+
result = capture(payload_json)
|
|
27
|
+
raise(render_error(error_message(result.stderr, result.status))) unless result.status.success?
|
|
28
|
+
|
|
29
|
+
body = JSON.parse(result.stdout)
|
|
30
|
+
validate_response!(body)
|
|
31
|
+
ReactEmailRails::RenderedEmail.new(html: body.fetch("html"), text: body["text"].to_s)
|
|
32
|
+
rescue JSON::ParserError => e
|
|
33
|
+
raise(render_error("render process returned invalid JSON: #{e.message}"))
|
|
34
|
+
rescue KeyError => e
|
|
35
|
+
raise(render_error("render process returned an invalid response: missing #{e.key.inspect}"))
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def capture(input)
|
|
39
|
+
validate_command!
|
|
40
|
+
CommandRunner.capture(command, input:, timeout: render_timeout)
|
|
41
|
+
rescue Timeout::Error
|
|
42
|
+
raise(render_error("render process timed out after #{render_timeout}s"))
|
|
43
|
+
rescue Errno::ENOENT
|
|
44
|
+
raise(render_error("render command not found: #{command.inspect}"))
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def command
|
|
48
|
+
@command ||= ReactEmailRails.configuration.send(:resolved_render_command)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def render_timeout
|
|
52
|
+
ReactEmailRails.configuration.render_timeout
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def payload
|
|
56
|
+
@payload ||= begin
|
|
57
|
+
payload = {
|
|
58
|
+
component:,
|
|
59
|
+
props: ReactEmailRails.configuration.send(:serialize_props, props),
|
|
60
|
+
}
|
|
61
|
+
payload[:renderOptions] = render_options if render_options.present?
|
|
62
|
+
payload
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def payload_json
|
|
67
|
+
@payload_json ||= JSON.generate(payload)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def error_message(stderr, status)
|
|
71
|
+
stderr.to_s.strip.presence || "render process exited with #{status}"
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def validate_command!
|
|
75
|
+
command_path = command.first.to_s
|
|
76
|
+
bundle_path = command[1].to_s
|
|
77
|
+
|
|
78
|
+
if command_path.end_with?(ReactEmailRails::Configuration::DEV_RENDER_BIN) && !File.exist?(command_path)
|
|
79
|
+
raise(render_error("development renderer not found at #{command_path.inspect}; install JavaScript dependencies with npm, pnpm, yarn, or bun"))
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
return unless command_path == "node" && bundle_path.end_with?(ReactEmailRails::Configuration::BUNDLE_PATH)
|
|
83
|
+
return if File.file?(bundle_path)
|
|
84
|
+
|
|
85
|
+
raise(render_error("email bundle not found at #{bundle_path.inspect}; run vite build before rendering React emails"))
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def validate_response!(body)
|
|
89
|
+
raise(render_error(ReactEmailRails::RenderProtocol.mismatch_message(body))) unless ReactEmailRails::RenderProtocol.compatible_metadata?(body)
|
|
90
|
+
|
|
91
|
+
raise(KeyError.new(key: "html")) unless body.key?("html")
|
|
92
|
+
raise(render_error("render process returned an invalid response: html must be a string")) unless body["html"].is_a?(String)
|
|
93
|
+
raise(render_error("render process returned an invalid response: text must be a string")) if body.key?("text") && !body["text"].is_a?(String)
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def render_error(message)
|
|
97
|
+
ReactEmailRails::RenderError.new("React Email render failed for #{component}: #{message}")
|
|
98
|
+
end
|
|
99
|
+
end
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
module ReactEmailRails::RenderModes; end
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
module ReactEmailRails
|
|
2
|
+
RENDER_PROTOCOL_VERSION = 1
|
|
3
|
+
|
|
4
|
+
module RenderProtocol
|
|
5
|
+
extend(self)
|
|
6
|
+
|
|
7
|
+
def compatible_response?(body)
|
|
8
|
+
body["ok"] == true && compatible_metadata?(body)
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def compatible_metadata?(body)
|
|
12
|
+
body["protocolVersion"] == RENDER_PROTOCOL_VERSION &&
|
|
13
|
+
body["packageVersion"] == VERSION
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def mismatch_message(body)
|
|
17
|
+
"renderer version mismatch: expected react-email-rails #{VERSION} protocol #{RENDER_PROTOCOL_VERSION}, " \
|
|
18
|
+
"got package #{body["packageVersion"].inspect} protocol #{body["protocolVersion"].inspect}"
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|