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,145 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsDev
4
+ class Instance
5
+ def initialize(directory)
6
+ @directory = directory
7
+ @path = File.join(directory, "instance.json")
8
+ end
9
+
10
+ def id
11
+ File.basename(@directory)
12
+ end
13
+
14
+ def record(**details)
15
+ data = File.file?(@path) ? read : {}
16
+ File.write("#{@path}.tmp", JSON.generate(data.merge(details.transform_keys(&:to_s))), perm: 0o600)
17
+ File.rename("#{@path}.tmp", @path)
18
+ end
19
+
20
+ def record_processes(groups)
21
+ record(processes: ProcessTable.new.members(groups))
22
+ end
23
+
24
+ def snapshot
25
+ File.open(File.join(@directory, "owner.lock")) do |owner|
26
+ data = read.merge("id" => id)
27
+ data["status"] = interrupted_status(data) if owner.flock(File::LOCK_EX | File::LOCK_NB)
28
+ data
29
+ end
30
+ rescue Errno::ENOENT
31
+ nil
32
+ end
33
+
34
+ def stop
35
+ File.open(File.join(@directory, "owner.lock")) do |owner|
36
+ stop_owner(owner)
37
+ recover if File.file?(@path)
38
+ end
39
+ rescue Errno::ENOENT
40
+ nil
41
+ end
42
+
43
+ private
44
+
45
+ def read
46
+ JSON.parse(File.read(@path))
47
+ end
48
+
49
+ def interrupted_status(data)
50
+ records = data["processes"]
51
+ return "interrupted" unless records
52
+
53
+ table = ProcessTable.new
54
+ return "orphaned" if records.any? { |identity| table.include?(identity) }
55
+
56
+ table.members(process_groups(records)).empty? ? "stale" : "interrupted"
57
+ end
58
+
59
+ def process_groups(records)
60
+ records.map { |identity| identity[1] }.uniq
61
+ end
62
+
63
+ def stop_owner(owner)
64
+ return if owner.flock(File::LOCK_EX | File::LOCK_NB)
65
+
66
+ timeout = read.fetch("shutdown_timeout", 5)
67
+ signal_owner(owner, "TERM")
68
+ return if wait(timeout + 2) { owner.flock(File::LOCK_EX | File::LOCK_NB) }
69
+
70
+ signal_owner(owner, "KILL")
71
+ return if wait(2) { owner.flock(File::LOCK_EX | File::LOCK_NB) }
72
+
73
+ raise Error, "Instance #{id} is still stopping; retry with rails-dev stop #{id}."
74
+ end
75
+
76
+ def signal_owner(owner, signal)
77
+ data = read
78
+ identity = data["owner"]
79
+ if ProcessTable.new.include?(identity)
80
+ signal(signal, identity.first)
81
+ elsif !owner.flock(File::LOCK_EX | File::LOCK_NB)
82
+ raise Error, recovery_error("Cannot verify the launcher for #{id} (PID #{data.fetch("pid")}). " \
83
+ "Stop it from its original terminal.")
84
+ end
85
+ end
86
+
87
+ def recovery_error(message)
88
+ "#{message}\nInspect #{@path} and verify process ownership before manual cleanup."
89
+ end
90
+
91
+ def recover
92
+ data = read
93
+ @records = data.fetch("processes") do
94
+ raise Error, recovery_error("Cannot recover #{id}: process ownership was not recorded.")
95
+ end
96
+ @records = verified_members
97
+ record(processes: @records)
98
+ stop_groups(data.fetch("shutdown_timeout", 5))
99
+ FileUtils.remove_entry(@directory)
100
+ end
101
+
102
+ def verified_members
103
+ table = ProcessTable.new
104
+ members = table.members(process_groups(@records))
105
+ verified = process_groups(@records.select { |identity| table.include?(identity) })
106
+ unverified = process_groups(members) - verified
107
+ unless unverified.empty?
108
+ raise Error, recovery_error("Cannot verify process groups #{unverified.join(", ")} for #{id}. " \
109
+ "Recovery record kept.")
110
+ end
111
+
112
+ members
113
+ end
114
+
115
+ def stop_groups(timeout)
116
+ signal_groups("TERM")
117
+ return if wait(timeout) { verified_members.empty? }
118
+
119
+ signal_groups("KILL")
120
+ return if wait(2) { verified_members.empty? }
121
+
122
+ raise Error, "Instance #{id} is still stopping; retry with rails-dev stop #{id}."
123
+ end
124
+
125
+ def signal_groups(value)
126
+ process_groups(verified_members).each { |group| signal(value, -group) }
127
+ end
128
+
129
+ def signal(value, pid)
130
+ Process.kill(value, pid)
131
+ rescue Errno::ESRCH
132
+ nil
133
+ end
134
+
135
+ def wait(timeout)
136
+ deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout
137
+ loop do
138
+ return true if yield
139
+ return false if Process.clock_gettime(Process::CLOCK_MONOTONIC) >= deadline
140
+
141
+ sleep 0.05
142
+ end
143
+ end
144
+ end
145
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "delegate"
4
+
5
+ module RailsDev
6
+ class Output < SimpleDelegator
7
+ def write(*)
8
+ super
9
+ rescue Errno::EIO, Errno::EPIPE
10
+ 0
11
+ end
12
+
13
+ def puts(*)
14
+ super
15
+ rescue Errno::EIO, Errno::EPIPE
16
+ nil
17
+ end
18
+
19
+ def print(*)
20
+ super
21
+ rescue Errno::EIO, Errno::EPIPE
22
+ nil
23
+ end
24
+
25
+ def flush
26
+ super
27
+ rescue Errno::EIO, Errno::EPIPE
28
+ nil
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,28 @@
1
+ import { pathToFileURL } from 'node:url'
2
+
3
+ const [modulePath, directory, routeJSON] = process.argv.slice(2)
4
+ const { RouteStore } = await import(pathToFileURL(modulePath).href)
5
+ const store = new RouteStore(directory)
6
+ const routes = Object.entries(JSON.parse(routeJSON))
7
+
8
+ function stop() {
9
+ cleanup()
10
+ process.exit(0)
11
+ }
12
+
13
+ function cleanup() {
14
+ for (const [hostname] of routes) store.removeRoute(hostname, process.pid)
15
+ }
16
+
17
+ process.on('SIGTERM', stop)
18
+ process.on('SIGINT', stop)
19
+
20
+ try {
21
+ for (const [hostname, port] of routes) store.addRoute(hostname, port, process.pid)
22
+ setInterval(() => {}, 60_000)
23
+ console.log('HTTPS routes registered')
24
+ } catch (error) {
25
+ cleanup()
26
+ console.error(error.message)
27
+ process.exitCode = 1
28
+ }
@@ -0,0 +1,110 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "net/http"
4
+
5
+ module RailsDev
6
+ class Portless
7
+ VERSION = "0.15.6"
8
+
9
+ attr_reader :url
10
+
11
+ def initialize(commands)
12
+ @commands = commands
13
+ data = commands.env.fetch("XDG_DATA_HOME", File.join(Dir.home, ".local/share"))
14
+ @prefix = File.join(data, "rails-dev/portless", VERSION)
15
+ @package = File.join(@prefix, "lib/node_modules/portless")
16
+ @directory = File.expand_path(commands.env.fetch("PORTLESS_STATE_DIR", File.join(Dir.home, ".portless")))
17
+ commands.env["PORTLESS_STATE_DIR"] = @directory
18
+ end
19
+
20
+ def setup(port: 443)
21
+ raise Error, "Proxy port must be between 1 and 65535." unless (1..65_535).cover?(port)
22
+
23
+ @commands.with_signals { install }
24
+ @commands.env.merge!("PORTLESS_LAN" => "0", "PORTLESS_WILDCARD" => "0")
25
+ @commands.replace([*cli, "service", "install", "--https", "--port", port.to_s, "--tld", "localhost"])
26
+ end
27
+
28
+ def install
29
+ version = @commands.capture("node", "--version").delete_prefix("v").to_i
30
+ raise Error, "Portless requires Node.js 24 or newer." if version < 24
31
+ return if installed?
32
+
33
+ @commands.run("npm", "install", "--global", "--prefix", @prefix,
34
+ "--ignore-scripts", "--no-audit", "--no-fund", "portless@#{VERSION}")
35
+ end
36
+
37
+ def doctor
38
+ require_installation
39
+ @commands.replace([*cli, "doctor"])
40
+ end
41
+
42
+ def check
43
+ require_installation
44
+ @url = URI(@commands.capture(*cli, "get", "rails-dev", "--no-worktree").strip)
45
+ unless url.is_a?(URI::HTTPS) && url.host == "rails-dev.localhost"
46
+ raise Error, "The shared Portless proxy must use HTTPS and .localhost. Run rails-dev setup."
47
+ end
48
+
49
+ verify_proxy
50
+ end
51
+
52
+ def start(name, endpoints:, web:)
53
+ routes = endpoints.to_h do |endpoint|
54
+ host = hostname(name, endpoint, web)
55
+ endpoint.url = origin(host)
56
+ [host, endpoint.port]
57
+ end
58
+ @commands.env["NODE_EXTRA_CA_CERTS"] ||= File.join(@directory, "ca.pem")
59
+ yield @commands.start("https", ["node", File.join(__dir__, "portless.mjs"),
60
+ File.join(@package, "dist/index.js"), @directory, JSON.generate(routes)],
61
+ ready_message: "HTTPS routes registered")
62
+ end
63
+
64
+ private
65
+
66
+ def hostname(name, endpoint, web)
67
+ label = "#{name}-#{web.port}"
68
+ label += "-#{endpoint.name.downcase.tr("_", "-")}" unless endpoint == web
69
+ "#{label}.localhost"
70
+ end
71
+
72
+ def cli
73
+ ["node", File.join(@package, "dist/cli.js")]
74
+ end
75
+
76
+ def installed?
77
+ path = File.join(@package, "package.json")
78
+ File.file?(path) && JSON.parse(File.read(path))["version"] == VERSION
79
+ end
80
+
81
+ def require_installation
82
+ return if installed?
83
+
84
+ raise Error, "Portless #{VERSION} is not installed for rails-dev. Run rails-dev setup once on this machine."
85
+ end
86
+
87
+ def verify_proxy
88
+ return if proxy_connection.get("/")["X-Portless"] == "1"
89
+
90
+ raise Error, "The HTTPS listener is not Portless. Run rails-dev doctor."
91
+ rescue SystemCallError, IOError, OpenSSL::SSL::SSLError, Net::OpenTimeout, Net::ReadTimeout => error
92
+ raise Error, "Cannot reach the HTTPS proxy: #{error.message}. Run rails-dev setup or rails-dev doctor."
93
+ end
94
+
95
+ def proxy_connection
96
+ http = Net::HTTP.new(url.host, url.port, nil)
97
+ http.ipaddr = "127.0.0.1"
98
+ http.use_ssl = true
99
+ http.ca_file = File.join(@directory, "ca.pem")
100
+ http.open_timeout = http.read_timeout = 3
101
+ http
102
+ end
103
+
104
+ def origin(host)
105
+ uri = url.dup
106
+ uri.host = host
107
+ uri.to_s
108
+ end
109
+ end
110
+ end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "socket"
4
+
5
+ module RailsDev
6
+ module Ports
7
+ def self.available(first, excluding: [])
8
+ (first..65_535).each do |port|
9
+ next if excluding.include?(port)
10
+
11
+ begin
12
+ TCPServer.open("127.0.0.1", port) { return port }
13
+ rescue Errno::EADDRINUSE, Errno::EACCES
14
+ next
15
+ end
16
+ end
17
+ raise Error, "No available local port at or above #{first}."
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "open3"
4
+
5
+ module RailsDev
6
+ class ProcessTable
7
+ def initialize
8
+ output, status = Open3.capture2({ "LC_ALL" => "C", "TZ" => "UTC0" },
9
+ "ps", "-A", "-o", "pid=,pgid=,stat=,lstart=")
10
+ raise Error, "Cannot inspect running processes." unless status.success?
11
+
12
+ @entries = output.lines.filter_map do |line|
13
+ pid, group, state, started = line.strip.split(/\s+/, 4)
14
+ [pid.to_i, [pid.to_i, group.to_i, started]] unless state.start_with?("Z", "X")
15
+ end.to_h
16
+ rescue Errno::ENOENT
17
+ raise Error, "Cannot inspect running processes: ps is unavailable."
18
+ end
19
+
20
+ def identity(pid)
21
+ @entries[pid]
22
+ end
23
+
24
+ def include?(identity)
25
+ identity && self.identity(identity.first) == identity
26
+ end
27
+
28
+ def members(groups)
29
+ @entries.values.select { |entry| groups.include?(entry[1]) }
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/railtie"
4
+
5
+ module RailsDev
6
+ class RailsViteRailtie < Rails::Railtie
7
+ initializer "rails_dev.vite", before: :load_config_initializers do
8
+ next unless Rails.env.development? && ENV.fetch("RAILS_DEV_RUNTIME", nil) && ENV["RAILS_DEV_VITE_URL"]
9
+
10
+ RailsVite.config.dev_meta_path = Pathname.new(ENV.fetch("RAILS_DEV_RUNTIME")).join("rails-vite.json")
11
+ RailsVite.config.auto_build = false
12
+ end
13
+ end
14
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/railtie"
4
+ require "uri"
5
+ require_relative "cookies"
6
+
7
+ module RailsDev
8
+ class Railtie < Rails::Railtie
9
+ initializer "rails_dev.configure", before: :load_config_initializers do |app|
10
+ next unless Rails.env.development? && ENV.key?("RAILS_DEV_RUNTIME") && ENV.key?("RAILS_DEV_URL")
11
+
12
+ app.middleware.insert_before ActionDispatch::Cookies, Cookies, prefix: "rails-dev-#{ENV.fetch("RAILS_PORT")}-"
13
+
14
+ origin = URI(ENV.fetch("RAILS_DEV_URL"))
15
+ url_options = { host: origin.host, port: origin.port, protocol: origin.scheme }
16
+ app.config.hosts << origin.host
17
+ app.config.action_mailer.default_url_options = url_options
18
+ app.routes.default_url_options.merge!(url_options)
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,88 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsDev
4
+ class ServicePorts
5
+ attr_reader :ports
6
+
7
+ def initialize(services, config)
8
+ @config = config
9
+ @ports = services.to_h { |name, service| [name, service_ports(name, service)] }
10
+ validate_connections
11
+ end
12
+
13
+ def override
14
+ entries = ports.map do |name, mappings|
15
+ " #{name.to_json}:\n ports: !override #{mappings.to_json}\n"
16
+ end
17
+ entries += @config.exclude_services.map { |name| " #{name.to_json}: !reset null\n" }
18
+ "services:\n#{entries.join}"
19
+ end
20
+
21
+ def environment(endpoints)
22
+ @config.connections.to_h do |variable, mapping|
23
+ endpoint = endpoints.fetch([mapping[:service], mapping[:port], mapping[:protocol]])
24
+ value = mapping.fetch(:value).gsub("%{host}", endpoint.fetch(:host)).gsub("%{port}", endpoint.fetch(:port))
25
+ [variable, value]
26
+ end
27
+ end
28
+
29
+ private
30
+
31
+ def service_ports(name, service)
32
+ validate_service(name, service)
33
+ declared = declared_ports(service)
34
+ additional = @config.publish_ports.fetch(name, []).flat_map do |port|
35
+ target, protocol = port.to_s.split("/", 2)
36
+ expand(target, protocol || "tcp")
37
+ end
38
+ (declared + additional).uniq
39
+ end
40
+
41
+ def validate_service(name, service)
42
+ if service["network_mode"] == "host" || service.fetch("scale", 1) != 1 ||
43
+ service.dig("deploy", "replicas").to_i > 1
44
+ raise Error, "#{name}: host networking and multiple replicas are not supported."
45
+ end
46
+ end
47
+
48
+ def declared_ports(service)
49
+ service.fetch("ports", []).flat_map do |port|
50
+ expand(port.fetch("target"), port.fetch("protocol", "tcp"))
51
+ end
52
+ end
53
+
54
+ def expand(target, protocol)
55
+ match = /\A(\d+)(?:-(\d+))?\z/.match(target.to_s)
56
+ raise Error, "Invalid container port: #{target}/#{protocol}." unless match && %w[tcp udp].include?(protocol)
57
+
58
+ port_range(match).map { |port| { "target" => port, "host_ip" => "127.0.0.1", "protocol" => protocol } }
59
+ end
60
+
61
+ def port_range(match)
62
+ first = match[1].to_i
63
+ last = (match[2] || match[1]).to_i
64
+ unless first.positive? && last >= first && last <= 65_535
65
+ raise Error, "Invalid container port range: #{match[0]}."
66
+ end
67
+
68
+ first..last
69
+ end
70
+
71
+ def validate_connections
72
+ validate_publications
73
+ @config.connections.each do |variable, mapping|
74
+ port = ports.fetch(mapping.fetch(:service), [])
75
+ next if port.any? { |entry| entry["target"] == mapping[:port] && entry["protocol"] == mapping[:protocol] }
76
+
77
+ raise Error, "#{variable}: mapped port is not published by an active service. " \
78
+ "Use Compose ports or publish_ports."
79
+ end
80
+ end
81
+
82
+ def validate_publications
83
+ @config.publish_ports.each_key do |name|
84
+ raise Error, "Cannot publish ports for inactive or unknown service #{name.inspect}." unless ports.key?(name)
85
+ end
86
+ end
87
+ end
88
+ end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "tmpdir"
4
+
5
+ module RailsDev
6
+ class Services
7
+ def initialize(root, config, output: $stdout)
8
+ @config = config
9
+ @commands = Commands.new(root, output: output)
10
+ @state = State.new(@commands)
11
+ end
12
+
13
+ def run(action, argv = [])
14
+ @commands.with_signals do
15
+ @commands.env.merge!(@config.env)
16
+ manage(action) if @config.compose_file
17
+ raise Error, "Set config.compose_file to manage Compose services." if !@config.compose_file && action != "exec"
18
+ end
19
+ @commands.replace(argv) if action == "exec"
20
+ end
21
+
22
+ private
23
+
24
+ def manage(action)
25
+ @config.validate_connection_environment(@commands.env) unless action == "stop"
26
+ FileUtils.mkdir_p(@state.directory, mode: 0o700)
27
+ Dir.mktmpdir("services-", @state.directory) do |runtime|
28
+ compose = Compose.new(@config, @commands, @state, runtime)
29
+ if action == "stop"
30
+ compose.stop
31
+ else
32
+ @commands.env.merge!(compose.start)
33
+ end
34
+ end
35
+ end
36
+ end
37
+ end