jimmu 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (94) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE +21 -0
  3. data/README.md +279 -0
  4. data/examples/bbs/app.rb +21 -0
  5. data/examples/bbs/public/css/style.css +15 -0
  6. data/examples/bbs/views/404.erb +1 -0
  7. data/examples/bbs/views/500.erb +1 -0
  8. data/examples/bbs/views/index.erb +29 -0
  9. data/examples/bbs/views/layout.erb +15 -0
  10. data/examples/blog/app.rb +28 -0
  11. data/examples/blog/public/css/style.css +18 -0
  12. data/examples/blog/views/404.erb +1 -0
  13. data/examples/blog/views/500.erb +1 -0
  14. data/examples/blog/views/index.erb +9 -0
  15. data/examples/blog/views/layout.erb +15 -0
  16. data/examples/blog/views/new.erb +18 -0
  17. data/examples/blog/views/post/[id].erb +17 -0
  18. data/examples/chatroom/app.rb +44 -0
  19. data/examples/chatroom/public/css/style.css +15 -0
  20. data/examples/chatroom/views/404.erb +1 -0
  21. data/examples/chatroom/views/500.erb +1 -0
  22. data/examples/chatroom/views/index.erb +18 -0
  23. data/examples/chatroom/views/layout.erb +12 -0
  24. data/examples/chatroom/views/room/[name].erb +47 -0
  25. data/examples/counter/app.rb +15 -0
  26. data/examples/counter/public/css/style.css +8 -0
  27. data/examples/counter/public/js/app.js +11 -0
  28. data/examples/counter/views/404.erb +1 -0
  29. data/examples/counter/views/500.erb +1 -0
  30. data/examples/counter/views/api/increment.erb +5 -0
  31. data/examples/counter/views/api/reset.erb +4 -0
  32. data/examples/counter/views/index.erb +8 -0
  33. data/examples/counter/views/layout.erb +13 -0
  34. data/examples/hello-world/app.rb +6 -0
  35. data/examples/hello-world/public/css/style.css +3 -0
  36. data/examples/hello-world/views/404.erb +2 -0
  37. data/examples/hello-world/views/500.erb +1 -0
  38. data/examples/hello-world/views/greet/[name].erb +2 -0
  39. data/examples/hello-world/views/index.erb +3 -0
  40. data/examples/hello-world/views/layout.erb +12 -0
  41. data/examples/image-upload/app.rb +22 -0
  42. data/examples/image-upload/public/css/style.css +11 -0
  43. data/examples/image-upload/views/404.erb +1 -0
  44. data/examples/image-upload/views/500.erb +1 -0
  45. data/examples/image-upload/views/index.erb +31 -0
  46. data/examples/image-upload/views/layout.erb +15 -0
  47. data/examples/json-api/app.rb +27 -0
  48. data/examples/json-api/views/404.erb +2 -0
  49. data/examples/json-api/views/500.erb +2 -0
  50. data/examples/json-api/views/api/notes/[id].erb +33 -0
  51. data/examples/json-api/views/api/notes.erb +20 -0
  52. data/examples/json-api/views/index.erb +13 -0
  53. data/examples/login-demo/app.rb +31 -0
  54. data/examples/login-demo/public/css/style.css +12 -0
  55. data/examples/login-demo/views/404.erb +1 -0
  56. data/examples/login-demo/views/500.erb +1 -0
  57. data/examples/login-demo/views/index.erb +6 -0
  58. data/examples/login-demo/views/layout.erb +24 -0
  59. data/examples/login-demo/views/login.erb +22 -0
  60. data/examples/login-demo/views/logout.erb +4 -0
  61. data/examples/login-demo/views/mypage/index.erb +3 -0
  62. data/examples/login-demo/views/signup.erb +24 -0
  63. data/examples/poll/app.rb +55 -0
  64. data/examples/poll/public/css/style.css +10 -0
  65. data/examples/poll/public/js/app.js +24 -0
  66. data/examples/poll/views/404.erb +1 -0
  67. data/examples/poll/views/500.erb +1 -0
  68. data/examples/poll/views/index.erb +6 -0
  69. data/examples/poll/views/layout.erb +13 -0
  70. data/examples/todo/app.rb +21 -0
  71. data/examples/todo/public/css/style.css +17 -0
  72. data/examples/todo/views/404.erb +1 -0
  73. data/examples/todo/views/500.erb +1 -0
  74. data/examples/todo/views/delete/[id].erb +4 -0
  75. data/examples/todo/views/index.erb +27 -0
  76. data/examples/todo/views/layout.erb +15 -0
  77. data/examples/todo/views/toggle/[id].erb +5 -0
  78. data/examples/url-shortener/app.rb +39 -0
  79. data/examples/url-shortener/public/css/style.css +13 -0
  80. data/examples/url-shortener/views/404.erb +1 -0
  81. data/examples/url-shortener/views/500.erb +1 -0
  82. data/examples/url-shortener/views/[code].erb +10 -0
  83. data/examples/url-shortener/views/api/shorten.erb +13 -0
  84. data/examples/url-shortener/views/index.erb +35 -0
  85. data/examples/url-shortener/views/layout.erb +15 -0
  86. data/examples/visitor-counter/app.rb +22 -0
  87. data/examples/visitor-counter/public/css/style.css +4 -0
  88. data/examples/visitor-counter/views/404.erb +1 -0
  89. data/examples/visitor-counter/views/500.erb +1 -0
  90. data/examples/visitor-counter/views/index.erb +11 -0
  91. data/examples/visitor-counter/views/layout.erb +12 -0
  92. data/exe/jimmu +5 -0
  93. data/lib/jimmu.rb +1916 -0
  94. metadata +143 -0
data/lib/jimmu.rb ADDED
@@ -0,0 +1,1916 @@
1
+ # frozen_string_literal: false
2
+ #
3
+ # Jimmu (神武) - A lightweight, single-file Ruby web framework.
4
+ #
5
+ # "Full-featured but simple."
6
+ # "Build small web apps quickly, from a single file."
7
+ #
8
+ # Jimmu bundles ERB templating, file-based routing, SQLite (via a
9
+ # dependency-free Fiddle binding to the system libsqlite3), WebSocket
10
+ # support, sessions, cookies, and a small JSON/HTML/text response API
11
+ # into one file with no gem dependencies beyond the Ruby standard
12
+ # library.
13
+ #
14
+ # https://github.com/ (project home - edit me)
15
+ #
16
+ # Copyright and license: see LICENSE.
17
+
18
+ require 'erb'
19
+ require 'socket'
20
+ require 'digest/sha1'
21
+ require 'base64'
22
+ require 'securerandom'
23
+ require 'time'
24
+ require 'json'
25
+ require 'uri'
26
+ require 'cgi'
27
+ require 'fileutils'
28
+ require 'optparse'
29
+ require 'monitor'
30
+ require 'fiddle'
31
+ require 'fiddle/import'
32
+
33
+ module Jimmu
34
+ VERSION = '1.0.0'
35
+
36
+ class << self
37
+ # The single Application instance that app.rb's top-level DSL
38
+ # calls (port, before, websocket, run, ...) delegate to. Set by
39
+ # Application#load_app_file just before `load`ing app.rb.
40
+ attr_accessor :application
41
+ end
42
+
43
+ # Base class for all Jimmu-specific errors.
44
+ class Error < StandardError; end
45
+
46
+ # Raised when the SQLite shared library cannot be located, or when a
47
+ # query fails.
48
+ class DatabaseError < Error; end
49
+
50
+ # Raised when app.rb cannot be found or fails to load.
51
+ class ConfigError < Error; end
52
+
53
+ # Internal symbol used with `throw`/`catch` to unwind out of ERB
54
+ # evaluation as soon as a response-terminating DSL method
55
+ # (redirect / json / text / html / render) is called. This is a
56
+ # lightweight, non-exception based control-flow jump: the methods
57
+ # that call it have already recorded the final status/headers/body
58
+ # on the Context object before throwing, so the catch site simply
59
+ # reads those back off the Context.
60
+ HALT = :jimmu_halt
61
+
62
+ # Maximum accepted request body size (form posts / uploads), in bytes.
63
+ # This exists purely as a safety net against a client claiming an
64
+ # enormous Content-Length; it is not a tuning knob most apps need to
65
+ # touch. 50 MB is generous for a "small-to-medium" app.
66
+ MAX_BODY_SIZE = 50 * 1024 * 1024
67
+
68
+ # File extension -> MIME type, used for static file serving.
69
+ MIME_TYPES = {
70
+ '.html' => 'text/html; charset=utf-8',
71
+ '.htm' => 'text/html; charset=utf-8',
72
+ '.css' => 'text/css; charset=utf-8',
73
+ '.js' => 'text/javascript; charset=utf-8',
74
+ '.mjs' => 'text/javascript; charset=utf-8',
75
+ '.json' => 'application/json; charset=utf-8',
76
+ '.txt' => 'text/plain; charset=utf-8',
77
+ '.xml' => 'application/xml; charset=utf-8',
78
+ '.csv' => 'text/csv; charset=utf-8',
79
+ '.md' => 'text/markdown; charset=utf-8',
80
+ '.png' => 'image/png',
81
+ '.jpg' => 'image/jpeg',
82
+ '.jpeg' => 'image/jpeg',
83
+ '.gif' => 'image/gif',
84
+ '.svg' => 'image/svg+xml',
85
+ '.webp' => 'image/webp',
86
+ '.ico' => 'image/x-icon',
87
+ '.bmp' => 'image/bmp',
88
+ '.woff' => 'font/woff',
89
+ '.woff2' => 'font/woff2',
90
+ '.ttf' => 'font/ttf',
91
+ '.otf' => 'font/otf',
92
+ '.eot' => 'application/vnd.ms-fontobject',
93
+ '.mp3' => 'audio/mpeg',
94
+ '.wav' => 'audio/wav',
95
+ '.ogg' => 'audio/ogg',
96
+ '.mp4' => 'video/mp4',
97
+ '.webm' => 'video/webm',
98
+ '.pdf' => 'application/pdf',
99
+ '.zip' => 'application/zip',
100
+ '.wasm' => 'application/wasm',
101
+ '.map' => 'application/json; charset=utf-8'
102
+ }
103
+ MIME_TYPES.default = 'application/octet-stream'
104
+ MIME_TYPES.freeze
105
+
106
+ # HTTP status code -> reason phrase.
107
+ STATUS_PHRASES = {
108
+ 100 => 'Continue', 101 => 'Switching Protocols',
109
+ 200 => 'OK', 201 => 'Created', 202 => 'Accepted', 204 => 'No Content', 206 => 'Partial Content',
110
+ 301 => 'Moved Permanently', 302 => 'Found', 303 => 'See Other', 304 => 'Not Modified',
111
+ 307 => 'Temporary Redirect', 308 => 'Permanent Redirect',
112
+ 400 => 'Bad Request', 401 => 'Unauthorized', 403 => 'Forbidden', 404 => 'Not Found',
113
+ 405 => 'Method Not Allowed', 406 => 'Not Acceptable', 408 => 'Request Timeout',
114
+ 409 => 'Conflict', 410 => 'Gone', 411 => 'Length Required', 413 => 'Payload Too Large',
115
+ 414 => 'URI Too Long', 415 => 'Unsupported Media Type', 422 => 'Unprocessable Entity',
116
+ 426 => 'Upgrade Required', 429 => 'Too Many Requests',
117
+ 500 => 'Internal Server Error', 501 => 'Not Implemented', 502 => 'Bad Gateway',
118
+ 503 => 'Service Unavailable', 504 => 'Gateway Timeout'
119
+ }.freeze
120
+
121
+ def self.status_phrase(code)
122
+ STATUS_PHRASES[code.to_i] || 'Unknown Status'
123
+ end
124
+
125
+ # Percent-decode a URL path segment (`%XX` sequences only; unlike
126
+ # form/query decoding, `+` is left as a literal plus in paths).
127
+ def self.percent_decode(str)
128
+ str.gsub(/%([0-9A-Fa-f]{2})/) { [Regexp.last_match(1)].pack('H2') }
129
+ end
130
+ end
131
+ module Jimmu
132
+ # Minimal Fiddle-based binding to the system's libsqlite3. This
133
+ # avoids requiring the `sqlite3` gem (and therefore avoids a native
134
+ # extension compile step) by calling directly into the SQLite C API
135
+ # that ships with the OS (Linux/macOS) or can be dropped in as a DLL
136
+ # (Windows).
137
+ module SQLite3FFI
138
+ extend Fiddle::Importer
139
+
140
+ LIB_CANDIDATES =
141
+ if Fiddle::WINDOWS
142
+ %w[sqlite3.dll]
143
+ elsif RbConfig::CONFIG['host_os'] =~ /darwin/
144
+ %w[libsqlite3.dylib /usr/lib/libsqlite3.dylib /opt/homebrew/lib/libsqlite3.dylib /usr/local/lib/libsqlite3.dylib]
145
+ else
146
+ %w[libsqlite3.so.0 libsqlite3.so /usr/lib/x86_64-linux-gnu/libsqlite3.so.0 /usr/lib/libsqlite3.so.0]
147
+ end.freeze
148
+
149
+ loaded_lib = LIB_CANDIDATES.find do |lib|
150
+ begin
151
+ dlload lib
152
+ true
153
+ rescue Fiddle::DLError
154
+ false
155
+ end
156
+ end
157
+
158
+ LOADED = !loaded_lib.nil?
159
+
160
+ if LOADED
161
+ extern 'int sqlite3_open(const char*, void**)'
162
+ extern 'int sqlite3_close(void*)'
163
+ extern 'int sqlite3_prepare_v2(void*, const char*, int, void**, void**)'
164
+ extern 'int sqlite3_step(void*)'
165
+ extern 'int sqlite3_finalize(void*)'
166
+ extern 'int sqlite3_reset(void*)'
167
+ extern 'int sqlite3_column_count(void*)'
168
+ extern 'const char* sqlite3_column_name(void*, int)'
169
+ extern 'int sqlite3_column_type(void*, int)'
170
+ extern 'const char* sqlite3_column_text(void*, int)'
171
+ extern 'double sqlite3_column_double(void*, int)'
172
+ extern 'long long sqlite3_column_int64(void*, int)'
173
+ extern 'int sqlite3_column_bytes(void*, int)'
174
+ extern 'const void* sqlite3_column_blob(void*, int)'
175
+ extern 'int sqlite3_bind_text(void*, int, const char*, int, void*)'
176
+ extern 'int sqlite3_bind_int64(void*, int, long long)'
177
+ extern 'int sqlite3_bind_double(void*, int, double)'
178
+ extern 'int sqlite3_bind_null(void*, int)'
179
+ extern 'int sqlite3_bind_blob(void*, int, const void*, int, void*)'
180
+ extern 'const char* sqlite3_errmsg(void*)'
181
+ extern 'int sqlite3_changes(void*)'
182
+ extern 'long long sqlite3_last_insert_rowid(void*)'
183
+ extern 'int sqlite3_busy_timeout(void*, int)'
184
+ end
185
+ end
186
+
187
+ # A small SQLite wrapper exposing the query / exec / transaction API
188
+ # described in the Jimmu spec. A single Database instance may be
189
+ # shared across the many request-handling threads the server spawns;
190
+ # all access to the underlying sqlite3 connection handle is
191
+ # serialized through a re-entrant Monitor so that (a) concurrent
192
+ # requests never touch the C-level handle at the same time and
193
+ # (b) a block passed to #transaction can itself call #query / #exec
194
+ # on the same thread without deadlocking.
195
+ class Database
196
+ SQLITE_OK = 0
197
+ SQLITE_ROW = 100
198
+ SQLITE_DONE = 101
199
+ SQLITE_INTEGER = 1
200
+ SQLITE_FLOAT = 2
201
+ SQLITE_TEXT = 3
202
+ SQLITE_BLOB = 4
203
+ SQLITE_NULL = 5
204
+ SQLITE_TRANSIENT = Fiddle::Pointer.new(-1)
205
+
206
+ attr_reader :path
207
+
208
+ def initialize(path)
209
+ unless SQLite3FFI::LOADED
210
+ raise DatabaseError,
211
+ "Could not locate the SQLite3 shared library on this system " \
212
+ "(looked for: #{SQLite3FFI::LIB_CANDIDATES.join(', ')}). " \
213
+ "Install SQLite3 (e.g. `apt install libsqlite3-0`, `brew install sqlite3`); " \
214
+ "it ships with macOS by default, and Windows needs sqlite3.dll on the PATH."
215
+ end
216
+
217
+ @path = path
218
+ dir = File.dirname(path)
219
+ FileUtils.mkdir_p(dir) unless dir == '.' || Dir.exist?(dir)
220
+
221
+ @lock = Monitor.new
222
+ dbptr = Fiddle::Pointer.malloc(Fiddle::SIZEOF_VOIDP)
223
+ rc = SQLite3FFI.sqlite3_open(path.to_s, dbptr)
224
+ @db = dbptr.ptr
225
+ if rc != SQLITE_OK
226
+ raise DatabaseError, "Could not open database at #{path.inspect} (sqlite3_open returned #{rc})"
227
+ end
228
+ SQLite3FFI.sqlite3_busy_timeout(@db, 5000)
229
+ end
230
+
231
+ # Run a SELECT (or any statement that returns rows) and return an
232
+ # Array of Hashes, keyed by column name (as Symbols).
233
+ #
234
+ # db.query("SELECT * FROM users")
235
+ # db.query("SELECT * FROM users WHERE id = ?", id)
236
+ def query(sql, *binds)
237
+ @lock.synchronize { run_query(sql, binds) }
238
+ end
239
+
240
+ # Run an INSERT / UPDATE / DELETE / DDL statement. Returns the
241
+ # number of rows changed by the statement (via sqlite3_changes).
242
+ #
243
+ # db.exec("INSERT INTO users(name) VALUES (?)", "Yamato")
244
+ def exec(sql, *binds)
245
+ @lock.synchronize { run_exec(sql, binds) }
246
+ end
247
+
248
+ # Wrap a block in BEGIN / COMMIT. If the block raises, the
249
+ # transaction is rolled back and the error re-raised.
250
+ #
251
+ # db.transaction do
252
+ # db.exec(...)
253
+ # db.exec(...)
254
+ # end
255
+ def transaction
256
+ @lock.synchronize do
257
+ run_exec('BEGIN', [])
258
+ begin
259
+ result = yield
260
+ run_exec('COMMIT', [])
261
+ result
262
+ rescue Exception => e
263
+ begin
264
+ run_exec('ROLLBACK', [])
265
+ rescue Exception
266
+ nil # the original error is more useful than a rollback failure
267
+ end
268
+ raise e
269
+ end
270
+ end
271
+ end
272
+
273
+ # The rowid of the most recent successful INSERT on this
274
+ # connection.
275
+ def last_insert_rowid
276
+ @lock.synchronize { SQLite3FFI.sqlite3_last_insert_rowid(@db) }
277
+ end
278
+
279
+ def close
280
+ @lock.synchronize do
281
+ next if @closed
282
+ SQLite3FFI.sqlite3_close(@db)
283
+ @closed = true
284
+ end
285
+ end
286
+
287
+ private
288
+
289
+ def prepare(sql)
290
+ stmtptr = Fiddle::Pointer.malloc(Fiddle::SIZEOF_VOIDP)
291
+ tailptr = Fiddle::Pointer.malloc(Fiddle::SIZEOF_VOIDP)
292
+ sql_bytes = sql.to_s.dup.force_encoding(Encoding::UTF_8)
293
+ rc = SQLite3FFI.sqlite3_prepare_v2(@db, sql_bytes, sql_bytes.bytesize, stmtptr, tailptr)
294
+ unless rc == SQLITE_OK
295
+ raise DatabaseError, "SQL error: #{SQLite3FFI.sqlite3_errmsg(@db)} (in query: #{sql})"
296
+ end
297
+ stmtptr.ptr
298
+ end
299
+
300
+ def bind_all(stmt, binds)
301
+ binds.each_with_index do |val, i|
302
+ idx = i + 1
303
+ case val
304
+ when nil
305
+ SQLite3FFI.sqlite3_bind_null(stmt, idx)
306
+ when Integer
307
+ SQLite3FFI.sqlite3_bind_int64(stmt, idx, val)
308
+ when Float
309
+ SQLite3FFI.sqlite3_bind_double(stmt, idx, val)
310
+ when true
311
+ SQLite3FFI.sqlite3_bind_int64(stmt, idx, 1)
312
+ when false
313
+ SQLite3FFI.sqlite3_bind_int64(stmt, idx, 0)
314
+ else
315
+ str = val.to_s.dup.force_encoding(Encoding::UTF_8)
316
+ SQLite3FFI.sqlite3_bind_text(stmt, idx, str, str.bytesize, SQLITE_TRANSIENT)
317
+ end
318
+ end
319
+ end
320
+
321
+ def run_query(sql, binds)
322
+ stmt = prepare(sql)
323
+ bind_all(stmt, binds)
324
+ results = []
325
+ loop do
326
+ rc = SQLite3FFI.sqlite3_step(stmt)
327
+ case rc
328
+ when SQLITE_ROW
329
+ results << extract_row(stmt)
330
+ when SQLITE_DONE
331
+ break
332
+ else
333
+ SQLite3FFI.sqlite3_finalize(stmt)
334
+ raise DatabaseError, "SQL error: #{SQLite3FFI.sqlite3_errmsg(@db)} (in query: #{sql})"
335
+ end
336
+ end
337
+ SQLite3FFI.sqlite3_finalize(stmt)
338
+ results
339
+ end
340
+
341
+ def run_exec(sql, binds)
342
+ stmt = prepare(sql)
343
+ bind_all(stmt, binds)
344
+ rc = SQLite3FFI.sqlite3_step(stmt)
345
+ unless rc == SQLITE_DONE || rc == SQLITE_ROW
346
+ SQLite3FFI.sqlite3_finalize(stmt)
347
+ raise DatabaseError, "SQL error: #{SQLite3FFI.sqlite3_errmsg(@db)} (in query: #{sql})"
348
+ end
349
+ SQLite3FFI.sqlite3_finalize(stmt)
350
+ SQLite3FFI.sqlite3_changes(@db)
351
+ end
352
+
353
+ def extract_row(stmt)
354
+ count = SQLite3FFI.sqlite3_column_count(stmt)
355
+ row = {}
356
+ count.times do |i|
357
+ name = SQLite3FFI.sqlite3_column_name(stmt, i).to_s.to_sym
358
+ row[name] = extract_value(stmt, i)
359
+ end
360
+ row
361
+ end
362
+
363
+ def extract_value(stmt, i)
364
+ type = SQLite3FFI.sqlite3_column_type(stmt, i)
365
+ case type
366
+ when SQLITE_INTEGER
367
+ SQLite3FFI.sqlite3_column_int64(stmt, i)
368
+ when SQLITE_FLOAT
369
+ SQLite3FFI.sqlite3_column_double(stmt, i)
370
+ when SQLITE_TEXT
371
+ bytes = SQLite3FFI.sqlite3_column_bytes(stmt, i)
372
+ return ''.dup.force_encoding(Encoding::UTF_8) if bytes.zero?
373
+ ptr = SQLite3FFI.sqlite3_column_text(stmt, i)
374
+ ptr.to_s(bytes).force_encoding(Encoding::UTF_8)
375
+ when SQLITE_BLOB
376
+ bytes = SQLite3FFI.sqlite3_column_bytes(stmt, i)
377
+ return ''.b if bytes.zero?
378
+ ptr = SQLite3FFI.sqlite3_column_blob(stmt, i)
379
+ ptr.to_s(bytes)
380
+ when SQLITE_NULL
381
+ nil
382
+ end
383
+ end
384
+ end
385
+ end
386
+ module Jimmu
387
+ # Represents one uploaded file from a multipart/form-data request.
388
+ # Data is held in memory as a binary String; call #save to write it
389
+ # to disk.
390
+ #
391
+ # file = params[:image]
392
+ # file.filename # "photo.png"
393
+ # file.content_type # "image/png"
394
+ # file.size # 48213
395
+ # file.save("public/uploads/photo.png")
396
+ UploadedFile = Struct.new(:filename, :content_type, :data) do
397
+ def size
398
+ data.bytesize
399
+ end
400
+
401
+ def save(path)
402
+ dir = File.dirname(path)
403
+ FileUtils.mkdir_p(dir) unless Dir.exist?(dir)
404
+ File.open(path, 'wb') { |f| f.write(data) }
405
+ path
406
+ end
407
+
408
+ def empty?
409
+ data.nil? || data.empty?
410
+ end
411
+ end
412
+
413
+ # Parses application/x-www-form-urlencoded and multipart/form-data
414
+ # request bodies, and application/json bodies. All parsing is done
415
+ # byte-safe (ASCII-8BIT) throughout and only the final text values
416
+ # are tagged back to UTF-8, so binary upload data is never at risk
417
+ # of being mangled or raising encoding errors partway through.
418
+ module BodyParser
419
+ module_function
420
+
421
+ def parse_urlencoded(raw_body)
422
+ str = raw_body.to_s.dup.force_encoding(Encoding::UTF_8)
423
+ result = {}
424
+ URI.decode_www_form(str, Encoding::UTF_8).each do |k, v|
425
+ result[k.to_sym] = v
426
+ end
427
+ result
428
+ rescue ArgumentError
429
+ {}
430
+ end
431
+
432
+ def parse_json(raw_body)
433
+ return {} if raw_body.nil? || raw_body.empty?
434
+ data = JSON.parse(raw_body.dup.force_encoding(Encoding::UTF_8), symbolize_names: true)
435
+ data.is_a?(Hash) ? data : { _json: data }
436
+ rescue JSON::ParserError
437
+ {}
438
+ end
439
+
440
+ # Returns [fields_hash, files_hash], both keyed by Symbol.
441
+ def parse_multipart(raw_body, boundary)
442
+ fields = {}
443
+ files = {}
444
+ return [fields, files] if raw_body.nil? || boundary.nil? || boundary.empty?
445
+
446
+ body = raw_body.dup.force_encoding(Encoding::BINARY)
447
+ delim = "--#{boundary}".b
448
+
449
+ body.split(delim).each do |part|
450
+ next if part.nil? || part.empty?
451
+ next if part.start_with?('--'.b)
452
+
453
+ part = part.sub(/\A\r\n/n, ''.b)
454
+ part = part.sub(/\r\n\z/n, ''.b)
455
+ next if part.empty?
456
+
457
+ header_section, sep, body_section = part.partition("\r\n\r\n".b)
458
+ next if sep.empty?
459
+
460
+ headers = {}
461
+ header_section.split("\r\n".b).each do |line|
462
+ k, v = line.split(':'.b, 2)
463
+ next unless k && v
464
+ headers[k.strip.downcase] = v.strip
465
+ end
466
+
467
+ disposition = headers['content-disposition'.b]
468
+ next unless disposition
469
+
470
+ name = disposition[/name="([^"]*)"/n, 1] || disposition[/name=([^;]*)/n, 1]
471
+ next unless name
472
+
473
+ name_sym = name.dup.force_encoding(Encoding::UTF_8).to_sym
474
+ filename = disposition[/filename="([^"]*)"/n, 1]
475
+
476
+ if filename
477
+ content_type = (headers['content-type'.b] || 'application/octet-stream').dup.force_encoding(Encoding::UTF_8)
478
+ files[name_sym] = UploadedFile.new(filename.dup.force_encoding(Encoding::UTF_8), content_type, body_section)
479
+ else
480
+ fields[name_sym] = body_section.dup.force_encoding(Encoding::UTF_8)
481
+ end
482
+ end
483
+
484
+ [fields, files]
485
+ end
486
+
487
+ def extract_boundary(content_type)
488
+ return nil unless content_type
489
+ content_type[/boundary="?([^";]+)"?/, 1]
490
+ end
491
+ end
492
+
493
+ # A parsed HTTP request. Constructed once per connection by
494
+ # Application's connection-handling code; passed into Context.
495
+ class Request
496
+ attr_reader :http_method, :path, :query_string, :raw_headers, :body, :ip, :http_version
497
+ attr_accessor :route_params
498
+
499
+ def initialize(http_method:, path:, query_string:, headers:, body:, ip:)
500
+ @http_method = http_method
501
+ @path = path
502
+ @query_string = query_string || ''
503
+ @raw_headers = headers # Hash, lowercase String keys => String values
504
+ @body = body || ''.b
505
+ @ip = ip
506
+ @route_params = {}
507
+ end
508
+
509
+ # Public DSL name (spec: request.method)
510
+ def method
511
+ @http_method
512
+ end
513
+
514
+ def header(name)
515
+ @raw_headers[name.to_s.downcase]
516
+ end
517
+
518
+ def content_type
519
+ header('content-type')
520
+ end
521
+
522
+ def query_params
523
+ @query_params ||= begin
524
+ h = {}
525
+ URI.decode_www_form(@query_string, Encoding::UTF_8).each { |k, v| h[k.to_sym] = v } unless @query_string.empty?
526
+ h
527
+ rescue ArgumentError
528
+ {}
529
+ end
530
+ end
531
+
532
+ def body_params
533
+ @body_params ||= compute_body_params
534
+ end
535
+
536
+ def uploaded_files
537
+ @uploaded_files ||= begin
538
+ compute_body_params
539
+ @uploaded_files_cache || {}
540
+ end
541
+ end
542
+
543
+ # Query params, then body params, then route (file-based dynamic
544
+ # segment) params, then uploaded files -- later sources win on key
545
+ # collision, with route params taking precedence as the most
546
+ # specific/intentional source, and uploaded files layered on top
547
+ # since their field names are defined by the form itself.
548
+ def params
549
+ @params ||= query_params.merge(body_params).merge(route_params).merge(uploaded_files)
550
+ end
551
+
552
+ private
553
+
554
+ def compute_body_params
555
+ return @body_params_cache if defined?(@body_params_cache)
556
+ ctype = content_type.to_s
557
+ if ctype.start_with?('multipart/form-data')
558
+ boundary = BodyParser.extract_boundary(ctype)
559
+ fields, files = BodyParser.parse_multipart(@body, boundary)
560
+ @uploaded_files_cache = files
561
+ @body_params_cache = fields
562
+ elsif ctype.start_with?('application/json')
563
+ @uploaded_files_cache = {}
564
+ @body_params_cache = BodyParser.parse_json(@body)
565
+ elsif ctype.start_with?('application/x-www-form-urlencoded')
566
+ @uploaded_files_cache = {}
567
+ @body_params_cache = BodyParser.parse_urlencoded(@body)
568
+ else
569
+ @uploaded_files_cache = {}
570
+ @body_params_cache = {}
571
+ end
572
+ end
573
+ end
574
+
575
+ # Read/write access to request and response cookies for a single
576
+ # request. `cookie[:user]` reads an incoming cookie value (or nil).
577
+ # `cookie[:user] = "value"` queues a Set-Cookie header on the
578
+ # response. Values are transparently percent-encoded/decoded so any
579
+ # text (including Japanese) round-trips safely.
580
+ class CookieJar
581
+ def initialize(cookie_header)
582
+ @incoming = {}
583
+ cookie_header.to_s.split(';').each do |pair|
584
+ k, v = pair.split('=', 2)
585
+ next unless k
586
+ k = k.strip
587
+ next if k.empty?
588
+ v = (v || '').strip
589
+ @incoming[k] = begin
590
+ URI.decode_www_form_component(v)
591
+ rescue ArgumentError
592
+ v
593
+ end
594
+ end
595
+ @outgoing = []
596
+ end
597
+
598
+ def [](name)
599
+ @incoming[name.to_s]
600
+ end
601
+
602
+ def []=(name, value)
603
+ set(name, value)
604
+ end
605
+
606
+ # Full control over cookie attributes when needed.
607
+ def set(name, value, path: '/', max_age: nil, expires: nil, http_only: false, secure: false, same_site: nil)
608
+ parts = ["#{name}=#{URI.encode_www_form_component(value.to_s)}"]
609
+ parts << "Path=#{path}" if path
610
+ parts << "Max-Age=#{max_age.to_i}" if max_age
611
+ parts << "Expires=#{expires.httpdate}" if expires
612
+ parts << 'HttpOnly' if http_only
613
+ parts << 'Secure' if secure
614
+ parts << "SameSite=#{same_site}" if same_site
615
+ @outgoing << parts.join('; ')
616
+ value
617
+ end
618
+
619
+ def delete(name, path: '/')
620
+ set(name, '', path: path, max_age: 0, expires: Time.at(0))
621
+ end
622
+
623
+ def to_a
624
+ @incoming.dup
625
+ end
626
+
627
+ def response_header_values
628
+ @outgoing
629
+ end
630
+ end
631
+
632
+ # Thread-safe, in-memory server-side session store shared by the
633
+ # whole Application. Session data does not survive a server
634
+ # restart -- this is a development-focused framework, and adding a
635
+ # persistent backend is left as an extension point.
636
+ class SessionStore
637
+ COOKIE_NAME = '_jimmu_session'
638
+ DEFAULT_MAX_AGE = 60 * 60 * 24 # 24 hours, refreshed on every response
639
+
640
+ def initialize
641
+ @sessions = {}
642
+ @lock = Mutex.new
643
+ end
644
+
645
+ # Returns [session_id, session_hash, newly_created]
646
+ def fetch_or_create(session_id)
647
+ @lock.synchronize do
648
+ if session_id && @sessions.key?(session_id)
649
+ [session_id, @sessions[session_id], false]
650
+ else
651
+ id = SecureRandom.hex(16)
652
+ @sessions[id] = {}
653
+ [id, @sessions[id], true]
654
+ end
655
+ end
656
+ end
657
+
658
+ def destroy(session_id)
659
+ @lock.synchronize { @sessions.delete(session_id) }
660
+ end
661
+ end
662
+ end
663
+ module Jimmu
664
+ BUILTIN_ERROR_CSS = <<~CSS.freeze
665
+ body{margin:0;padding:0;background:#1e1f26;color:#e8e8ec;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Helvetica,Arial,sans-serif}
666
+ .jimmu-error{max-width:760px;margin:6vh auto;padding:0 24px}
667
+ .jimmu-error h1{font-size:22px;color:#ff6b6b;border-bottom:1px solid #34353f;padding-bottom:14px;margin-bottom:18px}
668
+ .jimmu-error p{line-height:1.6;color:#c3c4cf}
669
+ .jimmu-error-class{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:14px;background:#2a2b35;padding:10px 14px;border-radius:6px;color:#ffb1b1}
670
+ .jimmu-error-trace{background:#26272f;border-radius:6px;padding:16px;overflow-x:auto;font-size:12.5px;line-height:1.6;color:#9fa3b5}
671
+ .jimmu-error-hint{font-size:13px;color:#767a8c;margin-top:20px}
672
+ .jimmu-error a{color:#7aa2ff}
673
+ CSS
674
+
675
+ # Context is instantiated once per HTTP request. It is the `self`
676
+ # against which ERB templates, layouts, and before/after hook blocks
677
+ # all run (via instance_eval / a bound top-level `binding`), which is
678
+ # what makes bare DSL calls like `params`, `redirect "/login"`, or
679
+ # `session[:user]` work naturally inside a .erb file.
680
+ class Context
681
+ attr_reader :status_code, :response_headers, :session_id
682
+ attr_accessor :response_body, :error
683
+
684
+ def initialize(application, request, cookie_jar, session_id, session_hash, new_session)
685
+ @application = application
686
+ @request = request
687
+ @cookie_jar = cookie_jar
688
+ @session_id = session_id
689
+ @session_hash = session_hash
690
+ @new_session = new_session
691
+ @status_code = 200
692
+ @response_headers = {}
693
+ @response_body = nil
694
+ @use_layout = true
695
+ @error = nil
696
+ end
697
+
698
+ # ---- DSL: read accessors -------------------------------------------
699
+
700
+ def params
701
+ @request.params
702
+ end
703
+
704
+ def request
705
+ @request
706
+ end
707
+
708
+ def cookie
709
+ @cookie_jar
710
+ end
711
+
712
+ def session
713
+ @session_hash
714
+ end
715
+
716
+ # ---- DSL: response building -----------------------------------------
717
+
718
+ def status(code)
719
+ @status_code = code.to_i
720
+ nil
721
+ end
722
+
723
+ def header(name, value)
724
+ @response_headers[name.to_s] = value.to_s
725
+ nil
726
+ end
727
+
728
+ def log(message)
729
+ @application.log(message)
730
+ nil
731
+ end
732
+
733
+ def db
734
+ @application.default_db
735
+ end
736
+
737
+ def layout(enabled)
738
+ @use_layout = enabled ? true : false
739
+ nil
740
+ end
741
+
742
+ # ---- DSL: response-terminating actions ------------------------------
743
+ # Each of these fully determines the response and immediately
744
+ # unwinds out of whatever ERB template (or before-hook) called it,
745
+ # via `throw Jimmu::HALT`. The dispatch loop in Application wraps
746
+ # request handling in `catch(Jimmu::HALT)`, so this jump is always
747
+ # caught -- see Application#dispatch.
748
+
749
+ def redirect(location, code = 302)
750
+ @status_code = code
751
+ @response_headers['Location'] = location.to_s
752
+ @response_body = ''
753
+ throw HALT
754
+ end
755
+
756
+ def json(data)
757
+ @response_headers['Content-Type'] = 'application/json; charset=utf-8'
758
+ @response_body = data.to_json
759
+ throw HALT
760
+ end
761
+
762
+ def text(str)
763
+ @response_headers['Content-Type'] = 'text/plain; charset=utf-8'
764
+ @response_body = str.to_s
765
+ throw HALT
766
+ end
767
+
768
+ def html(str)
769
+ @response_headers['Content-Type'] = 'text/html; charset=utf-8'
770
+ @response_body = str.to_s
771
+ throw HALT
772
+ end
773
+
774
+ def render(name)
775
+ @response_body = render_file(resolve_view_path(name))
776
+ @response_headers['Content-Type'] ||= 'text/html; charset=utf-8'
777
+ throw HALT
778
+ end
779
+
780
+ # ---- Internal rendering pipeline (not part of the ERB-facing DSL) --
781
+
782
+ # Renders a static file's bytes directly, without ERB or layout.
783
+ def serve_static_file(path)
784
+ @response_body = File.binread(path)
785
+ @response_headers['Content-Type'] = MIME_TYPES[File.extname(path).downcase]
786
+ nil
787
+ end
788
+
789
+ # Renders `views/<code>.erb` if present (through the normal
790
+ # ERB+layout pipeline, so it has full access to params/session/etc),
791
+ # falling back to a built-in page if it is missing or itself
792
+ # raises.
793
+ def render_error_page(code)
794
+ @status_code = code
795
+ path = resolve_view_path(code.to_s)
796
+ if File.file?(path)
797
+ begin
798
+ return render_file(path)
799
+ rescue Exception => e
800
+ @application.log("[jimmu] error while rendering views/#{code}.erb: #{e.class}: #{e.message}")
801
+ end
802
+ end
803
+ built_in_error_page(code)
804
+ end
805
+
806
+ # Renders the .erb file at `path`, wrapping it in views/layout.erb
807
+ # unless the template disabled that via `layout false`. This is
808
+ # used both for the initially-matched route and by the `render`
809
+ # DSL method above.
810
+ def render_file(path)
811
+ raise Jimmu::Error, "Template not found: #{path}" unless File.file?(path)
812
+
813
+ @use_layout = true
814
+ content = evaluate_erb(read_template(path))
815
+ return content unless @use_layout
816
+
817
+ layout_path = File.join(@application.views_dir, 'layout.erb')
818
+ return content unless File.file?(layout_path)
819
+
820
+ layout_src = read_template(layout_path)
821
+ wrap_with_layout(content, layout_src)
822
+ end
823
+
824
+ private
825
+
826
+ def read_template(path)
827
+ File.read(path, encoding: Encoding::UTF_8)
828
+ end
829
+
830
+ def resolve_view_path(name)
831
+ rel = name.to_s
832
+ rel = "#{rel}.erb" unless rel.end_with?('.erb')
833
+ File.join(@application.views_dir, rel)
834
+ end
835
+
836
+ def evaluate_erb(src)
837
+ erb = ERB.new(src, trim_mode: '-')
838
+ erb.result(binding)
839
+ end
840
+
841
+ def wrap_with_layout(content, layout_src)
842
+ erb = ERB.new(layout_src, trim_mode: '-')
843
+ capture_and_render(erb) { content }
844
+ end
845
+
846
+ # `binding` is captured directly in this method's own body (not
847
+ # nested inside a further block), so that a bare `yield` inside
848
+ # the layout ERB -- once compiled and eval'd against that binding
849
+ # -- correctly resolves to the block passed to *this* call
850
+ # (`{ content }` from wrap_with_layout above), rather than to any
851
+ # outer method. This is the standard trick ERB-based frameworks
852
+ # use to support `<%= yield %>` in layouts.
853
+ def capture_and_render(erb)
854
+ erb.result(binding)
855
+ end
856
+
857
+ def built_in_error_page(code)
858
+ title = code == 404 ? '404 Not Found' : "#{code} Internal Server Error"
859
+ if code == 500 && @error
860
+ backtrace = @error.backtrace ? @error.backtrace.first(25).join("\n") : '(no backtrace available)'
861
+ <<~HTML
862
+ <!DOCTYPE html>
863
+ <html><head><meta charset="UTF-8"><title>500 Internal Server Error</title>
864
+ <style>#{BUILTIN_ERROR_CSS}</style></head>
865
+ <body><div class="jimmu-error">
866
+ <h1>500 Internal Server Error</h1>
867
+ <p class="jimmu-error-class">#{CGI.escapeHTML(@error.class.to_s)}: #{CGI.escapeHTML(@error.message.to_s)}</p>
868
+ <pre class="jimmu-error-trace">#{CGI.escapeHTML(backtrace)}</pre>
869
+ <p class="jimmu-error-hint">This detailed page is shown because no <code>views/500.erb</code> was found (or it raised too). Add one to customize error handling -- it can read <code>error.message</code> and <code>error.backtrace</code> if you want to keep showing details, or just show a friendly message.</p>
870
+ </div></body></html>
871
+ HTML
872
+ else
873
+ message = code == 404 ? 'The page you requested could not be found.' : 'Something went wrong while handling this request.'
874
+ <<~HTML
875
+ <!DOCTYPE html>
876
+ <html><head><meta charset="UTF-8"><title>#{title}</title>
877
+ <style>#{BUILTIN_ERROR_CSS}</style></head>
878
+ <body><div class="jimmu-error"><h1>#{title}</h1><p>#{message}</p></div></body></html>
879
+ HTML
880
+ end
881
+ end
882
+ end
883
+ end
884
+ module Jimmu
885
+ # Resolves request paths to .erb files under views/, following the
886
+ # file-based routing convention described in the Jimmu spec:
887
+ #
888
+ # views/index.erb -> /
889
+ # views/about.erb -> /about
890
+ # views/blog/index.erb -> /blog
891
+ # views/blog/post.erb -> /blog/post
892
+ # views/user/[id].erb -> /user/:id
893
+ # views/post/[category]/[id].erb -> /post/:category/:id
894
+ #
895
+ # A literal path segment always wins over a `[dynamic]` one at the
896
+ # same directory level (so views/user/admin.erb beats
897
+ # views/user/[id].erb for the request "/user/admin", while any other
898
+ # value still falls through to the dynamic route).
899
+ #
900
+ # Directory entries are enumerated directly (Dir.children) rather
901
+ # than matched with Dir.glob bracket patterns, since glob's `[...]`
902
+ # character-class syntax collides with the literal `[name]` folder
903
+ # naming convention and is easy to get subtly wrong across relative
904
+ # vs. absolute paths.
905
+ module Router
906
+ module_function
907
+
908
+ # Returns [file_path, params] or nil.
909
+ def resolve_view(views_dir, request_path)
910
+ segments = split_segments(request_path)
911
+ resolve_dir(views_dir, segments, {})
912
+ end
913
+
914
+ # Returns the on-disk path if `request_path` maps to a real file
915
+ # under public_dir, else nil. Guards against path traversal.
916
+ def resolve_static(public_dir, request_path)
917
+ return nil if request_path.include?('..')
918
+ decoded = Jimmu.percent_decode(request_path)
919
+ return nil if decoded.include?('..')
920
+ path = File.join(public_dir, decoded)
921
+ return nil unless path.start_with?(File.expand_path(public_dir) + File::SEPARATOR) ||
922
+ File.expand_path(path).start_with?(File.expand_path(public_dir) + File::SEPARATOR)
923
+ File.file?(path) ? path : nil
924
+ end
925
+
926
+ # Matches a single registered pattern (e.g. "/room/[name]") against
927
+ # an incoming request path, used for `websocket` routes. Returns a
928
+ # params Hash on match, or nil.
929
+ def match_pattern(pattern, request_path)
930
+ pattern_segments = split_segments(pattern)
931
+ path_segments = split_segments(request_path)
932
+ return nil unless pattern_segments.length == path_segments.length
933
+
934
+ params = {}
935
+ pattern_segments.each_with_index do |seg, i|
936
+ actual = path_segments[i]
937
+ if seg.start_with?('[') && seg.end_with?(']')
938
+ params[seg[1..-2].to_sym] = actual
939
+ elsif seg != actual
940
+ return nil
941
+ end
942
+ end
943
+ params
944
+ end
945
+
946
+ def split_segments(path)
947
+ path.to_s.split('/').reject(&:empty?)
948
+ end
949
+
950
+ def resolve_dir(base_dir, segments, params)
951
+ return nil unless Dir.exist?(base_dir)
952
+
953
+ if segments.empty?
954
+ idx = File.join(base_dir, 'index.erb')
955
+ return File.file?(idx) ? [idx, params] : nil
956
+ end
957
+
958
+ seg = segments[0]
959
+ rest = segments[1..-1]
960
+
961
+ if rest.empty?
962
+ exact = File.join(base_dir, "#{seg}.erb")
963
+ return [exact, params] if File.file?(exact)
964
+
965
+ subdir = File.join(base_dir, seg)
966
+ if Dir.exist?(subdir)
967
+ idx = File.join(subdir, 'index.erb')
968
+ return [idx, params] if File.file?(idx)
969
+ end
970
+ else
971
+ subdir = File.join(base_dir, seg)
972
+ if Dir.exist?(subdir)
973
+ result = resolve_dir(subdir, rest, params)
974
+ return result if result
975
+ end
976
+ end
977
+
978
+ resolve_dynamic(base_dir, seg, rest, params)
979
+ end
980
+
981
+ def resolve_dynamic(base_dir, seg, rest, params)
982
+ entries = Dir.children(base_dir).sort
983
+ if rest.empty?
984
+ entries.each do |entry|
985
+ next unless entry.start_with?('[') && entry.end_with?('].erb')
986
+ f = File.join(base_dir, entry)
987
+ next unless File.file?(f)
988
+ name = entry[1...-('].erb'.length)]
989
+ return [f, params.merge(name.to_sym => seg)]
990
+ end
991
+ else
992
+ entries.each do |entry|
993
+ next unless entry.start_with?('[') && entry.end_with?(']')
994
+ d = File.join(base_dir, entry)
995
+ next unless Dir.exist?(d)
996
+ name = entry[1..-2]
997
+ result = resolve_dir(d, rest, params.merge(name.to_sym => seg))
998
+ return result if result
999
+ end
1000
+ end
1001
+ nil
1002
+ end
1003
+ private_class_method :resolve_dynamic
1004
+ end
1005
+ end
1006
+ module Jimmu
1007
+ # RFC 6455 handshake + frame codec. Implements unfragmented and
1008
+ # fragmented text/binary messages, ping/pong, and close frames.
1009
+ # Extensions (permessage-deflate etc.) are not negotiated or
1010
+ # supported, which is the right amount of protocol for a
1011
+ # small-to-medium app framework talking to browser clients.
1012
+ module WebSocketProtocol
1013
+ MAGIC_GUID = '258EAFA5-E914-47DA-95CA-C5AB0DC85B11'
1014
+
1015
+ module_function
1016
+
1017
+ def accept_key(client_key)
1018
+ Base64.strict_encode64(Digest::SHA1.digest(client_key.to_s + MAGIC_GUID))
1019
+ end
1020
+
1021
+ # Reads one complete (possibly fragmented) message from `socket`.
1022
+ # Returns {opcode:, payload:} or nil on EOF/disconnect. Control
1023
+ # frames (close/ping/pong) are returned immediately and are never
1024
+ # fragmented, per spec.
1025
+ def read_message(socket)
1026
+ first_opcode = nil
1027
+ buffer = +''.b
1028
+ loop do
1029
+ frame = read_single_frame(socket)
1030
+ return nil if frame.nil?
1031
+ return frame if [0x8, 0x9, 0xA].include?(frame[:opcode])
1032
+
1033
+ first_opcode ||= frame[:opcode]
1034
+ buffer << frame[:payload]
1035
+ if frame[:fin]
1036
+ payload = first_opcode == 0x1 ? buffer.force_encoding(Encoding::UTF_8) : buffer
1037
+ return { opcode: first_opcode, payload: payload }
1038
+ end
1039
+ end
1040
+ end
1041
+
1042
+ def read_single_frame(socket)
1043
+ hdr = read_exactly(socket, 2)
1044
+ return nil if hdr.nil?
1045
+ b0, b1 = hdr.unpack('C2')
1046
+ fin = (b0 & 0x80) != 0
1047
+ opcode = b0 & 0x0F
1048
+ masked = (b1 & 0x80) != 0
1049
+ len = b1 & 0x7F
1050
+
1051
+ if len == 126
1052
+ ext = read_exactly(socket, 2)
1053
+ return nil if ext.nil?
1054
+ len = ext.unpack1('n')
1055
+ elsif len == 127
1056
+ ext = read_exactly(socket, 8)
1057
+ return nil if ext.nil?
1058
+ len = ext.unpack1('Q>')
1059
+ end
1060
+
1061
+ mask_key = nil
1062
+ if masked
1063
+ mask_key = read_exactly(socket, 4)
1064
+ return nil if mask_key.nil?
1065
+ end
1066
+
1067
+ payload = len.positive? ? read_exactly(socket, len) : ''.b
1068
+ return nil if payload.nil?
1069
+
1070
+ if masked
1071
+ unmasked = +''.b
1072
+ payload.each_byte.with_index { |byte, i| unmasked << (byte ^ mask_key.getbyte(i % 4)) }
1073
+ payload = unmasked
1074
+ end
1075
+
1076
+ { fin: fin, opcode: opcode, payload: payload }
1077
+ end
1078
+
1079
+ def write_frame(socket, opcode, payload, fin: true)
1080
+ payload = payload.to_s.dup.force_encoding(Encoding::BINARY)
1081
+ b0 = (fin ? 0x80 : 0x00) | (opcode & 0x0F)
1082
+ out = +''.b
1083
+ out << [b0].pack('C')
1084
+ len = payload.bytesize
1085
+ if len <= 125
1086
+ out << [len].pack('C')
1087
+ elsif len <= 0xFFFF
1088
+ out << [126, len].pack('Cn')
1089
+ else
1090
+ out << [127, len].pack('CQ>')
1091
+ end
1092
+ out << payload
1093
+ socket.write(out)
1094
+ end
1095
+
1096
+ def read_exactly(socket, n)
1097
+ data = socket.read(n)
1098
+ return nil if data.nil? || data.bytesize < n
1099
+ data
1100
+ rescue IOError, Errno::ECONNRESET, Errno::EPIPE, Errno::ETIMEDOUT
1101
+ nil
1102
+ end
1103
+ private_class_method :read_exactly
1104
+ end
1105
+
1106
+ # Passed as `self` into a `websocket "/path" do ... end` block (via
1107
+ # instance_exec), and as `self` into the on_message / on_close
1108
+ # handler blocks registered from inside it. This is what lets those
1109
+ # blocks call bare `send(...)`, `close`, `params`, `session`, etc.
1110
+ #
1111
+ # websocket "/room/[name]" do
1112
+ # log "opened: #{params[:name]}"
1113
+ # on_message { |msg| send("echo: #{msg}") }
1114
+ # on_close { log "closed" }
1115
+ # end
1116
+ class WebSocketConnection
1117
+ attr_reader :params, :request, :cookie, :session
1118
+
1119
+ def initialize(application, socket, request, route_params, cookie_jar, session_hash)
1120
+ @application = application
1121
+ @socket = socket
1122
+ @request = request
1123
+ @params = route_params
1124
+ @cookie = cookie_jar
1125
+ @session = session_hash
1126
+ @message_handler = nil
1127
+ @close_handler = nil
1128
+ @open = true
1129
+ end
1130
+
1131
+ def open?
1132
+ @open
1133
+ end
1134
+
1135
+ def on_message(&block)
1136
+ @message_handler = block
1137
+ nil
1138
+ end
1139
+
1140
+ def on_close(&block)
1141
+ @close_handler = block
1142
+ nil
1143
+ end
1144
+
1145
+ def log(message)
1146
+ @application.log(message)
1147
+ nil
1148
+ end
1149
+
1150
+ def db
1151
+ @application.default_db
1152
+ end
1153
+
1154
+ # Sends a text frame to this client.
1155
+ def send(message)
1156
+ return false unless @open
1157
+ WebSocketProtocol.write_frame(@socket, 0x1, message.to_s)
1158
+ true
1159
+ rescue IOError, Errno::EPIPE, Errno::ECONNRESET
1160
+ @open = false
1161
+ false
1162
+ end
1163
+
1164
+ # Sends a binary frame to this client.
1165
+ def send_binary(bytes)
1166
+ return false unless @open
1167
+ WebSocketProtocol.write_frame(@socket, 0x2, bytes)
1168
+ true
1169
+ rescue IOError, Errno::EPIPE, Errno::ECONNRESET
1170
+ @open = false
1171
+ false
1172
+ end
1173
+
1174
+ def close(code = 1000, reason = '')
1175
+ return unless @open
1176
+ payload = [code].pack('n') + reason.to_s
1177
+ begin
1178
+ WebSocketProtocol.write_frame(@socket, 0x8, payload)
1179
+ rescue IOError, Errno::EPIPE, Errno::ECONNRESET
1180
+ nil
1181
+ end
1182
+ @open = false
1183
+ begin
1184
+ @socket.close
1185
+ rescue IOError
1186
+ nil
1187
+ end
1188
+ nil
1189
+ end
1190
+
1191
+ # Runs the blocking read loop for this connection's lifetime.
1192
+ # Called by Application after the block passed to `websocket` has
1193
+ # been instance_exec'd (so on_message/on_close are registered).
1194
+ def run_loop
1195
+ loop do
1196
+ break unless @open
1197
+ message = WebSocketProtocol.read_message(@socket)
1198
+ break if message.nil?
1199
+
1200
+ case message[:opcode]
1201
+ when 0x1, 0x2
1202
+ safely { @message_handler&.call(message[:payload]) }
1203
+ when 0x8
1204
+ close(1000, '')
1205
+ break
1206
+ when 0x9
1207
+ WebSocketProtocol.write_frame(@socket, 0xA, message[:payload]) rescue nil
1208
+ when 0xA
1209
+ nil # pong received, nothing to do
1210
+ end
1211
+ end
1212
+ rescue IOError, Errno::ECONNRESET, Errno::EPIPE, Errno::ETIMEDOUT
1213
+ nil
1214
+ ensure
1215
+ @open = false
1216
+ safely { @close_handler&.call }
1217
+ begin
1218
+ @socket.close
1219
+ rescue IOError
1220
+ nil
1221
+ end
1222
+ end
1223
+
1224
+ private
1225
+
1226
+ def safely
1227
+ yield
1228
+ rescue Exception => e
1229
+ @application.log("[jimmu] websocket handler error: #{e.class}: #{e.message}")
1230
+ end
1231
+ end
1232
+ end
1233
+ module Jimmu
1234
+ # Application is the object app.rb runs against (via instance_eval),
1235
+ # which is what makes bare top-level calls like `port 3000`,
1236
+ # `before do ... end`, or `run` work as a DSL. It also owns the
1237
+ # actual TCPServer accept loop and per-connection dispatch once
1238
+ # `run` is called.
1239
+ class Application
1240
+ attr_reader :views_dir, :public_dir, :db_dir, :app_root
1241
+
1242
+ def initialize(app_root)
1243
+ @app_root = File.expand_path(app_root)
1244
+ @views_dir = File.join(@app_root, 'views')
1245
+ @public_dir = File.join(@app_root, 'public')
1246
+ @db_dir = File.join(@app_root, 'db')
1247
+
1248
+ @port = 3000
1249
+ @host = '127.0.0.1'
1250
+ @port_forced = false
1251
+ @host_forced = false
1252
+
1253
+ @before_hooks = []
1254
+ @after_hooks = []
1255
+ @websocket_routes = []
1256
+ @databases = []
1257
+ @default_db = nil
1258
+
1259
+ @session_store = SessionStore.new
1260
+ @log_mutex = Mutex.new
1261
+ @run_called = false
1262
+ @server = nil
1263
+ end
1264
+
1265
+ # ================= DSL methods (called from app.rb) =================
1266
+
1267
+ # `port 8080` sets it (unless overridden with -p on the command
1268
+ # line); `port` with no argument reads the current value.
1269
+ def port(value = nil)
1270
+ return @port if value.nil?
1271
+ @port = value.to_i unless @port_forced
1272
+ nil
1273
+ end
1274
+
1275
+ def host(value = nil)
1276
+ return @host if value.nil?
1277
+ @host = value.to_s unless @host_forced
1278
+ nil
1279
+ end
1280
+
1281
+ # Used internally by the CLI's -p/-h flags, which always win over
1282
+ # whatever app.rb itself requests.
1283
+ def force_port!(value)
1284
+ @port = value.to_i
1285
+ @port_forced = true
1286
+ end
1287
+
1288
+ def force_host!(value)
1289
+ @host = value.to_s
1290
+ @host_forced = true
1291
+ end
1292
+
1293
+ # `db = sqlite "db/app.db"` -- opens (creating if needed) a SQLite
1294
+ # database, relative to the app root. The most recently opened
1295
+ # connection becomes the implicit `db` available inside views.
1296
+ def sqlite(path)
1297
+ full_path = File.expand_path(path, @app_root)
1298
+ database = Database.new(full_path)
1299
+ @databases << database
1300
+ @default_db = database
1301
+ database
1302
+ end
1303
+
1304
+ def default_db
1305
+ @default_db
1306
+ end
1307
+
1308
+ def before(&block)
1309
+ raise ArgumentError, 'before requires a block' unless block
1310
+ @before_hooks << block
1311
+ nil
1312
+ end
1313
+
1314
+ def after(&block)
1315
+ raise ArgumentError, 'after requires a block' unless block
1316
+ @after_hooks << block
1317
+ nil
1318
+ end
1319
+
1320
+ # `websocket "/room/[name]" do ... end` -- the block runs with
1321
+ # self bound to a WebSocketConnection (see WebSocketConnection);
1322
+ # register on_message / on_close from inside it.
1323
+ def websocket(path, &block)
1324
+ raise ArgumentError, 'websocket requires a block' unless block
1325
+ @websocket_routes << [path.to_s, block]
1326
+ nil
1327
+ end
1328
+
1329
+ def log(message, level = :info)
1330
+ @log_mutex.synchronize do
1331
+ ts = Time.now.strftime('%Y-%m-%d %H:%M:%S.%L')
1332
+ $stdout.puts "[#{ts}] #{level.to_s.upcase.ljust(5)} #{message}"
1333
+ $stdout.flush
1334
+ end
1335
+ nil
1336
+ end
1337
+
1338
+ def run
1339
+ @run_called = true
1340
+ start_server
1341
+ end
1342
+
1343
+ def run_called?
1344
+ @run_called
1345
+ end
1346
+
1347
+ # ================= App loading =================
1348
+
1349
+ def load_app_file(path)
1350
+ raise ConfigError, "app.rb not found at #{path}" unless File.file?(path)
1351
+ # Deliberately `load` rather than `instance_eval` a string: Ruby
1352
+ # scopes `def` / `module` / `class` / constants written inside
1353
+ # instance_eval'd source to the receiver's singleton class, which
1354
+ # would make helper methods, modules, and constants that app.rb
1355
+ # defines invisible from views (Context runs its own bindings) or
1356
+ # from before/after/websocket blocks running against a different
1357
+ # object. Loading the file normally puts them at the true,
1358
+ # ordinary Ruby top level, and Jimmu.application (see the DSL
1359
+ # module below) is what makes bare `port 3000`-style calls still
1360
+ # work despite that.
1361
+ Jimmu.application = self
1362
+ load(path)
1363
+ end
1364
+
1365
+ # ================= Server =================
1366
+
1367
+ def start_server
1368
+ begin
1369
+ @server = TCPServer.new(@host, @port)
1370
+ rescue Errno::EADDRINUSE
1371
+ raise ConfigError, "Port #{@port} is already in use. Try `jimmu server -p <port>` with a different port."
1372
+ rescue Errno::EACCES
1373
+ raise ConfigError, "Permission denied binding to #{@host}:#{@port}. Ports below 1024 usually need elevated privileges -- try a port like 3000 or 8080."
1374
+ end
1375
+
1376
+ log("Jimmu #{VERSION} listening on http://#{display_host}:#{@port} (app: #{@app_root})")
1377
+ log('Press Ctrl+C to stop.')
1378
+
1379
+ # Signal handlers run in a restricted "trap context" where
1380
+ # blocking operations (Mutex#synchronize, which our #log uses)
1381
+ # are not allowed. So the handler itself does nothing but raise
1382
+ # Interrupt back into the accepting thread; all real cleanup
1383
+ # (closing databases, logging, closing the socket) happens down
1384
+ # in the rescue/ensure below, in ordinary thread context.
1385
+ trap('INT') { raise Interrupt }
1386
+ trap('TERM') { raise Interrupt }
1387
+
1388
+ begin
1389
+ loop do
1390
+ client =
1391
+ begin
1392
+ @server.accept
1393
+ rescue IOError, Errno::EBADF
1394
+ break
1395
+ end
1396
+ Thread.new(client) { |socket| handle_connection(socket) }
1397
+ end
1398
+ rescue Interrupt
1399
+ nil
1400
+ ensure
1401
+ log('Shutting down...')
1402
+ @databases.each { |d| d.close rescue nil }
1403
+ begin
1404
+ @server.close
1405
+ rescue IOError
1406
+ nil
1407
+ end
1408
+ end
1409
+ end
1410
+
1411
+ def display_host
1412
+ @host == '0.0.0.0' ? 'localhost' : @host
1413
+ end
1414
+
1415
+ # ================= Per-connection handling =================
1416
+
1417
+ def handle_connection(socket)
1418
+ socket.setsockopt(Socket::IPPROTO_TCP, Socket::TCP_NODELAY, 1) rescue nil
1419
+ socket.set_encoding(Encoding::BINARY)
1420
+
1421
+ request = parse_request(socket)
1422
+ return if request.nil?
1423
+
1424
+ if websocket_upgrade_request?(request)
1425
+ route = find_websocket_route(request.path)
1426
+ if route
1427
+ handle_websocket_upgrade(socket, request, route)
1428
+ return
1429
+ end
1430
+ end
1431
+
1432
+ handle_http_request(socket, request)
1433
+ rescue Exception => e
1434
+ log("connection error: #{e.class}: #{e.message}", :error)
1435
+ ensure
1436
+ begin
1437
+ socket.close unless socket.closed?
1438
+ rescue IOError
1439
+ nil
1440
+ end
1441
+ end
1442
+
1443
+ def parse_request(socket)
1444
+ request_line = read_line(socket)
1445
+ return nil if request_line.nil? || request_line.empty?
1446
+
1447
+ parts = request_line.split(' ')
1448
+ return nil if parts.length < 2
1449
+ http_method, target = parts[0], parts[1]
1450
+
1451
+ raw_path, _, query = target.partition('?')
1452
+ path = Jimmu.percent_decode(raw_path).dup.force_encoding(Encoding::UTF_8)
1453
+ path = path.valid_encoding? ? path : path.scrub
1454
+ path = '/' if path.empty?
1455
+
1456
+ headers = {}
1457
+ loop do
1458
+ line = read_line(socket)
1459
+ break if line.nil? || line.empty?
1460
+ k, v = line.split(':', 2)
1461
+ next unless k && v
1462
+ headers[k.strip.downcase] = v.strip
1463
+ end
1464
+
1465
+ body = ''.b
1466
+ content_length = headers['content-length'].to_i
1467
+ if content_length.positive?
1468
+ return nil if content_length > MAX_BODY_SIZE
1469
+ remaining = content_length
1470
+ chunks = []
1471
+ while remaining.positive?
1472
+ chunk = socket.read([remaining, 65536].min)
1473
+ break if chunk.nil?
1474
+ chunks << chunk
1475
+ remaining -= chunk.bytesize
1476
+ end
1477
+ body = chunks.join.b
1478
+ end
1479
+
1480
+ peer_ip =
1481
+ begin
1482
+ socket.peeraddr(false)[3]
1483
+ rescue IOError, Errno::ENOTCONN, SocketError
1484
+ 'unknown'
1485
+ end
1486
+
1487
+ Request.new(
1488
+ http_method: http_method.to_s.upcase,
1489
+ path: path,
1490
+ query_string: query.to_s,
1491
+ headers: headers,
1492
+ body: body,
1493
+ ip: peer_ip
1494
+ )
1495
+ rescue IOError, Errno::ECONNRESET, Errno::ETIMEDOUT
1496
+ nil
1497
+ end
1498
+
1499
+ def read_line(socket)
1500
+ line = socket.gets("\n")
1501
+ return nil if line.nil?
1502
+ line.sub(/\r?\n\z/, '')
1503
+ rescue IOError, Errno::ECONNRESET
1504
+ nil
1505
+ end
1506
+
1507
+ def websocket_upgrade_request?(request)
1508
+ upgrade = request.header('upgrade').to_s.downcase
1509
+ connection = request.header('connection').to_s.downcase
1510
+ upgrade == 'websocket' && connection.include?('upgrade')
1511
+ end
1512
+
1513
+ def find_websocket_route(path)
1514
+ @websocket_routes.each do |pattern, block|
1515
+ params = Router.match_pattern(pattern, path)
1516
+ return [block, params] if params
1517
+ end
1518
+ nil
1519
+ end
1520
+
1521
+ def handle_websocket_upgrade(socket, request, route)
1522
+ block, route_params = route
1523
+ key = request.header('sec-websocket-key')
1524
+ unless key
1525
+ write_simple_response(socket, 400, 'Bad Request', 'Missing Sec-WebSocket-Key header.')
1526
+ return
1527
+ end
1528
+
1529
+ cookie_jar = CookieJar.new(request.header('cookie'))
1530
+ session_id, session_hash, = @session_store.fetch_or_create(cookie_jar[SessionStore::COOKIE_NAME])
1531
+ cookie_jar.set(SessionStore::COOKIE_NAME, session_id, http_only: true, max_age: SessionStore::DEFAULT_MAX_AGE)
1532
+ request.route_params = route_params
1533
+
1534
+ accept = WebSocketProtocol.accept_key(key)
1535
+ out = +''.b
1536
+ out << "HTTP/1.1 101 Switching Protocols\r\n"
1537
+ out << "Upgrade: websocket\r\n"
1538
+ out << "Connection: Upgrade\r\n"
1539
+ out << "Sec-WebSocket-Accept: #{accept}\r\n"
1540
+ cookie_jar.response_header_values.each { |v| out << "Set-Cookie: #{v}\r\n" }
1541
+ out << "\r\n"
1542
+ socket.write(out)
1543
+
1544
+ ws = WebSocketConnection.new(self, socket, request, route_params, cookie_jar, session_hash)
1545
+ log("WS #{request.path} connected [#{request.ip}]")
1546
+
1547
+ begin
1548
+ ws.instance_exec(&block)
1549
+ rescue Exception => e
1550
+ log("websocket setup error on #{request.path}: #{e.class}: #{e.message}", :error)
1551
+ (e.backtrace || []).first(10).each { |l| log(" #{l}", :error) }
1552
+ ws.close rescue nil
1553
+ return
1554
+ end
1555
+
1556
+ ws.run_loop
1557
+ log("WS #{request.path} disconnected [#{request.ip}]")
1558
+ end
1559
+
1560
+ def handle_http_request(socket, request)
1561
+ start_time = Time.now
1562
+ cookie_jar = CookieJar.new(request.header('cookie'))
1563
+ session_id, session_hash, = @session_store.fetch_or_create(cookie_jar[SessionStore::COOKIE_NAME])
1564
+ ctx = Context.new(self, request, cookie_jar, session_id, session_hash, false)
1565
+
1566
+ begin
1567
+ catch(HALT) do
1568
+ run_before_hooks(ctx)
1569
+ dispatch(ctx, request)
1570
+ end
1571
+ run_after_hooks(ctx)
1572
+ rescue Exception => e
1573
+ log("#{request.http_method} #{request.path} -> 500 #{e.class}: #{e.message}", :error)
1574
+ (e.backtrace || []).each { |line| log(" #{line}", :error) }
1575
+ ctx.error = e
1576
+ ctx.response_body = ctx.render_error_page(500)
1577
+ ctx.response_headers['Content-Type'] ||= 'text/html; charset=utf-8'
1578
+ begin
1579
+ run_after_hooks(ctx)
1580
+ rescue Exception => e2
1581
+ log("error in after-hook while handling 500: #{e2.class}: #{e2.message}", :error)
1582
+ end
1583
+ end
1584
+
1585
+ cookie_jar.set(SessionStore::COOKIE_NAME, session_id, http_only: true, max_age: SessionStore::DEFAULT_MAX_AGE)
1586
+ send_response(socket, ctx, request)
1587
+
1588
+ duration_ms = ((Time.now - start_time) * 1000).round(1)
1589
+ log("#{request.http_method} #{request.path} -> #{ctx.status_code} (#{ctx.response_body.to_s.bytesize}B, #{duration_ms}ms) [#{request.ip}]")
1590
+ end
1591
+
1592
+ def run_before_hooks(ctx)
1593
+ @before_hooks.each { |hook| ctx.instance_exec(&hook) }
1594
+ end
1595
+
1596
+ def run_after_hooks(ctx)
1597
+ @after_hooks.each { |hook| ctx.instance_exec(&hook) }
1598
+ end
1599
+
1600
+ def dispatch(ctx, request)
1601
+ if %w[GET HEAD].include?(request.http_method)
1602
+ static_path = Router.resolve_static(@public_dir, request.path)
1603
+ if static_path
1604
+ ctx.serve_static_file(static_path)
1605
+ return
1606
+ end
1607
+ end
1608
+
1609
+ if find_websocket_route(request.path)
1610
+ ctx.status(426)
1611
+ ctx.text('Upgrade Required: this endpoint only accepts WebSocket connections.')
1612
+ return
1613
+ end
1614
+
1615
+ match = Router.resolve_view(@views_dir, request.path)
1616
+ if match
1617
+ file_path, route_params = match
1618
+ request.route_params = route_params
1619
+ ctx.response_body = ctx.render_file(file_path)
1620
+ ctx.response_headers['Content-Type'] ||= 'text/html; charset=utf-8'
1621
+ else
1622
+ ctx.response_body = ctx.render_error_page(404)
1623
+ ctx.response_headers['Content-Type'] ||= 'text/html; charset=utf-8'
1624
+ end
1625
+ end
1626
+
1627
+ def send_response(socket, ctx, request)
1628
+ body_bytes = ctx.response_body.to_s.dup.force_encoding(Encoding::BINARY)
1629
+
1630
+ headers = ctx.response_headers.dup
1631
+ headers['Content-Type'] ||= 'text/html; charset=utf-8'
1632
+ headers['Content-Length'] = body_bytes.bytesize.to_s
1633
+ headers['Connection'] = 'close'
1634
+ headers['Server'] = "Jimmu/#{VERSION}"
1635
+
1636
+ out = +''.b
1637
+ out << "HTTP/1.1 #{ctx.status_code} #{Jimmu.status_phrase(ctx.status_code)}\r\n"
1638
+ headers.each { |k, v| out << "#{k}: #{v}\r\n" }
1639
+ ctx.cookie.response_header_values.each { |v| out << "Set-Cookie: #{v}\r\n" }
1640
+ out << "\r\n"
1641
+ out << body_bytes unless request.http_method == 'HEAD'
1642
+
1643
+ socket.write(out)
1644
+ rescue IOError, Errno::EPIPE, Errno::ECONNRESET
1645
+ nil
1646
+ end
1647
+
1648
+ def write_simple_response(socket, code, title, message)
1649
+ body = message.to_s.b
1650
+ out = +''.b
1651
+ out << "HTTP/1.1 #{code} #{title}\r\n"
1652
+ out << "Content-Type: text/plain; charset=utf-8\r\n"
1653
+ out << "Content-Length: #{body.bytesize}\r\n"
1654
+ out << "Connection: close\r\n\r\n"
1655
+ out << body
1656
+ socket.write(out)
1657
+ rescue IOError
1658
+ nil
1659
+ end
1660
+ end
1661
+
1662
+ # Mixed into Object (below) so that app.rb -- an ordinary, normally
1663
+ # `load`ed Ruby file, not an instance_eval'd string -- can still use
1664
+ # top-level DSL calls like `port 3000` or `before do ... end`. Each
1665
+ # method simply forwards to whatever Application instance is
1666
+ # currently active (Jimmu.application), which the CLI sets just
1667
+ # before loading app.rb.
1668
+ module DSL
1669
+ def port(value = nil)
1670
+ Jimmu.application.port(value)
1671
+ end
1672
+
1673
+ def host(value = nil)
1674
+ Jimmu.application.host(value)
1675
+ end
1676
+
1677
+ def sqlite(path)
1678
+ Jimmu.application.sqlite(path)
1679
+ end
1680
+
1681
+ def before(&block)
1682
+ Jimmu.application.before(&block)
1683
+ end
1684
+
1685
+ def after(&block)
1686
+ Jimmu.application.after(&block)
1687
+ end
1688
+
1689
+ def websocket(path, &block)
1690
+ Jimmu.application.websocket(path, &block)
1691
+ end
1692
+
1693
+ def run
1694
+ Jimmu.application.run
1695
+ end
1696
+
1697
+ def log(message, level = :info)
1698
+ Jimmu.application.log(message, level)
1699
+ end
1700
+ end
1701
+ end
1702
+
1703
+ # Extend only the single top-level "main" object (not Object globally)
1704
+ # with the DSL, so app.rb's bare `port 3000` / `before do ... end`
1705
+ # calls work without making these method names appear on every object
1706
+ # in the process.
1707
+ TOPLEVEL_BINDING.eval('self').singleton_class.include(Jimmu::DSL)
1708
+ module Jimmu
1709
+ module CLI
1710
+ TEMPLATE_APP_RB = <<~'RUBY'
1711
+ # Jimmu application entry point.
1712
+ # Start the development server with: jimmu server
1713
+
1714
+ port 3000
1715
+ host "127.0.0.1"
1716
+
1717
+ before do
1718
+ log "#{request.method} #{request.path}"
1719
+ end
1720
+
1721
+ run
1722
+ RUBY
1723
+
1724
+ TEMPLATE_LAYOUT_ERB = <<~'ERB'
1725
+ <!DOCTYPE html>
1726
+ <html lang="en">
1727
+ <head>
1728
+ <meta charset="UTF-8">
1729
+ <meta name="viewport" content="width=device-width, initial-scale=1">
1730
+ <title>Jimmu App</title>
1731
+ <link rel="stylesheet" href="/css/style.css">
1732
+ </head>
1733
+ <body>
1734
+ <%= yield %>
1735
+ <script src="/js/app.js"></script>
1736
+ </body>
1737
+ </html>
1738
+ ERB
1739
+
1740
+ TEMPLATE_INDEX_ERB = <<~'ERB'
1741
+ <h1>Welcome to Jimmu</h1>
1742
+ <p>Edit <code>views/index.erb</code> to get started.</p>
1743
+ <p>This page is automatically wrapped by <code>views/layout.erb</code>.</p>
1744
+ ERB
1745
+
1746
+ TEMPLATE_404_ERB = <<~'ERB'
1747
+ <h1>404 - Page Not Found</h1>
1748
+ <p>The page you requested does not exist.</p>
1749
+ <p><a href="/">Go back home</a></p>
1750
+ ERB
1751
+
1752
+ TEMPLATE_500_ERB = <<~'ERB'
1753
+ <h1>500 - Internal Server Error</h1>
1754
+ <p>Something went wrong on our end.</p>
1755
+ ERB
1756
+
1757
+ TEMPLATE_CSS = <<~'CSS'
1758
+ :root { color-scheme: light dark; }
1759
+ * { box-sizing: border-box; }
1760
+ body {
1761
+ margin: 0;
1762
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif;
1763
+ line-height: 1.6;
1764
+ max-width: 760px;
1765
+ padding: 48px 24px;
1766
+ margin: 0 auto;
1767
+ }
1768
+ code {
1769
+ background: rgba(127,127,127,0.15);
1770
+ padding: 2px 6px;
1771
+ border-radius: 4px;
1772
+ font-size: 0.9em;
1773
+ }
1774
+ a { color: #3b6fe0; }
1775
+ CSS
1776
+
1777
+ TEMPLATE_JS = <<~'JS'
1778
+ console.log("Jimmu app loaded");
1779
+ JS
1780
+
1781
+ # ================= entry point =================
1782
+
1783
+ def self.start(argv)
1784
+ command = argv[0]
1785
+ case command
1786
+ when 'new'
1787
+ cmd_new(argv[1..-1])
1788
+ when 'server'
1789
+ cmd_server(argv[1..-1])
1790
+ when 'version', '-v', '--version'
1791
+ puts "jimmu #{Jimmu::VERSION}"
1792
+ when 'build'
1793
+ puts 'jimmu build: not implemented yet. Future versions of Jimmu will support'
1794
+ puts 'bundling an app into a single distributable executable/package.'
1795
+ when 'help', '-h', '--help', nil
1796
+ print_help
1797
+ else
1798
+ warn "Unknown command: #{command}\n\n"
1799
+ print_help
1800
+ exit(1)
1801
+ end
1802
+ end
1803
+
1804
+ def self.print_help
1805
+ puts <<~HELP
1806
+ Jimmu #{Jimmu::VERSION} - a lightweight, single-file Ruby web framework
1807
+
1808
+ Usage:
1809
+ jimmu new <name> Create a new Jimmu project in ./<name>
1810
+ jimmu server [options] Start the development server (reads ./app.rb)
1811
+ jimmu version Print the installed Jimmu version
1812
+ jimmu help Show this help message
1813
+ jimmu build (reserved for future use)
1814
+
1815
+ Server options:
1816
+ -p, --port PORT Port to listen on (overrides app.rb's `port`, default 3000)
1817
+ -h, --host HOST Host/IP to bind to (overrides app.rb's `host`, default 127.0.0.1)
1818
+
1819
+ Examples:
1820
+ jimmu new myapp
1821
+ cd myapp
1822
+ jimmu server
1823
+ jimmu server -p 8080
1824
+ jimmu server -h 0.0.0.0 -p 8080
1825
+ HELP
1826
+ end
1827
+
1828
+ def self.cmd_new(args)
1829
+ name = args[0]
1830
+ if name.nil? || name.strip.empty?
1831
+ warn 'Usage: jimmu new <name>'
1832
+ exit(1)
1833
+ end
1834
+
1835
+ root = File.expand_path(name)
1836
+ if Dir.exist?(root) && !Dir.empty?(root)
1837
+ warn "Error: directory #{name.inspect} already exists and is not empty."
1838
+ exit(1)
1839
+ end
1840
+
1841
+ %w[views public/css public/js public/img db].each do |dir|
1842
+ FileUtils.mkdir_p(File.join(root, dir))
1843
+ end
1844
+
1845
+ write_file(File.join(root, 'app.rb'), TEMPLATE_APP_RB)
1846
+ write_file(File.join(root, 'views/layout.erb'), TEMPLATE_LAYOUT_ERB)
1847
+ write_file(File.join(root, 'views/index.erb'), TEMPLATE_INDEX_ERB)
1848
+ write_file(File.join(root, 'views/404.erb'), TEMPLATE_404_ERB)
1849
+ write_file(File.join(root, 'views/500.erb'), TEMPLATE_500_ERB)
1850
+ write_file(File.join(root, 'public/css/style.css'), TEMPLATE_CSS)
1851
+ write_file(File.join(root, 'public/js/app.js'), TEMPLATE_JS)
1852
+ write_file(File.join(root, 'public/img/.gitkeep'), '')
1853
+ write_file(File.join(root, 'db/.gitkeep'), '')
1854
+
1855
+ puts "Created new Jimmu app in ./#{name}"
1856
+ puts ''
1857
+ puts ' cd ' + name
1858
+ puts ' jimmu server'
1859
+ puts ''
1860
+ end
1861
+
1862
+ def self.write_file(path, content)
1863
+ File.write(path, content, encoding: Encoding::UTF_8)
1864
+ end
1865
+
1866
+ def self.cmd_server(args)
1867
+ forced_port = nil
1868
+ forced_host = nil
1869
+
1870
+ parser = OptionParser.new do |opts|
1871
+ opts.banner = 'Usage: jimmu server [options]'
1872
+ opts.on('-p PORT', '--port PORT', 'Port to listen on') { |v| forced_port = v }
1873
+ opts.on('-h HOST', '--host HOST', 'Host/IP to bind to') { |v| forced_host = v }
1874
+ end
1875
+
1876
+ begin
1877
+ parser.parse!(args)
1878
+ rescue OptionParser::ParseError => e
1879
+ warn "Error: #{e.message}"
1880
+ exit(1)
1881
+ end
1882
+
1883
+ app_rb_path = File.expand_path('app.rb', Dir.pwd)
1884
+ unless File.file?(app_rb_path)
1885
+ warn "Error: no app.rb found in the current directory (#{Dir.pwd})."
1886
+ warn 'Run `jimmu new <name>` to create a new project, or `cd` into an existing one.'
1887
+ exit(1)
1888
+ end
1889
+
1890
+ app = Jimmu::Application.new(Dir.pwd)
1891
+ app.force_port!(forced_port) if forced_port
1892
+ app.force_host!(forced_host) if forced_host
1893
+
1894
+ begin
1895
+ app.load_app_file(app_rb_path)
1896
+ unless app.run_called?
1897
+ app.log('app.rb did not call `run` -- starting the server automatically.')
1898
+ app.run
1899
+ end
1900
+ rescue Jimmu::ConfigError => e
1901
+ warn "Error: #{e.message}"
1902
+ exit(1)
1903
+ rescue Jimmu::DatabaseError => e
1904
+ warn "Database error: #{e.message}"
1905
+ exit(1)
1906
+ rescue Interrupt
1907
+ puts "\nShutting down..."
1908
+ exit(0)
1909
+ rescue Exception => e
1910
+ warn "Error loading app.rb: #{e.class}: #{e.message}"
1911
+ (e.backtrace || []).first(15).each { |l| warn " #{l}" }
1912
+ exit(1)
1913
+ end
1914
+ end
1915
+ end
1916
+ end