rails-dev 0.1.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.
@@ -0,0 +1,215 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "English"
4
+ require "forwardable"
5
+
6
+ module RailsDev
7
+ class Session
8
+ extend Forwardable
9
+
10
+ attr_reader :root, :runtime, :error
11
+
12
+ def_delegators :@commands, :env, :run, :capture, :say, :check_for_interrupt
13
+ def_delegator :@config, :enabled?
14
+
15
+ def initialize(root, config, tailscale: false, output: $stdout)
16
+ @root = root
17
+ @config = config
18
+ @tailscale = tailscale
19
+ @commands = Commands.new(root, output: output, shutdown_timeout: config.shutdown_timeout + 1)
20
+ @state = State.new(@commands)
21
+ @hooks = Hooks.new(config, self)
22
+ end
23
+
24
+ def start
25
+ @commands.with_signals do
26
+ @state.with_run do |runtime|
27
+ begin_run(runtime)
28
+ prepare_environment
29
+ @hooks.around { start_application }
30
+ ensure
31
+ @error = $ERROR_INFO
32
+ stop
33
+ end
34
+ end
35
+ end
36
+
37
+ def url(name = @config.web)
38
+ @endpoints&.find { |endpoint| endpoint.name == name.to_s }&.url
39
+ end
40
+
41
+ private
42
+
43
+ def begin_run(runtime)
44
+ @runtime = runtime
45
+ @instance = Instance.new(runtime)
46
+ @commands.instance = @instance
47
+ record_run("starting", owner: ProcessTable.new.identity(Process.pid), processes: [],
48
+ shutdown_timeout: @config.shutdown_timeout + 1)
49
+ end
50
+
51
+ def record_run(status, **details)
52
+ @instance.record(name: @config.name, root: root, pid: Process.pid, status: status,
53
+ urls: Array(@endpoints).to_h { |endpoint| [endpoint.name, endpoint.url] }, **details)
54
+ end
55
+
56
+ def prepare_environment
57
+ env.merge!(@config.env)
58
+ @config.validate_connection_environment(env) if @config.compose_file
59
+ env.merge!("RAILS_ENV" => "development", "RACK_ENV" => "development", "RAILS_DEV_RUNTIME" => runtime,
60
+ "DEV_HOST" => "127.0.0.1", "PIDFILE" => File.join(runtime, "rails.pid"))
61
+ @foreman = Foreman.new(root, @config)
62
+ @endpoints = @foreman.endpoints
63
+ prepare_proxy if @tailscale || @config.https
64
+ end
65
+
66
+ def prepare_proxy
67
+ @proxy = (@tailscale ? Tailscale : Portless).new(@commands)
68
+ @proxy.check
69
+ end
70
+
71
+ def start_application
72
+ @state.synchronize("checkout:#{root}") do
73
+ check_existing_rails
74
+ prepare_services
75
+ start_web
76
+ end
77
+ @hooks.call(:after_ready)
78
+ report_ready
79
+ supervise
80
+ ensure
81
+ @error = $ERROR_INFO
82
+ @commands.stop
83
+ end
84
+
85
+ def check_existing_rails
86
+ path = File.join(root, "tmp/pids/server.pid")
87
+ return unless File.file?(path)
88
+
89
+ pid = File.read(path).to_i
90
+ return unless pid.positive?
91
+
92
+ Process.kill(0, pid)
93
+ raise Error, "Rails is already running for this checkout. Stop it before starting rails-dev."
94
+ rescue Errno::ESRCH
95
+ nil
96
+ end
97
+
98
+ def prepare_services
99
+ if @config.compose_file
100
+ compose = Compose.new(@config, @commands, @state, runtime)
101
+ env.merge!(compose.start)
102
+ @state.synchronize("database:#{compose.lock_key}") { @hooks.call(:before_start) }
103
+ else
104
+ @hooks.call(:before_start)
105
+ end
106
+ end
107
+
108
+ def start_web
109
+ @state.synchronize("web-ports") do
110
+ 5.times do
111
+ ports = []
112
+ @endpoints.each do |endpoint|
113
+ endpoint.allocate(excluding: ports)
114
+ ports << endpoint.port
115
+ end
116
+ return if launch_web
117
+
118
+ @commands.say("A selected port was taken during startup; selecting ports again.")
119
+ @commands.stop
120
+ end
121
+ raise Error, "Ports were repeatedly taken during startup. Try starting again."
122
+ end
123
+ end
124
+
125
+ def launch_web
126
+ @deadline = @commands.monotonic_time + @config.startup_timeout
127
+ @processes = []
128
+ web = @endpoints.find { |endpoint| endpoint.name == @config.web.to_s }
129
+ @proxy&.start(@config.name, endpoints: @endpoints, web: web) do |process|
130
+ @processes << process
131
+ return false unless await_processes
132
+ end
133
+ start_foreman(web)
134
+ await_processes(check_endpoints: true)
135
+ end
136
+
137
+ def start_foreman(web)
138
+ @endpoints.each { |endpoint| env.merge!(endpoint.environment) }
139
+ env.merge!("RAILS_PORT" => web.port.to_s, "RAILS_DEV_URL" => web.url)
140
+ @application = @commands.start("foreman", @foreman.command, keep_stdin_open: true)
141
+ @processes << @application
142
+ end
143
+
144
+ def await_processes(check_endpoints: false)
145
+ loop do
146
+ @commands.check_for_interrupt
147
+ return false if startup_failed?
148
+ return true if ready?(check_endpoints)
149
+
150
+ raise Error, startup_timeout(check_endpoints) if @commands.monotonic_time >= @deadline
151
+
152
+ sleep 0.05
153
+ end
154
+ end
155
+
156
+ def startup_failed?
157
+ failed = @processes.find { |process| !process.running? }
158
+ return false unless failed
159
+ return true if failed.startup_conflict? && (failed != @application || ports_taken?)
160
+
161
+ process_failed(failed, "during startup")
162
+ end
163
+
164
+ def ports_taken?
165
+ @endpoints.any? { |endpoint| Ports.available(endpoint.port) != endpoint.port }
166
+ end
167
+
168
+ def process_failed(process, phase)
169
+ status = process.status.exitstatus || (128 + process.status.termsig)
170
+ raise Error.new("#{process.name} exited #{phase} (#{process.exit_reason}). See its output above.",
171
+ status: status.zero? ? 1 : status)
172
+ end
173
+
174
+ def ready?(check_endpoints)
175
+ @processes.all?(&:ready?) && (!check_endpoints || @endpoints.map { |endpoint| endpoint.ready?(@deadline) }.all?)
176
+ end
177
+
178
+ def startup_timeout(check_endpoints)
179
+ pending = @processes.filter_map do |process|
180
+ "#{process.name}: readiness message not received" unless process.ready?
181
+ end
182
+ pending.concat(@endpoints.filter_map(&:readiness_failure)) if check_endpoints
183
+ ["Startup timed out after #{@config.startup_timeout}s.", *pending,
184
+ "Check the process output and readiness settings."].join("\n")
185
+ end
186
+
187
+ def report_ready
188
+ @commands.record_processes
189
+ record_run("ready")
190
+ @commands.say("Ready: #{url}")
191
+ @endpoints.reject { |endpoint| endpoint.name == @config.web.to_s }.each do |endpoint|
192
+ @commands.say("#{endpoint.name}: #{endpoint.url}")
193
+ end
194
+ @commands.say("Press Ctrl+C to stop the app processes.")
195
+ end
196
+
197
+ def supervise
198
+ loop do
199
+ @commands.check_for_interrupt
200
+ failed = @processes.find { |process| !process.running? }
201
+ return if failed == @application && failed.status.success?
202
+
203
+ process_failed(failed, "while running") if failed
204
+ sleep 0.05
205
+ end
206
+ end
207
+
208
+ def stop
209
+ @commands.stop
210
+ ensure
211
+ @error ||= $ERROR_INFO
212
+ @hooks.after_stop(error)
213
+ end
214
+ end
215
+ end
@@ -0,0 +1,60 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+ require "fileutils"
5
+ require "json"
6
+ require "tmpdir"
7
+
8
+ module RailsDev
9
+ class State
10
+ attr_reader :directory
11
+
12
+ def initialize(commands = nil, base: ENV.fetch("XDG_STATE_HOME", File.join(Dir.home, ".local/state")))
13
+ @commands = commands
14
+ @directory = File.join(base, "rails-dev")
15
+ end
16
+
17
+ def with_run
18
+ FileUtils.mkdir_p(directory, mode: 0o700)
19
+ Dir.mktmpdir("run-", directory) do |runtime|
20
+ File.open(File.join(runtime, "owner.lock"), File::RDWR | File::CREAT, 0o600) do |owner|
21
+ owner.flock(File::LOCK_EX)
22
+ FileUtils.cp(File.join(__dir__, "vite.mjs"), File.join(runtime, "vite.mjs"))
23
+ yield runtime
24
+ end
25
+ end
26
+ end
27
+
28
+ def runs
29
+ instances.filter_map(&:snapshot)
30
+ .sort_by { |run| run.values_at("name", "root", "pid") }
31
+ end
32
+
33
+ def stop(id, root: nil)
34
+ instance = instances.find { |run| run.id == id }
35
+ if !instance || (root && instance.snapshot&.fetch("root") != root)
36
+ raise Error, "Unknown instance: #{id}. See rails-dev list."
37
+ end
38
+
39
+ synchronize("stop:#{id}") { instance.stop }
40
+ end
41
+
42
+ def synchronize(key)
43
+ FileUtils.mkdir_p(directory, mode: 0o700)
44
+ path = File.join(directory, "#{Digest::SHA256.hexdigest(key)}.lock")
45
+ File.open(path, File::RDWR | File::CREAT, 0o600) do |file|
46
+ until file.flock(File::LOCK_EX | File::LOCK_NB)
47
+ @commands&.check_for_interrupt
48
+ sleep 0.05
49
+ end
50
+ yield
51
+ end
52
+ end
53
+
54
+ private
55
+
56
+ def instances
57
+ Dir.glob(File.join(directory, "run-*/instance.json")).map { |path| Instance.new(File.dirname(path)) }
58
+ end
59
+ end
60
+ end
@@ -0,0 +1,59 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "uri"
4
+
5
+ module RailsDev
6
+ class Tailscale
7
+ def initialize(commands)
8
+ @commands = commands
9
+ end
10
+
11
+ def check
12
+ status = JSON.parse(@commands.capture("tailscale", "status", "--json"))
13
+ node = status["Self"] || {}
14
+ @hostname = node.fetch("DNSName", "").delete_suffix(".")
15
+ unless status["BackendState"] == "Running" && !@hostname.empty?
16
+ raise Error, "Connect Tailscale before using --tailscale. Run tailscale status."
17
+ end
18
+
19
+ return if https_enabled?(node)
20
+
21
+ raise Error, "Enable HTTPS certificates in your tailnet's DNS settings before using --tailscale."
22
+ end
23
+
24
+ def start(_name, endpoints:, **)
25
+ endpoints.zip(available_ports(endpoints.size)).each do |endpoint, https|
26
+ endpoint.url = origin(https)
27
+ yield @commands.start("tailscale-#{endpoint.name}",
28
+ ["tailscale", "serve", "--bg=false", "--https=#{https}", "http://127.0.0.1:#{endpoint.port}"],
29
+ ready_message: "Press Ctrl+C to exit.")
30
+ end
31
+ end
32
+
33
+ private
34
+
35
+ def https_enabled?(node)
36
+ capabilities = Array(node["Capabilities"]) + node.fetch("CapMap", {}).keys
37
+ capabilities.any? { |capability| capability == "https" || capability.end_with?("/https") }
38
+ end
39
+
40
+ def available_ports(count)
41
+ config = JSON.parse(@commands.capture("tailscale", "serve", "status", "--json"))
42
+ used = [config, *config.fetch("Foreground", {}).values].flat_map { |entry| used_ports(entry) }
43
+ ports = [443].chain(8443..65_535).lazy.reject { |port| used.include?(port) }.take(count).to_a
44
+ raise Error, "Tailscale needs #{count} available HTTPS ports." unless ports.size == count
45
+
46
+ ports
47
+ end
48
+
49
+ def used_ports(config)
50
+ config.fetch("TCP", {}).keys.map(&:to_i) + config.fetch("AllowFunnel", {}).filter_map do |host, enabled|
51
+ host.split(":").last.to_i if enabled
52
+ end
53
+ end
54
+
55
+ def origin(port)
56
+ URI::HTTPS.build(host: @hostname, port: port).to_s
57
+ end
58
+ end
59
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsDev
4
+ VERSION = "0.1.0"
5
+ end
@@ -0,0 +1,31 @@
1
+ import { join } from 'node:path'
2
+
3
+ export default function railsDev() {
4
+ const runtime = process.env.RAILS_DEV_RUNTIME
5
+ const origin = new URL(process.env.RAILS_DEV_VITE_URL)
6
+
7
+ return {
8
+ rails: { devMetaFile: join(runtime, 'rails-vite.json') },
9
+ plugin: {
10
+ name: 'rails-dev',
11
+ apply: 'serve',
12
+ config: () => ({
13
+ cacheDir: join(runtime, 'vite-cache'),
14
+ define: {
15
+ 'import.meta.env.VITE_RAILS_DEV_COOKIE_PREFIX': JSON.stringify(`rails-dev-${process.env.RAILS_PORT}-`),
16
+ },
17
+ server: {
18
+ host: '127.0.0.1',
19
+ origin: origin.origin,
20
+ allowedHosts: [origin.hostname],
21
+ cors: { origin: process.env.RAILS_DEV_URL },
22
+ ws: {
23
+ protocol: origin.protocol === 'https:' ? 'wss' : 'ws',
24
+ host: origin.hostname,
25
+ clientPort: Number(origin.port || (origin.protocol === 'https:' ? 443 : 80)),
26
+ },
27
+ },
28
+ }),
29
+ },
30
+ }
31
+ }
data/lib/rails_dev.rb ADDED
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "rails_dev/version"
4
+
5
+ module RailsDev
6
+ class Error < StandardError
7
+ attr_reader :status
8
+
9
+ def initialize(message, status: 1)
10
+ @status = status
11
+ super(message)
12
+ end
13
+ end
14
+
15
+ class Interrupted < Error
16
+ attr_reader :signal
17
+
18
+ def initialize(signal)
19
+ @signal = signal
20
+ super("Stopped.")
21
+ end
22
+ end
23
+
24
+ def self.configure
25
+ raise Error, "Configuration must be loaded by rails-dev." unless @configuration
26
+
27
+ yield @configuration
28
+ end
29
+
30
+ def self.load_configuration(configuration, paths)
31
+ @configuration = configuration
32
+ paths.each { |path| load path if File.file?(path) }
33
+ ensure
34
+ @configuration = nil
35
+ end
36
+ end
37
+
38
+ require_relative "rails_dev/configuration"
39
+ require_relative "rails_dev/ports"
40
+ require_relative "rails_dev/endpoint"
41
+ require_relative "rails_dev/foreman"
42
+ require_relative "rails_dev/output"
43
+ require_relative "rails_dev/process_table"
44
+ require_relative "rails_dev/instance"
45
+ require_relative "rails_dev/child"
46
+ require_relative "rails_dev/commands"
47
+ require_relative "rails_dev/state"
48
+ require_relative "rails_dev/service_ports"
49
+ require_relative "rails_dev/compose"
50
+ require_relative "rails_dev/services"
51
+ require_relative "rails_dev/portless"
52
+ require_relative "rails_dev/tailscale"
53
+ require_relative "rails_dev/hooks"
54
+ require_relative "rails_dev/session"
55
+ require_relative "rails_dev/cli"
metadata ADDED
@@ -0,0 +1,141 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: rails-dev
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Ali Hamdi Ali Fadel
8
+ bindir: exe
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: bundler
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - ">="
17
+ - !ruby/object:Gem::Version
18
+ version: '2.5'
19
+ - - "<"
20
+ - !ruby/object:Gem::Version
21
+ version: '5'
22
+ type: :runtime
23
+ prerelease: false
24
+ version_requirements: !ruby/object:Gem::Requirement
25
+ requirements:
26
+ - - ">="
27
+ - !ruby/object:Gem::Version
28
+ version: '2.5'
29
+ - - "<"
30
+ - !ruby/object:Gem::Version
31
+ version: '5'
32
+ - !ruby/object:Gem::Dependency
33
+ name: foreman
34
+ requirement: !ruby/object:Gem::Requirement
35
+ requirements:
36
+ - - "~>"
37
+ - !ruby/object:Gem::Version
38
+ version: '0.90'
39
+ type: :runtime
40
+ prerelease: false
41
+ version_requirements: !ruby/object:Gem::Requirement
42
+ requirements:
43
+ - - "~>"
44
+ - !ruby/object:Gem::Version
45
+ version: '0.90'
46
+ - !ruby/object:Gem::Dependency
47
+ name: json
48
+ requirement: !ruby/object:Gem::Requirement
49
+ requirements:
50
+ - - "~>"
51
+ - !ruby/object:Gem::Version
52
+ version: '2.7'
53
+ type: :runtime
54
+ prerelease: false
55
+ version_requirements: !ruby/object:Gem::Requirement
56
+ requirements:
57
+ - - "~>"
58
+ - !ruby/object:Gem::Version
59
+ version: '2.7'
60
+ - !ruby/object:Gem::Dependency
61
+ name: thor
62
+ requirement: !ruby/object:Gem::Requirement
63
+ requirements:
64
+ - - "~>"
65
+ - !ruby/object:Gem::Version
66
+ version: '1.5'
67
+ type: :runtime
68
+ prerelease: false
69
+ version_requirements: !ruby/object:Gem::Requirement
70
+ requirements:
71
+ - - "~>"
72
+ - !ruby/object:Gem::Version
73
+ version: '1.5'
74
+ email:
75
+ - aliosm1997@gmail.com
76
+ executables:
77
+ - rails-dev
78
+ extensions: []
79
+ extra_rdoc_files: []
80
+ files:
81
+ - CHANGELOG.md
82
+ - LICENSE.txt
83
+ - README.md
84
+ - docs/reference.md
85
+ - exe/rails-dev
86
+ - lib/rails_dev.rb
87
+ - lib/rails_dev/child.rb
88
+ - lib/rails_dev/cli.rb
89
+ - lib/rails_dev/cli/base.rb
90
+ - lib/rails_dev/cli/main.rb
91
+ - lib/rails_dev/cli/services.rb
92
+ - lib/rails_dev/cli/shell.rb
93
+ - lib/rails_dev/commands.rb
94
+ - lib/rails_dev/compose.rb
95
+ - lib/rails_dev/configuration.rb
96
+ - lib/rails_dev/cookies.rb
97
+ - lib/rails_dev/endpoint.rb
98
+ - lib/rails_dev/foreman.rb
99
+ - lib/rails_dev/hooks.rb
100
+ - lib/rails_dev/instance.rb
101
+ - lib/rails_dev/output.rb
102
+ - lib/rails_dev/portless.mjs
103
+ - lib/rails_dev/portless.rb
104
+ - lib/rails_dev/ports.rb
105
+ - lib/rails_dev/process_table.rb
106
+ - lib/rails_dev/rails_vite.rb
107
+ - lib/rails_dev/railtie.rb
108
+ - lib/rails_dev/service_ports.rb
109
+ - lib/rails_dev/services.rb
110
+ - lib/rails_dev/session.rb
111
+ - lib/rails_dev/state.rb
112
+ - lib/rails_dev/tailscale.rb
113
+ - lib/rails_dev/version.rb
114
+ - lib/rails_dev/vite.mjs
115
+ homepage: https://github.com/milkstrawai/rails-dev
116
+ licenses:
117
+ - MIT
118
+ metadata:
119
+ allowed_push_host: https://rubygems.org
120
+ homepage_uri: https://github.com/milkstrawai/rails-dev
121
+ source_code_uri: https://github.com/milkstrawai/rails-dev
122
+ changelog_uri: https://github.com/milkstrawai/rails-dev/blob/main/CHANGELOG.md
123
+ rubygems_mfa_required: 'true'
124
+ rdoc_options: []
125
+ require_paths:
126
+ - lib
127
+ required_ruby_version: !ruby/object:Gem::Requirement
128
+ requirements:
129
+ - - ">="
130
+ - !ruby/object:Gem::Version
131
+ version: '3.4'
132
+ required_rubygems_version: !ruby/object:Gem::Requirement
133
+ requirements:
134
+ - - ">="
135
+ - !ruby/object:Gem::Version
136
+ version: '0'
137
+ requirements: []
138
+ rubygems_version: 3.6.9
139
+ specification_version: 4
140
+ summary: Available ports, HTTPS, and Tailscale for Rails development with Foreman.
141
+ test_files: []