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
@@ -18,6 +18,7 @@
18
18
  # 2. Keep all #{some_var} fully to the left so that all indentation is done evenly in that var
19
19
 
20
20
  require "react_on_rails/helper"
21
+ require "react_on_rails_pro/open_telemetry"
21
22
  require "async/promise"
22
23
  require "digest"
23
24
  require "json"
@@ -158,8 +159,13 @@ module ReactOnRailsProHelper
158
159
  # Extract streaming-specific callback
159
160
  on_complete = options.delete(:on_complete)
160
161
 
162
+ # Optional per-chunk error callback (set by cached_stream_react_component) that
163
+ # is notified of each chunk's `hasErrors` flag so an error-containing render is
164
+ # not written to the cache. See https://github.com/shakacode/react_on_rails/issues/4581.
165
+ on_chunk_errors = options.delete(:on_chunk_errors)
166
+
161
167
  consumer_stream_async(on_complete:) do
162
- internal_stream_react_component(component_name, options)
168
+ internal_stream_react_component(component_name, options, on_chunk_errors:)
163
169
  end
164
170
  end
165
171
 
@@ -175,10 +181,11 @@ module ReactOnRailsProHelper
175
181
  end
176
182
 
177
183
  on_complete = options.delete(:on_complete)
184
+ on_chunk_errors = options.delete(:on_chunk_errors)
178
185
  collect_chunks = on_complete.respond_to?(:call)
179
186
  buffer = collect_chunks ? [] : +""
180
187
 
181
- internal_stream_react_component(component_name, options).each_chunk do |chunk|
188
+ internal_stream_react_component(component_name, options, on_chunk_errors:).each_chunk do |chunk|
182
189
  buffer << chunk.to_s
183
190
  end
184
191
 
@@ -330,13 +337,12 @@ module ReactOnRailsProHelper
330
337
  prerender: true
331
338
  )
332
339
 
333
- cached_result = fetch_react_component(component_name, cache_options) do
334
- options = render_options.merge(
335
- props: yield,
336
- skip_prerender_cache: true
337
- )
338
- buffered_stream_react_component(component_name, options)
339
- end
340
+ cached_result = render_cached_buffered_stream_react_component(
341
+ component_name,
342
+ cache_options,
343
+ render_options,
344
+ &block
345
+ )
340
346
  cached_result.html_safe
341
347
  end
342
348
  end
@@ -391,8 +397,11 @@ module ReactOnRailsProHelper
391
397
  "Include ReactOnRailsPro::AsyncRendering in your controller and call enable_async_react_rendering."
392
398
  end
393
399
 
400
+ parent_context = ReactOnRailsPro::OpenTelemetry.capture_context
394
401
  task = @react_on_rails_async_barrier.async do
395
- react_component(component_name, options)
402
+ ReactOnRailsPro::OpenTelemetry.with_context(parent_context) do
403
+ react_component(component_name, options)
404
+ end
396
405
  end
397
406
 
398
407
  ReactOnRailsPro::AsyncValue.new(task:)
@@ -444,30 +453,64 @@ module ReactOnRailsProHelper
444
453
 
445
454
  private
446
455
 
447
- def fetch_react_component(component_name, options)
456
+ def render_cached_buffered_stream_react_component(component_name, cache_options, render_options)
457
+ stream_has_errors = false
458
+ fetch_react_component(component_name, cache_options, cache_write_if: -> { !stream_has_errors }) do
459
+ options = render_options.merge(
460
+ props: yield,
461
+ skip_prerender_cache: true,
462
+ on_chunk_errors: ->(chunk_has_errors) { stream_has_errors ||= chunk_has_errors == true }
463
+ )
464
+ buffered_stream_react_component(component_name, options)
465
+ end
466
+ end
467
+
468
+ def fetch_react_component(component_name, options, cache_write_if: nil)
448
469
  return yield unless ReactOnRailsPro::Cache.use_cache?(options)
449
470
 
450
471
  cache_key = ReactOnRailsPro::Cache.react_component_cache_key(component_name, options)
451
472
  Rails.logger.debug { "React on Rails Pro cache_key is #{cache_key.inspect}" }
452
- cache_options = ReactOnRailsPro::Cache.cache_write_options(options[:cache_options])
473
+ cache_write_options = ReactOnRailsPro::Cache.cache_write_options(options[:cache_options])
453
474
  if ReactOnRailsPro::Cache.cache_write_expired?(options[:cache_options])
454
475
  return add_component_cache_metadata(yield, cache_key, false)
455
476
  end
456
477
 
457
- cache_hit = true
458
478
  normalized_cache_tags = []
459
- result = Rails.cache.fetch(cache_key, cache_options) do
460
- cache_hit = false
479
+ result, cache_hit, cache_write_skipped = fetch_cache_entry(
480
+ cache_key,
481
+ cache_write_options,
482
+ cache_write_if:
483
+ ) do
461
484
  normalized_cache_tags = ReactOnRailsPro::Cache.normalize_tags(options[:cache_tags])
462
485
  yield
463
486
  end
464
- ReactOnRailsPro::Cache.register_normalized_tags(normalized_cache_tags, cache_key, cache_options) unless cache_hit
487
+ unless cache_hit || cache_write_skipped
488
+ ReactOnRailsPro::Cache.register_normalized_tags(normalized_cache_tags, cache_key, cache_write_options)
489
+ end
465
490
  load_pack_for_cached_react_component(component_name, options) if cache_hit
466
491
  result = normalize_cached_pro_attribution(result) if cache_hit
467
492
 
468
493
  add_component_cache_metadata(result, cache_key, cache_hit)
469
494
  end
470
495
 
496
+ def fetch_cache_entry(cache_key, cache_write_options, cache_write_if:)
497
+ cache_hit = true
498
+ cache_write_skipped = false
499
+ skip_cache_write = Object.new
500
+ result = catch(skip_cache_write) do
501
+ Rails.cache.fetch(cache_key, cache_write_options) do
502
+ cache_hit = false
503
+ rendered_result = yield
504
+ next rendered_result unless cache_write_if && !cache_write_if.call
505
+
506
+ cache_write_skipped = true
507
+ throw(skip_cache_write, rendered_result)
508
+ end
509
+ end
510
+
511
+ [result, cache_hit, cache_write_skipped]
512
+ end
513
+
471
514
  def normalize_cached_pro_attribution(result)
472
515
  return normalize_cached_pro_attribution_html(result) if result.is_a?(String)
473
516
 
@@ -599,21 +642,30 @@ module ReactOnRailsProHelper
599
642
  end
600
643
 
601
644
  def render_cached_static_rsc_component(component_name, cache_options, render_options, diagnostics_context, &block)
645
+ stream_has_errors = false
602
646
  fetch_static_rsc_component(
603
647
  component_name,
604
648
  cache_options,
605
649
  render_options,
606
650
  diagnostics_context[:cache],
607
- diagnostics_enabled: static_rsc_render_diagnostics_enabled?(diagnostics_context[:config])
651
+ diagnostics_enabled: static_rsc_render_diagnostics_enabled?(diagnostics_context[:config]),
652
+ cache_write_if: -> { !stream_has_errors }
608
653
  ) do
609
- static_rsc_component_cache_miss_html(component_name, render_options, diagnostics_context, &block)
654
+ static_rsc_component_cache_miss_html(
655
+ component_name,
656
+ render_options,
657
+ diagnostics_context,
658
+ on_chunk_errors: ->(chunk_has_errors) { stream_has_errors ||= chunk_has_errors == true },
659
+ &block
660
+ )
610
661
  end
611
662
  end
612
663
 
613
- def static_rsc_component_cache_miss_html(component_name, render_options, diagnostics_context)
664
+ def static_rsc_component_cache_miss_html(component_name, render_options, diagnostics_context, on_chunk_errors:)
614
665
  options = render_options.merge(
615
666
  props: yield,
616
- skip_prerender_cache: true
667
+ skip_prerender_cache: true,
668
+ on_chunk_errors:
617
669
  )
618
670
  strip_static_rsc_payload_scripts(
619
671
  buffered_stream_react_component(component_name, options),
@@ -627,6 +679,7 @@ module ReactOnRailsProHelper
627
679
  render_options,
628
680
  cache_diagnostics,
629
681
  diagnostics_enabled:,
682
+ cache_write_if:,
630
683
  &
631
684
  )
632
685
  cache_enabled = ReactOnRailsPro::Cache.use_cache?(cache_options)
@@ -652,6 +705,7 @@ module ReactOnRailsProHelper
652
705
  render_options,
653
706
  cache_diagnostics,
654
707
  cache_key,
708
+ cache_write_if:,
655
709
  &
656
710
  )
657
711
  end
@@ -661,18 +715,21 @@ module ReactOnRailsProHelper
661
715
  cache_options,
662
716
  render_options,
663
717
  cache_diagnostics,
664
- cache_key
718
+ cache_key,
719
+ cache_write_if:
665
720
  )
666
721
  cache_write_options = ReactOnRailsPro::Cache.cache_write_options(cache_options[:cache_options])
667
- cache_hit = true
668
722
  normalized_cache_tags = []
669
- result = Rails.cache.fetch(cache_key, cache_write_options) do
670
- cache_hit = false
723
+ result, cache_hit, cache_write_skipped = fetch_cache_entry(
724
+ cache_key,
725
+ cache_write_options,
726
+ cache_write_if:
727
+ ) do
671
728
  normalized_cache_tags = ReactOnRailsPro::Cache.normalize_tags(cache_options[:cache_tags])
672
729
  yield
673
730
  end
674
731
 
675
- unless cache_hit
732
+ unless cache_hit || cache_write_skipped
676
733
  ReactOnRailsPro::Cache.register_normalized_tags(normalized_cache_tags, cache_key, cache_write_options)
677
734
  end
678
735
  load_pack_for_cached_react_component(component_name, render_options) if cache_hit
@@ -1112,14 +1169,17 @@ module ReactOnRailsProHelper
1112
1169
  initial_result = normalize_cached_pro_attribution(cached_chunks.first)
1113
1170
 
1114
1171
  # Enqueue remaining chunks asynchronously
1172
+ parent_context = ReactOnRailsPro::OpenTelemetry.capture_context
1115
1173
  @async_barrier.async do |task|
1116
- task.yield
1174
+ ReactOnRailsPro::OpenTelemetry.with_context(parent_context) do
1175
+ task.yield
1117
1176
 
1118
- cached_chunks.each_with_index do |chunk, index|
1119
- next if index.zero?
1120
- break if response.stream.closed?
1177
+ cached_chunks.each_with_index do |chunk, index|
1178
+ next if index.zero?
1179
+ break if response.stream.closed?
1121
1180
 
1122
- @main_output_queue.enqueue(normalize_cached_pro_attribution(chunk))
1181
+ @main_output_queue.enqueue(normalize_cached_pro_attribution(chunk))
1182
+ end
1123
1183
  end
1124
1184
  rescue Async::Queue::ClosedError
1125
1185
  # Queue closed due to error/disconnect in another component — stop enqueuing
@@ -1132,8 +1192,21 @@ module ReactOnRailsProHelper
1132
1192
  def handle_stream_cache_miss(component_name, raw_options, auto_load_bundle, view_cache_key, &)
1133
1193
  normalized_cache_tags = ReactOnRailsPro::Cache.normalize_tags(raw_options[:cache_tags])
1134
1194
  raw_cache_options = raw_options[:cache_options] || {}
1195
+ # Shared between the per-chunk error callback and the on_complete cache write.
1196
+ # Both run in the same async task/fiber, so by the time on_complete fires the
1197
+ # stream is fully consumed and this flag reflects every chunk.
1198
+ stream_has_errors = false
1135
1199
  cache_aware_options = raw_options.merge(
1200
+ on_chunk_errors: ->(chunk_has_errors) { stream_has_errors ||= chunk_has_errors == true },
1136
1201
  on_complete: lambda { |chunks|
1202
+ # Never persist a render that emitted an error chunk. With production
1203
+ # defaults (`raise_non_shell_server_rendering_errors: false`), a stream
1204
+ # whose shell succeeded but whose async boundary errored completes
1205
+ # "normally", so without this guard the broken fragment would be cached
1206
+ # and served to every subsequent visitor until the entry expires.
1207
+ # See https://github.com/shakacode/react_on_rails/issues/4581.
1208
+ next if stream_has_errors
1209
+
1137
1210
  cache_write = ReactOnRailsPro::StreamCacheWrites.build(
1138
1211
  cache_key: view_cache_key,
1139
1212
  chunks:,
@@ -1220,8 +1293,11 @@ module ReactOnRailsProHelper
1220
1293
  def render_async_react_component_uncached(component_name, raw_options, &)
1221
1294
  options = prepare_async_render_options(raw_options, &)
1222
1295
 
1296
+ parent_context = ReactOnRailsPro::OpenTelemetry.capture_context
1223
1297
  task = @react_on_rails_async_barrier.async do
1224
- react_component(component_name, options)
1298
+ ReactOnRailsPro::OpenTelemetry.with_context(parent_context) do
1299
+ react_component(component_name, options)
1300
+ end
1225
1301
  end
1226
1302
 
1227
1303
  ReactOnRailsPro::AsyncValue.new(task:)
@@ -1238,14 +1314,17 @@ module ReactOnRailsProHelper
1238
1314
  normalized_cache_tags = ReactOnRailsPro::Cache.normalize_tags(raw_options[:cache_tags])
1239
1315
  options = prepare_async_render_options(raw_options, &)
1240
1316
 
1317
+ parent_context = ReactOnRailsPro::OpenTelemetry.capture_context
1241
1318
  task = @react_on_rails_async_barrier.async do
1242
- result = react_component(component_name, options)
1243
- unless ReactOnRailsPro::Cache.cache_write_expired?(raw_cache_options)
1244
- cache_options = ReactOnRailsPro::Cache.cache_write_options(raw_cache_options)
1245
- Rails.cache.write(cache_key, result, cache_options)
1246
- ReactOnRailsPro::Cache.register_normalized_tags(normalized_cache_tags, cache_key, cache_options)
1319
+ ReactOnRailsPro::OpenTelemetry.with_context(parent_context) do
1320
+ result = react_component(component_name, options)
1321
+ unless ReactOnRailsPro::Cache.cache_write_expired?(raw_cache_options)
1322
+ cache_options = ReactOnRailsPro::Cache.cache_write_options(raw_cache_options)
1323
+ Rails.cache.write(cache_key, result, cache_options)
1324
+ ReactOnRailsPro::Cache.register_normalized_tags(normalized_cache_tags, cache_key, cache_options)
1325
+ end
1326
+ result
1247
1327
  end
1248
- result
1249
1328
  end
1250
1329
 
1251
1330
  ReactOnRailsPro::AsyncValue.new(task:)
@@ -1270,13 +1349,16 @@ module ReactOnRailsProHelper
1270
1349
  first_chunk_promise = Async::Promise.new
1271
1350
  all_chunks = [] if on_complete # Only collect if callback provided
1272
1351
  renderer_server_timing_collector = ReactOnRailsPro::Stream.renderer_server_timing_collector
1352
+ parent_context = ReactOnRailsPro::OpenTelemetry.capture_context
1273
1353
 
1274
1354
  # Start an async task on the barrier to stream all chunks
1275
1355
  @async_barrier.async do
1276
- ReactOnRailsPro::Stream.with_renderer_server_timing_collector(renderer_server_timing_collector) do
1277
- stream = yield
1278
- fully_consumed = process_stream_chunks(stream, first_chunk_promise, all_chunks)
1279
- on_complete&.call(all_chunks) if fully_consumed
1356
+ ReactOnRailsPro::OpenTelemetry.with_context(parent_context) do
1357
+ ReactOnRailsPro::Stream.with_renderer_server_timing_collector(renderer_server_timing_collector) do
1358
+ stream = yield
1359
+ fully_consumed = process_stream_chunks(stream, first_chunk_promise, all_chunks)
1360
+ on_complete&.call(all_chunks) if fully_consumed
1361
+ end
1280
1362
  end
1281
1363
  rescue StandardError => e
1282
1364
  # Propagate the error to the calling fiber via the promise.
@@ -1332,13 +1414,14 @@ module ReactOnRailsProHelper
1332
1414
  true
1333
1415
  end
1334
1416
 
1335
- def internal_stream_react_component(component_name, options = {})
1417
+ def internal_stream_react_component(component_name, options = {}, on_chunk_errors: nil)
1336
1418
  options = options.merge(render_mode: :html_streaming)
1337
1419
  result = internal_react_component(component_name, options)
1338
1420
  build_react_component_result_for_server_streamed_content(
1339
1421
  rendered_html_stream: result[:result],
1340
1422
  component_specification_tag: result[:tag],
1341
- render_options: result[:render_options]
1423
+ render_options: result[:render_options],
1424
+ on_chunk_errors:
1342
1425
  )
1343
1426
  end
1344
1427
 
@@ -1354,19 +1437,69 @@ module ReactOnRailsProHelper
1354
1437
  # persist an empty payload and every cache hit would serve zero bytes.
1355
1438
  # See https://github.com/shakacode/react_on_rails/issues/4550.
1356
1439
  html = chunk["html"] || ""
1357
- metadata = chunk.except("html").to_json
1440
+ metadata = redact_rsc_payload_error_metadata(chunk.except("html")).to_json
1358
1441
  content_bytes = html.bytesize.to_s(16).rjust(8, "0")
1359
1442
  "#{metadata}\t#{content_bytes}\n#{html}".html_safe
1360
1443
  end
1361
1444
  end
1362
1445
 
1446
+ # The fetched (client-navigation) RSC payload crosses the trusted-server -> untrusted-client
1447
+ # boundary, so server-internal error text must not ride along on it.
1448
+ #
1449
+ # This mirrors the fail-closed allowlist in `createRSCDiagnosticScript`
1450
+ # (packages/react-on-rails-pro/src/injectRSCPayload.ts), which redacts the same fields on the
1451
+ # inline payload path: full detail only in development/test, and every other environment --
1452
+ # production, staging, or anything unrecognized -- is redacted.
1453
+ #
1454
+ # Redacting here rather than in the shared producer (`buildRenderMetadata` in
1455
+ # packages/react-on-rails/src/serverRenderUtils.ts) is deliberate. `server_rendered_react_component`
1456
+ # installs a raise-transform that runs BEFORE this one and feeds `renderingError` to
1457
+ # `raise_prerender_error`/`rendering_error_from_result`. Redacting upstream would silently strip
1458
+ # the message and stack out of `PrerenderError` for apps that enable
1459
+ # `raise_non_shell_server_rendering_errors`. This transform is the last hop before the bytes
1460
+ # reach the browser, so the server keeps full detail and only the wire is redacted.
1461
+ #
1462
+ # Returns a new Hash; never mutates the caller's chunk (StreamCache buffers it -- see
1463
+ # https://github.com/shakacode/react_on_rails/issues/4550).
1464
+ def redact_rsc_payload_error_metadata(metadata)
1465
+ return metadata if Rails.env.development? || Rails.env.test?
1466
+
1467
+ error_signal = rsc_payload_rendering_error_signal?(metadata)
1468
+ return metadata unless error_signal || metadata.key?("renderingError")
1469
+
1470
+ # Match the inline path's production allowlist exactly. Error chunks may carry sensitive
1471
+ # details in console replay or future metadata fields, so forwarding every field except the
1472
+ # known renderingError key would fail open as the producer evolves.
1473
+ { "hasErrors" => error_signal }
1474
+ end
1475
+
1476
+ def rsc_payload_rendering_error_signal?(metadata)
1477
+ return true if metadata["hasErrors"] == true
1478
+
1479
+ rendering_error = metadata["renderingError"]
1480
+ return false unless rendering_error.is_a?(Hash)
1481
+
1482
+ non_blank_rsc_metadata_string?(rendering_error["message"]) ||
1483
+ non_blank_rsc_metadata_string?(rendering_error["stack"])
1484
+ end
1485
+
1486
+ def non_blank_rsc_metadata_string?(value)
1487
+ value.is_a?(String) && value.strip.present?
1488
+ end
1489
+
1363
1490
  def build_react_component_result_for_server_streamed_content(
1364
1491
  rendered_html_stream:,
1365
1492
  component_specification_tag:,
1366
- render_options:
1493
+ render_options:,
1494
+ on_chunk_errors: nil
1367
1495
  )
1368
1496
  is_first_chunk = true
1369
1497
  rendered_html_stream.transform do |chunk_json_result|
1498
+ # Surface the parsed `hasErrors` flag before the chunk is serialized to an
1499
+ # HTML string. Downstream (the on_complete cache write) only sees strings,
1500
+ # so this is the last point where the error flag is still available.
1501
+ # See https://github.com/shakacode/react_on_rails/issues/4581.
1502
+ on_chunk_errors&.call(chunk_json_result["hasErrors"])
1370
1503
  if is_first_chunk
1371
1504
  is_first_chunk = false
1372
1505
  build_react_component_result_for_server_rendered_string(
@@ -1,15 +1,23 @@
1
- # Seed rolling-deploy bundles at renderer boot, not only at build
1
+ # Seed rolling-deploy bundles at release time, not only at build
2
2
 
3
3
  **Status:** accepted
4
4
 
5
- When the production image is produced by **promoting the staging image**
6
- (`upstream: hichee-staging` in Control Plane), a build-time pre-seed cannot know
7
- production's live **draining bundle**: it runs in the staging pipeline against a
8
- staging snapshot, and promotion happens later and is sometimes skipped. We
9
- therefore have the **node-renderer container pull the target environment's
10
- actually-live bundle at boot** (`rake react_on_rails_pro:pre_seed_renderer_cache`,
11
- readiness-gated) as the correctness path, and keep the build-time seed as a
12
- fallback floor.
5
+ When the production image is produced by **promoting a staging image** (for
6
+ example, through a Control Plane `upstream:` configuration), a build-time
7
+ pre-seed cannot know production's live **draining bundle**: it runs in the
8
+ staging pipeline against a staging snapshot, and promotion happens later and is
9
+ sometimes skipped. We
10
+ therefore run the **Rails/Rake seed task**
11
+ (`bundle exec rake react_on_rails_pro:pre_seed_renderer_cache`) in a
12
+ Ruby-capable release, init, or startup step as the correctness path, and keep
13
+ the build-time seed as a fallback floor. A combined Ruby+Node image can run it
14
+ before Node. A Node-only renderer needs that step to use the promoted app
15
+ artifact/config. The step and renderer must mount the same writable shared volume at
16
+ `RENDERER_SERVER_BUNDLE_CACHE_PATH`, or copy/sync the completed cache into the
17
+ renderer before it starts; gate renderer start/readiness on completion. Without
18
+ a Ruby-capable step that makes the completed cache available to the renderer,
19
+ the Rake seed cannot run for it; use multi-source fallback plus 410 recovery or
20
+ a combined shape.
13
21
 
14
22
  ## Considered options
15
23
 
@@ -28,11 +36,14 @@ fallback floor.
28
36
 
29
37
  ## Consequences
30
38
 
31
- - Correctness of the boot seed **depends on deploy ordering (R2)**: the renderer
32
- must boot before Rails, so the environment's live endpoint still advertises the
33
- draining hash. If Rails cut over first, the boot seed would fetch the new hash
34
- and miss the draining one.
35
- - Readiness gates on the boot seed **completing, not succeeding** — a failed seed
36
- degrades to the 410 fallback rather than wedging the deploy.
37
- - Staging seeds production bundles, so staging's own rolling deploys cold-miss.
38
- Accepted: staging SSR performance during deploys is not a goal.
39
+ - Correctness of the boot seed **depends on deploy ordering (R2)**: its
40
+ Ruby-capable step must complete before renderer readiness and Rails cutover, so
41
+ the environment's live endpoint still advertises the draining hash. If Rails
42
+ cuts over first, the boot seed would fetch the new hash and miss the draining
43
+ one.
44
+ - Renderer start/readiness gates on the boot seed **completing, not succeeding**
45
+ — a failed seed degrades to the 410 fallback rather than wedging the deploy.
46
+ - The build-time fallback seeds from both staging and production, so the image
47
+ retains each environment's then-live bundle. That keeps staging's own rolling
48
+ deploys warm while still giving a promoted image a recent production bundle if
49
+ the boot seed cannot reach the live endpoint.
@@ -112,31 +112,44 @@ module ReactOnRailsPro
112
112
 
113
113
  publish_bundles(adapter)
114
114
  rescue StandardError => e
115
- # Outer rescue catches anything raised by the setup-side calls below
116
- # (collect_assets, server_bundle_hash, rsc_bundle_js_file_path). Per the
117
- # rolling-deploy contract, a failed upload must degrade the next deploy's
118
- # seeding — not fail *this* deploy's assets:precompile.
115
+ # Role-scoped construction/publication failures are handled inside
116
+ # publish_artifact so the other role can still publish. Keep this outer
117
+ # fallback for failures before role isolation (for example, configuration
118
+ # access) so rolling-deploy publication never fails assets:precompile.
119
119
  warn "[ReactOnRailsPro] rolling_deploy_adapter publication failed: #{e.class}: #{e.message}. " \
120
120
  "Next deploy's rolling-deploy seeding may degrade; precompile continuing."
121
121
  end
122
122
 
123
123
  def self.publish_bundles(adapter)
124
- pool = ReactOnRailsPro::ServerRenderingPool::NodeRenderingPool
125
- # Companion manifests are generated for the deploy as a whole, so server
126
- # and RSC hashes from the same build intentionally share this asset set.
127
- assets = filter_existing_assets(ReactOnRailsPro::RendererCacheHelpers.collect_assets.map(&:to_s))
128
-
129
- # Defer the hash computation behind a block: `bundle_hash` reads the bundle
130
- # file (`File.mtime` in dev/test, `Digest::MD5.file` for non-content-hashed
131
- # names), so evaluating it eagerly as an argument would let a missing
132
- # bundle raise and bypass the per-bundle warning path.
133
- server_bundle = ReactOnRails::Utils.server_bundle_js_file_path
134
- publish_bundle_if_present(adapter, server_bundle, assets, "server") { pool.server_bundle_hash }
135
-
136
- return unless ReactOnRailsPro.configuration.enable_rsc_support
137
-
138
- rsc_bundle = ReactOnRailsPro::Utils.rsc_bundle_js_file_path
139
- publish_bundle_if_present(adapter, rsc_bundle, assets, "RSC") { pool.rsc_bundle_hash }
124
+ roles = [:server]
125
+ roles << :rsc if ReactOnRailsPro.configuration.enable_rsc_support
126
+ roles.each { |role| publish_artifact(adapter, role) }
127
+ end
128
+
129
+ def self.publish_artifact(adapter, role)
130
+ bundle_label = role == :rsc ? "RSC" : "server"
131
+ artifacts = ReactOnRailsPro::Utils.renderer_artifacts(
132
+ action_description: "publishing rolling-deploy #{bundle_label} artifact",
133
+ roles: [role]
134
+ )
135
+ artifact = artifacts.find { |candidate| candidate.role == role }
136
+ unless artifact
137
+ raise ReactOnRailsPro::Error,
138
+ "No current renderer artifact is configured for role #{role.inspect}"
139
+ end
140
+
141
+ artifact.with_materialized_files(bundle_name: "#{artifact.id}.js") do |bundle, companions|
142
+ publish_bundle_if_present(
143
+ adapter,
144
+ bundle.to_s,
145
+ companions.values.map(&:to_s),
146
+ bundle_label
147
+ ) { artifact.id }
148
+ end
149
+ rescue StandardError => e
150
+ warn "[ReactOnRailsPro] rolling_deploy_adapter publication failed: #{bundle_label} bundle: " \
151
+ "#{e.class}: #{e.message}. Next deploy's rolling-deploy seeding for this role may degrade; " \
152
+ "precompile continuing."
140
153
  end
141
154
 
142
155
  # Some collected companion assets may be absent or point at non-file paths.
@@ -230,6 +243,7 @@ module ReactOnRailsPro
230
243
  end
231
244
  private_class_method :publish_current_bundle_if_configured,
232
245
  :publish_bundles,
246
+ :publish_artifact,
233
247
  :filter_existing_assets,
234
248
  :warn_skipped_invalid_assets,
235
249
  :warn_if_unavailable_required_rsc_assets,
@@ -50,16 +50,44 @@ module ReactOnRailsPro
50
50
  class AsyncPropsEmitter
51
51
  SANITIZED_REJECTION_REASON = "Async prop rejected by server"
52
52
 
53
+ # Socket-level errors that mean the renderer request stream is already gone.
54
+ # These mirror the family that ReactOnRailsPro::Stream#log_client_disconnect
55
+ # (concerns/stream.rb) treats as a routine client disconnect, so the two files
56
+ # classify disconnect races the same way.
57
+ CLOSED_REQUEST_STREAM_SOCKET_ERRORS = [
58
+ IOError, Errno::EPIPE, Errno::ECONNRESET, Errno::ECONNABORTED
59
+ ].freeze
60
+
53
61
  attr_reader :pull_requests
54
62
 
55
63
  def initialize(bundle_timestamp, request_stream, pull_enabled: false)
56
64
  @bundle_timestamp = bundle_timestamp
57
65
  @request_stream = request_stream
58
66
  @pushed_props = Set.new
67
+ # Latched the first time a write hits a closed request stream, so the rest of
68
+ # the user's block (which runs in its own fiber and may keep emitting) is
69
+ # skipped silently instead of logging once per remaining prop.
70
+ @request_stream_closed = false
59
71
  @pull_enabled = pull_enabled
60
72
  @pull_requests = PullRequestQueue.new(@pushed_props) if pull_enabled
61
73
  end
62
74
 
75
+ # Exception classes that indicate a write landed on an already-closed request
76
+ # stream. `@request_stream` is a Protocol::HTTP::Body::Writable::Output; writing
77
+ # after its queue closes raises Protocol::HTTP::Body::Writable::Closed on a clean
78
+ # close, or the stored socket error (Errno::*/IOError) that tore the stream down
79
+ # on an aborted one (see async-http Output#passthrough -> Writable#write).
80
+ #
81
+ # Resolved at rescue time rather than at load time on purpose: the protocol-http
82
+ # writable-body class is required lazily (only once streaming starts) and its
83
+ # Closed constant has moved namespaces across gem versions, so the defined? guard
84
+ # keeps this file loadable everywhere and picks the class up once it exists.
85
+ def self.closed_request_stream_errors
86
+ return CLOSED_REQUEST_STREAM_SOCKET_ERRORS unless defined?(Protocol::HTTP::Body::Writable::Closed)
87
+
88
+ CLOSED_REQUEST_STREAM_SOCKET_ERRORS + [Protocol::HTTP::Body::Writable::Closed]
89
+ end
90
+
63
91
  # Sends an async prop to the Node renderer.
64
92
  # The prop value is JSON-serialized and sent as an NDJSON line.
65
93
  # On the Node side, this triggers asyncPropsManager.setProp(propName, value).
@@ -92,8 +120,29 @@ module ReactOnRailsPro
92
120
  private
93
121
 
94
122
  def write_settled_chunk(prop_name, action:)
123
+ # A closed request stream is routine (client disconnect, or the block still
124
+ # emitting after renderComplete winds the connection down). Once we have seen
125
+ # it close, skip the remaining writes entirely so a block emitting N more props
126
+ # does not generate N log lines or N wasted serializations.
127
+ return if @request_stream_closed
128
+
95
129
  chunk = yield
96
- @request_stream << "#{chunk.to_json}\n"
130
+ serialized_chunk = "#{chunk.to_json}\n"
131
+
132
+ # Scope the closed-stream classification to the socket write itself. Building
133
+ # and serializing the chunk above (including a prop value's own #to_json) can
134
+ # raise IOError/Errno::* for reasons unrelated to a disconnect; letting those
135
+ # fall through to the genuine-failure path below keeps them at error level
136
+ # instead of latching the emitter shut and silently dropping later props.
137
+ begin
138
+ @request_stream << serialized_chunk
139
+ rescue *self.class.closed_request_stream_errors => e
140
+ # Routine disconnect race, not a genuine failure: keep it quiet, mirroring
141
+ # ReactOnRailsPro::Stream#log_client_disconnect. The prop is intentionally
142
+ # not marked pushed, matching the error path below.
143
+ return handle_closed_request_stream(prop_name, action, e)
144
+ end
145
+
97
146
  # Once the chunk is written, Ruby treats the prop as settled too.
98
147
  # That keeps duplicate pull requests filtered even if the JS manager is recreated.
99
148
  @pushed_props.add(prop_name)
@@ -108,6 +157,20 @@ module ReactOnRailsPro
108
157
  end
109
158
  end
110
159
 
160
+ # Handles a write to an already-closed request stream. Mirrors
161
+ # ReactOnRailsPro::Stream#log_client_disconnect: quiet (debug, no backtrace) and
162
+ # gated on logging_on_server. Latches @request_stream_closed so this logs at most
163
+ # once per stream and the block's remaining emits are skipped without noise.
164
+ def handle_closed_request_stream(prop_name, action, error)
165
+ @request_stream_closed = true
166
+ return unless ReactOnRails.configuration.logging_on_server
167
+
168
+ Rails.logger.debug do
169
+ "[ReactOnRailsPro::AsyncProps] Request stream closed while emitting async prop " \
170
+ "'#{prop_name}' (#{action}); skipping remaining props: #{error.class}"
171
+ end
172
+ end
173
+
111
174
  def generate_update_chunk(prop_name, value)
112
175
  {
113
176
  bundleTimestamp: @bundle_timestamp,
@@ -189,7 +189,7 @@ module ReactOnRailsPro
189
189
 
190
190
  def rsc_bundle_cache_key
191
191
  ReactOnRailsPro::Utils.rsc_bundle_hash
192
- rescue Errno::ENOENT
192
+ rescue ReactOnRailsPro::MissingRendererBundleError
193
193
  RSC_BUNDLE_MISSING_CACHE_KEY
194
194
  end
195
195
  end