gemstack-dev 0.2.4 → 0.3.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 412fcd1d46c05fd57427b4efabff65f5f318fde7de647b82cbcb34295537a21f
4
- data.tar.gz: 3afd4a5159ce7d0f96b477504ca76eb114f794193d310d56100fbc455e153494
3
+ metadata.gz: fb88e004507f1d705767b95ed2c9ebd5f3359d222671380c7cf9a06699ad1f20
4
+ data.tar.gz: a23d9b91475eec43af979ebc888bd0973d04799cd93b5883f1bc9295684387d5
5
5
  SHA512:
6
- metadata.gz: e5db3bda12111495f8b65df58ae790d407b3f53c2cf354add665751a7425b165ff9a0fb463e67c084082fbcc06c423a82fe04537a98d8383e56cf652e7d1b002
7
- data.tar.gz: fd412202bbbde52eaafaeddf41865c33871ac93e72eefed74127c75ade41ed4b617c4c8d998b17d0d0bedb8def459ef9461507139076d71c5d8c68b00c62d843
6
+ metadata.gz: a1b7a5c31396f6261abe9359dd98d72acddcd322307ee53094ef118daa9c56334e5b43c61b5bff142a8f5fc0b7c895dcf542571dc36a173be131e4cdd43ea1b9
7
+ data.tar.gz: ab411641ae5b0c8b8e511ecab3820b3697c4fa95388af55fb94b5009b1fa76017da63cd99a92c80b962caca294d511e99aba7cdbf6b72b2ca8f70c742316f07c
data/CHANGELOG.md CHANGED
@@ -1,25 +1,5 @@
1
1
  # Changelog
2
2
 
3
- ## 0.2.4
3
+ ## 0.3.0
4
4
 
5
- See the [GemStack changelog](https://github.com/gemstack-rb/gemstack/blob/main/CHANGELOG.md).
6
-
7
- ## 0.2.3
8
-
9
- See the [GemStack changelog](https://github.com/gemstack-rb/gemstack/blob/main/CHANGELOG.md).
10
-
11
- ## 0.2.2
12
-
13
- See the [GemStack changelog](https://github.com/gemstack-rb/gemstack/blob/main/CHANGELOG.md).
14
-
15
- ## 0.2.1
16
-
17
- See the [GemStack changelog](https://github.com/gemstack-rb/gemstack/blob/main/CHANGELOG.md).
18
-
19
- ## 0.2.0
20
-
21
- See the [GemStack changelog](https://github.com/gemstack-rb/gemstack/blob/main/CHANGELOG.md).
22
-
23
- ## 0.1.0
24
-
25
- First release. See the [GemStack changelog](https://github.com/gemstack-rb/gemstack/blob/main/CHANGELOG.md).
5
+ Merged into the gemstack gem; this version is a transition shim. See the [GemStack changelog](https://github.com/gemstack-rb/gemstack/blob/main/CHANGELOG.md).
data/README.md CHANGED
@@ -1,23 +1,13 @@
1
1
  # gemstack-dev
2
2
 
3
- GemStack development server: single-origin gateway and process supervisor.
3
+ **Merged into [`gemstack`](https://rubygems.org/gems/gemstack) in GemStack 0.3.0.**
4
4
 
5
- Part of [GemStack](https://github.com/gemstack-rb/gemstack), a modular Ruby API framework for Next.js
6
- applications by [Adware Technologies](https://www.adwaretech.com). All GemStack gems are developed together in that repository and released with the same
7
- version.
5
+ This version is a transition shim: it depends on `gemstack` and loads `gemstack/dev`, so
6
+ Gemfiles that still list `gemstack-dev` keep working. To finish upgrading, remove `gem "gemstack-dev"`
7
+ from your Gemfile (it's loaded by `require "gemstack"`).
8
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`).
9
+ GemStack is a modular Ruby API framework for Next.js applications by
10
+ [Adware Technologies](https://www.adwaretech.com) — [gemstack-rb/gemstack](https://github.com/gemstack-rb/gemstack).
21
11
 
22
12
  ## License
23
13
 
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ # gemstack-dev is part of the gemstack gem since 0.3.0. This shim keeps Gemfiles
4
+ # that still list gemstack-dev working; remove the line from your Gemfile.
5
+ require "gemstack/dev"
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: gemstack-dev
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.4
4
+ version: 0.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Adware Technologies
@@ -11,19 +11,28 @@ cert_chain: []
11
11
  date: 1980-01-02 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
- name: gemstack-core
14
+ name: gemstack
15
15
  requirement: !ruby/object:Gem::Requirement
16
16
  requirements:
17
- - - '='
17
+ - - ">="
18
18
  - !ruby/object:Gem::Version
19
- version: 0.2.4
19
+ version: 0.3.0
20
+ - - "<"
21
+ - !ruby/object:Gem::Version
22
+ version: '1.0'
20
23
  type: :runtime
21
24
  prerelease: false
22
25
  version_requirements: !ruby/object:Gem::Requirement
23
26
  requirements:
24
- - - '='
27
+ - - ">="
28
+ - !ruby/object:Gem::Version
29
+ version: 0.3.0
30
+ - - "<"
25
31
  - !ruby/object:Gem::Version
26
- version: 0.2.4
32
+ version: '1.0'
33
+ description: Since GemStack 0.3.0, gemstack-dev is part of the gemstack gem. This
34
+ version only depends on gemstack and loads gemstack/dev, so Gemfiles that still
35
+ list it keep working.
27
36
  email:
28
37
  - gemstack26@gmail.com
29
38
  executables: []
@@ -33,23 +42,18 @@ files:
33
42
  - CHANGELOG.md
34
43
  - LICENSE.txt
35
44
  - README.md
36
- - lib/gemstack/dev.rb
37
- - lib/gemstack/dev/file_watcher.rb
38
- - lib/gemstack/dev/gateway.rb
39
- - lib/gemstack/dev/managed_process.rb
40
- - lib/gemstack/dev/ports.rb
41
- - lib/gemstack/dev/supervisor.rb
42
- - lib/gemstack/dev/terminal.rb
43
- - lib/gemstack/dev/toolchain.rb
45
+ - lib/gemstack-dev.rb
44
46
  homepage: https://github.com/gemstack-rb/gemstack
45
47
  licenses:
46
48
  - MIT
47
49
  metadata:
48
50
  rubygems_mfa_required: 'true'
49
- source_code_uri: https://github.com/gemstack-rb/gemstack/tree/main/gems/gemstack-dev
50
- changelog_uri: https://github.com/gemstack-rb/gemstack/blob/main/gems/gemstack-dev/CHANGELOG.md
51
+ source_code_uri: https://github.com/gemstack-rb/gemstack
52
+ changelog_uri: https://github.com/gemstack-rb/gemstack/blob/main/CHANGELOG.md
51
53
  bug_tracker_uri: https://github.com/gemstack-rb/gemstack/issues
52
54
  documentation_uri: https://github.com/gemstack-rb/gemstack/tree/main/docs
55
+ post_install_message: gemstack-dev is now part of the gemstack gem. Remove `gem "gemstack-dev"`
56
+ from your Gemfile (it's loaded by `require "gemstack"`).
53
57
  rdoc_options: []
54
58
  require_paths:
55
59
  - lib
@@ -66,5 +70,5 @@ required_rubygems_version: !ruby/object:Gem::Requirement
66
70
  requirements: []
67
71
  rubygems_version: 4.0.20
68
72
  specification_version: 4
69
- summary: 'GemStack development server: single-origin gateway and process supervisor'
73
+ summary: Merged into the gemstack gem — remove gemstack-dev from your Gemfile
70
74
  test_files: []
@@ -1,58 +0,0 @@
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
@@ -1,220 +0,0 @@
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
@@ -1,96 +0,0 @@
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
@@ -1,26 +0,0 @@
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
@@ -1,267 +0,0 @@
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
- if frontend?
26
- check_node!
27
- prepare_frontend
28
- end
29
- start
30
- install_signal_handlers
31
- loop_until_stopped
32
- ensure
33
- shutdown
34
- end
35
-
36
- def start
37
- @started_at = monotonic
38
- api_port = Ports.free(LOOPBACK)
39
- web_port = Ports.free(LOOPBACK) if frontend?
40
- @gateway = Gateway.new(
41
- port: @dev.port, bind: @dev.bind, api_path: @config.http.api_path,
42
- api: Gateway::Upstream.new(:api, LOOPBACK, api_port, "Ruby API"),
43
- frontend: web_port && Gateway::Upstream.new(:next, LOOPBACK, web_port, "Next.js"),
44
- on_error: ->(e) { @terminal.line("gateway", "#{e.class}: #{e.message}") }
45
- ).start
46
-
47
- start_processes(api_port, web_port)
48
- @restart_watcher = FileWatcher.new(@dev.restart_on, root: @root, exclude: @dev.restart_exclude)
49
- @recovery_watcher = FileWatcher.new(["app/**/*", "config/**/*", "Gemfile.lock"], root: @root)
50
- @jobs_watcher = FileWatcher.new(["app/**/*.rb", "db/migrations/*.rb"], root: @root) if @dev.jobs_command
51
- start_jobs if jobs_enabled?
52
- if web_port && @dev.contract_command
53
- @contract_watcher = FileWatcher.new(@dev.contract_watch, root: @root)
54
- @contract_pending = true # generate once at startup
55
- end
56
- banner
57
- watch_readiness
58
- self
59
- end
60
-
61
- def start_processes(api_port, web_port)
62
- @processes[:api] = spawn("api", @dev.api_command, @root, api_env(api_port))
63
- @processes[:next] = spawn("next", frontend_command(web_port), frontend_dir, frontend_env(api_port)) if web_port
64
- end
65
-
66
- def spawn(name, command, dir, env)
67
- ManagedProcess.new(name, command, terminal: @terminal, chdir: dir, env: env).start
68
- end
69
-
70
- def stop!
71
- @stopping = true
72
- end
73
-
74
- def shutdown
75
- return if @shut_down
76
-
77
- @shut_down = true
78
- @terminal.puts
79
- @terminal.line("gemstack", "shutting down…")
80
- @processes.values.map { |process| Thread.new { process.stop } }.each(&:join)
81
- @gateway&.stop
82
- end
83
-
84
- private
85
-
86
- def frontend? = frontend_dir.join("package.json").file?
87
- def frontend_dir = @root.join(@dev.frontend_dir)
88
-
89
- def api_env(port)
90
- {
91
- "GEMSTACK_ENV" => @env.fetch("GEMSTACK_ENV", "development"),
92
- "GEMSTACK_API_HOST" => LOOPBACK,
93
- "GEMSTACK_API_PORT" => port.to_s,
94
- "GEMSTACK_LOG_COLOR" => @terminal.color ? "1" : "0",
95
- "PORT" => nil # the public port belongs to the gateway
96
- }
97
- end
98
-
99
- def frontend_env(api_port)
100
- {
101
- "GEMSTACK_API_URL" => "http://#{LOOPBACK}:#{api_port}",
102
- "NEXT_PUBLIC_GEMSTACK_API_PATH" => @config.http.api_path,
103
- "FORCE_COLOR" => @terminal.color ? "1" : nil,
104
- "PORT" => nil
105
- }
106
- end
107
-
108
- def frontend_command(port)
109
- return Array(@dev.frontend_command) + ["--port", port.to_s] if @dev.frontend_command
110
-
111
- [frontend_dir.join("node_modules/.bin/next").to_s, "dev", "--hostname", LOOPBACK, "--port", port.to_s]
112
- end
113
-
114
- # Next.js exits at once on an old Node.js with a line that's easy to miss
115
- # among the other output; say it up front, with the command that fixes it.
116
- def check_node!(node = Toolchain.node)
117
- return if node && Toolchain.node_ok?(node[:version])
118
-
119
- wanted = [@root.join(".node-version"), @root.join(".nvmrc")].find(&:file?)&.read&.strip
120
- wanted = Toolchain::LTS_NODE if wanted.nil? || wanted.empty?
121
- found = node ? "Node.js #{node[:version]} (#{node[:path]})" : "no `node` on the PATH"
122
- raise Error, "Next.js needs Node.js #{Toolchain::MIN_NODE.join(".")} or newer; found #{found}.\n " \
123
- "→ #{Toolchain.node_hint(wanted, Toolchain.node_manager(node&.fetch(:path)))}"
124
- end
125
-
126
- # Installs frontend dependencies on first run so `gemstack new && gemstack dev`
127
- # works even with --skip-install.
128
- def prepare_frontend
129
- return if frontend_dir.join("node_modules/.bin/next").exist?
130
-
131
- manager = package_manager
132
- @terminal.line("gemstack", "installing frontend dependencies with #{manager} (first run)…")
133
- installer = ManagedProcess.new(manager, [manager, "install"], terminal: @terminal, chdir: frontend_dir).start
134
- sleep 0.1 while installer.running?
135
- return if installer.status.is_a?(Process::Status) && installer.status.success?
136
-
137
- raise Error, "#{manager} install failed (#{installer.describe_status}) in #{frontend_dir}"
138
- end
139
-
140
- def package_manager
141
- {
142
- "pnpm-lock.yaml" => "pnpm", "yarn.lock" => "yarn", "bun.lockb" => "bun", "bun.lock" => "bun"
143
- }.each { |lock, manager| return manager if frontend_dir.join(lock).exist? }
144
- "npm"
145
- end
146
-
147
- def banner
148
- url = "http://localhost:#{@gateway.port}"
149
- t = @terminal
150
- t.puts
151
- t.puts(" #{t.bold("GemStack")} #{t.dim("v#{GemStack::VERSION} · #{@env.fetch("GEMSTACK_ENV",
152
- "development")}")}")
153
- t.puts
154
- split = t.dim("(#{@config.http.api_path}/* → Ruby, everything else → Next.js)")
155
- t.puts(" #{t.green("✓")} Gateway #{url} #{split}")
156
- t.puts(" #{t.yellow("…")} Ruby API #{t.dim("starting on #{@gateway.api} (internal)")}")
157
- if @gateway.frontend
158
- t.puts(" #{t.yellow("…")} Next.js #{t.dim("starting on #{@gateway.frontend} (internal)")}")
159
- else
160
- t.puts(" #{t.dim("-")} Next.js #{t.dim("no #{@dev.frontend_dir}/package.json — API only")}")
161
- end
162
- t.puts
163
- t.puts(" Application: #{t.bold(url)}")
164
- t.puts
165
- end
166
-
167
- def watch_readiness
168
- [@gateway.api, @gateway.frontend].compact.each do |upstream|
169
- Thread.new do
170
- until @stopping
171
- if Ports.open?(upstream.host, upstream.port)
172
- seconds = (monotonic - @started_at).round(1)
173
- @terminal.line("gemstack", "#{@terminal.green("✓")} #{upstream.label} ready (#{seconds}s)")
174
- break
175
- end
176
- sleep TICK
177
- end
178
- end
179
- end
180
- end
181
-
182
- def install_signal_handlers
183
- %w[INT TERM].each { |signal| trap(signal) { @stopping = true } }
184
- end
185
-
186
- def loop_until_stopped
187
- until @stopping
188
- sleep TICK
189
- supervise
190
- end
191
- end
192
-
193
- def supervise
194
- api = @processes[:api]
195
- if @restart_watcher.changed?
196
- restart_api("configuration changed")
197
- elsif !api.running? && api.exited?
198
- report_exit(api)
199
- restart_api("files changed") if @recovery_watcher.changed?
200
- end
201
- web = @processes[:next]
202
- report_exit(web) if web && !web.running? && web.exited?
203
- supervise_contract
204
- supervise_jobs
205
- end
206
-
207
- # A worker only makes sense with the database queue and its table.
208
- def jobs_enabled?
209
- return false unless @dev.jobs_command && defined?(GemStack::Jobs) && @config.respond_to?(:jobs)
210
- return false unless %w[database postgres].include?(@config.jobs.adapter.to_s)
211
-
212
- Dir.glob(@root.join("db/migrations/*_create_gemstack_jobs.rb").to_s).any?
213
- end
214
-
215
- def start_jobs
216
- @processes[:jobs] = spawn("jobs", @dev.jobs_command, @root, { "GEMSTACK_ENV" => "development" })
217
- end
218
-
219
- # The worker doesn't reload code, so restart it when app/ changes
220
- # (it finishes running jobs first), and start it once the jobs table exists.
221
- def supervise_jobs
222
- return unless @jobs_watcher&.changed?
223
-
224
- jobs = @processes[:jobs]
225
- if jobs&.running?
226
- @terminal.line("gemstack", "app changed — restarting job worker")
227
- jobs.restart
228
- elsif jobs_enabled?
229
- start_jobs
230
- end
231
- end
232
-
233
- # One contract run at a time; changes during a run queue exactly one more.
234
- def supervise_contract
235
- return unless @contract_watcher
236
-
237
- @contract_pending = true if @contract_watcher.changed?
238
- running = @processes[:contract]&.running?
239
- return if running || !@contract_pending
240
-
241
- @contract_pending = false
242
- @processes[:contract] = spawn("contract", @dev.contract_command, @root, { "GEMSTACK_ENV" => "development" })
243
- end
244
-
245
- # Both watchers overlap (config/), so re-baseline both after a restart
246
- # to avoid restarting twice for a single change.
247
- def restart_api(reason)
248
- @terminal.line("gemstack", "#{reason} — restarting Ruby API")
249
- @processes[:api].restart
250
- @restart_watcher.changed?
251
- @recovery_watcher.changed?
252
- @reported_exit = nil
253
- end
254
-
255
- def report_exit(process)
256
- key = [process.name, process.pid]
257
- return if @reported_exit&.include?(key)
258
-
259
- (@reported_exit ||= []) << key
260
- hint = process.name == "api" ? " — fix the error; it restarts when you save a file" : ""
261
- @terminal.line("gemstack", @terminal.red("#{process.name} exited (#{process.describe_status})#{hint}"))
262
- end
263
-
264
- def monotonic = Process.clock_gettime(Process::CLOCK_MONOTONIC)
265
- end
266
- end
267
- end
@@ -1,40 +0,0 @@
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
@@ -1,86 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "open3"
4
- require "rbconfig"
5
-
6
- module GemStack
7
- module Dev
8
- # The Ruby and Node.js a developer runs, and how to change them — for
9
- # whichever version manager installed them (rbenv, rvm, asdf, mise,
10
- # chruby, nvm, fnm, nodenv, Volta, Homebrew…). GemStack pins versions in
11
- # files every manager reads (.ruby-version, .node-version, .nvmrc,
12
- # .tool-versions) and never assumes one manager (DECISIONS D-066).
13
- module Toolchain
14
- MIN_RUBY = "3.3"
15
- MIN_NODE = [20, 9].freeze # Next.js 16
16
- LTS_NODE = "22"
17
-
18
- # [pattern in the executable's path, manager name]; the first match wins.
19
- RUBY_MANAGERS = [
20
- [%r{/\.rbenv/}, :rbenv], [%r{/\.rvm/|/rvm/rubies/}, :rvm], [%r{/\.asdf/|/asdf/installs/}, :asdf],
21
- [%r{/mise/installs/|/\.local/share/mise/}, :mise], [%r{/\.rubies/|/opt/rubies/}, :chruby],
22
- [%r{/(?:opt/)?homebrew/|/usr/local/Cellar/}, :homebrew]
23
- ].freeze
24
- NODE_MANAGERS = [
25
- [%r{/\.nvm/}, :nvm], [%r{/fnm/|/\.fnm/|fnm_multishells}, :fnm], [%r{/\.nodenv/}, :nodenv],
26
- [%r{/\.asdf/|/asdf/installs/}, :asdf], [%r{/mise/installs/|/\.local/share/mise/}, :mise],
27
- [%r{/\.volta/}, :volta], [%r{/(?:opt/)?homebrew/|/usr/local/Cellar/}, :homebrew]
28
- ].freeze
29
-
30
- module_function
31
-
32
- def ruby_manager(path = RbConfig.ruby) = detect(RUBY_MANAGERS, path)
33
- def node_manager(path) = path && detect(NODE_MANAGERS, path)
34
-
35
- def detect(managers, path)
36
- managers.find { |pattern, _| path.to_s.match?(pattern) }&.last
37
- end
38
-
39
- # How to install and select a Ruby, with the manager in use.
40
- def ruby_hint(version, manager = ruby_manager)
41
- {
42
- rbenv: "rbenv install #{version} && rbenv local #{version}",
43
- rvm: "rvm install #{version} && rvm use #{version}",
44
- asdf: "asdf install ruby #{version} && asdf set ruby #{version}",
45
- mise: "mise use ruby@#{version}",
46
- chruby: "ruby-install ruby #{version}, then: chruby #{version}",
47
- homebrew: "brew upgrade ruby (or install #{version} with rbenv, rvm, asdf or mise)"
48
- }.fetch(manager, "install Ruby #{version} (e.g. with rbenv, rvm, asdf or mise) and make it the active Ruby")
49
- end
50
-
51
- # How to install and select a Node.js, with the manager in use.
52
- def node_hint(version, manager)
53
- {
54
- nvm: "nvm install #{version} && nvm use #{version}",
55
- fnm: "fnm install #{version} && fnm use #{version}",
56
- nodenv: "nodenv install #{version} && nodenv local #{version}",
57
- asdf: "asdf install nodejs #{version} && asdf set nodejs #{version}",
58
- mise: "mise use node@#{version}",
59
- volta: "volta install node@#{version}",
60
- homebrew: "brew install node@#{version} (or use nvm, fnm, asdf or mise)"
61
- }.fetch(manager, "install Node.js #{version} (e.g. with nvm, fnm, asdf or mise) and make it the active node")
62
- end
63
-
64
- # { version: "22.11.0", path: "/…/bin/node" } or nil when there's no node.
65
- def node(run = ->(*cmd) { Open3.capture2e(*cmd) })
66
- out, status = run.call("node", "-p", "process.version + ' ' + process.execPath")
67
- return nil unless status.success?
68
-
69
- version, path = out.strip.split(" ", 2)
70
- { version: version.delete_prefix("v"), path: path }
71
- rescue SystemCallError
72
- nil
73
- end
74
-
75
- def node_ok?(version) = (version.to_s.split(".").map(&:to_i) <=> MIN_NODE) >= 0
76
- def ruby_ok?(version = RUBY_VERSION) = Gem::Version.new(version) >= Gem::Version.new(MIN_RUBY)
77
-
78
- # The Node.js version new apps pin: the one running `gemstack new` when
79
- # it's new enough, else the current LTS line.
80
- def pinned_node_version(run = ->(*cmd) { Open3.capture2e(*cmd) })
81
- found = node(run)
82
- found && node_ok?(found[:version]) ? found[:version] : LTS_NODE
83
- end
84
- end
85
- end
86
- end
data/lib/gemstack/dev.rb DELETED
@@ -1,43 +0,0 @@
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
- autoload :Toolchain, "gemstack/dev/toolchain"
17
-
18
- class Config < Settings
19
- # The single public port.
20
- setting :port, default: -> { Integer(ENV.fetch("PORT", 3000)) }
21
- # Interfaces the gateway listens on. Both loopback families, because
22
- # browsers may resolve "localhost" to either.
23
- setting :bind, default: %w[127.0.0.1 ::1]
24
- setting :frontend_dir, default: "frontend"
25
- setting :api_command, default: %w[bundle exec puma -C config/puma.rb]
26
- # nil = run the frontend's own `next` binary in dev mode.
27
- setting :frontend_command, default: nil
28
- # Changes here restart the Ruby process. app/ and config/routes.rb are
29
- # reloaded in-process instead and don't need a restart.
30
- setting :restart_on, default: ["config/**/*.rb", "Gemfile.lock", ".env*"]
31
- setting :restart_exclude, default: ["config/routes.rb"]
32
- # Regenerates TypeScript types/clients when backend code changes
33
- # (only when the app has a frontend). nil disables.
34
- setting :contract_command, default: %w[bundle exec gemstack contract --quiet]
35
- setting :contract_watch, default: ["app/**/*.rb", "config/routes.rb"]
36
- # Background job worker, run when the app uses the :postgres job
37
- # adapter and has the gemstack_jobs migration. Restarted when app/ changes.
38
- setting :jobs_command, default: %w[bundle exec gemstack jobs]
39
- end
40
- end
41
-
42
- Config.namespace(:dev, Dev::Config)
43
- end