speedscope/serveit

228 lines
6.3 KiB
Ruby
Executable File

#!/usr/bin/env ruby
# Copyright (c) 2015 Gary Bernhardt
#
# MIT License
#
# Permission is hereby granted, free of charge, to any person obtaining
# a copy of this software and associated documentation files (the
# "Software"), to deal in the Software without restriction, including
# without limitation the rights to use, copy, modify, merge, publish,
# distribute, sublicense, and/or sell copies of the Software, and to
# permit persons to whom the Software is furnished to do so, subject to
# the following conditions:
#
# The above copyright notice and this permission notice shall be
# included in all copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
# LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
# OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
# WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
require "find"
require "webrick"
require "open3"
require "optparse"
class ServeIt
VERSION = [0, 0, 2]
def self.main
serve_dir, ignored_paths, command = parse_opts
serve_dir = File.expand_path(serve_dir)
Server.new(serve_dir, ignored_paths, command).serve
end
def self.parse_opts
options = {:serve_dir => ".",
:ignored_paths => []}
parser = OptionParser.new do |opts|
opts.banner = "Usage: #{$PROGRAM_NAME} [options] command"
opts.on_tail("-s", "--serve-dir DIR", "Root directory for server") do |dir|
options[:serve_dir] = dir
end
opts.on_tail("-i", "--ignore PATH", "Ignore changes to file or directory") do |path|
options[:ignored_paths] << path
end
opts.on_tail("--version", "Show version") do |dir|
puts ServeIt::VERSION.join('.')
exit
end
end
begin
parser.parse!(ARGV)
rescue OptionParser::InvalidOption => e
$stderr.puts e
$stderr.puts parser
exit 1
end
if ARGV.count == 0
command = nil
elsif ARGV.count == 1
command = ARGV.fetch(0)
else
$stderr.write parser.to_s
exit 1
end
[options.fetch(:serve_dir), options.fetch(:ignored_paths), command]
end
class Server
def initialize(serve_dir, ignored_paths, command)
@mutex = Mutex.new
@serve_dir = serve_dir
@command = command
@rebuilder = Rebuilder.new(@command, ignored_paths) if @command
end
def serve
port = 8000
puts "Starting server at http://localhost:#{port}"
server = WEBrick::HTTPServer.new(:Port => port)
server.mount_proc '/' do |req, res|
relative_path = req.path.sub(/^\//, '')
local_abs_path = File.absolute_path(relative_path, @serve_dir)
if relative_path == "favicon.ico"
respond_to_favicon(res)
else
respond_to_path(res, relative_path, local_abs_path)
end
end
trap 'INT' do server.shutdown end
server.start
end
def respond_to_favicon(res)
res.status = 404
end
def respond_to_path(res, relative_path, local_abs_path)
begin
rebuild_if_needed
rescue Rebuilder::RebuildFailed => e
return respond_to_error(res, e.to_s)
end
if File.directory?(local_abs_path)
respond_to_dir(res, relative_path, local_abs_path)
else
# We're building a file
respond_to_file(res, local_abs_path)
end
end
def respond_to_error(res, message)
res.content_type = "text/html"
res.body = "<pre>" + message + "</pre>"
end
def respond_to_dir(res, rel_path, local_abs_path)
res.content_type = "text/html"
res.body = (
"<p><h3>Listing for /#{rel_path}</h3></p>\n" +
Dir.entries(local_abs_path).select do |child|
child != "."
end.sort.map do |child|
full_child_path_on_server = File.join("/", rel_path, child)
%{<a href="#{full_child_path_on_server}">#{child}</a><br>}
end.join("\n")
)
end
def respond_to_file(res, local_abs_path)
res.body = File.read(local_abs_path)
res.content_type = guess_content_type(local_abs_path)
end
def guess_content_type(path)
extension = File.extname(path).sub(/^\./, '')
WEBrick::HTTPUtils::DefaultMimeTypes.fetch(extension) do
"application/octet-stream"
end
end
def rebuild_if_needed
# Webrick is multi-threaded; guard against concurrent builds
@mutex.synchronize do
if @rebuilder
@rebuilder.rebuild_if_needed
end
end
end
end
class Rebuilder
def initialize(command, ignored_paths)
@command = command
@ignored_paths = ignored_paths
@last_disk_state = nil
end
def rebuild_if_needed
if disk_state != @last_disk_state
stdout_and_stderr, success = rebuild
if !success
message = "Failed to build! Command output:\n\n" + stdout_and_stderr
raise RebuildFailed.new(message)
end
# Get a new post-build disk state so we don't pick up changes made during
# the build.
@last_disk_state = disk_state
[stdout_and_stderr, success]
end
end
def rebuild
puts "Running command: #{@command}"
puts " begin build".rjust(80, "=")
start_time = Time.now
stdout_and_stderr, status = Open3.capture2e(@command)
print stdout_and_stderr
puts (" built in %.03fs" % (Time.now - start_time)).rjust(80, "=")
[stdout_and_stderr, status.success?]
end
def disk_state
start_time = Time.now
paths = []
Find.find(".") do |path|
if ignore_path?(path)
Find.prune
else
paths << path
end
end
paths.map do |path|
[path, File.stat(path).mtime.to_s]
end.sort.tap do
puts (" scanned in %.03fs" % (Time.now - start_time)).rjust(80, "=")
end
end
def ignore_path?(path)
@ignored_paths.any? do |ignored_path|
File.absolute_path(path) == File.absolute_path(ignored_path)
end
end
class RebuildFailed < RuntimeError; end
end
end
if __FILE__ == $PROGRAM_NAME
ServeIt.main
end