react_on_rails_pro 16.6.0 → 16.7.0.rc.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/.controlplane/rails.yml +2 -2
- data/CLAUDE.md +10 -0
- data/CONTRIBUTING.md +2 -2
- data/Gemfile.development_dependencies +20 -13
- data/Gemfile.lock +12 -28
- data/Rakefile +0 -5
- data/app/helpers/react_on_rails_pro_helper.rb +28 -1
- data/lib/react_on_rails_pro/assets_precompile.rb +170 -1
- data/lib/react_on_rails_pro/async_props_emitter.rb +80 -0
- data/lib/react_on_rails_pro/concerns/stream.rb +1 -1
- data/lib/react_on_rails_pro/configuration.rb +114 -17
- data/lib/react_on_rails_pro/engine.rb +10 -0
- data/lib/react_on_rails_pro/httpx_stream_bidi_patch.rb +42 -0
- data/lib/react_on_rails_pro/js_code_builder.rb +121 -0
- data/lib/react_on_rails_pro/pre_seed_renderer_cache.rb +148 -0
- data/lib/react_on_rails_pro/prepare_node_renderer_bundles.rb +33 -28
- data/lib/react_on_rails_pro/renderer_cache_helpers.rb +276 -0
- data/lib/react_on_rails_pro/renderer_cache_path.rb +74 -0
- data/lib/react_on_rails_pro/rendering_strategy/node_strategy.rb +29 -0
- data/lib/react_on_rails_pro/request.rb +135 -8
- data/lib/react_on_rails_pro/rolling_deploy_cache_stager.rb +516 -0
- data/lib/react_on_rails_pro/server_rendering_js_code.rb +47 -10
- data/lib/react_on_rails_pro/server_rendering_pool/node_rendering_pool.rb +35 -10
- data/lib/react_on_rails_pro/stream_request.rb +42 -49
- data/lib/react_on_rails_pro/utils.rb +7 -9
- data/lib/react_on_rails_pro/version.rb +1 -1
- data/lib/react_on_rails_pro.rb +8 -0
- data/lib/tasks/assets.rake +36 -3
- data/rakelib/run_rspec.rake +6 -6
- data/react_on_rails_pro.gemspec +9 -4
- data/sig/react_on_rails_pro/configuration.rbs +2 -0
- metadata +35 -22
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fileutils"
|
|
4
|
+
require "securerandom"
|
|
5
|
+
require "pathname"
|
|
6
|
+
require "set"
|
|
7
|
+
|
|
8
|
+
require "react_on_rails_pro/error"
|
|
9
|
+
|
|
10
|
+
module ReactOnRailsPro
|
|
11
|
+
# Shared helpers for staging the Node Renderer bundle cache. Used by both
|
|
12
|
+
# PreSeedRendererCache (copies files for Docker images) and
|
|
13
|
+
# PrepareNodeRenderBundles (symlinks for same-filesystem workflows).
|
|
14
|
+
module RendererCacheHelpers
|
|
15
|
+
LOADABLE_STATS_ASSET_NAME = "loadable-stats.json"
|
|
16
|
+
|
|
17
|
+
module_function
|
|
18
|
+
|
|
19
|
+
def collect_assets_with_required_paths
|
|
20
|
+
config = ReactOnRailsPro.configuration
|
|
21
|
+
# assets_to_copy may include nil entries (user-configured, optional);
|
|
22
|
+
# those are silently dropped by `.compact`. RSC manifests, by contrast,
|
|
23
|
+
# are required, so resolve them separately and fail loudly if either
|
|
24
|
+
# resolves to nil rather than letting `.compact` swallow the gap.
|
|
25
|
+
assets = Array(config.assets_to_copy).compact
|
|
26
|
+
loadable_stats_path = loadable_stats_asset_path
|
|
27
|
+
assets << loadable_stats_path if loadable_stats_path
|
|
28
|
+
|
|
29
|
+
if config.enable_rsc_support
|
|
30
|
+
rsc_manifests = rsc_manifest_paths
|
|
31
|
+
assets.concat(rsc_manifests)
|
|
32
|
+
else
|
|
33
|
+
rsc_manifests = []
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
unique = assets.uniq(&:to_s)
|
|
37
|
+
warn_on_duplicate_basenames(unique)
|
|
38
|
+
[unique, required_rsc_asset_paths(rsc_manifests)]
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Convenience for callers that only need the asset list and intentionally
|
|
42
|
+
# discard the rsc_required_paths Set returned by
|
|
43
|
+
# collect_assets_with_required_paths. If you need to enforce required-RSC
|
|
44
|
+
# availability (raising loudly when a required manifest is missing), use
|
|
45
|
+
# collect_assets_with_required_paths and pass both into each_stageable_asset
|
|
46
|
+
# — `nil`-or-empty here would silently skip the required-paths check.
|
|
47
|
+
def collect_assets
|
|
48
|
+
collect_assets_with_required_paths.first
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def required_rsc_asset_basenames
|
|
52
|
+
required_rsc_asset_paths_for_current_config.map { |path| File.basename(path) }
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# No-arg companion to `required_rsc_asset_paths` for callers (rolling-deploy
|
|
56
|
+
# adapter publication, payload validation) that don't already hold the
|
|
57
|
+
# resolved manifest list. Centralising the rsc_manifest_paths lookup avoids
|
|
58
|
+
# call-site drift if the manifest sources change.
|
|
59
|
+
def required_rsc_asset_paths_for_current_config
|
|
60
|
+
return Set.new unless ReactOnRailsPro.configuration.enable_rsc_support
|
|
61
|
+
|
|
62
|
+
required_rsc_asset_paths(rsc_manifest_paths)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def rsc_manifest_paths
|
|
66
|
+
manifests = {
|
|
67
|
+
react_client_manifest_file_path: ReactOnRailsPro::Utils.react_client_manifest_file_path,
|
|
68
|
+
react_server_client_manifest_file_path: ReactOnRailsPro::Utils.react_server_client_manifest_file_path
|
|
69
|
+
}
|
|
70
|
+
nil_manifest_names = manifests.select { |_name, path| path.nil? }.keys
|
|
71
|
+
unless nil_manifest_names.empty?
|
|
72
|
+
raise ReactOnRailsPro::Error,
|
|
73
|
+
"RSC manifest path resolved to nil for #{nil_manifest_names.join(', ')}. " \
|
|
74
|
+
"Check react_client_manifest_file and react_server_client_manifest_file configuration."
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
manifests.values
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# `stage_assets` writes each asset into `bundle_dir` using only its basename,
|
|
81
|
+
# so two distinct assets with the same basename (e.g. `/path/a/manifest.json`
|
|
82
|
+
# and `/path/b/manifest.json`) silently overwrite one another. Uniq-by-path
|
|
83
|
+
# cannot detect this; warn so the user notices the misconfiguration.
|
|
84
|
+
def warn_on_duplicate_basenames(assets)
|
|
85
|
+
basenames = assets.reject { |a| http_url?(a) }.map { |a| File.basename(a.to_s) }
|
|
86
|
+
duplicates = basenames.tally.select { |_, count| count > 1 }.keys
|
|
87
|
+
return if duplicates.empty?
|
|
88
|
+
|
|
89
|
+
warn "[ReactOnRailsPro] Duplicate asset basenames in assets_to_copy / RSC manifests: " \
|
|
90
|
+
"#{duplicates.join(', ')}. Only the last entry per basename will be staged."
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def loadable_stats_asset_path
|
|
94
|
+
path = ReactOnRails::PackerUtils.asset_uri_from_packer(LOADABLE_STATS_ASSET_NAME)
|
|
95
|
+
File.exist?(path.to_s) ? path : nil
|
|
96
|
+
rescue KeyError, TypeError, Errno::ENOENT
|
|
97
|
+
# Narrow to errors PackerUtils.asset_uri_from_packer can plausibly raise
|
|
98
|
+
# (missing manifest key, nil path, manifest file absent). Unexpected bugs
|
|
99
|
+
# like NoMethodError or NameError should surface so operators can see them
|
|
100
|
+
# rather than being silently swallowed.
|
|
101
|
+
nil
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# Required assets are matched by expanded path rather than basename so a
|
|
105
|
+
# same-named unrelated entry in assets_to_copy cannot trigger a false-
|
|
106
|
+
# positive "required" error. Expand against Rails.root to match how
|
|
107
|
+
# required_rsc_asset_paths builds its Set.
|
|
108
|
+
#
|
|
109
|
+
# URL-backed assets (returned by `asset_uri_from_packer` while the dev
|
|
110
|
+
# server is running) cannot be staged into the local cache; skip them with
|
|
111
|
+
# a warning so the renderer falls back to fetching them at request time
|
|
112
|
+
# rather than aborting the entire pre-seed.
|
|
113
|
+
def each_stageable_asset(assets, rsc_required_paths, action_description)
|
|
114
|
+
assets.each do |asset_path|
|
|
115
|
+
if http_url?(asset_path)
|
|
116
|
+
warn "[ReactOnRailsPro] Skipping URL-backed asset #{asset_path} while " \
|
|
117
|
+
"#{action_description} the renderer cache; the dev server is serving " \
|
|
118
|
+
"this asset, so the renderer will fetch it on first request."
|
|
119
|
+
next
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
expanded =
|
|
123
|
+
begin
|
|
124
|
+
File.expand_path(asset_path.to_s, Rails.root)
|
|
125
|
+
rescue ArgumentError => e
|
|
126
|
+
warn "[ReactOnRailsPro] Asset not found #{asset_label(asset_path)} (invalid path: #{e.message})"
|
|
127
|
+
next
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
unless File.file?(expanded)
|
|
131
|
+
if rsc_required_paths.include?(expanded)
|
|
132
|
+
raise ReactOnRailsPro::Error, "Required RSC asset not found or not a file: #{asset_path}. " \
|
|
133
|
+
"Build your bundles before #{action_description} the renderer cache."
|
|
134
|
+
end
|
|
135
|
+
warn "[ReactOnRailsPro] Asset not found #{asset_label(asset_path)} (missing or not a file)"
|
|
136
|
+
next
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
yield expanded
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def copy_file_atomically(src, dest, log_prefix:)
|
|
144
|
+
FileUtils.mkdir_p(File.dirname(dest))
|
|
145
|
+
tmp_file = "#{dest}.tmp-#{Process.pid}-#{SecureRandom.hex(6)}"
|
|
146
|
+
FileUtils.cp(src, tmp_file)
|
|
147
|
+
File.rename(tmp_file, dest)
|
|
148
|
+
puts "[ReactOnRailsPro] #{log_prefix}: #{src} -> #{dest}"
|
|
149
|
+
ensure
|
|
150
|
+
# Clean up the temp file on failure; rm_f is harmless after a successful rename.
|
|
151
|
+
FileUtils.rm_f(tmp_file) if tmp_file
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
def asset_label(asset_path)
|
|
155
|
+
asset_path.to_s.empty? ? "<blank>" : asset_path
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
# Mirrors `Request#http_url?`: detects dev-server-served assets returned
|
|
159
|
+
# by `ReactOnRails::PackerUtils.asset_uri_from_packer` so the staging
|
|
160
|
+
# path can skip them instead of treating them as filesystem paths.
|
|
161
|
+
def http_url?(path)
|
|
162
|
+
path.to_s.match?(%r{\Ahttps?://})
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
# Must expand against Rails.root so that callers who expand per-asset paths
|
|
166
|
+
# against the same base produce Set-comparable strings. Without an explicit
|
|
167
|
+
# base, File.expand_path uses Dir.pwd, which differs in Docker RUN steps
|
|
168
|
+
# and would make the Set lookup miss.
|
|
169
|
+
#
|
|
170
|
+
# URL-backed manifests (dev server) cannot be staged; exclude them so
|
|
171
|
+
# `each_stageable_asset` does not see them as "required" and raise.
|
|
172
|
+
def required_rsc_asset_paths(manifests)
|
|
173
|
+
return Set.new unless ReactOnRailsPro.configuration.enable_rsc_support
|
|
174
|
+
|
|
175
|
+
Set.new(
|
|
176
|
+
manifests
|
|
177
|
+
.reject { |path| http_url?(path) }
|
|
178
|
+
.map { |path| File.expand_path(path.to_s, Rails.root) }
|
|
179
|
+
)
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
def validate_bundle_exists!(path, action_description)
|
|
183
|
+
return if File.file?(path)
|
|
184
|
+
|
|
185
|
+
raise ReactOnRailsPro::Error,
|
|
186
|
+
"Bundle not found or not a file at #{path}. " \
|
|
187
|
+
"Please build your bundles before #{action_description} the renderer cache."
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
# Defense-in-depth against future regressions in the hash-computation path:
|
|
191
|
+
# `calc_bundle_hash` always returns a non-empty string today, but a blank
|
|
192
|
+
# value here would cause `File.join(cache_dir, "")` to resolve to `cache_dir`
|
|
193
|
+
# itself and stage the bundle as `<cache_dir>/.js` — a hidden file the
|
|
194
|
+
# renderer never reads. Fail loudly instead of silently mis-staging.
|
|
195
|
+
#
|
|
196
|
+
# We also reject non-String, non-nil types (e.g. Pathname, Symbol) so a
|
|
197
|
+
# future pool that returns one fails loudly rather than silently producing
|
|
198
|
+
# surprising `File.join` results downstream.
|
|
199
|
+
def validate_bundle_hash!(hash, path)
|
|
200
|
+
unless hash.nil? || hash.is_a?(String)
|
|
201
|
+
raise ReactOnRailsPro::Error,
|
|
202
|
+
"Bundle hash for #{path} must be a String or nil, got #{hash.class}."
|
|
203
|
+
end
|
|
204
|
+
return unless hash.to_s.strip.empty?
|
|
205
|
+
|
|
206
|
+
raise ReactOnRailsPro::Error,
|
|
207
|
+
"Bundle hash for #{path} is nil or blank; cannot stage renderer cache."
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
def make_relative_symlink(source, destination, log_prefix:)
|
|
211
|
+
destination_dir = Pathname.new(destination).dirname
|
|
212
|
+
FileUtils.mkdir_p(destination_dir)
|
|
213
|
+
|
|
214
|
+
source_path = realpath_for_symlink_source(source)
|
|
215
|
+
destination_dir_real = realpath_for_symlink_destination(destination_dir)
|
|
216
|
+
relative_source_path = source_path.relative_path_from(destination_dir_real)
|
|
217
|
+
tmp_link = "#{destination}.tmp-#{Process.pid}-#{SecureRandom.hex(6)}"
|
|
218
|
+
|
|
219
|
+
File.symlink(relative_source_path.to_s, tmp_link)
|
|
220
|
+
File.rename(tmp_link, destination)
|
|
221
|
+
puts "[ReactOnRailsPro] #{log_prefix}: #{relative_source_path} -> #{destination}"
|
|
222
|
+
ensure
|
|
223
|
+
FileUtils.rm_f(tmp_link) if tmp_link
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
def stage_file(src, dest, mode, log_prefix:)
|
|
227
|
+
if mode == :copy
|
|
228
|
+
copy_file_atomically(src, dest, log_prefix: log_prefix)
|
|
229
|
+
else
|
|
230
|
+
make_relative_symlink(src, dest, log_prefix: log_prefix)
|
|
231
|
+
end
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
def realpath_for_symlink_source(source)
|
|
235
|
+
Pathname.new(source).realpath
|
|
236
|
+
rescue Errno::ENOENT
|
|
237
|
+
raise ReactOnRailsPro::Error,
|
|
238
|
+
"Cannot resolve real path for symlink source #{source} — " \
|
|
239
|
+
"it does not exist or is a dangling symlink. " \
|
|
240
|
+
"Rebuild your bundles before staging the renderer cache."
|
|
241
|
+
end
|
|
242
|
+
|
|
243
|
+
def realpath_for_symlink_destination(destination_dir)
|
|
244
|
+
destination_dir.realpath
|
|
245
|
+
rescue Errno::ENOENT
|
|
246
|
+
raise ReactOnRailsPro::Error,
|
|
247
|
+
"Cannot resolve real path for symlink destination dir #{destination_dir} — " \
|
|
248
|
+
"it may have been removed after mkdir_p (race with an external cleanup)."
|
|
249
|
+
end
|
|
250
|
+
|
|
251
|
+
# Resolves bundle sources as [path, hash] pairs so callers can iterate
|
|
252
|
+
# without needing to re-call pool methods. `pool` must respond to
|
|
253
|
+
# `server_bundle_hash` and (when RSC is enabled) `rsc_bundle_hash`.
|
|
254
|
+
#
|
|
255
|
+
# Validates each bundle path exists *before* computing its hash, because
|
|
256
|
+
# `pool.server_bundle_hash` eventually calls `Digest::MD5.file` / `File.mtime`
|
|
257
|
+
# on the bundle path, which raises raw `Errno::ENOENT` if the file is
|
|
258
|
+
# missing — bypassing the friendly `ReactOnRailsPro::Error` message.
|
|
259
|
+
def bundle_sources(pool, action_description)
|
|
260
|
+
server_bundle_path = ReactOnRails::Utils.server_bundle_js_file_path
|
|
261
|
+
validate_bundle_exists!(server_bundle_path, action_description)
|
|
262
|
+
server_hash = pool.server_bundle_hash
|
|
263
|
+
validate_bundle_hash!(server_hash, server_bundle_path)
|
|
264
|
+
sources = [[server_bundle_path, server_hash]]
|
|
265
|
+
|
|
266
|
+
return sources unless ReactOnRailsPro.configuration.enable_rsc_support
|
|
267
|
+
|
|
268
|
+
rsc_bundle_path = ReactOnRailsPro::Utils.rsc_bundle_js_file_path
|
|
269
|
+
validate_bundle_exists!(rsc_bundle_path, action_description)
|
|
270
|
+
rsc_hash = pool.rsc_bundle_hash
|
|
271
|
+
validate_bundle_hash!(rsc_hash, rsc_bundle_path)
|
|
272
|
+
sources << [rsc_bundle_path, rsc_hash]
|
|
273
|
+
sources
|
|
274
|
+
end
|
|
275
|
+
end
|
|
276
|
+
end
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "react_on_rails_pro/error"
|
|
4
|
+
|
|
5
|
+
module ReactOnRailsPro
|
|
6
|
+
# Resolves the Node Renderer server-bundle cache directory from environment
|
|
7
|
+
# variables, preserving the same precedence and warning behavior as the Node
|
|
8
|
+
# renderer configuration.
|
|
9
|
+
module RendererCachePath
|
|
10
|
+
PREFERRED_ENV_VAR = "RENDERER_SERVER_BUNDLE_CACHE_PATH"
|
|
11
|
+
LEGACY_ENV_VAR = "RENDERER_BUNDLE_PATH"
|
|
12
|
+
DEFAULT_CACHE_DIR = ".node-renderer-bundles"
|
|
13
|
+
LEGACY_ENV_VAR_DEPRECATION_MUTEX = Mutex.new
|
|
14
|
+
|
|
15
|
+
private_constant :PREFERRED_ENV_VAR,
|
|
16
|
+
:LEGACY_ENV_VAR,
|
|
17
|
+
:DEFAULT_CACHE_DIR,
|
|
18
|
+
:LEGACY_ENV_VAR_DEPRECATION_MUTEX
|
|
19
|
+
|
|
20
|
+
# Module-level instance var read/written by singleton methods under the mutex.
|
|
21
|
+
@legacy_env_var_deprecation_warned = false
|
|
22
|
+
|
|
23
|
+
class << self
|
|
24
|
+
def resolve
|
|
25
|
+
preferred = env_value(PREFERRED_ENV_VAR)
|
|
26
|
+
return preferred if preferred
|
|
27
|
+
|
|
28
|
+
legacy = env_value(LEGACY_ENV_VAR)
|
|
29
|
+
return Rails.root.join(DEFAULT_CACHE_DIR).to_s unless legacy
|
|
30
|
+
|
|
31
|
+
warn_legacy_env_var_once
|
|
32
|
+
legacy
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
private
|
|
36
|
+
|
|
37
|
+
# Surrounding whitespace is preserved verbatim because the Node renderer
|
|
38
|
+
# reads these env vars raw, but it is almost always a misconfigured CI
|
|
39
|
+
# secret — warn so operators notice.
|
|
40
|
+
#
|
|
41
|
+
# The two whitespace guards are intentionally asymmetric:
|
|
42
|
+
# * Whitespace-only (" ") raises because there is no valid
|
|
43
|
+
# interpretation, so a misconfigured deploy should fail fast instead
|
|
44
|
+
# of silently falling back.
|
|
45
|
+
# * Surrounding whitespace (" /app/bundles ") only warns because the
|
|
46
|
+
# trimmed path could conceivably be intentional. Preserve it and let
|
|
47
|
+
# the operator decide.
|
|
48
|
+
def env_value(name)
|
|
49
|
+
value = ENV.fetch(name, "")
|
|
50
|
+
raise ReactOnRailsPro::Error, "#{name} is whitespace-only; set or unset it." if value.match?(/\A\s+\z/)
|
|
51
|
+
|
|
52
|
+
if value != value.strip
|
|
53
|
+
warn "[ReactOnRailsPro] #{name} has surrounding whitespace " \
|
|
54
|
+
"and will be used verbatim: #{value.inspect}"
|
|
55
|
+
end
|
|
56
|
+
value.empty? ? nil : value
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def warn_legacy_env_var_once
|
|
60
|
+
LEGACY_ENV_VAR_DEPRECATION_MUTEX.synchronize do
|
|
61
|
+
unless @legacy_env_var_deprecation_warned
|
|
62
|
+
warn "[ReactOnRailsPro] #{LEGACY_ENV_VAR} is deprecated. Use #{PREFERRED_ENV_VAR} instead."
|
|
63
|
+
@legacy_env_var_deprecation_warned = true
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# :nodoc: Test helper for resetting the one-time deprecation-warning guard.
|
|
69
|
+
def reset_deprecation_warned!
|
|
70
|
+
LEGACY_ENV_VAR_DEPRECATION_MUTEX.synchronize { @legacy_env_var_deprecation_warned = false }
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ReactOnRailsPro
|
|
4
|
+
module RenderingStrategy
|
|
5
|
+
# Pro rendering strategy wrapping ProRendering, which handles caching,
|
|
6
|
+
# streaming, and the ExecJS vs Node renderer dispatch.
|
|
7
|
+
#
|
|
8
|
+
# Part of the strategy pattern refactoring (see issue #2905).
|
|
9
|
+
# Currently additive — not yet wired into the main rendering path.
|
|
10
|
+
class NodeStrategy
|
|
11
|
+
include ReactOnRails::RenderingStrategy
|
|
12
|
+
|
|
13
|
+
def execute(render_request)
|
|
14
|
+
js_code = render_request.to_js
|
|
15
|
+
ReactOnRailsPro::ServerRenderingPool::ProRendering
|
|
16
|
+
.exec_server_render_js(js_code, render_request.render_options)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def reset
|
|
20
|
+
ReactOnRailsPro::ServerRenderingPool::ProRendering.reset_pool
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def reset_if_bundle_changed
|
|
24
|
+
ReactOnRailsPro::ServerRenderingPool::ProRendering
|
|
25
|
+
.reset_pool_if_server_bundle_was_modified
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
require "uri"
|
|
4
4
|
require "httpx"
|
|
5
5
|
require_relative "stream_request"
|
|
6
|
+
require_relative "async_props_emitter"
|
|
6
7
|
|
|
7
8
|
module ReactOnRailsPro
|
|
8
9
|
class Request # rubocop:disable Metrics/ClassLength
|
|
@@ -35,12 +36,92 @@ module ReactOnRailsPro
|
|
|
35
36
|
"rendering any RSC payload."
|
|
36
37
|
end
|
|
37
38
|
|
|
38
|
-
ReactOnRailsPro::StreamRequest.create do |send_bundle|
|
|
39
|
-
|
|
39
|
+
ReactOnRailsPro::StreamRequest.create do |send_bundle, _barrier|
|
|
40
|
+
if send_bundle
|
|
41
|
+
Rails.logger.info { "[ReactOnRailsPro] Sending bundle to the node renderer" }
|
|
42
|
+
upload_assets
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
form = form_with_code(js_code, false)
|
|
40
46
|
perform_request(path, form: form, stream: true)
|
|
41
47
|
end
|
|
42
48
|
end
|
|
43
49
|
|
|
50
|
+
# Performs an incremental render request with bidirectional HTTP/2 streaming.
|
|
51
|
+
#
|
|
52
|
+
# ARCHITECTURE: This method orchestrates the async props flow:
|
|
53
|
+
#
|
|
54
|
+
# ┌─────────────────────────────────────────────────────────────────────────┐
|
|
55
|
+
# │ Rails Thread (main) │ Rails Thread (barrier.async) │
|
|
56
|
+
# ├───────────────────────────────────┼─────────────────────────────────────┤
|
|
57
|
+
# │ 1. Send initial NDJSON line │ │
|
|
58
|
+
# │ {renderingRequest, ...} │ │
|
|
59
|
+
# │ │ │
|
|
60
|
+
# │ 2. Return response stream │ 3. Execute async_props_block │
|
|
61
|
+
# │ (caller processes HTML) │ emit.call("users", User.all) │
|
|
62
|
+
# │ │ └── Sends NDJSON: {updateChunk} │
|
|
63
|
+
# │ │ emit.call("posts", Post.all) │
|
|
64
|
+
# │ │ └── Sends NDJSON: {updateChunk} │
|
|
65
|
+
# │ │ │
|
|
66
|
+
# │ ... streaming HTML chunks ... │ 4. Block completes │
|
|
67
|
+
# │ │ request.close (sends END_STREAM)│
|
|
68
|
+
# └───────────────────────────────────┴─────────────────────────────────────┘
|
|
69
|
+
#
|
|
70
|
+
# WHY barrier.async?
|
|
71
|
+
# - We need to return the response stream immediately so Rails can start sending HTML
|
|
72
|
+
# - The async_props_block runs concurrently, sending props as they become available
|
|
73
|
+
# - When the block finishes, we close the request (END_STREAM flag)
|
|
74
|
+
# - Node's handleRequestClosed then calls asyncPropsManager.endStream()
|
|
75
|
+
#
|
|
76
|
+
def render_code_with_incremental_updates(path, js_code, async_props_block:)
|
|
77
|
+
Rails.logger.info { "[ReactOnRailsPro] Perform incremental rendering request #{path}" }
|
|
78
|
+
|
|
79
|
+
# Determine bundle timestamp based on RSC support
|
|
80
|
+
pool = ReactOnRailsPro::ServerRenderingPool::NodeRenderingPool
|
|
81
|
+
|
|
82
|
+
# Incremental rendering goes through the same `streamServerRenderedReactComponent`
|
|
83
|
+
# transform as one-shot streaming, so each response chunk arrives in the
|
|
84
|
+
# length-prefixed wire format. `StreamRequest` always parses length-prefixed.
|
|
85
|
+
ReactOnRailsPro::StreamRequest.create do |send_bundle, barrier|
|
|
86
|
+
if send_bundle
|
|
87
|
+
Rails.logger.info { "[ReactOnRailsPro] Sending bundle to the node renderer" }
|
|
88
|
+
upload_assets
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# Build bidirectional streaming request using HTTPX's stream_bidi plugin.
|
|
92
|
+
# This creates an HTTP/2 stream where we can send data while receiving.
|
|
93
|
+
request = connection.build_request(
|
|
94
|
+
"POST",
|
|
95
|
+
path,
|
|
96
|
+
headers: { "content-type" => "application/x-ndjson" },
|
|
97
|
+
body: [],
|
|
98
|
+
stream: true
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
# Create emitter - it will write NDJSON lines to the request stream
|
|
102
|
+
emitter = ReactOnRailsPro::AsyncPropsEmitter.new(pool.rsc_bundle_hash, request)
|
|
103
|
+
initial_data = build_initial_incremental_request(js_code, emitter)
|
|
104
|
+
|
|
105
|
+
# Start the request - response begins streaming immediately
|
|
106
|
+
response = connection.request(request, stream: true)
|
|
107
|
+
|
|
108
|
+
# Send the initial render request as first NDJSON line
|
|
109
|
+
request << "#{initial_data.to_json}\n"
|
|
110
|
+
|
|
111
|
+
# Execute async props block in a separate fiber via barrier.
|
|
112
|
+
# This runs concurrently with the response streaming back to the client.
|
|
113
|
+
barrier.async do
|
|
114
|
+
async_props_block.call(emitter)
|
|
115
|
+
ensure
|
|
116
|
+
# When the block completes (or raises), close the request.
|
|
117
|
+
# This sends HTTP/2 END_STREAM flag, triggering Node's handleRequestClosed.
|
|
118
|
+
request.close
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
response
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
|
|
44
125
|
def upload_assets
|
|
45
126
|
Rails.logger.info { "[ReactOnRailsPro] Uploading assets" }
|
|
46
127
|
|
|
@@ -108,13 +189,20 @@ module ReactOnRailsPro
|
|
|
108
189
|
end
|
|
109
190
|
end
|
|
110
191
|
|
|
111
|
-
|
|
192
|
+
# Performs HTTP POST requests using the build_request pattern.
|
|
193
|
+
# This approach is required because when stream_bidi plugin is loaded,
|
|
194
|
+
# using connection.post with stream: true causes timeouts (the plugin's
|
|
195
|
+
# empty? method returns false, preventing END_STREAM from being sent).
|
|
196
|
+
#
|
|
197
|
+
# For consistency and to share error handling logic, both streaming and
|
|
198
|
+
# non-streaming requests use build_request with manually encoded bodies.
|
|
199
|
+
def perform_request(path, form: nil, json: nil, stream: false) # rubocop:disable Metrics/AbcSize,Metrics/CyclomaticComplexity
|
|
112
200
|
available_retries = ReactOnRailsPro.configuration.renderer_request_retry_limit
|
|
113
201
|
retry_request = true
|
|
114
202
|
while retry_request
|
|
115
203
|
begin
|
|
116
204
|
start_time = Time.now
|
|
117
|
-
response =
|
|
205
|
+
response = execute_http_request(path, form: form, json: json, stream: stream)
|
|
118
206
|
raise response.error if response.is_a?(HTTPX::ErrorResponse)
|
|
119
207
|
|
|
120
208
|
request_time = Time.now - start_time
|
|
@@ -138,7 +226,7 @@ module ReactOnRailsPro
|
|
|
138
226
|
next
|
|
139
227
|
rescue HTTPX::Error => e # Connection errors or other unexpected errors
|
|
140
228
|
# Such errors are handled by ReactOnRailsPro::StreamRequest instead
|
|
141
|
-
raise if e.is_a?(HTTPX::HTTPError) &&
|
|
229
|
+
raise if e.is_a?(HTTPX::HTTPError) && stream
|
|
142
230
|
|
|
143
231
|
raise ReactOnRailsPro::Error,
|
|
144
232
|
"Node renderer request failed: #{path}.\nOriginal error:\n#{e}\n#{e.backtrace}"
|
|
@@ -155,6 +243,40 @@ module ReactOnRailsPro
|
|
|
155
243
|
response
|
|
156
244
|
end
|
|
157
245
|
|
|
246
|
+
# Executes an HTTP POST request using build_request pattern.
|
|
247
|
+
# For streaming requests, calls request.close to send END_STREAM flag.
|
|
248
|
+
def execute_http_request(path, form: nil, json: nil, stream: false)
|
|
249
|
+
body, content_type = encode_request_body(form: form, json: json)
|
|
250
|
+
|
|
251
|
+
request_options = {
|
|
252
|
+
headers: { "content-type" => content_type },
|
|
253
|
+
body: body
|
|
254
|
+
}
|
|
255
|
+
request_options[:stream] = true if stream
|
|
256
|
+
|
|
257
|
+
request = connection.build_request("POST", path, **request_options)
|
|
258
|
+
request.close if stream # Signal end of request body to send END_STREAM flag
|
|
259
|
+
|
|
260
|
+
connection.request(request)
|
|
261
|
+
end
|
|
262
|
+
|
|
263
|
+
# Encodes request body for use with build_request.
|
|
264
|
+
# Supports both form data (with automatic multipart detection) and JSON data.
|
|
265
|
+
def encode_request_body(form: nil, json: nil)
|
|
266
|
+
if form
|
|
267
|
+
encoder = if HTTPX::Transcoder::Multipart.multipart?(form)
|
|
268
|
+
HTTPX::Transcoder::Multipart.encode(form)
|
|
269
|
+
else
|
|
270
|
+
HTTPX::Transcoder::Form.encode(form)
|
|
271
|
+
end
|
|
272
|
+
[encoder.to_s, encoder.content_type]
|
|
273
|
+
elsif json
|
|
274
|
+
[JSON.generate(json), "application/json"]
|
|
275
|
+
else
|
|
276
|
+
raise ArgumentError, "Either form: or json: must be provided"
|
|
277
|
+
end
|
|
278
|
+
end
|
|
279
|
+
|
|
158
280
|
def form_with_code(js_code, send_bundle)
|
|
159
281
|
form = common_form_data
|
|
160
282
|
form["renderingRequest"] = js_code
|
|
@@ -239,12 +361,18 @@ module ReactOnRailsPro
|
|
|
239
361
|
ReactOnRailsPro::Utils.common_form_data
|
|
240
362
|
end
|
|
241
363
|
|
|
364
|
+
def build_initial_incremental_request(js_code, emitter)
|
|
365
|
+
common_form_data.merge(
|
|
366
|
+
renderingRequest: js_code,
|
|
367
|
+
onRequestClosedUpdateChunk: emitter.end_stream_chunk
|
|
368
|
+
)
|
|
369
|
+
end
|
|
370
|
+
|
|
242
371
|
def create_connection # rubocop:disable Metrics/MethodLength, Metrics/AbcSize
|
|
243
372
|
url = ReactOnRailsPro.configuration.renderer_url
|
|
244
373
|
Rails.logger.info do
|
|
245
374
|
"[ReactOnRailsPro] Setting up Node Renderer connection to #{url}"
|
|
246
375
|
end
|
|
247
|
-
|
|
248
376
|
HTTPX
|
|
249
377
|
# For persistent connections we want retries,
|
|
250
378
|
# so the requests don't just fail if the other side closes the connection
|
|
@@ -270,7 +398,6 @@ module ReactOnRailsPro
|
|
|
270
398
|
"of a component.\nOriginal error:\n#{e}\n#{e.backtrace}"
|
|
271
399
|
)
|
|
272
400
|
end
|
|
273
|
-
|
|
274
401
|
Rails.logger.info do
|
|
275
402
|
"[ReactOnRailsPro] An error occurred while making " \
|
|
276
403
|
"a request to the Node Renderer.\n" \
|
|
@@ -283,7 +410,7 @@ module ReactOnRailsPro
|
|
|
283
410
|
nil
|
|
284
411
|
end
|
|
285
412
|
)
|
|
286
|
-
.plugin(:
|
|
413
|
+
.plugin(:stream_bidi)
|
|
287
414
|
# See https://www.rubydoc.info/gems/httpx/1.3.3/HTTPX%2FOptions:initialize for the available options
|
|
288
415
|
.with(
|
|
289
416
|
origin: url,
|