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,815 @@
1
+ # Fresco: dev loop. CRuby only — no Spinel rebuild.
2
+ #
3
+ # 1. Regenerate generated/* via `fresco build`.
4
+ # 2. Watch config/routes.rb + app/actions/ + app/views/ for changes;
5
+ # rebuild + reload on edit.
6
+ # 3. Listen on -p PORT (default 3030) with a tiny TCPServer-backed
7
+ # HTTP loop that calls the same `dispatch_request` the prod
8
+ # binary uses. ERB / action edits round-trip without a Spinel
9
+ # recompile; the C shim only matters in release.
10
+ #
11
+ # Loaded by Fresco::CLI::Dev#run into top-level scope so methods
12
+ # defined here share scope with the methods generated/runtime.rb
13
+ # defines on load.
14
+
15
+ require "socket"
16
+
17
+ ROOT = Dir.pwd
18
+ # Defensive: dev's helpers all assume relative paths from the user
19
+ # app's cwd; bind it explicitly so `fresco dev` invoked from a
20
+ # subdirectory still walks up to the project root cleanly when
21
+ # we add that capability later.
22
+ Dir.chdir(ROOT)
23
+
24
+ WATCH_GLOBS = [
25
+ "config/routes.rb",
26
+ "config/app.rb",
27
+ "config/database.rb",
28
+ "db/schema.rb",
29
+ "app/action.rb",
30
+ "app/actions/**/*.rb",
31
+ "app/models/**/*.rb",
32
+ "app/views/**/*.erb",
33
+ ].freeze
34
+ RELOAD_LOCK = Mutex.new
35
+
36
+ # Spinel's FFI DSL (`ffi_func`, `ffi_cflags`, …) is analyzer-only — the
37
+ # AOT pipeline rewrites it before runtime. Under CRuby those names
38
+ # don't exist, which would crash any `load` of a runtime that declares
39
+ # FFI modules. Stub them as no-ops on `Module` so we can load
40
+ # generated/runtime.rb in dev.
41
+ class Module
42
+ def ffi_lib(*); end
43
+ def ffi_cflags(*); end
44
+ def ffi_func(*); end
45
+ def ffi_buffer(*); end
46
+ def ffi_read_u32(*); end
47
+ def ffi_read_i32(*); end
48
+ def ffi_read_ptr(*); end
49
+ def ffi_const(name, value)
50
+ const_set(name, value) unless const_defined?(name)
51
+ end
52
+ end
53
+
54
+ # `load`-ing the generated runtime + actions on every reload re-runs
55
+ # their toplevel `FOO = ...` assignments, which Ruby warns about. The
56
+ # redefinitions are intentional under the dev reloader, so filter just
57
+ # those two warning shapes — everything else (uninitialized ivars,
58
+ # method redefinitions, etc.) still flows through.
59
+ module SilenceReloadConstWarnings
60
+ IGNORE = /already initialized constant|previous definition of/
61
+ def warn(msg, category: nil)
62
+ return if msg.is_a?(String) && msg.match?(IGNORE)
63
+ super
64
+ end
65
+ end
66
+ Warning.singleton_class.prepend(SilenceReloadConstWarnings)
67
+
68
+ def build!
69
+ # Subprocess (vs. in-process call) keeps Fresco's build-time DSL
70
+ # accumulators (Fresco.models, Fresco.app.routes.entries, etc.) from
71
+ # leaking across rebuilds. The cost is one fork + ruby startup per
72
+ # reload, which is fine for development.
73
+ unless system("bundle", "exec", "fresco", "build")
74
+ warn "[dev] fresco build failed — keeping previous dispatcher"
75
+ return false
76
+ end
77
+ true
78
+ end
79
+
80
+ # `load` (vs require) re-evaluates on each call so reloads pick up
81
+ # edits. Paths are relative to ROOT (we chdir'd above).
82
+ def reload!
83
+ RELOAD_LOCK.synchronize do
84
+ load "generated/runtime.rb"
85
+ Dir.glob("generated/views/*.rb").sort.each { |f| load f }
86
+ load "generated/layout_dispatch.rb"
87
+ # db_adapter mirrors boot.rb's order: between layout_dispatch and
88
+ # config/app so the FFI module + Fresco::Db::Active are defined
89
+ # before user code can reference them. `fresco build` always emits
90
+ # this file (an empty stub when no adapter is configured), so the
91
+ # load never misses.
92
+ load "generated/db_adapter.rb"
93
+ # Migrations runner — defines Fresco::DbMigrations. Used by
94
+ # `./app db:migrate` and `fresco dev db:migrate` alike, so it has
95
+ # to be on the dev load path too. Boot.rb requires it between
96
+ # db_adapter and config/app; we mirror that order here.
97
+ load "generated/migrations.rb"
98
+ load "config/app.rb"
99
+ # config/database.rb populates Fresco.app.database_config from the
100
+ # running process's environment (ENV.fetch lives inside the file).
101
+ # `fresco build` also synthesises a placeholder when the user
102
+ # didn't supply one, so the load is unconditional.
103
+ load "config/database.rb" if File.exist?("config/database.rb")
104
+ # Generated models load before user action.rb so action classes
105
+ # can reference them. Models live under generated/models/ (one .rb
106
+ # per declared model in app/models/) — same file location dev and
107
+ # release use.
108
+ Dir.glob("generated/models/*.rb").sort.each { |f| load f }
109
+ load "app/action.rb"
110
+ Dir.glob("app/actions/**/*.rb").sort.each { |f| load f }
111
+ # Fresco-supplied default welcome action — only present when
112
+ # config/routes.rb has no `root to:` and `fresco build` emitted
113
+ # the fallback. Dispatch.rb references Fresco::Welcome, so load
114
+ # it before the dispatcher.
115
+ load "generated/welcome.rb" if File.exist?("generated/welcome.rb")
116
+ load "generated/dispatch.rb"
117
+
118
+ # The C shim isn't linked under CRuby — `ffi_func` was a no-op
119
+ # above, so `Sock` exists but has no methods. Provide CRuby
120
+ # stand-ins for the handful of Sock entries the runtime calls
121
+ # from non-server code paths (i.e. inside actions like Files::Show).
122
+ # The server-loop entries (sphttp_listen/accept/read/etc.) aren't
123
+ # needed because serve_dev replaces that loop entirely.
124
+ Sock.define_singleton_method(:sphttp_file_size) do |path|
125
+ File.exist?(path) ? File.size(path) : -1
126
+ end
127
+
128
+ # HMAC stand-ins. Under Spinel these are the public-domain
129
+ # SHA-256/HMAC code in generated/runtime/http.c; under CRuby we
130
+ # route through OpenSSL::HMAC so the Session class round-trips
131
+ # identically.
132
+ require "openssl"
133
+ require "base64"
134
+ Sock.define_singleton_method(:sphttp_hmac_sha256_hex) do |key, msg|
135
+ OpenSSL::HMAC.hexdigest("SHA256", key, msg)
136
+ end
137
+ Sock.define_singleton_method(:sphttp_hmac_sha256_b64url) do |key, msg|
138
+ Base64.urlsafe_encode64(OpenSSL::HMAC.digest("SHA256", key, msg), padding: false)
139
+ end
140
+
141
+ # Monotonic-clock stand-ins for the request-timing primitives the
142
+ # production handle_connection uses. Actions can reach for these
143
+ # (e.g. an around filter that times its work) and `fresco dev`
144
+ # needs to honor the same API surface, even though serve_dev's own
145
+ # loop doesn't rely on them.
146
+ Sock.instance_variable_set(:@dev_mark, 0)
147
+ Sock.define_singleton_method(:sphttp_mark_now) do
148
+ Sock.instance_variable_set(:@dev_mark, Process.clock_gettime(Process::CLOCK_MONOTONIC, :microsecond))
149
+ 0
150
+ end
151
+ Sock.define_singleton_method(:sphttp_elapsed_micros) do
152
+ Process.clock_gettime(Process::CLOCK_MONOTONIC, :microsecond) -
153
+ Sock.instance_variable_get(:@dev_mark)
154
+ end
155
+
156
+ # DB adapter stand-ins. config/database.rb (loaded above) sets
157
+ # Fresco.app.database_config; we install the matching FFI stubs so
158
+ # actions hitting Fresco::Db::Active work under CRuby. Production
159
+ # routes through the linked C shim instead; the Ruby surface is
160
+ # identical so dev and prod behave the same from action code.
161
+ db_adapter = Fresco.database_adapter || :none
162
+
163
+ if db_adapter == :sqlite
164
+ require "sqlite3"
165
+ Sock.instance_variable_set(:@dev_sqlite_handles, [nil] * 16)
166
+ Sock.instance_variable_set(:@dev_sqlite_stmts, [nil] * 64)
167
+ Sqlite.define_singleton_method(:fresco_sqlite_open) do |path|
168
+ handles = Sock.instance_variable_get(:@dev_sqlite_handles)
169
+ slot = handles.index(nil)
170
+ return -1 if slot.nil?
171
+ begin
172
+ handles[slot] = ::SQLite3::Database.new(path)
173
+ slot + 1
174
+ rescue ::SQLite3::Exception
175
+ -1
176
+ end
177
+ end
178
+ Sqlite.define_singleton_method(:fresco_sqlite_close) do |h|
179
+ handles = Sock.instance_variable_get(:@dev_sqlite_handles)
180
+ stmts = Sock.instance_variable_get(:@dev_sqlite_stmts)
181
+ return -1 if h < 1 || h > handles.length
182
+ db = handles[h - 1]
183
+ return -1 if db.nil?
184
+ stmts.each_with_index do |s, i|
185
+ next if s.nil?
186
+ if s[0] == db
187
+ s[1].close unless s[1].closed?
188
+ stmts[i] = nil
189
+ end
190
+ end
191
+ db.close
192
+ handles[h - 1] = nil
193
+ 0
194
+ end
195
+ Sqlite.define_singleton_method(:fresco_sqlite_exec) do |h, sql|
196
+ handles = Sock.instance_variable_get(:@dev_sqlite_handles)
197
+ return -1 if h < 1 || h > handles.length
198
+ db = handles[h - 1]
199
+ return -1 if db.nil?
200
+ begin
201
+ db.execute_batch(sql)
202
+ 0
203
+ rescue ::SQLite3::Exception
204
+ -1
205
+ end
206
+ end
207
+ Sqlite.define_singleton_method(:fresco_sqlite_prepare) do |h, sql|
208
+ handles = Sock.instance_variable_get(:@dev_sqlite_handles)
209
+ stmts = Sock.instance_variable_get(:@dev_sqlite_stmts)
210
+ return -1 if h < 1 || h > handles.length
211
+ db = handles[h - 1]
212
+ return -1 if db.nil?
213
+ slot = stmts.index(nil)
214
+ return -1 if slot.nil?
215
+ begin
216
+ stmt = db.prepare(sql)
217
+ stmts[slot] = [db, stmt, nil] # [db, stmt, last_step_row_or_nil]
218
+ slot + 1
219
+ rescue ::SQLite3::Exception
220
+ -1
221
+ end
222
+ end
223
+ Sqlite.define_singleton_method(:fresco_sqlite_bind_str) do |cid, idx, value|
224
+ stmts = Sock.instance_variable_get(:@dev_sqlite_stmts)
225
+ return -1 if cid < 1 || cid > stmts.length
226
+ entry = stmts[cid - 1]
227
+ return -1 if entry.nil?
228
+ begin
229
+ entry[1].bind_param(idx, value)
230
+ 0
231
+ rescue ::SQLite3::Exception
232
+ -1
233
+ end
234
+ end
235
+ Sqlite.define_singleton_method(:fresco_sqlite_bind_int) do |cid, idx, value|
236
+ stmts = Sock.instance_variable_get(:@dev_sqlite_stmts)
237
+ return -1 if cid < 1 || cid > stmts.length
238
+ entry = stmts[cid - 1]
239
+ return -1 if entry.nil?
240
+ begin
241
+ entry[1].bind_param(idx, value)
242
+ 0
243
+ rescue ::SQLite3::Exception
244
+ -1
245
+ end
246
+ end
247
+ Sqlite.define_singleton_method(:fresco_sqlite_step) do |cid|
248
+ stmts = Sock.instance_variable_get(:@dev_sqlite_stmts)
249
+ return -1 if cid < 1 || cid > stmts.length
250
+ entry = stmts[cid - 1]
251
+ return -1 if entry.nil?
252
+ # The gem's stmt acts as an iterator; capture the next row (or
253
+ # nil at end-of-result) so col_* lookups read off the same row.
254
+ entry[2] = entry[1].step
255
+ return 0 if entry[2].nil?
256
+ 1
257
+ end
258
+ Sqlite.define_singleton_method(:fresco_sqlite_col_str) do |cid, idx|
259
+ stmts = Sock.instance_variable_get(:@dev_sqlite_stmts)
260
+ return "" if cid < 1 || cid > stmts.length
261
+ entry = stmts[cid - 1]
262
+ return "" if entry.nil? || entry[2].nil?
263
+ v = entry[2][idx]
264
+ v.nil? ? "" : v.to_s
265
+ end
266
+ Sqlite.define_singleton_method(:fresco_sqlite_col_int) do |cid, idx|
267
+ stmts = Sock.instance_variable_get(:@dev_sqlite_stmts)
268
+ return 0 if cid < 1 || cid > stmts.length
269
+ entry = stmts[cid - 1]
270
+ return 0 if entry.nil? || entry[2].nil?
271
+ v = entry[2][idx]
272
+ v.nil? ? 0 : v.to_i
273
+ end
274
+ Sqlite.define_singleton_method(:fresco_sqlite_col_count) do |cid|
275
+ stmts = Sock.instance_variable_get(:@dev_sqlite_stmts)
276
+ return 0 if cid < 1 || cid > stmts.length
277
+ entry = stmts[cid - 1]
278
+ return 0 if entry.nil?
279
+ entry[1].columns.length
280
+ end
281
+ Sqlite.define_singleton_method(:fresco_sqlite_finalize) do |cid|
282
+ stmts = Sock.instance_variable_get(:@dev_sqlite_stmts)
283
+ return 0 if cid < 1 || cid > stmts.length
284
+ entry = stmts[cid - 1]
285
+ return 0 if entry.nil?
286
+ entry[1].close unless entry[1].closed?
287
+ stmts[cid - 1] = nil
288
+ 0
289
+ end
290
+ Sqlite.define_singleton_method(:fresco_sqlite_reset) do |cid|
291
+ stmts = Sock.instance_variable_get(:@dev_sqlite_stmts)
292
+ return -1 if cid < 1 || cid > stmts.length
293
+ entry = stmts[cid - 1]
294
+ return -1 if entry.nil?
295
+ entry[1].reset!
296
+ entry[2] = nil
297
+ 0
298
+ end
299
+ Sqlite.define_singleton_method(:fresco_sqlite_last_insert_rowid) do |h|
300
+ handles = Sock.instance_variable_get(:@dev_sqlite_handles)
301
+ return -1 if h < 1 || h > handles.length
302
+ db = handles[h - 1]
303
+ return -1 if db.nil?
304
+ db.last_insert_row_id.to_i
305
+ end
306
+ end
307
+
308
+ if db_adapter == :postgres
309
+ # Postgres stand-ins. The `pg` gem is loaded lazily so apps on
310
+ # the SQLite adapter (or with no DB) don't need it installed.
311
+ # Mirrors the production generated/runtime/postgres.c shim:
312
+ # per-cursor state holding {conn, sql, accumulated binds,
313
+ # PGresult, row index}; bind_*/step lazy-execute via PQexecParams
314
+ # on first step.
315
+ begin
316
+ require "pg"
317
+ rescue LoadError
318
+ abort "[dev] config/database.rb declares :postgres but the `pg` gem " \
319
+ "is not installed. Add `gem \"pg\"` to the :development group " \
320
+ "in your Gemfile and bundle."
321
+ end
322
+ Sock.instance_variable_set(:@dev_pg_handles, [nil] * 16)
323
+ Sock.instance_variable_set(:@dev_pg_stmts, [nil] * 64)
324
+ Pg.define_singleton_method(:fresco_pg_open) do |url|
325
+ handles = Sock.instance_variable_get(:@dev_pg_handles)
326
+ slot = handles.index(nil)
327
+ return -1 if slot.nil?
328
+ begin
329
+ handles[slot] = ::PG.connect(url)
330
+ slot + 1
331
+ rescue ::PG::Error
332
+ -1
333
+ end
334
+ end
335
+ Pg.define_singleton_method(:fresco_pg_close) do |h|
336
+ handles = Sock.instance_variable_get(:@dev_pg_handles)
337
+ stmts = Sock.instance_variable_get(:@dev_pg_stmts)
338
+ return -1 if h < 1 || h > handles.length
339
+ conn = handles[h - 1]
340
+ return -1 if conn.nil?
341
+ stmts.each_with_index do |s, i|
342
+ next if s.nil?
343
+ if s[:conn] == conn
344
+ stmts[i] = nil
345
+ end
346
+ end
347
+ conn.close
348
+ handles[h - 1] = nil
349
+ 0
350
+ end
351
+ Pg.define_singleton_method(:fresco_pg_exec) do |h, sql|
352
+ handles = Sock.instance_variable_get(:@dev_pg_handles)
353
+ return -1 if h < 1 || h > handles.length
354
+ conn = handles[h - 1]
355
+ return -1 if conn.nil?
356
+ begin
357
+ conn.exec(sql)
358
+ 0
359
+ rescue ::PG::Error
360
+ -1
361
+ end
362
+ end
363
+ Pg.define_singleton_method(:fresco_pg_prepare) do |h, sql|
364
+ handles = Sock.instance_variable_get(:@dev_pg_handles)
365
+ stmts = Sock.instance_variable_get(:@dev_pg_stmts)
366
+ return -1 if h < 1 || h > handles.length
367
+ conn = handles[h - 1]
368
+ return -1 if conn.nil?
369
+ slot = stmts.index(nil)
370
+ return -1 if slot.nil?
371
+ # Defer execution until the first step, like the C shim. Binds
372
+ # accumulate into :params (1-indexed, sparse), then PQexecParams
373
+ # equivalent fires on first step.
374
+ stmts[slot] = { conn: conn, sql: sql, params: [], result: nil, row: -1, ntuples: 0 }
375
+ slot + 1
376
+ end
377
+ Pg.define_singleton_method(:fresco_pg_bind_str) do |cid, idx, value|
378
+ stmts = Sock.instance_variable_get(:@dev_pg_stmts)
379
+ return -1 if cid < 1 || cid > stmts.length
380
+ entry = stmts[cid - 1]
381
+ return -1 if entry.nil?
382
+ entry[:params][idx - 1] = value.to_s
383
+ 0
384
+ end
385
+ Pg.define_singleton_method(:fresco_pg_bind_int) do |cid, idx, value|
386
+ stmts = Sock.instance_variable_get(:@dev_pg_stmts)
387
+ return -1 if cid < 1 || cid > stmts.length
388
+ entry = stmts[cid - 1]
389
+ return -1 if entry.nil?
390
+ entry[:params][idx - 1] = value.to_s
391
+ 0
392
+ end
393
+ Pg.define_singleton_method(:fresco_pg_step) do |cid|
394
+ stmts = Sock.instance_variable_get(:@dev_pg_stmts)
395
+ return -1 if cid < 1 || cid > stmts.length
396
+ entry = stmts[cid - 1]
397
+ return -1 if entry.nil?
398
+ if entry[:result].nil?
399
+ # First step: fire query with accumulated binds. Replace nils
400
+ # with empty strings so libpq's text coercion has something
401
+ # to chew on.
402
+ values = entry[:params].map { |v| v.nil? ? "" : v }
403
+ begin
404
+ entry[:result] = entry[:conn].exec_params(entry[:sql], values)
405
+ entry[:ntuples] = entry[:result].ntuples
406
+ entry[:row] = -1
407
+ rescue ::PG::Error
408
+ entry[:result] = nil
409
+ return -1
410
+ end
411
+ end
412
+ entry[:row] += 1
413
+ entry[:row] < entry[:ntuples] ? 1 : 0
414
+ end
415
+ Pg.define_singleton_method(:fresco_pg_col_str) do |cid, idx|
416
+ stmts = Sock.instance_variable_get(:@dev_pg_stmts)
417
+ return "" if cid < 1 || cid > stmts.length
418
+ entry = stmts[cid - 1]
419
+ return "" if entry.nil? || entry[:result].nil?
420
+ return "" if entry[:row] < 0 || entry[:row] >= entry[:ntuples]
421
+ v = entry[:result].getvalue(entry[:row], idx)
422
+ v.nil? ? "" : v.to_s
423
+ end
424
+ Pg.define_singleton_method(:fresco_pg_col_int) do |cid, idx|
425
+ stmts = Sock.instance_variable_get(:@dev_pg_stmts)
426
+ return 0 if cid < 1 || cid > stmts.length
427
+ entry = stmts[cid - 1]
428
+ return 0 if entry.nil? || entry[:result].nil?
429
+ return 0 if entry[:row] < 0 || entry[:row] >= entry[:ntuples]
430
+ v = entry[:result].getvalue(entry[:row], idx)
431
+ v.nil? ? 0 : v.to_i
432
+ end
433
+ Pg.define_singleton_method(:fresco_pg_col_count) do |cid|
434
+ stmts = Sock.instance_variable_get(:@dev_pg_stmts)
435
+ return 0 if cid < 1 || cid > stmts.length
436
+ entry = stmts[cid - 1]
437
+ return 0 if entry.nil? || entry[:result].nil?
438
+ entry[:result].nfields
439
+ end
440
+ Pg.define_singleton_method(:fresco_pg_finalize) do |cid|
441
+ stmts = Sock.instance_variable_get(:@dev_pg_stmts)
442
+ return 0 if cid < 1 || cid > stmts.length
443
+ stmts[cid - 1] = nil
444
+ 0
445
+ end
446
+ Pg.define_singleton_method(:fresco_pg_reset) do |cid|
447
+ stmts = Sock.instance_variable_get(:@dev_pg_stmts)
448
+ return -1 if cid < 1 || cid > stmts.length
449
+ entry = stmts[cid - 1]
450
+ return -1 if entry.nil?
451
+ entry[:result] = nil
452
+ entry[:row] = -1
453
+ entry[:ntuples] = 0
454
+ 0
455
+ end
456
+ Pg.define_singleton_method(:fresco_pg_last_insert_rowid) do |h|
457
+ handles = Sock.instance_variable_get(:@dev_pg_handles)
458
+ return -1 if h < 1 || h > handles.length
459
+ conn = handles[h - 1]
460
+ return -1 if conn.nil?
461
+ begin
462
+ res = conn.exec("SELECT lastval()")
463
+ res.ntuples > 0 ? res.getvalue(0, 0).to_i : -1
464
+ rescue ::PG::Error
465
+ -1
466
+ end
467
+ end
468
+ end
469
+ end
470
+ end
471
+
472
+ def snapshot_mtimes
473
+ files = WATCH_GLOBS.flat_map { |g| Dir.glob(g) }.sort
474
+ files.each_with_object({}) { |f, h| h[f] = File.mtime(f).to_f }
475
+ end
476
+
477
+ def start_watcher
478
+ last = snapshot_mtimes
479
+ Thread.new do
480
+ Thread.current.abort_on_exception = false
481
+ loop do
482
+ sleep 0.2
483
+ cur = snapshot_mtimes
484
+ next if cur == last
485
+ changed = (cur.keys | last.keys).select { |k| cur[k] != last[k] }
486
+ last = cur
487
+ puts "#{Color.yellow('[reload]')} #{changed.join(', ')}"
488
+ reload! if build!
489
+ end
490
+ end
491
+ end
492
+
493
+ # --- Dev HTTP listener ------------------------------------------
494
+ #
495
+ # Single-threaded TCPServer accept loop. Each request: parse the
496
+ # request line + headers, read a Content-Length-bounded body, build a
497
+ # Request, dispatch under RELOAD_LOCK (so file-watcher reloads don't
498
+ # race with in-flight requests), write the response. No keep-alive —
499
+ # every dev response carries `Connection: close`. Keep-alive lives in
500
+ # the prefork worker on the prod path.
501
+
502
+ def parse_dev_request(sock)
503
+ request_line = sock.gets("\r\n")
504
+ return nil if request_line.nil?
505
+ method, raw_path, _version = request_line.strip.split(" ", 3)
506
+ return nil if method.nil? || raw_path.nil?
507
+
508
+ qmark = raw_path.index("?")
509
+ if qmark
510
+ path = raw_path[0...qmark]
511
+ qstr = raw_path[(qmark + 1)..]
512
+ else
513
+ path = raw_path
514
+ qstr = ""
515
+ end
516
+
517
+ headers = {}
518
+ while (line = sock.gets("\r\n"))
519
+ line = line.chomp
520
+ break if line.empty?
521
+ colon = line.index(":")
522
+ next unless colon
523
+ name = line[0...colon].downcase
524
+ value = line[(colon + 1)..].lstrip
525
+ headers[name] = value
526
+ end
527
+
528
+ body = ""
529
+ cl = headers["content-length"]
530
+ if cl && cl.to_i > 0
531
+ body = sock.read(cl.to_i) || ""
532
+ end
533
+
534
+ req = Request.new(method, path, body)
535
+ parse_query_string!(qstr, req.params) if qstr.length > 0
536
+ headers.each { |k, v| req.headers[k] = v }
537
+ ctype = headers["content-type"]
538
+ if ctype && ctype == "application/x-www-form-urlencoded"
539
+ parse_form_body!(body, req.params)
540
+ end
541
+
542
+ # Mirror runtime's `_method` override (see runtime/runtime.rb).
543
+ if req.verb == "POST"
544
+ override = req.params.str(:_method)
545
+ req.verb = override.upcase if override.length > 0
546
+ end
547
+
548
+ # Parse Cookie header into req.cookies. We can't reuse runtime's
549
+ # `parse_cookie_header!` because it relies on Spinel's
550
+ # `String#index`-returns-`-1` semantics (CRuby returns nil). The
551
+ # idiomatic split-and-loop below is equivalent.
552
+ cookie_hdr = headers["cookie"]
553
+ if cookie_hdr && !cookie_hdr.empty?
554
+ cookie_hdr.split(";").each do |pair|
555
+ pair = pair.strip
556
+ next if pair.empty?
557
+ eq = pair.index("=")
558
+ if eq.nil?
559
+ req.cookies[pair] = ""
560
+ else
561
+ req.cookies[pair[0...eq]] = url_decode(pair[(eq + 1)..])
562
+ end
563
+ end
564
+ end
565
+ maybe_load_session!(req)
566
+
567
+ req
568
+ end
569
+
570
+ # Write an HTTP/1.1 response. Body responses go out as one write;
571
+ # file responses (file_path set on the Response) are loaded from
572
+ # disk and written inline — production uses sendfile(2), but dev
573
+ # is correctness-over-throughput.
574
+ def write_dev_response(sock, res)
575
+ if res.file_path.length > 0
576
+ if File.exist?(res.file_path)
577
+ status = res.status
578
+ ctype = res.content_type
579
+ body = File.binread(res.file_path)
580
+ else
581
+ status = 404
582
+ ctype = "text/plain; charset=utf-8"
583
+ body = "Not Found"
584
+ end
585
+ else
586
+ status = res.status
587
+ ctype = res.content_type
588
+ body = res.body
589
+ end
590
+
591
+ reason = reason_for(status)
592
+ sock.write("HTTP/1.1 #{status} #{reason}\r\n")
593
+ sock.write("Content-Type: #{ctype}\r\n")
594
+ sock.write("Content-Length: #{body.bytesize}\r\n")
595
+ sock.write("Location: #{res.location}\r\n") if res.location.length > 0
596
+ res.set_cookies.each { |line| sock.write("Set-Cookie: #{line}\r\n") }
597
+ sock.write("Connection: close\r\n\r\n")
598
+ sock.write(body)
599
+ end
600
+
601
+ def serve_dev(port)
602
+ server = TCPServer.new("0.0.0.0", port)
603
+ puts "#{Color.cyan('[dev]')} listening on http://0.0.0.0:#{port}"
604
+ loop do
605
+ client = server.accept
606
+ handle_dev_connection(client)
607
+ end
608
+ rescue Interrupt
609
+ puts "\n#{Color.cyan('[dev]')} stopping"
610
+ end
611
+
612
+ # Dev-only crash page. Renders the exception class, message, and
613
+ # backtrace inline so /crash (and any other action raise) shows up
614
+ # in the browser instead of being a static "Internal Server Error"
615
+ # page. NEVER call this in production — leaks paths, gem versions,
616
+ # and whatever string the exception happened to carry.
617
+ def render_dev_error(err, req)
618
+ esc = ->(s) { Spinel::View.h(s.to_s) }
619
+ bt = (err.backtrace || []).map { |line| "<li>#{esc.call(line)}</li>" }.join
620
+
621
+ method = req ? req.verb : "?"
622
+ path = req ? req.path : "?"
623
+ params = req ? format_hash(req.params.raw) : "(unavailable)"
624
+ query = req ? format_hash(req.query) : "(unavailable)"
625
+ headers = req ? format_hash(req.headers) : "(unavailable)"
626
+
627
+ body = <<~HTML
628
+ <!doctype html>
629
+ <html>
630
+ <head>
631
+ <meta charset="utf-8">
632
+ <title>500 — #{esc.call(err.class.name)}</title>
633
+ <link rel="preconnect" href="https://fonts.googleapis.com">
634
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
635
+ <link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500;700&display=swap" rel="stylesheet">
636
+ <script>
637
+ (function () {
638
+ var t = localStorage.getItem("theme");
639
+ if (!t) {
640
+ t = (window.matchMedia && window.matchMedia("(prefers-color-scheme: dark)").matches) ? "dark" : "light";
641
+ localStorage.setItem("theme", t);
642
+ }
643
+ document.documentElement.setAttribute("data-theme", t);
644
+ })();
645
+ </script>
646
+ <style>
647
+ :root {
648
+ --bg: #fff; --fg: #222; --muted: #888;
649
+ --border: #eee; --code-bg: #f7f7f7;
650
+ --accent: #0066cc; --status: #b00020;
651
+ }
652
+ :root[data-theme="dark"] {
653
+ --bg: #141414; --fg: #e8e8e8; --muted: #9a9a9a;
654
+ --border: #2a2a2a; --code-bg: #1f1f1f;
655
+ --accent: #6ab0ff; --status: #ff6680;
656
+ }
657
+ body {
658
+ font-family: 'IBM Plex Mono', ui-monospace, monospace;
659
+ max-width: 60rem; margin: 2rem auto; padding: 0 1.5rem;
660
+ background: var(--bg); color: var(--fg);
661
+ transition: background 0.15s, color 0.15s;
662
+ }
663
+ .status { color: var(--status); font-size: 0.8rem; letter-spacing: 0.05em; text-transform: uppercase; }
664
+ h1 { font-size: 1.25rem; font-weight: 700; margin: 0.25rem 0 0; word-break: break-word; }
665
+ .msg { color: var(--muted); margin: 0.5rem 0 1.5rem; white-space: pre-wrap; }
666
+ h2 { font-size: 0.85rem; text-transform: uppercase; letter-spacing: 0.05em; color: var(--muted); margin-top: 2rem; }
667
+ ol.bt { background: var(--code-bg); border: 1px solid var(--border); border-radius: 4px; padding: 1rem 1rem 1rem 3rem; font-size: 0.85rem; line-height: 1.5; color: var(--fg); }
668
+ ol.bt li { word-break: break-all; }
669
+ dl { display: grid; grid-template-columns: 6rem 1fr; gap: 0.4rem 1rem; font-size: 0.85rem; }
670
+ dt { color: var(--muted); }
671
+ dd { margin: 0; word-break: break-word; }
672
+ footer { color: var(--muted); font-size: 0.8rem; margin-top: 2rem; border-top: 1px solid var(--border); padding-top: 1rem; }
673
+ #theme-toggle {
674
+ position: fixed; top: 1rem; right: 1rem;
675
+ background: transparent; border: 1px solid var(--border);
676
+ color: var(--fg); border-radius: 6px;
677
+ padding: 0.4rem; cursor: pointer;
678
+ display: inline-flex; align-items: center; justify-content: center;
679
+ }
680
+ #theme-toggle:hover { background: var(--code-bg); }
681
+ #theme-toggle svg { width: 18px; height: 18px; display: none; }
682
+ :root[data-theme="light"] #theme-toggle .icon-moon { display: inline-block; }
683
+ :root[data-theme="dark"] #theme-toggle .icon-sun { display: inline-block; }
684
+ </style>
685
+ </head>
686
+ <body>
687
+ <button id="theme-toggle" aria-label="Toggle color theme" title="Toggle color theme">
688
+ <svg class="icon-sun" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="4"/><path d="M12 2v2"/><path d="M12 20v2"/><path d="m4.93 4.93 1.41 1.41"/><path d="m17.66 17.66 1.41 1.41"/><path d="M2 12h2"/><path d="M20 12h2"/><path d="m6.34 17.66-1.41 1.41"/><path d="m19.07 4.93-1.41 1.41"/></svg>
689
+ <svg class="icon-moon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/></svg>
690
+ </button>
691
+
692
+ <div class="status">500 &middot; fresco dev exception</div>
693
+ <h1>#{esc.call(err.class.name)}</h1>
694
+ <p class="msg">#{esc.call(err.message)}</p>
695
+
696
+ <h2>Backtrace</h2>
697
+ <ol class="bt">#{bt}</ol>
698
+
699
+ <h2>Request</h2>
700
+ <dl>
701
+ <dt>Method</dt><dd>#{esc.call(method)}</dd>
702
+ <dt>Path</dt><dd>#{esc.call(path)}</dd>
703
+ <dt>Params</dt><dd>#{esc.call(params)}</dd>
704
+ <dt>Query</dt><dd>#{esc.call(query)}</dd>
705
+ <dt>Headers</dt><dd>#{esc.call(headers)}</dd>
706
+ </dl>
707
+
708
+ <footer>Rendered by `fresco dev`. Production serves public/500.html with no error details.</footer>
709
+
710
+ <script>
711
+ (function () {
712
+ document.getElementById("theme-toggle").addEventListener("click", function () {
713
+ var cur = document.documentElement.getAttribute("data-theme");
714
+ var nxt = cur === "dark" ? "light" : "dark";
715
+ document.documentElement.setAttribute("data-theme", nxt);
716
+ localStorage.setItem("theme", nxt);
717
+ });
718
+ })();
719
+ </script>
720
+ </body>
721
+ </html>
722
+ HTML
723
+
724
+ Response.html(500, body)
725
+ end
726
+
727
+ def handle_dev_connection(client)
728
+ req = parse_dev_request(client)
729
+ if req.nil?
730
+ write_dev_response(client, error_response(400, "Bad Request\n"))
731
+ puts "#{Color.dim('[request]')} ? ? #{color_status(400)}"
732
+ return
733
+ end
734
+
735
+ # Mirror runtime.rb's request bracket so the SQL-log buffer flushes
736
+ # under the [request] line. The Fresco::Db no-op stub is loaded even
737
+ # when no adapter is configured, so this call is safe regardless.
738
+ Fresco::Db.begin_request!
739
+ t0 = Process.clock_gettime(Process::CLOCK_MONOTONIC)
740
+ res = RELOAD_LOCK.synchronize { dispatch_request(req.verb, req.path, req) }
741
+ attach_session_cookie!(req, res)
742
+ micros = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - t0) * 1_000_000).to_i
743
+ write_dev_response(client, res)
744
+ log_request_full(req, res.status, micros)
745
+ Fresco::Db.flush_log!
746
+ rescue => e
747
+ puts "#{Color.red('[handler]')} #{e.class}: #{e.message}"
748
+ (e.backtrace || []).first(5).each { |b| puts Color.dim(" #{b}") }
749
+ Fresco::Db.flush_log!
750
+ begin
751
+ write_dev_response(client, render_dev_error(e, req))
752
+ rescue StandardError
753
+ # client already gone — nothing to do
754
+ end
755
+ ensure
756
+ client.close rescue nil
757
+ end
758
+
759
+ # --- main --------------------------------------------------------
760
+
761
+ # `fresco dev db:*` short-circuits the watcher+server and just runs
762
+ # the requested subcommand against the dev-mode CRuby load. Useful for
763
+ # applying migrations from your editor without a release build, and
764
+ # for `db:console` — drops into IRB with every generated model
765
+ # loaded and the FFI stand-ins wired so model methods actually hit
766
+ # the DB.
767
+ if ARGV[0] && ARGV[0].start_with?("db:")
768
+ build! || abort("[dev] build failed; fix and retry")
769
+ reload!
770
+ cmd = ARGV[0]
771
+ case cmd
772
+ when "db:migrate"
773
+ Fresco::DbMigrations.migrate!
774
+ when "db:rollback"
775
+ Fresco::DbMigrations.rollback!
776
+ when "db:reset"
777
+ # Roll back to a clean slate, then re-apply. Loops because
778
+ # rollback! peels one migration per call. For a small migration
779
+ # set this is fine; M6+ could add a single-call "rollback all".
780
+ loop_count = 0
781
+ while loop_count < Fresco.migrations.length + 1
782
+ Fresco::DbMigrations.rollback!
783
+ loop_count += 1
784
+ end
785
+ Fresco::DbMigrations.migrate!
786
+ when "db:console"
787
+ require "irb"
788
+ puts "[dev] Fresco IRB console — models and Fresco::Db.* are loaded."
789
+ puts "[dev] adapter=#{Fresco.database_adapter.inspect} dsn=#{Fresco.database_url.empty? ? Fresco.database_path : Fresco.database_url}"
790
+ # IRB.start scans ARGV for a script path; left as-is it tries to
791
+ # open `db:console` as a file. Clear ARGV so IRB drops into a
792
+ # bare interactive session.
793
+ ARGV.clear
794
+ IRB.start
795
+ else
796
+ abort "[dev] unknown subcommand: #{cmd} (try db:migrate, db:rollback, db:reset, db:console)"
797
+ end
798
+ exit 0
799
+ end
800
+
801
+ port = 3030
802
+ i = 0
803
+ while i < ARGV.length
804
+ if ARGV[i] == "-p" && ARGV[i + 1]
805
+ port = ARGV[i + 1].to_i
806
+ i += 2
807
+ else
808
+ i += 1
809
+ end
810
+ end
811
+
812
+ build! || abort("[dev] initial build failed; fix and retry")
813
+ reload!
814
+ start_watcher
815
+ serve_dev(port)