diff --git a/.gitignore b/.gitignore index f0178bd96a..aac5c06f93 100644 --- a/.gitignore +++ b/.gitignore @@ -61,5 +61,8 @@ yalc.lock # Generated by ROR FS-based Registry generated +# Server-side rendering generated bundles +ssr-generated + # Claude Code local settings .claude/settings.local.json diff --git a/CHANGELOG.md b/CHANGELOG.md index 24c0790740..4235faa439 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,36 @@ After a release, please make sure to run `bundle exec rake update_changelog`. Th Changes since the last non-beta release. +#### New Features + +- **Server Bundle Security**: Added new configuration options for enhanced server bundle security and organization: + - `server_bundle_output_path`: Configurable directory for server bundle output (default: nil, uses fallback locations) + - `enforce_private_server_bundles`: When enabled, ensures server bundles are only loaded from private directories outside the public folder (default: false for backward compatibility) + +- **Improved Bundle Path Resolution**: Enhanced bundle path resolution with better fallback logic that tries multiple locations when manifest lookup fails: + 1. Environment-specific path (e.g., `public/webpack/test`) + 2. Standard Shakapacker location (`public/packs`) + 3. Generated assets path (for legacy setups) + +#### API Improvements + +- **Method Naming Clarification**: Added `public_bundles_full_path` method to clarify bundle path handling: + - `public_bundles_full_path`: New method specifically for webpack bundles in public directories + - `generated_assets_full_path`: Now deprecated (backwards-compatible alias) + - This eliminates confusion between webpack bundles and general Rails public assets + +#### Security Enhancements + +- **Private Server Bundle Enforcement**: When `enforce_private_server_bundles` is enabled, server bundles bypass public directory fallbacks and are only loaded from designated private locations +- **Path Validation**: Added validation to ensure `server_bundle_output_path` points to private directories when enforcement is enabled + +#### Bug Fixes + +- **Non-Packer Environment Compatibility**: Fixed potential NoMethodError when using bundle path resolution in environments without Shakapacker +- **Server Bundle Detection**: Improved server bundle detection to work correctly with both `server_bundle_js_file` and `rsc_bundle_js_file` configurations + +### [16.0.1-rc.2] - 2025-09-20 + #### Bug Fixes - **Packs generator**: Fixed error when `server_bundle_js_file` configuration is empty (default). Added safety check to prevent attempting operations on invalid file paths when server-side rendering is not configured. [PR 1802](https://github.com/shakacode/react_on_rails/pull/1802) diff --git a/docs/guides/configuration.md b/docs/guides/configuration.md index 82f6731351..3fc933f505 100644 --- a/docs/guides/configuration.md +++ b/docs/guides/configuration.md @@ -129,6 +129,69 @@ ReactOnRails.configure do |config| # This manifest file is automatically generated by the React Server Components Webpack plugin. Only set this if you've configured the plugin to use a different filename. config.react_server_client_manifest_file = "react-server-client-manifest.json" + ################################################################################ + # SERVER BUNDLE SECURITY AND ORGANIZATION + ################################################################################ + + # Directory where server bundles will be output during build process. + # This allows organizing server-side rendering assets separately from client assets. + # + # Default is nil, which uses standard fallback locations in this priority order: + # 1. Environment-specific path (e.g., public/webpack/test) + # 2. Standard Shakapacker location (public/packs) + # 3. Generated assets path (for legacy setups) + # + # Example configurations: + # config.server_bundle_output_path = "ssr-generated" # Private directory (recommended) + # config.server_bundle_output_path = "app/assets/builds" # Custom private location + # config.server_bundle_output_path = nil # Use fallback locations (default) + config.server_bundle_output_path = nil + + # When enabled, enforces that server bundles are only loaded from private, designated locations + # to prevent potential security risks from loading untrusted server-side code. + # + # SECURITY IMPORTANT: When enabled, server bundles bypass public directory fallbacks + # and are only loaded from the configured server_bundle_output_path. + # + # Requirements when enabled: + # - server_bundle_output_path must be set to a non-nil value + # - server_bundle_output_path must point to a location outside the public directory + # - Recommended for production environments where security is critical + # + # Default is false for backward compatibility. + config.enforce_private_server_bundles = false + + ################################################################################ + # BUNDLE ORGANIZATION EXAMPLES + ################################################################################ + # + # This configuration creates a clear separation between client and server assets: + # + # CLIENT BUNDLES (Public, Web-Accessible): + # Location: public/webpack/[environment]/ or public/packs/ + # Files: application.js, manifest.json, CSS files + # Served by: Web server directly + # Access: ReactOnRails::Utils.public_bundles_full_path + # + # SERVER BUNDLES (Private, Server-Only): + # Location: ssr-generated/ (when server_bundle_output_path configured) + # Files: server-bundle.js, rsc-bundle.js + # Served by: Never served to browsers + # Access: ReactOnRails::Utils.server_bundle_js_file_path + # + # Example directory structure with recommended configuration: + # app/ + # ├── ssr-generated/ # Private server bundles + # │ ├── server-bundle.js + # │ └── rsc-bundle.js + # └── public/ + # └── webpack/development/ # Public client bundles + # ├── application.js + # ├── manifest.json + # └── styles.css + # + ################################################################################ + # `prerender` means server-side rendering # default is false. This is an option for view helpers `render_component` and `render_component_hash`. # Set to true to change the default value to true. diff --git a/lib/generators/react_on_rails/base_generator.rb b/lib/generators/react_on_rails/base_generator.rb index 2b3a197c67..3d42dbf203 100644 --- a/lib/generators/react_on_rails/base_generator.rb +++ b/lib/generators/react_on_rails/base_generator.rb @@ -136,14 +136,17 @@ def update_gitignore_for_auto_registration return unless File.exist?(gitignore_path) gitignore_content = File.read(gitignore_path) - return if gitignore_content.include?("**/generated/**") - append_to_file ".gitignore" do - <<~GITIGNORE + additions = [] + additions << "**/generated/**" unless gitignore_content.include?("**/generated/**") + additions << "ssr-generated" unless gitignore_content.include?("ssr-generated") + + return if additions.empty? - # Generated React on Rails packs - **/generated/** - GITIGNORE + append_to_file ".gitignore" do + lines = ["\n# Generated React on Rails packs"] + lines.concat(additions) + "#{lines.join("\n")}\n" end end diff --git a/lib/generators/react_on_rails/templates/base/base/config/initializers/react_on_rails.rb.tt b/lib/generators/react_on_rails/templates/base/base/config/initializers/react_on_rails.rb.tt index 223169639a..f1b1a8665d 100644 --- a/lib/generators/react_on_rails/templates/base/base/config/initializers/react_on_rails.rb.tt +++ b/lib/generators/react_on_rails/templates/base/base/config/initializers/react_on_rails.rb.tt @@ -43,6 +43,15 @@ ReactOnRails.configure do |config| # config.server_bundle_js_file = "server-bundle.js" + # Configure where server bundles are output. Defaults to "ssr-generated". + # This should match your webpack configuration for server bundles. + config.server_bundle_output_path = "ssr-generated" + + # Enforce that server bundles are only loaded from private (non-public) directories. + # When true, server bundles will only be loaded from the configured server_bundle_output_path. + # This is recommended for production to prevent server-side code from being exposed. + config.enforce_private_server_bundles = true + ################################################################################ ################################################################################ # FILE SYSTEM BASED COMPONENT REGISTRY diff --git a/lib/generators/react_on_rails/templates/base/base/config/webpack/serverWebpackConfig.js.tt b/lib/generators/react_on_rails/templates/base/base/config/webpack/serverWebpackConfig.js.tt index a1f40f065d..deb724704d 100644 --- a/lib/generators/react_on_rails/templates/base/base/config/webpack/serverWebpackConfig.js.tt +++ b/lib/generators/react_on_rails/templates/base/base/config/webpack/serverWebpackConfig.js.tt @@ -44,13 +44,14 @@ const configureServer = () => { // Custom output for the server-bundle that matches the config in // config/initializers/react_on_rails.rb + // Server bundles are output to a private directory (not public) for security serverWebpackConfig.output = { filename: 'server-bundle.js', globalObject: 'this', // If using the React on Rails Pro node server renderer, uncomment the next line // libraryTarget: 'commonjs2', - path: config.outputPath, - publicPath: config.publicPath, + path: require('path').resolve(__dirname, '../../ssr-generated'), + // No publicPath needed since server bundles are not served via web // https://webpack.js.org/configuration/output/#outputglobalobject }; diff --git a/lib/react_on_rails/configuration.rb b/lib/react_on_rails/configuration.rb index d3047a59ed..885591fd28 100644 --- a/lib/react_on_rails/configuration.rb +++ b/lib/react_on_rails/configuration.rb @@ -52,7 +52,9 @@ def self.configuration # If exceeded, an error will be thrown for server-side rendered components not registered on the client. # Set to 0 to disable the timeout and wait indefinitely for component registration. component_registry_timeout: DEFAULT_COMPONENT_REGISTRY_TIMEOUT, - generated_component_packs_loading_strategy: nil + generated_component_packs_loading_strategy: nil, + server_bundle_output_path: nil, + enforce_private_server_bundles: false ) end @@ -68,7 +70,8 @@ class Configuration :same_bundle_for_client_and_server, :rendering_props_extension, :make_generated_server_bundle_the_entrypoint, :generated_component_packs_loading_strategy, :immediate_hydration, :rsc_bundle_js_file, - :react_client_manifest_file, :react_server_client_manifest_file, :component_registry_timeout + :react_client_manifest_file, :react_server_client_manifest_file, :component_registry_timeout, + :server_bundle_output_path, :enforce_private_server_bundles # rubocop:disable Metrics/AbcSize def initialize(node_modules_location: nil, server_bundle_js_file: nil, prerender: nil, @@ -85,7 +88,7 @@ def initialize(node_modules_location: nil, server_bundle_js_file: nil, prerender random_dom_id: nil, server_render_method: nil, rendering_props_extension: nil, components_subdirectory: nil, auto_load_bundle: nil, immediate_hydration: nil, rsc_bundle_js_file: nil, react_client_manifest_file: nil, react_server_client_manifest_file: nil, - component_registry_timeout: nil) + component_registry_timeout: nil, server_bundle_output_path: nil, enforce_private_server_bundles: nil) self.node_modules_location = node_modules_location.present? ? node_modules_location : Rails.root self.generated_assets_dirs = generated_assets_dirs self.generated_assets_dir = generated_assets_dir @@ -130,6 +133,8 @@ def initialize(node_modules_location: nil, server_bundle_js_file: nil, prerender self.defer_generated_component_packs = defer_generated_component_packs self.immediate_hydration = immediate_hydration self.generated_component_packs_loading_strategy = generated_component_packs_loading_strategy + self.server_bundle_output_path = server_bundle_output_path + self.enforce_private_server_bundles = enforce_private_server_bundles end # rubocop:enable Metrics/AbcSize @@ -146,6 +151,7 @@ def setup_config_values adjust_precompile_task check_component_registry_timeout validate_generated_component_packs_loading_strategy + validate_enforce_private_server_bundles end private @@ -194,6 +200,30 @@ def validate_generated_component_packs_loading_strategy raise ReactOnRails::Error, "generated_component_packs_loading_strategy must be either :async, :defer, or :sync" end + def validate_enforce_private_server_bundles + return unless enforce_private_server_bundles + + # Check if server_bundle_output_path is nil + if server_bundle_output_path.nil? + raise ReactOnRails::Error, "enforce_private_server_bundles is set to true, but " \ + "server_bundle_output_path is nil. Please set server_bundle_output_path " \ + "to a directory outside of the public directory." + end + + # Check if server_bundle_output_path is inside public directory + # Skip validation if Rails.root is not available (e.g., in tests) + return unless defined?(Rails) && Rails.root + + public_path = Rails.root.join("public").to_s + server_output_path = File.expand_path(server_bundle_output_path, Rails.root.to_s) + + return unless server_output_path.start_with?(public_path) + + raise ReactOnRails::Error, "enforce_private_server_bundles is set to true, but " \ + "server_bundle_output_path (#{server_bundle_output_path}) is inside " \ + "the public directory. Please set it to a directory outside of public." + end + def check_autobundling_requirements raise_missing_components_subdirectory if auto_load_bundle && !components_subdirectory.present? return unless components_subdirectory.present? diff --git a/lib/react_on_rails/packs_generator.rb b/lib/react_on_rails/packs_generator.rb index 171fe0e2d0..40119f5dec 100644 --- a/lib/react_on_rails/packs_generator.rb +++ b/lib/react_on_rails/packs_generator.rb @@ -166,7 +166,7 @@ def generated_server_pack_file_content def add_generated_pack_to_server_bundle return if ReactOnRails.configuration.make_generated_server_bundle_the_entrypoint - return if ReactOnRails.configuration.server_bundle_js_file.empty? + return if ReactOnRails.configuration.server_bundle_js_file.blank? relative_path_to_generated_server_bundle = relative_path(server_bundle_entrypoint, generated_server_bundle_file_path) diff --git a/lib/react_on_rails/utils.rb b/lib/react_on_rails/utils.rb index 1f148188d8..103203942c 100644 --- a/lib/react_on_rails/utils.rb +++ b/lib/react_on_rails/utils.rb @@ -72,6 +72,17 @@ def self.server_bundle_path_is_http? end def self.bundle_js_file_path(bundle_name) + # Check if this is a server bundle with configured output path - skip manifest lookup + if server_bundle?(bundle_name) + config = ReactOnRails.configuration + root_path = Rails.root || "." + + # Use configured server_bundle_output_path if present + if config.server_bundle_output_path.present? + return File.expand_path(File.join(root_path, config.server_bundle_output_path, bundle_name)) + end + end + # Either: # 1. Using same bundle for both server and client, so server bundle will be hashed in manifest # 2. Using a different bundle (different Webpack config), so file is not hashed, and @@ -81,21 +92,68 @@ def self.bundle_js_file_path(bundle_name) # a. The webpack manifest plugin would have a race condition where the same manifest.json # is edited by both the webpack-dev-server # b. There is no good reason to hash the server bundle name. - if bundle_name == "manifest.json" - # Default to the non-hashed name in the specified output directory, which, for legacy - # React on Rails, this is the output directory picked up by the asset pipeline. - # For Shakapacker, this is the public output path defined in the (shaka/web)packer.yml file. - File.join(generated_assets_full_path, bundle_name) - else + if ReactOnRails::PackerUtils.using_packer? && bundle_name != "manifest.json" begin ReactOnRails::PackerUtils.bundle_js_uri_from_packer(bundle_name) - rescue Shakapacker::Manifest::MissingEntryError - File.expand_path( - File.join(ReactOnRails::PackerUtils.packer_public_output_path, - bundle_name) - ) + rescue Object.const_get( + ReactOnRails::PackerUtils.packer_type.capitalize + )::Manifest::MissingEntryError + handle_missing_manifest_entry(bundle_name) end + else + # Default to the non-hashed name in the specified output directory, which, for legacy + # React on Rails, this is the output directory picked up by the asset pipeline. + # For Shakapacker, this is the public output path defined in the (shaka/web)packer.yml file. + File.join(public_bundles_full_path, bundle_name) + end + end + + private_class_method def self.handle_missing_manifest_entry(bundle_name) + # For server bundles with enforcement enabled, skip public path fallbacks + return server_bundle_private_path(bundle_name) if server_bundle?(bundle_name) && enforce_private_server_bundles? + + # When manifest lookup fails, try multiple fallback locations: + # Build fallback locations conditionally based on packer availability + fallback_locations = [] + + # 1. Environment-specific path (e.g., public/webpack/test) - only if using packer + if ReactOnRails::PackerUtils.using_packer? + fallback_locations << File.join(ReactOnRails::PackerUtils.packer_public_output_path, bundle_name) end + + # 2. Standard Shakapacker location (public/packs) + fallback_locations << File.join("public", "packs", bundle_name) + + # 3. Generated assets path (for legacy setups) + fallback_locations << File.join(public_bundles_full_path, bundle_name) + + fallback_locations.uniq! + + # Return the first location where the bundle file actually exists + fallback_locations.each do |path| + expanded_path = File.expand_path(path) + return expanded_path if File.exist?(expanded_path) + end + + # If none exist, return the environment-specific path (original behavior) + File.expand_path(fallback_locations.first) + end + + private_class_method def self.server_bundle?(bundle_name) + config = ReactOnRails.configuration + bundle_name == config.server_bundle_js_file || + bundle_name == config.rsc_bundle_js_file + end + + private_class_method def self.enforce_private_server_bundles? + ReactOnRails.configuration.enforce_private_server_bundles + end + + private_class_method def self.server_bundle_private_path(bundle_name) + config = ReactOnRails.configuration + preferred_dir = config.server_bundle_output_path.presence || "ssr-generated" + root_path = Rails.root || "." + File.expand_path(File.join(root_path, preferred_dir, bundle_name)) end def self.server_bundle_js_file_path @@ -130,7 +188,7 @@ def self.react_server_client_manifest_file_path "react_server_client_manifest_file is nil, ensure it is set in your configuration" end - @react_server_manifest_path = File.join(generated_assets_full_path, asset_name) + @react_server_manifest_path = File.join(public_bundles_full_path, asset_name) end def self.running_on_windows? @@ -166,10 +224,15 @@ def self.using_packer_source_path_is_not_defined_and_custom_node_modules? ReactOnRails.configuration.node_modules_location.present? end - def self.generated_assets_full_path + def self.public_bundles_full_path ReactOnRails::PackerUtils.packer_public_output_path end + # DEPRECATED: Use public_bundles_full_path for clarity about public vs private bundle paths + def self.generated_assets_full_path + public_bundles_full_path + end + def self.gem_available?(name) Gem.loaded_specs[name].present? rescue Gem::LoadError diff --git a/spec/dummy/config/initializers/react_on_rails.rb b/spec/dummy/config/initializers/react_on_rails.rb index b1c70a9722..b1a46ea5db 100644 --- a/spec/dummy/config/initializers/react_on_rails.rb +++ b/spec/dummy/config/initializers/react_on_rails.rb @@ -27,6 +27,11 @@ def self.adjust_props_for_client_side_hydration(component_name, props) config.server_bundle_js_file = "server-bundle.js" config.random_dom_id = false # default is true + # Set server_bundle_output_path to nil so bundles are read from public path + # This ensures the dummy app uses the standard webpack output location + config.server_bundle_output_path = nil + config.enforce_private_server_bundles = false + # Uncomment to test these # config.build_test_command = "yarn run build:test" # config.build_production_command = "RAILS_ENV=production NODE_ENV=production bin/shakapacker" diff --git a/spec/react_on_rails/configuration_spec.rb b/spec/react_on_rails/configuration_spec.rb index c3037546cf..4fcb4f8c60 100644 --- a/spec/react_on_rails/configuration_spec.rb +++ b/spec/react_on_rails/configuration_spec.rb @@ -76,6 +76,7 @@ module ReactOnRails describe ".build_production_command" do context "when using Shakapacker 8" do it "fails when \"shakapacker_precompile\" is truly and \"build_production_command\" is truly" do + allow(ReactOnRails::PackerUtils).to receive(:using_packer?).and_return(true) allow(Shakapacker).to receive_message_chain("config.shakapacker_precompile?") .and_return(true) expect do @@ -86,6 +87,7 @@ module ReactOnRails end it "doesn't fail when \"shakapacker_precompile\" is falsy and \"build_production_command\" is truly" do + allow(ReactOnRails::PackerUtils).to receive(:using_packer?).and_return(true) allow(Shakapacker).to receive_message_chain("config.shakapacker_precompile?") .and_return(false) expect do @@ -96,6 +98,7 @@ module ReactOnRails end it "doesn't fail when \"shakapacker_precompile\" is truly and \"build_production_command\" is falsy" do + allow(ReactOnRails::PackerUtils).to receive(:using_packer?).and_return(true) allow(Shakapacker).to receive_message_chain("config.shakapacker_precompile?") .and_return(true) expect do @@ -104,6 +107,7 @@ module ReactOnRails end it "doesn't fail when \"shakapacker_precompile\" is falsy and \"build_production_command\" is falsy" do + allow(ReactOnRails::PackerUtils).to receive(:using_packer?).and_return(true) allow(Shakapacker).to receive_message_chain("config.shakapacker_precompile?") .and_return(false) expect do @@ -459,6 +463,62 @@ module ReactOnRails end end end + + describe "enforce_private_server_bundles validation" do + context "when enforce_private_server_bundles is true" do + before do + # Mock Rails.root for tests that need path validation + allow(Rails).to receive(:root).and_return(Pathname.new("/test/app")) + end + + it "raises error when server_bundle_output_path is nil" do + expect do + ReactOnRails.configure do |config| + config.server_bundle_output_path = nil + config.enforce_private_server_bundles = true + end + end.to raise_error(ReactOnRails::Error, /server_bundle_output_path is nil/) + end + + it "raises error when server_bundle_output_path is inside public directory" do + expect do + ReactOnRails.configure do |config| + config.server_bundle_output_path = "public/server-bundles" + config.enforce_private_server_bundles = true + end + end.to raise_error(ReactOnRails::Error, /is inside the public directory/) + end + + it "allows server_bundle_output_path outside public directory" do + expect do + ReactOnRails.configure do |config| + config.server_bundle_output_path = "ssr-generated" + config.enforce_private_server_bundles = true + end + end.not_to raise_error + end + end + + context "when enforce_private_server_bundles is false" do + it "allows server_bundle_output_path to be nil" do + expect do + ReactOnRails.configure do |config| + config.server_bundle_output_path = nil + config.enforce_private_server_bundles = false + end + end.not_to raise_error + end + + it "allows server_bundle_output_path inside public directory" do + expect do + ReactOnRails.configure do |config| + config.server_bundle_output_path = "public/server-bundles" + config.enforce_private_server_bundles = false + end + end.not_to raise_error + end + end + end end end diff --git a/spec/react_on_rails/test_helper/webpack_assets_status_checker_spec.rb b/spec/react_on_rails/test_helper/webpack_assets_status_checker_spec.rb index 3e48778c9d..48f748f456 100644 --- a/spec/react_on_rails/test_helper/webpack_assets_status_checker_spec.rb +++ b/spec/react_on_rails/test_helper/webpack_assets_status_checker_spec.rb @@ -62,7 +62,7 @@ let(:fixture_dirname) { "assets_with_manifest_exist_server_bundle_separate" } before do - Packer = Shakapacker # rubocop:disable Lint/ConstantDefinitionInBlock, RSpec/LeakyConstantDeclaration + Packer = ReactOnRails::PackerUtils.packer # rubocop:disable Lint/ConstantDefinitionInBlock, RSpec/LeakyConstantDeclaration allow(ReactOnRails::PackerUtils).to receive_messages( manifest_exists?: true, packer_public_output_path: generated_assets_full_path diff --git a/spec/react_on_rails/utils_spec.rb b/spec/react_on_rails/utils_spec.rb index dae9850b83..5cd5f83f7e 100644 --- a/spec/react_on_rails/utils_spec.rb +++ b/spec/react_on_rails/utils_spec.rb @@ -61,6 +61,10 @@ def mock_bundle_configs(server_bundle_name: random_bundle_name, rsc_bundle_name: .and_return(server_bundle_name) allow(ReactOnRails).to receive_message_chain("configuration.rsc_bundle_js_file") .and_return(rsc_bundle_name) + allow(ReactOnRails).to receive_message_chain("configuration.server_bundle_output_path") + .and_return("ssr-generated") + allow(ReactOnRails).to receive_message_chain("configuration.enforce_private_server_bundles") + .and_return(false) end def mock_dev_server_running @@ -88,6 +92,14 @@ def mock_dev_server_running end describe ".bundle_js_file_path" do + before do + # Mock configuration calls to avoid server bundle detection for regular client bundles + allow(ReactOnRails).to receive_message_chain("configuration.server_bundle_js_file") + .and_return("server-bundle.js") + allow(ReactOnRails).to receive_message_chain("configuration.rsc_bundle_js_file") + .and_return("rsc-bundle.js") + end + subject do described_class.bundle_js_file_path("webpack-bundle.js") end @@ -117,6 +129,59 @@ def mock_dev_server_running it { is_expected.to eq("#{packer_public_output_path}/manifest.json") } end + + context "when file not in manifest and fallback to standard location" do + before do + mock_missing_manifest_entry("webpack-bundle.js") + end + + let(:standard_path) { File.expand_path(File.join("public", "packs", "webpack-bundle.js")) } + let(:env_specific_path) { File.join(packer_public_output_path, "webpack-bundle.js") } + + it "returns standard path when bundle exists there" do + allow(File).to receive(:exist?).and_call_original + allow(File).to receive(:exist?).with(File.expand_path(env_specific_path)).and_return(false) + allow(File).to receive(:exist?).with(standard_path).and_return(true) + + result = described_class.bundle_js_file_path("webpack-bundle.js") + expect(result).to eq(standard_path) + end + + it "returns environment-specific path when no bundle exists anywhere" do + allow(File).to receive(:exist?).and_call_original + allow(File).to receive(:exist?).and_return(false) + + result = described_class.bundle_js_file_path("webpack-bundle.js") + expect(result).to eq(File.expand_path(env_specific_path)) + end + end + + context "with enforce_private_server_bundles enabled" do + before do + mock_missing_manifest_entry("server-bundle.js") + allow(ReactOnRails).to receive_message_chain("configuration.server_bundle_js_file") + .and_return("server-bundle.js") + allow(ReactOnRails).to receive_message_chain("configuration.server_bundle_output_path") + .and_return("ssr-generated") + allow(ReactOnRails).to receive_message_chain("configuration.enforce_private_server_bundles") + .and_return(true) + end + + it "returns private path and does not fall back to public when enforcement is enabled" do + ssr_generated_path = File.expand_path(File.join(Rails.root.to_s, "ssr-generated", "server-bundle.js")) + public_packs_path = File.expand_path(File.join("public", "packs", "server-bundle.js")) + + # Mock File.exist? so SSR-generated path returns false but public path returns true + allow(File).to receive(:exist?).and_call_original + allow(File).to receive(:exist?).with(ssr_generated_path).and_return(false) + allow(File).to receive(:exist?).with(public_packs_path).and_return(true) + + result = described_class.bundle_js_file_path("server-bundle.js") + + # Should return the private path even though it doesn't exist, not fall back to public + expect(result).to eq(ssr_generated_path) + end + end end end end @@ -157,14 +222,72 @@ def mock_dev_server_running include_context "with #{packer_type} enabled" context "with server file not in manifest", packer_type.to_sym do - it "returns the unhashed server path" do + it "returns the private ssr-generated path for server bundles" do server_bundle_name = "server-bundle.js" mock_bundle_configs(server_bundle_name: server_bundle_name) mock_missing_manifest_entry(server_bundle_name) path = described_class.server_bundle_js_file_path - expect(path).to end_with("public/webpack/development/#{server_bundle_name}") + expect(path).to end_with("ssr-generated/#{server_bundle_name}") + end + + context "with server_bundle_output_path configured" do + it "returns the configured path directly without checking file existence" do + server_bundle_name = "server-bundle.js" + mock_bundle_configs(server_bundle_name: server_bundle_name) + + # Since server_bundle_output_path is configured, should return path immediately + # without trying manifest lookup + expect(ReactOnRails::PackerUtils.packer).not_to receive(:manifest) + expect(File).not_to receive(:exist?) + + path = described_class.server_bundle_js_file_path + + expect(path).to end_with("ssr-generated/#{server_bundle_name}") + end + end + + context "with bundle file existing in standard location but not environment-specific location" do + it "returns the standard location path" do + server_bundle_name = "server-bundle.js" + mock_bundle_configs(server_bundle_name: server_bundle_name) + # Override server_bundle_output_path to test fallback behavior + allow(ReactOnRails).to receive_message_chain("configuration.server_bundle_output_path") + .and_return(nil) + mock_missing_manifest_entry(server_bundle_name) + + # Mock File.exist? to return false for environment-specific path but true for standard path + standard_path = File.expand_path(File.join("public", "packs", server_bundle_name)) + env_specific_path = File.join(packer_public_output_path, server_bundle_name) + + allow(File).to receive(:exist?).and_call_original + allow(File).to receive(:exist?).with(File.expand_path(env_specific_path)).and_return(false) + allow(File).to receive(:exist?).with(standard_path).and_return(true) + + path = described_class.server_bundle_js_file_path + + expect(path).to eq(standard_path) + end + end + + context "with bundle file not existing in any fallback location" do + it "returns the environment-specific path as final fallback" do + server_bundle_name = "server-bundle.js" + mock_bundle_configs(server_bundle_name: server_bundle_name) + # Override server_bundle_output_path to test fallback behavior + allow(ReactOnRails).to receive_message_chain("configuration.server_bundle_output_path") + .and_return(nil) + mock_missing_manifest_entry(server_bundle_name) + + # Mock File.exist? to return false for all paths + allow(File).to receive(:exist?).and_call_original + allow(File).to receive(:exist?).and_return(false) + + path = described_class.server_bundle_js_file_path + + expect(path).to end_with("public/webpack/development/#{server_bundle_name}") + end end end @@ -172,6 +295,9 @@ def mock_dev_server_running it "returns the correct path hashed server path" do packer = ::Shakapacker mock_bundle_configs(server_bundle_name: "webpack-bundle.js") + # Clear server_bundle_output_path to test manifest behavior + allow(ReactOnRails).to receive_message_chain("configuration.server_bundle_output_path") + .and_return(nil) allow(ReactOnRails).to receive_message_chain("configuration.same_bundle_for_client_and_server") .and_return(true) mock_bundle_in_manifest("webpack-bundle.js", "webpack/development/webpack-bundle-123456.js") @@ -186,6 +312,9 @@ def mock_dev_server_running context "with webpack-dev-server running, and same file used for server and client" do it "returns the correct path hashed server path" do mock_bundle_configs(server_bundle_name: "webpack-bundle.js") + # Clear server_bundle_output_path to test manifest behavior + allow(ReactOnRails).to receive_message_chain("configuration.server_bundle_output_path") + .and_return(nil) allow(ReactOnRails).to receive_message_chain("configuration.same_bundle_for_client_and_server") .and_return(true) mock_dev_server_running @@ -202,6 +331,9 @@ def mock_dev_server_running packer_type.to_sym do it "returns the correct path hashed server path" do mock_bundle_configs(server_bundle_name: "server-bundle.js") + # Clear server_bundle_output_path to test manifest behavior + allow(ReactOnRails).to receive_message_chain("configuration.server_bundle_output_path") + .and_return(nil) allow(ReactOnRails).to receive_message_chain("configuration.same_bundle_for_client_and_server") .and_return(false) mock_bundle_in_manifest("server-bundle.js", "webpack/development/server-bundle-123456.js") @@ -220,14 +352,14 @@ def mock_dev_server_running include_context "with #{packer_type} enabled" context "with server file not in manifest", packer_type.to_sym do - it "returns the unhashed server path" do + it "returns the private ssr-generated path for RSC bundles" do server_bundle_name = "rsc-bundle.js" mock_bundle_configs(rsc_bundle_name: server_bundle_name) mock_missing_manifest_entry(server_bundle_name) path = described_class.rsc_bundle_js_file_path - expect(path).to end_with("public/webpack/development/#{server_bundle_name}") + expect(path).to end_with("ssr-generated/#{server_bundle_name}") end end @@ -235,6 +367,9 @@ def mock_dev_server_running it "returns the correct path hashed server path" do packer = ::Shakapacker mock_bundle_configs(rsc_bundle_name: "webpack-bundle.js") + # Clear server_bundle_output_path to test manifest behavior + allow(ReactOnRails).to receive_message_chain("configuration.server_bundle_output_path") + .and_return(nil) allow(ReactOnRails).to receive_message_chain("configuration.same_bundle_for_client_and_server") .and_return(true) mock_bundle_in_manifest("webpack-bundle.js", "webpack/development/webpack-bundle-123456.js") @@ -249,6 +384,9 @@ def mock_dev_server_running context "with webpack-dev-server running, and same file used for server and client" do it "returns the correct path hashed server path" do mock_bundle_configs(rsc_bundle_name: "webpack-bundle.js") + # Clear server_bundle_output_path to test manifest behavior + allow(ReactOnRails).to receive_message_chain("configuration.server_bundle_output_path") + .and_return(nil) allow(ReactOnRails).to receive_message_chain("configuration.same_bundle_for_client_and_server") .and_return(true) mock_dev_server_running @@ -265,6 +403,9 @@ def mock_dev_server_running packer_type.to_sym do it "returns the correct path hashed server path" do mock_bundle_configs(rsc_bundle_name: "rsc-bundle.js") + # Clear server_bundle_output_path to test manifest behavior + allow(ReactOnRails).to receive_message_chain("configuration.server_bundle_output_path") + .and_return(nil) allow(ReactOnRails).to receive_message_chain("configuration.same_bundle_for_client_and_server") .and_return(false) mock_bundle_in_manifest("rsc-bundle.js", "webpack/development/server-bundle-123456.js")