react_on_rails_pro 17.0.1 → 17.1.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 (42) hide show
  1. checksums.yaml +4 -4
  2. data/CLAUDE.md +4 -3
  3. data/CONTEXT.md +34 -27
  4. data/Gemfile.development_dependencies +1 -0
  5. data/Gemfile.lock +18 -3
  6. data/LICENSE_SETUP.md +42 -9
  7. data/README.md +12 -1
  8. data/app/controllers/react_on_rails_pro/rolling_deploy/bundles_controller.rb +69 -95
  9. data/app/helpers/react_on_rails_pro_helper.rb +178 -45
  10. data/docs/adr/0001-boot-seed-rolling-deploy-bundles.md +28 -17
  11. data/lib/react_on_rails_pro/assets_precompile.rb +34 -20
  12. data/lib/react_on_rails_pro/async_props_emitter.rb +64 -1
  13. data/lib/react_on_rails_pro/cache.rb +1 -1
  14. data/lib/react_on_rails_pro/concerns/async_rendering.rb +11 -6
  15. data/lib/react_on_rails_pro/concerns/rsc_payload_renderer.rb +7 -0
  16. data/lib/react_on_rails_pro/concerns/stream.rb +17 -9
  17. data/lib/react_on_rails_pro/configuration.rb +79 -11
  18. data/lib/react_on_rails_pro/engine.rb +11 -5
  19. data/lib/react_on_rails_pro/error.rb +5 -0
  20. data/lib/react_on_rails_pro/license_task_formatter.rb +1 -1
  21. data/lib/react_on_rails_pro/license_validator.rb +38 -38
  22. data/lib/react_on_rails_pro/open_telemetry.rb +210 -0
  23. data/lib/react_on_rails_pro/pre_seed_renderer_cache.rb +269 -35
  24. data/lib/react_on_rails_pro/renderer_artifact.rb +176 -0
  25. data/lib/react_on_rails_pro/renderer_artifact_support.rb +287 -0
  26. data/lib/react_on_rails_pro/renderer_cache_helpers.rb +58 -32
  27. data/lib/react_on_rails_pro/renderer_http_client.rb +134 -39
  28. data/lib/react_on_rails_pro/request.rb +249 -118
  29. data/lib/react_on_rails_pro/rolling_deploy/tarball.rb +48 -18
  30. data/lib/react_on_rails_pro/rolling_deploy_adapters/http.rb +242 -93
  31. data/lib/react_on_rails_pro/rolling_deploy_cache_stager.rb +77 -19
  32. data/lib/react_on_rails_pro/server_rendering_js_code.rb +48 -4
  33. data/lib/react_on_rails_pro/server_rendering_pool/node_rendering_pool.rb +46 -7
  34. data/lib/react_on_rails_pro/stream_cache.rb +18 -0
  35. data/lib/react_on_rails_pro/stream_request.rb +9 -2
  36. data/lib/react_on_rails_pro/utils.rb +77 -17
  37. data/lib/react_on_rails_pro/version.rb +1 -1
  38. data/lib/react_on_rails_pro.rb +1 -0
  39. data/sig/react_on_rails_pro/configuration.rbs +7 -0
  40. data/sig/react_on_rails_pro/open_telemetry.rbs +52 -0
  41. data/sig/react_on_rails_pro/renderer_http_client.rbs +18 -0
  42. metadata +7 -3
@@ -16,12 +16,16 @@
16
16
  require "digest"
17
17
  require "io/wait"
18
18
  require "uri"
19
+ require_relative "open_telemetry"
19
20
  require_relative "renderer_http_client"
20
21
  require_relative "stream_request"
21
22
  require_relative "async_props_emitter"
22
23
 
23
24
  module ReactOnRailsPro
24
25
  class Request # rubocop:disable Metrics/ClassLength
26
+ RAW_RENDER_CONTENT_TYPE = "application/vnd.react-on-rails.render-request+javascript"
27
+ RAW_RENDER_HEADER_PREFIX = "x-react-on-rails-pro-"
28
+
25
29
  class UploadAssetsWaiter
26
30
  def initialize
27
31
  if Fiber.scheduler
@@ -86,13 +90,15 @@ module ReactOnRailsPro
86
90
  end
87
91
  end
88
92
 
89
- def render_code(path, js_code, send_bundle)
93
+ def render_code(path, js_code, send_bundle, bundle_role: :server, artifacts: nil)
90
94
  Rails.logger.info { "[ReactOnRailsPro] Perform rendering request #{path}" }
91
- form = form_with_code(js_code, send_bundle)
92
- perform_request(path, form:)
95
+ artifacts = resolve_upload_artifacts(artifacts, action_description: "uploading requested assets") if send_bundle
96
+ request_path = send_bundle ? retarget_render_path(path, artifacts, bundle_role) : path
97
+ form = form_with_code(js_code, send_bundle, artifacts:)
98
+ perform_render_request(request_path, js_code, form:, send_bundle:)
93
99
  end
94
100
 
95
- def render_code_as_stream(path, js_code, is_rsc_payload:, rsc_stream_observability: false)
101
+ def render_code_as_stream(path, js_code, is_rsc_payload:, rsc_stream_observability: false, artifacts: nil)
96
102
  Rails.logger.info { "[ReactOnRailsPro] Perform rendering request as a stream #{path}" }
97
103
  if is_rsc_payload && !ReactOnRailsPro.configuration.enable_rsc_support
98
104
  raise ReactOnRailsPro::Error,
@@ -102,18 +108,16 @@ module ReactOnRailsPro
102
108
  end
103
109
 
104
110
  warn_cb = ->(request_time) { warn_if_slow_streaming_first_chunk(path, request_time) }
111
+ bundle_role = is_rsc_payload ? :rsc : :server
105
112
  ReactOnRailsPro::StreamRequest.create(first_chunk_warn_callback: warn_cb) do |send_bundle, _tasks|
106
- if send_bundle
107
- Rails.logger.info { "[ReactOnRailsPro] Sending bundle to the node renderer" }
108
- upload_assets
109
- end
113
+ request_path, request_artifacts = prepare_streaming_request(path, send_bundle:, bundle_role:, artifacts:)
110
114
 
111
- form = form_with_code(js_code, false, rsc_stream_observability:)
112
- perform_request(path, form:, stream: true)
115
+ form = form_with_code(js_code, false, artifacts: request_artifacts, rsc_stream_observability:)
116
+ perform_render_request(request_path, js_code, form:, send_bundle: false, stream: true)
113
117
  end
114
118
  end
115
119
 
116
- # Performs an incremental render request with bidirectional HTTP/2 streaming.
120
+ # Performs an incremental render request with bidirectional streaming.
117
121
  #
118
122
  # ARCHITECTURE: This method orchestrates the async props flow:
119
123
  #
@@ -130,13 +134,13 @@ module ReactOnRailsPro
130
134
  # │ │ └── Sends NDJSON: {updateChunk} │
131
135
  # │ │ │
132
136
  # │ ... streaming HTML chunks ... │ 4. Block completes │
133
- # │ │ output.close (sends END_STREAM) │
137
+ # │ │ output.close (request EOF) │
134
138
  # └───────────────────────────────────┴─────────────────────────────────────┘
135
139
  #
136
140
  # WHY async task?
137
141
  # - We need to return the response stream immediately so Rails can start sending HTML
138
142
  # - The async_props_block runs concurrently, sending props as they become available
139
- # - When the block finishes, we close the output (END_STREAM flag)
143
+ # - When the block finishes, we close the request body
140
144
  # - Node's handleRequestClosed then calls asyncPropsManager.endStream()
141
145
  #
142
146
  def render_code_with_incremental_updates(
@@ -145,51 +149,52 @@ module ReactOnRailsPro
145
149
  async_props_block:,
146
150
  pull_enabled: false,
147
151
  push_props: nil,
148
- rsc_stream_observability: false
152
+ is_rsc_payload: false,
153
+ rsc_stream_observability: false,
154
+ artifacts: nil
149
155
  )
150
156
  Rails.logger.info { "[ReactOnRailsPro] Perform incremental rendering request #{path}" }
151
157
 
152
- pool = ReactOnRailsPro::ServerRenderingPool::NodeRenderingPool
153
158
  if !push_props.nil? && !pull_enabled
154
159
  raise ArgumentError, "push_props can only be provided when pull_enabled is true"
155
160
  end
156
161
 
157
162
  warn_cb = ->(request_time) { warn_if_slow_streaming_first_chunk(path, request_time) }
163
+ bundle_role = is_rsc_payload ? :rsc : :server
158
164
  ReactOnRailsPro::StreamRequest.create(
159
165
  first_chunk_warn_callback: warn_cb,
160
166
  pull_enabled:
161
167
  ) do |send_bundle, tasks|
162
- if send_bundle
163
- Rails.logger.info { "[ReactOnRailsPro] Sending bundle to the node renderer" }
164
- upload_assets
165
- end
168
+ request_path, request_artifacts = prepare_streaming_request(path, send_bundle:, bundle_role:, artifacts:)
166
169
 
167
- # Open a bidirectional HTTP/2 stream using async-http's Writable body.
168
- # output supports << (alias for write) and close (sends END_STREAM).
170
+ # Open a bidirectional request using async-http's Writable body.
171
+ # output supports << (alias for write) and close (signals request EOF).
169
172
  output, response = connection.post_bidi(
170
- path,
173
+ request_path,
171
174
  headers: [["content-type", "application/x-ndjson"]]
172
175
  )
173
176
 
174
- emitter = ReactOnRailsPro::AsyncPropsEmitter.new(
175
- pool.rsc_bundle_hash,
177
+ emitter = build_incremental_emitter(request_artifacts, output, pull_enabled:)
178
+ write_initial_incremental_request(
176
179
  output,
177
- pull_enabled:
178
- )
179
- initial_data = build_initial_incremental_request(
180
- js_code, emitter, pull_enabled:, push_props:, rsc_stream_observability:
180
+ js_code,
181
+ emitter,
182
+ artifacts: request_artifacts,
183
+ pull_enabled:,
184
+ push_props:,
185
+ rsc_stream_observability:
181
186
  )
182
187
 
183
- # Send the initial render request as first NDJSON line
184
- output << "#{initial_data.to_json}\n"
185
-
186
188
  # Execute async props block in a separate fiber.
187
189
  # This runs concurrently with the response streaming back to the client.
190
+ parent_context = ReactOnRailsPro::OpenTelemetry.capture_context
188
191
  tasks.push(Async::Task.current.async do
189
- async_props_block.call(emitter)
192
+ ReactOnRailsPro::OpenTelemetry.with_context(parent_context) do
193
+ async_props_block.call(emitter)
194
+ end
190
195
  ensure
191
196
  # When the block completes (or raises), close the output.
192
- # This sends HTTP/2 END_STREAM flag, triggering Node's handleRequestClosed.
197
+ # This signals request-body EOF, triggering Node's handleRequestClosed.
193
198
  output.close
194
199
  end)
195
200
 
@@ -197,35 +202,16 @@ module ReactOnRailsPro
197
202
  end
198
203
  end
199
204
 
200
- def upload_assets
205
+ def upload_assets(artifacts: nil)
201
206
  Rails.logger.info { "[ReactOnRailsPro] Uploading assets" }
202
-
203
- # Early checks with descriptive messages. add_bundle_to_form(check_bundle: true) also
204
- # validates existence, but these provide clearer context for the rake task user.
205
- server_bundle_path = ReactOnRails::Utils.server_bundle_js_file_path
206
- unless File.exist?(server_bundle_path)
207
- raise ReactOnRailsPro::Error, "Server bundle not found at #{server_bundle_path}. " \
208
- "Please build your bundles before uploading assets."
209
- end
210
-
211
- # Create a list of bundle timestamps to send to the node renderer
212
- pool = ReactOnRailsPro::ServerRenderingPool::NodeRenderingPool
213
- target_bundles = [pool.server_bundle_hash]
214
-
215
- # Add RSC bundle if enabled
216
- if ReactOnRailsPro.configuration.enable_rsc_support
217
- rsc_bundle_path = ReactOnRailsPro::Utils.rsc_bundle_js_file_path
218
- unless File.exist?(rsc_bundle_path)
219
- raise ReactOnRailsPro::Error, "RSC bundle not found at #{rsc_bundle_path}. " \
220
- "Please build your bundles before uploading assets."
221
- end
222
- target_bundles << pool.rsc_bundle_hash
223
- end
224
-
225
- assets_to_copy = assets_to_copy_for_upload
226
-
227
- with_asset_upload_single_flight(upload_assets_single_flight_key(target_bundles, assets_to_copy)) do
228
- form = form_with_assets_and_bundle(assets_to_copy:)
207
+ artifacts = resolve_upload_artifacts(artifacts, action_description: "uploading assets")
208
+ target_bundles = artifacts.map(&:id)
209
+
210
+ # Artifact IDs already bind every bundle and companion byte. Keying the
211
+ # single-flight upload by those IDs avoids re-reading live paths after
212
+ # the operation-scoped snapshot was constructed.
213
+ with_asset_upload_single_flight(upload_assets_single_flight_key(target_bundles, [])) do
214
+ form = form_with_assets_and_bundle(artifacts:)
229
215
  # TODO: targetBundles is only kept for backward compatibility with older node renderers
230
216
  # (protocol 2.0.0) that require it. The new node renderer derives target directories from
231
217
  # the bundle_<hash> form keys and ignores this field. Remove at the next breaking version.
@@ -251,7 +237,8 @@ module ReactOnRailsPro
251
237
 
252
238
  form_data["targetBundles"] = target_bundles
253
239
 
254
- response = perform_request("/asset-exists?filename=#{filename}", json: form_data)
240
+ encoded_filename = URI.encode_www_form_component(filename)
241
+ response = perform_request("/asset-exists?filename=#{encoded_filename}", json: form_data)
255
242
  JSON.parse(response.body)["exists"] == true
256
243
  end
257
244
 
@@ -288,7 +275,9 @@ module ReactOnRailsPro
288
275
  def retry_or_raise_transport_error(error, available_retries, path, error_type)
289
276
  if available_retries.zero?
290
277
  raise ReactOnRailsPro::Error,
291
- "#{error_type} error on renderer request: #{path}.\nOriginal error:\n#{error}\n#{error.backtrace}"
278
+ "#{error_type} error on renderer request: #{path}.\n" \
279
+ "#{ReactOnRailsPro::RendererHttpClient.transport_config_description}\n" \
280
+ "Original error:\n#{error}\n#{error.backtrace}"
292
281
  end
293
282
  Rails.logger.info do
294
283
  "[ReactOnRailsPro] #{error_type} error when making a request to the Node Renderer. " \
@@ -331,103 +320,163 @@ module ReactOnRailsPro
331
320
  "expected at most #{warn_timeout}."
332
321
  end
333
322
 
334
- def form_with_code(js_code, send_bundle, rsc_stream_observability: false)
335
- form = common_form_data
323
+ def form_with_code(js_code, send_bundle, artifacts: nil, rsc_stream_observability: false)
324
+ artifacts ||= if send_bundle
325
+ ReactOnRailsPro::Utils.renderer_artifacts(action_description: "uploading requested assets")
326
+ end
327
+ form = common_form_data(artifacts:)
336
328
  form["renderingRequest"] = js_code
337
329
  form["rscStreamObservability"] = true if rsc_stream_observability
338
- populate_form_with_bundle_and_assets(form, check_bundle: false) if send_bundle
330
+ populate_form_with_bundle_and_assets(form, check_bundle: false, artifacts:) if send_bundle
339
331
  form
340
332
  end
341
333
 
342
- def populate_form_with_bundle_and_assets(form, check_bundle:, assets_to_copy: assets_to_copy_for_upload)
343
- pool = ReactOnRailsPro::ServerRenderingPool::NodeRenderingPool
334
+ def perform_render_request(path, js_code, form:, send_bundle:, stream: false)
335
+ if send_bundle
336
+ perform_request(path, form:, stream:)
337
+ else
338
+ perform_request(path, raw: raw_render_request(js_code, form), stream:)
339
+ end
340
+ end
344
341
 
345
- add_bundle_to_form(
346
- form,
347
- bundle_path: ReactOnRails::Utils.server_bundle_js_file_path,
348
- bundle_file_name: pool.renderer_bundle_file_name,
349
- bundle_hash: pool.server_bundle_hash,
350
- check_bundle:
351
- )
342
+ # The field list here must stay in sync with common_form_data / form_with_code and
343
+ # the Node renderer's normalizeRawRenderRequest (node renderer's worker.ts); an
344
+ # unmapped field raises ArgumentError below. Every value is sanitized because these
345
+ # body fields become raw HTTP header values.
346
+ def raw_render_request(js_code, form)
347
+ metadata = form.except("renderingRequest")
348
+ headers = [
349
+ ["content-type", RAW_RENDER_CONTENT_TYPE],
350
+ ["#{RAW_RENDER_HEADER_PREFIX}protocol-version",
351
+ sanitize_raw_render_header(metadata.delete("protocolVersion"))],
352
+ ["#{RAW_RENDER_HEADER_PREFIX}gem-version", sanitize_raw_render_header(metadata.delete("gemVersion"))],
353
+ ["#{RAW_RENDER_HEADER_PREFIX}rails-env", sanitize_raw_render_header(metadata.delete("railsEnv"))],
354
+ [
355
+ "#{RAW_RENDER_HEADER_PREFIX}dependency-bundle-timestamps",
356
+ sanitize_raw_render_header(JSON.generate(Array(metadata.delete("dependencyBundleTimestamps"))))
357
+ ]
358
+ ]
359
+ password = metadata.delete("password")
360
+ headers << ["authorization", "Bearer #{sanitize_raw_render_header(password)}"] if password
361
+ if metadata.key?("rscStreamObservability")
362
+ observability = sanitize_raw_render_header(metadata.delete("rscStreamObservability"))
363
+ headers << ["#{RAW_RENDER_HEADER_PREFIX}rsc-stream-observability", observability]
364
+ end
365
+ raise ArgumentError, "Unsupported raw render metadata: #{metadata.keys.join(', ')}" unless metadata.empty?
366
+
367
+ { headers:, body: js_code }
368
+ end
352
369
 
353
- if ReactOnRailsPro.configuration.enable_rsc_support
370
+ def sanitize_raw_render_header(value)
371
+ value.to_s.tap do |header_value|
372
+ raise ArgumentError, "Renderer metadata headers cannot contain newlines" if header_value.match?(/[\r\n]/)
373
+ end
374
+ end
375
+
376
+ def populate_form_with_bundle_and_assets(form, check_bundle:, artifacts: nil, assets_to_copy: nil)
377
+ artifacts ||= ReactOnRailsPro::Utils.renderer_artifacts(action_description: "building renderer upload")
378
+ artifacts.each do |artifact|
354
379
  add_bundle_to_form(
355
380
  form,
356
- bundle_path: ReactOnRailsPro::Utils.rsc_bundle_js_file_path,
357
- bundle_file_name: pool.rsc_renderer_bundle_file_name,
358
- bundle_hash: pool.rsc_bundle_hash,
359
- check_bundle:
381
+ bundle_path: artifact.bundle,
382
+ bundle_file_name: "#{artifact.id}.js",
383
+ bundle_hash: artifact.id,
384
+ check_bundle:,
385
+ bundle_body: artifact.bundle_body
360
386
  )
361
387
  end
362
388
 
363
- add_assets_to_form(form, assets_to_copy:)
389
+ if assets_to_copy
390
+ add_assets_to_form(form, assets_to_copy:)
391
+ else
392
+ artifact = artifacts.fetch(0)
393
+ add_assets_to_form(
394
+ form,
395
+ assets_to_copy: artifact.companions,
396
+ snapshot_bodies: artifact.companion_bodies
397
+ )
398
+ end
364
399
  end
365
400
 
366
- def add_bundle_to_form(form, bundle_path:, bundle_file_name:, bundle_hash:, check_bundle:)
367
- raise ReactOnRailsPro::Error, "Bundle not found #{bundle_path}" if check_bundle && !File.exist?(bundle_path)
401
+ def add_bundle_to_form(form, bundle_path:, bundle_file_name:, bundle_hash:, check_bundle:, bundle_body: nil)
402
+ if check_bundle && bundle_body.nil? && !File.exist?(bundle_path)
403
+ raise ReactOnRailsPro::Error, "Bundle not found #{bundle_path}"
404
+ end
368
405
 
369
406
  form["bundle_#{bundle_hash}"] = {
370
- body: get_form_body_for_file(bundle_path),
407
+ body: bundle_body || get_form_body_for_file(bundle_path),
371
408
  content_type: "text/javascript",
372
409
  filename: bundle_file_name
373
410
  }
374
411
  end
375
412
 
376
413
  def assets_to_copy_for_upload
377
- assets_to_copy = (ReactOnRailsPro.configuration.assets_to_copy || []).dup
378
- # react_client_manifest and react_server_manifest files are needed to generate react server components payload
379
- if ReactOnRailsPro.configuration.enable_rsc_support
380
- assets_to_copy << ReactOnRailsPro::Utils.react_client_manifest_file_path
381
- assets_to_copy << ReactOnRailsPro::Utils.react_server_client_manifest_file_path
382
- end
383
-
384
- assets_to_copy
414
+ ReactOnRailsPro::Utils
415
+ .renderer_artifacts(action_description: "collecting renderer upload assets")
416
+ .fetch(0).companions.values
385
417
  end
386
418
 
387
- def add_assets_to_form(form, assets_to_copy:)
419
+ def add_assets_to_form(form, assets_to_copy:, snapshot_bodies: nil)
388
420
  return form unless assets_to_copy.present?
389
421
 
390
- assets_to_copy.each_with_index do |asset_path, idx|
391
- Rails.logger.info { "[ReactOnRailsPro] Uploading asset #{asset_path}" }
392
- unless http_url?(asset_path) || File.exist?(asset_path)
393
- warn "Asset not found #{asset_path}"
394
- next
395
- end
422
+ assets = assets_to_copy.respond_to?(:each_pair) ? assets_to_copy.to_a : Array(assets_to_copy)
423
+ assets.each_with_index do |asset, idx|
424
+ add_asset_to_form(form, asset, idx, snapshot_bodies)
425
+ end
396
426
 
397
- content_type = ReactOnRailsPro::Utils.mine_type_from_file_name(asset_path)
427
+ form
428
+ end
398
429
 
399
- begin
400
- form["assetsToCopy#{idx}"] = {
401
- body: get_form_body_for_file(asset_path),
402
- content_type:,
403
- filename: File.basename(asset_path)
404
- }
405
- rescue StandardError => e
406
- warn "[ReactOnRailsPro] Error uploading asset #{asset_path}: #{e}"
407
- end
430
+ def add_asset_to_form(form, asset, index, snapshot_bodies)
431
+ basename, asset_path = asset.is_a?(Array) ? asset : [File.basename(asset.to_s), asset]
432
+ Rails.logger.info { "[ReactOnRailsPro] Uploading asset #{asset_path}" }
433
+ snapshot_body = snapshot_bodies&.fetch(basename, nil)
434
+ unless uploadable_asset?(asset_path, snapshot_body)
435
+ warn "Asset not found #{asset_path}"
436
+ return
408
437
  end
409
438
 
410
- form
439
+ form["assetsToCopy#{index}"] = {
440
+ body: asset_upload_body(asset_path, snapshot_body),
441
+ content_type: ReactOnRailsPro::Utils.mine_type_from_file_name(basename),
442
+ filename: basename
443
+ }
444
+ rescue StandardError => e
445
+ warn "[ReactOnRailsPro] Error uploading asset #{asset_path}: #{e}"
446
+ end
447
+
448
+ def uploadable_asset?(asset_path, snapshot_body)
449
+ snapshot_body || asset_path.is_a?(RendererArtifact::InlineCompanion) ||
450
+ http_url?(asset_path) || File.exist?(asset_path)
411
451
  end
412
452
 
413
- def form_with_assets_and_bundle(assets_to_copy: assets_to_copy_for_upload)
414
- form = common_form_data
415
- populate_form_with_bundle_and_assets(form, check_bundle: true, assets_to_copy:)
453
+ def asset_upload_body(asset_path, snapshot_body)
454
+ return snapshot_body if snapshot_body
455
+ return asset_path.body if asset_path.is_a?(RendererArtifact::InlineCompanion)
456
+
457
+ get_form_body_for_file(asset_path)
458
+ end
459
+
460
+ def form_with_assets_and_bundle(artifacts: nil, assets_to_copy: nil)
461
+ artifacts ||= ReactOnRailsPro::Utils.renderer_artifacts(action_description: "building renderer upload")
462
+ form = common_form_data(artifacts:)
463
+ populate_form_with_bundle_and_assets(form, check_bundle: true, artifacts:, assets_to_copy:)
416
464
  form
417
465
  end
418
466
 
419
- def common_form_data
420
- ReactOnRailsPro::Utils.common_form_data
467
+ def common_form_data(artifacts: nil)
468
+ ReactOnRailsPro::Utils.common_form_data(artifacts:)
421
469
  end
422
470
 
423
471
  def build_initial_incremental_request(
424
472
  js_code,
425
473
  emitter,
474
+ artifacts: nil,
426
475
  pull_enabled: false,
427
476
  push_props: nil,
428
477
  rsc_stream_observability: false
429
478
  )
430
- data = common_form_data.merge(
479
+ data = common_form_data(artifacts:).merge(
431
480
  renderingRequest: js_code,
432
481
  onRequestClosedUpdateChunk: emitter.end_stream_chunk
433
482
  )
@@ -439,6 +488,82 @@ module ReactOnRailsPro
439
488
  data
440
489
  end
441
490
 
491
+ def build_incremental_emitter(artifacts, output, pull_enabled:)
492
+ rsc_artifact = artifact_for_role(artifacts, :rsc)
493
+ pool = ReactOnRailsPro::ServerRenderingPool::NodeRenderingPool
494
+ ReactOnRailsPro::AsyncPropsEmitter.new(
495
+ rsc_artifact&.id || pool.rsc_bundle_hash,
496
+ output,
497
+ pull_enabled:
498
+ )
499
+ end
500
+
501
+ def write_initial_incremental_request(output, js_code, emitter, **)
502
+ initial_data = build_initial_incremental_request(js_code, emitter, **)
503
+ output << "#{initial_data.to_json}\n"
504
+ end
505
+
506
+ def retarget_render_path(path, artifacts, role)
507
+ match = path.match(%r{\A/bundles/[^/]+/(?<endpoint>render|incremental-render)/})
508
+ return path unless match
509
+
510
+ artifact = artifact_for_role(artifacts, role)
511
+ unless artifact
512
+ raise ReactOnRailsPro::Error,
513
+ "No renderer artifact is configured for role #{role.inspect} while retrying #{match[:endpoint]}"
514
+ end
515
+
516
+ path.sub(%r{\A/bundles/[^/]+/}, "/bundles/#{artifact.id}/")
517
+ end
518
+
519
+ def prepare_streaming_request(path, send_bundle:, bundle_role:, artifacts: nil)
520
+ return [path, artifacts] unless send_bundle
521
+
522
+ Rails.logger.info { "[ReactOnRailsPro] Sending bundle to the node renderer" }
523
+ artifacts = resolve_upload_artifacts(artifacts, action_description: "uploading requested assets")
524
+ request_path = retarget_render_path(path, artifacts, bundle_role)
525
+ upload_assets(artifacts:)
526
+ [request_path, artifacts]
527
+ end
528
+
529
+ def resolve_upload_artifacts(artifacts, action_description:)
530
+ return ReactOnRailsPro::Utils.renderer_artifacts(action_description:) unless artifacts
531
+
532
+ artifacts = Array(artifacts)
533
+ expected_ids = expected_artifact_ids(artifacts)
534
+ return artifacts unless expected_ids
535
+
536
+ current_artifacts = ReactOnRailsPro::Utils.renderer_artifacts(
537
+ action_description:,
538
+ roles: expected_ids.keys
539
+ )
540
+ current_ids = current_artifacts.to_h { |artifact| [artifact.role, artifact.id] }
541
+ return current_artifacts if current_ids == expected_ids && current_artifacts.length == expected_ids.length
542
+
543
+ raise ReactOnRailsPro::Error,
544
+ "Renderer artifacts changed after this render request was prepared; refusing to upload different bytes"
545
+ end
546
+
547
+ def expected_artifact_ids(artifacts)
548
+ identities = artifacts.select { |artifact| artifact.is_a?(RendererArtifact::Identity) }
549
+ return nil if identities.empty?
550
+
551
+ unless identities.length == artifacts.length
552
+ raise ReactOnRailsPro::Error, "Renderer artifact snapshots cannot mix identities and captured bodies"
553
+ end
554
+
555
+ ids_by_role = identities.to_h { |identity| [identity.role, identity.id] }
556
+ if ids_by_role.length != identities.length
557
+ raise ReactOnRailsPro::Error, "Renderer artifact snapshot contains duplicate roles"
558
+ end
559
+
560
+ ids_by_role
561
+ end
562
+
563
+ def artifact_for_role(artifacts, role)
564
+ Array(artifacts).find { |artifact| artifact.role == role }
565
+ end
566
+
442
567
  def upload_assets_single_flight_key(target_bundles, assets_to_copy)
443
568
  [
444
569
  "bundles",
@@ -451,6 +576,10 @@ module ReactOnRailsPro
451
576
  end
452
577
 
453
578
  def upload_asset_fingerprint(asset_path)
579
+ if asset_path.is_a?(RendererArtifact::InlineCompanion)
580
+ return "inline:#{asset_path.url}:#{Digest::SHA256.hexdigest(asset_path.body)}"
581
+ end
582
+
454
583
  path = asset_path.to_s
455
584
  return "url:#{path}" if http_url?(path)
456
585
  return "missing:#{path}" unless File.exist?(path)
@@ -543,7 +672,7 @@ module ReactOnRailsPro
543
672
 
544
673
  def create_connection
545
674
  url = ReactOnRailsPro.configuration.renderer_url
546
- Rails.logger.info do
675
+ Rails.logger.debug do
547
676
  "[ReactOnRailsPro] Setting up Node Renderer connection to #{url}"
548
677
  end
549
678
 
@@ -551,7 +680,8 @@ module ReactOnRailsPro
551
680
  origin: url,
552
681
  pool_size: ReactOnRailsPro.configuration.renderer_http_pool_size,
553
682
  connect_timeout: ReactOnRailsPro.configuration.renderer_http_pool_timeout,
554
- read_timeout: ReactOnRailsPro.configuration.ssr_timeout
683
+ read_timeout: ReactOnRailsPro.configuration.ssr_timeout,
684
+ force_http2: ReactOnRailsPro.configuration.renderer_http_force_http2
555
685
  )
556
686
  rescue StandardError => e
557
687
  message = <<~MSG
@@ -560,6 +690,7 @@ module ReactOnRailsPro
560
690
  renderer_http_pool_timeout = #{ReactOnRailsPro.configuration.renderer_http_pool_timeout}
561
691
  renderer_http_pool_warn_timeout = #{ReactOnRailsPro.configuration.renderer_http_pool_warn_timeout}
562
692
  renderer_http_keep_alive_timeout = #{ReactOnRailsPro.configuration.renderer_http_keep_alive_timeout}
693
+ renderer_http_force_http2 = #{ReactOnRailsPro.configuration.renderer_http_force_http2}
563
694
  renderer_url = #{url}
564
695
  Be sure to use a url that contains the protocol of http or https.
565
696
  Original error is
@@ -20,6 +20,7 @@ require "tempfile"
20
20
  require "zlib"
21
21
 
22
22
  require "react_on_rails_pro/error"
23
+ require "react_on_rails_pro/renderer_artifact"
23
24
 
24
25
  module ReactOnRailsPro
25
26
  module RollingDeploy
@@ -51,17 +52,23 @@ module ReactOnRailsPro
51
52
  # file doesn't blow heap before the running total trips the size cap.
52
53
  EXTRACT_CHUNK_SIZE = 64 * 1024
53
54
 
54
- # Path-safety regex for tar entries. We require entries to be flat
55
- # basenames (no slashes, no `..`, no NUL bytes, no leading dot, no
56
- # leading hyphen) so an attacker can never write outside the target
57
- # directory or hide files from `ls`. The `./` prefix permitted by tar
58
- # is normalised away before this match runs.
59
- #
60
- # Functionally identical to `ReactOnRailsPro::RollingDeploy::SAFE_HASH_PATTERN`
61
- # — kept as a separate constant because tarball entry names and
62
- # rolling-deploy hash strings are conceptually distinct: a future
63
- # protocol change might tighten one without the other.
64
- ENTRY_NAME_PATTERN = /\A[A-Za-z0-9_][A-Za-z0-9_.\-]*\z/
55
+ # Path-safety regex for tar entries. Reuse RendererArtifact's companion
56
+ # contract while keeping the transport-specific TarWriter byte limit at
57
+ # this boundary. The shared pattern rejects
58
+ # slash, backslash, colon, NUL/ASCII controls, and the special `.` / `..`
59
+ # directory entries while permitting ordinary filename characters such
60
+ # as spaces, `@`, `%`, Unicode, and leading dots or hyphens. The `./`
61
+ # prefix permitted by tar is normalised away before this match runs.
62
+ # RubyGems TarWriter stores a flat name in a 100-byte header field.
63
+ ENTRY_NAME_PATTERN = ReactOnRailsPro::RendererArtifact::SAFE_COMPANION_NAME_PATTERN
64
+ ENTRY_NAME_MAX_BYTES = 100
65
+
66
+ def safe_entry_name?(value)
67
+ name = value.to_s.dup.force_encoding(Encoding::UTF_8)
68
+ name.valid_encoding? &&
69
+ name.bytesize <= ENTRY_NAME_MAX_BYTES &&
70
+ ReactOnRailsPro::RendererArtifact.safe_companion_name?(name)
71
+ end
65
72
 
66
73
  # Compose a gzipped tarball from the given entries and yield the
67
74
  # resulting Tempfile. The temp file is removed when the block returns.
@@ -158,10 +165,23 @@ module ReactOnRailsPro
158
165
 
159
166
  def validate_compose_entries!(entries)
160
167
  entries.each do |name, path|
161
- unless ENTRY_NAME_PATTERN.match?(name.to_s)
168
+ raw_name = name.to_s
169
+ utf8_name = raw_name.dup.force_encoding(Encoding::UTF_8)
170
+ unless utf8_name.valid_encoding?
171
+ raise ReactOnRailsPro::Error,
172
+ "Tarball entry name #{raw_name.inspect} is not valid UTF-8."
173
+ end
174
+ if utf8_name.bytesize > ENTRY_NAME_MAX_BYTES
175
+ raise ReactOnRailsPro::Error,
176
+ "Tarball entry name #{raw_name.inspect} exceeds the maximum of " \
177
+ "#{ENTRY_NAME_MAX_BYTES} UTF-8 bytes."
178
+ end
179
+
180
+ unless safe_entry_name?(utf8_name)
162
181
  raise ReactOnRailsPro::Error,
163
182
  "Tarball entry name #{name.inspect} is not a safe basename. " \
164
- "Allowed: flat alphanumeric basenames (no slashes, no leading dot)."
183
+ "Allowed: flat basenames up to #{ENTRY_NAME_MAX_BYTES} UTF-8 bytes without slashes, " \
184
+ "backslashes, colons, or control characters."
165
185
  end
166
186
 
167
187
  raise ReactOnRailsPro::Error, "Tarball source path for #{name.inspect} is blank" if path.to_s.empty?
@@ -192,14 +212,24 @@ module ReactOnRailsPro
192
212
  def safe_entry_name!(entry)
193
213
  # `TarReader` exposes the entry name verbatim; tar archives commonly
194
214
  # prefix entries with `./`. Strip that prefix before the pattern check
195
- # so a legitimate `./bundle.js` doesn't get rejected, but anything more
196
- # exotic (subdir, leading-dot hidden name, traversal) still fails.
215
+ # so a legitimate `./bundle.js` doesn't get rejected. The shared flat-
216
+ # basename contract still rejects subdirectories and traversal names.
197
217
  raw = entry.full_name.to_s
198
- name = raw.delete_prefix("./")
199
- unless ENTRY_NAME_PATTERN.match?(name)
218
+ # Ruby 3.3 tags tar header bytes as ASCII-8BIT even when they contain a
219
+ # valid UTF-8 filename. The wire contract uses UTF-8 names: retag valid
220
+ # bytes before matching/writing, and reject malformed input instead of
221
+ # passing ambiguous bytes to the filesystem.
222
+ name = raw.dup.force_encoding(Encoding::UTF_8)
223
+ unless name.valid_encoding?
224
+ raise ReactOnRailsPro::Error,
225
+ "Rolling-deploy tarball entry name #{raw.inspect} is not valid UTF-8."
226
+ end
227
+ name = name.delete_prefix("./")
228
+ unless safe_entry_name?(name)
200
229
  raise ReactOnRailsPro::Error,
201
230
  "Rolling-deploy tarball entry name #{raw.inspect} is not a safe basename. " \
202
- "Allowed: flat alphanumeric basenames (no slashes, no leading dot, no `..`)."
231
+ "Allowed: flat basenames up to #{ENTRY_NAME_MAX_BYTES} UTF-8 bytes without slashes, " \
232
+ "backslashes, colons, control characters, or `.` / `..`."
203
233
  end
204
234
  name
205
235
  end