react_on_rails_pro 16.6.0 → 16.7.0.rc.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (33) hide show
  1. checksums.yaml +4 -4
  2. data/.controlplane/rails.yml +2 -2
  3. data/CLAUDE.md +10 -0
  4. data/CONTRIBUTING.md +2 -2
  5. data/Gemfile.development_dependencies +20 -13
  6. data/Gemfile.lock +12 -28
  7. data/Rakefile +0 -5
  8. data/app/helpers/react_on_rails_pro_helper.rb +28 -1
  9. data/lib/react_on_rails_pro/assets_precompile.rb +170 -1
  10. data/lib/react_on_rails_pro/async_props_emitter.rb +80 -0
  11. data/lib/react_on_rails_pro/concerns/stream.rb +1 -1
  12. data/lib/react_on_rails_pro/configuration.rb +114 -17
  13. data/lib/react_on_rails_pro/engine.rb +10 -0
  14. data/lib/react_on_rails_pro/httpx_stream_bidi_patch.rb +42 -0
  15. data/lib/react_on_rails_pro/js_code_builder.rb +121 -0
  16. data/lib/react_on_rails_pro/pre_seed_renderer_cache.rb +148 -0
  17. data/lib/react_on_rails_pro/prepare_node_renderer_bundles.rb +33 -28
  18. data/lib/react_on_rails_pro/renderer_cache_helpers.rb +276 -0
  19. data/lib/react_on_rails_pro/renderer_cache_path.rb +74 -0
  20. data/lib/react_on_rails_pro/rendering_strategy/node_strategy.rb +29 -0
  21. data/lib/react_on_rails_pro/request.rb +135 -8
  22. data/lib/react_on_rails_pro/rolling_deploy_cache_stager.rb +516 -0
  23. data/lib/react_on_rails_pro/server_rendering_js_code.rb +47 -10
  24. data/lib/react_on_rails_pro/server_rendering_pool/node_rendering_pool.rb +35 -10
  25. data/lib/react_on_rails_pro/stream_request.rb +42 -49
  26. data/lib/react_on_rails_pro/utils.rb +7 -9
  27. data/lib/react_on_rails_pro/version.rb +1 -1
  28. data/lib/react_on_rails_pro.rb +8 -0
  29. data/lib/tasks/assets.rake +36 -3
  30. data/rakelib/run_rspec.rake +6 -6
  31. data/react_on_rails_pro.gemspec +9 -4
  32. data/sig/react_on_rails_pro/configuration.rbs +2 -0
  33. metadata +35 -22
@@ -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, ssr_pre_hook_js: nil, assets_to_copy: 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 RAILS_ENV is explicitly set to development or test.
274
- # Rails.env defaults to "development" when RAILS_ENV is unset, which would silently skip
275
- # validation in misconfigured environments. Checking ENV["RAILS_ENV"] directly matches the
276
- # Node-side behavior where an unset environment is treated as production-like.
277
- rails_env = ENV["RAILS_ENV"]&.downcase
278
- return if rails_env.present? && %w[development test].include?(rails_env)
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 — password optional (no authentication)
309
- test password optional (no authentication)
310
- (RAILS_ENV unset) treated as production-like; RENDERER_PASSWORD required
311
- staging — RENDERER_PASSWORD required
312
- production — RENDERER_PASSWORD required
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 "pathname"
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
- extend FileUtils
8
-
9
- def self.make_relative_symlink(source, destination)
10
- FileUtils.rm_f(destination)
11
- relative_source_path = Pathname.new(source).relative_path_from(Pathname.new(destination).dirname)
12
- File.symlink(relative_source_path, destination)
13
- puts "[ReactOnRailsPro] Symlinked #{relative_source_path} to #{destination}"
14
- end
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
- # TODO: temporarily hardcoding tmp/bundles directory. renderer and rails should read from a Yaml file
18
- src_bundle_path = ReactOnRails::Utils.server_bundle_js_file_path
19
- renderer_bundle_file_name = ReactOnRailsPro::ServerRenderingPool::NodeRenderingPool.renderer_bundle_file_name
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
- ReactOnRailsPro.configuration.assets_to_copy.each do |asset_path|
30
- unless File.exist?(asset_path)
31
- warn "Asset not found #{asset_path}"
32
- next
33
- end
24
+ def self.emit_deprecation_warning!
25
+ @deprecation_mutex.synchronize do
26
+ return if @deprecation_warned
34
27
 
35
- destination_full_path = File.join(dest_path, asset_path.basename.to_s)
36
- make_relative_symlink(asset_path, destination_full_path)
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