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.
- checksums.yaml +7 -0
- data/exe/fresco +3 -0
- data/lib/fresco/application.rb +12 -0
- data/lib/fresco/cli/build.rb +682 -0
- data/lib/fresco/cli/dev.rb +17 -0
- data/lib/fresco/cli/dev_loop.rb +815 -0
- data/lib/fresco/cli/new.rb +120 -0
- data/lib/fresco/cli/release.rb +76 -0
- data/lib/fresco/cli.rb +56 -0
- data/lib/fresco/database_config.rb +34 -0
- data/lib/fresco/generators/app/Gemfile.tt +18 -0
- data/lib/fresco/generators/app/README.md.tt +32 -0
- data/lib/fresco/generators/app/app/action.rb.tt +20 -0
- data/lib/fresco/generators/app/app/actions/root_path.rb.tt +5 -0
- data/lib/fresco/generators/app/app/views/layouts/application.html.erb +29 -0
- data/lib/fresco/generators/app/app/views/root_path.html.erb +8 -0
- data/lib/fresco/generators/app/app.rb.tt +15 -0
- data/lib/fresco/generators/app/bin/build +2 -0
- data/lib/fresco/generators/app/bin/dev +2 -0
- data/lib/fresco/generators/app/bin/release +2 -0
- data/lib/fresco/generators/app/config/app.rb.tt +26 -0
- data/lib/fresco/generators/app/config/database.rb +17 -0
- data/lib/fresco/generators/app/config/routes.rb +11 -0
- data/lib/fresco/generators/app/db/schema.rb +14 -0
- data/lib/fresco/generators/app/public/404.html +87 -0
- data/lib/fresco/generators/app/public/500.html +84 -0
- data/lib/fresco/migration_builder.rb +55 -0
- data/lib/fresco/model_builder.rb +54 -0
- data/lib/fresco/paths.rb +20 -0
- data/lib/fresco/router.rb +67 -0
- data/lib/fresco/runtime/boot.rb +34 -0
- data/lib/fresco/runtime/db_postgres.rb +403 -0
- data/lib/fresco/runtime/db_sqlite.rb +495 -0
- data/lib/fresco/runtime/http.c +456 -0
- data/lib/fresco/runtime/postgres.c +339 -0
- data/lib/fresco/runtime/runtime.rb +1810 -0
- data/lib/fresco/runtime/sqlite.c +220 -0
- data/lib/fresco/runtime/welcome.rb +152 -0
- data/lib/fresco/schema_builder.rb +71 -0
- data/lib/fresco/templates/dispatch.rb.erb +32 -0
- data/lib/fresco/templates/layout_dispatch.rb.erb +16 -0
- data/lib/fresco/templates/manifest.rb.erb +5 -0
- data/lib/fresco/templates/migrations.rb.erb +152 -0
- data/lib/fresco/templates/model.rb.erb +223 -0
- data/lib/fresco/templates/view.rb.erb +5 -0
- data/lib/fresco/version.rb +3 -0
- data/lib/fresco.rb +61 -0
- 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("&", "&")
|
|
787
|
+
s = s.gsub("<", "<")
|
|
788
|
+
s = s.gsub(">", ">")
|
|
789
|
+
s = s.gsub('"', """)
|
|
790
|
+
s = s.gsub("'", "'")
|
|
791
|
+
s
|
|
792
|
+
end
|
|
793
|
+
|
|
794
|
+
def self.attr(value)
|
|
795
|
+
s = value.to_s
|
|
796
|
+
s = s.gsub("&", "&")
|
|
797
|
+
s = s.gsub('"', """)
|
|
798
|
+
s = s.gsub("'", "'")
|
|
799
|
+
s = s.gsub("<", "<")
|
|
800
|
+
s = s.gsub(">", ">")
|
|
801
|
+
s = s.gsub("\n", " ")
|
|
802
|
+
s = s.gsub("\r", " ")
|
|
803
|
+
s = s.gsub("\t", "	")
|
|
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
|