react_on_rails 16.6.0 → 16.7.0.rc.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (63) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop.yml +1 -0
  3. data/Gemfile.development_dependencies +2 -2
  4. data/Gemfile.lock +2 -14
  5. data/Rakefile +0 -6
  6. data/Steepfile +4 -0
  7. data/lib/generators/react_on_rails/base_generator.rb +4 -4
  8. data/lib/generators/react_on_rails/demo_page_config.rb +3 -3
  9. data/lib/generators/react_on_rails/dev_tests_generator.rb +1 -1
  10. data/lib/generators/react_on_rails/generator_helper.rb +6 -65
  11. data/lib/generators/react_on_rails/generator_messages/ci_section.rb +42 -0
  12. data/lib/generators/react_on_rails/generator_messages/package_manager_detection.rb +194 -0
  13. data/lib/generators/react_on_rails/generator_messages/shakapacker_status_section.rb +61 -0
  14. data/lib/generators/react_on_rails/generator_messages.rb +22 -79
  15. data/lib/generators/react_on_rails/install_generator.rb +243 -28
  16. data/lib/generators/react_on_rails/js_dependency_manager.rb +7 -4
  17. data/lib/generators/react_on_rails/pro/USAGE +1 -1
  18. data/lib/generators/react_on_rails/pro_generator.rb +206 -183
  19. data/lib/generators/react_on_rails/pro_setup.rb +102 -26
  20. data/lib/generators/react_on_rails/react_with_redux_generator.rb +3 -2
  21. data/lib/generators/react_on_rails/templates/base/base/.env.example +25 -0
  22. data/lib/generators/react_on_rails/templates/base/base/.github/workflows/ci.yml.tt +86 -0
  23. data/lib/generators/react_on_rails/templates/base/base/Procfile.dev +4 -3
  24. data/lib/generators/react_on_rails/templates/base/base/babel.config.js.tt +1 -1
  25. data/lib/generators/react_on_rails/templates/base/base/bin/switch-bundler +2 -2
  26. data/lib/generators/react_on_rails/templates/base/base/config/webpack/ServerClientOrBoth.js.tt +1 -1
  27. data/lib/generators/react_on_rails/templates/base/base/config/webpack/clientWebpackConfig.js.tt +1 -1
  28. data/lib/generators/react_on_rails/templates/base/base/config/webpack/commonWebpackConfig.js.tt +2 -2
  29. data/lib/generators/react_on_rails/templates/base/base/config/webpack/development.js.tt +1 -1
  30. data/lib/generators/react_on_rails/templates/base/base/config/webpack/production.js.tt +1 -1
  31. data/lib/generators/react_on_rails/templates/base/base/config/webpack/serverWebpackConfig.js.tt +6 -5
  32. data/lib/generators/react_on_rails/templates/base/base/config/webpack/test.js.tt +1 -1
  33. data/lib/generators/react_on_rails/templates/pro/base/config/initializers/react_on_rails_pro.rb.tt +1 -1
  34. data/lib/generators/react_on_rails/templates/pro/base/{client → renderer}/node-renderer.js +1 -0
  35. data/lib/react_on_rails/config_path_resolver.rb +101 -4
  36. data/lib/react_on_rails/configuration.rb +22 -0
  37. data/lib/react_on_rails/dev/file_manager.rb +135 -8
  38. data/lib/react_on_rails/dev/port_selector.rb +259 -7
  39. data/lib/react_on_rails/dev/process_manager.rb +29 -2
  40. data/lib/react_on_rails/dev/server_manager.rb +607 -39
  41. data/lib/react_on_rails/doctor.rb +513 -45
  42. data/lib/react_on_rails/helper.rb +3 -11
  43. data/lib/react_on_rails/js_code_builder.rb +66 -0
  44. data/lib/react_on_rails/length_prefixed_parser.rb +142 -0
  45. data/lib/react_on_rails/packs_generator.rb +65 -12
  46. data/lib/react_on_rails/pro_migration.rb +175 -0
  47. data/lib/react_on_rails/render_request.rb +74 -0
  48. data/lib/react_on_rails/rendering_strategy/exec_js_strategy.rb +29 -0
  49. data/lib/react_on_rails/rendering_strategy.rb +44 -0
  50. data/lib/react_on_rails/server_rendering_pool/ruby_embedded_java_script.rb +33 -22
  51. data/lib/react_on_rails/system_checker.rb +44 -23
  52. data/lib/react_on_rails/utils.rb +5 -0
  53. data/lib/react_on_rails/version.rb +1 -1
  54. data/lib/react_on_rails.rb +3 -0
  55. data/rakelib/run_rspec.rake +0 -5
  56. data/rakelib/shakapacker_examples.rake +66 -23
  57. data/react_on_rails.gemspec +18 -8
  58. data/sig/react_on_rails/js_code_builder.rbs +11 -0
  59. data/sig/react_on_rails/render_request.rbs +28 -0
  60. data/sig/react_on_rails/rendering_strategy/exec_js_strategy.rbs +11 -0
  61. data/sig/react_on_rails/rendering_strategy.rbs +7 -0
  62. data/sig/react_on_rails.rbs +6 -0
  63. metadata +31 -10
@@ -1,8 +1,27 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "open3"
4
+ require "socket"
5
+
3
6
  module ReactOnRails
4
7
  module Dev
5
8
  class FileManager
9
+ # Bounded probe so a stuck server with a full accept queue (rare for a
10
+ # local overmind socket but theoretically possible) cannot stall
11
+ # bin/dev startup indefinitely.
12
+ #
13
+ # Two paths consume this budget differently:
14
+ # - synchronous failure (typical): UNIX socket connect() to a dead
15
+ # listener fails in microseconds, so 150 ms is far more than needed.
16
+ # - async wait_writable: if connect() returns IO::WaitWritable, the
17
+ # full 150 ms can be consumed waiting for the kernel to mark the
18
+ # socket writable. This is the actual budget for slow-loopback or
19
+ # paused-PID cases — not "microseconds".
20
+ # Result: 150 ms is conservative for the common path and the cap for
21
+ # the worst case.
22
+ SOCKET_PROBE_TIMEOUT_SECS = 0.15
23
+ private_constant :SOCKET_PROBE_TIMEOUT_SECS
24
+
6
25
  class << self
7
26
  def cleanup_stale_files
8
27
  socket_cleanup = cleanup_overmind_sockets
@@ -13,13 +32,18 @@ module ReactOnRails
13
32
 
14
33
  private
15
34
 
35
+ # Targets overmind-named sockets only (`.overmind.sock` at the project root and
36
+ # `tmp/sockets/overmind*.sock` for copied/renamed variants). Inactive matches are
37
+ # removed on startup. Other apps' Unix sockets in `tmp/sockets/` (Puma, Action
38
+ # Cable, custom services) are left untouched even when stale, so a tight
39
+ # startup race window cannot delete a socket bin/dev does not own.
16
40
  def cleanup_overmind_sockets
17
- return false if overmind_running?
18
-
19
- socket_files = [".overmind.sock", "tmp/sockets/overmind.sock"]
41
+ socket_files = [".overmind.sock", *Dir.glob("tmp/sockets/overmind*.sock")].uniq
20
42
  cleaned_any = false
21
43
 
22
44
  socket_files.each do |socket_file|
45
+ next if socket_active?(socket_file)
46
+
23
47
  cleaned_any = true if remove_file_if_exists(socket_file, "stale socket")
24
48
  end
25
49
 
@@ -43,13 +67,15 @@ module ReactOnRails
43
67
  return true
44
68
  end
45
69
 
46
- return false if process_running?(pid)
70
+ unless process_running?(pid)
71
+ remove_file_if_exists(server_pid_file, "stale Rails pid file")
72
+ return true
73
+ end
47
74
 
48
- remove_file_if_exists(server_pid_file, "stale Rails pid file")
49
- end
75
+ pid_working_directory = working_directory_for_pid(pid)
76
+ return false if pid_working_directory.nil? || same_working_directory?(pid_working_directory, Dir.pwd)
50
77
 
51
- def overmind_running?
52
- !`pgrep -f "overmind" 2>/dev/null`.split("\n").empty?
78
+ remove_file_if_exists(server_pid_file, "stale Rails pid file from another app directory")
53
79
  end
54
80
 
55
81
  def process_running?(pid)
@@ -63,6 +89,107 @@ module ReactOnRails
63
89
  true
64
90
  end
65
91
 
92
+ def socket_active?(socket_path)
93
+ return false unless File.exist?(socket_path)
94
+
95
+ # Pre-pack the sockaddr in a narrow rescue so the only ArgumentError
96
+ # we swallow is the one Ruby raises for "too long unix socket path"
97
+ # (sun_path is capped at ~104/108 bytes). The wider connect block
98
+ # below intentionally does NOT catch ArgumentError, so a programming
99
+ # mistake in Socket.new or connect_nonblock surfaces instead of
100
+ # silently returning false.
101
+ begin
102
+ sockaddr = Socket.sockaddr_un(socket_path)
103
+ rescue ArgumentError
104
+ return false
105
+ end
106
+
107
+ socket = nil
108
+ begin
109
+ socket = Socket.new(Socket::AF_UNIX, Socket::SOCK_STREAM, 0)
110
+ socket.connect_nonblock(sockaddr)
111
+ true
112
+ rescue IO::WaitWritable
113
+ # connect is in progress — wait up to SOCKET_PROBE_TIMEOUT_SECS for
114
+ # writability, then check SO_ERROR to distinguish accepted vs.
115
+ # refused/timed-out connections. Uses socket.wait_writable rather
116
+ # than IO.select so a Fiber scheduler can interleave properly.
117
+ ready = socket.wait_writable(SOCKET_PROBE_TIMEOUT_SECS)
118
+ return false unless ready
119
+
120
+ socket.getsockopt(Socket::SOL_SOCKET, Socket::SO_ERROR).int.zero?
121
+ rescue Errno::EISCONN
122
+ true
123
+ rescue SystemCallError, IOError
124
+ false
125
+ ensure
126
+ socket&.close
127
+ end
128
+ end
129
+
130
+ # Two-stage lookup: try the zero-dependency `/proc/PID/cwd` readlink first
131
+ # (Linux), then fall back to `lsof` (macOS, BSD, Linux without /proc visibility).
132
+ # The /proc path matters for minimal Alpine/CI containers where `lsof`
133
+ # is not installed by default — without it, this method would silently
134
+ # return nil on every container and leave stale PID files behind.
135
+ #
136
+ # The lsof call uses Open3.capture2 with a word-list argv (matching
137
+ # ServerManager#find_port_pids) so the no-shell-injection invariant is
138
+ # structural rather than caller-enforced.
139
+ #
140
+ # Returns nil when both probes fail:
141
+ # - /proc not present (macOS, BSD) and `lsof` absent (Errno::ENOENT) — minimal containers,
142
+ # - the kernel withholds info (permission denied, process exited mid-probe),
143
+ # - any other unexpected error.
144
+ # The nil return causes `cleanup_rails_pid_file` to keep the PID file as a safe
145
+ # fallback. Set DEBUG=1 to surface the failure on stderr.
146
+ def working_directory_for_pid(pid)
147
+ proc_cwd = working_directory_via_proc(pid)
148
+ return proc_cwd if proc_cwd
149
+
150
+ working_directory_via_lsof(pid)
151
+ end
152
+
153
+ def working_directory_via_proc(pid)
154
+ # File.readlink either returns a non-empty String or raises.
155
+ File.readlink("/proc/#{pid}/cwd")
156
+ rescue Errno::ENOENT, Errno::EACCES, Errno::EPERM, NotImplementedError
157
+ # /proc not present (macOS, BSD), no permission to read this PID's cwd,
158
+ # or readlink unsupported on this platform. Fall through to lsof.
159
+ nil
160
+ end
161
+
162
+ def working_directory_via_lsof(pid)
163
+ stdout, = Open3.capture2("lsof", "-a", "-p", pid.to_s, "-d", "cwd", "-Fn", err: File::NULL)
164
+ path_line = stdout.lines.find { |line| line.start_with?("n") }
165
+ path = path_line&.delete_prefix("n")&.strip
166
+ return nil if path.nil? || path.empty?
167
+
168
+ path
169
+ rescue Errno::ENOENT => e
170
+ # `lsof` binary missing — expected on minimal images. DEBUG-gated so
171
+ # users on Linux/macOS without the package don't see noise.
172
+ log_lsof_missing(e) if ENV["DEBUG"]
173
+ nil
174
+ rescue StandardError => e
175
+ # Genuinely unexpected: resource limits (Errno::EMFILE), interrupted
176
+ # syscall (Errno::EINTR), permission errors, etc. Always-warn so the
177
+ # cross-directory PID check can't silently misfire under load.
178
+ warn "bin/dev: could not determine working directory for PID #{pid} " \
179
+ "(#{e.class}: #{e.message})."
180
+ nil
181
+ end
182
+
183
+ def log_lsof_missing(error)
184
+ warn "bin/dev: `lsof` not found; cross-directory PID check skipped (#{error.message})."
185
+ end
186
+
187
+ def same_working_directory?(left, right)
188
+ File.realpath(left) == File.realpath(right)
189
+ rescue SystemCallError
190
+ File.expand_path(left) == File.expand_path(right)
191
+ end
192
+
66
193
  def remove_file_if_exists(file_path, description)
67
194
  return false unless File.exist?(file_path)
68
195
 
@@ -9,13 +9,66 @@ module ReactOnRails
9
9
  DEFAULT_WEBPACK_PORT = 3035
10
10
  MAX_ATTEMPTS = 100
11
11
 
12
+ # Offsets from the base port when REACT_ON_RAILS_BASE_PORT (or a recognized
13
+ # tool-specific equivalent like CONDUCTOR_PORT) is set. The base port block
14
+ # is typically 10 consecutive ports allocated per workspace.
15
+ BASE_PORT_RAILS_OFFSET = 0
16
+ BASE_PORT_WEBPACK_OFFSET = 1
17
+ BASE_PORT_RENDERER_OFFSET = 2
18
+ TCP_PORT_MAX = 65_535
19
+ MAX_BASE_PORT = TCP_PORT_MAX - BASE_PORT_RENDERER_OFFSET
20
+
21
+ # Ports 1..1023 are privileged on Linux/macOS and require root to bind.
22
+ PRIVILEGED_PORT_MAX = 1023
23
+
24
+ # Env vars checked (in order) for a base port value.
25
+ #
26
+ # CONDUCTOR_PORT is an empirical interpretation based on Conductor.build
27
+ # (https://conductor.build) allocating a block of consecutive ports per
28
+ # workspace and exposing the block base via this env var. This contract
29
+ # is not in a public Conductor API, so treat CONDUCTOR_PORT support as
30
+ # best-effort until Conductor documents it. If a future release changes
31
+ # the meaning (e.g. CONDUCTOR_PORT becomes the Rails port itself rather
32
+ # than a block base), the derived offsets below will land on the wrong
33
+ # ports — users would see port-conflict failures at runtime rather than
34
+ # a clear misconfiguration error. A future "validate derived ports are
35
+ # reachable on startup" path could surface this earlier.
36
+ #
37
+ # Escape hatch: REACT_ON_RAILS_BASE_PORT takes precedence, so users can
38
+ # override the CONDUCTOR_PORT interpretation without code changes.
39
+ BASE_PORT_ENV_VARS = %w[REACT_ON_RAILS_BASE_PORT CONDUCTOR_PORT].freeze
40
+
12
41
  class NoPortAvailable < StandardError; end
13
42
 
14
43
  class << self
15
- # Returns { rails: Integer, webpack: Integer }.
16
- # Respects existing ENV['PORT'] / ENV['SHAKAPACKER_DEV_SERVER_PORT'].
17
- # Probes for free ports when either or both env vars are unset.
18
- def select_ports
44
+ # Returns { rails: Integer, webpack: Integer, renderer: Integer|nil,
45
+ # base_port_mode: Boolean }.
46
+ #
47
+ # Priority:
48
+ # 1. Base port (REACT_ON_RAILS_BASE_PORT or CONDUCTOR_PORT) — all ports
49
+ # derived deterministically from the base; no probing.
50
+ # 2. Explicit per-service env vars (PORT, SHAKAPACKER_DEV_SERVER_PORT).
51
+ # 3. Auto-detect free ports starting from defaults.
52
+ #
53
+ # The :renderer key is populated only when a base port is set (it is a
54
+ # Pro-only service and does not participate in auto-detection).
55
+ # :base_port_mode is true only in case 1.
56
+ #
57
+ # NOTE: This method mutates ENV.
58
+ # @side_effect Deletes invalid PORT / SHAKAPACKER_DEV_SERVER_PORT
59
+ # values via `read_and_sanitize_port_env!` so ServerManager's
60
+ # apply_explicit_port_env path doesn't re-warn on the same bad
61
+ # value. Intended for `bin/dev` startup; do not call from
62
+ # read-only contexts that expect ENV to survive the call. See
63
+ # `read_and_sanitize_port_env!` (which uses the `!` suffix to make
64
+ # the mutation explicit at the inner call site).
65
+ # @param pro_renderer [Boolean] when false, suppresses the renderer
66
+ # port-in-use warning so OSS apps without a node renderer don't
67
+ # see "port X (renderer)" noise on a coincidentally-bound base+2.
68
+ def select_ports!(pro_renderer: true)
69
+ base = base_port_ports(pro_renderer: pro_renderer)
70
+ return base if base
71
+
19
72
  rails_port = explicit_rails_port
20
73
  webpack_port = explicit_webpack_port
21
74
 
@@ -30,7 +83,16 @@ module ReactOnRails
30
83
  puts "Default ports in use. Using Rails :#{rails_port}, webpack :#{webpack_port}"
31
84
  end
32
85
 
33
- { rails: rails_port, webpack: webpack_port }
86
+ { rails: rails_port, webpack: webpack_port, renderer: nil, base_port_mode: false }
87
+ end
88
+
89
+ # Deprecated alias for the pre-bang name. Kept as a safety net for any
90
+ # external caller (generator extension, host-app rake task) that wired
91
+ # to `select_ports` before the rename. The bang form is preferred — it
92
+ # surfaces the ENV-mutation side effect at the call site, which was
93
+ # the whole point of the rename. Remove in a future major release.
94
+ def select_ports(**kwargs)
95
+ select_ports!(**kwargs)
34
96
  end
35
97
 
36
98
  # Public so it can be stubbed in tests.
@@ -52,6 +114,48 @@ module ReactOnRails
52
114
  end
53
115
  end
54
116
 
117
+ # Returns the base-port-derived port hash when a base port env var is
118
+ # set (with the same shape as #select_ports!), otherwise nil. Does not
119
+ # fall back to per-service env vars or auto-detect, so callers can
120
+ # branch on "is base-port mode active?" without triggering probing.
121
+ # Used by ServerManager so all bin/dev modes (development, static,
122
+ # production-like) honor the base-port contract consistently.
123
+ #
124
+ # Logs the detected base port and warns on derived-port collisions.
125
+ # Callers that need the derived ports without user-facing output
126
+ # (e.g. ServerManager#kill_processes, which shouldn't print a banner
127
+ # while killing) should use #base_port_hash instead.
128
+ def base_port_ports(pro_renderer: true)
129
+ bp, source = base_port_with_source
130
+ return nil unless bp
131
+
132
+ ports = derive_ports_from_base(bp)
133
+ source_note = if source == "CONDUCTOR_PORT"
134
+ " (unofficial contract; set REACT_ON_RAILS_BASE_PORT to override)"
135
+ else
136
+ ""
137
+ end
138
+ renderer_segment = pro_renderer ? ", renderer :#{ports[:renderer]}" : ""
139
+ puts "Base port #{bp} detected via #{source}#{source_note}. Using Rails :#{ports[:rails]}, " \
140
+ "webpack :#{ports[:webpack]}#{renderer_segment}"
141
+ warn_if_derived_ports_in_use(bp, ports, source: source, pro_renderer: pro_renderer)
142
+ ports
143
+ end
144
+
145
+ # Pure derivation: returns the same port hash as #base_port_ports but
146
+ # without the "Base port X detected" log line or the derived-port
147
+ # collision warnings. Safe to call from any context where logging is
148
+ # undesirable (e.g. kill flows). Still delegates to
149
+ # #base_port_with_source, which surfaces invalid-value warnings — those
150
+ # describe the env input, not the port output, and are desirable even
151
+ # in silent callers.
152
+ def base_port_hash
153
+ bp, _source = base_port_with_source
154
+ return nil unless bp
155
+
156
+ derive_ports_from_base(bp)
157
+ end
158
+
55
159
  def find_available_port(start_port, exclude: nil)
56
160
  MAX_ATTEMPTS.times do |i|
57
161
  port = start_port + i
@@ -63,14 +167,162 @@ module ReactOnRails
63
167
  raise NoPortAvailable, "No available port found starting at #{start_port}."
64
168
  end
65
169
 
170
+ # Strict port-string predicate shared with ServerManager so the two
171
+ # layers can't silently diverge. `String#to_i` would otherwise truncate
172
+ # `"3000abc"` to 3000 and slip it through here while ServerManager's
173
+ # overwrite path rejected it.
174
+ def valid_port_string?(value)
175
+ return false if value.nil?
176
+
177
+ stripped = value.to_s.strip
178
+ return false if stripped.empty?
179
+ return false unless stripped.match?(/\A\d+\z/)
180
+
181
+ stripped.to_i.between?(1, TCP_PORT_MAX)
182
+ end
183
+
66
184
  private
67
185
 
186
+ def derive_ports_from_base(base)
187
+ {
188
+ rails: base + BASE_PORT_RAILS_OFFSET,
189
+ webpack: base + BASE_PORT_WEBPACK_OFFSET,
190
+ renderer: base + BASE_PORT_RENDERER_OFFSET,
191
+ base_port_mode: true
192
+ }
193
+ end
194
+
195
+ # Advisory: surface early conflicts when a base port's derived ports are
196
+ # already bound (e.g. two worktrees share a base). Does not fail — the
197
+ # actual bind at server start gives the definitive error.
198
+ #
199
+ # Skips the renderer port when `pro_renderer` is false: OSS apps don't
200
+ # run a node renderer, so "port base+2 (renderer) is already in use"
201
+ # would be confusing noise on a coincidental collision with an
202
+ # unrelated local service.
203
+ #
204
+ # When the base came from CONDUCTOR_PORT and the *Rails* port (base+0)
205
+ # is taken, append a hint that Conductor's contract is unofficial —
206
+ # this is the most likely failure mode if Conductor ever changes
207
+ # CONDUCTOR_PORT to mean "the Rails port" rather than "a block base"
208
+ # (the derived ports would silently land on whatever the user already
209
+ # has bound).
210
+ def warn_if_derived_ports_in_use(base, ports, source: nil, pro_renderer: true)
211
+ roles = pro_renderer ? %i[rails webpack renderer] : %i[rails webpack]
212
+ roles.each do |role|
213
+ port_num = ports[role]
214
+ next if port_available?(port_num)
215
+
216
+ hint = if role == :rails && source == "CONDUCTOR_PORT"
217
+ " If your Conductor workspace exposes CONDUCTOR_PORT as the Rails port " \
218
+ "rather than a block base, set REACT_ON_RAILS_BASE_PORT explicitly to override."
219
+ else
220
+ ""
221
+ end
222
+ warn "WARNING: port #{port_num} (#{role}, derived from base #{base}) is already in use.#{hint}"
223
+ end
224
+ end
225
+
226
+ # Returns [val, source_var] when a valid base port env var is set,
227
+ # otherwise nil. The source var is included so callers can surface it
228
+ # in user-facing log lines (helpful when CONDUCTOR_PORT vs.
229
+ # REACT_ON_RAILS_BASE_PORT activated base-port mode).
230
+ def base_port_with_source
231
+ # Upper bound accounts for the largest derived offset so base + N stays
232
+ # within the valid TCP port range (1..65_535).
233
+ #
234
+ # Strip before validating so whitespace-padded values (common with
235
+ # copy-paste or env-file templating) parse the same way PORT and
236
+ # SHAKAPACKER_DEV_SERVER_PORT do via read_and_sanitize_port_env!.
237
+ BASE_PORT_ENV_VARS.each_with_index do |var, idx|
238
+ raw = ENV.fetch(var, nil)
239
+ next if raw.nil?
240
+
241
+ stripped = raw.strip
242
+ next if stripped.empty?
243
+
244
+ unless stripped.match?(/\A\d+\z/)
245
+ warn invalid_base_port_warning(var, raw, "not a valid integer", idx)
246
+ next
247
+ end
248
+
249
+ val = stripped.to_i
250
+ unless val.between?(1, MAX_BASE_PORT)
251
+ reason = "out of range (1..#{MAX_BASE_PORT}; must leave room for " \
252
+ "+#{BASE_PORT_RENDERER_OFFSET} renderer offset)"
253
+ warn invalid_base_port_warning(var, raw, reason, idx)
254
+ next
255
+ end
256
+
257
+ if val <= PRIVILEGED_PORT_MAX
258
+ warn "WARNING: #{var}=#{raw.inspect} is in the privileged range " \
259
+ "(1..#{PRIVILEGED_PORT_MAX}); binding will fail without root."
260
+ end
261
+
262
+ return [val, var]
263
+ end
264
+ nil
265
+ end
266
+
267
+ # Invalid REACT_ON_RAILS_BASE_PORT silently falls through to CONDUCTOR_PORT
268
+ # (or any future later entry). Surface the fallthrough in the warning so
269
+ # users who set a non-integer to "disable" base port mode realize they
270
+ # also need to unset the next var.
271
+ #
272
+ # Filter `remaining` against the same validity rules as `base_port_ports`
273
+ # (numeric + 1..MAX_BASE_PORT) so we never promise activation from a
274
+ # var that the validator will also reject — e.g.
275
+ # `REACT_ON_RAILS_BASE_PORT="disabled"` + `CONDUCTOR_PORT="abc"` must
276
+ # not say "will still activate from CONDUCTOR_PORT".
277
+ def invalid_base_port_warning(var, raw, reason, idx)
278
+ msg = "WARNING: #{var}=#{raw.inspect} is #{reason}; ignoring."
279
+ remaining = BASE_PORT_ENV_VARS[(idx + 1)..].select do |v|
280
+ val = ENV.fetch(v, "").strip
281
+ val.match?(/\A\d+\z/) && val.to_i.between?(1, MAX_BASE_PORT)
282
+ end
283
+ return msg if remaining.empty?
284
+
285
+ msg + " Base port mode will still activate from #{remaining.join(', ')}; " \
286
+ "unset to disable entirely."
287
+ end
288
+
68
289
  def explicit_rails_port
69
- ENV["PORT"]&.to_i&.then { |p| p.between?(1, 65_535) ? p : nil }
290
+ read_and_sanitize_port_env!("PORT")
70
291
  end
71
292
 
72
293
  def explicit_webpack_port
73
- ENV["SHAKAPACKER_DEV_SERVER_PORT"]&.to_i&.then { |p| p.between?(1, 65_535) ? p : nil }
294
+ read_and_sanitize_port_env!("SHAKAPACKER_DEV_SERVER_PORT")
295
+ end
296
+
297
+ # Reject values that aren't valid port strings and clear the env var
298
+ # so ServerManager's apply_explicit_port_env path (which also rejects
299
+ # them) doesn't emit a second warning for the same value.
300
+ #
301
+ # The `!` suffix signals the ENV-mutation side effect at the call site
302
+ # (explicit_rails_port / explicit_webpack_port); the "warn once + fall
303
+ # back" flow is shared with ServerManager via the cleared env, not via
304
+ # the return value. Kept in one place so the coupling is obvious.
305
+ def read_and_sanitize_port_env!(var_name)
306
+ raw = ENV.fetch(var_name, nil)
307
+ return nil if raw.nil?
308
+
309
+ stripped = raw.strip
310
+ return nil if stripped.empty?
311
+
312
+ unless stripped.match?(/\A\d+\z/)
313
+ warn "WARNING: #{var_name}=#{raw.inspect} is not a valid integer; ignoring."
314
+ ENV.delete(var_name)
315
+ return nil
316
+ end
317
+
318
+ n = stripped.to_i
319
+ unless n.between?(1, TCP_PORT_MAX)
320
+ warn "WARNING: #{var_name}=#{raw.inspect} is out of range (1..#{TCP_PORT_MAX}); ignoring."
321
+ ENV.delete(var_name)
322
+ return nil
323
+ end
324
+
325
+ n
74
326
  end
75
327
  end
76
328
  end
@@ -14,7 +14,29 @@ module ReactOnRails
14
14
  # before entering the block and pass them explicitly to system().
15
15
  # This follows the same pattern used by Rails' bundle_command (railties),
16
16
  # Spring's process spawning, and this codebase's own PackGenerator.
17
- ENV_KEYS_TO_PRESERVE = %w[PORT SHAKAPACKER_DEV_SERVER_PORT].freeze
17
+ #
18
+ # REACT_ON_RAILS_BASE_PORT and CONDUCTOR_PORT are intentionally excluded:
19
+ # by the time sub-processes spawn, configure_ports has already derived
20
+ # concrete values into PORT / SHAKAPACKER_DEV_SERVER_PORT / RENDERER_PORT /
21
+ # REACT_RENDERER_URL. Sub-processes should use those fixed ports rather
22
+ # than re-deriving from the base.
23
+ # SHAKAPACKER_SKIP_PRECOMPILE_HOOK is also runtime-only and must survive
24
+ # Bundler's env reset so nested shakapacker commands don't rerun the hook.
25
+ # RENDERER_URL is the legacy name for REACT_RENDERER_URL; preserved for
26
+ # mid-migration users (see ServerManager#warn_if_legacy_renderer_url_env_used).
27
+ # Inclusion here also matters when base-port mode scrubs the legacy var:
28
+ # `preserve_runtime_env_vars` returns `nil` (not the string "nil") for unset
29
+ # keys, which `Process.spawn`/`system` use to explicitly unset the variable
30
+ # in the child — preventing `with_unbundled_env` from resurrecting a stale
31
+ # pre-Bundler value. See the comment on `preserve_runtime_env_vars` below.
32
+ ENV_KEYS_TO_PRESERVE = %w[
33
+ PORT
34
+ SHAKAPACKER_DEV_SERVER_PORT
35
+ RENDERER_PORT
36
+ REACT_RENDERER_URL
37
+ RENDERER_URL
38
+ SHAKAPACKER_SKIP_PRECOMPILE_HOOK
39
+ ].freeze
18
40
 
19
41
  class << self
20
42
  # Check if a process is available and usable in the current execution context
@@ -180,9 +202,14 @@ module ReactOnRails
180
202
  MSG
181
203
  end
182
204
 
205
+ # Always include every key (nil when unset) so Process.spawn/system
206
+ # explicitly unsets it in the child. A non-nil-only hash would let
207
+ # `with_unbundled_env` restore a pre-Bundler value the parent had
208
+ # just deleted — e.g. an invalid `RENDERER_PORT=abc` that
209
+ # PortSelector scrubbed would resurrect in the renderer child.
183
210
  def preserve_runtime_env_vars
184
211
  ENV_KEYS_TO_PRESERVE.each_with_object({}) do |key, hash|
185
- hash[key] = ENV[key] if ENV[key]
212
+ hash[key] = ENV.fetch(key, nil)
186
213
  end
187
214
  end
188
215