Http Application
The main application spawns two child processes: a local web server and a web browser instance.
In an ideal world, the application would wait for both processes to exit, and perhaps offer to restart one or the other in the event of a crash. Because of the way modern browsers work -- opening URLs in an existing process instead of starting a new one -- this has to be simplified to waiting only for the web server process to exit, and hoping that the browser process can successfully manage things.
The HttpApplication class provides a run() method that starts a webserver on localhost using the first available port, then opens a browser window/tab connected to the webserver:
class HttpApplication |
def run(host=nil, port=nil, browser=nil, path=nil) |
@server = ServerProcess.new(host, port, @controller) |
@server.start |
uri = server.uri.dup |
if path |
path = '/' + path if (! path.start_with? '/') |
uri.path = path |
end |
Browser.open(uri, browser) |
Process.waitpid(@server.pid) |
end |
end |
When the webserver process exits, the application will exit. Note that this means the webapp itself must close the webserver process, either through an explicit signout, or through a Javascript on_close or on_unload event handler.
ServerProcess
The webserver process is simply a Webrick instance with a servlet acting as a controller. This is pretty straightforward: the only interesting part is detecting the next available port so that it can be passed to the Webrick constructor.
require 'uri' |
require 'webrick' |
class ServerProcess |
attr_reader :host, :port, :controller, :pid, :uri, :webrick |
def initialize(host=nil, port=nil, controller=nil) |
@port = port || get_avail_port(host) |
@host = host || IPSocket.getaddress(Socket.gethostname) |
@controller = controller |
end |
def start |
@pid = Process.fork do |
@webrick = WEBrick::HTTPServer.new( :Port => @port ) |
@webrick.mount('/', @controller) if @controller |
trap('HUP') { @webrick.stop; @webrick.start } |
trap('INT') { @webrick.shutdown } |
@webrick.start |
end |
trap('INT') { Process.kill 'INT', @pid } |
trap('TERM') { Process.kill 'INT', @pid } |
@uri = URI::HTTP.build( {:host => @host, :port => @port} ) |
self |
end |
def stop |
@webrick.shutdown if @webrick |
Process.kill('INT', @pid) if @pid |
end |
private |
def get_avail_port(host) |
host ||= (Socket::gethostbyname('')||['localhost'])[0] |
inf = Socket::getaddrinfo(host, nil, Socket::AF_UNSPEC, |
Socket::SOCK_STREAM, 0, Socket::AI_PASSIVE) |
fam = inf.inject({}) { |h, arr| h[arr[0]]= arr[2]; h } |
sock_host = fam['AF_INET'] || fam['AF_INET6'] |
sock = sock_host ? TCPServer.open(sock_host, 0) : TCPServer.open(0) |
port = sock.addr[1] |
sock.close |
port |
end |
end |
Browser
The browser process is a simple launcher that opens a URL in a browser instance , in what is hopefully a platform-independent manner. One could use the launchy gem or some other suitably over-engineered solution, but there's really not much need to support more than Windows, OS X, and Linux. Let's face it, anyone who doesn't have xdg-open installed probably doesn't want to run your webserver-based app on their ideologically-pure system.
require 'shellwords' |
class Browser |
if RUBY_PLATFORM =~ /darwin/ |
URL_OPEN_COMMAND = 'open' |
elsif RUBY_PLATFORM =~ /linux/ or RUBY_PLATFORM =~ /bsd/ |
URL_OPEN_COMMAND = 'xdg-open' |
else |
# assume windows |
URL_OPEN_COMMAND = 'start' |
end |
def self.open(uri, cmd=nil) |
pid = Process.fork do |
uri_s = Shellwords.shellescape uri.to_s |
`#{Shellwords.shellescape(cmd || URL_OPEN_COMMAND)} #{uri_s}` |
end |
Process.detach(pid) |
end |
end |
RestContoller
The meat of the UI is in the controller object which HttpApplication uses to handle webserver routes. This particular one is based on Webrick's AbstractServlet class, and uses regex patterns to determine the handler for a particular route.
Each route consists of a pattern and a block. The pattern is used to match the URL; for best results, the ^ and $ anchors should be used, and the URL path should be absolute (i.e., starting with '/').
The block receives three arguments: query, request, and server. The query is a Hash extracted from the Webrick HTTPRequest object, the request is the complete HTTPRequest object, and the server is the Webrick HTTPServer object for the webserver (used mainly to shutdown the server). The block must return either the response body or an array [body, content_type, status]; these return values will be written to the Webrick HTTPResponse object.
class RestController < WEBrick::HTTPServlet::AbstractServlet |
DEFAULT_STATUS = 200 |
DEFAULT_CONTENT_TYPE = 'text/html' |
DEFAULT_BODY = '404 : not found' # this should really be html |
@routes = [] |
def self.route(pat, &block) |
@routes << [pat, Proc.new(&block)] |
end |
def self.routes; @routes; end |
def initialize(server) |
@webrick = server |
super server |
end |
def fill_response(response, body, content_type=DEFAULT_CONTENT_TYPE, status=DEFAULT_STATUS) |
response.status = status |
response['Content-Type'] = content_type |
response.body = body |
response |
end |
def route_request(request) |
content_type = DEFAULT_CONTENT_TYPE |
status = 404 |
body = DEFAULT_BODY |
self.class.routes.each do |pat, proc_obj| |
if request.path =~ pat |
arr = [ proc_obj.call(request.query, request, @webrick) ].flatten |
body = arr[0] |
content_type = arr[1] || DEFAULT_CONTENT_TYPE |
status = arr[2] || DEFAULT_STATUS |
break |
end |
end |
[body, content_type, status] |
end |
def do_GET(request, response) |
body, content_type, status = route_request(request) |
fill_response(response, body, content_type, status) |
end |
def do_POST(request, response) |
body, content_type, status = route_request(request) |
fill_response(response, body, content_type, status) |
end |
end |
Example
Here's a quick example that defines a few handlers.
|
The content returned by the routes is all plaintext because hey, the blogger interface sucks for entering < and > characters.
Note the /quit handler: this is necessary to shutdown the webserver process (otherwise it will continue to run in the background). If you are certain your users permit Javascript, you can have an event handler hit this URL when the user closes the browser tab/window.
An alternative is to have the web server manage a PID file, so that subsequent invocations of the application will connect to the webserver process running in the background (much like the browsers that forced us into this mess).
As usual, the code is available on github.