Module: Ruby2D::CLI::StaticServer

Defined in:
lib/ruby2d/cli/static_server.rb

Constant Summary collapse

CONTENT_TYPES =
{
  '.html'  => 'text/html; charset=utf-8',
  '.htm'   => 'text/html; charset=utf-8',
  '.js'    => 'text/javascript; charset=utf-8',
  '.mjs'   => 'text/javascript; charset=utf-8',
  '.wasm'  => 'application/wasm',
  '.json'  => 'application/json',
  '.css'   => 'text/css; charset=utf-8',
  '.data'  => 'application/octet-stream',
  '.png'   => 'image/png',
  '.jpg'   => 'image/jpeg',
  '.jpeg'  => 'image/jpeg',
  '.gif'   => 'image/gif',
  '.svg'   => 'image/svg+xml',
  '.ico'   => 'image/x-icon',
  '.wav'   => 'audio/wav',
  '.mp3'   => 'audio/mpeg',
  '.ogg'   => 'audio/ogg',
  '.ttf'   => 'font/ttf',
  '.woff'  => 'font/woff',
  '.woff2' => 'font/woff2',
  '.txt'   => 'text/plain; charset=utf-8'
}.freeze
DEFAULT_CONTENT_TYPE =
'application/octet-stream'

Class Method Summary collapse

Class Method Details

.browser_command(url) ⇒ Object

The platform command (as an argv array) to open a URL in the browser.



151
152
153
154
155
156
157
# File 'lib/ruby2d/cli/static_server.rb', line 151

def self.browser_command(url)
  case AssetsTarget.host_os
  when 'macos'   then ['open', url]
  when 'windows' then ['cmd', '/c', 'start', '', url]
  else                ['xdg-open', url] # linux, bsd
  end
end

.content_type(path) ⇒ Object

Content-Type for a file path, by extension.



44
45
46
# File 'lib/ruby2d/cli/static_server.rb', line 44

def self.content_type(path)
  CONTENT_TYPES.fetch(File.extname(path).downcase, DEFAULT_CONTENT_TYPE)
end

.decode(str) ⇒ Object

Percent-decode a URL path (e.g. "%20" -> " ").



70
71
72
# File 'lib/ruby2d/cli/static_server.rb', line 70

def self.decode(str)
  str.gsub(/%([0-9a-fA-F]{2})/) { Regexp.last_match(1).hex.chr }
end

.handle(client, dir) ⇒ Object

Handle one HTTP connection: read the request line, serve the file or 404.



108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
# File 'lib/ruby2d/cli/static_server.rb', line 108

def self.handle(client, dir)
  request_line = client.gets
  return if request_line.nil?

  target = request_line.split(' ')[1]
  # Drain the remaining request headers up to the blank line.
  while (line = client.gets) && line != "\r\n"; end

  full = target && resolve(dir, target)
  if full
    write_response(client, 200, 'OK', content_type(full), File.binread(full))
  else
    write_response(client, 404, 'Not Found', 'text/plain; charset=utf-8', "404 Not Found\n")
  end
rescue Errno::EPIPE, Errno::ECONNRESET, IOError
  # Client disconnected mid-response — nothing to do.
ensure
  client.close rescue nil
end

.open_browser(url) ⇒ Object

Open url in the default browser, shortly after the server starts.



140
141
142
143
144
145
146
147
148
# File 'lib/ruby2d/cli/static_server.rb', line 140

def self.open_browser(url)
  cmd = browser_command(url)
  return unless cmd

  Thread.new do
    sleep 0.5
    system(*cmd)
  end
end

.resolve(root, target) ⇒ Object

Resolve an HTTP request target (e.g. "/app.html?v=1") to a real file under root. A directory request (e.g. "/") serves its index.html. Returns the absolute path, or nil if the file does not exist or the request escapes root (directory-traversal protection).



52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
# File 'lib/ruby2d/cli/static_server.rb', line 52

def self.resolve(root, target)
  path = decode(target.split(/[?#]/, 2).first.to_s)
  # A NUL byte (e.g. from `%00`) makes File.expand_path/File.file? raise
  # ArgumentError; treat such a path as a non-existent file (clean 404).
  return nil if path.include?("\u0000")

  root_real = File.expand_path(root)
  full = File.expand_path(File.join(root_real, path))
  return nil unless full == root_real || full.start_with?(root_real + File::SEPARATOR)
  # Serve the directory index for a directory request. `full` is already
  # confirmed within `root`, so the joined index stays in bounds too.
  full = File.join(full, 'index.html') if File.directory?(full)
  return nil unless File.file?(full)

  full
end

.serve(dir:, port: 8080, path: '') ⇒ Object

Serve dir over HTTP on port and open path in the browser. Blocks until interrupted with Ctrl-C.



76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
# File 'lib/ruby2d/cli/static_server.rb', line 76

def self.serve(dir:, port: 8080, path: '')
  require 'socket'
  server =
    begin
      TCPServer.new('127.0.0.1', port)
    rescue Errno::EADDRINUSE
      error "Port #{port} is already in use. Stop the other server and try again."
      exit 1
    end

  url = "http://localhost:#{port}/#{path}"
  puts "  Serving at #{url.bold}"
  puts "  #{'Press Ctrl-C to stop.'.dim}"

  open_browser(url)

  # Block in accept until Ctrl-C. Let the default SIGINT -> Interrupt
  # exception unwind the loop and close the socket in `ensure`. Closing
  # the server from a `trap` handler instead would deadlock with
  # "recursive locking" — the handler runs on this same thread while it is
  # mid-`accept`, so it would try to re-lock the socket accept already holds.
  loop do
    client = server.accept
    Thread.new(client) { |c| handle(c, dir) }
  end
rescue Interrupt
  puts # finish the "^C" line cleanly
ensure
  server&.close
end

.write_response(client, code, reason, type, body) ⇒ Object

Write a complete HTTP/1.1 response and close (no keep-alive).



129
130
131
132
133
134
135
136
137
# File 'lib/ruby2d/cli/static_server.rb', line 129

def self.write_response(client, code, reason, type, body)
  body = body.to_s
  client.write("HTTP/1.1 #{code} #{reason}\r\n")
  client.write("Content-Type: #{type}\r\n")
  client.write("Content-Length: #{body.bytesize}\r\n")
  client.write("Cache-Control: no-cache\r\n")
  client.write("Connection: close\r\n\r\n")
  client.write(body)
end