react_on_rails_pro 16.6.0 → 16.7.0.rc.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/.controlplane/rails.yml +2 -2
- data/CLAUDE.md +10 -0
- data/CONTRIBUTING.md +2 -2
- data/Gemfile.development_dependencies +20 -13
- data/Gemfile.lock +12 -28
- data/Rakefile +0 -5
- data/app/helpers/react_on_rails_pro_helper.rb +28 -1
- data/lib/react_on_rails_pro/assets_precompile.rb +170 -1
- data/lib/react_on_rails_pro/async_props_emitter.rb +80 -0
- data/lib/react_on_rails_pro/concerns/stream.rb +1 -1
- data/lib/react_on_rails_pro/configuration.rb +114 -17
- data/lib/react_on_rails_pro/engine.rb +10 -0
- data/lib/react_on_rails_pro/httpx_stream_bidi_patch.rb +42 -0
- data/lib/react_on_rails_pro/js_code_builder.rb +121 -0
- data/lib/react_on_rails_pro/pre_seed_renderer_cache.rb +148 -0
- data/lib/react_on_rails_pro/prepare_node_renderer_bundles.rb +33 -28
- data/lib/react_on_rails_pro/renderer_cache_helpers.rb +276 -0
- data/lib/react_on_rails_pro/renderer_cache_path.rb +74 -0
- data/lib/react_on_rails_pro/rendering_strategy/node_strategy.rb +29 -0
- data/lib/react_on_rails_pro/request.rb +135 -8
- data/lib/react_on_rails_pro/rolling_deploy_cache_stager.rb +516 -0
- data/lib/react_on_rails_pro/server_rendering_js_code.rb +47 -10
- data/lib/react_on_rails_pro/server_rendering_pool/node_rendering_pool.rb +35 -10
- data/lib/react_on_rails_pro/stream_request.rb +42 -49
- data/lib/react_on_rails_pro/utils.rb +7 -9
- data/lib/react_on_rails_pro/version.rb +1 -1
- data/lib/react_on_rails_pro.rb +8 -0
- data/lib/tasks/assets.rake +36 -3
- data/rakelib/run_rspec.rake +6 -6
- data/react_on_rails_pro.gemspec +9 -4
- data/sig/react_on_rails_pro/configuration.rbs +2 -0
- metadata +35 -22
|
@@ -21,6 +21,7 @@ module ReactOnRailsPro
|
|
|
21
21
|
dependency_globs: Configuration::DEFAULT_DEPENDENCY_GLOBS,
|
|
22
22
|
excluded_dependency_globs: Configuration::DEFAULT_EXCLUDED_DEPENDENCY_GLOBS,
|
|
23
23
|
remote_bundle_cache_adapter: Configuration::DEFAULT_REMOTE_BUNDLE_CACHE_ADAPTER,
|
|
24
|
+
rolling_deploy_adapter: Configuration::DEFAULT_ROLLING_DEPLOY_ADAPTER,
|
|
24
25
|
ssr_timeout: Configuration::DEFAULT_SSR_TIMEOUT,
|
|
25
26
|
ssr_pre_hook_js: nil,
|
|
26
27
|
assets_to_copy: nil,
|
|
@@ -52,6 +53,7 @@ module ReactOnRailsPro
|
|
|
52
53
|
DEFAULT_DEPENDENCY_GLOBS = [].freeze
|
|
53
54
|
DEFAULT_EXCLUDED_DEPENDENCY_GLOBS = [].freeze
|
|
54
55
|
DEFAULT_REMOTE_BUNDLE_CACHE_ADAPTER = nil
|
|
56
|
+
DEFAULT_ROLLING_DEPLOY_ADAPTER = nil
|
|
55
57
|
DEFAULT_RENDERER_REQUEST_RETRY_LIMIT = 5
|
|
56
58
|
DEFAULT_THROW_JS_ERRORS = true
|
|
57
59
|
DEFAULT_RENDERING_RETURNS_PROMISES = false
|
|
@@ -63,12 +65,16 @@ module ReactOnRailsPro
|
|
|
63
65
|
DEFAULT_REACT_CLIENT_MANIFEST_FILE = "react-client-manifest.json"
|
|
64
66
|
DEFAULT_REACT_SERVER_CLIENT_MANIFEST_FILE = "react-server-client-manifest.json"
|
|
65
67
|
DEFAULT_CONCURRENT_COMPONENT_STREAMING_BUFFER_SIZE = 64
|
|
68
|
+
ROLLING_DEPLOY_UPLOAD_POSITIONAL_PARAMS = %i[req opt rest].freeze
|
|
69
|
+
ROLLING_DEPLOY_UPLOAD_KEYWORD_PARAMS = %i[key keyreq].freeze
|
|
70
|
+
ROLLING_DEPLOY_UPLOAD_ALL_KEYWORD_PARAMS = %i[keyrest].freeze
|
|
71
|
+
ROLLING_DEPLOY_UPLOAD_REQUIRED_KEYWORDS = %i[bundle assets].freeze
|
|
66
72
|
|
|
67
73
|
attr_accessor :renderer_url, :renderer_password, :tracing,
|
|
68
74
|
:server_renderer, :renderer_use_fallback_exec_js, :prerender_caching,
|
|
69
75
|
:renderer_http_pool_size, :renderer_http_pool_timeout, :renderer_http_pool_warn_timeout,
|
|
70
76
|
:dependency_globs, :excluded_dependency_globs, :rendering_returns_promises,
|
|
71
|
-
:remote_bundle_cache_adapter, :ssr_pre_hook_js, :assets_to_copy,
|
|
77
|
+
:remote_bundle_cache_adapter, :rolling_deploy_adapter, :ssr_pre_hook_js, :assets_to_copy,
|
|
72
78
|
:renderer_request_retry_limit, :throw_js_errors, :ssr_timeout,
|
|
73
79
|
:profile_server_rendering_js_code, :raise_non_shell_server_rendering_errors, :enable_rsc_support,
|
|
74
80
|
:rsc_payload_generation_url_path, :rsc_bundle_js_file, :react_client_manifest_file,
|
|
@@ -116,7 +122,8 @@ module ReactOnRailsPro
|
|
|
116
122
|
renderer_http_pool_warn_timeout: nil, renderer_http_keep_alive_timeout: nil,
|
|
117
123
|
tracing: nil,
|
|
118
124
|
dependency_globs: nil, excluded_dependency_globs: nil, rendering_returns_promises: nil,
|
|
119
|
-
remote_bundle_cache_adapter: nil,
|
|
125
|
+
remote_bundle_cache_adapter: nil, rolling_deploy_adapter: nil,
|
|
126
|
+
ssr_pre_hook_js: nil, assets_to_copy: nil,
|
|
120
127
|
renderer_request_retry_limit: nil, throw_js_errors: nil, ssr_timeout: nil,
|
|
121
128
|
profile_server_rendering_js_code: nil, raise_non_shell_server_rendering_errors: nil,
|
|
122
129
|
enable_rsc_support: nil, rsc_payload_generation_url_path: nil,
|
|
@@ -137,6 +144,7 @@ module ReactOnRailsPro
|
|
|
137
144
|
self.dependency_globs = dependency_globs
|
|
138
145
|
self.excluded_dependency_globs = excluded_dependency_globs
|
|
139
146
|
self.remote_bundle_cache_adapter = remote_bundle_cache_adapter
|
|
147
|
+
self.rolling_deploy_adapter = rolling_deploy_adapter
|
|
140
148
|
self.ssr_pre_hook_js = ssr_pre_hook_js
|
|
141
149
|
self.assets_to_copy = assets_to_copy
|
|
142
150
|
self.renderer_request_retry_limit = renderer_request_retry_limit
|
|
@@ -156,7 +164,9 @@ module ReactOnRailsPro
|
|
|
156
164
|
configure_default_url_if_not_provided
|
|
157
165
|
validate_url
|
|
158
166
|
validate_remote_bundle_cache_adapter
|
|
167
|
+
validate_rolling_deploy_adapter
|
|
159
168
|
setup_renderer_password
|
|
169
|
+
validate_renderer_password_for_production
|
|
160
170
|
setup_assets_to_copy
|
|
161
171
|
setup_execjs_profiler_if_needed
|
|
162
172
|
check_react_on_rails_support_for_rsc
|
|
@@ -249,6 +259,94 @@ module ReactOnRailsPro
|
|
|
249
259
|
end
|
|
250
260
|
end
|
|
251
261
|
|
|
262
|
+
def validate_rolling_deploy_adapter
|
|
263
|
+
return if rolling_deploy_adapter.nil?
|
|
264
|
+
|
|
265
|
+
unless rolling_deploy_adapter.is_a?(Module)
|
|
266
|
+
raise ReactOnRailsPro::Error, "config.rolling_deploy_adapter must be a module or class"
|
|
267
|
+
end
|
|
268
|
+
|
|
269
|
+
%i[previous_bundle_hashes fetch upload].each do |method_name|
|
|
270
|
+
next if rolling_deploy_adapter.respond_to?(method_name)
|
|
271
|
+
|
|
272
|
+
raise ReactOnRailsPro::Error,
|
|
273
|
+
"config.rolling_deploy_adapter must define class method ##{method_name}. " \
|
|
274
|
+
"See docs/pro/rolling-deploy-adapters.md for the full protocol and reference implementations."
|
|
275
|
+
end
|
|
276
|
+
|
|
277
|
+
validate_rolling_deploy_upload_signature
|
|
278
|
+
end
|
|
279
|
+
|
|
280
|
+
def validate_rolling_deploy_upload_signature
|
|
281
|
+
params = rolling_deploy_adapter.method(:upload).parameters
|
|
282
|
+
return if rolling_deploy_upload_signature_valid?(params)
|
|
283
|
+
|
|
284
|
+
raise ReactOnRailsPro::Error,
|
|
285
|
+
"config.rolling_deploy_adapter#upload must accept signature " \
|
|
286
|
+
"upload(bundle_hash, bundle:, assets:) or an options-hash equivalent (e.g. " \
|
|
287
|
+
"upload(bundle_hash, **opts) / upload(*args) where opts/args[1] yield :bundle and :assets). " \
|
|
288
|
+
"See docs/pro/rolling-deploy-adapters.md."
|
|
289
|
+
end
|
|
290
|
+
|
|
291
|
+
# Best-effort signature check — covers the common explicit, splat, and
|
|
292
|
+
# options-hash shapes adapter authors actually write. Edge cases (e.g.
|
|
293
|
+
# `upload(hash, *args, bundle:)` mixing splat with explicit keywords) may
|
|
294
|
+
# pass this check and still fail at call time; the runtime ArgumentError
|
|
295
|
+
# in that case is clear enough that we accept the gap rather than encode
|
|
296
|
+
# every Ruby parameter combination here.
|
|
297
|
+
def rolling_deploy_upload_signature_valid?(params)
|
|
298
|
+
accepts_bundle_hash_argument?(params) &&
|
|
299
|
+
(accepts_upload_keyword_arguments?(params) || accepts_upload_options_hash?(params))
|
|
300
|
+
end
|
|
301
|
+
|
|
302
|
+
def accepts_bundle_hash_argument?(params)
|
|
303
|
+
required_positionals = params.count { |type, _name| type == :req }
|
|
304
|
+
return false if required_positionals > 1
|
|
305
|
+
|
|
306
|
+
params.any? { |type, _name| ROLLING_DEPLOY_UPLOAD_POSITIONAL_PARAMS.include?(type) }
|
|
307
|
+
end
|
|
308
|
+
|
|
309
|
+
def accepts_upload_keyword_arguments?(params)
|
|
310
|
+
return false if extra_required_upload_keywords(params).any?
|
|
311
|
+
|
|
312
|
+
accepts_all_upload_keywords?(params) || accepts_required_upload_keywords?(params)
|
|
313
|
+
end
|
|
314
|
+
|
|
315
|
+
def extra_required_upload_keywords(params)
|
|
316
|
+
required_keywords = params.select { |type, _name| type == :keyreq }.map(&:last)
|
|
317
|
+
required_keywords - ROLLING_DEPLOY_UPLOAD_REQUIRED_KEYWORDS
|
|
318
|
+
end
|
|
319
|
+
|
|
320
|
+
def accepts_all_upload_keywords?(params)
|
|
321
|
+
params.any? { |type, _name| ROLLING_DEPLOY_UPLOAD_ALL_KEYWORD_PARAMS.include?(type) }
|
|
322
|
+
end
|
|
323
|
+
|
|
324
|
+
def accepts_required_upload_keywords?(params)
|
|
325
|
+
ROLLING_DEPLOY_UPLOAD_REQUIRED_KEYWORDS.all? do |keyword|
|
|
326
|
+
params.any? { |type, name| ROLLING_DEPLOY_UPLOAD_KEYWORD_PARAMS.include?(type) && name == keyword }
|
|
327
|
+
end
|
|
328
|
+
end
|
|
329
|
+
|
|
330
|
+
def accepts_upload_options_hash?(params)
|
|
331
|
+
# Ruby 3 only converts keywords to an options hash when the callee has no
|
|
332
|
+
# explicit keyword parameters. `upload(hash, options = {}, region:)` still
|
|
333
|
+
# rejects the `bundle:` / `assets:` call shape used by assets precompile.
|
|
334
|
+
# `**nil` (the `:nokey` parameter kind) explicitly forbids keywords too,
|
|
335
|
+
# so reject it for the same reason.
|
|
336
|
+
return false if uses_explicit_upload_keywords?(params)
|
|
337
|
+
return true if params.any? { |type, _name| type == :rest }
|
|
338
|
+
|
|
339
|
+
required_positionals = params.count { |type, _name| type == :req }
|
|
340
|
+
optional_positionals = params.count { |type, _name| type == :opt }
|
|
341
|
+
required_positionals == 1 && optional_positionals.positive?
|
|
342
|
+
end
|
|
343
|
+
|
|
344
|
+
def uses_explicit_upload_keywords?(params)
|
|
345
|
+
params.any? do |type, _name|
|
|
346
|
+
type == :nokey || ROLLING_DEPLOY_UPLOAD_KEYWORD_PARAMS.include?(type)
|
|
347
|
+
end
|
|
348
|
+
end
|
|
349
|
+
|
|
252
350
|
def setup_renderer_password
|
|
253
351
|
# Explicit passwords, including values loaded from ENV in the initializer, skip URL extraction.
|
|
254
352
|
# Blank values (nil or "") fall through so URL extraction and ENV fallback still apply.
|
|
@@ -260,8 +358,6 @@ module ReactOnRailsPro
|
|
|
260
358
|
# Mirror Node-side defaults: if Rails config and URL are both missing a password,
|
|
261
359
|
# use RENDERER_PASSWORD from env.
|
|
262
360
|
self.renderer_password = ENV.fetch("RENDERER_PASSWORD", nil) if renderer_password.blank?
|
|
263
|
-
|
|
264
|
-
validate_renderer_password_for_production
|
|
265
361
|
end
|
|
266
362
|
|
|
267
363
|
def validate_renderer_password_for_production
|
|
@@ -270,12 +366,14 @@ module ReactOnRailsPro
|
|
|
270
366
|
return if renderer_password.present?
|
|
271
367
|
return unless node_renderer?
|
|
272
368
|
|
|
273
|
-
# Fail closed: only skip validation when
|
|
274
|
-
#
|
|
275
|
-
#
|
|
276
|
-
#
|
|
277
|
-
|
|
278
|
-
|
|
369
|
+
# Fail closed: only skip validation when every present runtime env is explicitly
|
|
370
|
+
# development or test. This mirrors the Node-side runtimeEnvsAllowDevelopmentDefaults()
|
|
371
|
+
# which checks both NODE_ENV and RAILS_ENV. Checking NODE_ENV here surfaces
|
|
372
|
+
# misconfigurations (e.g. NODE_ENV=production + RAILS_ENV=development) at Rails boot
|
|
373
|
+
# time rather than waiting for the Node renderer to reject the request.
|
|
374
|
+
runtime_envs = [ENV.fetch("RAILS_ENV", nil), ENV.fetch("NODE_ENV", nil)].compact_blank.map(&:downcase)
|
|
375
|
+
allowed_envs = %w[development test].freeze
|
|
376
|
+
return if runtime_envs.any? && runtime_envs.all? { |e| allowed_envs.include?(e) }
|
|
279
377
|
|
|
280
378
|
raise ReactOnRailsPro::Error, <<~MSG
|
|
281
379
|
RENDERER_PASSWORD must be set in production-like environments (staging, production, etc.)
|
|
@@ -304,13 +402,12 @@ module ReactOnRailsPro
|
|
|
304
402
|
|
|
305
403
|
If Rails and the Node Renderer disagree about startup behavior, verify both RAILS_ENV and NODE_ENV.
|
|
306
404
|
|
|
307
|
-
Environment matrix:
|
|
308
|
-
development
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
(any other) — RENDERER_PASSWORD required
|
|
405
|
+
Environment matrix (both RAILS_ENV and NODE_ENV are checked):
|
|
406
|
+
development/test — password optional when every set env is development or test
|
|
407
|
+
(both unset) — treated as production-like; RENDERER_PASSWORD required
|
|
408
|
+
staging — RENDERER_PASSWORD required
|
|
409
|
+
production — RENDERER_PASSWORD required
|
|
410
|
+
(mixed envs) — RENDERER_PASSWORD required (e.g. NODE_ENV=production + RAILS_ENV=development)
|
|
314
411
|
MSG
|
|
315
412
|
end
|
|
316
413
|
end
|
|
@@ -24,6 +24,16 @@ module ReactOnRailsPro
|
|
|
24
24
|
config.after_initialize { ReactOnRailsPro::Engine.log_problematic_compression_middleware_warnings }
|
|
25
25
|
end
|
|
26
26
|
|
|
27
|
+
# Override the default rendering strategy with Pro's NodeStrategy and JsCodeBuilder.
|
|
28
|
+
# Runs after core's initializer since Pro engine loads after core.
|
|
29
|
+
# Not yet wired into the main rendering path — currently additive only (see issue #2905).
|
|
30
|
+
initializer "react_on_rails_pro.set_rendering_strategy" do
|
|
31
|
+
config.after_initialize do
|
|
32
|
+
ReactOnRails.rendering_strategy = ReactOnRailsPro::RenderingStrategy::NodeStrategy.new
|
|
33
|
+
ReactOnRails.js_code_builder = ReactOnRailsPro::JsCodeBuilder.new
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
|
|
27
37
|
class << self
|
|
28
38
|
def log_license_status
|
|
29
39
|
status = ReactOnRailsPro::LicenseValidator.license_status
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "httpx"
|
|
4
|
+
|
|
5
|
+
#
|
|
6
|
+
# Temporary patch for HTTPX stream_bidi plugin retry bug
|
|
7
|
+
#
|
|
8
|
+
# Issue: https://github.com/HoneyryderChuck/httpx/issues/124
|
|
9
|
+
#
|
|
10
|
+
# Problem: When a streaming request fails and is retried, the @headers_sent
|
|
11
|
+
# flag is not reset. This causes the :body callback to fire prematurely on
|
|
12
|
+
# retry, leading to re-entrant handle() calls that crash with:
|
|
13
|
+
# HTTP2::Error::InternalError
|
|
14
|
+
#
|
|
15
|
+
# This patch resets @headers_sent when transitioning back to :idle state.
|
|
16
|
+
#
|
|
17
|
+
# Can be removed once fixed upstream in httpx gem.
|
|
18
|
+
#
|
|
19
|
+
|
|
20
|
+
HTTPX::Plugins.load_plugin(:stream_bidi)
|
|
21
|
+
|
|
22
|
+
if defined?(HTTPX::Plugins::StreamBidi)
|
|
23
|
+
module HTTPX
|
|
24
|
+
module Plugins
|
|
25
|
+
module StreamBidi
|
|
26
|
+
module RequestMethodsRetryFix
|
|
27
|
+
def transition(nextstate)
|
|
28
|
+
@headers_sent = false if nextstate == :idle
|
|
29
|
+
|
|
30
|
+
return super unless @options.stream
|
|
31
|
+
|
|
32
|
+
callbacks(:body).clear if nextstate == :idle
|
|
33
|
+
|
|
34
|
+
super
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
RequestMethods.prepend(RequestMethodsRetryFix)
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ReactOnRailsPro
|
|
4
|
+
# Pro JS code builder that extends the base JsCodeBuilder with RSC support,
|
|
5
|
+
# pre-hooks, and Node renderer-specific options.
|
|
6
|
+
#
|
|
7
|
+
# Part of the strategy pattern refactoring (see issue #2905).
|
|
8
|
+
# Currently additive — not yet wired into the main rendering path.
|
|
9
|
+
class JsCodeBuilder < ReactOnRails::JsCodeBuilder
|
|
10
|
+
protected
|
|
11
|
+
|
|
12
|
+
def build_sections(render_request)
|
|
13
|
+
[
|
|
14
|
+
rails_context_section(render_request),
|
|
15
|
+
rsc_params_section(render_request),
|
|
16
|
+
rsc_payload_function_section(render_request),
|
|
17
|
+
pre_hook_section(render_request),
|
|
18
|
+
store_initialization_section(render_request),
|
|
19
|
+
props_section(render_request),
|
|
20
|
+
render_call_section(render_request)
|
|
21
|
+
]
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def rsc_params_section(render_request)
|
|
25
|
+
return nil unless rsc_streaming?(render_request)
|
|
26
|
+
|
|
27
|
+
config = ReactOnRailsPro.configuration
|
|
28
|
+
react_client_manifest_file = config.react_client_manifest_file
|
|
29
|
+
react_server_client_manifest_file = config.react_server_client_manifest_file
|
|
30
|
+
|
|
31
|
+
<<~JS.chomp
|
|
32
|
+
railsContext.reactClientManifestFileName = #{react_client_manifest_file.to_json};
|
|
33
|
+
railsContext.reactServerClientManifestFileName = #{react_server_client_manifest_file.to_json};
|
|
34
|
+
JS
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def rsc_payload_function_section(render_request)
|
|
38
|
+
return nil unless rsc_streaming?(render_request)
|
|
39
|
+
|
|
40
|
+
if render_request.rsc_payload_streaming?
|
|
41
|
+
<<~JS.chomp
|
|
42
|
+
if (typeof generateRSCPayload !== 'function') {
|
|
43
|
+
globalThis.generateRSCPayload = function generateRSCPayload() {
|
|
44
|
+
throw new Error('The rendering request is already running on the RSC bundle. Please ensure that generateRSCPayload is only called from any React Server Component.')
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
JS
|
|
48
|
+
else
|
|
49
|
+
<<~JS.chomp
|
|
50
|
+
railsContext.serverSideRSCPayloadParameters = {
|
|
51
|
+
renderingRequest,
|
|
52
|
+
rscBundleHash: #{ReactOnRailsPro::Utils.rsc_bundle_hash.to_json},
|
|
53
|
+
}
|
|
54
|
+
if (typeof generateRSCPayload !== 'function') {
|
|
55
|
+
globalThis.generateRSCPayload = function generateRSCPayload(componentName, props, railsContext) {
|
|
56
|
+
const { renderingRequest, rscBundleHash } = railsContext.serverSideRSCPayloadParameters;
|
|
57
|
+
const propsString = JSON.stringify(props);
|
|
58
|
+
const newRenderingRequest = renderingRequest.replace(
|
|
59
|
+
/\\(\\s*\\)\\s*$/,
|
|
60
|
+
function() { return `(${JSON.stringify(componentName)}, ${propsString})`; }
|
|
61
|
+
);
|
|
62
|
+
return runOnOtherBundle(rscBundleHash, newRenderingRequest);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
JS
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def pre_hook_section(_render_request)
|
|
70
|
+
pre_hook = ReactOnRailsPro.configuration.ssr_pre_hook_js
|
|
71
|
+
pre_hook.presence
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# == JS variable contract (see base class build_sections for details)
|
|
75
|
+
# These three methods are coupled by JS variable names:
|
|
76
|
+
# - wrap_in_iife introduces `componentName` and `props` as IIFE default parameters
|
|
77
|
+
# - props_section declares `usedProps` (falls back to render_request.props_string
|
|
78
|
+
# when `props` IIFE param is undefined, i.e. normal invocation)
|
|
79
|
+
# - render_call_section references both `componentName` and `usedProps`
|
|
80
|
+
# Overriding any one without the others will produce broken JavaScript.
|
|
81
|
+
def props_section(render_request)
|
|
82
|
+
"var usedProps = typeof props === 'undefined' ? #{render_request.props_string} : props;"
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def render_call_section(render_request)
|
|
86
|
+
render_function_js_expr = resolve_render_function_js_expr(render_request)
|
|
87
|
+
|
|
88
|
+
<<~JS.chomp
|
|
89
|
+
return ReactOnRails[#{render_function_js_expr}]({
|
|
90
|
+
name: componentName,
|
|
91
|
+
domNodeId: #{render_request.dom_id.to_json},
|
|
92
|
+
props: usedProps,
|
|
93
|
+
trace: #{render_request.render_options.trace},
|
|
94
|
+
railsContext: railsContext,
|
|
95
|
+
throwJsErrors: #{ReactOnRailsPro.configuration.throw_js_errors},
|
|
96
|
+
renderingReturnsPromises: #{ReactOnRailsPro.configuration.rendering_returns_promises},
|
|
97
|
+
});
|
|
98
|
+
JS
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def wrap_in_iife(body, render_request)
|
|
102
|
+
"(function(componentName = #{render_request.component_name.to_json}, props = undefined) {\n#{body}\n})()"
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
private
|
|
106
|
+
|
|
107
|
+
def rsc_streaming?(render_request)
|
|
108
|
+
ReactOnRailsPro.configuration.enable_rsc_support && render_request.streaming?
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# Returns a JavaScript expression for the render function name.
|
|
112
|
+
# For RSC streaming, this is a ternary expression (not a simple string literal).
|
|
113
|
+
def resolve_render_function_js_expr(render_request)
|
|
114
|
+
if rsc_streaming?(render_request)
|
|
115
|
+
"ReactOnRails.isRSCBundle ? 'serverRenderRSCReactComponent' : 'streamServerRenderedReactComponent'"
|
|
116
|
+
else
|
|
117
|
+
"'serverRenderReactComponent'"
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
end
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fileutils"
|
|
4
|
+
require "react_on_rails_pro/renderer_cache_helpers"
|
|
5
|
+
require "react_on_rails_pro/renderer_cache_path"
|
|
6
|
+
require "react_on_rails_pro/rolling_deploy_cache_stager"
|
|
7
|
+
|
|
8
|
+
module ReactOnRailsPro
|
|
9
|
+
# Stages the Node Renderer bundle cache in the renderer's expected directory
|
|
10
|
+
# structure (`<cache>/<bundleHash>/<bundleHash>.js`), including any configured
|
|
11
|
+
# assets_to_copy and, when RSC support is enabled, the RSC bundle and manifests.
|
|
12
|
+
#
|
|
13
|
+
# Supports two modes:
|
|
14
|
+
#
|
|
15
|
+
# * `:copy` - copies bundle and assets. Designed for Docker image
|
|
16
|
+
# builds where the cache must be baked into an immutable artifact.
|
|
17
|
+
# * `:symlink` - creates relative symlinks. For same-filesystem workflows
|
|
18
|
+
# (local dev, CI, Heroku-style same-dyno deploys, bundle-caching restores).
|
|
19
|
+
#
|
|
20
|
+
# Both modes produce the same on-disk cache layout, matching the renderer's
|
|
21
|
+
# runtime contract. The 410->retry cold-start round-trip on first SSR request
|
|
22
|
+
# is eliminated when the pre-seeded bundle is present at renderer startup.
|
|
23
|
+
class PreSeedRendererCache
|
|
24
|
+
VALID_MODES = %i[copy symlink].freeze
|
|
25
|
+
|
|
26
|
+
# `mode:` is required (no default) because the two modes have fundamentally
|
|
27
|
+
# different semantics — `:copy` bakes immutable artifacts for Docker/image
|
|
28
|
+
# builds; `:symlink` links live files on a shared filesystem. Forcing callers
|
|
29
|
+
# to be explicit prevents the footgun where an implicit default would mismatch
|
|
30
|
+
# the deploy context (e.g., copy-mode raises about RENDERER_SERVER_BUNDLE_CACHE_PATH
|
|
31
|
+
# in a dev shell). The rake task and AssetsPrecompile auto-invocation both pass
|
|
32
|
+
# `mode:` explicitly with their own context-appropriate defaults.
|
|
33
|
+
def self.call(mode:)
|
|
34
|
+
unless VALID_MODES.include?(mode)
|
|
35
|
+
raise ArgumentError, "mode must be one of #{VALID_MODES.inspect}, got #{mode.inspect}"
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
cache_dir = resolve_cache_dir(mode)
|
|
39
|
+
puts "[ReactOnRailsPro] Staging renderer cache (mode: #{mode}) in: #{cache_dir}"
|
|
40
|
+
pool = ReactOnRailsPro::ServerRenderingPool::NodeRenderingPool
|
|
41
|
+
|
|
42
|
+
assets, rsc_required_paths = RendererCacheHelpers.collect_assets_with_required_paths
|
|
43
|
+
|
|
44
|
+
current_hashes = []
|
|
45
|
+
# Block-level `rescue` (Ruby 2.5+): equivalent to wrapping the block body in
|
|
46
|
+
# begin/rescue/end. RuboCop's Style/RedundantBegin enforces this form, so
|
|
47
|
+
# callers reading the loop should treat the rescue clause below as the
|
|
48
|
+
# iteration body's exception handler — not the surrounding method's.
|
|
49
|
+
RendererCacheHelpers.bundle_sources(pool, action_description(mode)).each do |src_bundle_path, bundle_hash|
|
|
50
|
+
bundle_dir = File.join(cache_dir, bundle_hash.to_s)
|
|
51
|
+
stage_bundle(src_bundle_path, bundle_dir, bundle_hash, mode)
|
|
52
|
+
# The Node Renderer serves manifests from whichever bundle dir it loaded,
|
|
53
|
+
# so both server and RSC dirs need the manifests present.
|
|
54
|
+
stage_assets(assets, bundle_dir, rsc_required_paths, mode)
|
|
55
|
+
current_hashes << bundle_hash.to_s
|
|
56
|
+
rescue StandardError => e
|
|
57
|
+
# Fail-fast: re-raise on the first bundle failure so the deploy sees a non-zero exit and
|
|
58
|
+
# aborts before downstream steps assume the cache is complete. Earlier bundles that
|
|
59
|
+
# already staged successfully (e.g. server bundle when RSC fails) remain on disk for
|
|
60
|
+
# diagnosis or for a re-run, but the renderer should not be expected to start from a
|
|
61
|
+
# partially-staged cache — operators must rebuild the cache or roll back.
|
|
62
|
+
warn "[ReactOnRailsPro] Renderer cache staging failed for bundle #{bundle_hash}; " \
|
|
63
|
+
"cache may be partially staged: #{e.message}"
|
|
64
|
+
raise
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# Optionally seed previous deploys' bundle hashes for rolling-deploy safety.
|
|
68
|
+
# No-op when neither config.rolling_deploy_adapter nor PREVIOUS_BUNDLE_HASHES is set.
|
|
69
|
+
RollingDeployCacheStager.call(cache_dir: cache_dir, current_hashes: current_hashes, mode: mode)
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# Validates the cache-dir env var (raises in production-like copy mode when
|
|
73
|
+
# unset) before resolving. See enforce_cache_dir_env_var! for the rationale.
|
|
74
|
+
def self.resolve_cache_dir(mode)
|
|
75
|
+
enforce_cache_dir_env_var!(mode)
|
|
76
|
+
ReactOnRailsPro::RendererCachePath.resolve
|
|
77
|
+
end
|
|
78
|
+
private_class_method :resolve_cache_dir
|
|
79
|
+
|
|
80
|
+
# In copy mode (Docker image builds), silent fallback to Rails.root/.node-renderer-bundles
|
|
81
|
+
# is a footgun: the renderer process may run from a different cwd and resolve its default
|
|
82
|
+
# cache directory to a different path (e.g., /tmp/react-on-rails-pro-node-renderer-bundles),
|
|
83
|
+
# causing pre-seeded bundles to land somewhere the renderer never reads. Require an
|
|
84
|
+
# explicit env var in non-dev/test environments.
|
|
85
|
+
def self.enforce_cache_dir_env_var!(mode)
|
|
86
|
+
return unless mode == :copy
|
|
87
|
+
return if Rails.env.development? || Rails.env.test?
|
|
88
|
+
|
|
89
|
+
# Only development and test are exempt; custom environments (ci, staging,
|
|
90
|
+
# review, etc.) must set the env var explicitly because their default cache
|
|
91
|
+
# path can differ from the Node renderer's default, causing silent
|
|
92
|
+
# mis-staging.
|
|
93
|
+
# RENDERER_BUNDLE_PATH remains accepted for compatibility, but new deploys
|
|
94
|
+
# should migrate to RENDERER_SERVER_BUNDLE_CACHE_PATH. Whitespace-only
|
|
95
|
+
# values intentionally pass this guard so RendererCachePath can raise the
|
|
96
|
+
# specific validation error instead of the missing-env guidance.
|
|
97
|
+
return if !ENV.fetch("RENDERER_SERVER_BUNDLE_CACHE_PATH", "").empty? ||
|
|
98
|
+
!ENV.fetch("RENDERER_BUNDLE_PATH", "").empty?
|
|
99
|
+
|
|
100
|
+
raise ReactOnRailsPro::Error, <<~MSG.strip
|
|
101
|
+
Pre-seeding the renderer cache in copy mode (#{Rails.env}) requires an explicit
|
|
102
|
+
cache directory. Set RENDERER_SERVER_BUNDLE_CACHE_PATH in your environment, e.g.
|
|
103
|
+
in your Dockerfile:
|
|
104
|
+
|
|
105
|
+
ENV RENDERER_SERVER_BUNDLE_CACHE_PATH=/app/.node-renderer-bundles
|
|
106
|
+
|
|
107
|
+
The Node Renderer's default cache directory resolution differs between the Ruby
|
|
108
|
+
and standalone Node environments, so relying on the default in production-like
|
|
109
|
+
deploys can cause pre-seeded bundles to land in a path the renderer never reads.
|
|
110
|
+
|
|
111
|
+
If you don't need an immutable artifact (e.g. in CI or same-filesystem deploys),
|
|
112
|
+
use mode: :symlink instead:
|
|
113
|
+
|
|
114
|
+
rake react_on_rails_pro:pre_seed_renderer_cache MODE=symlink
|
|
115
|
+
MSG
|
|
116
|
+
end
|
|
117
|
+
private_class_method :enforce_cache_dir_env_var!
|
|
118
|
+
|
|
119
|
+
def self.action_description(mode)
|
|
120
|
+
mode == :copy ? "pre-seeding" : "pre-staging"
|
|
121
|
+
end
|
|
122
|
+
private_class_method :action_description
|
|
123
|
+
|
|
124
|
+
def self.stage_bundle(src_path, bundle_dir, bundle_hash, mode)
|
|
125
|
+
dest_file = File.join(bundle_dir, "#{bundle_hash}.js")
|
|
126
|
+
log_prefix = mode == :copy ? "Pre-seeded renderer cache" : "Pre-staged renderer cache"
|
|
127
|
+
stage_file(src_path, dest_file, mode, log_prefix)
|
|
128
|
+
end
|
|
129
|
+
private_class_method :stage_bundle
|
|
130
|
+
|
|
131
|
+
def self.stage_file(src, dest, mode, log_prefix)
|
|
132
|
+
RendererCacheHelpers.stage_file(src, dest, mode, log_prefix: log_prefix)
|
|
133
|
+
end
|
|
134
|
+
private_class_method :stage_file
|
|
135
|
+
|
|
136
|
+
# RSC manifests are required when RSC is enabled; user-configured
|
|
137
|
+
# assets_to_copy are optional and only produce a warning.
|
|
138
|
+
def self.stage_assets(assets, bundle_dir, rsc_required_paths, mode)
|
|
139
|
+
action_desc = action_description(mode)
|
|
140
|
+
RendererCacheHelpers.each_stageable_asset(assets, rsc_required_paths, action_desc) do |expanded|
|
|
141
|
+
dest = File.join(bundle_dir, File.basename(expanded))
|
|
142
|
+
log_prefix = mode == :copy ? "Copied asset" : "Symlinked asset"
|
|
143
|
+
stage_file(expanded, dest, mode, log_prefix)
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
private_class_method :stage_assets
|
|
147
|
+
end
|
|
148
|
+
end
|
|
@@ -1,40 +1,45 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require "
|
|
3
|
+
require "react_on_rails_pro/pre_seed_renderer_cache"
|
|
4
4
|
|
|
5
5
|
module ReactOnRailsPro
|
|
6
|
+
# DEPRECATED: use `ReactOnRailsPro::PreSeedRendererCache.call(mode: :symlink)` directly.
|
|
7
|
+
# Retained as a thin shim so existing callers (custom rake tasks, Procfile entries,
|
|
8
|
+
# deploy scripts) keep working during the deprecation cycle. Emits a warning once
|
|
9
|
+
# per process on first call.
|
|
6
10
|
class PrepareNodeRenderBundles
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
11
|
+
# Mutex guards the check-then-set on @deprecation_warned so concurrent callers
|
|
12
|
+
# (e.g. multiple Puma workers invoking the shim at boot) still see exactly one
|
|
13
|
+
# warning per process.
|
|
14
|
+
@deprecation_mutex = Mutex.new
|
|
15
|
+
@deprecation_warned = false
|
|
16
|
+
|
|
17
|
+
# The deprecated rake task emits its own warning and calls PreSeedRendererCache
|
|
18
|
+
# directly; it does not set this one-time guard. See assets.rake for that path.
|
|
16
19
|
def self.call
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
dest_path = ENV["RENDERER_BUNDLE_PATH"].presence || Rails.root.join(".node-renderer-bundles").to_s
|
|
21
|
-
bundle_dest_path = File.join(dest_path, renderer_bundle_file_name.to_s).to_s
|
|
22
|
-
puts "[ReactOnRailsPro] Symlinking assets to local node-renderer, path #{dest_path}"
|
|
23
|
-
mkdir_p(dest_path)
|
|
24
|
-
|
|
25
|
-
make_relative_symlink(src_bundle_path, bundle_dest_path)
|
|
26
|
-
|
|
27
|
-
return unless ReactOnRailsPro.configuration.assets_to_copy.present?
|
|
20
|
+
emit_deprecation_warning!
|
|
21
|
+
PreSeedRendererCache.call(mode: :symlink)
|
|
22
|
+
end
|
|
28
23
|
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
next
|
|
33
|
-
end
|
|
24
|
+
def self.emit_deprecation_warning!
|
|
25
|
+
@deprecation_mutex.synchronize do
|
|
26
|
+
return if @deprecation_warned
|
|
34
27
|
|
|
35
|
-
|
|
36
|
-
|
|
28
|
+
warn "[ReactOnRailsPro] ReactOnRailsPro::PrepareNodeRenderBundles is deprecated. " \
|
|
29
|
+
"Use ReactOnRailsPro::PreSeedRendererCache.call(mode: :symlink) instead. " \
|
|
30
|
+
"The rake task equivalent is 'rake react_on_rails_pro:pre_seed_renderer_cache MODE=symlink'."
|
|
31
|
+
@deprecation_warned = true
|
|
37
32
|
end
|
|
38
33
|
end
|
|
34
|
+
private_class_method :emit_deprecation_warning!
|
|
35
|
+
|
|
36
|
+
# :nodoc: Test helper - resets the one-time deprecation-warning guard so
|
|
37
|
+
# specs can exercise the warning path without leaking state between examples.
|
|
38
|
+
# Private so it can only be invoked from specs via `send`; prevents accidental
|
|
39
|
+
# reset from production code.
|
|
40
|
+
def self.reset_deprecation_warned!
|
|
41
|
+
@deprecation_mutex.synchronize { @deprecation_warned = false }
|
|
42
|
+
end
|
|
43
|
+
private_class_method :reset_deprecation_warned!
|
|
39
44
|
end
|
|
40
45
|
end
|