Skip to content
Closed
Original file line number Diff line number Diff line change
Expand Up @@ -43,8 +43,14 @@ ReactOnRails.configure do |config|
#
config.server_bundle_js_file = "server-bundle.js"

# ⚠️ IMPORTANT: This must match output.path in config/webpack/serverWebpackConfig.js
#
# Both are currently set to 'ssr-generated' (relative to Rails.root)
# Keeping these in sync ensures React on Rails can find the server bundle at runtime.
#
# Configure where server bundles are output. Defaults to "ssr-generated".
# This should match your webpack configuration for server bundles.
# This path is relative to Rails.root and should point to a private directory
# (outside of public/) for security.
config.server_bundle_output_path = "ssr-generated"

# Enforce that server bundles are only loaded from private (non-public) directories.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -44,9 +44,15 @@ const configureServer = () => {
};
serverWebpackConfig.plugins.unshift(new bundler.optimize.LimitChunkCountPlugin({ maxChunks: 1 }));

// 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
// Custom output for the server-bundle
// ⚠️ IMPORTANT: This output.path must match server_bundle_output_path in
// config/initializers/react_on_rails.rb
//
// Both are currently set to 'ssr-generated' (relative to Rails.root)
// Keeping these in sync ensures React on Rails can find the server bundle at runtime.
//
// Server bundles are output to a private directory (not public) for security.
// This prevents server-side code from being exposed via the web server.
serverWebpackConfig.output = {
filename: 'server-bundle.js',
globalObject: 'this',
Expand Down
100 changes: 99 additions & 1 deletion lib/react_on_rails/doctor.rb
Original file line number Diff line number Diff line change
Expand Up @@ -664,6 +664,7 @@ def check_react_on_rails_initializer
end
end

# rubocop:disable Metrics/CyclomaticComplexity
def analyze_server_rendering_config(content)
checker.add_info("\n🖥️ Server Rendering:")

Expand All @@ -675,6 +676,18 @@ def analyze_server_rendering_config(content)
checker.add_info(" server_bundle_js_file: server-bundle.js (default)")
end

# Server bundle output path
server_bundle_path_match = content.match(/config\.server_bundle_output_path\s*=\s*["']([^"']+)["']/)
rails_bundle_path = server_bundle_path_match ? server_bundle_path_match[1] : "ssr-generated"
checker.add_info(" server_bundle_output_path: #{rails_bundle_path}")

# Enforce private server bundles
enforce_private_match = content.match(/config\.enforce_private_server_bundles\s*=\s*([^\s\n,]+)/)
checker.add_info(" enforce_private_server_bundles: #{enforce_private_match[1]}") if enforce_private_match

# Validate webpack config matches Rails config
validate_server_bundle_path_sync(rails_bundle_path)

# RSC bundle file (Pro feature)
rsc_bundle_match = content.match(/config\.rsc_bundle_js_file\s*=\s*["']([^"']+)["']/)
if rsc_bundle_match
Expand All @@ -699,7 +712,7 @@ def analyze_server_rendering_config(content)

checker.add_info(" raise_on_prerender_error: #{raise_on_error_match[1]}")
end
# rubocop:enable Metrics/AbcSize
# rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity

# rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity
def analyze_performance_config(content)
Expand Down Expand Up @@ -1144,6 +1157,91 @@ def safe_display_config_value(label, config, method_name)
checker.add_info(" #{label}: <error reading value: #{e.message}>")
end
end

# Validates that webpack serverWebpackConfig.js output.path matches
# React on Rails config.server_bundle_output_path
def validate_server_bundle_path_sync(rails_bundle_path)
webpack_config_path = "config/webpack/serverWebpackConfig.js"

unless File.exist?(webpack_config_path)
checker.add_info("\n ℹ️ Webpack server config not found - skipping path validation")
return
end

begin
webpack_content = File.read(webpack_config_path)

# Try to extract the path from webpack config
webpack_bundle_path = extract_webpack_output_path(webpack_content, webpack_config_path)

return unless webpack_bundle_path

# Normalize and compare paths
normalized_webpack_path = normalize_path(webpack_bundle_path)
normalized_rails_path = normalize_path(rails_bundle_path)

if normalized_webpack_path == normalized_rails_path
checker.add_success("\n ✅ Webpack and Rails configs are in sync (both use '#{rails_bundle_path}')")
else
checker.add_warning(<<~MSG.strip)
\n ⚠️ Configuration mismatch detected!

React on Rails config (config/initializers/react_on_rails.rb):
server_bundle_output_path = "#{rails_bundle_path}"

Webpack config (#{webpack_config_path}):
output.path = "#{webpack_bundle_path}" (relative to Rails.root)

These must match for server rendering to work correctly.

To fix:
1. Update server_bundle_output_path in config/initializers/react_on_rails.rb, OR
2. Update output.path in #{webpack_config_path}

Make sure both point to the same directory relative to Rails.root.
MSG
end
rescue StandardError => e
checker.add_info("\n ℹ️ Could not validate webpack config: #{e.message}")
end
end

# Extract output.path from webpack config, supporting multiple patterns
def extract_webpack_output_path(webpack_content, _webpack_config_path)
# Pattern 1: path: require('path').resolve(__dirname, '../../ssr-generated')
hardcoded_pattern = %r{path:\s*require\(['"]path['"]\)\.resolve\(__dirname,\s*['"]\.\./\.\./([^'"]+)['"]\)}
if (match = webpack_content.match(hardcoded_pattern))
return match[1]
end

# Pattern 2: path: config.outputPath (can't validate - runtime value)
if webpack_content.match?(/path:\s*config\.outputPath/)
checker.add_info(<<~MSG.strip)
\n ℹ️ Webpack config uses config.outputPath (from shakapacker.yml)
Cannot validate sync with Rails config as this is resolved at build time.
Ensure your shakapacker.yml public_output_path matches server_bundle_output_path.
MSG
return nil
end

# Pattern 3: path: some_variable (can't validate)
if webpack_content.match?(/path:\s*[a-zA-Z_]\w*/)
checker.add_info("\n ℹ️ Webpack config uses a variable for output.path - cannot validate")
return nil
end

checker.add_info("\n ℹ️ Could not parse webpack server bundle path - skipping validation")
nil
end

# Normalize path for comparison (remove leading ./, trailing /)
def normalize_path(path)
return path unless path.is_a?(String)

normalized = path.strip
normalized = normalized.sub(%r{^\.?/}, "") # Remove leading ./ or /
normalized.sub(%r{/$}, "") # Remove trailing /
end
end
# rubocop:enable Metrics/ClassLength
end
197 changes: 197 additions & 0 deletions spec/lib/react_on_rails/doctor_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -188,4 +188,201 @@
end
end
end

describe "server bundle path validation" do
let(:doctor) { described_class.new }
let(:checker) { doctor.instance_variable_get(:@checker) }

before do
allow(checker).to receive(:add_info)
allow(checker).to receive(:add_success)
allow(checker).to receive(:add_warning)
end

describe "#validate_server_bundle_path_sync" do
context "when webpack config file doesn't exist" do
before do
allow(File).to receive(:exist?).with("config/webpack/serverWebpackConfig.js").and_return(false)
end

it "adds info message and skips validation" do
expected_msg = "\n ℹ️ Webpack server config not found - skipping path validation"
expect(checker).to receive(:add_info).with(expected_msg)
doctor.send(:validate_server_bundle_path_sync, "ssr-generated")
end
end

context "when webpack config uses hardcoded path" do
let(:webpack_content) do
<<~JS
serverWebpackConfig.output = {
filename: 'server-bundle.js',
path: require('path').resolve(__dirname, '../../ssr-generated'),
};
JS
end

before do
allow(File).to receive(:exist?).with("config/webpack/serverWebpackConfig.js").and_return(true)
allow(File).to receive(:read).with("config/webpack/serverWebpackConfig.js").and_return(webpack_content)
end

it "reports success when paths match" do
expected_msg = "\n ✅ Webpack and Rails configs are in sync (both use 'ssr-generated')"
expect(checker).to receive(:add_success).with(expected_msg)
doctor.send(:validate_server_bundle_path_sync, "ssr-generated")
end

it "reports warning when paths don't match" do
expect(checker).to receive(:add_warning).with(/Configuration mismatch detected/)
doctor.send(:validate_server_bundle_path_sync, "server-bundles")
end

it "includes both paths in warning when mismatched" do
expect(checker).to receive(:add_warning) do |msg|
expect(msg).to include('server_bundle_output_path = "server-bundles"')
expect(msg).to include('output.path = "ssr-generated"')
end
doctor.send(:validate_server_bundle_path_sync, "server-bundles")
end
end

context "when webpack config uses config.outputPath" do
let(:webpack_content) do
<<~JS
serverWebpackConfig.output = {
filename: 'server-bundle.js',
path: config.outputPath,
};
JS
end

before do
allow(File).to receive(:exist?).with("config/webpack/serverWebpackConfig.js").and_return(true)
allow(File).to receive(:read).with("config/webpack/serverWebpackConfig.js").and_return(webpack_content)
end

it "reports that it cannot validate" do
expect(checker).to receive(:add_info).with(/Webpack config uses config\.outputPath/)
doctor.send(:validate_server_bundle_path_sync, "ssr-generated")
end

it "does not report success or warning" do
expect(checker).not_to receive(:add_success)
expect(checker).not_to receive(:add_warning)
doctor.send(:validate_server_bundle_path_sync, "ssr-generated")
end
end

context "when webpack config uses a variable" do
let(:webpack_content) do
<<~JS
const outputPath = calculatePath();
serverWebpackConfig.output = {
path: outputPath,
};
JS
end

before do
allow(File).to receive(:exist?).with("config/webpack/serverWebpackConfig.js").and_return(true)
allow(File).to receive(:read).with("config/webpack/serverWebpackConfig.js").and_return(webpack_content)
end

it "reports that it cannot validate" do
expect(checker).to receive(:add_info).with(/Webpack config uses a variable/)
doctor.send(:validate_server_bundle_path_sync, "ssr-generated")
end
end

context "when webpack config reading fails" do
before do
allow(File).to receive(:exist?).with("config/webpack/serverWebpackConfig.js").and_return(true)
allow(File).to receive(:read).and_raise(StandardError, "Permission denied")
end

it "handles error gracefully" do
expect(checker).to receive(:add_info).with(/Could not validate webpack config: Permission denied/)
doctor.send(:validate_server_bundle_path_sync, "ssr-generated")
end
end
end

describe "#extract_webpack_output_path" do
context "with hardcoded path pattern" do
let(:webpack_content) do
"path: require('path').resolve(__dirname, '../../my-bundle-dir')"
end

it "extracts the path" do
result = doctor.send(:extract_webpack_output_path, webpack_content, "config/webpack/test.js")
expect(result).to eq("my-bundle-dir")
end
end

context "with config.outputPath" do
let(:webpack_content) { "path: config.outputPath" }

it "returns nil and adds info message" do
expect(checker).to receive(:add_info).with(/config\.outputPath/)
result = doctor.send(:extract_webpack_output_path, webpack_content, "config/webpack/test.js")
expect(result).to be_nil
end
end

context "with variable" do
let(:webpack_content) { "path: myPath" }

it "returns nil and adds info message" do
expect(checker).to receive(:add_info).with(/variable/)
result = doctor.send(:extract_webpack_output_path, webpack_content, "config/webpack/test.js")
expect(result).to be_nil
end
end

context "with unrecognized pattern" do
let(:webpack_content) { "output: {}" }

it "returns nil and adds info message" do
expect(checker).to receive(:add_info).with(/Could not parse/)
result = doctor.send(:extract_webpack_output_path, webpack_content, "config/webpack/test.js")
expect(result).to be_nil
end
end
end

describe "#normalize_path" do
it "removes leading ./" do
expect(doctor.send(:normalize_path, "./ssr-generated")).to eq("ssr-generated")
end

it "removes leading /" do
expect(doctor.send(:normalize_path, "/ssr-generated")).to eq("ssr-generated")
end

it "removes trailing /" do
expect(doctor.send(:normalize_path, "ssr-generated/")).to eq("ssr-generated")
end

it "handles paths with both leading and trailing slashes" do
expect(doctor.send(:normalize_path, "./ssr-generated/")).to eq("ssr-generated")
end

it "strips whitespace" do
expect(doctor.send(:normalize_path, " ssr-generated ")).to eq("ssr-generated")
end

it "returns unchanged path if already normalized" do
expect(doctor.send(:normalize_path, "ssr-generated")).to eq("ssr-generated")
end

it "handles nil gracefully" do
expect(doctor.send(:normalize_path, nil)).to be_nil
end

it "handles non-string values gracefully" do
expect(doctor.send(:normalize_path, 123)).to eq(123)
end
end
end
end
Loading