gemstack-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.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 29f4e62e1d5a456f9baa82311bd2940b5991b51ecb4e977f2cf20103040f6b43
4
+ data.tar.gz: 26d9a84c3052e370f59f34d24e4b9e9fec4adcb97c9827c352dc073b8661c107
5
+ SHA512:
6
+ metadata.gz: 9a67351093cf50e23c12baa5f45445227f764bc0a7ab2463fa5716ff397c240838d840a35979844362eb12c33f721ab67a49daffe81dedd530190268ce355898
7
+ data.tar.gz: 40eb97dce3d3c0de4e67d1de381c61e0677e625f81f23387e603800aa1764b7a60eee72f08a42f41660a66b199e00ce67401253a20c72b70461276f0a510a1ad
data/CHANGELOG.md ADDED
@@ -0,0 +1,5 @@
1
+ # Changelog
2
+
3
+ ## 0.1.0
4
+
5
+ First release. See the [GemStack changelog](https://github.com/gemstack-rb/gemstack/blob/main/CHANGELOG.md).
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Shoaib Malik
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,24 @@
1
+ # gemstack-dev
2
+
3
+ GemStack development server: single-origin gateway and process supervisor.
4
+
5
+ Part of [GemStack](https://github.com/gemstack-rb/gemstack), a modular Ruby API framework for Next.js
6
+ applications. All GemStack gems are developed together in that repository and released with the same
7
+ version.
8
+
9
+ ## Installation
10
+
11
+ Installed with the `gemstack` gem; you rarely need to add it yourself.
12
+
13
+ ## Documentation
14
+
15
+ - [Guide](https://github.com/gemstack-rb/gemstack/blob/main/docs/development.md)
16
+ - [All guides](https://github.com/gemstack-rb/gemstack/tree/main/docs) ·
17
+ [Architecture](https://github.com/gemstack-rb/gemstack/blob/main/ARCHITECTURE.md)
18
+
19
+ Source, issues and pull requests: [gemstack-rb/gemstack](https://github.com/gemstack-rb/gemstack)
20
+ (this gem lives in `gems/gemstack-dev`).
21
+
22
+ ## License
23
+
24
+ MIT — see [LICENSE.txt](LICENSE.txt).
@@ -0,0 +1,58 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+
5
+ module GemStack
6
+ module Dev
7
+ # Detects changes (edits, additions, deletions) in a set of glob patterns.
8
+ # Polling keeps it dependency-free and reliable across editors, containers
9
+ # and network filesystems; it is only used in development.
10
+ #
11
+ # Modification times are the cheap first check; a file whose mtime changed
12
+ # only counts as changed if its content digest changed too. Tools that
13
+ # rewrite files with identical content — Bundler 4 rewrites Gemfile.lock on
14
+ # every `bundle exec` — therefore don't trigger reloads or restarts.
15
+ class FileWatcher
16
+ Entry = Struct.new(:mtime, :bytes, :digest)
17
+
18
+ def initialize(patterns, root: Dir.pwd, exclude: [])
19
+ @patterns = Array(patterns)
20
+ @exclude = Array(exclude)
21
+ @root = root.to_s
22
+ @entries = scan({})
23
+ end
24
+
25
+ # True once per change: the new state becomes the baseline.
26
+ def changed?
27
+ current = scan(@entries)
28
+ changed = current.keys != @entries.keys ||
29
+ current.any? { |path, entry| entry.digest != @entries[path].digest }
30
+ @entries = current
31
+ changed
32
+ end
33
+
34
+ def files
35
+ found = Dir.glob(@patterns, base: @root)
36
+ found.reject! { |file| @exclude.any? { |pattern| File.fnmatch?(pattern, file, File::FNM_PATHNAME) } }
37
+ found.sort
38
+ end
39
+
40
+ private
41
+
42
+ # Reuses the previous digest when mtime and size are unchanged.
43
+ def scan(previous)
44
+ files.each_with_object({}) do |file, entries|
45
+ path = File.join(@root, file)
46
+ stat = File.stat(path)
47
+ next if stat.directory?
48
+
49
+ old = previous[file]
50
+ same = old && old.mtime == stat.mtime && old.bytes == stat.size
51
+ entries[file] = Entry.new(stat.mtime, stat.size, same ? old.digest : Digest::SHA256.file(path).hexdigest)
52
+ rescue Errno::ENOENT
53
+ next
54
+ end
55
+ end
56
+ end
57
+ end
58
+ end
@@ -0,0 +1,220 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "socket"
4
+ require "json"
5
+
6
+ module GemStack
7
+ module Dev
8
+ # The development gateway: one public origin in front of Next.js and the
9
+ # Ruby API (DECISIONS D-008).
10
+ #
11
+ # For each connection it reads only the request line and headers, picks
12
+ # an upstream by path (api_path → API, everything else → frontend), adds
13
+ # X-Forwarded-* headers and then copies raw bytes in both directions.
14
+ # Because it never interprets bodies, streaming responses, Server-Sent
15
+ # Events and WebSocket upgrades (Next.js HMR, realtime) just work.
16
+ #
17
+ # Each request gets its own upstream connection (`Connection: close`
18
+ # upstream), so routing stays correct per request without parsing
19
+ # response framing. That trade-off is fine on localhost and is the reason
20
+ # this gateway is for development only.
21
+ class Gateway
22
+ Upstream = Struct.new(:name, :host, :port, :label) do
23
+ def to_s = "#{host}:#{port}"
24
+ end
25
+
26
+ MAX_HEAD = 64 * 1024
27
+ CONNECT_TIMEOUT = 2
28
+ HOP_BY_HOP = %w[connection keep-alive proxy-connection].freeze
29
+ FORWARDED = %w[x-forwarded-for x-forwarded-proto x-forwarded-host x-forwarded-port].freeze
30
+
31
+ attr_reader :port
32
+ attr_accessor :api, :frontend
33
+
34
+ def initialize(port:, api_path:, api:, frontend: nil, bind: %w[127.0.0.1 ::1], on_error: nil)
35
+ @port = port
36
+ @api_path = api_path.to_s.chomp("/")
37
+ @api = api
38
+ @frontend = frontend
39
+ @bind = Array(bind)
40
+ @on_error = on_error
41
+ @servers = []
42
+ @threads = []
43
+ end
44
+
45
+ def start
46
+ @bind.each do |host|
47
+ server = listen(host)
48
+ next unless server
49
+
50
+ @port = server.addr[1] if @port.zero? # port 0: let the OS choose (tests)
51
+ @servers << server
52
+ end
53
+ raise Error, "could not listen on any of #{@bind.join(", ")}" if @servers.empty?
54
+
55
+ @threads = @servers.map { |server| Thread.new { accept_loop(server) } }
56
+ self
57
+ end
58
+
59
+ def stop
60
+ @servers.each { |server| server.close unless server.closed? }
61
+ @threads.each { |thread| thread.join(1) }
62
+ @servers.clear
63
+ end
64
+
65
+ def api_request?(path)
66
+ return true if @api_path.empty?
67
+
68
+ path == @api_path || path.start_with?("#{@api_path}/")
69
+ end
70
+
71
+ private
72
+
73
+ def listen(host)
74
+ TCPServer.new(host, @port)
75
+ rescue Errno::EADDRINUSE
76
+ stop
77
+ raise Error,
78
+ "port #{@port} is already in use on #{host}. Stop the other process or run PORT=#{@port + 1} gemstack dev"
79
+ rescue Errno::EADDRNOTAVAIL, Errno::EAFNOSUPPORT, SocketError
80
+ nil # e.g. no IPv6 loopback on this machine
81
+ end
82
+
83
+ def accept_loop(server)
84
+ loop do
85
+ client = server.accept
86
+ Thread.new(client) { |socket| handle(socket) }
87
+ end
88
+ rescue IOError, Errno::EBADF, Errno::EINVAL
89
+ nil # server closed
90
+ end
91
+
92
+ def handle(client)
93
+ head, rest = read_head(client)
94
+ return unless head
95
+
96
+ request_line, *header_lines = head.split("\r\n")
97
+ _method, target, = request_line.split(" ", 3)
98
+ return respond(client, 400, "text/plain", "Bad Request") unless target
99
+
100
+ path = target.sub(%r{\Ahttps?://[^/]+}i, "").split("?", 2).first
101
+ api = api_request?(path)
102
+ upstream = api ? @api : @frontend
103
+ return unavailable(client, api, header_lines, nil) unless upstream
104
+
105
+ server = connect(upstream)
106
+ return unavailable(client, api, header_lines, upstream) unless server
107
+
108
+ server.write("#{request_line}\r\n#{rewrite(header_lines, client).join("\r\n")}\r\n\r\n")
109
+ server.write(rest) unless rest.empty?
110
+ relay(client, server)
111
+ rescue IOError, SystemCallError
112
+ nil # client or upstream went away
113
+ rescue StandardError => e
114
+ @on_error&.call(e)
115
+ ensure
116
+ [client, server].each { |socket| socket&.close unless socket&.closed? }
117
+ end
118
+
119
+ def read_head(client)
120
+ buffer = String.new(encoding: Encoding::BINARY)
121
+ until (index = buffer.index("\r\n\r\n"))
122
+ return nil if buffer.bytesize > MAX_HEAD
123
+
124
+ buffer << client.readpartial(16 * 1024)
125
+ end
126
+ [buffer.byteslice(0, index), buffer.byteslice(index + 4, buffer.bytesize)]
127
+ rescue IOError, SystemCallError # EOFError is an IOError
128
+ nil
129
+ end
130
+
131
+ def rewrite(lines, client)
132
+ upgrade = upgrade?(lines)
133
+ host = nil
134
+ kept = lines.reject do |line|
135
+ name = line[/\A[^:]+/].to_s.strip.downcase
136
+ host = line.split(":", 2).last.strip if name == "host"
137
+ FORWARDED.include?(name) || (!upgrade && HOP_BY_HOP.include?(name))
138
+ end
139
+ kept << "Connection: close" unless upgrade
140
+ kept << "X-Forwarded-For: #{client_ip(client)}"
141
+ kept << "X-Forwarded-Proto: http"
142
+ kept << "X-Forwarded-Host: #{host}" if host
143
+ kept << "X-Forwarded-Port: #{@port}"
144
+ end
145
+
146
+ def upgrade?(lines)
147
+ connection = lines.find { |l| l.match?(/\Aconnection\s*:/i) }
148
+ connection&.match?(/upgrade/i) && lines.any? { |l| l.match?(/\Aupgrade\s*:/i) }
149
+ end
150
+
151
+ def client_ip(client)
152
+ client.remote_address.ip_address
153
+ rescue StandardError
154
+ "127.0.0.1"
155
+ end
156
+
157
+ def connect(upstream)
158
+ Socket.tcp(upstream.host, upstream.port, connect_timeout: CONNECT_TIMEOUT)
159
+ rescue SystemCallError, SocketError, IOError
160
+ nil
161
+ end
162
+
163
+ # Upstream → client on this thread; client → upstream on a helper
164
+ # thread. The exchange ends when the upstream closes (after its
165
+ # response, or when a WebSocket/stream ends).
166
+ def relay(client, server)
167
+ pump = Thread.new do
168
+ IO.copy_stream(client, server)
169
+ server.close_write
170
+ rescue IOError, SystemCallError
171
+ nil
172
+ end
173
+ IO.copy_stream(server, client)
174
+ rescue IOError, SystemCallError
175
+ nil
176
+ ensure
177
+ pump&.kill
178
+ end
179
+
180
+ def unavailable(client, api, header_lines, upstream)
181
+ name = api ? "Ruby API" : "Next.js"
182
+ state = upstream ? "is starting or not running" : "is not configured"
183
+ message = "#{name} #{state}. Check the terminal running `gemstack dev`."
184
+ if api
185
+ body = JSON.generate(error: { code: "upstream_unavailable", message: message })
186
+ respond(client, 503, "application/json", body)
187
+ elsif header_lines.any? { |l| l.match?(%r{\Aaccept\s*:.*text/html}i) }
188
+ respond(client, 503, "text/html; charset=utf-8", waiting_page(name, message, refresh: !upstream.nil?))
189
+ else
190
+ respond(client, 503, "text/plain", message)
191
+ end
192
+ end
193
+
194
+ def respond(client, status, type, body)
195
+ reason = { 400 => "Bad Request", 503 => "Service Unavailable" }.fetch(status, "Error")
196
+ client.write("HTTP/1.1 #{status} #{reason}\r\ncontent-type: #{type}\r\ncontent-length: #{body.bytesize}\r\n" \
197
+ "cache-control: no-store\r\nretry-after: 1\r\nconnection: close\r\n\r\n#{body}")
198
+ end
199
+
200
+ def waiting_page(name, message, refresh:)
201
+ <<~HTML
202
+ <!doctype html>
203
+ <html lang="en"><head><meta charset="utf-8"><title>GemStack · #{name}</title>
204
+ #{'<meta http-equiv="refresh" content="1">' if refresh}
205
+ <meta name="viewport" content="width=device-width, initial-scale=1">
206
+ <style>
207
+ body{font:16px/1.5 system-ui,sans-serif;display:grid;place-items:center;min-height:100vh;margin:0;
208
+ background:#0b0d12;color:#e6e8ee}
209
+ main{max-width:32rem;padding:2rem;text-align:center}
210
+ .dot{display:inline-block;width:.6rem;height:.6rem;border-radius:50%;background:#f5b041;
211
+ margin-right:.5rem;animation:p 1s infinite alternate}
212
+ @keyframes p{to{opacity:.3}} code{background:#1c2030;padding:.1rem .35rem;border-radius:4px}
213
+ </style></head>
214
+ <body><main><h1>GemStack</h1><p><span class="dot"></span>#{message.sub(/`([^`]+)`/, '<code>\1</code>')}</p>
215
+ <p style="color:#8a90a2">#{"This page refreshes automatically." if refresh}</p></main></body></html>
216
+ HTML
217
+ end
218
+ end
219
+ end
220
+ end
@@ -0,0 +1,96 @@
1
+ # frozen_string_literal: true
2
+
3
+ module GemStack
4
+ module Dev
5
+ # A child process whose output is streamed, line by line, to a Terminal.
6
+ # Each child runs in its own process group so stopping it also stops
7
+ # anything it spawned (e.g. npm → node).
8
+ class ManagedProcess
9
+ attr_reader :name, :command, :pid, :status
10
+
11
+ def initialize(name, command, terminal:, env: {}, chdir: Dir.pwd)
12
+ @name = name
13
+ @command = Array(command)
14
+ @terminal = terminal
15
+ @env = env
16
+ @chdir = chdir.to_s
17
+ @pid = nil
18
+ @status = nil
19
+ end
20
+
21
+ def start
22
+ reader, writer = IO.pipe
23
+ @status = nil
24
+ @pid = Process.spawn(@env, *@command, chdir: @chdir, out: writer, err: writer, in: File::NULL,
25
+ pgroup: true)
26
+ writer.close
27
+ @output = Thread.new do
28
+ reader.each_line { |line| @terminal.line(@name, line) }
29
+ rescue IOError
30
+ nil
31
+ ensure
32
+ reader.close
33
+ end
34
+ self
35
+ rescue SystemCallError => e
36
+ writer&.close
37
+ reader&.close
38
+ @terminal.line(@name, "could not start `#{@command.join(" ")}`: #{e.message}")
39
+ @status = :failed_to_start
40
+ self
41
+ end
42
+
43
+ # Non-blocking. Records the exit status once the process has exited.
44
+ def running?
45
+ return false unless @pid
46
+ return false if @status
47
+
48
+ done = Process.waitpid2(@pid, Process::WNOHANG)
49
+ return true unless done
50
+
51
+ @status = done[1]
52
+ false
53
+ rescue Errno::ECHILD
54
+ @status ||= :unknown
55
+ false
56
+ end
57
+
58
+ def exited? = !@status.nil?
59
+
60
+ def stop(timeout: 5)
61
+ return unless running?
62
+
63
+ signal("TERM")
64
+ deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout
65
+ sleep 0.05 while running? && Process.clock_gettime(Process::CLOCK_MONOTONIC) < deadline
66
+ signal("KILL") if running?
67
+ running?
68
+ @output&.join(1)
69
+ end
70
+
71
+ def restart
72
+ stop
73
+ start
74
+ end
75
+
76
+ def describe_status
77
+ case @status
78
+ when Process::Status then @status.signaled? ? "signal #{@status.termsig}" : "status #{@status.exitstatus}"
79
+ else @status.to_s
80
+ end
81
+ end
82
+
83
+ private
84
+
85
+ def signal(name)
86
+ Process.kill(name, -@pid)
87
+ rescue Errno::ESRCH, Errno::EPERM
88
+ begin
89
+ Process.kill(name, @pid)
90
+ rescue Errno::ESRCH, Errno::EPERM
91
+ nil
92
+ end
93
+ end
94
+ end
95
+ end
96
+ end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "socket"
4
+
5
+ module GemStack
6
+ module Dev
7
+ module Ports
8
+ module_function
9
+
10
+ # An unused TCP port on host, chosen by the OS.
11
+ def free(host = "127.0.0.1")
12
+ server = TCPServer.new(host, 0)
13
+ server.addr[1]
14
+ ensure
15
+ server&.close
16
+ end
17
+
18
+ def open?(host, port, timeout: 0.5)
19
+ Socket.tcp(host, port, connect_timeout: timeout).close
20
+ true
21
+ rescue SystemCallError, SocketError, IOError
22
+ false
23
+ end
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,252 @@
1
+ # frozen_string_literal: true
2
+
3
+ module GemStack
4
+ module Dev
5
+ # Runs `gemstack dev`: the gateway on the public port, the Ruby API and
6
+ # Next.js on private ports chosen automatically, prefixed output, restarts
7
+ # of the API when configuration changes, and a clean shutdown on Ctrl-C.
8
+ class Supervisor
9
+ LOOPBACK = "127.0.0.1"
10
+ TICK = 0.25
11
+
12
+ attr_reader :gateway, :processes
13
+
14
+ def initialize(root:, config: GemStack.config, terminal: Terminal.new, env: ENV)
15
+ @root = Pathname.new(root)
16
+ @config = config
17
+ @dev = config.dev
18
+ @terminal = terminal
19
+ @env = env
20
+ @processes = {}
21
+ @stopping = false
22
+ end
23
+
24
+ def run
25
+ prepare_frontend if frontend?
26
+ start
27
+ install_signal_handlers
28
+ loop_until_stopped
29
+ ensure
30
+ shutdown
31
+ end
32
+
33
+ def start
34
+ @started_at = monotonic
35
+ api_port = Ports.free(LOOPBACK)
36
+ web_port = Ports.free(LOOPBACK) if frontend?
37
+ @gateway = Gateway.new(
38
+ port: @dev.port, bind: @dev.bind, api_path: @config.http.api_path,
39
+ api: Gateway::Upstream.new(:api, LOOPBACK, api_port, "Ruby API"),
40
+ frontend: web_port && Gateway::Upstream.new(:next, LOOPBACK, web_port, "Next.js"),
41
+ on_error: ->(e) { @terminal.line("gateway", "#{e.class}: #{e.message}") }
42
+ ).start
43
+
44
+ start_processes(api_port, web_port)
45
+ @restart_watcher = FileWatcher.new(@dev.restart_on, root: @root, exclude: @dev.restart_exclude)
46
+ @recovery_watcher = FileWatcher.new(["app/**/*", "config/**/*", "Gemfile.lock"], root: @root)
47
+ @jobs_watcher = FileWatcher.new(["app/**/*.rb", "db/migrations/*.rb"], root: @root) if @dev.jobs_command
48
+ start_jobs if jobs_enabled?
49
+ if web_port && @dev.contract_command
50
+ @contract_watcher = FileWatcher.new(@dev.contract_watch, root: @root)
51
+ @contract_pending = true # generate once at startup
52
+ end
53
+ banner
54
+ watch_readiness
55
+ self
56
+ end
57
+
58
+ def start_processes(api_port, web_port)
59
+ @processes[:api] = spawn("api", @dev.api_command, @root, api_env(api_port))
60
+ @processes[:next] = spawn("next", frontend_command(web_port), frontend_dir, frontend_env(api_port)) if web_port
61
+ end
62
+
63
+ def spawn(name, command, dir, env)
64
+ ManagedProcess.new(name, command, terminal: @terminal, chdir: dir, env: env).start
65
+ end
66
+
67
+ def stop!
68
+ @stopping = true
69
+ end
70
+
71
+ def shutdown
72
+ return if @shut_down
73
+
74
+ @shut_down = true
75
+ @terminal.puts
76
+ @terminal.line("gemstack", "shutting down…")
77
+ @processes.values.map { |process| Thread.new { process.stop } }.each(&:join)
78
+ @gateway&.stop
79
+ end
80
+
81
+ private
82
+
83
+ def frontend? = frontend_dir.join("package.json").file?
84
+ def frontend_dir = @root.join(@dev.frontend_dir)
85
+
86
+ def api_env(port)
87
+ {
88
+ "GEMSTACK_ENV" => @env.fetch("GEMSTACK_ENV", "development"),
89
+ "GEMSTACK_API_HOST" => LOOPBACK,
90
+ "GEMSTACK_API_PORT" => port.to_s,
91
+ "GEMSTACK_LOG_COLOR" => @terminal.color ? "1" : "0",
92
+ "PORT" => nil # the public port belongs to the gateway
93
+ }
94
+ end
95
+
96
+ def frontend_env(api_port)
97
+ {
98
+ "GEMSTACK_API_URL" => "http://#{LOOPBACK}:#{api_port}",
99
+ "NEXT_PUBLIC_GEMSTACK_API_PATH" => @config.http.api_path,
100
+ "FORCE_COLOR" => @terminal.color ? "1" : nil,
101
+ "PORT" => nil
102
+ }
103
+ end
104
+
105
+ def frontend_command(port)
106
+ return Array(@dev.frontend_command) + ["--port", port.to_s] if @dev.frontend_command
107
+
108
+ [frontend_dir.join("node_modules/.bin/next").to_s, "dev", "--hostname", LOOPBACK, "--port", port.to_s]
109
+ end
110
+
111
+ # Installs frontend dependencies on first run so `gemstack new && gemstack dev`
112
+ # works even with --skip-install.
113
+ def prepare_frontend
114
+ return if frontend_dir.join("node_modules/.bin/next").exist?
115
+
116
+ manager = package_manager
117
+ @terminal.line("gemstack", "installing frontend dependencies with #{manager} (first run)…")
118
+ installer = ManagedProcess.new(manager, [manager, "install"], terminal: @terminal, chdir: frontend_dir).start
119
+ sleep 0.1 while installer.running?
120
+ return if installer.status.is_a?(Process::Status) && installer.status.success?
121
+
122
+ raise Error, "#{manager} install failed (#{installer.describe_status}) in #{frontend_dir}"
123
+ end
124
+
125
+ def package_manager
126
+ {
127
+ "pnpm-lock.yaml" => "pnpm", "yarn.lock" => "yarn", "bun.lockb" => "bun", "bun.lock" => "bun"
128
+ }.each { |lock, manager| return manager if frontend_dir.join(lock).exist? }
129
+ "npm"
130
+ end
131
+
132
+ def banner
133
+ url = "http://localhost:#{@gateway.port}"
134
+ t = @terminal
135
+ t.puts
136
+ t.puts(" #{t.bold("GemStack")} #{t.dim("v#{GemStack::VERSION} · #{@env.fetch("GEMSTACK_ENV",
137
+ "development")}")}")
138
+ t.puts
139
+ split = t.dim("(#{@config.http.api_path}/* → Ruby, everything else → Next.js)")
140
+ t.puts(" #{t.green("✓")} Gateway #{url} #{split}")
141
+ t.puts(" #{t.yellow("…")} Ruby API #{t.dim("starting on #{@gateway.api} (internal)")}")
142
+ if @gateway.frontend
143
+ t.puts(" #{t.yellow("…")} Next.js #{t.dim("starting on #{@gateway.frontend} (internal)")}")
144
+ else
145
+ t.puts(" #{t.dim("-")} Next.js #{t.dim("no #{@dev.frontend_dir}/package.json — API only")}")
146
+ end
147
+ t.puts
148
+ t.puts(" Application: #{t.bold(url)}")
149
+ t.puts
150
+ end
151
+
152
+ def watch_readiness
153
+ [@gateway.api, @gateway.frontend].compact.each do |upstream|
154
+ Thread.new do
155
+ until @stopping
156
+ if Ports.open?(upstream.host, upstream.port)
157
+ seconds = (monotonic - @started_at).round(1)
158
+ @terminal.line("gemstack", "#{@terminal.green("✓")} #{upstream.label} ready (#{seconds}s)")
159
+ break
160
+ end
161
+ sleep TICK
162
+ end
163
+ end
164
+ end
165
+ end
166
+
167
+ def install_signal_handlers
168
+ %w[INT TERM].each { |signal| trap(signal) { @stopping = true } }
169
+ end
170
+
171
+ def loop_until_stopped
172
+ until @stopping
173
+ sleep TICK
174
+ supervise
175
+ end
176
+ end
177
+
178
+ def supervise
179
+ api = @processes[:api]
180
+ if @restart_watcher.changed?
181
+ restart_api("configuration changed")
182
+ elsif !api.running? && api.exited?
183
+ report_exit(api)
184
+ restart_api("files changed") if @recovery_watcher.changed?
185
+ end
186
+ web = @processes[:next]
187
+ report_exit(web) if web && !web.running? && web.exited?
188
+ supervise_contract
189
+ supervise_jobs
190
+ end
191
+
192
+ # A worker only makes sense with the PostgreSQL queue and its table.
193
+ def jobs_enabled?
194
+ return false unless @dev.jobs_command && defined?(GemStack::Jobs) && @config.respond_to?(:jobs)
195
+ return false unless @config.jobs.adapter.to_s == "postgres"
196
+
197
+ Dir.glob(@root.join("db/migrations/*_create_gemstack_jobs.rb").to_s).any?
198
+ end
199
+
200
+ def start_jobs
201
+ @processes[:jobs] = spawn("jobs", @dev.jobs_command, @root, { "GEMSTACK_ENV" => "development" })
202
+ end
203
+
204
+ # The worker doesn't reload code, so restart it when app/ changes
205
+ # (it finishes running jobs first), and start it once the jobs table exists.
206
+ def supervise_jobs
207
+ return unless @jobs_watcher&.changed?
208
+
209
+ jobs = @processes[:jobs]
210
+ if jobs&.running?
211
+ @terminal.line("gemstack", "app changed — restarting job worker")
212
+ jobs.restart
213
+ elsif jobs_enabled?
214
+ start_jobs
215
+ end
216
+ end
217
+
218
+ # One contract run at a time; changes during a run queue exactly one more.
219
+ def supervise_contract
220
+ return unless @contract_watcher
221
+
222
+ @contract_pending = true if @contract_watcher.changed?
223
+ running = @processes[:contract]&.running?
224
+ return if running || !@contract_pending
225
+
226
+ @contract_pending = false
227
+ @processes[:contract] = spawn("contract", @dev.contract_command, @root, { "GEMSTACK_ENV" => "development" })
228
+ end
229
+
230
+ # Both watchers overlap (config/), so re-baseline both after a restart
231
+ # to avoid restarting twice for a single change.
232
+ def restart_api(reason)
233
+ @terminal.line("gemstack", "#{reason} — restarting Ruby API")
234
+ @processes[:api].restart
235
+ @restart_watcher.changed?
236
+ @recovery_watcher.changed?
237
+ @reported_exit = nil
238
+ end
239
+
240
+ def report_exit(process)
241
+ key = [process.name, process.pid]
242
+ return if @reported_exit&.include?(key)
243
+
244
+ (@reported_exit ||= []) << key
245
+ hint = process.name == "api" ? " — fix the error; it restarts when you save a file" : ""
246
+ @terminal.line("gemstack", @terminal.red("#{process.name} exited (#{process.describe_status})#{hint}"))
247
+ end
248
+
249
+ def monotonic = Process.clock_gettime(Process::CLOCK_MONOTONIC)
250
+ end
251
+ end
252
+ end
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ module GemStack
4
+ module Dev
5
+ # Serialised, prefixed, coloured output for multiple processes:
6
+ #
7
+ # api │ 12:00:01.120 INFO GET /api/health status=200 ms=0.4
8
+ # next │ ✓ Compiled / in 812ms
9
+ class Terminal
10
+ COLORS = { "api" => 35, "next" => 36, "gateway" => 33, "gemstack" => 32, "jobs" => 34 }.freeze
11
+ WIDTH = 8
12
+
13
+ def initialize(io = $stdout, color: io.respond_to?(:tty?) && io.tty?)
14
+ @io = io
15
+ @io.sync = true if @io.respond_to?(:sync=) # output may be a pipe or file
16
+ @color = color
17
+ @mutex = Mutex.new
18
+ end
19
+
20
+ attr_reader :color
21
+
22
+ def line(name, text)
23
+ prefix = paint(name.ljust(WIDTH), COLORS.fetch(name, 37))
24
+ text = text.to_s.chomp
25
+ @mutex.synchronize { @io.puts("#{prefix}│ #{text}") }
26
+ end
27
+
28
+ def puts(text = "")
29
+ @mutex.synchronize { @io.puts(text) }
30
+ end
31
+
32
+ def paint(text, code) = @color ? "\e[#{code}m#{text}\e[0m" : text
33
+ def bold(text) = @color ? "\e[1m#{text}\e[0m" : text
34
+ def dim(text) = paint(text, 90)
35
+ def green(text) = paint(text, 32)
36
+ def red(text) = paint(text, 31)
37
+ def yellow(text) = paint(text, 33)
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "gemstack/core"
4
+
5
+ module GemStack
6
+ # Development tooling: the single-origin gateway and the process
7
+ # supervisor behind `gemstack dev` (ARCHITECTURE §5). Nothing here is
8
+ # loaded in production unless explicitly required.
9
+ module Dev
10
+ autoload :FileWatcher, "gemstack/dev/file_watcher"
11
+ autoload :Gateway, "gemstack/dev/gateway"
12
+ autoload :ManagedProcess, "gemstack/dev/managed_process"
13
+ autoload :Ports, "gemstack/dev/ports"
14
+ autoload :Supervisor, "gemstack/dev/supervisor"
15
+ autoload :Terminal, "gemstack/dev/terminal"
16
+
17
+ class Config < Settings
18
+ # The single public port.
19
+ setting :port, default: -> { Integer(ENV.fetch("PORT", 3000)) }
20
+ # Interfaces the gateway listens on. Both loopback families, because
21
+ # browsers may resolve "localhost" to either.
22
+ setting :bind, default: %w[127.0.0.1 ::1]
23
+ setting :frontend_dir, default: "frontend"
24
+ setting :api_command, default: %w[bundle exec puma -C config/puma.rb]
25
+ # nil = run the frontend's own `next` binary in dev mode.
26
+ setting :frontend_command, default: nil
27
+ # Changes here restart the Ruby process. app/ and config/routes.rb are
28
+ # reloaded in-process instead and don't need a restart.
29
+ setting :restart_on, default: ["config/**/*.rb", "Gemfile.lock", ".env*"]
30
+ setting :restart_exclude, default: ["config/routes.rb"]
31
+ # Regenerates TypeScript types/clients when backend code changes
32
+ # (only when the app has a frontend). nil disables.
33
+ setting :contract_command, default: %w[bundle exec gemstack contract --quiet]
34
+ setting :contract_watch, default: ["app/**/*.rb", "config/routes.rb"]
35
+ # Background job worker, run when the app uses the :postgres job
36
+ # adapter and has the gemstack_jobs migration. Restarted when app/ changes.
37
+ setting :jobs_command, default: %w[bundle exec gemstack jobs]
38
+ end
39
+ end
40
+
41
+ Config.namespace(:dev, Dev::Config)
42
+ end
metadata ADDED
@@ -0,0 +1,68 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: gemstack-dev
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Shoaib Malik
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: gemstack-core
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - '='
17
+ - !ruby/object:Gem::Version
18
+ version: 0.1.0
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - '='
24
+ - !ruby/object:Gem::Version
25
+ version: 0.1.0
26
+ email:
27
+ - gemstack26@gmail.com
28
+ executables: []
29
+ extensions: []
30
+ extra_rdoc_files: []
31
+ files:
32
+ - CHANGELOG.md
33
+ - LICENSE.txt
34
+ - README.md
35
+ - lib/gemstack/dev.rb
36
+ - lib/gemstack/dev/file_watcher.rb
37
+ - lib/gemstack/dev/gateway.rb
38
+ - lib/gemstack/dev/managed_process.rb
39
+ - lib/gemstack/dev/ports.rb
40
+ - lib/gemstack/dev/supervisor.rb
41
+ - lib/gemstack/dev/terminal.rb
42
+ homepage: https://github.com/gemstack-rb/gemstack
43
+ licenses:
44
+ - MIT
45
+ metadata:
46
+ rubygems_mfa_required: 'true'
47
+ source_code_uri: https://github.com/gemstack-rb/gemstack/tree/main/gems/gemstack-dev
48
+ changelog_uri: https://github.com/gemstack-rb/gemstack/blob/main/gems/gemstack-dev/CHANGELOG.md
49
+ bug_tracker_uri: https://github.com/gemstack-rb/gemstack/issues
50
+ documentation_uri: https://github.com/gemstack-rb/gemstack/tree/main/docs
51
+ rdoc_options: []
52
+ require_paths:
53
+ - lib
54
+ required_ruby_version: !ruby/object:Gem::Requirement
55
+ requirements:
56
+ - - ">="
57
+ - !ruby/object:Gem::Version
58
+ version: '4.0'
59
+ required_rubygems_version: !ruby/object:Gem::Requirement
60
+ requirements:
61
+ - - ">="
62
+ - !ruby/object:Gem::Version
63
+ version: '0'
64
+ requirements: []
65
+ rubygems_version: 4.0.20
66
+ specification_version: 4
67
+ summary: 'GemStack development server: single-origin gateway and process supervisor'
68
+ test_files: []