diff --git a/bin/serveit b/bin/serveit new file mode 100755 index 0000000..77f4f37 --- /dev/null +++ b/bin/serveit @@ -0,0 +1,203 @@ +#!/usr/bin/env ruby + +require "find" +require "webrick" +require "open3" +require "optparse" + +class ServeIt + VERSION = [0, 0, 1] + 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 = "
" + message + "
" + end + + def respond_to_dir(res, rel_path, local_abs_path) + res.content_type = "text/html" + res.body = ( + "

Listing for /#{rel_path}

\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) + %{#{child}
} + 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