Class: Jimmu::Application

Inherits:
Object
  • Object
show all
Defined in:
lib/jimmu.rb

Overview

Application is the object app.rb runs against (via instance_eval), which is what makes bare top-level calls like port 3000, before do ... end, or run work as a DSL. It also owns the actual TCPServer accept loop and per-connection dispatch once run is called.

Instance Attribute Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(app_root) ⇒ Application

Returns a new instance of Application.



1242
1243
1244
1245
1246
1247
1248
1249
1250
1251
1252
1253
1254
1255
1256
1257
1258
1259
1260
1261
1262
1263
# File 'lib/jimmu.rb', line 1242

def initialize(app_root)
  @app_root = File.expand_path(app_root)
  @views_dir = File.join(@app_root, 'views')
  @public_dir = File.join(@app_root, 'public')
  @db_dir = File.join(@app_root, 'db')

  @port = 3000
  @host = '127.0.0.1'
  @port_forced = false
  @host_forced = false

  @before_hooks = []
  @after_hooks = []
  @websocket_routes = []
  @databases = []
  @default_db = nil

  @session_store = SessionStore.new
  @log_mutex = Mutex.new
  @run_called = false
  @server = nil
end

Instance Attribute Details

#app_rootObject (readonly)

Returns the value of attribute app_root.



1240
1241
1242
# File 'lib/jimmu.rb', line 1240

def app_root
  @app_root
end

#db_dirObject (readonly)

Returns the value of attribute db_dir.



1240
1241
1242
# File 'lib/jimmu.rb', line 1240

def db_dir
  @db_dir
end

#public_dirObject (readonly)

Returns the value of attribute public_dir.



1240
1241
1242
# File 'lib/jimmu.rb', line 1240

def public_dir
  @public_dir
end

#views_dirObject (readonly)

Returns the value of attribute views_dir.



1240
1241
1242
# File 'lib/jimmu.rb', line 1240

def views_dir
  @views_dir
end

Instance Method Details

#after(&block) ⇒ Object

Raises:

  • (ArgumentError)


1314
1315
1316
1317
1318
# File 'lib/jimmu.rb', line 1314

def after(&block)
  raise ArgumentError, 'after requires a block' unless block
  @after_hooks << block
  nil
end

#before(&block) ⇒ Object

Raises:

  • (ArgumentError)


1308
1309
1310
1311
1312
# File 'lib/jimmu.rb', line 1308

def before(&block)
  raise ArgumentError, 'before requires a block' unless block
  @before_hooks << block
  nil
end

#default_dbObject



1304
1305
1306
# File 'lib/jimmu.rb', line 1304

def default_db
  @default_db
end

#dispatch(ctx, request) ⇒ Object



1600
1601
1602
1603
1604
1605
1606
1607
1608
1609
1610
1611
1612
1613
1614
1615
1616
1617
1618
1619
1620
1621
1622
1623
1624
1625
# File 'lib/jimmu.rb', line 1600

def dispatch(ctx, request)
  if %w[GET HEAD].include?(request.http_method)
    static_path = Router.resolve_static(@public_dir, request.path)
    if static_path
      ctx.serve_static_file(static_path)
      return
    end
  end

  if find_websocket_route(request.path)
    ctx.status(426)
    ctx.text('Upgrade Required: this endpoint only accepts WebSocket connections.')
    return
  end

  match = Router.resolve_view(@views_dir, request.path)
  if match
    file_path, route_params = match
    request.route_params = route_params
    ctx.response_body = ctx.render_file(file_path)
    ctx.response_headers['Content-Type'] ||= 'text/html; charset=utf-8'
  else
    ctx.response_body = ctx.render_error_page(404)
    ctx.response_headers['Content-Type'] ||= 'text/html; charset=utf-8'
  end
end

#display_hostObject



1411
1412
1413
# File 'lib/jimmu.rb', line 1411

def display_host
  @host == '0.0.0.0' ? 'localhost' : @host
end

#find_websocket_route(path) ⇒ Object



1513
1514
1515
1516
1517
1518
1519
# File 'lib/jimmu.rb', line 1513

def find_websocket_route(path)
  @websocket_routes.each do |pattern, block|
    params = Router.match_pattern(pattern, path)
    return [block, params] if params
  end
  nil
end

#force_host!(value) ⇒ Object



1288
1289
1290
1291
# File 'lib/jimmu.rb', line 1288

def force_host!(value)
  @host = value.to_s
  @host_forced = true
end

#force_port!(value) ⇒ Object

Used internally by the CLI's -p/-h flags, which always win over whatever app.rb itself requests.



1283
1284
1285
1286
# File 'lib/jimmu.rb', line 1283

def force_port!(value)
  @port = value.to_i
  @port_forced = true
end

#handle_connection(socket) ⇒ Object

Per-connection handling =================


1417
1418
1419
1420
1421
1422
1423
1424
1425
1426
1427
1428
1429
1430
1431
1432
1433
1434
1435
1436
1437
1438
1439
1440
1441
# File 'lib/jimmu.rb', line 1417

def handle_connection(socket)
  socket.setsockopt(Socket::IPPROTO_TCP, Socket::TCP_NODELAY, 1) rescue nil
  socket.set_encoding(Encoding::BINARY)

  request = parse_request(socket)
  return if request.nil?

  if websocket_upgrade_request?(request)
    route = find_websocket_route(request.path)
    if route
      handle_websocket_upgrade(socket, request, route)
      return
    end
  end

  handle_http_request(socket, request)
rescue Exception => e
  log("connection error: #{e.class}: #{e.message}", :error)
ensure
  begin
    socket.close unless socket.closed?
  rescue IOError
    nil
  end
end

#handle_http_request(socket, request) ⇒ Object



1560
1561
1562
1563
1564
1565
1566
1567
1568
1569
1570
1571
1572
1573
1574
1575
1576
1577
1578
1579
1580
1581
1582
1583
1584
1585
1586
1587
1588
1589
1590
# File 'lib/jimmu.rb', line 1560

def handle_http_request(socket, request)
  start_time = Time.now
  cookie_jar = CookieJar.new(request.header('cookie'))
  session_id, session_hash, = @session_store.fetch_or_create(cookie_jar[SessionStore::COOKIE_NAME])
  ctx = Context.new(self, request, cookie_jar, session_id, session_hash, false)

  begin
    catch(HALT) do
      run_before_hooks(ctx)
      dispatch(ctx, request)
    end
    run_after_hooks(ctx)
  rescue Exception => e
    log("#{request.http_method} #{request.path} -> 500 #{e.class}: #{e.message}", :error)
    (e.backtrace || []).each { |line| log("    #{line}", :error) }
    ctx.error = e
    ctx.response_body = ctx.render_error_page(500)
    ctx.response_headers['Content-Type'] ||= 'text/html; charset=utf-8'
    begin
      run_after_hooks(ctx)
    rescue Exception => e2
      log("error in after-hook while handling 500: #{e2.class}: #{e2.message}", :error)
    end
  end

  cookie_jar.set(SessionStore::COOKIE_NAME, session_id, http_only: true, max_age: SessionStore::DEFAULT_MAX_AGE)
  send_response(socket, ctx, request)

  duration_ms = ((Time.now - start_time) * 1000).round(1)
  log("#{request.http_method} #{request.path} -> #{ctx.status_code} (#{ctx.response_body.to_s.bytesize}B, #{duration_ms}ms) [#{request.ip}]")
end

#handle_websocket_upgrade(socket, request, route) ⇒ Object



1521
1522
1523
1524
1525
1526
1527
1528
1529
1530
1531
1532
1533
1534
1535
1536
1537
1538
1539
1540
1541
1542
1543
1544
1545
1546
1547
1548
1549
1550
1551
1552
1553
1554
1555
1556
1557
1558
# File 'lib/jimmu.rb', line 1521

def handle_websocket_upgrade(socket, request, route)
  block, route_params = route
  key = request.header('sec-websocket-key')
  unless key
    write_simple_response(socket, 400, 'Bad Request', 'Missing Sec-WebSocket-Key header.')
    return
  end

  cookie_jar = CookieJar.new(request.header('cookie'))
  session_id, session_hash, = @session_store.fetch_or_create(cookie_jar[SessionStore::COOKIE_NAME])
  cookie_jar.set(SessionStore::COOKIE_NAME, session_id, http_only: true, max_age: SessionStore::DEFAULT_MAX_AGE)
  request.route_params = route_params

  accept = WebSocketProtocol.accept_key(key)
  out = +''.b
  out << "HTTP/1.1 101 Switching Protocols\r\n"
  out << "Upgrade: websocket\r\n"
  out << "Connection: Upgrade\r\n"
  out << "Sec-WebSocket-Accept: #{accept}\r\n"
  cookie_jar.response_header_values.each { |v| out << "Set-Cookie: #{v}\r\n" }
  out << "\r\n"
  socket.write(out)

  ws = WebSocketConnection.new(self, socket, request, route_params, cookie_jar, session_hash)
  log("WS   #{request.path} connected [#{request.ip}]")

  begin
    ws.instance_exec(&block)
  rescue Exception => e
    log("websocket setup error on #{request.path}: #{e.class}: #{e.message}", :error)
    (e.backtrace || []).first(10).each { |l| log("    #{l}", :error) }
    ws.close rescue nil
    return
  end

  ws.run_loop
  log("WS   #{request.path} disconnected [#{request.ip}]")
end

#host(value = nil) ⇒ Object



1275
1276
1277
1278
1279
# File 'lib/jimmu.rb', line 1275

def host(value = nil)
  return @host if value.nil?
  @host = value.to_s unless @host_forced
  nil
end

#load_app_file(path) ⇒ Object

App loading =================

Raises:



1349
1350
1351
1352
1353
1354
1355
1356
1357
1358
1359
1360
1361
1362
1363
# File 'lib/jimmu.rb', line 1349

def load_app_file(path)
  raise ConfigError, "app.rb not found at #{path}" unless File.file?(path)
  # Deliberately `load` rather than `instance_eval` a string: Ruby
  # scopes `def` / `module` / `class` / constants written inside
  # instance_eval'd source to the receiver's singleton class, which
  # would make helper methods, modules, and constants that app.rb
  # defines invisible from views (Context runs its own bindings) or
  # from before/after/websocket blocks running against a different
  # object. Loading the file normally puts them at the true,
  # ordinary Ruby top level, and Jimmu.application (see the DSL
  # module below) is what makes bare `port 3000`-style calls still
  # work despite that.
  Jimmu.application = self
  load(path)
end

#log(message, level = :info) ⇒ Object



1329
1330
1331
1332
1333
1334
1335
1336
# File 'lib/jimmu.rb', line 1329

def log(message, level = :info)
  @log_mutex.synchronize do
    ts = Time.now.strftime('%Y-%m-%d %H:%M:%S.%L')
    $stdout.puts "[#{ts}] #{level.to_s.upcase.ljust(5)} #{message}"
    $stdout.flush
  end
  nil
end

#parse_request(socket) ⇒ Object



1443
1444
1445
1446
1447
1448
1449
1450
1451
1452
1453
1454
1455
1456
1457
1458
1459
1460
1461
1462
1463
1464
1465
1466
1467
1468
1469
1470
1471
1472
1473
1474
1475
1476
1477
1478
1479
1480
1481
1482
1483
1484
1485
1486
1487
1488
1489
1490
1491
1492
1493
1494
1495
1496
1497
# File 'lib/jimmu.rb', line 1443

def parse_request(socket)
  request_line = read_line(socket)
  return nil if request_line.nil? || request_line.empty?

  parts = request_line.split(' ')
  return nil if parts.length < 2
  http_method, target = parts[0], parts[1]

  raw_path, _, query = target.partition('?')
  path = Jimmu.percent_decode(raw_path).dup.force_encoding(Encoding::UTF_8)
  path = path.valid_encoding? ? path : path.scrub
  path = '/' if path.empty?

  headers = {}
  loop do
    line = read_line(socket)
    break if line.nil? || line.empty?
    k, v = line.split(':', 2)
    next unless k && v
    headers[k.strip.downcase] = v.strip
  end

  body = ''.b
  content_length = headers['content-length'].to_i
  if content_length.positive?
    return nil if content_length > MAX_BODY_SIZE
    remaining = content_length
    chunks = []
    while remaining.positive?
      chunk = socket.read([remaining, 65536].min)
      break if chunk.nil?
      chunks << chunk
      remaining -= chunk.bytesize
    end
    body = chunks.join.b
  end

  peer_ip =
    begin
      socket.peeraddr(false)[3]
    rescue IOError, Errno::ENOTCONN, SocketError
      'unknown'
    end

  Request.new(
    http_method: http_method.to_s.upcase,
    path: path,
    query_string: query.to_s,
    headers: headers,
    body: body,
    ip: peer_ip
  )
rescue IOError, Errno::ECONNRESET, Errno::ETIMEDOUT
  nil
end

#port(value = nil) ⇒ Object

port 8080 sets it (unless overridden with -p on the command line); port with no argument reads the current value.



1269
1270
1271
1272
1273
# File 'lib/jimmu.rb', line 1269

def port(value = nil)
  return @port if value.nil?
  @port = value.to_i unless @port_forced
  nil
end

#read_line(socket) ⇒ Object



1499
1500
1501
1502
1503
1504
1505
# File 'lib/jimmu.rb', line 1499

def read_line(socket)
  line = socket.gets("\n")
  return nil if line.nil?
  line.sub(/\r?\n\z/, '')
rescue IOError, Errno::ECONNRESET
  nil
end

#runObject



1338
1339
1340
1341
# File 'lib/jimmu.rb', line 1338

def run
  @run_called = true
  start_server
end

#run_after_hooks(ctx) ⇒ Object



1596
1597
1598
# File 'lib/jimmu.rb', line 1596

def run_after_hooks(ctx)
  @after_hooks.each { |hook| ctx.instance_exec(&hook) }
end

#run_before_hooks(ctx) ⇒ Object



1592
1593
1594
# File 'lib/jimmu.rb', line 1592

def run_before_hooks(ctx)
  @before_hooks.each { |hook| ctx.instance_exec(&hook) }
end

#run_called?Boolean

Returns:

  • (Boolean)


1343
1344
1345
# File 'lib/jimmu.rb', line 1343

def run_called?
  @run_called
end

#send_response(socket, ctx, request) ⇒ Object



1627
1628
1629
1630
1631
1632
1633
1634
1635
1636
1637
1638
1639
1640
1641
1642
1643
1644
1645
1646
# File 'lib/jimmu.rb', line 1627

def send_response(socket, ctx, request)
  body_bytes = ctx.response_body.to_s.dup.force_encoding(Encoding::BINARY)

  headers = ctx.response_headers.dup
  headers['Content-Type'] ||= 'text/html; charset=utf-8'
  headers['Content-Length'] = body_bytes.bytesize.to_s
  headers['Connection'] = 'close'
  headers['Server'] = "Jimmu/#{VERSION}"

  out = +''.b
  out << "HTTP/1.1 #{ctx.status_code} #{Jimmu.status_phrase(ctx.status_code)}\r\n"
  headers.each { |k, v| out << "#{k}: #{v}\r\n" }
  ctx.cookie.response_header_values.each { |v| out << "Set-Cookie: #{v}\r\n" }
  out << "\r\n"
  out << body_bytes unless request.http_method == 'HEAD'

  socket.write(out)
rescue IOError, Errno::EPIPE, Errno::ECONNRESET
  nil
end

#sqlite(path) ⇒ Object

db = sqlite "db/app.db" -- opens (creating if needed) a SQLite database, relative to the app root. The most recently opened connection becomes the implicit db available inside views.



1296
1297
1298
1299
1300
1301
1302
# File 'lib/jimmu.rb', line 1296

def sqlite(path)
  full_path = File.expand_path(path, @app_root)
  database = Database.new(full_path)
  @databases << database
  @default_db = database
  database
end

#start_serverObject

Server =================


1367
1368
1369
1370
1371
1372
1373
1374
1375
1376
1377
1378
1379
1380
1381
1382
1383
1384
1385
1386
1387
1388
1389
1390
1391
1392
1393
1394
1395
1396
1397
1398
1399
1400
1401
1402
1403
1404
1405
1406
1407
1408
1409
# File 'lib/jimmu.rb', line 1367

def start_server
  begin
    @server = TCPServer.new(@host, @port)
  rescue Errno::EADDRINUSE
    raise ConfigError, "Port #{@port} is already in use. Try `jimmu server -p <port>` with a different port."
  rescue Errno::EACCES
    raise ConfigError, "Permission denied binding to #{@host}:#{@port}. Ports below 1024 usually need elevated privileges -- try a port like 3000 or 8080."
  end

  log("Jimmu #{VERSION} listening on http://#{display_host}:#{@port}  (app: #{@app_root})")
  log('Press Ctrl+C to stop.')

  # Signal handlers run in a restricted "trap context" where
  # blocking operations (Mutex#synchronize, which our #log uses)
  # are not allowed. So the handler itself does nothing but raise
  # Interrupt back into the accepting thread; all real cleanup
  # (closing databases, logging, closing the socket) happens down
  # in the rescue/ensure below, in ordinary thread context.
  trap('INT') { raise Interrupt }
  trap('TERM') { raise Interrupt }

  begin
    loop do
      client =
        begin
          @server.accept
        rescue IOError, Errno::EBADF
          break
        end
      Thread.new(client) { |socket| handle_connection(socket) }
    end
  rescue Interrupt
    nil
  ensure
    log('Shutting down...')
    @databases.each { |d| d.close rescue nil }
    begin
      @server.close
    rescue IOError
      nil
    end
  end
end

#websocket(path, &block) ⇒ Object

websocket "/room/[name]" do ... end -- the block runs with self bound to a WebSocketConnection (see WebSocketConnection); register on_message / on_close from inside it.

Raises:

  • (ArgumentError)


1323
1324
1325
1326
1327
# File 'lib/jimmu.rb', line 1323

def websocket(path, &block)
  raise ArgumentError, 'websocket requires a block' unless block
  @websocket_routes << [path.to_s, block]
  nil
end

#websocket_upgrade_request?(request) ⇒ Boolean

Returns:

  • (Boolean)


1507
1508
1509
1510
1511
# File 'lib/jimmu.rb', line 1507

def websocket_upgrade_request?(request)
  upgrade = request.header('upgrade').to_s.downcase
  connection = request.header('connection').to_s.downcase
  upgrade == 'websocket' && connection.include?('upgrade')
end

#write_simple_response(socket, code, title, message) ⇒ Object



1648
1649
1650
1651
1652
1653
1654
1655
1656
1657
1658
1659
# File 'lib/jimmu.rb', line 1648

def write_simple_response(socket, code, title, message)
  body = message.to_s.b
  out = +''.b
  out << "HTTP/1.1 #{code} #{title}\r\n"
  out << "Content-Type: text/plain; charset=utf-8\r\n"
  out << "Content-Length: #{body.bytesize}\r\n"
  out << "Connection: close\r\n\r\n"
  out << body
  socket.write(out)
rescue IOError
  nil
end