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.
- checksums.yaml +4 -4
- data/CLAUDE.md +4 -3
- data/CONTEXT.md +34 -27
- data/Gemfile.development_dependencies +1 -0
- data/Gemfile.lock +18 -3
- data/LICENSE_SETUP.md +42 -9
- data/README.md +12 -1
- data/app/controllers/react_on_rails_pro/rolling_deploy/bundles_controller.rb +69 -95
- data/app/helpers/react_on_rails_pro_helper.rb +178 -45
- data/docs/adr/0001-boot-seed-rolling-deploy-bundles.md +28 -17
- data/lib/react_on_rails_pro/assets_precompile.rb +34 -20
- data/lib/react_on_rails_pro/async_props_emitter.rb +64 -1
- data/lib/react_on_rails_pro/cache.rb +1 -1
- data/lib/react_on_rails_pro/concerns/async_rendering.rb +11 -6
- data/lib/react_on_rails_pro/concerns/rsc_payload_renderer.rb +7 -0
- data/lib/react_on_rails_pro/concerns/stream.rb +17 -9
- data/lib/react_on_rails_pro/configuration.rb +79 -11
- data/lib/react_on_rails_pro/engine.rb +11 -5
- data/lib/react_on_rails_pro/error.rb +5 -0
- data/lib/react_on_rails_pro/license_task_formatter.rb +1 -1
- data/lib/react_on_rails_pro/license_validator.rb +38 -38
- data/lib/react_on_rails_pro/open_telemetry.rb +210 -0
- data/lib/react_on_rails_pro/pre_seed_renderer_cache.rb +269 -35
- data/lib/react_on_rails_pro/renderer_artifact.rb +176 -0
- data/lib/react_on_rails_pro/renderer_artifact_support.rb +287 -0
- data/lib/react_on_rails_pro/renderer_cache_helpers.rb +58 -32
- data/lib/react_on_rails_pro/renderer_http_client.rb +134 -39
- data/lib/react_on_rails_pro/request.rb +249 -118
- data/lib/react_on_rails_pro/rolling_deploy/tarball.rb +48 -18
- data/lib/react_on_rails_pro/rolling_deploy_adapters/http.rb +242 -93
- data/lib/react_on_rails_pro/rolling_deploy_cache_stager.rb +77 -19
- data/lib/react_on_rails_pro/server_rendering_js_code.rb +48 -4
- data/lib/react_on_rails_pro/server_rendering_pool/node_rendering_pool.rb +46 -7
- data/lib/react_on_rails_pro/stream_cache.rb +18 -0
- data/lib/react_on_rails_pro/stream_request.rb +9 -2
- data/lib/react_on_rails_pro/utils.rb +77 -17
- data/lib/react_on_rails_pro/version.rb +1 -1
- data/lib/react_on_rails_pro.rb +1 -0
- data/sig/react_on_rails_pro/configuration.rbs +7 -0
- data/sig/react_on_rails_pro/open_telemetry.rbs +52 -0
- data/sig/react_on_rails_pro/renderer_http_client.rbs +18 -0
- 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 =
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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 =
|
|
460
|
-
|
|
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
|
-
|
|
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(
|
|
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 =
|
|
670
|
-
|
|
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
|
-
|
|
1174
|
+
ReactOnRailsPro::OpenTelemetry.with_context(parent_context) do
|
|
1175
|
+
task.yield
|
|
1117
1176
|
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
|
|
1177
|
+
cached_chunks.each_with_index do |chunk, index|
|
|
1178
|
+
next if index.zero?
|
|
1179
|
+
break if response.stream.closed?
|
|
1121
1180
|
|
|
1122
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
|
|
1246
|
-
|
|
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::
|
|
1277
|
-
|
|
1278
|
-
|
|
1279
|
-
|
|
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
|
|
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
|
|
6
|
-
|
|
7
|
-
production's live **draining bundle**: it runs in the
|
|
8
|
-
staging snapshot, and promotion happens later and is
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
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)**:
|
|
32
|
-
must
|
|
33
|
-
|
|
34
|
-
and miss the draining
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
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
|
-
#
|
|
116
|
-
#
|
|
117
|
-
#
|
|
118
|
-
#
|
|
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
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
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
|
-
|
|
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,
|