Skip to content
Open
Show file tree
Hide file tree
Changes from 22 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
45 changes: 42 additions & 3 deletions docs/building-features/process-managers.md
Original file line number Diff line number Diff line change
Expand Up @@ -320,18 +320,57 @@ overmind start -f Procfile.dev
foreman start -f Procfile.dev
```

> [!WARNING]
> When running Foreman directly (not via `bin/dev`), Foreman injects its own `PORT`
> environment variable (starting at 5000) into every subprocess. This causes
> `${PORT:-3000}` in `Procfile.dev` to evaluate to Foreman's injected value rather
> than the fallback 3000.
>
> To avoid this, set `PORT` explicitly in your shell or `.env` file before running
> Foreman:
>
> ```sh
> PORT=3000 foreman start -f Procfile.dev
> # or add PORT=3000 to your .env file
> ```
>
> `bin/dev` handles this automatically — port detection runs before Foreman starts,
> so `PORT` is always set correctly when Foreman launches.

## Customizing Your Setup

Edit `Procfile.dev` in your project root to customize which processes run and their configuration.

The default `Procfile.dev` includes:

```procfile
rails: bundle exec rails s -p 3000
wp-client: bin/shakapacker-dev-server
wp-server: SERVER_BUNDLE_ONLY=true bin/shakapacker --watch
rails: bundle exec rails s -p ${PORT:-3000}
dev-server: bin/shakapacker-dev-server
server-bundle: SERVER_BUNDLE_ONLY=yes 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:

```text
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 Expand Up @@ -145,11 +146,12 @@ def update_gitignore_for_auto_registration
additions = []
additions << "**/generated/**" unless gitignore_content.include?("**/generated/**")
additions << "ssr-generated" unless gitignore_content.include?("ssr-generated")
additions << ".env" unless gitignore_content.match?(/^\.env$/)

return if additions.empty?

append_to_file ".gitignore" do
lines = ["\n# Generated React on Rails packs"]
lines = ["\n# React on Rails"]
lines.concat(additions)
"#{lines.join("\n")}\n"
end
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 for Procfile.dev / 3001 for Procfile.dev-prod-assets)
# 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
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
76 changes: 76 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,76 @@
# frozen_string_literal: true

require "socket"

module ReactOnRails
module Dev
class PortSelector
DEFAULT_RAILS_PORT = 3000
DEFAULT_WEBPACK_PORT = 3035
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: 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.
# NOTE: Inherent TOCTOU race — another process can claim the port between
# server.close and the caller binding to it. This is unavoidable with the
# probe-then-use pattern and acceptable for the worktree port-selection use case.
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
27 changes: 22 additions & 5 deletions react_on_rails/lib/react_on_rails/dev/server_manager.rb
Original file line number Diff line number Diff line change
Expand Up @@ -614,14 +614,16 @@ def run_production_like(_verbose: false, route: nil, rails_env: nil, skip_databa
# rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity

def run_static_development(procfile, verbose: false, route: nil, skip_database_check: false)
print_procfile_info(procfile, route: route)

# Check database setup before starting
exit 1 unless DatabaseChecker.check_database(skip: skip_database_check)

# Check required services before starting
exit 1 unless ServiceChecker.check_services

# Configure ports before printing so the banner shows the correct URL
configure_ports
print_procfile_info(procfile, route: route)

features = [
"Using shakapacker --watch (no HMR)",
"CSS extracted to separate files (no FOUC)",
Expand All @@ -646,14 +648,16 @@ def run_static_development(procfile, verbose: false, route: nil, skip_database_c
end

def run_development(procfile, verbose: false, route: nil, skip_database_check: false)
print_procfile_info(procfile, route: route)

# Check database setup before starting
exit 1 unless DatabaseChecker.check_database(skip: skip_database_check)

# Check required services before starting
exit 1 unless ServiceChecker.check_services

# Configure ports before printing so the banner shows the correct URL
configure_ports
print_procfile_info(procfile, route: route)

PackGenerator.generate(verbose: verbose)
ProcessManager.ensure_procfile(procfile)
ProcessManager.run_with_process_manager(procfile)
Expand Down Expand Up @@ -687,8 +691,21 @@ 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
rescue PortSelector::NoPortAvailable => e
warn e.message
exit 1
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
Loading
Loading