fresco 0.0.1

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 (48) hide show
  1. checksums.yaml +7 -0
  2. data/exe/fresco +3 -0
  3. data/lib/fresco/application.rb +12 -0
  4. data/lib/fresco/cli/build.rb +682 -0
  5. data/lib/fresco/cli/dev.rb +17 -0
  6. data/lib/fresco/cli/dev_loop.rb +815 -0
  7. data/lib/fresco/cli/new.rb +120 -0
  8. data/lib/fresco/cli/release.rb +76 -0
  9. data/lib/fresco/cli.rb +56 -0
  10. data/lib/fresco/database_config.rb +34 -0
  11. data/lib/fresco/generators/app/Gemfile.tt +18 -0
  12. data/lib/fresco/generators/app/README.md.tt +32 -0
  13. data/lib/fresco/generators/app/app/action.rb.tt +20 -0
  14. data/lib/fresco/generators/app/app/actions/root_path.rb.tt +5 -0
  15. data/lib/fresco/generators/app/app/views/layouts/application.html.erb +29 -0
  16. data/lib/fresco/generators/app/app/views/root_path.html.erb +8 -0
  17. data/lib/fresco/generators/app/app.rb.tt +15 -0
  18. data/lib/fresco/generators/app/bin/build +2 -0
  19. data/lib/fresco/generators/app/bin/dev +2 -0
  20. data/lib/fresco/generators/app/bin/release +2 -0
  21. data/lib/fresco/generators/app/config/app.rb.tt +26 -0
  22. data/lib/fresco/generators/app/config/database.rb +17 -0
  23. data/lib/fresco/generators/app/config/routes.rb +11 -0
  24. data/lib/fresco/generators/app/db/schema.rb +14 -0
  25. data/lib/fresco/generators/app/public/404.html +87 -0
  26. data/lib/fresco/generators/app/public/500.html +84 -0
  27. data/lib/fresco/migration_builder.rb +55 -0
  28. data/lib/fresco/model_builder.rb +54 -0
  29. data/lib/fresco/paths.rb +20 -0
  30. data/lib/fresco/router.rb +67 -0
  31. data/lib/fresco/runtime/boot.rb +34 -0
  32. data/lib/fresco/runtime/db_postgres.rb +403 -0
  33. data/lib/fresco/runtime/db_sqlite.rb +495 -0
  34. data/lib/fresco/runtime/http.c +456 -0
  35. data/lib/fresco/runtime/postgres.c +339 -0
  36. data/lib/fresco/runtime/runtime.rb +1810 -0
  37. data/lib/fresco/runtime/sqlite.c +220 -0
  38. data/lib/fresco/runtime/welcome.rb +152 -0
  39. data/lib/fresco/schema_builder.rb +71 -0
  40. data/lib/fresco/templates/dispatch.rb.erb +32 -0
  41. data/lib/fresco/templates/layout_dispatch.rb.erb +16 -0
  42. data/lib/fresco/templates/manifest.rb.erb +5 -0
  43. data/lib/fresco/templates/migrations.rb.erb +152 -0
  44. data/lib/fresco/templates/model.rb.erb +223 -0
  45. data/lib/fresco/templates/view.rb.erb +5 -0
  46. data/lib/fresco/version.rb +3 -0
  47. data/lib/fresco.rb +61 -0
  48. metadata +115 -0
@@ -0,0 +1,1810 @@
1
+ # Fresco: runtime shim. Auto-generated by `fresco build`; do not edit.
2
+ #
3
+ # Source of truth: lib/fresco/runtime/runtime.rb in the fresco gem —
4
+ # `fresco build` copies this file verbatim into generated/runtime.rb.
5
+ # The FFI module's `ffi_cflags "generated/runtime/http.c"` injects the
6
+ # shim into the cc invocation Spinel issues. `fresco build` copies the
7
+ # C source into generated/runtime/ before Spinel runs, so the relative
8
+ # path resolves from the user app's cwd. CRuby ignores ffi_*
9
+ # declarations because they're rewritten by Spinel's codegen, not
10
+ # invoked at runtime.
11
+
12
+ module Sock
13
+ ffi_cflags "generated/runtime/http.c"
14
+
15
+ ffi_func :sphttp_listen, [:int, :int], :int
16
+ ffi_func :sphttp_accept, [:int], :int
17
+ ffi_func :sphttp_read_request, [:int], :int
18
+ ffi_func :sphttp_request_buf, [], :str
19
+ ffi_func :sphttp_request_len, [], :int
20
+ ffi_func :sphttp_header_end, [], :int
21
+ ffi_func :sphttp_drain_body, [:int, :int, :int], :int
22
+ ffi_func :sphttp_copy_body, [:int, :int, :int], :int
23
+ ffi_func :sphttp_body_buf, [], :str
24
+ ffi_func :sphttp_write_str, [:int, :str], :int
25
+ ffi_func :sphttp_sendfile, [:int, :str], :int
26
+ ffi_func :sphttp_file_size, [:str], :int
27
+ ffi_func :sphttp_close, [:int], :int
28
+ ffi_func :sphttp_fork, [], :int
29
+ ffi_func :sphttp_wait_any, [], :int
30
+ ffi_func :sphttp_getpid, [], :int
31
+ ffi_func :sphttp_mark_now, [], :int
32
+ ffi_func :sphttp_elapsed_micros, [], :int
33
+
34
+ # HMAC-SHA256 over (key, msg). `_hex` returns 64-char hex (cookie
35
+ # session signature); `_b64url` returns 43-char RFC 4648 §5 (for
36
+ # future JWT). Both buffers are static — copy the result before the
37
+ # next call if you need to keep it. Inputs are NUL-terminated, which
38
+ # is fine for cookie secrets / URL-encoded payloads (NUL-free).
39
+ ffi_func :sphttp_hmac_sha256_hex, [:str, :str], :str
40
+ ffi_func :sphttp_hmac_sha256_b64url, [:str, :str], :str
41
+ end
42
+
43
+ # The database wrapper (Sqlite/Postgres FFI module + Fresco::Db::*
44
+ # Ruby surface) lives in a per-adapter file at lib/templates/db_*.rb.
45
+ # bin/build picks one based on config/database.rb and copies it to
46
+ # generated/db_adapter.rb. Boot order is fixed (see boot.rb):
47
+ # runtime → views → layout_dispatch → db_adapter → config/app →
48
+ # config/database → requires → dispatch. Keeping the adapter out of
49
+ # this file means a Postgres-only build doesn't drag in the libsqlite3
50
+ # FFI declarations and vice versa.
51
+
52
+ # Marker base class for actions. The dispatcher calls
53
+ # `SomeAction.new.handle(req)`; handle stashes @req on the instance
54
+ # (so render can read flash) and delegates to the user-defined #call.
55
+ # Concrete actions inherit from `Fresco::Action` (or app-level
56
+ # `App::Action`) to share helpers — render, redirect_to, etc.
57
+ module Fresco
58
+ # Per-process session secret. Set this in config/app.rb (typically
59
+ # `Fresco.session_secret = ENV.fetch("SESSION_SECRET")`); empty
60
+ # disables session signing entirely (req.session still accepts writes
61
+ # but no Set-Cookie is emitted and no inbound cookie is trusted).
62
+ # Module-level rather than per-App instance so request handlers can
63
+ # reach it without threading the App through the call stack.
64
+ @session_secret = ""
65
+
66
+ def self.session_secret
67
+ @session_secret
68
+ end
69
+
70
+ def self.session_secret=(s = "")
71
+ @session_secret = s
72
+ end
73
+
74
+ # Non-`=` setter, used by config/app.rb. Spinel's codegen for
75
+ # top-level `Fresco.session_secret = local` rewrites the call into
76
+ # a direct `cst_Fresco_session_secret = sp_box_str(local)` ivar
77
+ # write, but the ivar slot is declared `const char *` — the
78
+ # `sp_box_str` wrap produces a C type mismatch. Routing through a
79
+ # plain method bypasses the rewrite and goes through the
80
+ # function-call path (which handles the const-char-* assignment
81
+ # correctly, as in `def self.session_secret=`).
82
+ def self.set_session_secret(s = "")
83
+ @session_secret = s
84
+ end
85
+
86
+ # Database configuration slots. Populated by config/database.rb's
87
+ # `Fresco.database :sqlite, ...` (or :postgres) call. Mirrored
88
+ # verbatim from lib/fresco.rb so build-time and runtime see the
89
+ # same DSL.
90
+ #
91
+ # Three separate scalars instead of one Sym→Mixed hash:
92
+ # - hash-shaped config widens to RbVal under Spinel (the
93
+ # `[[spinel_poly_hash_each]]` / `[[spinel_poly_hash_aset]]`
94
+ # trap), and a `:url` lookup returning RbVal can't be passed
95
+ # to fresco_pg_open(const char *).
96
+ # - module-level `@database_config = nil` emits as `sp_box_nil()`
97
+ # in the generated C, which isn't a compile-time constant —
98
+ # Spinel rejects it as a static initializer.
99
+ # Both go away with typed scalars: `:none` Symbol + two empty
100
+ # Strings. Each accessor pins to its own monomorphic type.
101
+ @database_adapter = :none
102
+ @database_path = ""
103
+ @database_url = ""
104
+
105
+ def self.database_adapter
106
+ @database_adapter
107
+ end
108
+
109
+ def self.database_path
110
+ @database_path
111
+ end
112
+
113
+ def self.database_url
114
+ @database_url
115
+ end
116
+
117
+ def self.database(adapter, path: "", url: "")
118
+ @database_adapter = adapter
119
+ @database_path = path
120
+ @database_url = url
121
+ end
122
+
123
+ # SQL roll-up counters for the [request] log line. Lives on
124
+ # `Fresco` (not `Fresco::Db`) so log_request_full — a top-level
125
+ # free function in this file — can read the totals with Spinel
126
+ # type resolution intact. Cross-file `Fresco::Db.<getter>` calls
127
+ # from a free function collapse the receiver to `int` and emit 0
128
+ # for any value-returning call (cross-file *side-effect* calls
129
+ # like begin_request!/flush_log! still resolve fine, since their
130
+ # return value is discarded). Same-file `Fresco.<getter>` is what
131
+ # `session_secret` / `database_adapter` already lean on, so
132
+ # mirroring that shape gets us reliable typing here too.
133
+ #
134
+ # The adapter (db_sqlite.rb / db_postgres.rb) calls
135
+ # `Fresco.add_sql_micros!(dt)` from its flush_pending! and exec
136
+ # paths; `Fresco.reset_db_sql_stats!` runs once per request from
137
+ # `Fresco::Db.begin_request!`. Both are side-effect-only — the
138
+ # cross-file write through these works under Spinel.
139
+ @db_sql_total_micros = 0
140
+ @db_sql_count = 0
141
+
142
+ def self.db_sql_total_micros
143
+ @db_sql_total_micros
144
+ end
145
+
146
+ def self.db_sql_query_count
147
+ @db_sql_count
148
+ end
149
+
150
+ def self.reset_db_sql_stats!
151
+ @db_sql_total_micros = 0
152
+ @db_sql_count = 0
153
+ end
154
+
155
+ def self.add_sql_micros!(us = 0)
156
+ @db_sql_total_micros += us
157
+ @db_sql_count += 1
158
+ end
159
+ end
160
+
161
+ # Spinel #532: `String#index` / `#rindex` now return boxed poly (nil
162
+ # for not-found, boxed int when found) instead of `mrb_int` with a
163
+ # `-1` sentinel. These wrappers restore the old contract so call
164
+ # sites can keep using `pos < 0` as the not-found test and pass `pos`
165
+ # to int-expecting slices (`s[pos, n]`, `s[pos...n]`) without each
166
+ # site having to unbox by hand. The `s = ""` / `needle = ""` / `start
167
+ # = 0` defaults pin parameter types — required positional args with
168
+ # no type signal collapse to `mrb_int` in Spinel's analyzer.
169
+ def str_idx(s = "", needle = "")
170
+ p = s.index(needle)
171
+ p.nil? ? -1 : p.to_i
172
+ end
173
+
174
+ def str_idx_from(s = "", needle = "", start = 0)
175
+ p = s.index(needle, start)
176
+ p.nil? ? -1 : p.to_i
177
+ end
178
+
179
+ def str_ridx(s = "", needle = "")
180
+ p = s.rindex(needle)
181
+ p.nil? ? -1 : p.to_i
182
+ end
183
+
184
+ # Name of the cookie carrying the signed session payload. Hardcoded
185
+ # rather than configurable — apps that need multiple session stores
186
+ # can reach for `res.set_cookie` directly with their own names.
187
+ SESSION_COOKIE_NAME = "_session"
188
+
189
+ # Signed session cookie store. Wire format: `urlencoded_payload.hexhmac`.
190
+ # The payload is *visible* to the client (not encrypted) but the HMAC
191
+ # binds it to the server's secret — modifying the payload requires
192
+ # forging a 32-byte SHA-256 HMAC, which is infeasible without the key.
193
+ #
194
+ # Spinel-shape constraints:
195
+ # - Custom `[]` / `[]=` on a user class collapses callers to mrb_int
196
+ # params and mismatches the underlying String/String slots. So we
197
+ # expose only named methods (`get`, `set`, `has?`); use those rather
198
+ # than `session[k]`.
199
+ # - `@data` is pinned to StrStrHash via the seed-and-clear trick used
200
+ # elsewhere in this file. `clear` reseeds the same way.
201
+ class Session
202
+ attr_reader :data, :dirty
203
+
204
+ def initialize
205
+ @data = { "__t" => "" }
206
+ @data.delete("__t")
207
+ @dirty = false
208
+ end
209
+
210
+ # `|| ""` keeps the missing-key semantics consistent under CRuby
211
+ # (where Hash#[] returns nil) and Spinel (where the StrStrHash
212
+ # returns "" already — the coalesce is a no-op there). Callers can
213
+ # then write `req.session.get("x").length > 0` portably.
214
+ def get(k = ""); @data[k] || ""; end
215
+ def set(k = "", v = ""); @data[k] = v; @dirty = true; end
216
+ def has?(k = ""); @data.key?(k); end
217
+ def length; @data.length; end
218
+
219
+ def clear
220
+ @data = { "__t" => "" }
221
+ @data.delete("__t")
222
+ @dirty = true
223
+ end
224
+
225
+ # Verify + decode an inbound cookie value. Returns true on success
226
+ # (data populated), false on missing / malformed / tampered. We don't
227
+ # raise so a forged cookie just looks like "no session" to the app.
228
+ def load_from(cookie_value, secret)
229
+ return false if cookie_value.length == 0 || secret.length == 0
230
+ dot = Session.last_dot(cookie_value)
231
+ return false if dot < 0
232
+ payload = cookie_value[0, dot]
233
+ sig = cookie_value[dot + 1, cookie_value.length - dot - 1]
234
+ expect = Sock.sphttp_hmac_sha256_hex(secret, payload)
235
+ return false unless Session.timing_safe_eq(sig, expect)
236
+ parse_payload!(payload)
237
+ true
238
+ end
239
+
240
+ # Sign + serialise the current data into a cookie value. Caller
241
+ # decides when (typically only when @dirty).
242
+ def to_cookie_value(secret)
243
+ payload = ""
244
+ first = true
245
+ @data.each do |k, v|
246
+ payload += "&" unless first
247
+ payload += url_encode(k) + "=" + url_encode(v)
248
+ first = false
249
+ end
250
+ payload + "." + Sock.sphttp_hmac_sha256_hex(secret, payload)
251
+ end
252
+
253
+ # Walk a `k=v&k=v` payload into @data. Sibling of parse_query_string!
254
+ # but writes Str keys directly (no url_decode().to_sym), so it's
255
+ # private to Session.
256
+ def parse_payload!(payload)
257
+ pairs = payload.split("&")
258
+ i = 0
259
+ while i < pairs.length
260
+ pair = pairs[i]
261
+ if pair.length > 0
262
+ eq = str_idx(pair, "=")
263
+ if eq < 0
264
+ @data[url_decode(pair)] = ""
265
+ else
266
+ k = url_decode(pair[0, eq])
267
+ v = url_decode(pair[eq + 1, pair.length - eq - 1])
268
+ @data[k] = v
269
+ end
270
+ end
271
+ i += 1
272
+ end
273
+ end
274
+
275
+ # Last `.` position in s, or -1 if none. Spinel doesn't have a
276
+ # stdlib rindex equivalent we can rely on; the explicit walk lowers
277
+ # cleanly.
278
+ def self.last_dot(s)
279
+ i = s.length - 1
280
+ while i >= 0
281
+ return i if s[i] == "."
282
+ i -= 1
283
+ end
284
+ -1
285
+ end
286
+
287
+ # Constant-time string equality. Avoids leaking the matching prefix
288
+ # length via early-exit timing. Spinel doesn't have a stdlib crypto-
289
+ # safe compare, so we walk byte by byte.
290
+ def self.timing_safe_eq(a, b)
291
+ return false if a.length != b.length
292
+ diff = 0
293
+ i = 0
294
+ while i < a.length
295
+ diff = diff | (a[i].bytes[0] ^ b[i].bytes[0])
296
+ i += 1
297
+ end
298
+ diff == 0
299
+ end
300
+
301
+ # Force the dirty flag from outside the class (used by flash
302
+ # promotion, which mutates @data directly via the reader to clear
303
+ # consumed entries).
304
+ def mark_dirty
305
+ @dirty = true
306
+ end
307
+ end
308
+
309
+ # Key prefix marking flash entries inside the session payload. Apps
310
+ # never write this prefix directly; req.flash.set / req.flash.get do
311
+ # the bookkeeping.
312
+ FLASH_KEY_PREFIX = "_flash."
313
+
314
+ # One-shot key/value bag that survives exactly one request bounce.
315
+ # Read side (`read_data`) carries messages set on the *previous*
316
+ # request; write side (`write_data`) collects messages that should
317
+ # survive into the *next* one. Backed by the signed session cookie —
318
+ # flash is a thin wrapper on top of Session, not its own cookie.
319
+ #
320
+ # Lifecycle (per request):
321
+ # 1. maybe_load_session! pulls `_flash.<key>` entries out of the
322
+ # newly-loaded session into req.flash.read_data, removes them
323
+ # from the session, and marks the session dirty (so the cleared
324
+ # state ships back).
325
+ # 2. Handler reads via req.flash.get(k); writes via req.flash.set(k, v).
326
+ # 3. attach_session_cookie! bakes write_data back into the session
327
+ # as `_flash.<key>` entries, then session signing proceeds as
328
+ # usual.
329
+ class Flash
330
+ attr_reader :read_data, :write_data
331
+
332
+ def initialize
333
+ @read_data = { "__t" => "" }
334
+ @read_data.delete("__t")
335
+ @write_data = { "__t" => "" }
336
+ @write_data.delete("__t")
337
+ end
338
+
339
+ def get(k = ""); @read_data[k] || ""; end # see Session#get re `|| ""`
340
+ def has?(k = ""); @read_data.key?(k); end
341
+ def set(k = "", v = ""); @write_data[k] = v; end
342
+ def length; @read_data.length; end
343
+ end
344
+
345
+ class Fresco::Action
346
+ attr_reader :req
347
+
348
+ # Per-instance filter state. @halt_response is left unset until
349
+ # halt! installs a real Response — #handle only reads it inside
350
+ # `if @halted` branches, so the uninitialised state is never
351
+ # observed.
352
+ def initialize
353
+ @halted = false
354
+ end
355
+
356
+ # Declare a layout by overriding `#layout` to return the symbol name
357
+ # of a layout under app/views/layouts/. Subclasses inherit via normal
358
+ # method lookup.
359
+ def layout
360
+ :none
361
+ end
362
+
363
+ # Dispatcher entry point. Stashes `@req` on the instance so #render
364
+ # can reach the current request's flash, then runs the before/after
365
+ # filter sandwich around the user-defined #call.
366
+ #
367
+ # Filter contract:
368
+ # 1. #before_action runs first. It can halt!(resp) to skip #call.
369
+ # 2. #call runs only if no halt was requested.
370
+ # 3. #after_action runs after #call. It can also halt! to replace
371
+ # the response.
372
+ #
373
+ # "Around" filters: override #handle in a subclass and call super.
374
+ # (See app/actions/filters/show.rb for the Spinel-shape workaround
375
+ # the super-assignment requires.)
376
+ #
377
+ # Rails-style multi-callback chains (`before_action :auth, :rate_limit`)
378
+ # aren't viable under Spinel — they need `send(name)` for dynamic
379
+ # dispatch, which the analyzer rejects. Call helper methods explicitly
380
+ # from inside #before_action instead.
381
+ def handle(req)
382
+ @req = req
383
+ before_action(req)
384
+ return @halt_response if @halted
385
+ res = call(req)
386
+ after_action(req, res)
387
+ return @halt_response if @halted
388
+ res
389
+ end
390
+
391
+ # Filter hooks. Default no-op bodies; subclasses opt in by overriding.
392
+ def before_action(req)
393
+ end
394
+
395
+ def after_action(req, res)
396
+ end
397
+
398
+ # Default #call — the polymorphic-dispatch anchor for Spinel's bare
399
+ # `call(req)` inside #handle. Without this base definition the
400
+ # analyzer can't resolve the call and emits 0 (mrb_int), which
401
+ # cascades into handle returning mrb_int and breaks every
402
+ # dispatcher call site.
403
+ #
404
+ # Subclasses ALWAYS override. The body's 500 also doubles as a
405
+ # safety net for actions that genuinely forgot to implement #call.
406
+ #
407
+ # Spinel quirk worth knowing: this method dispatches polymorphically
408
+ # to subclass #call methods ONLY when their `req` param has a
409
+ # matching type. Subclass methods whose body doesn't reference `req`
410
+ # need an explicit typed default — `def call(req = Request.new("", "", ""))`
411
+ # — or Spinel collapses the param to mrb_int, the signature
412
+ # mismatches this base's `sp_Request *`, and the case-on-cls_id
413
+ # dispatch falls back to this base (which always returns 500).
414
+ def call(req)
415
+ Response.text(500, "Action is missing a #call implementation.\n")
416
+ end
417
+
418
+ # Short-circuit the filter chain. Sets the response that #handle
419
+ # will return in place of the normal #call result.
420
+ def halt!(response)
421
+ @halted = true
422
+ @halt_response = response
423
+ end
424
+
425
+ def render(body, status: 200)
426
+ Response.html(status, dispatch_layout(layout, body, @req.flash))
427
+ end
428
+
429
+ def redirect_to(location = "", status: 302)
430
+ Response.redirect(location, status: status)
431
+ end
432
+ end
433
+
434
+ # Boot class for the user's application. Generated apps subclass this
435
+ # in config/app.rb (e.g. `class App::Base < Fresco::App`) and call
436
+ # `App::Base.new.run` from app.rb. Subclasses can override defaults by
437
+ # reassigning @port / @workers in their own initialize.
438
+ #
439
+ # `run` dispatches between two modes:
440
+ # serve [-p PORT] [-w N] → HTTP listener (run_server)
441
+ # METHOD PATH [BODY] → one-shot CLI dispatch (kept for smoke tests)
442
+ #
443
+ # ARGV is referenced directly rather than passed through as a method
444
+ # parameter — Spinel infers `ARGV` as its dedicated `sp_Argv` type
445
+ # only at global-read sites; routing it through a Ruby parameter
446
+ # collapses the type to `mrb_int` and the indexing/length lowering
447
+ # fails. Keep all ARGV access inside these methods.
448
+ class Fresco::App
449
+ attr_accessor :port, :workers
450
+
451
+ def initialize
452
+ @port = 3000
453
+ @workers = 1
454
+ end
455
+
456
+ # Class-level convenience so app.rb is `App::Base.run` rather than
457
+ # `App::Base.new.run`. The instance carries config state; the class
458
+ # method exists purely to spare the caller the `.new`.
459
+ def self.run
460
+ new.run
461
+ end
462
+
463
+ def run
464
+ # Eager-open the DB connection at startup so a misconfigured DSN
465
+ # fails fast on the binary's first command rather than the first
466
+ # request. Spinel-shape note: Fresco::Db.boot! is defined by the
467
+ # generated db_adapter.rb when an adapter is configured; the
468
+ # `respond_to?` guard keeps no-DB apps working.
469
+ Fresco::Db.boot! if Fresco::Db.respond_to?(:boot!)
470
+
471
+ if ARGV[0] == "serve"
472
+ parse_serve_args
473
+ run_server(port: @port, workers: @workers)
474
+ elsif ARGV[0] == "db:migrate"
475
+ Fresco::DbMigrations.migrate!
476
+ elsif ARGV[0] == "db:rollback"
477
+ Fresco::DbMigrations.rollback!
478
+ else
479
+ run_cli
480
+ end
481
+ end
482
+
483
+ def parse_serve_args
484
+ i = 1
485
+ while i < ARGV.length
486
+ a = ARGV[i]
487
+ if a == "-p"
488
+ @port = ARGV[i + 1].to_i
489
+ i += 2
490
+ elsif a == "-w"
491
+ @workers = ARGV[i + 1].to_i
492
+ i += 2
493
+ else
494
+ i += 1
495
+ end
496
+ end
497
+ end
498
+
499
+ def run_cli
500
+ method = ARGV[0]
501
+ path = ARGV[1]
502
+ body = ARGV[2] || ""
503
+
504
+ req = Request.new(method, path, body)
505
+ res = dispatch_request(method, path, req)
506
+
507
+ puts res.status
508
+ puts
509
+ print res.body
510
+ end
511
+ end
512
+
513
+ # Params wraps a single Symbol→String hash that the parsers and
514
+ # dispatcher all write into. Folding (path > form > query) happens at
515
+ # write time — the dispatcher writes path captures after the parsers
516
+ # have already folded form and query, so path wins by virtue of write
517
+ # order. Writes use #set_sym instead of []= because Spinel widens
518
+ # user-defined []= keys to sp_RbVal on the HTTP dispatch path. Typed
519
+ # accessors (#int / #float / #bool / #str) coerce on read with a
520
+ # positional scalar default (kwargs-with-defaults collapse to mrb_int
521
+ # under Spinel — see [[spinel_initialize_kwargs]]).
522
+ class Params
523
+ def initialize(raw)
524
+ @raw = raw
525
+ end
526
+
527
+ def [](key = :__t)
528
+ @raw[key]
529
+ end
530
+
531
+ # Defaults are type hints for Spinel. Without them, user-defined
532
+ # setters can widen the key to sp_RbVal even when every call passes a
533
+ # symbol, and codegen then emits a bad SymStrHash_set call.
534
+ def set_sym(key = :__t, value = "")
535
+ @raw[key] = value
536
+ end
537
+
538
+ def has?(key = :__t)
539
+ @raw.key?(key)
540
+ end
541
+
542
+ def length
543
+ @raw.length
544
+ end
545
+
546
+ # Underlying hash for monomorphic callers (logger, parsers). Returning
547
+ # the real reference is the point — the parsers mutate it in place.
548
+ # Callers that want to iterate go through `.raw.each` — proxying
549
+ # `each(&blk)` here doesn't lower under Spinel (the block param's
550
+ # type collapses and codegen emits an undefined block variable).
551
+ def raw
552
+ @raw
553
+ end
554
+
555
+ def str(key = :__t, default = "")
556
+ v = @raw[key]
557
+ return default if v.nil?
558
+ return default if v.length == 0
559
+ v
560
+ end
561
+
562
+ def int(key = :__t, default = 0)
563
+ v = @raw[key]
564
+ return default if v.nil?
565
+ return default if v.length == 0
566
+ v.to_i
567
+ end
568
+
569
+ def float(key = :__t, default = 0.0)
570
+ v = @raw[key]
571
+ return default if v.nil?
572
+ return default if v.length == 0
573
+ v.to_f
574
+ end
575
+
576
+ def bool(key = :__t, default = false)
577
+ v = @raw[key]
578
+ return default if v.nil?
579
+ return true if v == "true"
580
+ return true if v == "1"
581
+ return true if v == "on"
582
+ false
583
+ end
584
+ end
585
+
586
+ class Request
587
+ attr_accessor :verb, :path, :body, :params, :query, :headers, :version, :cookies, :session, :flash, :parse_status
588
+
589
+ # The HTTP verb is exposed as `req.verb` (not `req.method`). Spinel
590
+ # mis-types any accessor named `method`: it conflates the reader
591
+ # with Ruby's `Object#method(:sym)` reflection and boxes the
592
+ # const-char* return as sp_Method (cls_id 45), which then breaks
593
+ # dispatch when handle_connection calls dispatch_request with the
594
+ # boxed value. Renaming sidesteps the analyzer collision.
595
+
596
+ def initialize(method, path, body)
597
+ @verb = method
598
+ @path = path
599
+ @body = body
600
+ @version = "HTTP/1.0" # parser overwrites; CLI mode keeps the default
601
+ # 0 = parsed OK; 400 = malformed request; 413 = body too large.
602
+ # parse_http_request always returns a Request (no Request|nil
603
+ # union) so Spinel keeps `parsed` pinned to sp_Request * through
604
+ # the dispatch chain — otherwise every #handle / #call along the
605
+ # way widens to sp_RbVal req and the polymorphic call to each
606
+ # subclass's #call falls back to the base 500 fallback.
607
+ @parse_status = 0
608
+ # Seed-and-clear pins ivar hashes to their concrete specialisation;
609
+ # see plan's Spinel quirks. The Params wrapper keeps Request's
610
+ # public surface stable while letting actions reach typed coercion
611
+ # via `req.params.int(:id)` etc.
612
+ raw = { __t: "" }
613
+ raw.delete(:__t)
614
+ @params = Params.new(raw)
615
+ @query = { __t: "" }
616
+ @query.delete(:__t)
617
+ @headers = { "__t" => "" }
618
+ @headers.delete("__t")
619
+ # Cookies are Str→Str (raw Cookie header pairs after url_decode).
620
+ # Same StrStrHash pinning as @headers.
621
+ @cookies = { "__t" => "" }
622
+ @cookies.delete("__t")
623
+ # Session is allocated fresh per request. Stays empty unless
624
+ # parse_http_request / parse_dev_request finds a valid signed
625
+ # cookie and load_from succeeds.
626
+ @session = Session.new
627
+ # Flash is the one-shot companion to session. promote_flash!
628
+ # populates read_data after session load; consume_flash_writes!
629
+ # bakes write_data back in before the response cookie ships.
630
+ @flash = Flash.new
631
+ end
632
+
633
+ # HTTP/1.1 keep-alive defaults to on; explicit `Connection: close`
634
+ # opts out. HTTP/1.0 defaults to off; `Connection: keep-alive` opts
635
+ # in. Header lookup is case-insensitive on the wire, but our parser
636
+ # already lowercased keys.
637
+ def keep_alive?
638
+ conn = ""
639
+ if @headers.key?("connection")
640
+ conn = @headers["connection"].downcase
641
+ end
642
+ if @version == "HTTP/1.1"
643
+ return conn != "close"
644
+ end
645
+ conn == "keep-alive"
646
+ end
647
+ end
648
+
649
+ class Response
650
+ attr_reader :status, :body, :content_type, :file_path, :set_cookies, :location
651
+
652
+ # All slots are kwargs with defaults. The defaults are sentinels —
653
+ # external callers go through the .text / .html / .json / .markdown
654
+ # / .file / .redirect helpers below. Spinel's type inference for
655
+ # `initialize` kwargs reads each default to fix the C parameter type;
656
+ # without a default the slot collapses to mrb_int. `file_path == ""`
657
+ # means "body response"; non-empty means "sendfile this path on the
658
+ # wire". `location == ""` means "no redirect"; non-empty emits a
659
+ # `Location:` header alongside the chosen 3xx status.
660
+ def initialize(status: 0, body: "", content_type: "text/plain; charset=utf-8", file_path: "", location: "")
661
+ @status = status
662
+ @body = body
663
+ @content_type = content_type
664
+ @file_path = file_path
665
+ @location = location
666
+ # Set-Cookie can repeat; one fully-formatted line per entry. Seed
667
+ # and delete to pin Array<String> for Spinel. Writes go through
668
+ # #set_cookie / #set_cookie_with_opts (named mutators) — the same
669
+ # poly-receiver-write trap that motivated Response's attr_reader
670
+ # surface also kills `res.set_cookies << x` from outside.
671
+ @set_cookies = [""]
672
+ @set_cookies.delete_at(0)
673
+ end
674
+
675
+ # Append a Set-Cookie line with no attributes: `name=value` (value
676
+ # URL-encoded). The handful of apps that want Path/HttpOnly/SameSite
677
+ # use #set_cookie_with_opts instead.
678
+ def set_cookie(name, value)
679
+ @set_cookies.push(name + "=" + url_encode(value))
680
+ end
681
+
682
+ # Append a Set-Cookie line with attributes. `opts` is a Str→Str hash
683
+ # (`"Path" => "/"`, `"HttpOnly" => ""` for bare flags, etc.). Kept on
684
+ # a separate method (vs an optional kwarg) so this parameter stays
685
+ # monomorphic on StrStrHash — see [[spinel_poly_hash_each]]; an
686
+ # opts-default of `{}` would collapse to RbVal and the iteration
687
+ # would silently no-op.
688
+ def set_cookie_with_opts(name, value, opts)
689
+ line = name + "=" + url_encode(value)
690
+ opts.each do |k, v|
691
+ if v.length == 0
692
+ line += "; " + k # bare flag (HttpOnly, Secure)
693
+ else
694
+ line += "; " + k + "=" + v
695
+ end
696
+ end
697
+ @set_cookies.push(line)
698
+ end
699
+
700
+ # Every helper passes `location: ""` explicitly even though it's the
701
+ # initialize default. Spinel lowers an omitted kwarg as a literal 0
702
+ # (NULL pointer) into the C callee, NOT the Ruby-side default — so
703
+ # `Response.text` without `location:` would store NULL on @location
704
+ # and `build_headers` later strlen()s it → segfault. Same trap for
705
+ # any other helper that builds a Response from `new(...)` without
706
+ # supplying every kwarg.
707
+ def self.text(status, body)
708
+ new(status: status, body: body, content_type: "text/plain; charset=utf-8", file_path: "", location: "")
709
+ end
710
+
711
+ def self.html(status, body)
712
+ new(status: status, body: body, content_type: "text/html; charset=utf-8", file_path: "", location: "")
713
+ end
714
+
715
+ # `body` is a `Hash<Symbol, *>` — the typical JSON-response shape.
716
+ # We call `Spinel::Json.encode_hash` directly (not `encode`) so the
717
+ # hash type stays pinned at the call boundary. Going through
718
+ # `encode(body)` boxes the hash as `sp_RbVal`, and the analyzer's
719
+ # polymorphic `.each` lowering only walks array-tag classes — a
720
+ # Hash payload silently iterates zero entries (yielding `"{}"`).
721
+ def self.json(status, body)
722
+ new(status: status, body: Spinel::Json.encode_hash(body), content_type: "application/json", file_path: "", location: "")
723
+ end
724
+
725
+ def self.markdown(status, body)
726
+ new(status: status, body: body, content_type: "text/markdown; charset=utf-8", file_path: "", location: "")
727
+ end
728
+
729
+ def self.file(status, path, content_type)
730
+ new(status: status, body: "", content_type: content_type, file_path: path, location: "")
731
+ end
732
+
733
+ # 302 Found by default (matches Rails' `redirect_to`). Pass `status:`
734
+ # for 301 (permanent), 303 (POST → GET, See Other), or 307/308. The
735
+ # body is empty — clients follow the Location header without reading
736
+ # it — but content_type stays at the text/plain default so anything
737
+ # that does look at the response sees a sane Content-Type.
738
+ def self.redirect(location = "", status: 302)
739
+ new(status: status, body: "", content_type: "text/plain; charset=utf-8", file_path: "", location: location)
740
+ end
741
+ end
742
+
743
+ # HTTP/1.1 reason phrases for the statuses M2-M3 actually emit. Spinel
744
+ # can't take a Hash#[] of a missing key safely under polymorphism, so
745
+ # `reason_for` falls back to "OK" for anything we forgot.
746
+ REASON_PHRASES = {
747
+ 200 => "OK",
748
+ 201 => "Created",
749
+ 204 => "No Content",
750
+ 301 => "Moved Permanently",
751
+ 302 => "Found",
752
+ 303 => "See Other",
753
+ 304 => "Not Modified",
754
+ 307 => "Temporary Redirect",
755
+ 308 => "Permanent Redirect",
756
+ 400 => "Bad Request",
757
+ 401 => "Unauthorized",
758
+ 403 => "Forbidden",
759
+ 404 => "Not Found",
760
+ 405 => "Method Not Allowed",
761
+ 413 => "Payload Too Large",
762
+ 500 => "Internal Server Error",
763
+ 501 => "Not Implemented",
764
+ 503 => "Service Unavailable",
765
+ }.freeze
766
+
767
+ def reason_for(status)
768
+ r = REASON_PHRASES[status]
769
+ return r if r
770
+ "OK"
771
+ end
772
+
773
+ # Extension → Content-Type for the handful of types static-file
774
+ # routes are likely to serve in MVP. Everything else falls back to
775
+ # application/octet-stream so browsers download rather than guess.
776
+ # View-helper namespace targeted by Herb-compiled templates. Templates
777
+ # emit `::Spinel::View.h((expr))` for escaped interpolations and the
778
+ # attr/js/css variants for attribute/script/style contexts. The escape
779
+ # tables are open-coded as gsub chains — Spinel doesn't support the
780
+ # `gsub(/regex/, hash)` form Herb's own helpers use, but
781
+ # `gsub(string, string)` lowers fine.
782
+ module Spinel
783
+ module View
784
+ def self.h(value)
785
+ s = value.to_s
786
+ s = s.gsub("&", "&amp;")
787
+ s = s.gsub("<", "&lt;")
788
+ s = s.gsub(">", "&gt;")
789
+ s = s.gsub('"', "&quot;")
790
+ s = s.gsub("'", "&#39;")
791
+ s
792
+ end
793
+
794
+ def self.attr(value)
795
+ s = value.to_s
796
+ s = s.gsub("&", "&amp;")
797
+ s = s.gsub('"', "&quot;")
798
+ s = s.gsub("'", "&#39;")
799
+ s = s.gsub("<", "&lt;")
800
+ s = s.gsub(">", "&gt;")
801
+ s = s.gsub("\n", "&#10;")
802
+ s = s.gsub("\r", "&#13;")
803
+ s = s.gsub("\t", "&#9;")
804
+ s
805
+ end
806
+
807
+ # MVP js/css escapers: conservative replacements rather than the
808
+ # codepoint-driven gsub-with-block Herb ships. Good enough to keep
809
+ # `</script>` and CSS terminators from breaking out; extend later.
810
+ def self.js(value)
811
+ s = value.to_s
812
+ s = s.gsub("\\", "\\\\")
813
+ s = s.gsub("\n", "\\n")
814
+ s = s.gsub("\r", "\\r")
815
+ s = s.gsub("\t", "\\t")
816
+ s = s.gsub("'", "\\'")
817
+ s = s.gsub('"', "\\\"")
818
+ s = s.gsub("<", "\\u003c")
819
+ s = s.gsub(">", "\\u003e")
820
+ s = s.gsub("&", "\\u0026")
821
+ s
822
+ end
823
+
824
+ def self.css(value)
825
+ s = value.to_s
826
+ s = s.gsub("\\", "\\\\")
827
+ s = s.gsub('"', "\\\"")
828
+ s = s.gsub("'", "\\'")
829
+ s = s.gsub("<", "\\3c ")
830
+ s = s.gsub(">", "\\3e ")
831
+ s = s.gsub("&", "\\26 ")
832
+ s
833
+ end
834
+ end
835
+
836
+ # Hand-rolled JSON encoder. Scalars and Arrays dispatch through the
837
+ # polymorphic `encode`; Hashes are handled by `encode_hash`, which
838
+ # is pinned to `Hash<Symbol, *>` (`SymPolyHash`) via a default-arg
839
+ # type hint *and* a single-call-site discipline.
840
+ #
841
+ # Spinel limitation: `.each` lowering on a polymorphic-typed
842
+ # (`sp_RbVal`) hash only emits array-tag branches — a Hash there
843
+ # silently iterates zero times. The analyzer also widens
844
+ # `encode_hash`'s parameter to `sp_RbVal` if there's any call site
845
+ # where the argument is polymorphic. So `encode_hash` is called
846
+ # only from `Response.json` (where `body` is a concrete SymPolyHash),
847
+ # and `encode`'s `value.is_a?(Hash)` branch recurses through it via
848
+ # an implicit `(sp_SymPolyHash *)` cast that Spinel inserts at the
849
+ # call site. Nested hashes within Arrays / Hash values therefore
850
+ # only encode their *first* level correctly when they happen to be
851
+ # SymPolyHash; deeper nesting through a polymorphic-typed value
852
+ # will need either a Spinel codegen change or per-shape encoders.
853
+ module Json
854
+ def self.encode(value)
855
+ return "null" if value.nil?
856
+ return "true" if value == true
857
+ return "false" if value == false
858
+ return value.to_s if value.is_a?(Integer)
859
+ return value.to_s if value.is_a?(Float)
860
+ return encode_string(value) if value.is_a?(String)
861
+ return encode_string(value.to_s) if value.is_a?(Symbol)
862
+ if value.is_a?(Array)
863
+ out = "["
864
+ i = 0
865
+ while i < value.length
866
+ out += "," if i > 0
867
+ out += encode(value[i])
868
+ i += 1
869
+ end
870
+ return out + "]"
871
+ end
872
+ # Polymorphic Hash recursion path. Under CRuby this iterates
873
+ # correctly; under Spinel the `.each` lowering on a RbVal-typed
874
+ # hash only emits array-tag branches and silently produces
875
+ # `"{}"`. The two implementations diverge here on purpose —
876
+ # CRuby (bin/dev) reads payloads as written, while Spinel-built
877
+ # binaries should pre-encode nested hashes with `encode_hash`
878
+ # and concatenate the outer JSON manually if they need it.
879
+ return encode_recursive_hash(value) if value.is_a?(Hash)
880
+ encode_string(value.to_s)
881
+ end
882
+
883
+ # CRuby-correct, Spinel-degraded sibling of `encode_hash`. Lives
884
+ # behind a different name so `encode_hash` stays single-caller
885
+ # and pinned to `SymPolyHash`; this one is the polymorphic
886
+ # recursion target from `encode` and Spinel widens its parameter
887
+ # to RbVal, breaking the iteration. Kept for CRuby parity so
888
+ # `bin/dev` and unit tests still behave as expected.
889
+ def self.encode_recursive_hash(h = { __t: nil })
890
+ out = "{"
891
+ first = true
892
+ h.each do |k, v|
893
+ out += "," unless first
894
+ first = false
895
+ encoded_value = encode(v)
896
+ out += encode_string(k.to_s) + ":" + encoded_value
897
+ end
898
+ out + "}"
899
+ end
900
+
901
+ # Hash branch lives in its own function so the `h = { __t: nil }`
902
+ # default pins `h` to `SymPolyHash` for Spinel's analyzer. Inline
903
+ # iteration on a polymorphic `value` inside `encode` would lower to
904
+ # array-only branches; passing through a typed parameter forces a
905
+ # real hash walk. The default `{ __t: nil }` is never visible —
906
+ # the function is only ever called with an actual hash from the
907
+ # `value.is_a?(Hash)` branch in `encode`.
908
+ def self.encode_hash(h = { __t: nil })
909
+ out = "{"
910
+ first = true
911
+ h.each do |k, v|
912
+ out += "," unless first
913
+ first = false
914
+ encoded_value = encode(v)
915
+ out += encode_string(k.to_s) + ":" + encoded_value
916
+ end
917
+ out + "}"
918
+ end
919
+
920
+ # Char-by-char concat (plan quirk: stick to the existing parsers'
921
+ # shape rather than `gsub` chains). gsub-with-string-replacement
922
+ # interprets backslash backreferences under CRuby — encoding the
923
+ # JSON `\\` escape requires four-deep escaping that doesn't carry
924
+ # to Spinel. The walk sidesteps all of that and stays monomorphic.
925
+ # Control chars below 0x20 other than the named escapes pass
926
+ # through unescaped; an MVP encoder for app-built payloads doesn't
927
+ # need the full \u00XX table.
928
+ def self.encode_string(s)
929
+ out = "\""
930
+ i = 0
931
+ n = s.length
932
+ while i < n
933
+ c = s[i]
934
+ if c == "\\"
935
+ out += "\\\\"
936
+ elsif c == "\""
937
+ out += "\\\""
938
+ elsif c == "\n"
939
+ out += "\\n"
940
+ elsif c == "\r"
941
+ out += "\\r"
942
+ elsif c == "\t"
943
+ out += "\\t"
944
+ elsif c == "\b"
945
+ out += "\\b"
946
+ elsif c == "\f"
947
+ out += "\\f"
948
+ else
949
+ out += c
950
+ end
951
+ i += 1
952
+ end
953
+ out + "\""
954
+ end
955
+
956
+ end
957
+ end
958
+
959
+ module Mime
960
+ EXT_MAP = {
961
+ ".html" => "text/html; charset=utf-8",
962
+ ".htm" => "text/html; charset=utf-8",
963
+ ".css" => "text/css; charset=utf-8",
964
+ ".js" => "application/javascript",
965
+ ".json" => "application/json",
966
+ ".txt" => "text/plain; charset=utf-8",
967
+ ".md" => "text/markdown; charset=utf-8",
968
+ ".png" => "image/png",
969
+ ".jpg" => "image/jpeg",
970
+ ".jpeg" => "image/jpeg",
971
+ ".gif" => "image/gif",
972
+ ".svg" => "image/svg+xml",
973
+ ".ico" => "image/x-icon",
974
+ ".wasm" => "application/wasm",
975
+ }.freeze
976
+
977
+ def self.for_path(path = "")
978
+ dot = str_ridx(path, ".")
979
+ return "application/octet-stream" if dot < 0
980
+ ext = path[dot...path.length].downcase
981
+ return EXT_MAP[ext] if EXT_MAP.key?(ext)
982
+ "application/octet-stream"
983
+ end
984
+ end
985
+
986
+ # Build a sendfile-backed Response for a path-under-root. Rejects
987
+ # anything with `..` (traversal) or a leading `/` (absolute), then
988
+ # stats the file. Missing file → 404 (rendered via error_response,
989
+ # which picks up public/404.html if the app supplies one). Otherwise
990
+ # → Response.file with the right Content-Type for the extension.
991
+ #
992
+ # Path-safety is intentionally simple: substring checks on the raw
993
+ # string. We don't URL-decode (the parser leaves that alone for MVP),
994
+ # and we don't follow symlinks back out of root. Good enough for an
995
+ # MVP serving from a known-safe public/ dir; tighten when needed.
996
+ def file_response(rel_path = "", root = "")
997
+ if rel_path.include?("..") || rel_path.length == 0 || rel_path[0] == "/"
998
+ return error_response(404, "Not Found")
999
+ end
1000
+ abs = root + "/" + rel_path
1001
+ size = Sock.sphttp_file_size(abs)
1002
+ if size < 0
1003
+ return error_response(404, "Not Found")
1004
+ end
1005
+ Response.file(200, abs, Mime.for_path(rel_path))
1006
+ end
1007
+
1008
+ # Per-status error page lookup. If `public/<status>.html` exists,
1009
+ # serve it as the body (sendfile path); otherwise fall back to a
1010
+ # plain-text body so 4xx/5xx still emit a sane response when the
1011
+ # app hasn't supplied a custom page. Used by the dispatcher 404, the
1012
+ # static-file 404, the request-parse 400/413, and the action-raised
1013
+ # 500 path. Keep this dead simple — no template rendering, no
1014
+ # locals — so it stays callable when the rest of the app is broken.
1015
+ def error_response(status, default_body)
1016
+ path = "public/" + status.to_s + ".html"
1017
+ if Sock.sphttp_file_size(path) >= 0
1018
+ return Response.file(status, path, "text/html; charset=utf-8")
1019
+ end
1020
+ Response.text(status, default_body)
1021
+ end
1022
+
1023
+ # Single hex nibble — explicit case keeps each branch monomorphic on
1024
+ # String. Returns -1 for a non-hex char so `url_decode` can fall back
1025
+ # to literal '%' rather than fabricating a byte. The `c = ""` default
1026
+ # acts as a String type hint for Spinel: parameters without a default
1027
+ # collapse to mrb_int when the analyzer can't disambiguate (the same
1028
+ # rule as ctor kwargs — see [[spinel_initialize_kwargs]]).
1029
+ def hex_nibble(c = "")
1030
+ return 0 if c == "0"
1031
+ return 1 if c == "1"
1032
+ return 2 if c == "2"
1033
+ return 3 if c == "3"
1034
+ return 4 if c == "4"
1035
+ return 5 if c == "5"
1036
+ return 6 if c == "6"
1037
+ return 7 if c == "7"
1038
+ return 8 if c == "8"
1039
+ return 9 if c == "9"
1040
+ return 10 if c == "a" || c == "A"
1041
+ return 11 if c == "b" || c == "B"
1042
+ return 12 if c == "c" || c == "C"
1043
+ return 13 if c == "d" || c == "D"
1044
+ return 14 if c == "e" || c == "E"
1045
+ return 15 if c == "f" || c == "F"
1046
+ -1
1047
+ end
1048
+
1049
+ # `+` → space, then `%XX` → byte. Byte walk + char concat — no
1050
+ # String#unpack or Regexp tricks (Spinel quirks). Malformed escapes
1051
+ # (% at EOF, %ZZ) pass through literally rather than raising.
1052
+ # `s = ""` default pins `s` to String — without it, Spinel's analyzer
1053
+ # can't tell whether `s.length` / `s[i]` mean String or bitfield-on-int
1054
+ # and lowers s[i] to `(s >> i) & 1` (genuinely surprising).
1055
+ def url_decode(s = "")
1056
+ out = ""
1057
+ i = 0
1058
+ n = s.length
1059
+ while i < n
1060
+ c = s[i]
1061
+ if c == "+"
1062
+ out += " "
1063
+ i += 1
1064
+ elsif c == "%" && i + 2 < n
1065
+ hi = hex_nibble(s[i + 1])
1066
+ lo = hex_nibble(s[i + 2])
1067
+ if hi >= 0 && lo >= 0
1068
+ out += (hi * 16 + lo).chr
1069
+ i += 3
1070
+ else
1071
+ out += c
1072
+ i += 1
1073
+ end
1074
+ else
1075
+ out += c
1076
+ i += 1
1077
+ end
1078
+ end
1079
+ out
1080
+ end
1081
+
1082
+ # Parse `k=v&k=v` into the supplied Params wrapper. Mutates in place
1083
+ # to dodge the polymorphic-write no-op (plan quirks). Both name and
1084
+ # value run through `url_decode` so `?q=hello%20world` lands as
1085
+ # `:q => "hello world"`.
1086
+ #
1087
+ # Takes the `Params` object rather than the raw hash so writes lower
1088
+ # through the monomorphic `Params#set_sym`. Spinel's generic polymorphic
1089
+ # `Hash#[]=` only emits array branches in the dispatch and silently
1090
+ # no-ops on the hash case.
1091
+ def parse_query_string!(s, into)
1092
+ pairs = s.split("&")
1093
+ i = 0
1094
+ while i < pairs.length
1095
+ pair = pairs[i]
1096
+ if pair.length > 0
1097
+ eq = str_idx(pair, "=")
1098
+ if eq < 0
1099
+ into.set_sym(url_decode(pair).to_sym, "")
1100
+ else
1101
+ name = url_decode(pair[0...eq]).to_sym
1102
+ value = url_decode(pair[(eq + 1)...pair.length])
1103
+ into.set_sym(name, value)
1104
+ end
1105
+ end
1106
+ i += 1
1107
+ end
1108
+ end
1109
+
1110
+ # Form bodies share the `application/x-www-form-urlencoded` framing
1111
+ # with query strings — same `k=v&k=v`, same percent/`+` decode. Kept
1112
+ # as a separate function (vs aliasing) so the call-site reads
1113
+ # "form body" rather than "abusing the query parser". Takes the
1114
+ # `Params` wrapper for the same reason as `parse_query_string!`.
1115
+ def parse_form_body!(body, into)
1116
+ parse_query_string!(body, into)
1117
+ end
1118
+
1119
+ # RFC 3986 percent-encode. ALPHA / DIGIT / `-._~` pass through; every
1120
+ # other byte becomes `%XX` (uppercase hex). Used by Response#set_cookie
1121
+ # to make values safe for the Set-Cookie line and by Session to encode
1122
+ # the signed payload. `s = ""` pins the String type for Spinel's analyzer
1123
+ # (same default-hint discipline as url_decode).
1124
+ def url_encode(s = "")
1125
+ out = ""
1126
+ i = 0
1127
+ n = s.length
1128
+ while i < n
1129
+ c = s[i]
1130
+ if (c >= "a" && c <= "z") || (c >= "A" && c <= "Z") ||
1131
+ (c >= "0" && c <= "9") || c == "-" || c == "." ||
1132
+ c == "_" || c == "~"
1133
+ out += c
1134
+ else
1135
+ b = c.bytes[0]
1136
+ out += "%"
1137
+ out += hex_char_upper(b / 16)
1138
+ out += hex_char_upper(b % 16)
1139
+ end
1140
+ i += 1
1141
+ end
1142
+ out
1143
+ end
1144
+
1145
+ def hex_char_upper(n = 0)
1146
+ return "0" if n == 0
1147
+ return "1" if n == 1
1148
+ return "2" if n == 2
1149
+ return "3" if n == 3
1150
+ return "4" if n == 4
1151
+ return "5" if n == 5
1152
+ return "6" if n == 6
1153
+ return "7" if n == 7
1154
+ return "8" if n == 8
1155
+ return "9" if n == 9
1156
+ return "A" if n == 10
1157
+ return "B" if n == 11
1158
+ return "C" if n == 12
1159
+ return "D" if n == 13
1160
+ return "E" if n == 14
1161
+ return "F" if n == 15
1162
+ "0"
1163
+ end
1164
+
1165
+ # Parse a Cookie: header value (`a=1; b=hello%20world; flag`) into the
1166
+ # given Str->Str hash. Values are URL-decoded (clients may percent-encode
1167
+ # arbitrary bytes; we always round-trip via `url_decode` on read). Bare
1168
+ # tokens with no `=` are recorded as empty-value entries — rare but
1169
+ # preserves "is this name present?" semantics.
1170
+ def parse_cookie_header!(s = "", into = { "__t" => "" })
1171
+ i = 0
1172
+ n = s.length
1173
+ while i < n
1174
+ # Skip leading whitespace between pairs.
1175
+ while i < n && (s[i] == " " || s[i] == "\t")
1176
+ i += 1
1177
+ end
1178
+ break if i >= n
1179
+
1180
+ pair_end = str_idx_from(s, ";", i)
1181
+ pair_end = n if pair_end < 0
1182
+
1183
+ eq = str_idx_from(s, "=", i)
1184
+ if eq < 0 || eq >= pair_end
1185
+ into[s[i...pair_end]] = ""
1186
+ else
1187
+ name = s[i...eq]
1188
+ value = url_decode(s[(eq + 1)...pair_end])
1189
+ into[name] = value
1190
+ end
1191
+
1192
+ i = pair_end + 1
1193
+ end
1194
+ end
1195
+
1196
+ # If a signed session cookie is present and a secret is configured,
1197
+ # verify + decode it into req.session. No-ops when either piece is
1198
+ # missing — the bag stays empty and handlers writing to it just stage
1199
+ # fresh state for the response cookie.
1200
+ def maybe_load_session!(req)
1201
+ secret = Fresco.session_secret
1202
+ return if secret.length == 0
1203
+ cv = req.cookies[SESSION_COOKIE_NAME]
1204
+ return if cv.nil?
1205
+ return if cv.length == 0
1206
+ req.session.load_from(cv, secret)
1207
+ promote_flash!(req)
1208
+ end
1209
+
1210
+ # Move any `_flash.<key>` entries from the loaded session into
1211
+ # req.flash.read_data, removing them from the session as we go. The
1212
+ # removal marks the session dirty so the cleared state ships back in
1213
+ # the response cookie — that's how flash earns its "one bounce only"
1214
+ # guarantee. If no flash entries are present this is a no-op (session
1215
+ # stays clean).
1216
+ def promote_flash!(req)
1217
+ data = req.session.data
1218
+ prefix_len = FLASH_KEY_PREFIX.length
1219
+ # Collect keys first; can't safely mutate a hash while iterating it.
1220
+ to_clear = [""]
1221
+ to_clear.delete_at(0)
1222
+ data.each do |k, v|
1223
+ if k.length > prefix_len && k[0, prefix_len] == FLASH_KEY_PREFIX
1224
+ short_key = k[prefix_len, k.length - prefix_len]
1225
+ req.flash.read_data[short_key] = v
1226
+ to_clear.push(k)
1227
+ end
1228
+ end
1229
+ i = 0
1230
+ while i < to_clear.length
1231
+ data.delete(to_clear[i])
1232
+ i += 1
1233
+ end
1234
+ if to_clear.length > 0
1235
+ req.session.mark_dirty
1236
+ end
1237
+ end
1238
+
1239
+ # Before signing the outbound session cookie, copy req.flash.write_data
1240
+ # into the session as `_flash.<key>` entries. Each set marks the session
1241
+ # dirty, so the cookie will be emitted even if the handler didn't touch
1242
+ # req.session directly.
1243
+ def consume_flash_writes!(req)
1244
+ return if req.flash.write_data.length == 0
1245
+ req.flash.write_data.each do |k, v|
1246
+ req.session.set(FLASH_KEY_PREFIX + k, v)
1247
+ end
1248
+ end
1249
+
1250
+ # After the handler runs, if anything wrote to req.session, sign +
1251
+ # encode the bag and attach a Set-Cookie line to the response. Path=/
1252
+ # so it covers the whole app; HttpOnly to keep it out of JS; SameSite=Lax
1253
+ # as a sensible default (blocks CSRF on top-level cross-site POSTs
1254
+ # without breaking link navigations).
1255
+ def attach_session_cookie!(req, res)
1256
+ secret = Fresco.session_secret
1257
+ return if secret.length == 0
1258
+ consume_flash_writes!(req)
1259
+ return unless req.session.dirty
1260
+ opts = { "__t" => "" }
1261
+ opts.delete("__t")
1262
+ opts["Path"] = "/"
1263
+ opts["HttpOnly"] = ""
1264
+ opts["SameSite"] = "Lax"
1265
+ res.set_cookie_with_opts(SESSION_COOKIE_NAME, req.session.to_cookie_value(secret), opts)
1266
+ end
1267
+
1268
+ # Parse an HTTP/1.x request out of the C-side request buffer. Reads
1269
+ # the request line, headers (lowercased keys), strips any `?query`
1270
+ # off the path, and pulls a `Content-Length`-bounded body. Returns a
1271
+ # fully populated Request, or nil if the bytes don't look like HTTP.
1272
+ #
1273
+ # Body strategy: any bytes that arrived past the header terminator are
1274
+ # already in the request buf (capped at 64 KiB). For bodies that span
1275
+ # beyond that, sphttp_drain_body reads the rest into the body buf.
1276
+ # Cap: 1 MiB (decision #5); larger requests get parse_status=413.
1277
+ #
1278
+ # Always returns a Request — failures populate `parse_status` with the
1279
+ # HTTP code the caller should respond with (400 / 413). The single-type
1280
+ # return is load-bearing for Spinel: a Request|nil|:too_large union
1281
+ # would widen the entire dispatch chain's `req` parameter to sp_RbVal
1282
+ # and break the polymorphic call from Fresco::Action#handle to each
1283
+ # subclass's #call.
1284
+ def bad_request
1285
+ r = Request.new("", "", "")
1286
+ r.parse_status = 400
1287
+ r
1288
+ end
1289
+
1290
+ def parse_http_request(client)
1291
+ buf = Sock.sphttp_request_buf
1292
+ buf_len = Sock.sphttp_request_len
1293
+ hdr_end = Sock.sphttp_header_end
1294
+ return bad_request if hdr_end < 0
1295
+ return bad_request if buf_len <= 0
1296
+
1297
+ rl_end = str_idx(buf, "\r\n")
1298
+ return bad_request if rl_end < 0
1299
+
1300
+ request_line = buf[0...rl_end]
1301
+ parts = request_line.split(" ")
1302
+ return bad_request if parts.length < 3
1303
+
1304
+ method = parts[0]
1305
+ raw_path = parts[1]
1306
+ version = parts[2]
1307
+
1308
+ qmark = str_idx(raw_path, "?")
1309
+ if qmark >= 0
1310
+ path = raw_path[0...qmark]
1311
+ qstr = raw_path[(qmark + 1)...raw_path.length]
1312
+ else
1313
+ path = raw_path
1314
+ qstr = ""
1315
+ end
1316
+
1317
+ # Build the Request now so the parser can fill its headers/body
1318
+ # in place. Reassigning @headers risks Spinel's polymorphic-write
1319
+ # no-op trap (plan's Spinel quirks).
1320
+ req = Request.new(method, path, "")
1321
+ req.version = version
1322
+ hdrs = req.headers
1323
+
1324
+ # Fold the query string directly into the params hash. The
1325
+ # dispatcher will write path captures into the same hash after this
1326
+ # parse returns, so path beats query without explicit precedence
1327
+ # logic — see plan decision #2.
1328
+ parse_query_string!(qstr, req.params) if qstr.length > 0
1329
+
1330
+ # cl + ctype captured during the header walk so the body-read step
1331
+ # below doesn't need a `hdrs[]` lookup. Reading through `hdrs[]` (a
1332
+ # Str→Str hash exposed via `attr_reader :headers`) returns sp_RbVal
1333
+ # under Spinel, and `cl_str.to_i` on a boxed value silently evaluates
1334
+ # to 0 — the body never gets drained and form-body params come back
1335
+ # empty. Capturing the values inline avoids the boxed read entirely.
1336
+ cl = 0
1337
+ ctype = ""
1338
+ i = rl_end + 2
1339
+ limit = hdr_end - 2 # one CRLF before the blank-line CRLF
1340
+ while i < limit
1341
+ line_end = str_idx_from(buf, "\r\n", i)
1342
+ break if line_end < 0
1343
+ break if line_end == i
1344
+
1345
+ colon = str_idx_from(buf, ":", i)
1346
+ if colon > i && colon < line_end
1347
+ name = buf[i...colon].downcase
1348
+ v = colon + 1
1349
+ while v < line_end && buf[v] == " "
1350
+ v += 1
1351
+ end
1352
+ val = buf[v...line_end]
1353
+ hdrs[name] = val
1354
+ cl = val.to_i if name == "content-length"
1355
+ ctype = val if name == "content-type"
1356
+ end
1357
+
1358
+ i = line_end + 2
1359
+ end
1360
+
1361
+ cookie_hdr = hdrs["cookie"]
1362
+ if cookie_hdr && cookie_hdr.length > 0
1363
+ parse_cookie_header!(cookie_hdr, req.cookies)
1364
+ end
1365
+ maybe_load_session!(req)
1366
+
1367
+ # `cl` and `ctype` were captured during the header walk above —
1368
+ # see the comment there for why we don't read them back through
1369
+ # `hdrs[]` here.
1370
+ if cl > 0
1371
+ if cl > 1048576
1372
+ req.parse_status = 413
1373
+ return req
1374
+ end
1375
+
1376
+ body_in_buf = buf_len - hdr_end
1377
+ if body_in_buf < 0
1378
+ body_in_buf = 0
1379
+ end
1380
+ have = body_in_buf < cl ? body_in_buf : cl
1381
+
1382
+ # Seed body buf with bytes that came in with the headers.
1383
+ Sock.sphttp_copy_body(hdr_end, 0, have)
1384
+ if have < cl
1385
+ Sock.sphttp_drain_body(client, have, cl - have)
1386
+ end
1387
+ # `+ ""` forces a Ruby-owned copy out of the C-owned body buf,
1388
+ # which will be overwritten on the next request. Stash in a local
1389
+ # so the form-body parser below gets a String-pinned value —
1390
+ # reading back through `req.body`'s attr_accessor widens the type
1391
+ # at the call site and the downstream `s.split("&")` lowers to a
1392
+ # silent no-op.
1393
+ body_str = Sock.sphttp_body_buf + ""
1394
+ req.body = body_str
1395
+
1396
+ # Fold form bodies into the same params hash query went into.
1397
+ # Exact-match content-type filter (plan decision #6) — no
1398
+ # tolerance for `; charset=utf-8` suffixes in MVP. The dispatcher
1399
+ # writes path captures after parse_http_request returns, so the
1400
+ # final precedence is path > form > query.
1401
+ #
1402
+ # Calls parse_query_string! directly (not parse_form_body!): the
1403
+ # one-line indirection through parse_form_body! collapses param
1404
+ # types to mrb_int under Spinel and the parse silently no-ops.
1405
+ if ctype == "application/x-www-form-urlencoded"
1406
+ parse_query_string!(body_str, req.params)
1407
+ end
1408
+ end
1409
+
1410
+ # Rails-style `_method` override: HTML forms can only emit GET/POST,
1411
+ # so a hidden `_method=patch|put|delete` lets a POST form route as
1412
+ # the verb the dispatcher expects. Only honored when the wire method
1413
+ # is POST — query-string overrides on GET would let a stray link
1414
+ # delete things.
1415
+ if req.verb == "POST"
1416
+ override = req.params.str(:_method)
1417
+ req.verb = override.upcase if override.length > 0
1418
+ end
1419
+
1420
+ req
1421
+ end
1422
+
1423
+ def build_headers(status, ctype, content_length, conn, set_cookies, location = "")
1424
+ out = "HTTP/1.1 "
1425
+ out += status.to_s
1426
+ out += " "
1427
+ out += reason_for(status)
1428
+ out += "\r\nContent-Type: "
1429
+ out += ctype
1430
+ out += "\r\nContent-Length: "
1431
+ out += content_length.to_s
1432
+ out += "\r\nConnection: "
1433
+ out += conn
1434
+ # `Location:` is emitted only for redirect responses (Response.redirect
1435
+ # populates @location). Empty string → no header, so non-redirect
1436
+ # responses stay byte-identical to before this slot existed.
1437
+ if location.length > 0
1438
+ out += "\r\nLocation: "
1439
+ out += location
1440
+ end
1441
+ # One Set-Cookie line per array entry. Order matches push order.
1442
+ i = 0
1443
+ while i < set_cookies.length
1444
+ out += "\r\nSet-Cookie: "
1445
+ out += set_cookies[i]
1446
+ i += 1
1447
+ end
1448
+ out += "\r\n\r\n"
1449
+ out
1450
+ end
1451
+
1452
+ # Build a wire-format HTTP/1.1 response. For body responses the
1453
+ # headers + body go out in one write. For file responses (file_path
1454
+ # set on the Response) we stat the file for size, send the headers,
1455
+ # then `sphttp_sendfile(2)` the bytes — skipping the copy through a
1456
+ # Ruby string entirely.
1457
+ def write_response(client, res, keep_alive)
1458
+ if keep_alive
1459
+ conn = "keep-alive"
1460
+ else
1461
+ conn = "close"
1462
+ end
1463
+
1464
+ if res.file_path.length > 0
1465
+ size = Sock.sphttp_file_size(res.file_path)
1466
+ if size < 0
1467
+ # File vanished between dispatch and write. Honor the contract
1468
+ # with a 404 rather than partial output. Force the socket shut.
1469
+ body = "Not Found"
1470
+ hdr = build_headers(404, "text/plain; charset=utf-8", body.length, "close", res.set_cookies, "")
1471
+ Sock.sphttp_write_str(client, hdr + body)
1472
+ return
1473
+ end
1474
+ Sock.sphttp_write_str(client, build_headers(res.status, res.content_type, size, conn, res.set_cookies, res.location))
1475
+ Sock.sphttp_sendfile(client, res.file_path)
1476
+ return
1477
+ end
1478
+
1479
+ body = res.body
1480
+ bytes = body.length
1481
+ Sock.sphttp_write_str(client, build_headers(res.status, res.content_type, bytes, conn, res.set_cookies, res.location) + body)
1482
+ end
1483
+
1484
+ # ANSI colorization for log lines. Honors NO_COLOR
1485
+ # (https://no-color.org) — any non-empty value disables. We don't
1486
+ # auto-detect a TTY: log output usually goes to a terminal, journalctl,
1487
+ # or a TTY-aware aggregator, all of which render ANSI fine. Pipe to a
1488
+ # file? `NO_COLOR=1 ./bin/server`.
1489
+ #
1490
+ # Spinel-shape constraints worth flagging up front, since they shape
1491
+ # the awkward bits below:
1492
+ # - `wrap` does stepwise concat (vs a single `+` chain) so each
1493
+ # intermediate is narrowly typed String, matching the
1494
+ # build_headers / url_encode pattern in this file.
1495
+ # - The disabled branch goes through the same `out = ""; out += s`
1496
+ # concat instead of `return s` so wrap's return type doesn't fork
1497
+ # into String|RbVal — Spinel would widen the function (and every
1498
+ # caller's parameter) to RbVal, then the enabled branch's
1499
+ # `out += s` would fail to compile against an RbVal `s`.
1500
+ # - For the same reason, `color_method` / `color_status` *always*
1501
+ # route through a Color helper, including their fallback path —
1502
+ # a bare `return m` would be String while the other returns are
1503
+ # RbVal-shaped, and the unification widens `m` itself, which then
1504
+ # propagates back into Color.red(m) → wrap → broken codegen.
1505
+ module Color
1506
+ def self.enabled?
1507
+ ENV.fetch("NO_COLOR", "") == ""
1508
+ end
1509
+
1510
+ def self.wrap(code = "", s = "")
1511
+ if enabled?
1512
+ out = "\e["
1513
+ out += code
1514
+ out += "m"
1515
+ out += s
1516
+ out += "\e[0m"
1517
+ return out
1518
+ end
1519
+ out = ""
1520
+ out += s
1521
+ out
1522
+ end
1523
+
1524
+ def self.dim(s = ""); wrap("2", s); end
1525
+ def self.bold(s = ""); wrap("1", s); end
1526
+ def self.red(s = ""); wrap("31", s); end
1527
+ def self.green(s = ""); wrap("32", s); end
1528
+ def self.yellow(s = ""); wrap("33", s); end
1529
+ def self.blue(s = ""); wrap("34", s); end
1530
+ def self.magenta(s = ""); wrap("35", s); end
1531
+ def self.cyan(s = ""); wrap("36", s); end
1532
+
1533
+ # No-op coloring: emits the input untouched but routes through `wrap`
1534
+ # so callers that need a uniform return type (color_method,
1535
+ # color_status) can use it as a fallback without forking the return
1536
+ # into String|RbVal — see the note on Color above.
1537
+ def self.plain(s = ""); wrap("", s); end
1538
+ end
1539
+
1540
+ # Per-method palette — green for safe reads, yellow for creates,
1541
+ # blue for updates, red for destroys. Matches the convention most
1542
+ # request inspectors (Postman, Insomnia, the Chrome devtools network
1543
+ # panel) settled on, so it reads at a glance. Unknown methods route
1544
+ # through Color.plain for the uniform-return-type discipline noted
1545
+ # on Color.
1546
+ #
1547
+ # `s = m.to_s` is the type-pin: Spinel's `.to_s` always lowers to
1548
+ # `sp_poly_to_s` (declared `const char *`) regardless of m's inferred
1549
+ # type, so `s` is guaranteed String. Without this, m's parameter type
1550
+ # (which the analyzer pessimistically widens to RbVal here — the
1551
+ # multiple `Color.*` call sites in this body form a circular
1552
+ # unification with wrap's `s`) would propagate into Color.green/red/
1553
+ # etc. and force every wrap-helper to take RbVal, which then breaks
1554
+ # the `out += s` (String += RbVal) inside wrap.
1555
+ def color_method(m = "")
1556
+ s = m.to_s
1557
+ return Color.green(s) if s == "GET"
1558
+ return Color.green(s) if s == "HEAD"
1559
+ return Color.yellow(s) if s == "POST"
1560
+ return Color.blue(s) if s == "PATCH"
1561
+ return Color.blue(s) if s == "PUT"
1562
+ return Color.red(s) if s == "DELETE"
1563
+ return Color.cyan(s) if s == "OPTIONS"
1564
+ Color.plain(s)
1565
+ end
1566
+
1567
+ # Status: 2xx green (success), 3xx cyan (redirect), 4xx yellow
1568
+ # (client error — operator should glance), 5xx red (server error —
1569
+ # operator must look). 1xx falls through to Color.plain — same
1570
+ # uniform-return-type discipline as color_method. `status.to_s` here
1571
+ # is naturally String (Integer#to_s in Spinel is `sp_int_to_s` →
1572
+ # `const char *`).
1573
+ def color_status(status = 0)
1574
+ s = status.to_s
1575
+ return Color.green(s) if status >= 200 && status < 300
1576
+ return Color.cyan(s) if status >= 300 && status < 400
1577
+ return Color.yellow(s) if status >= 400 && status < 500
1578
+ return Color.red(s) if status >= 500
1579
+ Color.plain(s)
1580
+ end
1581
+
1582
+ # SQL log colorization. Verb-driven palette mirroring color_method —
1583
+ # green for safe reads (SELECT), yellow for creates (INSERT), blue for
1584
+ # updates (UPDATE), red for destroys (DELETE), magenta for transaction
1585
+ # control and DDL (BEGIN/COMMIT/ROLLBACK/CREATE/ALTER/DROP). Unknown
1586
+ # leads fall through to Color.plain.
1587
+ #
1588
+ # Uniform-return-type discipline: every branch routes through a Color
1589
+ # helper so the inferred return stays String. The same widening trap
1590
+ # from color_method/color_status applies here — a bare `return sql`
1591
+ # would fork the return into String|RbVal under Spinel's analyzer.
1592
+ #
1593
+ # Match is by leading-keyword on the raw SQL string. Generated model
1594
+ # code always emits uppercase leads (see lib/templates/model.rb.erb);
1595
+ # user-supplied `Fresco::Db.exec(...)` strings that lead with
1596
+ # lowercase will fall through to Color.plain rather than mis-match,
1597
+ # which is the right safety default.
1598
+ def color_sql(sql = "")
1599
+ return Color.green(sql) if sql.start_with?("SELECT")
1600
+ return Color.yellow(sql) if sql.start_with?("INSERT")
1601
+ return Color.blue(sql) if sql.start_with?("UPDATE")
1602
+ return Color.red(sql) if sql.start_with?("DELETE")
1603
+ return Color.magenta(sql) if sql.start_with?("BEGIN")
1604
+ return Color.magenta(sql) if sql.start_with?("COMMIT")
1605
+ return Color.magenta(sql) if sql.start_with?("ROLLBACK")
1606
+ return Color.magenta(sql) if sql.start_with?("CREATE")
1607
+ return Color.magenta(sql) if sql.start_with?("ALTER")
1608
+ return Color.magenta(sql) if sql.start_with?("DROP")
1609
+ Color.plain(sql)
1610
+ end
1611
+
1612
+ # Format a Sym→Str hash for the request log. Manual concat (vs
1613
+ # Hash#inspect) keeps the each-block in the str-concat narrow path —
1614
+ # Spinel infers the param as sym_str_hash from the k/v usage. See
1615
+ # spinel test/hash_each_param_narrow.rb.
1616
+ def format_hash(h)
1617
+ out = "{"
1618
+ first = true
1619
+ h.each do |k, v|
1620
+ out += ", " unless first
1621
+ first = false
1622
+ out += k.to_s + "=" + v.to_s
1623
+ end
1624
+ out + "}"
1625
+ end
1626
+
1627
+ # Format an integer micro-second count for the request / SQL log
1628
+ # lines. Sub-millisecond values stay in micros ("742us") so single-
1629
+ # digit-millisecond values aren't lost to rounding; >= 1ms switches
1630
+ # to milliseconds with one decimal ("12.3ms") because once you're in
1631
+ # the millis range nobody cares about the trailing micros.
1632
+ #
1633
+ # Plain integer math — Spinel doesn't lower Float arithmetic across
1634
+ # the analyzer pass yet, and the rest of the timing code in this
1635
+ # file is `int micros` throughout (sphttp_elapsed_micros returns
1636
+ # `int`). The whole.to_s / tenth.to_s split keeps the result a
1637
+ # narrow String.
1638
+ def fmt_micros(us = 0)
1639
+ return us.to_s + "us" if us < 1000
1640
+ whole = us / 1000
1641
+ tenth = (us % 1000) / 100
1642
+ whole.to_s + "." + tenth.to_s + "ms"
1643
+ end
1644
+
1645
+ def log_request_brief(method, path, status, micros)
1646
+ puts Color.dim("[request] ") + color_method(method) + " " + path + " " +
1647
+ color_status(status) + " " + Color.dim("(" + fmt_micros(micros) + ")")
1648
+ end
1649
+
1650
+ def log_request_full(req, status, micros)
1651
+ line = Color.dim("[request] ") + color_method(req.verb) + " " + req.path + " " +
1652
+ color_status(status) + " " + Color.dim("(" + fmt_micros(micros) + ")")
1653
+ # SQL roll-up. The db adapter funnels per-query micros into the
1654
+ # `Fresco`-module counters during the request (zeroed by
1655
+ # `Fresco::Db.begin_request!` → `Fresco.reset_db_sql_stats!`);
1656
+ # we read both back here so the operator sees the SQL share of
1657
+ # the request budget on the same line as the request total.
1658
+ # Read from `Fresco` (same file) rather than `Fresco::Db` (loaded
1659
+ # later) because cross-file value-returning module calls resolve
1660
+ # the receiver to `int` and emit 0 under Spinel — see the
1661
+ # comment on the counters' declaration above. `query_count == 0`
1662
+ # suppresses the segment entirely so request lines for handlers
1663
+ # that don't touch the DB stay short. The totals are maintained
1664
+ # even when FRESCO_LOG_SQL is off — per-query lines are gated,
1665
+ # the cheap roll-up isn't.
1666
+ sql_us = Fresco.db_sql_total_micros
1667
+ sql_n = Fresco.db_sql_query_count
1668
+ if sql_n > 0
1669
+ line += " " + Color.dim("sql=" + fmt_micros(sql_us) + "/" + sql_n.to_s + "q")
1670
+ end
1671
+ # Query is now folded into params at parse time; logging only the
1672
+ # merged hash keeps the formatter monomorphic on Sym→Str (plan
1673
+ # decision #5). `req.params.raw` is the underlying Hash — handing
1674
+ # the Params wrapper to `format_hash` would force a second type.
1675
+ if req.params.length > 0
1676
+ line += " " + Color.dim("params=" + format_hash(req.params.raw))
1677
+ end
1678
+ puts line
1679
+ end
1680
+
1681
+ # Serve one TCP connection. Loops until the client closes, a parse
1682
+ # fails, or the response opts out of keep-alive. Each iteration reads
1683
+ # a fresh request into the (reset) request buf; pipelining is out of
1684
+ # scope — clients must wait for response N before sending request N+1.
1685
+ def handle_connection(client)
1686
+ keep_going = true
1687
+ while keep_going
1688
+ n = Sock.sphttp_read_request(client)
1689
+ break if n <= 0
1690
+
1691
+ # Parse-error logs still use the parse/write window below. Successful
1692
+ # requests reset the timer after parsing so dev and release report the
1693
+ # same app-handling window: dispatch + session-cookie attachment.
1694
+ Sock.sphttp_mark_now
1695
+ parsed = parse_http_request(client)
1696
+ if parsed.parse_status == 400
1697
+ write_response(client, error_response(400, "Bad Request\n"), false)
1698
+ log_request_brief("?", "?", 400, Sock.sphttp_elapsed_micros)
1699
+ break
1700
+ end
1701
+ if parsed.parse_status == 413
1702
+ write_response(client, error_response(413, "Payload Too Large\n"), false)
1703
+ log_request_brief("?", "?", 413, Sock.sphttp_elapsed_micros)
1704
+ break
1705
+ end
1706
+
1707
+ keep_going = parsed.keep_alive?
1708
+ # Tell Fresco::Db to buffer SQL log lines during this request so
1709
+ # they flush as an indented block under the [request] line below
1710
+ # (instead of streaming above it in dispatch order). No-op when
1711
+ # FRESCO_LOG_SQL isn't set — the gate lives inside the db adapter.
1712
+ Fresco::Db.begin_request!
1713
+ begin
1714
+ Sock.sphttp_mark_now
1715
+ res = dispatch_request(parsed.verb, parsed.path, parsed)
1716
+ attach_session_cookie!(parsed, res)
1717
+ micros = Sock.sphttp_elapsed_micros
1718
+ write_response(client, res, keep_going)
1719
+ log_request_full(parsed, res.status, micros)
1720
+ Fresco::Db.flush_log!
1721
+ rescue => e
1722
+ micros = Sock.sphttp_elapsed_micros
1723
+ # An action raised. Log a single line (so an operator can grep
1724
+ # for crashes) and serve a sanitised 500 — never the message,
1725
+ # since it could leak internals to clients.
1726
+ puts Color.red("[handler] ") + e.class + ": " + e.message
1727
+ write_response(client, error_response(500, "Internal Server Error\n"), false)
1728
+ log_request_full(parsed, 500, micros)
1729
+ Fresco::Db.flush_log!
1730
+ keep_going = false
1731
+ end
1732
+ end
1733
+ Sock.sphttp_close(client)
1734
+ end
1735
+
1736
+ def worker_loop(sfd)
1737
+ loop do
1738
+ cfd = Sock.sphttp_accept(sfd)
1739
+ next if cfd < 0
1740
+ handle_connection(cfd)
1741
+ end
1742
+ end
1743
+
1744
+ # Prefork model. With workers == 1 we run the accept loop inline (no
1745
+ # fork, easier to debug, same behavior as M4). With workers > 1 the
1746
+ # parent forks N children — each opens its OWN SO_REUSEPORT listener
1747
+ # on `port`, and the kernel load-balances incoming accepts across
1748
+ # them. The parent never opens a listener; it just waits to reap
1749
+ # children. Killing one worker (kill -9) leaves the others serving
1750
+ # while the parent reaps the corpse.
1751
+ # Fork one worker process and return the child's pid in the parent.
1752
+ # Child branch runs worker_loop until exit(0) / crash. Extracted so the
1753
+ # initial spawn loop and the reap-loop respawn use one code path —
1754
+ # important so the listener-setup details and the "ready" log line
1755
+ # stay in lock-step between fresh-boot and post-crash respawn.
1756
+ def spawn_worker(port = 0, slot = 0)
1757
+ pid = Sock.sphttp_fork
1758
+ if pid == 0
1759
+ sfd = Sock.sphttp_listen(port, 1) # SO_REUSEPORT — each worker binds independently
1760
+ if sfd < 0
1761
+ puts Color.red("[worker " + Sock.sphttp_getpid.to_s + "] ") + "sphttp_listen failed"
1762
+ exit(1)
1763
+ end
1764
+ puts Color.cyan("[worker " + Sock.sphttp_getpid.to_s + "] ") + "ready (slot " + slot.to_s + ")"
1765
+ worker_loop(sfd)
1766
+ exit(0)
1767
+ end
1768
+ pid
1769
+ end
1770
+
1771
+ def run_server(port:, workers: 1)
1772
+ if workers <= 1
1773
+ sfd = Sock.sphttp_listen(port, 0)
1774
+ if sfd < 0
1775
+ puts Color.red("[server] ") + "sphttp_listen failed on port " + port.to_s
1776
+ return
1777
+ end
1778
+ puts Color.cyan("[server] ") + "listening on http://0.0.0.0:" + port.to_s + " (pid " + Sock.sphttp_getpid.to_s + ")"
1779
+ worker_loop(sfd)
1780
+ return
1781
+ end
1782
+
1783
+ puts Color.cyan("[server] ") + "listening on http://0.0.0.0:" + port.to_s +
1784
+ " (parent pid " + Sock.sphttp_getpid.to_s +
1785
+ ", workers=" + workers.to_s + ")"
1786
+
1787
+ workers.times do |i|
1788
+ spawn_worker(port, i)
1789
+ end
1790
+
1791
+ # Reap-and-respawn: when a worker dies (crash or graceful exit),
1792
+ # the parent forks a fresh replacement so the served-process count
1793
+ # stays steady. General-purpose crash safety net — handlers that
1794
+ # raise are already caught inside handle_connection, but anything
1795
+ # that escapes (OS-level signal, FFI fault) drops the worker, and
1796
+ # without respawn the service degrades over time.
1797
+ #
1798
+ # `slot` is monotonically incremented so log lines remain unique
1799
+ # (a respawned worker reports `slot=42` etc., not slot 0 again),
1800
+ # making it easy to count restarts from logs. The initial spawn
1801
+ # uses 0..N-1, so respawn slots start at `workers` to avoid overlap.
1802
+ next_slot = workers
1803
+ loop do
1804
+ reaped = Sock.sphttp_wait_any
1805
+ break if reaped < 0
1806
+ puts Color.dim("[server] reaped worker pid " + reaped.to_s + " — respawning as slot " + next_slot.to_s)
1807
+ spawn_worker(port, next_slot)
1808
+ next_slot += 1
1809
+ end
1810
+ end