monkrb 0.15.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 (80) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +581 -0
  3. data/LICENSE.txt +21 -0
  4. data/README.md +88 -0
  5. data/exe/monk +116 -0
  6. data/lib/monk/assets.rb +197 -0
  7. data/lib/monk/auth/errors.rb +13 -0
  8. data/lib/monk/auth/helpers.rb +76 -0
  9. data/lib/monk/auth/login_token.rb +11 -0
  10. data/lib/monk/auth/rate_limiter.rb +46 -0
  11. data/lib/monk/auth/session.rb +11 -0
  12. data/lib/monk/auth.rb +301 -0
  13. data/lib/monk/base.rb +323 -0
  14. data/lib/monk/context.rb +78 -0
  15. data/lib/monk/environment.rb +50 -0
  16. data/lib/monk/errors.rb +37 -0
  17. data/lib/monk/freeze_hooks.rb +23 -0
  18. data/lib/monk/live/client/idiomorph.LICENSE +13 -0
  19. data/lib/monk/live/client/idiomorph.js +4 -0
  20. data/lib/monk/live/client/monk_live.js +204 -0
  21. data/lib/monk/live/client/protocol.js +87 -0
  22. data/lib/monk/live/envelope.rb +51 -0
  23. data/lib/monk/live/errors.rb +9 -0
  24. data/lib/monk/live/helpers.rb +22 -0
  25. data/lib/monk/live/policy.rb +58 -0
  26. data/lib/monk/live/publisher.rb +91 -0
  27. data/lib/monk/live/renderer.rb +47 -0
  28. data/lib/monk/live/session.rb +121 -0
  29. data/lib/monk/live.rb +96 -0
  30. data/lib/monk/log.rb +130 -0
  31. data/lib/monk/persistence/errors.rb +7 -0
  32. data/lib/monk/persistence/model.rb +41 -0
  33. data/lib/monk/persistence/pg/errors.rb +4 -0
  34. data/lib/monk/persistence/pg/migrator.rb +165 -0
  35. data/lib/monk/persistence/pg/model.rb +233 -0
  36. data/lib/monk/persistence/pg.rb +34 -0
  37. data/lib/monk/persistence.rb +113 -0
  38. data/lib/monk/scaffold.rb +606 -0
  39. data/lib/monk/settings.rb +151 -0
  40. data/lib/monk/state_ractor.rb +45 -0
  41. data/lib/monk/templates/auth/config/auth.rb +28 -0
  42. data/lib/monk/templates/auth/db/migrate/00000000000001_create_auth_tables.down.sql +2 -0
  43. data/lib/monk/templates/auth/db/migrate/00000000000001_create_auth_tables.up.sql +18 -0
  44. data/lib/monk/templates/base/.dockerignore +5 -0
  45. data/lib/monk/templates/base/.gitignore +4 -0
  46. data/lib/monk/templates/base/.ruby-version +1 -0
  47. data/lib/monk/templates/base/Dockerfile +28 -0
  48. data/lib/monk/templates/base/Gemfile +7 -0
  49. data/lib/monk/templates/base/bin/server +5 -0
  50. data/lib/monk/templates/base/bin/websocket_server +62 -0
  51. data/lib/monk/templates/base/config/settings.rb +30 -0
  52. data/lib/monk/templates/base/config.ru +13 -0
  53. data/lib/monk/templates/base/public/css/app.css +17 -0
  54. data/lib/monk/templates/base/public/js/app.js +5 -0
  55. data/lib/monk/templates/base/views/index.erb +6 -0
  56. data/lib/monk/templates/base/views/layouts/app.erb +18 -0
  57. data/lib/monk/templates/live/bin/websocket_server +30 -0
  58. data/lib/monk/templates/live/config/live.rb +47 -0
  59. data/lib/monk/templates/live/config.ru +27 -0
  60. data/lib/monk/templates/live/views/index.erb +18 -0
  61. data/lib/monk/templates/live/views/live/_hits.erb +1 -0
  62. data/lib/monk/templates/postgres/Dockerfile +30 -0
  63. data/lib/monk/templates/postgres/Gemfile.extra +2 -0
  64. data/lib/monk/templates/postgres/bin/console +7 -0
  65. data/lib/monk/templates/postgres/bin/migrate +22 -0
  66. data/lib/monk/templates/postgres/bin/setup_db +9 -0
  67. data/lib/monk/templates/postgres/config/persistence.rb +10 -0
  68. data/lib/monk/templates/redis/Gemfile.extra +1 -0
  69. data/lib/monk/version.rb +9 -0
  70. data/lib/monk/views.rb +175 -0
  71. data/lib/monk/websocket/connection.rb +226 -0
  72. data/lib/monk/websocket/errors.rb +9 -0
  73. data/lib/monk/websocket/frame.rb +71 -0
  74. data/lib/monk/websocket/handshake.rb +77 -0
  75. data/lib/monk/websocket/redis_fanout.rb +103 -0
  76. data/lib/monk/websocket/registry.rb +92 -0
  77. data/lib/monk/websocket/server.rb +234 -0
  78. data/lib/monk/websocket.rb +19 -0
  79. data/lib/monk.rb +45 -0
  80. metadata +252 -0
data/lib/monk/auth.rb ADDED
@@ -0,0 +1,301 @@
1
+ require "securerandom"
2
+ require "digest"
3
+ require "openssl"
4
+
5
+ require_relative "freeze_hooks"
6
+ require_relative "auth/errors"
7
+ require_relative "auth/login_token"
8
+ require_relative "auth/session"
9
+ require_relative "auth/helpers"
10
+ require_relative "auth/rate_limiter"
11
+
12
+ module Monk
13
+ # Passwordless token auth. Opt-in: require "monk/auth" explicitly --
14
+ # `require "monk"` alone does not load this, since it depends on a
15
+ # persistence backend the app may not use (docs/design/auth-sessions.md).
16
+ module Auth
17
+ REQUIRED_CONFIG_KEYS = %i[db_name secret login_ttl session_ttl].freeze
18
+
19
+ class << self
20
+ # Called from Base#freeze! (Seam B), via Monk.freeze_hooks. Freezes
21
+ # the value, not the module -- Monk::Auth is always Ractor.shareable?
22
+ # regardless of its ivars, so freezing the module itself would do
23
+ # nothing (docs/design/persistence-ractor-connections.md "Phase 4/5 finding").
24
+ def freeze_registry!
25
+ begin
26
+ @config = Ractor.make_shareable(@config)
27
+ rescue ArgumentError, Ractor::IsolationError => e
28
+ raise Monk::UnshareableBlockError,
29
+ "Monk::Auth.configure(deliver:) is not Ractor-shareable: #{e.message} " \
30
+ "(build it where self is shareable, e.g. as a module constant at class/module-body " \
31
+ "scope -- self at a script's top level, e.g. config/auth.rb itself, is not shareable, " \
32
+ "same constraint as Monk::Live.authorize blocks)"
33
+ end
34
+ freeze_rqrcode!
35
+ end
36
+
37
+ def configure(
38
+ db_name: nil, secret: nil, login_ttl: nil, session_ttl: nil, redirect_allowlist: [], secure: true,
39
+ deliver: nil
40
+ )
41
+ config = {
42
+ db_name: db_name, secret: secret, login_ttl: login_ttl, session_ttl: session_ttl,
43
+ redirect_allowlist: redirect_allowlist, secure: secure, deliver: deliver,
44
+ }
45
+
46
+ REQUIRED_CONFIG_KEYS.each do |key|
47
+ raise Monk::MissingAuthConfigError,
48
+ "Monk::Auth.configure is missing required key #{key.inspect}" if config[key].nil?
49
+ end
50
+
51
+ LoginToken.db_name = db_name
52
+ Session.db_name = db_name
53
+
54
+ @config = config
55
+ end
56
+
57
+ def config
58
+ @config
59
+ end
60
+
61
+ # Test-only: drops the current config. Not part of the app-facing API.
62
+ def reset!
63
+ @config = nil
64
+ end
65
+
66
+ def request_login(email, redirect_to: nil)
67
+ ensure_configured!
68
+ if redirect_to && !config[:redirect_allowlist].include?(redirect_to)
69
+ raise Monk::InvalidRedirectError, "#{redirect_to.inspect} is not in Monk::Auth's redirect_allowlist"
70
+ end
71
+
72
+ raw = SecureRandom.urlsafe_base64(32)
73
+
74
+ LoginToken.create(
75
+ email: email,
76
+ token_hash: hash_token(raw),
77
+ redirect_to: redirect_to,
78
+ expires_at: Time.now + config[:login_ttl],
79
+ )
80
+
81
+ raw
82
+ end
83
+
84
+ # The single place an app's login-request handler calls to get a
85
+ # magic link to its owner: uses the `deliver:` callable passed to
86
+ # `configure` (e.g. wrapping a mailer or a provider's HTTP API) when
87
+ # one is set, regardless of environment -- an app that wants to
88
+ # exercise real delivery in development is free to. With no
89
+ # `deliver:` configured, falls back to #log_dev_link in development
90
+ # (see below), and raises everywhere else: a silent no-op here would
91
+ # mean an app that forgot to wire delivery finds out only when a
92
+ # user reports never receiving their link, in production, which is
93
+ # the wrong place to fail (docs/design/auth-sessions.md's "Email
94
+ # delivery stays outside the framework" -- Monk still needs to know
95
+ # it was told to send *something*).
96
+ #
97
+ # `link` is the app's own job to build (Monk doesn't own routing, so
98
+ # it can't know the callback path) -- from a trusted origin, e.g.
99
+ # Monk::Settings[:public_url], never from request headers like
100
+ # X-Forwarded-Proto/Host, which any direct client can spoof.
101
+ # `token` is passed through for a deliver: that wants it (an SMS
102
+ # body instead of a link, say); most won't need it.
103
+ def deliver_link(email:, link:, token: nil)
104
+ ensure_configured!
105
+
106
+ if config[:deliver]
107
+ config[:deliver].call(email: email, link: link, token: token)
108
+ elsif Monk.env.development?
109
+ log_dev_link(link, subject: email)
110
+ else
111
+ raise MissingAuthDeliveryError,
112
+ "Monk::Auth.configure(deliver:) is not set, and this isn't development -- " \
113
+ "#{email.inspect}'s magic link would never be sent. Configure a deliver: " \
114
+ "callable (wrapping your mailer or provider's API), or call " \
115
+ "Monk::Auth.log_dev_link directly if that's truly intended here."
116
+ end
117
+ end
118
+
119
+ # Convenience for an app's own login-request handler: prints a
120
+ # magic link to the dev console (and log/development.log via
121
+ # Monk::Log.info), plus a scannable QR code beneath it if the
122
+ # optional `rqrcode` gem is in the app's own Gemfile -- makes
123
+ # testing the login flow from a second device (another browser, a
124
+ # phone) trivial without a mailer. A no-op outside development,
125
+ # same posture as every other dev-only escape hatch in this
126
+ # framework (docs/history/secure-cookie-dev-http.md). #deliver_link
127
+ # above is what an app should call day to day; this is its
128
+ # development-only fallback and is still fine to call directly.
129
+ #
130
+ # subject: is optional context for the printed line only (e.g. the
131
+ # email being logged in) -- useful once more than one login is in
132
+ # flight at a time (two test users, two devices), never persisted
133
+ # or otherwise part of the token itself.
134
+ #
135
+ # rqrcode is opt-in, same as pg/redis: not a runtime dependency of
136
+ # monk itself (see monk.gemspec) -- an app that wants the QR code
137
+ # declares it in its own Gemfile. Without it, this still logs the
138
+ # plain link, just no QR beneath it.
139
+ def log_dev_link(link, subject: nil)
140
+ return unless Monk.env.development?
141
+
142
+ line = subject ? "[dev] magic link for #{subject}: #{link}" : "[dev] magic link: #{link}"
143
+ $stdout.puts(line)
144
+ $stdout.flush
145
+ Monk::Log.info(line)
146
+
147
+ begin
148
+ require "rqrcode"
149
+ rescue LoadError
150
+ return
151
+ end
152
+
153
+ $stdout.puts(compact_qr(RQRCode::QRCode.new(link, level: :l)))
154
+ end
155
+
156
+ def redeem(raw)
157
+ ensure_configured!
158
+ return nil if raw.nil? || raw.empty?
159
+
160
+ row = LoginToken.where(token_hash: hash_token(raw)).first
161
+ return nil unless row
162
+ return nil if row[:expires_at] <= Time.now
163
+
164
+ claimed = LoginToken.claim({ id: row[:id], used_at: nil }, used_at: Time.now)
165
+ return nil unless claimed
166
+
167
+ session_raw = SecureRandom.urlsafe_base64(32)
168
+ expires_at = Time.now + config[:session_ttl]
169
+ Session.create(subject: row[:email], token_hash: hash_token(session_raw), expires_at: expires_at)
170
+
171
+ { token: session_raw, subject: row[:email], expires_at: expires_at, redirect_to: row[:redirect_to] }
172
+ end
173
+
174
+ def verify(raw)
175
+ ensure_configured!
176
+ return nil if raw.nil? || raw.empty?
177
+
178
+ row = Session.where(token_hash: hash_token(raw)).first
179
+ return nil unless row
180
+ return nil if row[:revoked_at]
181
+ return nil if row[:expires_at] <= Time.now
182
+
183
+ row[:subject]
184
+ end
185
+
186
+ def revoke(raw)
187
+ ensure_configured!
188
+ row = Session.where(token_hash: hash_token(raw)).first
189
+ return false unless row
190
+
191
+ Session.update(row[:id], revoked_at: Time.now)
192
+ true
193
+ end
194
+
195
+ def revoke_all(subject)
196
+ ensure_configured!
197
+ rows = Session.where(subject: subject).select { |row| row[:revoked_at].nil? }
198
+ now = Time.now
199
+ rows.each { |row| Session.update(row[:id], revoked_at: now) }
200
+ rows.size
201
+ end
202
+
203
+ # Deliberately not grown onto Model: a `<` comparison is real
204
+ # query-DSL scope for a hygiene task (docs/design/auth-sessions.md).
205
+ def sweep!
206
+ ensure_configured!
207
+ Monk::Persistence::Pg.checkout(config[:db_name]) do |conn|
208
+ now = Time.now
209
+ login_tokens_deleted = conn.exec_params("DELETE FROM login_tokens WHERE expires_at < $1", [now]).cmd_tuples
210
+ sessions_deleted = conn.exec_params("DELETE FROM sessions WHERE expires_at < $1", [now]).cmd_tuples
211
+ { login_tokens: login_tokens_deleted, sessions: sessions_deleted }
212
+ end
213
+ end
214
+
215
+ # Stateless double-submit CSRF token, derived not stored
216
+ # (docs/design/auth-sessions.md's "CSRF: stateless double-submit, no third
217
+ # table") -- the one HMAC implementation set_session_cookie and
218
+ # require_csrf! both call, so there's no second place this could
219
+ # drift out of sync.
220
+ def csrf_token_for(session_token)
221
+ OpenSSL::HMAC.hexdigest("SHA256", config[:secret], session_token)
222
+ end
223
+
224
+ private
225
+
226
+ # as_ansi spends two columns and one line per module, so even a
227
+ # short link fills the terminal. Half-block characters pack two
228
+ # module rows into one line and one column per module (roughly a
229
+ # quarter of the area); the explicit black-on-white colors keep it
230
+ # scannable on dark terminals too. Quiet zone is 2 modules, below
231
+ # the spec's 4 but reliable for phone cameras against a white
232
+ # background.
233
+ def compact_qr(qr, quiet_zone: 2)
234
+ width = qr.modules.size + quiet_zone * 2
235
+ blank = Array.new(quiet_zone) { Array.new(width, false) }
236
+ grid = blank + qr.modules.map { |row| Array.new(quiet_zone, false) + row + Array.new(quiet_zone, false) } + blank
237
+ grid << Array.new(width, false) if grid.size.odd?
238
+
239
+ grid.each_slice(2).map do |top, bottom|
240
+ cells = top.zip(bottom).map do |t, b|
241
+ if t && b then "\u2588"
242
+ elsif t then "\u2580"
243
+ elsif b then "\u2584"
244
+ else " "
245
+ end
246
+ end
247
+ # dark modules are the glyph (black fg) on a white bg
248
+ "\e[30;47m#{cells.join}\e[0m"
249
+ end.join("\n")
250
+ end
251
+
252
+ def ensure_configured!
253
+ raise Monk::AuthNotConfiguredError,
254
+ "Monk::Auth is not configured -- call Monk::Auth.configure first" unless @config
255
+ end
256
+
257
+ def hash_token(raw)
258
+ Digest::SHA256.hexdigest(raw)
259
+ end
260
+
261
+ # rqrcode (used by log_dev_link) is a third-party gem with no
262
+ # Ractor awareness of its own: several of its lookup tables
263
+ # (RQRCodeCore::QRUtil::PATTERN_POSITION_TABLE and siblings) are
264
+ # ordinary, unfrozen constants. Reading one of those from a worker
265
+ # Ractor -- which is exactly what happens the first time
266
+ # log_dev_link runs inside a real request under Kino -- raises
267
+ # Ractor::IsolationError regardless of which Ractor originally
268
+ # required the gem; only the object's own shareability matters
269
+ # (confirmed live: requiring rqrcode in the main Ractor first does
270
+ # not help). Walking its constants here, at boot in the main
271
+ # Ractor, and freezing each one is the same fix
272
+ # docs/design/persistence-ractor-connections.md documents for this exact
273
+ # class of problem elsewhere in the stack. A no-op if the app
274
+ # hasn't added rqrcode to its own Gemfile (see log_dev_link).
275
+ def freeze_rqrcode!
276
+ require "rqrcode"
277
+ rescue LoadError
278
+ nil
279
+ else
280
+ freeze_constants!(RQRCodeCore)
281
+ freeze_constants!(RQRCode)
282
+ end
283
+
284
+ def freeze_constants!(mod, seen = {}.compare_by_identity)
285
+ return if seen[mod]
286
+ seen[mod] = true
287
+
288
+ mod.constants(false).each do |name|
289
+ value = mod.const_get(name)
290
+ if value.is_a?(Module)
291
+ freeze_constants!(value, seen) if value.name&.start_with?("RQRCode")
292
+ else
293
+ Ractor.make_shareable(value)
294
+ end
295
+ end
296
+ end
297
+ end
298
+
299
+ Monk.freeze_hooks << self
300
+ end
301
+ end
data/lib/monk/base.rb ADDED
@@ -0,0 +1,323 @@
1
+ require "json"
2
+ require "uri"
3
+
4
+ require_relative "freeze_hooks"
5
+ require_relative "environment"
6
+
7
+ module Monk
8
+ class Base
9
+ VERBS = %w[GET POST PUT PATCH DELETE].freeze
10
+ private_constant :VERBS
11
+
12
+ EMPTY_ARRAY = [].freeze
13
+ private_constant :EMPTY_ARRAY
14
+
15
+ # Action name -> path suffix (appended to the resource path) and verb(s)
16
+ # it dispatches on, for #resources. :update registers both PATCH and PUT --
17
+ # PATCH is the canonical partial-update verb, PUT accepted too since
18
+ # plenty of clients only ever send it for "update this resource".
19
+ REST_ACTIONS = {
20
+ index: { verbs: %w[GET], path: "" },
21
+ new: { verbs: %w[GET], path: "/new" },
22
+ create: { verbs: %w[POST], path: "" },
23
+ show: { verbs: %w[GET], path: "/:id" },
24
+ edit: { verbs: %w[GET], path: "/:id/edit" },
25
+ update: { verbs: %w[PATCH PUT], path: "/:id" },
26
+ destroy: { verbs: %w[DELETE], path: "/:id" },
27
+ }.freeze
28
+ private_constant :REST_ACTIONS
29
+
30
+ class << self
31
+ VERBS.each do |verb|
32
+ define_method(verb.downcase) do |path, &block|
33
+ routes << { verb: verb, path: path, block: block }
34
+ end
35
+ end
36
+
37
+ def routes
38
+ @routes ||= []
39
+ end
40
+
41
+ # EXPERIMENTAL: this is the one place Base's routing DSL departs
42
+ # from "verb(path) { block }" -- a controller class plus a list of
43
+ # action symbols, instead of a block. Not yet convinced that's the
44
+ # right shape (see monk-consumer-test session notes); may be
45
+ # reworked or removed rather than kept as-is.
46
+ #
47
+ # Registers a conventional set of REST routes rooted at `path` (e.g.
48
+ # "/orders" -> "/orders", "/orders/new", "/orders/:id", ...), each
49
+ # dispatching to `controller.new(context).public_send(action)`.
50
+ # `actions` defaults to all seven (index/new/create/show/edit/
51
+ # update/destroy); pass a subset to only wire up what `controller`
52
+ # actually implements -- an action not listed here raises
53
+ # ArgumentError immediately, rather than registering a route to a
54
+ # method that doesn't exist.
55
+ #
56
+ # Built entirely on top of #get/#post/#put/#patch/#delete above --
57
+ # no change to routing, dispatch, or freeze!, so an app that never
58
+ # calls #resources is unaffected.
59
+ def resources(path, controller, *actions)
60
+ actions = REST_ACTIONS.keys if actions.empty?
61
+ path = path.to_s.delete_prefix("/")
62
+
63
+ actions.each do |action|
64
+ mapping = REST_ACTIONS.fetch(action) do
65
+ raise ArgumentError, "unknown REST action #{action.inspect} (known: #{REST_ACTIONS.keys.join(", ")})"
66
+ end
67
+
68
+ route = "/#{path}#{mapping[:path]}"
69
+ mapping[:verbs].each { |verb| send(verb.downcase, route) { controller.new(self).public_send(action) } }
70
+ end
71
+ end
72
+
73
+ # Where .erb templates live (default "views"), relative to the
74
+ # process's working directory and read at Boot.
75
+ def views(dir)
76
+ Monk::Views.root = dir
77
+ end
78
+
79
+ # Default layout template wrapped around every render, e.g.
80
+ # layout "layouts/app". Individual renders opt out with
81
+ # `render "x", layout: false`.
82
+ def layout(name)
83
+ Monk::Views.layout = name
84
+ end
85
+
86
+ # Where static files live (default "public"); `assets false`
87
+ # disables serving them.
88
+ def assets(dir)
89
+ Monk::Assets.root = dir
90
+ end
91
+
92
+ def freeze!
93
+ routes.each do |route|
94
+ begin
95
+ Ractor.make_shareable(route[:block])
96
+ rescue ArgumentError => e
97
+ raise UnshareableRouteError, "#{route[:verb]} #{route[:path]} is not Ractor-shareable: #{e.message}"
98
+ end
99
+ end
100
+
101
+ error_handlers.each do |matcher, block|
102
+ begin
103
+ Ractor.make_shareable(block)
104
+ rescue ArgumentError => e
105
+ raise UnshareableRouteError, "error handler for #{matcher.inspect} is not Ractor-shareable: #{e.message}"
106
+ end
107
+ end
108
+
109
+ Monk.freeze!
110
+
111
+ # Settled once here, not read from ENV per request in
112
+ # #log_request: ENV is main-Ractor state, so a per-request read
113
+ # from a worker Ractor is the same hazard Monk::Assets already
114
+ # guards against the same way. Gates the $stdout echo only --
115
+ # Monk::Log's file write is unconditional, every environment.
116
+ @console_logging = Monk.env.development?
117
+
118
+ index_routes!
119
+
120
+ Ractor.make_shareable(routes)
121
+ Ractor.make_shareable(@static_routes)
122
+ Ractor.make_shareable(@dynamic_routes)
123
+ Ractor.make_shareable(error_handlers)
124
+ self
125
+ end
126
+
127
+ def error(matcher, &block)
128
+ error_handlers << [matcher, block]
129
+ end
130
+
131
+ def error_handlers
132
+ @error_handlers ||= []
133
+ end
134
+
135
+ def call(env)
136
+ freeze! unless Ractor.shareable?(routes)
137
+
138
+ start = Process.clock_gettime(Process::CLOCK_MONOTONIC)
139
+ status, headers, body = dispatch(env)
140
+ log_request(env, status, start)
141
+ [status, headers, body]
142
+ end
143
+
144
+ private
145
+
146
+ def dispatch(env)
147
+ # Assets before routes, the position Rack::Static would occupy in
148
+ # front of the app -- so a catch-all splat route can't shadow a
149
+ # stylesheet. The tradeoff, worth knowing: a route can't override
150
+ # a path that exists as a file.
151
+ asset = Monk::Assets.response(env)
152
+ return asset if asset
153
+
154
+ route, path_params = find_route(env["REQUEST_METHOD"], env["PATH_INFO"])
155
+ return not_found_response(env) unless route
156
+
157
+ params = parse_params(env).merge(path_params)
158
+ context = Context.new(params, env)
159
+ catch(:monk_halt) do
160
+ begin
161
+ body = context.instance_exec(context, &route[:block])
162
+ [200, context.headers, [body]]
163
+ rescue StandardError => e
164
+ handler = error_handlers.find { |matcher, _| matcher.is_a?(Class) && e.is_a?(matcher) }
165
+ if handler
166
+ context.status = 500
167
+ body = context.instance_exec(context, &handler.last)
168
+ [500, context.headers, [body]]
169
+ else
170
+ [500, { "content-type" => "application/json" }, ['{"error":"Internal Server Error"}']]
171
+ end
172
+ end
173
+ end
174
+ end
175
+
176
+ def log_request(env, status, start)
177
+ duration_ms = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - start) * 1000).round(1)
178
+ line = "#{Monk::Log.timestamp} #{env["REQUEST_METHOD"]} #{env["PATH_INFO"]} -> #{status} (#{duration_ms}ms)"
179
+
180
+ if @console_logging
181
+ $stdout.puts line
182
+ # Each worker Ractor buffers $stdout independently; without an explicit
183
+ # flush, lines only surface when the process exits, not in real time.
184
+ $stdout.flush
185
+ end
186
+
187
+ Monk::Log.write("#{line}\n")
188
+ end
189
+
190
+ def not_found_response(env)
191
+ handler = error_handlers.find { |matcher, _| matcher == 404 }
192
+ return [404, {}, [""]] unless handler
193
+
194
+ context = Context.new({}, env, status: 404)
195
+ catch(:monk_halt) do
196
+ body = context.instance_exec(context, &handler.last)
197
+ [404, context.headers, [body]]
198
+ end
199
+ end
200
+
201
+ # Query string, then a JSON body on top (POST /auth/request's
202
+ # redirect_to; a callback token as ?token=... instead of a path
203
+ # segment -- docs/history/plan-auth.md Phase 9 step 29). Path segment params
204
+ # always win the final merge in #dispatch -- the route's own
205
+ # declared intent outranks anything a caller supplies.
206
+ def parse_params(env)
207
+ parse_query_string(env["QUERY_STRING"]).merge(parse_json_body(env))
208
+ end
209
+
210
+ # URI.decode_www_form, not Rack::Utils.parse_nested_query -- the
211
+ # latter memoizes an unfrozen QueryParser instance in a module ivar
212
+ # (Rack::Utils.default_query_parser) the moment "rack/utils" loads,
213
+ # in the main Ractor, and reading it back from a worker Ractor raises
214
+ # Ractor::IsolationError regardless of which Ractor set it. A bug in
215
+ # rack itself (not yet Ractor-safe), measured directly against a real
216
+ # worker Ractor rather than assumed -- no nested/array query syntax,
217
+ # consistent with Monk's minimal query surface elsewhere.
218
+ def parse_query_string(query_string)
219
+ return {} if query_string.to_s.empty?
220
+
221
+ URI.decode_www_form(query_string).each_with_object({}) { |(k, v), h| h[k.to_sym] = v }
222
+ end
223
+
224
+ def parse_json_body(env)
225
+ return {} unless env["CONTENT_TYPE"].to_s.include?("application/json")
226
+
227
+ body = env["rack.input"]&.read.to_s
228
+ return {} if body.empty?
229
+
230
+ JSON.parse(body, symbolize_names: true)
231
+ rescue JSON::ParserError
232
+ {}
233
+ end
234
+
235
+ # Routes are static (literal path, no ":param" or trailing "*") in
236
+ # the overwhelming common case, so freeze! (#index_routes!) splits
237
+ # them into an O(1) exact-match table up front; only routes that
238
+ # actually need segment-by-segment matching pay for it at request
239
+ # time. Both structures hold the exact same route Hash objects that
240
+ # `routes` does, so making `routes` shareable would freeze them too
241
+ # -- but since they're not reachable *from* `routes`, they need
242
+ # their own Ractor.make_shareable call in #freeze!.
243
+ def index_routes!
244
+ static = {}
245
+ dynamic = {}
246
+ VERBS.each do |verb|
247
+ static[verb] = {}
248
+ dynamic[verb] = []
249
+ end
250
+
251
+ routes.each do |route|
252
+ segments = route[:path].split("/")
253
+ route[:segments] = segments
254
+
255
+ if segments.last == "*"
256
+ route[:prefix_segments] = segments[0...-1]
257
+ dynamic[route[:verb]] << route
258
+ elsif segments.any? { |s| s.start_with?(":") }
259
+ dynamic[route[:verb]] << route
260
+ else
261
+ static[route[:verb]][route[:path]] = route
262
+ end
263
+ end
264
+
265
+ @static_routes = static
266
+ @dynamic_routes = dynamic
267
+ end
268
+
269
+ def find_route(verb, path)
270
+ static_route = @static_routes.dig(verb, path)
271
+ return [static_route, {}] if static_route
272
+
273
+ path_segments = path.split("/")
274
+
275
+ (@dynamic_routes[verb] || EMPTY_ARRAY).each do |route|
276
+ if route[:prefix_segments]
277
+ prefix = route[:prefix_segments]
278
+ next if path_segments.size < prefix.size
279
+ next unless segments_match?(prefix, path_segments)
280
+
281
+ params = extract_params(prefix, path_segments)
282
+ params[:splat] = path_segments[prefix.size..].join("/")
283
+ return [route, params]
284
+ else
285
+ route_segments = route[:segments]
286
+ next unless route_segments.size == path_segments.size
287
+ next unless segments_match?(route_segments, path_segments)
288
+
289
+ return [route, extract_params(route_segments, path_segments)]
290
+ end
291
+ end
292
+
293
+ nil
294
+ end
295
+
296
+ # Boolean-only: whether a candidate route matches is decided before
297
+ # any params Hash is allocated, so a failed candidate costs nothing
298
+ # beyond the comparisons themselves.
299
+ def segments_match?(route_segments, path_segments)
300
+ route_segments.each_with_index.all? do |segment, i|
301
+ segment.start_with?(":") || segment == path_segments[i]
302
+ end
303
+ end
304
+
305
+ def extract_params(route_segments, path_segments)
306
+ params = {}
307
+ route_segments.each_with_index do |segment, i|
308
+ params[segment[1..].to_sym] = path_segments[i] if segment.start_with?(":")
309
+ end
310
+ params
311
+ end
312
+ end
313
+
314
+ def call(env)
315
+ self.class.call(env)
316
+ end
317
+
318
+ def freeze!
319
+ self.class.freeze!
320
+ self
321
+ end
322
+ end
323
+ end
@@ -0,0 +1,78 @@
1
+ require "json"
2
+
3
+ require_relative "views"
4
+ require_relative "assets"
5
+
6
+ module Monk
7
+ class Context
8
+ include Monk::Views::Compiled
9
+
10
+ attr_reader :params, :env, :headers
11
+ attr_accessor :status
12
+
13
+ # Internal: true while a template is rendering, so the default layout
14
+ # wraps the page once and not every partial it renders.
15
+ attr_accessor :rendering
16
+
17
+ def initialize(params, env = {}, status: 200)
18
+ @params = params
19
+ @env = env
20
+ @status = status
21
+ @headers = {}
22
+ @rendering = false
23
+ end
24
+
25
+ def header(name)
26
+ env["HTTP_#{name.upcase.tr("-", "_")}"]
27
+ end
28
+
29
+ def halt(status, body = "")
30
+ throw :monk_halt, [status, headers, [body]]
31
+ end
32
+
33
+ def redirect(location, status: 302)
34
+ headers["location"] = location
35
+ throw :monk_halt, [status, headers, [""]]
36
+ end
37
+
38
+ def json(data)
39
+ headers["content-type"] = "application/json"
40
+ throw :monk_halt, [status, headers, [JSON.generate(data)]]
41
+ end
42
+
43
+ # Renders a template and *returns* the HTML, unlike #json and #halt,
44
+ # which throw. That's what makes a partial work: a template rendering
45
+ # another template is this same call, and a route block's return value
46
+ # is already the response body.
47
+ #
48
+ # Data reaches a template two ways, both zero-machinery: ivars set in
49
+ # the route (the route block, the template and the layout all run with
50
+ # `self` bound to this same Context), and the `locals` hash passed
51
+ # here, which is what a partial rendered inside a loop wants.
52
+ def render(name, layout: :default, **locals)
53
+ headers["content-type"] ||= "text/html; charset=utf-8"
54
+ Monk::Views.render(self, name, locals, layout: layout)
55
+ end
56
+
57
+ def h(value)
58
+ Monk::Views.h(value)
59
+ end
60
+
61
+ # Marks a string as already-safe, so the implicit escaping around
62
+ # every `<%= %>` leaves it alone.
63
+ def raw(value)
64
+ Monk::Views::Raw.new(value.to_s)
65
+ end
66
+
67
+ def asset_path(path)
68
+ Monk::Assets.path_for(path)
69
+ end
70
+
71
+ # Same frozen values Monk::Settings[] reads, e.g. `settings[:api_key]`
72
+ # -- returns the module itself rather than duplicating its #[] here,
73
+ # so an undeclared key raises the exact same way through either path.
74
+ def settings
75
+ Monk::Settings
76
+ end
77
+ end
78
+ end