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 +7 -0
- data/CHANGELOG.md +5 -0
- data/LICENSE.txt +21 -0
- data/README.md +24 -0
- data/lib/gemstack/dev/file_watcher.rb +58 -0
- data/lib/gemstack/dev/gateway.rb +220 -0
- data/lib/gemstack/dev/managed_process.rb +96 -0
- data/lib/gemstack/dev/ports.rb +26 -0
- data/lib/gemstack/dev/supervisor.rb +252 -0
- data/lib/gemstack/dev/terminal.rb +40 -0
- data/lib/gemstack/dev.rb +42 -0
- metadata +68 -0
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
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
|
data/lib/gemstack/dev.rb
ADDED
|
@@ -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: []
|