diff --git a/.lychee.toml b/.lychee.toml index a0760286a4..0968ad7ba1 100644 --- a/.lychee.toml +++ b/.lychee.toml @@ -86,6 +86,7 @@ exclude = [ '^https://(www\.)?estately\.com', # Returns 403 '^https://hiring\.careerbuilder\.com', # Returns 403 '^https://(www\.)?yourmechanic\.com', # Returns 403/503 + '^https://(www\.)?guavapass\.com', # TLS handshake failures from CI '^https?://(www\.)?hvmn\.com', # Returns 503 from CI '^https://(www\.)?hawaiichee\.com/?$', # Intermittent 500 from CI diff --git a/CHANGELOG.md b/CHANGELOG.md index c8b1bbe0b3..c72a6a2f8c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -34,6 +34,7 @@ After a release, run `/update-changelog` in Claude Code to analyze commits, writ #### Changed +- **[Pro]** **Unified renderer cache staging**: `ReactOnRailsPro::PreSeedRendererCache.call(mode: :copy | :symlink)` is now the single entry point for staging the Node Renderer cache. Both modes produce the same `//.js` layout. The `react_on_rails_pro:pre_seed_renderer_cache` rake task accepts `MODE=copy` (default; Docker/image builds) or `MODE=symlink` (same-filesystem). `MODE=copy` now raises a clear error when neither `RENDERER_SERVER_BUNDLE_CACHE_PATH` nor `RENDERER_BUNDLE_PATH` is set in non-dev/test environments, because the Node renderer's default lookup can differ from the Ruby side and would silently drop pre-seeded bundles in the wrong directory. The legacy `react_on_rails_pro:pre_stage_bundle_for_node_renderer` task and `ReactOnRailsPro::PrepareNodeRenderBundles` class remain as deprecated shims that emit a once-per-process warning and delegate to `mode: :symlink`. `react_on_rails:doctor` flags deploy scripts that still reference the deprecated task. [PR 3167](https://github.com/shakacode/react_on_rails/pull/3167) by [justin808](https://github.com/justin808). - **[Pro]** **Pro generator now creates the Node Renderer at `renderer/node-renderer.js`**: The canonical location for the Node Renderer entry point is now a dedicated top-level `renderer/` directory instead of `client/`, making it straightforward to exclude from production Docker builds that strip JS sources after bundling. Docs and Pro `spec/dummy` now use the new path consistently. Existing apps are unaffected — the generator skips files that already exist (including a legacy `client/node-renderer.js`). Fixes [Issue 3073](https://github.com/shakacode/react_on_rails/issues/3073). [PR 3165](https://github.com/shakacode/react_on_rails/pull/3165) by [justin808](https://github.com/justin808). - **Rspack install scaffolding now targets Rspack v2**: `react_on_rails:install --rspack` and `bin/switch-bundler` now generate the Rspack v2 package line (`@rspack/core@^2.0.0-0`, `@rspack/cli@^2.0.0-0`, `@rspack/plugin-react-refresh@^2.0.0`) while keeping `rspack-manifest-plugin@^5.0.0`, which is already compatible. Closes [Issue 3082](https://github.com/shakacode/react_on_rails/issues/3082). [PR 3084](https://github.com/shakacode/react_on_rails/pull/3084) by [justin808](https://github.com/justin808). diff --git a/docs/oss/building-features/node-renderer/js-configuration.md b/docs/oss/building-features/node-renderer/js-configuration.md index 2a3c079066..2df3f8241b 100644 --- a/docs/oss/building-features/node-renderer/js-configuration.md +++ b/docs/oss/building-features/node-renderer/js-configuration.md @@ -60,7 +60,8 @@ Deprecated options: ### Testing example: -[react_on_rails_pro/spec/dummy/renderer/node-renderer.js](https://github.com/shakacode/react_on_rails/blob/main/react_on_rails_pro/spec/dummy/renderer/node-renderer.js) +The repository's dummy app keeps a full integration-test launcher at +[`react_on_rails_pro/spec/dummy/renderer/node-renderer.js`](https://github.com/shakacode/react_on_rails/blob/main/react_on_rails_pro/spec/dummy/renderer/node-renderer.js). ### Simple example: diff --git a/docs/pro/node-renderer.md b/docs/pro/node-renderer.md index 8219a590b1..0647f0b7d8 100644 --- a/docs/pro/node-renderer.md +++ b/docs/pro/node-renderer.md @@ -119,16 +119,25 @@ When a new container starts, the Node Renderer has an empty bundle cache. The fi ### Pre-seeding the bundle cache -The `pre_seed_renderer_cache` rake task copies compiled server bundles directly into the renderer's cache directory during your Docker build, so the renderer finds them immediately on startup: +The `pre_seed_renderer_cache` rake task stages compiled server bundles directly into the renderer's cache directory, so the renderer finds them immediately on startup. + +It supports two modes, both producing the same on-disk cache layout (`//.js`): + +- **`MODE=copy`** (default) — copies files. Use in Docker/image builds so the cache is baked into an immutable artifact. +- **`MODE=symlink`** — creates relative symlinks. For same-filesystem workflows (local dev, CI, Heroku-style same-dyno deploys, bundle-caching restores). ```dockerfile -# After webpack/assets build step +# After webpack/assets build step (Docker image build) +ENV RENDERER_SERVER_BUNDLE_CACHE_PATH=/app/.node-renderer-bundles RUN bundle exec rake react_on_rails_pro:pre_seed_renderer_cache ``` -This copies the bundle into the renderer's expected directory structure (`//.js`), including any configured `assets_to_copy` and RSC bundles when RSC support is enabled. +Both modes stage the server bundle, any configured `assets_to_copy`, and (when RSC is enabled) the RSC bundle and its companion manifests. -This is the preferred path for Docker and other image-build workflows. React on Rails Pro has long supported runtime bundle uploads and the older `react_on_rails_pro:pre_stage_bundle_for_node_renderer` task for same-filesystem deployments; `pre_seed_renderer_cache` is the copy-based variant that fits immutable artifacts while using the same bundle-hash cache layout. +The `pre_seed_renderer_cache` task is also invoked automatically at the end of `assets:precompile` with `MODE=symlink`, so the local/CI/Heroku path has zero new configuration. + +> [!NOTE] +> The older `react_on_rails_pro:pre_stage_bundle_for_node_renderer` rake task and `ReactOnRailsPro::PrepareNodeRenderBundles` class are deprecated in favor of the unified API. Both remain available as thin shims that emit a deprecation warning and delegate to `MODE=symlink`. `react_on_rails:doctor` flags deploy scripts that still reference the deprecated task. ### Configuration @@ -136,9 +145,9 @@ The task follows the same environment-variable precedence as the Node Renderer, 1. `RENDERER_SERVER_BUNDLE_CACHE_PATH` environment variable (preferred) 2. `RENDERER_BUNDLE_PATH` environment variable (deprecated — emits a warning) -3. `Rails.root.join(".node-renderer-bundles")` (Rails-side default when env vars are unset) +3. `Rails.root.join(".node-renderer-bundles")` (Rails-side default when env vars are unset, only accepted for `MODE=symlink` and in dev/test) -Set `RENDERER_SERVER_BUNDLE_CACHE_PATH` in your Dockerfile to match the renderer's configuration: +In **`MODE=copy`** (Docker image builds) the task requires one of the env vars above to be set in non-dev/test environments. "Non-dev/test" means any `RAILS_ENV` other than `development` or `test` — including custom environments like `staging`, `review`, or `ci` — so set `RENDERER_SERVER_BUNDLE_CACHE_PATH` wherever you run `MODE=copy` outside of local/CI-test runs. Because the Node renderer's own default can differ (e.g., falling back to `/tmp/react-on-rails-pro-node-renderer-bundles` when its `cwd` sits outside the app tree), relying on the silent fallback risks pre-seeded bundles landing in a directory the renderer never reads. The task raises a clear error if the env var is missing: ```dockerfile ENV RENDERER_SERVER_BUNDLE_CACHE_PATH=/app/.node-renderer-bundles diff --git a/react_on_rails/lib/react_on_rails/doctor.rb b/react_on_rails/lib/react_on_rails/doctor.rb index b9d90d1b2c..e9d9375338 100644 --- a/react_on_rails/lib/react_on_rails/doctor.rb +++ b/react_on_rails/lib/react_on_rails/doctor.rb @@ -58,6 +58,26 @@ class Doctor SERVER_BUNDLE_SOURCE_EXTENSIONS = %w[.js .jsx .ts .tsx .mjs .cjs].freeze CUSTOM_LAUNCHER_INDICATOR_FILES = %w[dev].freeze + # Deprecated-renderer-cache scan (used by check_deprecated_renderer_cache_task): + # look for references to the old pre_stage_bundle_for_node_renderer task in + # common deploy-script locations so users on older Procfile/Dockerfile entries + # get a migration nudge before the task is removed. + DEPRECATED_RENDERER_CACHE_TASK = "pre_stage_bundle_for_node_renderer" + RENDERER_CACHE_DEPLOY_SCRIPT_PATHS = [ + "Procfile", + "Procfile.dev", + "Procfile.dev-static-assets", + "Procfile.production", + "Dockerfile", + "Dockerfile.production", + "Dockerfile.staging", + "Dockerfile.review", + "bin/deploy", + "bin/release", + "bin/docker-entrypoint" + ].freeze + RENDERER_CACHE_DEPLOY_SCRIPT_MAX_BYTES = 1_048_576 + def initialize(verbose: false, fix: false) @verbose = verbose @fix = fix @@ -2717,6 +2737,7 @@ def check_pro_setup ensure_rails_environment_loaded check_pro_renderer_mode check_base_package_imports + check_deprecated_renderer_cache_task end def check_pro_initializer_existence @@ -2746,6 +2767,50 @@ def check_pro_renderer_mode checker.add_warning("⚠️ Could not detect Pro renderer mode: #{e.message}") end + def check_deprecated_renderer_cache_task + # Resolve against Rails.root (not Dir.pwd) so the scan still fires when + # doctor is invoked from a subdirectory — otherwise the checks silently + # find nothing and the deprecation warning never surfaces. + # + # Substring match is intentional: a comment line in a Procfile/Dockerfile + # that mentions the old task name will also trigger the warning. That is + # acceptable — the worst case is a benign migration nudge on a file that's + # already been migrated but still references the old name in a comment. + matches = RENDERER_CACHE_DEPLOY_SCRIPT_PATHS.select do |path| + full_path = Rails.root.join(path) + next false unless full_path.file? + # Skip files larger than 1 MB; deploy scripts should be tiny. + next false if full_path.size > RENDERER_CACHE_DEPLOY_SCRIPT_MAX_BYTES + + full_path.read.include?(DEPRECATED_RENDERER_CACHE_TASK) + end + + return if matches.empty? + + checker.add_warning(<<~MSG.strip) + ⚠️ Deprecated rake task '#{DEPRECATED_RENDERER_CACHE_TASK}' referenced in: + #{matches.map { |p| " • #{p} → #{renderer_cache_migration_suggestion(p)}" }.join("\n")} + + The unified 'pre_seed_renderer_cache' task uses MODE=copy by default (for + Docker/image builds) and MODE=symlink for same-filesystem workflows. + This scan also matches comments; remove stale mentions after migrating. + MSG + rescue StandardError => e + checker.add_warning("⚠️ Could not scan for deprecated renderer-cache task references: #{e.message}") + end + + def renderer_cache_migration_suggestion(path) + # Dockerfile* entries are RUN steps during image build, so copy mode bakes + # the cache into the layer. Procfile, bin/*, and other runtime scripts run + # inside the already-booted container or dyno, where both the app and + # renderer share the same filesystem, so symlink mode is correct. + if path.start_with?("Dockerfile") + "rake react_on_rails_pro:pre_seed_renderer_cache" + else + "rake react_on_rails_pro:pre_seed_renderer_cache MODE=symlink" + end + end + # The base 'react-on-rails' npm package is a transitive dependency of 'react-on-rails-pro', # so `import ... from 'react-on-rails'` resolves silently — loading the base package instead # of Pro. Components registered through the base package won't have Pro features (streaming, diff --git a/react_on_rails/spec/lib/react_on_rails/doctor_spec.rb b/react_on_rails/spec/lib/react_on_rails/doctor_spec.rb index 36c85be15f..e909b7f50c 100644 --- a/react_on_rails/spec/lib/react_on_rails/doctor_spec.rb +++ b/react_on_rails/spec/lib/react_on_rails/doctor_spec.rb @@ -2468,6 +2468,171 @@ class << self end end + describe "check_deprecated_renderer_cache_task" do + let(:doctor) { described_class.new(verbose: false, fix: false) } + let(:checker) { doctor.instance_variable_get(:@checker) } + + context "when a Procfile references the deprecated task" do + let(:tmpdir) { Dir.mktmpdir } + + before do + File.write( + File.join(tmpdir, "Procfile"), + "web: bundle exec rake react_on_rails_pro:pre_stage_bundle_for_node_renderer && bundle exec puma\n" + ) + allow(Rails).to receive(:root).and_return(Pathname.new(tmpdir)) + end + + after { FileUtils.remove_entry(tmpdir) if File.directory?(tmpdir) } + + it "warns with migration guidance" do + doctor.send(:check_deprecated_renderer_cache_task) + warning_msgs = checker.messages.select { |m| m[:type] == :warning } + expect(warning_msgs.any? { |m| m[:content].include?("pre_stage_bundle_for_node_renderer") }).to be(true) + expect(warning_msgs.any? { |m| m[:content].include?("MODE=symlink") }).to be(true) + end + end + + context "when a Dockerfile variant references the deprecated task" do + let(:tmpdir) { Dir.mktmpdir } + + before do + File.write( + File.join(tmpdir, "Dockerfile.production"), + "RUN bundle exec rake react_on_rails_pro:pre_stage_bundle_for_node_renderer\n" + ) + allow(Rails).to receive(:root).and_return(Pathname.new(tmpdir)) + end + + after { FileUtils.remove_entry(tmpdir) if File.directory?(tmpdir) } + + it "suggests the copy-mode task without MODE=symlink" do + doctor.send(:check_deprecated_renderer_cache_task) + warning_msgs = checker.messages.select { |m| m[:type] == :warning } + expect(warning_msgs).not_to be_empty + suggestion_line = warning_msgs + .flat_map { |m| m[:content].split("\n") } + .find { |line| line.include?("Dockerfile.production →") } + expect(suggestion_line).not_to be_nil + expect(suggestion_line).to include("pre_seed_renderer_cache") + expect(suggestion_line).not_to include("MODE=symlink") + end + end + + context "when no deploy scripts reference the deprecated task" do + let(:tmpdir) { Dir.mktmpdir } + + before { allow(Rails).to receive(:root).and_return(Pathname.new(tmpdir)) } + after { FileUtils.remove_entry(tmpdir) if File.directory?(tmpdir) } + + it "adds no warnings" do + doctor.send(:check_deprecated_renderer_cache_task) + expect(checker.messages.select { |m| m[:type] == :warning }).to be_empty + end + end + + context "when a configured deploy-script path is a directory" do + let(:tmpdir) { Dir.mktmpdir } + + before do + FileUtils.mkdir_p(File.join(tmpdir, "bin/deploy")) + File.write( + File.join(tmpdir, "Procfile"), + "web: bundle exec rake react_on_rails_pro:pre_stage_bundle_for_node_renderer\n" + ) + allow(Rails).to receive(:root).and_return(Pathname.new(tmpdir)) + end + + after { FileUtils.remove_entry(tmpdir) if File.directory?(tmpdir) } + + it "skips the directory and continues scanning real files" do + doctor.send(:check_deprecated_renderer_cache_task) + warning_msgs = checker.messages.select { |m| m[:type] == :warning } + + expect(warning_msgs.any? { |m| m[:content].include?("Procfile") }).to be(true) + expect(warning_msgs.none? do |m| + m[:content].include?("Could not scan for deprecated renderer-cache task") + end).to be(true) + end + end + + context "when a deploy-script file exceeds the size gate" do + let(:tmpdir) { Dir.mktmpdir } + + before do + # Stub the cap so we do not have to write a real 1 MB file — the gate + # logic is what we are exercising, not the specific threshold. + stub_const("ReactOnRails::Doctor::RENDERER_CACHE_DEPLOY_SCRIPT_MAX_BYTES", 64) + padding = "x" * 128 + File.write( + File.join(tmpdir, "Procfile"), + "web: bundle exec rake react_on_rails_pro:pre_stage_bundle_for_node_renderer\n#{padding}" + ) + allow(Rails).to receive(:root).and_return(Pathname.new(tmpdir)) + end + + after { FileUtils.remove_entry(tmpdir) if File.directory?(tmpdir) } + + it "silently skips the file and emits no warning" do + doctor.send(:check_deprecated_renderer_cache_task) + expect(checker.messages.select { |m| m[:type] == :warning }).to be_empty + end + end + + context "when a deploy-script file is exactly the size gate" do + let(:tmpdir) { Dir.mktmpdir } + let(:script_content) do + "web: bundle exec rake react_on_rails_pro:pre_stage_bundle_for_node_renderer\n" + end + + before do + stub_const("ReactOnRails::Doctor::RENDERER_CACHE_DEPLOY_SCRIPT_MAX_BYTES", script_content.bytesize) + File.write(File.join(tmpdir, "Procfile"), script_content) + allow(Rails).to receive(:root).and_return(Pathname.new(tmpdir)) + end + + after { FileUtils.remove_entry(tmpdir) if File.directory?(tmpdir) } + + it "still scans the file" do + doctor.send(:check_deprecated_renderer_cache_task) + warning_msgs = checker.messages.select { |m| m[:type] == :warning } + expect(warning_msgs.any? { |m| m[:content].include?("pre_stage_bundle_for_node_renderer") }).to be(true) + end + end + + context "when reading a deploy-script file raises an unexpected error" do + let(:tmpdir) { Dir.mktmpdir } + let(:procfile_path) { File.join(tmpdir, "Procfile") } + + before do + File.write( + procfile_path, + "web: bundle exec rake react_on_rails_pro:pre_stage_bundle_for_node_renderer\n" + ) + root_path = Pathname.new(tmpdir) + allow(Rails).to receive(:root).and_return(root_path) + + # Simulate a filesystem error (e.g. transient EIO or a permissions race) + # on the actual Pathname receiver used by the doctor scan. + failing_procfile = instance_double(Pathname) + allow(failing_procfile).to receive_messages(file?: true, size: File.size(procfile_path)) + allow(failing_procfile).to receive(:read).and_raise(Errno::EIO, "simulated read failure") + allow(root_path).to receive(:join).and_call_original + allow(root_path).to receive(:join).with("Procfile").and_return(failing_procfile) + end + + after { FileUtils.remove_entry(tmpdir) if File.directory?(tmpdir) } + + it "captures the error as a warning instead of failing the doctor check" do + expect { doctor.send(:check_deprecated_renderer_cache_task) }.not_to raise_error + warning_msgs = checker.messages.select { |m| m[:type] == :warning } + expect(warning_msgs.any? do |m| + m[:content].include?("Could not scan for deprecated renderer-cache task") + end).to be(true) + end + end + end + describe "check_base_package_imports" do let(:doctor) { described_class.new(verbose: false, fix: false) } let(:checker) { doctor.instance_variable_get(:@checker) } diff --git a/react_on_rails_pro/lib/react_on_rails_pro/assets_precompile.rb b/react_on_rails_pro/lib/react_on_rails_pro/assets_precompile.rb index 43fc6d59e6..2747933174 100644 --- a/react_on_rails_pro/lib/react_on_rails_pro/assets_precompile.rb +++ b/react_on_rails_pro/lib/react_on_rails_pro/assets_precompile.rb @@ -63,7 +63,10 @@ def build_bundles def self.call instance.build_or_fetch_bundles - ReactOnRailsPro::PrepareNodeRenderBundles.call if ReactOnRailsPro.configuration.node_renderer? + # Auto-stage via symlink after asset precompile (same-filesystem default). + # Docker/image builds should invoke `rake react_on_rails_pro:pre_seed_renderer_cache` + # (MODE=copy, the default) as a separate step. + ReactOnRailsPro::PreSeedRendererCache.call(mode: :symlink) if ReactOnRailsPro.configuration.node_renderer? end def build_or_fetch_bundles diff --git a/react_on_rails_pro/lib/react_on_rails_pro/pre_seed_renderer_cache.rb b/react_on_rails_pro/lib/react_on_rails_pro/pre_seed_renderer_cache.rb index 556dd3cbe8..09b7d0fb7a 100644 --- a/react_on_rails_pro/lib/react_on_rails_pro/pre_seed_renderer_cache.rb +++ b/react_on_rails_pro/lib/react_on_rails_pro/pre_seed_renderer_cache.rb @@ -1,49 +1,189 @@ # frozen_string_literal: true +require "fileutils" +require "pathname" require "react_on_rails_pro/renderer_cache_helpers" module ReactOnRailsPro - # Pre-seeds the Node Renderer bundle cache by copying compiled server bundles - # into the renderer's expected directory structure. Designed for Docker builds - # where the bundle can be baked into the image, eliminating the 410→retry - # cold-start latency on first SSR request after deployment. + # Stages the Node Renderer bundle cache in the renderer's expected directory + # structure (`//.js`), including any configured + # assets_to_copy and, when RSC support is enabled, the RSC bundle and manifests. # - # Unlike PrepareNodeRenderBundles (which stages the same cache layout via - # symlinks for same-filesystem workflows), this class copies files so the - # cache can be baked into an image or other immutable artifact. + # Supports two modes: + # + # * `:copy` (default) - copies bundle and assets. Designed for Docker image + # builds where the cache must be baked into an immutable artifact. + # * `:symlink` - creates relative symlinks. For same-filesystem workflows + # (local dev, CI, Heroku-style same-dyno deploys, bundle-caching restores). + # + # Both modes produce the same on-disk cache layout, matching the renderer's + # runtime contract. The 410->retry cold-start round-trip on first SSR request + # is eliminated when the pre-seeded bundle is present at renderer startup. class PreSeedRendererCache - def self.call - cache_dir = ReactOnRailsPro::Utils.resolve_renderer_cache_dir - puts "[ReactOnRailsPro] Pre-seeding renderer cache in: #{cache_dir}" + VALID_MODES = %i[copy symlink].freeze + + def self.call(mode: :copy) + unless VALID_MODES.include?(mode) + raise ArgumentError, "mode must be one of #{VALID_MODES.inspect}, got #{mode.inspect}" + end + + cache_dir = resolve_cache_dir(mode) + puts "[ReactOnRailsPro] Staging renderer cache (mode: #{mode}) in: #{cache_dir}" pool = ReactOnRailsPro::ServerRenderingPool::NodeRenderingPool assets = RendererCacheHelpers.collect_assets rsc_required_paths = RendererCacheHelpers.required_rsc_asset_paths - RendererCacheHelpers.bundle_sources(pool, "pre-seeding").each do |src_bundle_path, bundle_hash| - seed_bundle(src_bundle_path, bundle_hash, cache_dir) + RendererCacheHelpers.bundle_sources(pool, action_description(mode)).each do |src_bundle_path, bundle_hash| + bundle_dir = File.join(cache_dir, bundle_hash.to_s) + stage_bundle(src_bundle_path, bundle_dir, bundle_hash, mode) # The Node Renderer serves manifests from whichever bundle dir it loaded, # so both server and RSC dirs need the manifests present. - copy_assets(assets, File.join(cache_dir, bundle_hash.to_s), rsc_required_paths) + stage_assets(assets, bundle_dir, rsc_required_paths, mode) end end - def self.seed_bundle(src_path, bundle_hash, cache_dir) - bundle_dir = File.join(cache_dir, bundle_hash.to_s) + # Validates the cache-dir env var (raises in production-like copy mode when + # unset) before resolving. See enforce_cache_dir_env_var! for the rationale. + def self.resolve_cache_dir(mode) + enforce_cache_dir_env_var!(mode) + ReactOnRailsPro::Utils.resolve_renderer_cache_dir + end + private_class_method :resolve_cache_dir + + # In copy mode (Docker image builds), silent fallback to Rails.root/.node-renderer-bundles + # is a footgun: the renderer process may run from a different cwd and resolve its default + # cache directory to a different path (e.g., /tmp/react-on-rails-pro-node-renderer-bundles), + # causing pre-seeded bundles to land somewhere the renderer never reads. Require an + # explicit env var in non-dev/test environments. + def self.enforce_cache_dir_env_var!(mode) + return unless mode == :copy + return if Rails.env.development? || Rails.env.test? + + # Use a plain-Ruby check (no ActiveSupport .present?) so whitespace-only + # values are treated as "not set" and the guard remains portable. + # RENDERER_BUNDLE_PATH remains accepted for compatibility, but new deploys + # should migrate to RENDERER_SERVER_BUNDLE_CACHE_PATH. + return unless ENV.fetch("RENDERER_SERVER_BUNDLE_CACHE_PATH", "").strip.empty? && + ENV.fetch("RENDERER_BUNDLE_PATH", "").strip.empty? + + raise ReactOnRailsPro::Error, <<~MSG.strip + Pre-seeding the renderer cache in copy mode (#{Rails.env}) requires an explicit + cache directory. Set RENDERER_SERVER_BUNDLE_CACHE_PATH in your environment, e.g. + in your Dockerfile: + + ENV RENDERER_SERVER_BUNDLE_CACHE_PATH=/app/.node-renderer-bundles + + The Node Renderer's default cache directory resolution differs between the Ruby + and standalone Node environments, so relying on the default in production-like + deploys can cause pre-seeded bundles to land in a path the renderer never reads. + + If you don't need an immutable artifact (e.g. in CI or same-filesystem deploys), + use mode: :symlink instead: + + rake react_on_rails_pro:pre_seed_renderer_cache MODE=symlink + MSG + end + private_class_method :enforce_cache_dir_env_var! + + def self.action_description(mode) + mode == :copy ? "pre-seeding" : "pre-staging" + end + private_class_method :action_description + + def self.stage_bundle(src_path, bundle_dir, bundle_hash, mode) dest_file = File.join(bundle_dir, "#{bundle_hash}.js") - RendererCacheHelpers.copy_file_atomically(src_path, dest_file, log_prefix: "Pre-seeded renderer cache") + log_prefix = mode == :copy ? "Pre-seeded renderer cache" : "Pre-staged renderer cache" + stage_file(src_path, dest_file, mode, log_prefix) + end + private_class_method :stage_bundle + + def self.stage_file(src, dest, mode, log_prefix) + if mode == :copy + RendererCacheHelpers.copy_file_atomically(src, dest, log_prefix: log_prefix) + else + make_relative_symlink(src, dest, log_prefix) + end end - private_class_method :seed_bundle + private_class_method :stage_file - # RSC manifests are required when RSC is enabled — a missing manifest would - # cause the renderer to fail at runtime with a hard-to-diagnose error. - # User-configured assets_to_copy are optional and only produce a warning. - def self.copy_assets(assets, bundle_dir, rsc_required_paths) - RendererCacheHelpers.each_stageable_asset(assets, rsc_required_paths, "pre-seeding") do |expanded| + # RSC manifests are required when RSC is enabled; user-configured + # assets_to_copy are optional and only produce a warning. + def self.stage_assets(assets, bundle_dir, rsc_required_paths, mode) + action_desc = action_description(mode) + RendererCacheHelpers.each_stageable_asset(assets, rsc_required_paths, action_desc) do |expanded| dest = File.join(bundle_dir, File.basename(expanded)) - RendererCacheHelpers.copy_file_atomically(expanded, dest, log_prefix: "Copied asset") + log_prefix = mode == :copy ? "Copied asset" : "Symlinked asset" + stage_file(expanded, dest, mode, log_prefix) + end + end + private_class_method :stage_assets + + # Replaces `destination` with a relative symlink to `source`. Not atomic: + # if the process is killed between `rm_f` and `File.symlink` the destination + # is briefly absent. In practice the renderer's 410->refetch retry at + # request time recovers from a missing bundle, so the brief gap is benign. + def self.make_relative_symlink(source, destination, log_prefix) + destination_dir = Pathname.new(destination).dirname + + # Canonicalize both sides so paths like /var -> /private/var do not + # produce broken relative symlinks when the cache dir comes from tmpdir. + # Pathname#realpath raises Errno::ENOENT on a dangling symlink or a + # path that vanished between File.exist? and here (e.g. webpack output + # rotating mid-stage). Wrap each realpath call separately so the error + # message correctly names the side that failed. + source_path = + begin + Pathname.new(source).realpath + rescue Errno::ENOENT + raise ReactOnRailsPro::Error, + "Cannot resolve real path for symlink source #{source} - " \ + "it does not exist or is a dangling symlink. " \ + "Rebuild your bundles before staging the renderer cache." + end + FileUtils.mkdir_p(destination_dir) + destination_dir_real = + begin + destination_dir.realpath + rescue Errno::ENOENT + raise ReactOnRailsPro::Error, + "Cannot resolve real path for symlink destination dir #{destination_dir} - " \ + "it may have been removed after mkdir_p (race with an external cleanup)." + end + relative_source_path = source_path.relative_path_from(destination_dir_real) + FileUtils.rm_f(destination) + begin + File.symlink(relative_source_path, destination) + puts "[ReactOnRailsPro] #{log_prefix}: #{relative_source_path} -> #{destination}" + rescue Errno::EEXIST + replace_existing_symlink(destination, relative_source_path, log_prefix) end end - private_class_method :copy_assets + private_class_method :make_relative_symlink + + def self.replace_existing_symlink(destination, relative_source_path, log_prefix) + if matching_symlink?(destination, relative_source_path) + puts "[ReactOnRailsPro] Symlink already present at #{destination} " \ + "(concurrent creator won the race); leaving existing link." + return + end + + FileUtils.rm_f(destination) + begin + File.symlink(relative_source_path, destination) + rescue Errno::EEXIST + return if matching_symlink?(destination, relative_source_path) + + raise + end + puts "[ReactOnRailsPro] #{log_prefix}: #{relative_source_path} -> #{destination} " \ + "(replaced stale symlink)" + end + private_class_method :replace_existing_symlink + + def self.matching_symlink?(destination, relative_source_path) + File.symlink?(destination) && File.readlink(destination) == relative_source_path.to_s + end + private_class_method :matching_symlink? end end diff --git a/react_on_rails_pro/lib/react_on_rails_pro/prepare_node_renderer_bundles.rb b/react_on_rails_pro/lib/react_on_rails_pro/prepare_node_renderer_bundles.rb index 185b717f5d..8655809e6c 100644 --- a/react_on_rails_pro/lib/react_on_rails_pro/prepare_node_renderer_bundles.rb +++ b/react_on_rails_pro/lib/react_on_rails_pro/prepare_node_renderer_bundles.rb @@ -1,56 +1,45 @@ # frozen_string_literal: true -require "fileutils" -require "pathname" -require "react_on_rails_pro/renderer_cache_helpers" +require "react_on_rails_pro/pre_seed_renderer_cache" module ReactOnRailsPro - # Pre-stages the Node Renderer cache via symlinks for same-filesystem workflows - # such as local development, Heroku-style same-dyno deploys, and bundle-caching - # restores. The staged layout matches the renderer's runtime cache contract: - # //.js + # DEPRECATED: use `ReactOnRailsPro::PreSeedRendererCache.call(mode: :symlink)` directly. + # Retained as a thin shim so existing callers (custom rake tasks, Procfile entries, + # deploy scripts) keep working during the deprecation cycle. Emits a warning once + # per process on first call. class PrepareNodeRenderBundles - def self.make_relative_symlink(source, destination) - destination_dir = Pathname.new(destination).dirname - FileUtils.mkdir_p(destination_dir) - FileUtils.rm_f(destination) - - # Canonicalize both sides so paths like /var -> /private/var do not - # produce broken relative symlinks when the cache dir comes from tmpdir. - source_path = Pathname.new(source).realpath - relative_source_path = source_path.relative_path_from(destination_dir.realpath) - File.symlink(relative_source_path, destination) - puts "[ReactOnRailsPro] Symlinked #{relative_source_path} to #{destination}" - end - private_class_method :make_relative_symlink - - def self.resolve_dest_path - ReactOnRailsPro::Utils.resolve_renderer_cache_dir - end - private_class_method :resolve_dest_path - + # Mutex guards the check-then-set on @deprecation_warned so concurrent callers + # (e.g. multiple Puma workers invoking the shim at boot) still see exactly one + # warning per process. + @deprecation_mutex = Mutex.new + @deprecation_warned = false + + # The deprecated rake task emits its own warning and calls PreSeedRendererCache + # directly; it does not set this one-time guard. See assets.rake for that path. def self.call - cache_dir = resolve_dest_path - pool = ReactOnRailsPro::ServerRenderingPool::NodeRenderingPool - puts "[ReactOnRailsPro] Pre-staging renderer cache via symlinks at: #{cache_dir}" + emit_deprecation_warning! + PreSeedRendererCache.call(mode: :symlink) + end - assets = RendererCacheHelpers.collect_assets - rsc_required_paths = RendererCacheHelpers.required_rsc_asset_paths + def self.emit_deprecation_warning! + @deprecation_mutex.synchronize do + return if @deprecation_warned - RendererCacheHelpers.bundle_sources(pool, "pre-staging").each do |src_bundle_path, bundle_hash| - bundle_dir = File.join(cache_dir, bundle_hash.to_s) - bundle_dest_path = File.join(bundle_dir, "#{bundle_hash}.js") - make_relative_symlink(src_bundle_path, bundle_dest_path) - symlink_assets(assets, bundle_dir, rsc_required_paths) + warn "[ReactOnRailsPro] ReactOnRailsPro::PrepareNodeRenderBundles is deprecated. " \ + "Use ReactOnRailsPro::PreSeedRendererCache.call(mode: :symlink) instead. " \ + "The rake task equivalent is 'rake react_on_rails_pro:pre_seed_renderer_cache MODE=symlink'." + @deprecation_warned = true end end - - def self.symlink_assets(assets, bundle_dir, rsc_required_paths) - RendererCacheHelpers.each_stageable_asset(assets, rsc_required_paths, "pre-staging") do |expanded| - destination_full_path = File.join(bundle_dir, File.basename(expanded)) - make_relative_symlink(expanded, destination_full_path) - end + private_class_method :emit_deprecation_warning! + + # :nodoc: Test helper - resets the one-time deprecation-warning guard so + # specs can exercise the warning path without leaking state between examples. + # Private so it can only be invoked from specs via `send`; prevents accidental + # reset from production code. + def self.reset_deprecation_warned! + @deprecation_mutex.synchronize { @deprecation_warned = false } end - private_class_method :symlink_assets + private_class_method :reset_deprecation_warned! end end diff --git a/react_on_rails_pro/lib/tasks/assets.rake b/react_on_rails_pro/lib/tasks/assets.rake index 2c1bb79780..7789ed5e2f 100644 --- a/react_on_rails_pro/lib/tasks/assets.rake +++ b/react_on_rails_pro/lib/tasks/assets.rake @@ -3,14 +3,33 @@ require "active_support" namespace :react_on_rails_pro do - desc "Pre-stage renderer cache locally via symlinks (legacy same-filesystem workflow)" - task pre_stage_bundle_for_node_renderer: :environment do - ReactOnRailsPro::PrepareNodeRenderBundles.call + desc "Stage the Node Renderer bundle cache. MODE=copy (default; Docker/image builds) " \ + "or MODE=symlink (dev/CI/same-filesystem deploys)." + task pre_seed_renderer_cache: :environment do + raw_mode = ENV["MODE"].to_s.downcase + raw_mode = "copy" if raw_mode.empty? + valid_modes = ReactOnRailsPro::PreSeedRendererCache::VALID_MODES.map(&:to_s) + unless valid_modes.include?(raw_mode) + abort "[ReactOnRailsPro] Unknown MODE=#{raw_mode.inspect}. " \ + "Expected one of: #{valid_modes.join(', ')}" + end + ReactOnRailsPro::PreSeedRendererCache.call(mode: raw_mode.to_sym) end - desc "Pre-seed renderer cache for Docker/image builds via copies" - task pre_seed_renderer_cache: :environment do - ReactOnRailsPro::PreSeedRendererCache.call + # Deprecated alias. Delegates to pre_seed_renderer_cache with MODE=symlink so + # existing Procfile/Dockerfile/deploy-script entries keep working during the + # deprecation cycle. + # + # The warning below fires on every task invocation (it is not guarded by the + # once-per-process mutex in PrepareNodeRenderBundles). Rake tasks are invoked + # once per deploy, so repeated output is not a concern here. + desc "DEPRECATED: use 'pre_seed_renderer_cache' (MODE=copy for Docker, MODE=symlink for same-filesystem)." + task pre_stage_bundle_for_node_renderer: :environment do + warn "[ReactOnRailsPro] The 'react_on_rails_pro:pre_stage_bundle_for_node_renderer' rake task is deprecated. " \ + "Migrate to 'rake react_on_rails_pro:pre_seed_renderer_cache' — use MODE=copy (default) for Docker/image " \ + "builds, or MODE=symlink for same-filesystem deploys (this shim preserves the old symlink behavior). " \ + "Run 'rake react_on_rails:doctor' for a per-file migration suggestion based on where the task is referenced." + ReactOnRailsPro::PreSeedRendererCache.call(mode: :symlink) end desc "Copy assets to remote node-renderer" diff --git a/react_on_rails_pro/spec/dummy/spec/pre_seed_renderer_cache_spec.rb b/react_on_rails_pro/spec/dummy/spec/pre_seed_renderer_cache_spec.rb index ac19238410..dad84fb3ed 100644 --- a/react_on_rails_pro/spec/dummy/spec/pre_seed_renderer_cache_spec.rb +++ b/react_on_rails_pro/spec/dummy/spec/pre_seed_renderer_cache_spec.rb @@ -53,6 +53,133 @@ ENV.delete("RENDERER_BUNDLE_PATH") end + context "when mode is invalid" do + it "raises ArgumentError" do + expect { described_class.call(mode: :hardlink) }.to raise_error(ArgumentError, /mode must be one of/) + end + end + + context "when mode is :symlink" do + it "symlinks the bundle instead of copying it" do + described_class.call(mode: :symlink) + + dest_file = File.join(bundle_dir, "#{bundle_hash}.js") + expect(File.exist?(dest_file)).to be(true) + expect(File.symlink?(dest_file)).to be(true) + end + + it "symlinks assets rather than copying them" do + FileUtils.cp(fixture_path, path_in_webpack_folder(asset_filename)) + FileUtils.cp(fixture_path2, path_in_webpack_folder(asset_filename2)) + + described_class.call(mode: :symlink) + + first_asset = File.join(bundle_dir, asset_filename) + second_asset = File.join(bundle_dir, asset_filename2) + expect(File.symlink?(first_asset)).to be(true) + expect(File.symlink?(second_asset)).to be(true) + expect(File.realpath(first_asset)).to eq(path_in_webpack_folder(asset_filename).to_s) + end + + it "logs symlink operations with symlink-specific labels" do + FileUtils.cp(fixture_path, path_in_webpack_folder(asset_filename)) + + expect { described_class.call(mode: :symlink) } + .to output(/Pre-staged renderer cache: .* -> .*Symlinked asset: .* ->/m).to_stdout + end + + it "treats a concurrent matching symlink as success" do + # Simulates two processes racing through make_relative_symlink: the + # other process recreated the destination between rm_f and File.symlink, + # so our syscall raises EEXIST. The guard should swallow that instead + # of propagating. + allow(File).to receive(:symlink).and_wrap_original do |original, source, destination| + original.call(source, destination) + raise Errno::EEXIST + end + + expect { described_class.call(mode: :symlink) }.not_to raise_error + end + + it "replaces a mismatched symlink created by a concurrent stage" do + stale_source = "stale-server-bundle.js" + created_stale_link = false + allow(File).to receive(:symlink).and_wrap_original do |original, source, destination| + unless created_stale_link + created_stale_link = true + original.call(stale_source, destination) + raise Errno::EEXIST + end + + original.call(source, destination) + end + + expect { described_class.call(mode: :symlink) }.to output(/replaced stale symlink/).to_stdout + dest_file = File.join(bundle_dir, "#{bundle_hash}.js") + expect(File.realpath(dest_file)).to eq(server_bundle_path) + end + + it "treats a concurrent matching symlink during stale replacement as success" do + stale_source = "stale-server-bundle.js" + symlink_calls = 0 + allow(File).to receive(:symlink).and_wrap_original do |original, source, destination| + symlink_calls += 1 + case symlink_calls + when 1 + original.call(stale_source, destination) + raise Errno::EEXIST + when 2 + original.call(source, destination) + raise Errno::EEXIST + else + original.call(source, destination) + end + end + + expect { described_class.call(mode: :symlink) }.not_to raise_error + dest_file = File.join(bundle_dir, "#{bundle_hash}.js") + expect(File.realpath(dest_file)).to eq(server_bundle_path) + end + + it "logs mode-accurate prefixes (Pre-staged / Symlinked) instead of copy-oriented wording" do + FileUtils.cp(fixture_path, path_in_webpack_folder(asset_filename)) + FileUtils.cp(fixture_path2, path_in_webpack_folder(asset_filename2)) + + accurate_symlink_logs = satisfy("uses mode-aware log prefixes") do |out| + out.match?(/Pre-staged renderer cache:.*->/) && + out.match?(/Symlinked asset:.*->/) && + !out.include?("Copied asset") && + !out.include?("Pre-seeded renderer cache") + end + expect { described_class.call(mode: :symlink) }.to output(accurate_symlink_logs).to_stdout + end + end + + context "when mode is :copy and no env var is set in a non-dev/test environment" do + before do + allow(Rails.env).to receive_messages(development?: false, test?: false) + allow(ReactOnRailsPro.configuration).to receive(:assets_to_copy).and_return(nil) + end + + it "raises a clear error pointing at RENDERER_SERVER_BUNDLE_CACHE_PATH" do + expect { described_class.call(mode: :copy) } + .to raise_error(ReactOnRailsPro::Error, /RENDERER_SERVER_BUNDLE_CACHE_PATH/) + end + + it "does not raise when the preferred env var is set" do + tmpdir = Dir.mktmpdir("renderer-cache-test") + ENV["RENDERER_SERVER_BUNDLE_CACHE_PATH"] = tmpdir + expect { described_class.call(mode: :copy) }.not_to raise_error + ensure + FileUtils.rm_rf(tmpdir) + ENV.delete("RENDERER_SERVER_BUNDLE_CACHE_PATH") + end + + it "does not raise in :symlink mode even without an env var" do + expect { described_class.call(mode: :symlink) }.not_to raise_error + end + end + context "when assets exist" do before do FileUtils.cp(fixture_path, path_in_webpack_folder(asset_filename)) diff --git a/react_on_rails_pro/spec/dummy/spec/prepare_node_renderer_bundles_spec.rb b/react_on_rails_pro/spec/dummy/spec/prepare_node_renderer_bundles_spec.rb index 5227e14480..83f7b6229f 100644 --- a/react_on_rails_pro/spec/dummy/spec/prepare_node_renderer_bundles_spec.rb +++ b/react_on_rails_pro/spec/dummy/spec/prepare_node_renderer_bundles_spec.rb @@ -39,6 +39,7 @@ ENV.delete("RENDERER_SERVER_BUNDLE_CACHE_PATH") ENV.delete("RENDERER_BUNDLE_PATH") ReactOnRailsPro::Utils.reset_renderer_bundle_path_deprecation_warned! + described_class.send(:reset_deprecation_warned!) end after do @@ -48,6 +49,11 @@ FileUtils.rm_f(path_in_webpack_folder(asset_filename2)) ENV.delete("RENDERER_SERVER_BUNDLE_CACHE_PATH") ENV.delete("RENDERER_BUNDLE_PATH") + described_class.send(:reset_deprecation_warned!) + end + + it "emits a deprecation warning pointing at PreSeedRendererCache" do + expect { pre_stage_cache }.to output(/deprecated.*PreSeedRendererCache/m).to_stderr end context "when assets exist" do diff --git a/react_on_rails_pro/spec/react_on_rails_pro/support/mock_block_helper.rb b/react_on_rails_pro/spec/react_on_rails_pro/support/mock_block_helper.rb index aaae4e6821..f71c8221d7 100644 --- a/react_on_rails_pro/spec/react_on_rails_pro/support/mock_block_helper.rb +++ b/react_on_rails_pro/spec/react_on_rails_pro/support/mock_block_helper.rb @@ -1,6 +1,20 @@ # frozen_string_literal: true module MockBlockHelper + class BlockMock + def initialize(callback) + @callback = callback + end + + def call(*args, &inner_block) + @callback&.call(*args, &inner_block) + end + + def block + method(:call).to_proc + end + end + # This is a class that can be used to mock a block. # It can be used to test that a block is called with the correct arguments. # @@ -10,13 +24,8 @@ module MockBlockHelper # testing_method_taking_block(&mocked_block.block) # expect(mocked_block).to have_received(:call).with(1, 2, 3) def mock_block(&block) - double("BlockMock").tap do |mock| # rubocop:disable RSpec/VerifiedDoubles - allow(mock).to receive(:call) do |*args, &inner_block| - block&.call(*args, &inner_block) - end - def mock.block - method(:call).to_proc - end + BlockMock.new(block).tap do |mock| + allow(mock).to receive(:call).and_call_original end end end