Skip to content
Open
Show file tree
Hide file tree
Changes from 10 commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
3ebe213
fix: use ${PORT:-3000} in Procfile.dev template to avoid worktree por…
ihabadham Mar 5, 2026
7ce155b
fix: use ${PORT:-N} in Procfile variant templates
ihabadham Mar 5, 2026
5026b83
feat: add .env.example template for per-worktree port configuration
ihabadham Mar 5, 2026
e717cb8
feat: copy .env.example during generator install
ihabadham Mar 5, 2026
85abb9c
test: assert env-var ports and .env.example in base generator output
ihabadham Mar 5, 2026
60573e7
docs: document worktree port configuration with .env pattern
ihabadham Mar 5, 2026
f84641a
feat: add PortSelector for automatic free port detection
ihabadham Mar 5, 2026
bcc9a59
feat: wire PortSelector into ServerManager before process manager starts
ihabadham Mar 5, 2026
f98bd7e
docs: update worktree section — auto port detection is now zero-config
ihabadham Mar 5, 2026
71c8bd3
test: fill coverage gaps in PortSelector and procfile_port
ihabadham Mar 5, 2026
ad62225
test(generator): assert Procfile.dev-prod-assets uses env-var-driven …
ihabadham Mar 5, 2026
fd4806f
docs(generator): clarify PORT default differs between Procfile variants
ihabadham Mar 5, 2026
d09b9d8
docs(process-managers): fix stale process names in Procfile.dev snippet
ihabadham Mar 5, 2026
0bc50cd
docs(process-managers): warn direct foreman users about PORT injection
ihabadham Mar 5, 2026
47d1844
fix(server_manager): configure ports before printing the access URL b…
ihabadham Mar 5, 2026
d67ace7
refactor(port_selector): remove unused WEBPACK_OFFSET and deduplicate…
ihabadham Mar 5, 2026
f4eca17
docs(process-managers): add language tag to fenced code block (MD040)
ihabadham Mar 5, 2026
ee004b6
fix(server_manager): rescue NoPortAvailable in configure_ports for cl…
ihabadham Mar 5, 2026
5dd07fd
test(server_manager): restore ENV vars instead of deleting in port co…
ihabadham Mar 5, 2026
446ce06
docs(port_selector): document TOCTOU limitation in port_available?
ihabadham Mar 5, 2026
9ecb4be
fix(generator): add .env to .gitignore so port config is not accident…
ihabadham Mar 5, 2026
d120d53
test(server_manager): clear ENV before each example to prevent order-…
ihabadham Mar 5, 2026
f06b6ff
fix(port_selector): probe the unset side in single-ENV branches
ihabadham Mar 5, 2026
3305a64
fix(server_manager): pass auto-detected port to print_server_info in …
ihabadham Mar 5, 2026
bfec470
fix: probe Rails and webpack ports independently in find_free_pair
ihabadham Mar 5, 2026
5fe353a
fix(server_manager): use procfile_port in run_production_like banner
ihabadham Mar 5, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 23 additions & 1 deletion docs/building-features/process-managers.md
Original file line number Diff line number Diff line change
Expand Up @@ -327,11 +327,33 @@ Edit `Procfile.dev` in your project root to customize which processes run and th
The default `Procfile.dev` includes:

```procfile
rails: bundle exec rails s -p 3000
rails: bundle exec rails s -p ${PORT:-3000}
wp-client: bin/shakapacker-dev-server
wp-server: SERVER_BUNDLE_ONLY=true bin/shakapacker --watch
```

## Running Multiple Worktrees Simultaneously

If you use [git worktrees](https://git-scm.com/docs/git-worktree) to work on multiple branches
in parallel, `bin/dev` automatically detects and avoids port conflicts — no configuration needed.

When the default ports (3000 for Rails, 3035 for webpack-dev-server) are occupied, `bin/dev`
scans for the next free pair and prints:

```
Default ports in use. Using Rails :3001, webpack :3036
```

**To override ports manually**, create a `.env` file in the worktree (gitignored by default).
A `.env.example` is generated by `rails g react_on_rails:install` as a reference:

```sh
PORT=3001
SHAKAPACKER_DEV_SERVER_PORT=3036
```

When `PORT` or `SHAKAPACKER_DEV_SERVER_PORT` are set, auto-detection is skipped entirely.

## See Also

- [HMR and Hot Reloading](./hmr-and-hot-reloading-with-the-webpack-dev-server.md)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ def copy_base_files
Procfile.dev-static-assets
Procfile.dev-prod-assets
.dev-services.yml.example
.env.example
bin/shakapacker-precompile-hook]

# HelloServer uses the hello_world layout so React on Rails can inject generated
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
# Development environment configuration
# Copy this file to .env to override default ports.
#
# This is especially useful when running multiple git worktrees simultaneously.
# Each worktree needs its own .env with different ports to avoid conflicts.
#
# Shakapacker reads SHAKAPACKER_DEV_SERVER_PORT on both the Ruby (proxy) and
# JS (webpack-dev-server) sides, so no shakapacker.yml changes are needed.
#
# Example for a second worktree:
# PORT=3001
# SHAKAPACKER_DEV_SERVER_PORT=3036

# Rails server port (default: 3000, used by Procfile.dev)
# PORT=3000

# Webpack dev server port (default: 3035, used by shakapacker)
# SHAKAPACKER_DEV_SERVER_PORT=3035
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
# Procfile for development
# You can run these commands in separate shells
rails: bundle exec rails s -p 3000
#
# To run multiple worktrees simultaneously, set different ports in each worktree's .env:
# PORT=3001
# SHAKAPACKER_DEV_SERVER_PORT=3036
rails: bundle exec rails s -p ${PORT:-3000}
dev-server: bin/shakapacker-dev-server
server-bundle: SERVER_BUNDLE_ONLY=yes bin/shakapacker --watch
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The process names changed from wp-client/wp-server to dev-server/server-bundle. This is a breaking change for any user who:

  • Has scripts referencing the old names (e.g. overmind connect wp-client)
  • Uses overmind stop wp-server in CI or deployment hooks
  • Has IDE/editor integrations keyed to these names

Since these are generated template files (not existing user files), only fresh installs are affected — but the name change should be documented in the CHANGELOG under "Breaking Changes" or "Changed" so teams upgrading their generator output know to update any tooling references.

Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
# Uses production-optimized, precompiled assets with development environment
# Uncomment additional processes as needed for your app

rails: bundle exec rails s -p 3001
rails: bundle exec rails s -p ${PORT:-3001}
# sidekiq: bundle exec sidekiq -C config/sidekiq.yml
# redis: redis-server
# mailcatcher: mailcatcher --foreground
Original file line number Diff line number Diff line change
@@ -1,2 +1,2 @@
web: bin/rails server -p 3000
web: bin/rails server -p ${PORT:-3000}
js: bin/shakapacker --watch
1 change: 1 addition & 0 deletions react_on_rails/lib/react_on_rails/dev.rb
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
# frozen_string_literal: true

require_relative "dev/port_selector"
require_relative "dev/server_manager"
require_relative "dev/process_manager"
require_relative "dev/pack_generator"
Expand Down
74 changes: 74 additions & 0 deletions react_on_rails/lib/react_on_rails/dev/port_selector.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
# frozen_string_literal: true

require "socket"

module ReactOnRails
module Dev
class PortSelector
DEFAULT_RAILS_PORT = 3000
DEFAULT_WEBPACK_PORT = 3035
WEBPACK_OFFSET = DEFAULT_WEBPACK_PORT - DEFAULT_RAILS_PORT # 35
MAX_ATTEMPTS = 100

class NoPortAvailable < StandardError; end

class << self
# Returns { rails: Integer, webpack: Integer }.
# Respects existing ENV['PORT'] / ENV['SHAKAPACKER_DEV_SERVER_PORT'].
# Only probes when both are unset (i.e. user hasn't configured them).
def select_ports
rails_port = explicit_rails_port
webpack_port = explicit_webpack_port

# If both are explicitly set, trust the user completely
return { rails: rails_port, webpack: webpack_port } if rails_port && webpack_port

# If only one is set, use it as the anchor and return defaults for the other
return { rails: rails_port, webpack: explicit_webpack_port || DEFAULT_WEBPACK_PORT } if rails_port

return { rails: DEFAULT_RAILS_PORT, webpack: webpack_port } if webpack_port

# Neither set — auto-detect a free pair
find_free_pair
end

# Public so it can be stubbed in tests
def port_available?(port, host = "127.0.0.1")
server = TCPServer.new(host, port)
server.close
true
rescue Errno::EADDRINUSE, Errno::EACCES
false
end

private

def explicit_rails_port
ENV["PORT"]&.to_i&.then { |p| p.positive? ? p : nil }
end

def explicit_webpack_port
ENV["SHAKAPACKER_DEV_SERVER_PORT"]&.to_i&.then { |p| p.positive? ? p : nil }
end

def find_free_pair
MAX_ATTEMPTS.times do |i|
rails_port = DEFAULT_RAILS_PORT + i
webpack_port = DEFAULT_WEBPACK_PORT + i

next unless port_available?(rails_port) && port_available?(webpack_port)

puts "Default ports in use. Using Rails :#{rails_port}, webpack :#{webpack_port}" if i.positive?

return { rails: rails_port, webpack: webpack_port }
end

raise NoPortAvailable,
"No available port pair found in range " \
"#{DEFAULT_RAILS_PORT}--#{DEFAULT_RAILS_PORT + MAX_ATTEMPTS - 1}. " \
"Run 'bin/dev kill' to free up ports."
end
end
end
end
end
14 changes: 13 additions & 1 deletion react_on_rails/lib/react_on_rails/dev/server_manager.rb
Original file line number Diff line number Diff line change
Expand Up @@ -640,6 +640,7 @@ def run_static_development(procfile, verbose: false, route: nil, skip_database_c
route: route
)

configure_ports
PackGenerator.generate(verbose: verbose)
ProcessManager.ensure_procfile(procfile)
ProcessManager.run_with_process_manager(procfile)
Expand All @@ -654,6 +655,7 @@ def run_development(procfile, verbose: false, route: nil, skip_database_check: f
# Check required services before starting
exit 1 unless ServiceChecker.check_services

configure_ports
PackGenerator.generate(verbose: verbose)
ProcessManager.ensure_procfile(procfile)
ProcessManager.run_with_process_manager(procfile)
Expand Down Expand Up @@ -687,8 +689,18 @@ def print_procfile_info(procfile, route: nil)
puts ""
end

def configure_ports
selected = PortSelector.select_ports
ENV["PORT"] = selected[:rails].to_s
ENV["SHAKAPACKER_DEV_SERVER_PORT"] = selected[:webpack].to_s
end

def procfile_port(procfile)
procfile == "Procfile.dev-prod-assets" ? 3001 : 3000
if procfile == "Procfile.dev-prod-assets"
ENV.fetch("PORT", 3001).to_i
else
ENV.fetch("PORT", 3000).to_i
end
end

def box_border(width)
Expand Down
157 changes: 157 additions & 0 deletions react_on_rails/spec/react_on_rails/dev/port_selector_spec.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
# frozen_string_literal: true

require_relative "../spec_helper"
require "react_on_rails/dev/port_selector"
require "socket"

RSpec.describe ReactOnRails::Dev::PortSelector do
describe ".select_ports" do
context "when default ports are free" do
it "returns the default Rails port 3000" do
allow(described_class).to receive(:port_available?).and_return(true)
result = described_class.select_ports
expect(result[:rails]).to eq(3000)
end

it "returns the default webpack port 3035" do
allow(described_class).to receive(:port_available?).and_return(true)
result = described_class.select_ports
expect(result[:webpack]).to eq(3035)
end

it "does not print a shift message" do
allow(described_class).to receive(:port_available?).and_return(true)
expect { described_class.select_ports }.not_to output(/shifted/i).to_stdout
end
end

context "when default ports are occupied" do
it "finds the next free Rails port" do
call_count = 0
allow(described_class).to receive(:port_available?) do
call_count += 1
call_count > 1 # first check (3000) fails; && short-circuits so 3035 is never checked
end
result = described_class.select_ports
expect(result[:rails]).to eq(3001)
end

it "keeps webpack port offset from Rails port" do
call_count = 0
allow(described_class).to receive(:port_available?) do
call_count += 1
call_count > 1
end
result = described_class.select_ports
expect(result[:webpack]).to eq(3036)
end

it "prints a message when ports are shifted" do
call_count = 0
allow(described_class).to receive(:port_available?) do
call_count += 1
call_count > 1
end
expect { described_class.select_ports }.to output(/3001.*3036|shifted|in use/i).to_stdout
end
end

context "when ENV['PORT'] is already set" do
around do |example|
old = ENV.fetch("PORT", nil)
ENV["PORT"] = "4000"
example.run
ENV["PORT"] = old
end

it "respects the existing PORT env var" do
result = described_class.select_ports
expect(result[:rails]).to eq(4000)
end

it "defaults webpack to 3035 when SHAKAPACKER_DEV_SERVER_PORT is not set" do
result = described_class.select_ports
expect(result[:webpack]).to eq(3035)
end

it "does not probe for free ports" do
expect(described_class).not_to receive(:port_available?)
described_class.select_ports
end
end

context "when ENV['SHAKAPACKER_DEV_SERVER_PORT'] is already set" do
around do |example|
old = ENV.fetch("SHAKAPACKER_DEV_SERVER_PORT", nil)
ENV["SHAKAPACKER_DEV_SERVER_PORT"] = "4035"
example.run
ENV["SHAKAPACKER_DEV_SERVER_PORT"] = old
end

it "respects the existing SHAKAPACKER_DEV_SERVER_PORT env var" do
result = described_class.select_ports
expect(result[:webpack]).to eq(4035)
end

it "defaults Rails to 3000 when PORT is not set" do
result = described_class.select_ports
expect(result[:rails]).to eq(3000)
end

it "does not probe for free ports" do
expect(described_class).not_to receive(:port_available?)
described_class.select_ports
end
end

context "when both PORT and SHAKAPACKER_DEV_SERVER_PORT are set" do
around do |example|
old_port = ENV.fetch("PORT", nil)
old_wp = ENV.fetch("SHAKAPACKER_DEV_SERVER_PORT", nil)
ENV["PORT"] = "4000"
ENV["SHAKAPACKER_DEV_SERVER_PORT"] = "4035"
example.run
ENV["PORT"] = old_port
ENV["SHAKAPACKER_DEV_SERVER_PORT"] = old_wp
end

it "returns both explicit ports" do
result = described_class.select_ports
expect(result[:rails]).to eq(4000)
expect(result[:webpack]).to eq(4035)
end

it "does not probe for free ports" do
expect(described_class).not_to receive(:port_available?)
described_class.select_ports
end
end

context "when no port is available within max attempts" do
it "raises an error" do
allow(described_class).to receive(:port_available?).and_return(false)
expect { described_class.select_ports }.to raise_error(described_class::NoPortAvailable, /No available port/)
end
end
end

describe ".port_available?" do
it "returns true for a port that nothing is listening on" do
# Find a definitely-free port using OS assignment, then close it and check
server = TCPServer.new("127.0.0.1", 0)
free_port = server.addr[1]
server.close
expect(described_class.port_available?(free_port)).to be true
end

it "returns false for a port that is already in use" do
server = TCPServer.new("127.0.0.1", 0)
occupied_port = server.addr[1]
begin
expect(described_class.port_available?(occupied_port)).to be false
ensure
server.close
end
end
end
end
Loading
Loading