gemstack 0.2.5 → 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.
Files changed (199) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +4 -0
  3. data/README.md +7 -3
  4. data/lib/gemstack/cache/memory_store.rb +69 -0
  5. data/lib/gemstack/cache/null_store.rb +18 -0
  6. data/lib/gemstack/cache/redis_store.rb +70 -0
  7. data/lib/gemstack/cache/store.rb +89 -0
  8. data/lib/gemstack/cache.rb +61 -0
  9. data/lib/gemstack/cli/add_generator.rb +258 -0
  10. data/lib/gemstack/cli/app_generator.rb +142 -0
  11. data/lib/gemstack/cli/commands/db.rb +136 -0
  12. data/lib/gemstack/cli/commands/jobs.rb +96 -0
  13. data/lib/gemstack/cli/controller_generator.rb +73 -0
  14. data/lib/gemstack/cli/deploy_generator.rb +102 -0
  15. data/lib/gemstack/cli/doctor/upgrade_check.rb +32 -0
  16. data/lib/gemstack/cli/doctor.rb +311 -0
  17. data/lib/gemstack/cli/generator.rb +161 -0
  18. data/lib/gemstack/cli/job_generator.rb +48 -0
  19. data/lib/gemstack/cli/migration_generator.rb +52 -0
  20. data/lib/gemstack/cli/policy_generator.rb +35 -0
  21. data/lib/gemstack/cli/project.rb +53 -0
  22. data/lib/gemstack/cli/resource_generator.rb +170 -0
  23. data/lib/gemstack/cli/resource_spec.rb +95 -0
  24. data/lib/gemstack/cli.rb +304 -0
  25. data/lib/gemstack/contract/builder.rb +168 -0
  26. data/lib/gemstack/contract/docs/index.html +264 -0
  27. data/lib/gemstack/contract/docs.rb +45 -0
  28. data/lib/gemstack/contract/openapi.rb +123 -0
  29. data/lib/gemstack/contract/typescript.rb +162 -0
  30. data/lib/gemstack/contract.rb +93 -0
  31. data/lib/gemstack/core.rb +129 -0
  32. data/lib/gemstack/db/configuration.rb +148 -0
  33. data/lib/gemstack/db/errors.rb +137 -0
  34. data/lib/gemstack/db/json_compat.rb +21 -0
  35. data/lib/gemstack/db/migrator.rb +80 -0
  36. data/lib/gemstack/db/model.rb +226 -0
  37. data/lib/gemstack/db/schema_types.rb +28 -0
  38. data/lib/gemstack/db/tasks.rb +94 -0
  39. data/lib/gemstack/db/testing.rb +99 -0
  40. data/lib/gemstack/db.rb +210 -0
  41. data/lib/gemstack/dev/file_watcher.rb +58 -0
  42. data/lib/gemstack/dev/gateway.rb +220 -0
  43. data/lib/gemstack/dev/managed_process.rb +96 -0
  44. data/lib/gemstack/dev/ports.rb +26 -0
  45. data/lib/gemstack/dev/supervisor.rb +267 -0
  46. data/lib/gemstack/dev/terminal.rb +40 -0
  47. data/lib/gemstack/dev/toolchain.rb +86 -0
  48. data/lib/gemstack/dev.rb +43 -0
  49. data/lib/gemstack/dotenv.rb +61 -0
  50. data/lib/gemstack/environment.rb +37 -0
  51. data/lib/gemstack/error_mapping.rb +37 -0
  52. data/lib/gemstack/errors.rb +100 -0
  53. data/lib/gemstack/http/app.rb +30 -0
  54. data/lib/gemstack/http/config.rb +84 -0
  55. data/lib/gemstack/http/controller.rb +343 -0
  56. data/lib/gemstack/http/error_page.rb +111 -0
  57. data/lib/gemstack/http/error_renderer.rb +63 -0
  58. data/lib/gemstack/http/json_codec.rb +114 -0
  59. data/lib/gemstack/http/middleware/body_limit.rb +69 -0
  60. data/lib/gemstack/http/middleware/compression.rb +127 -0
  61. data/lib/gemstack/http/middleware/cors.rb +77 -0
  62. data/lib/gemstack/http/middleware/error_handler.rb +39 -0
  63. data/lib/gemstack/http/middleware/etags.rb +24 -0
  64. data/lib/gemstack/http/middleware/health_check.rb +28 -0
  65. data/lib/gemstack/http/middleware/request_id.rb +31 -0
  66. data/lib/gemstack/http/middleware/request_logger.rb +39 -0
  67. data/lib/gemstack/http/middleware/security_headers.rb +31 -0
  68. data/lib/gemstack/http/middleware_stack.rb +96 -0
  69. data/lib/gemstack/http/page.rb +36 -0
  70. data/lib/gemstack/http/params.rb +140 -0
  71. data/lib/gemstack/http/request.rb +64 -0
  72. data/lib/gemstack/http/router.rb +315 -0
  73. data/lib/gemstack/http.rb +41 -0
  74. data/lib/gemstack/inflector.rb +133 -0
  75. data/lib/gemstack/job.rb +154 -0
  76. data/lib/gemstack/jobs/adapters/async.rb +94 -0
  77. data/lib/gemstack/jobs/adapters/database.rb +188 -0
  78. data/lib/gemstack/jobs/adapters/inline.rb +34 -0
  79. data/lib/gemstack/jobs/adapters/sidekiq.rb +65 -0
  80. data/lib/gemstack/jobs/adapters/test.rb +59 -0
  81. data/lib/gemstack/jobs/executor.rb +70 -0
  82. data/lib/gemstack/jobs/testing.rb +55 -0
  83. data/lib/gemstack/jobs/worker.rb +137 -0
  84. data/lib/gemstack/jobs.rb +144 -0
  85. data/lib/gemstack/logger.rb +131 -0
  86. data/lib/gemstack/mail/delivery_job.rb +20 -0
  87. data/lib/gemstack/mail/testing.rb +33 -0
  88. data/lib/gemstack/mail.rb +230 -0
  89. data/lib/gemstack/plugins.rb +38 -0
  90. data/lib/gemstack/schema.rb +251 -0
  91. data/lib/gemstack/serializer.rb +186 -0
  92. data/lib/gemstack/settings.rb +86 -0
  93. data/lib/gemstack/storage/endpoint.rb +112 -0
  94. data/lib/gemstack/storage/services/disk.rb +60 -0
  95. data/lib/gemstack/storage/services/s3.rb +65 -0
  96. data/lib/gemstack/storage/testing.rb +30 -0
  97. data/lib/gemstack/storage.rb +183 -0
  98. data/lib/gemstack/types.rb +163 -0
  99. data/lib/gemstack/version.rb +6 -0
  100. data/lib/gemstack.rb +1 -1
  101. data/templates/app/Gemfile.tt +27 -0
  102. data/templates/app/README.md.tt +29 -0
  103. data/templates/app/app/controllers/application_controller.rb +6 -0
  104. data/templates/app/app/jobs/application_job.rb +9 -0
  105. data/templates/app/app/mailers/application_mailer.rb +8 -0
  106. data/templates/app/app/mailers/templates/dot_keep +0 -0
  107. data/templates/app/app/models/application_model.rb +19 -0
  108. data/templates/app/app/serializers/application_serializer.rb +6 -0
  109. data/templates/app/bin/gemstack +7 -0
  110. data/templates/app/config/app.rb.tt +60 -0
  111. data/templates/app/config/database.yml.tt +61 -0
  112. data/templates/app/config/environments/development.rb.tt +35 -0
  113. data/templates/app/config/environments/production.rb.tt +40 -0
  114. data/templates/app/config/environments/test.rb.tt +21 -0
  115. data/templates/app/config/puma.rb +21 -0
  116. data/templates/app/config/routes.rb +10 -0
  117. data/templates/app/config.ru +6 -0
  118. data/templates/app/db/migrations/dot_keep +0 -0
  119. data/templates/app/db/seeds.rb +5 -0
  120. data/templates/app/dot_env.example.tt +19 -0
  121. data/templates/app/dot_gitignore +16 -0
  122. data/templates/app/dot_node-version.tt +1 -0
  123. data/templates/app/dot_nvmrc.tt +1 -0
  124. data/templates/app/dot_ruby-version.tt +1 -0
  125. data/templates/app/dot_tool-versions.tt +4 -0
  126. data/templates/app/test/health_test.rb +18 -0
  127. data/templates/app/test/test_helper.rb.tt +25 -0
  128. data/templates/auth/app/controllers/api_tokens_controller.rb +35 -0
  129. data/templates/auth/app/controllers/email_verifications_controller.rb +31 -0
  130. data/templates/auth/app/controllers/password_resets_controller.rb +40 -0
  131. data/templates/auth/app/controllers/registrations_controller.rb +19 -0
  132. data/templates/auth/app/controllers/sessions_controller.rb +29 -0
  133. data/templates/auth/app/mailers/auth_mailer.rb +20 -0
  134. data/templates/auth/app/mailers/templates/auth_mailer/email_verification.html.erb +2 -0
  135. data/templates/auth/app/mailers/templates/auth_mailer/email_verification.text.erb +2 -0
  136. data/templates/auth/app/mailers/templates/auth_mailer/password_reset.html.erb +3 -0
  137. data/templates/auth/app/mailers/templates/auth_mailer/password_reset.text.erb +6 -0
  138. data/templates/auth/app/models/auth_token.rb +15 -0
  139. data/templates/auth/app/models/user.rb +12 -0
  140. data/templates/auth/app/policies/application_policy.rb +6 -0
  141. data/templates/auth/app/serializers/api_token_serializer.rb +6 -0
  142. data/templates/auth/app/serializers/new_api_token_serializer.rb +9 -0
  143. data/templates/auth/app/serializers/user_serializer.rb +6 -0
  144. data/templates/auth/db/migrations/%timestamp%_create_auth_tables.rb +41 -0
  145. data/templates/auth/frontend/app/account/page.tsx +116 -0
  146. data/templates/auth/frontend/app/forgot-password/page.tsx +48 -0
  147. data/templates/auth/frontend/app/login/page.tsx +26 -0
  148. data/templates/auth/frontend/app/reset-password/page.tsx +11 -0
  149. data/templates/auth/frontend/app/signup/page.tsx +27 -0
  150. data/templates/auth/frontend/app/verify-email/page.tsx +11 -0
  151. data/templates/auth/frontend/components/auth/CredentialsForm.tsx +58 -0
  152. data/templates/auth/frontend/components/auth/ResetPasswordForm.tsx +43 -0
  153. data/templates/auth/frontend/components/auth/VerifyEmail.tsx +28 -0
  154. data/templates/auth/frontend/lib/auth.ts +113 -0
  155. data/templates/auth/test/controllers/auth_test.rb +108 -0
  156. data/templates/controller/app/controllers/%file_name%_controller.rb.tt +10 -0
  157. data/templates/controller/test/controllers/%file_name%_controller_test.rb.tt +14 -0
  158. data/templates/deploy/Caddyfile.tt +19 -0
  159. data/templates/deploy/Dockerfile.tt +63 -0
  160. data/templates/deploy/Procfile.tt +6 -0
  161. data/templates/deploy/compose.yaml.tt +129 -0
  162. data/templates/deploy/dot_dockerignore +14 -0
  163. data/templates/frontend/app/globals.css +171 -0
  164. data/templates/frontend/app/layout.tsx.tt +19 -0
  165. data/templates/frontend/app/page.module.css +315 -0
  166. data/templates/frontend/app/page.tsx.tt +183 -0
  167. data/templates/frontend/app/providers.tsx +24 -0
  168. data/templates/frontend/lib/gemstack/client.ts +125 -0
  169. data/templates/frontend/next-env.d.ts +5 -0
  170. data/templates/frontend/next.config.ts +17 -0
  171. data/templates/frontend/package.json.tt +23 -0
  172. data/templates/frontend/tsconfig.json +21 -0
  173. data/templates/job/app/jobs/%file_name%.rb.tt +15 -0
  174. data/templates/job/test/jobs/%file_name%_test.rb.tt +15 -0
  175. data/templates/migration/db/migrations/%timestamp%_%file_name%.rb.tt +17 -0
  176. data/templates/policy/app/policies/%file_name%_policy.rb.tt +21 -0
  177. data/templates/policy/test/policies/%file_name%_policy_test.rb.tt +9 -0
  178. data/templates/realtime/config/channels.rb +14 -0
  179. data/templates/realtime/frontend/lib/gemstack/realtime.ts +120 -0
  180. data/templates/resource/controller/app/controllers/%plural%_controller.rb.tt +51 -0
  181. data/templates/resource/controller/test/controllers/%plural%_controller_test.rb.tt +74 -0
  182. data/templates/resource/frontend/frontend/app/%url_segment%/[id]/edit/page.tsx.tt +45 -0
  183. data/templates/resource/frontend/frontend/app/%url_segment%/[id]/page.tsx.tt +49 -0
  184. data/templates/resource/frontend/frontend/app/%url_segment%/new/page.tsx.tt +27 -0
  185. data/templates/resource/frontend/frontend/app/%url_segment%/page.tsx.tt +51 -0
  186. data/templates/resource/frontend/frontend/components/%url_segment%/%class_name%Card.tsx.tt +15 -0
  187. data/templates/resource/frontend/frontend/components/%url_segment%/%class_name%Form.tsx.tt +98 -0
  188. data/templates/resource/frontend/frontend/components/%url_segment%/%class_name%Table.tsx.tt +36 -0
  189. data/templates/resource/frontend/frontend/lib/format.ts +12 -0
  190. data/templates/resource/frontend/frontend/lib/queries/%url_segment%.ts.tt +64 -0
  191. data/templates/resource/migration/db/migrations/%timestamp%_create_%table%.rb.tt +18 -0
  192. data/templates/resource/model/app/models/%file_name%.rb.tt +15 -0
  193. data/templates/resource/model/test/models/%file_name%_test.rb.tt +29 -0
  194. data/templates/resource/serializer/app/serializers/%file_name%_serializer.rb.tt +7 -0
  195. data/templates/storage/app/controllers/uploads_controller.rb.tt +33 -0
  196. data/templates/storage/app/serializers/upload_serializer.rb +11 -0
  197. data/templates/storage/frontend/lib/upload.ts +52 -0
  198. data/templates/storage/test/controllers/uploads_test.rb.tt +30 -0
  199. metadata +251 -39
@@ -0,0 +1,210 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "sequel"
4
+ require "json"
5
+ require "gemstack/core"
6
+ require "gemstack/schema"
7
+
8
+ require_relative "db/json_compat"
9
+ require_relative "db/configuration"
10
+ require_relative "db/schema_types"
11
+
12
+ module GemStack
13
+ # SQL databases through Sequel: PostgreSQL, MySQL
14
+ # (mysql2 or trilogy) and SQLite, configured by config/database.yml or
15
+ # DATABASE_URL. Load it with `require "gemstack/db"` in config/app.rb and add the driver gem
16
+ # (pg, mysql2, trilogy or sqlite3) to the Gemfile — `gemstack new` does.
17
+ #
18
+ # GemStack.db[:products].where(active: true).count # the Sequel::Database
19
+ # GemStack.transaction { order.save; payment.save }
20
+ module DB
21
+ class Config < Settings
22
+ # An explicit database URL; wins over DATABASE_URL and config/database.yml
23
+ # (see DB::Configuration). Test uses TEST_DATABASE_URL, never DATABASE_URL,
24
+ # so a development database from .env can't be wiped by the test suite.
25
+ setting :url, default: nil
26
+ # One connection per server thread by default (database.yml `pool:` wins).
27
+ setting :pool_size, default: lambda {
28
+ Integer(ENV.fetch("GEMSTACK_DB_POOL") { ENV.fetch("GEMSTACK_MAX_THREADS", 5) })
29
+ }
30
+ setting :pool_timeout, default: 5
31
+ setting :migrations_path, default: "db/migrations"
32
+ setting :seeds_path, default: "db/seeds.rb"
33
+ setting :log_queries, default: -> { GemStack.env.development? }
34
+ # Queries slower than this are logged at WARN in every environment.
35
+ setting :slow_query_ms, default: 500
36
+ # Server-side statement timeout in ms (nil = the database's default).
37
+ # PostgreSQL: statement_timeout; MySQL: max_execution_time (SELECTs); SQLite: none.
38
+ setting :statement_timeout, default: nil
39
+ # Sequel extensions to load; nil = pg_json + pg_array on PostgreSQL, none elsewhere.
40
+ setting :extensions, default: nil
41
+ # Extra options passed to Sequel.connect.
42
+ setting :options, default: {}
43
+ end
44
+
45
+ class RecordNotFound < NotFound; end
46
+
47
+ # Stored and compared in UTC on every adapter (MySQL DATETIME and SQLite
48
+ # text have no time zone); PostgreSQL timestamptz is unaffected.
49
+ Sequel.database_timezone = :utc
50
+
51
+ # Adapts Sequel's logging to the GemStack logger: SQL at DEBUG only when
52
+ # config.db.log_queries is on, slow queries at WARN, and failures of the
53
+ # existence probes Sequel runs on purpose (table_exists?, model setup
54
+ # before a migration) are not reported as errors.
55
+ class QueryLogger
56
+ PROBE = /: SELECT (?:NULL AS [`"']nil[`"'] FROM \S+ LIMIT 1|\* FROM \S+ LIMIT 0)\z/
57
+
58
+ def initialize(logger, queries:)
59
+ @logger = logger
60
+ @queries = queries
61
+ end
62
+
63
+ def debug(message) = (@logger.debug(message) if @queries)
64
+ def info(message) = @logger.info(message)
65
+ def warn(message) = @logger.warn(message)
66
+ def error(message) = (@logger.error(message) unless PROBE.match?(message.to_s))
67
+ end
68
+
69
+ @mutex = Mutex.new
70
+
71
+ class << self
72
+ def config = GemStack.config.db
73
+
74
+ def connection
75
+ @connection || @mutex.synchronize { @connection ||= connect }
76
+ end
77
+
78
+ def connected? = !@connection.nil?
79
+
80
+ # The resolved connection settings (a Hash; see DB::Configuration).
81
+ def settings = @settings ||= Configuration.resolve
82
+
83
+ # "postgres", "mysql2", "trilogy" or "sqlite".
84
+ def adapter = settings[:adapter]
85
+ def postgres?(db = nil) = type(db) == :postgres
86
+ def mysql?(db = nil) = type(db) == :mysql
87
+ def sqlite?(db = nil) = type(db) == :sqlite
88
+
89
+ # Sequel's database type (:postgres, :mysql, :sqlite) — from the
90
+ # connection when there is one, so no query is needed.
91
+ def type(db = nil)
92
+ db ||= @connection
93
+ return db.database_type if db
94
+
95
+ { "postgres" => :postgres, "mysql2" => :mysql, "trilogy" => :mysql, "sqlite" => :sqlite }.fetch(adapter)
96
+ end
97
+
98
+ # Builds the Sequel::Database. Connections are opened lazily, so an
99
+ # unavailable database doesn't prevent the application from booting.
100
+ def connect(target = settings)
101
+ target = Configuration.from_url(target, root: GemStack.config.root) if target.is_a?(String)
102
+ opts = target.except(:source)
103
+ options = { max_connections: opts.delete(:max_connections) || config.pool_size,
104
+ pool_timeout: config.pool_timeout, test: false, keep_reference: false }
105
+ options.merge!(adapter_options(opts[:adapter], opts[:database]))
106
+ db = open_database(opts.merge(options).merge(config.options))
107
+ configure(db)
108
+ Sequel::Model.db = db
109
+ db
110
+ end
111
+
112
+ # Closes pooled connections (e.g. before Puma forks workers). The
113
+ # Database object stays in place and reconnects lazily: models hold a
114
+ # reference to it, so replacing it would split them from GemStack.db.
115
+ def disconnect
116
+ @connection&.disconnect
117
+ end
118
+
119
+ def transaction(**, &) = connection.transaction(**, &)
120
+
121
+ # Forgets the resolved settings and the connection (tests, console).
122
+ def reset!
123
+ @connection&.disconnect
124
+ @connection = nil
125
+ @settings = nil
126
+ end
127
+
128
+ # True when the table isn't there — one catalog query, nothing logged
129
+ # (Sequel's table_exists? probes with a failing SELECT).
130
+ def table_missing?(db, table)
131
+ name = table.to_s
132
+ case db.database_type
133
+ when :postgres then db.get(Sequel.function(:to_regclass, db.literal(table))).nil?
134
+ when :mysql
135
+ db[Sequel[:information_schema][:tables]].where(table_schema: Sequel.function(:database),
136
+ table_name: name).empty?
137
+ when :sqlite then db[:sqlite_master].where(type: %w[table view], name: name).empty?
138
+ else !db.table_exists?(table)
139
+ end
140
+ end
141
+
142
+ private
143
+
144
+ def configure(db)
145
+ extensions = config.extensions || (postgres?(db) ? %i[pg_json pg_array] : [])
146
+ db.extension(*extensions) unless extensions.empty?
147
+ db.log_warn_duration = config.slow_query_ms / 1000.0 if config.slow_query_ms
148
+ db.loggers << QueryLogger.new(GemStack.logger, queries: config.log_queries)
149
+ db.sql_log_level = :debug
150
+ end
151
+
152
+ def open_database(options)
153
+ Sequel.connect(**options)
154
+ rescue Sequel::AdapterNotFound, LoadError => e
155
+ gem_name = Configuration::DRIVER_GEMS.fetch(options[:adapter], options[:adapter])
156
+ raise ConfigurationError, "#{options[:adapter]} needs gem \"#{gem_name}\" in the Gemfile (#{e.message})"
157
+ end
158
+
159
+ def adapter_options(adapter, database)
160
+ timeout = config.statement_timeout && Integer(config.statement_timeout)
161
+ case adapter
162
+ when "postgres" then timeout ? { connect_sqls: ["SET statement_timeout = #{timeout}"] } : {}
163
+ when "mysql2", "trilogy"
164
+ sqls = timeout ? ["SET SESSION max_execution_time = #{timeout}"] : []
165
+ { encoding: "utf8mb4", connect_sqls: sqls }
166
+ when "sqlite"
167
+ # Several Puma threads share the file: wait for the write lock instead
168
+ # of failing, and let readers work while one connection writes.
169
+ sqls = database == ":memory:" ? [] : ["PRAGMA journal_mode = WAL", "PRAGMA synchronous = NORMAL"]
170
+ { timeout: 5_000, connect_sqls: sqls }
171
+ else {}
172
+ end
173
+ end
174
+ end
175
+ end
176
+
177
+ # A model base class bound to an explicit table, like Sequel::Model(:table):
178
+ # class Item < GemStack::Model(:inventory_items)
179
+ def self.Model(source) = Model.Model(source) # rubocop:disable Naming/MethodName
180
+
181
+ class << self
182
+ def db = DB.connection
183
+ def transaction(**, &) = DB.transaction(**, &)
184
+ end
185
+ end
186
+
187
+ require_relative "db/model"
188
+ require_relative "db/errors"
189
+ require_relative "db/migrator"
190
+ require_relative "db/tasks"
191
+
192
+ GemStack::Config.namespace(:db, GemStack::DB::Config)
193
+
194
+ GemStack::Plugins.register(:db) do |app|
195
+ GemStack::DB.connection
196
+ app.on_shutdown { GemStack::DB.disconnect } if app.respond_to?(:on_shutdown)
197
+
198
+ # A friendly nudge in development; never blocks boot.
199
+ if GemStack.env.development?
200
+ begin
201
+ pending = GemStack::DB::Migrator.new.pending
202
+ unless pending.empty?
203
+ GemStack.logger.warn("#{pending.size} pending migration(s) — run `gemstack db:migrate`",
204
+ first: pending.first.file)
205
+ end
206
+ rescue Sequel::Error
207
+ nil # database not reachable yet; requests will report it
208
+ end
209
+ end
210
+ end
@@ -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.
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