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.
Files changed (33) hide show
  1. checksums.yaml +4 -4
  2. data/.controlplane/rails.yml +2 -2
  3. data/CLAUDE.md +10 -0
  4. data/CONTRIBUTING.md +2 -2
  5. data/Gemfile.development_dependencies +20 -13
  6. data/Gemfile.lock +12 -28
  7. data/Rakefile +0 -5
  8. data/app/helpers/react_on_rails_pro_helper.rb +28 -1
  9. data/lib/react_on_rails_pro/assets_precompile.rb +170 -1
  10. data/lib/react_on_rails_pro/async_props_emitter.rb +80 -0
  11. data/lib/react_on_rails_pro/concerns/stream.rb +1 -1
  12. data/lib/react_on_rails_pro/configuration.rb +114 -17
  13. data/lib/react_on_rails_pro/engine.rb +10 -0
  14. data/lib/react_on_rails_pro/httpx_stream_bidi_patch.rb +42 -0
  15. data/lib/react_on_rails_pro/js_code_builder.rb +121 -0
  16. data/lib/react_on_rails_pro/pre_seed_renderer_cache.rb +148 -0
  17. data/lib/react_on_rails_pro/prepare_node_renderer_bundles.rb +33 -28
  18. data/lib/react_on_rails_pro/renderer_cache_helpers.rb +276 -0
  19. data/lib/react_on_rails_pro/renderer_cache_path.rb +74 -0
  20. data/lib/react_on_rails_pro/rendering_strategy/node_strategy.rb +29 -0
  21. data/lib/react_on_rails_pro/request.rb +135 -8
  22. data/lib/react_on_rails_pro/rolling_deploy_cache_stager.rb +516 -0
  23. data/lib/react_on_rails_pro/server_rendering_js_code.rb +47 -10
  24. data/lib/react_on_rails_pro/server_rendering_pool/node_rendering_pool.rb +35 -10
  25. data/lib/react_on_rails_pro/stream_request.rb +42 -49
  26. data/lib/react_on_rails_pro/utils.rb +7 -9
  27. data/lib/react_on_rails_pro/version.rb +1 -1
  28. data/lib/react_on_rails_pro.rb +8 -0
  29. data/lib/tasks/assets.rake +36 -3
  30. data/rakelib/run_rspec.rake +6 -6
  31. data/react_on_rails_pro.gemspec +9 -4
  32. data/sig/react_on_rails_pro/configuration.rbs +2 -0
  33. 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
- form = form_with_code(js_code, send_bundle)
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
- def perform_request(path, **post_options) # rubocop:disable Metrics/AbcSize,Metrics/CyclomaticComplexity
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 = connection.post(path, **post_options)
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) && post_options[:stream]
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(:stream)
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,