├── .gitignore ├── LICENSE.txt ├── bin └── release ├── README.md └── serveit /.gitignore: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | Copyright (c) 2015 Gary Bernhardt 2 | 3 | MIT License 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining 6 | a copy of this software and associated documentation files (the 7 | "Software"), to deal in the Software without restriction, including 8 | without limitation the rights to use, copy, modify, merge, publish, 9 | distribute, sublicense, and/or sell copies of the Software, and to 10 | permit persons to whom the Software is furnished to do so, subject to 11 | the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be 14 | included in all copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 17 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 18 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 19 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 20 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 21 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 22 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 23 | -------------------------------------------------------------------------------- /bin/release: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | 3 | def main 4 | ensure_repo_is_clean 5 | version = get_version 6 | update_source_version(version) 7 | commit(version) 8 | tag(version) 9 | end 10 | 11 | def ensure_repo_is_clean 12 | status = `git status -s` or raise "git status failed" 13 | repo_is_clean = status.strip.empty? 14 | 15 | unless repo_is_clean 16 | raise "cowardly refusing to release in a dirty repo" 17 | end 18 | end 19 | 20 | def get_version 21 | filename = ARGV.fetch(0).strip 22 | version_string = ARGV.fetch(1).strip 23 | components = version_string.split(/\./) 24 | Version.new(filename, version_string, components) 25 | end 26 | 27 | class Version < Struct.new(:filename, :string, :components) 28 | end 29 | 30 | def update_source_version(version) 31 | source = File.readlines(version.filename).map(&:rstrip) 32 | updated_source = source.map do |line| 33 | if line =~ /^(\s+)VERSION =/ 34 | indentation = $1 35 | indentation + "VERSION = [" + version.components.join(", ") + "]" 36 | else 37 | line 38 | end 39 | end.join("\n") + "\n" 40 | 41 | File.write(version.filename, updated_source) 42 | end 43 | 44 | def commit(version) 45 | system("git add --all") or raise "git add failed" 46 | system("git commit -m 'update version to #{version.string}'") or raise "git commit failed" 47 | end 48 | 49 | def tag(version) 50 | tag = "v" + version.string 51 | system("git tag #{tag}") or raise "git tag failed" 52 | system("git show #{tag}") or raise "git show failed" 53 | end 54 | 55 | main 56 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # ServeIt 2 | 3 | ServeIt is a tool for previewing content in a browser, automatically rebuilding it when things change. 4 | It replaces tools like [guard](https://github.com/guard/guard) that do asynchronous rebuilds when files change. 5 | 6 | ServeIt is strictly synchronous: it performs builds when requests come in, blocking the request until the build completes. 7 | This means that the served content will never be stale or inconsistent, as it can be with asynchronous tools. 8 | 9 | You can use ServeIt to preview a markdown file as you write it, to serve your blog as you write a post, to review a book as you edit it, etc. 10 | 11 | ## Usage 12 | 13 | To serve the current directory at `http://localhost:8000` as a purely static site, including a simple directory listing, run: 14 | 15 | ``` 16 | $ serveit 17 | ``` 18 | 19 | That's useful, but most content isn't static. 20 | I'm using the command below to preview this README as I write it. 21 | Whenever I refresh the page, ServeIt runs `multimarkdown` to regenerate the HTML: 22 | 23 | ``` 24 | $ serveit 'multimarkdown README.md > README.html' 25 | ``` 26 | 27 | (You should try this yourself; it will make ServeIt's purpose and behavior much more clear than simply reading.) 28 | 29 | When I go to `localhost:8000`, the `multimarkdown` command will be run, then I get a simple directory listing: 30 | 31 | >

Listing for /

32 | > ..
33 | > .git
34 | > .gitignore
35 | > README.html
36 | > README.md
37 | > serveit
38 | 39 | If I click on `README.html`, I see this README file rendered as HTML. 40 | When I load or reload any page, ServeIt will check for changes to any files in its current working directory. 41 | If there was a change, *no matter what the change is*, then the command will be re-run, regenerating the HTML file. 42 | The HTTP request only completes once the build is finished. 43 | 44 | ## Theory of Operation 45 | 46 | ServeIt does exactly two things: 47 | 48 | 1. Serve static files. 49 | 2. Run a command when anything changes on disk, which may change the static files that it's serving. 50 | 51 | ServeIt works well for building more complex content, like blogs or books. 52 | However, it has no advanced concepts like dependency tracking; those are jobs for other tools. 53 | 54 | You can use whatever build tool you like -- `serveit make`, `serveit rake`, `serveit grunt`, etc., and these tools can do arbitrary build tasks. 55 | While writing this README, I'm sloppily letting ServeIt serve its own git repository directory, including detritus like the `.git` directory. 56 | When working on my book, I have a more rigorous build system. 57 | It uses `rake` rules to rebuild only chapters that have changed, and output goes into a `build` directory that doesn't contain any of the source files. 58 | 59 | ServeIt doesn't know about any details of the build. 60 | It will never be extended to know details of the build. 61 | *ServeIt does not do builds. It serves files and runs commands when files change!* 62 | 63 | ## Is It Fast Enough? 64 | 65 | ServeIt's file change tracking is naive: it simply crawls the current directory recursively, building a list of paths and their modification times. 66 | Computers are fast now; this is not a performance problem. 67 | My machine takes about 30 ms to check for changes in a directory of 10,000 files. 68 | If your blog has 1,000,000 files in it, ServeIt will be slow. 69 | Don't do that. 70 | 71 | ServeIt adds a trivial amount of overhead to requests -- generally less than a millisecond. 72 | If the build becomes annoyingly slow, that's a build tool problem that can be solved with incremental builds using `make`, `rake`, or whatever tool you like. 73 | Or, even better, it can be solved by reducing the complexity of the build. 74 | Reducing build complexity is usually more effective and reliable than increasing complexity by adding subtle incremental build rules. 75 | 76 | ## Options 77 | 78 | You can change ServeIt's server root with `-s`. 79 | This is useful when your build output goes into a specific directory. 80 | To build and serve my book, I run this command in the root of its repo: 81 | 82 | ``` 83 | serveit -s build rake 84 | ``` 85 | 86 | This runs `rake` in the repo root, where there's a Rakefile. 87 | When I reload, the Rakefile incrementally builds the book into the `build` directory. 88 | ServeIt is serving the `build` directory because of the `-s` argument, so I see my rendered changes upon reload. 89 | 90 | You can ignore files or directories with `-i YOUR_IGNORED_DIR`. 91 | Specify it multiple times to ignore multiple paths. 92 | 93 | ## Practicalities 94 | 95 | ServeIt requires Ruby 1.9.3 or later. 96 | To run with Ruby 3.0.0 or later, you'll need to `gem install webrick`. 97 | Or, on macOS, you can install ServeIt via `brew install serveit`. 98 | -------------------------------------------------------------------------------- /serveit: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | 3 | require "find" 4 | require "webrick" 5 | require "open3" 6 | require "optparse" 7 | 8 | class ServeIt 9 | VERSION = [0, 0, 3] 10 | 11 | def self.main 12 | serve_dir, port, ignored_paths, command = parse_opts 13 | serve_dir = File.expand_path(serve_dir) 14 | Server.new(serve_dir, port, ignored_paths, command).serve 15 | end 16 | 17 | def self.parse_opts 18 | options = {:serve_dir => ".", 19 | :ignored_paths => [], 20 | :port => 8000} 21 | parser = OptionParser.new do |opts| 22 | opts.banner = "Usage: #{$PROGRAM_NAME} [options] command" 23 | opts.on_tail("-s", "--serve-dir DIR", "Root directory for server") do |dir| 24 | options[:serve_dir] = dir 25 | end 26 | opts.on_tail("-p", "--port PORT", Integer, "TCP port number to listen on") do |port| 27 | options[:port] = port 28 | end 29 | opts.on_tail("-i", "--ignore PATH", "Ignore changes to file or directory") do |path| 30 | options[:ignored_paths] << path 31 | end 32 | opts.on_tail("--version", "Show version") do |dir| 33 | puts ServeIt::VERSION.join('.') 34 | exit 35 | end 36 | end 37 | 38 | begin 39 | parser.parse!(ARGV) 40 | rescue OptionParser::InvalidOption => e 41 | $stderr.puts e 42 | $stderr.puts parser 43 | exit 1 44 | end 45 | 46 | if ARGV.count == 0 47 | command = nil 48 | elsif ARGV.count == 1 49 | command = ARGV.fetch(0) 50 | else 51 | $stderr.write parser.to_s 52 | exit 1 53 | end 54 | 55 | [options.fetch(:serve_dir), options.fetch(:port), options.fetch(:ignored_paths), command] 56 | end 57 | 58 | class Server 59 | def initialize(serve_dir, port, ignored_paths, command) 60 | @mutex = Mutex.new 61 | @serve_dir = serve_dir 62 | @port = port 63 | @command = command 64 | @rebuilder = Rebuilder.new(@command, ignored_paths) if @command 65 | end 66 | 67 | def serve 68 | puts "Starting server at http://localhost:#{@port}" 69 | server = WEBrick::HTTPServer.new(:Port => @port) 70 | 71 | server.mount_proc '/' do |req, res| 72 | relative_path = req.path.sub(/^\//, '') 73 | local_abs_path = File.absolute_path(relative_path, @serve_dir) 74 | 75 | if relative_path == "favicon.ico" 76 | respond_to_favicon(res) 77 | else 78 | respond_to_path(res, relative_path, local_abs_path) 79 | end 80 | end 81 | 82 | trap 'INT' do server.shutdown end 83 | server.start 84 | end 85 | 86 | def respond_to_favicon(res) 87 | res.status = 404 88 | end 89 | 90 | def respond_to_path(res, relative_path, local_abs_path) 91 | begin 92 | rebuild_if_needed 93 | rescue Rebuilder::RebuildFailed => e 94 | return respond_to_error(res, e.to_s) 95 | end 96 | 97 | if File.directory?(local_abs_path) 98 | respond_to_dir(res, relative_path, local_abs_path) 99 | else 100 | # We're building a file 101 | respond_to_file(res, local_abs_path) 102 | end 103 | end 104 | 105 | def respond_to_error(res, message) 106 | res.content_type = "text/html" 107 | res.body = "
" + message + "
" 108 | end 109 | 110 | def respond_to_dir(res, rel_path, local_abs_path) 111 | res.content_type = "text/html" 112 | res.body = ( 113 | "

Listing for /#{rel_path}

\n" + 114 | Dir.entries(local_abs_path).select do |child| 115 | child != "." 116 | end.sort.map do |child| 117 | full_child_path_on_server = File.join("/", rel_path, child) 118 | %{#{child}
} 119 | end.join("\n") 120 | ) 121 | end 122 | 123 | def respond_to_file(res, local_abs_path) 124 | res.body = File.read(local_abs_path) 125 | res.content_type = guess_content_type(local_abs_path) 126 | end 127 | 128 | def guess_content_type(path) 129 | extension = File.extname(path).sub(/^\./, '') 130 | WEBrick::HTTPUtils::DefaultMimeTypes.fetch(extension) do 131 | "application/octet-stream" 132 | end 133 | end 134 | 135 | def rebuild_if_needed 136 | # Webrick is multi-threaded; guard against concurrent builds 137 | @mutex.synchronize do 138 | if @rebuilder 139 | @rebuilder.rebuild_if_needed 140 | end 141 | end 142 | end 143 | end 144 | 145 | class Rebuilder 146 | def initialize(command, ignored_paths) 147 | @command = command 148 | @ignored_paths = ignored_paths 149 | @last_disk_state = nil 150 | end 151 | 152 | def rebuild_if_needed 153 | if disk_state != @last_disk_state 154 | stdout_and_stderr, success = rebuild 155 | if !success 156 | message = "Failed to build! Command output:\n\n" + stdout_and_stderr 157 | raise RebuildFailed.new(message) 158 | end 159 | 160 | # Get a new post-build disk state so we don't pick up changes made during 161 | # the build. 162 | @last_disk_state = disk_state 163 | [stdout_and_stderr, success] 164 | end 165 | end 166 | 167 | def rebuild 168 | puts "Running command: #{@command}" 169 | puts " begin build".rjust(80, "=") 170 | start_time = Time.now 171 | stdout_and_stderr, status = Open3.capture2e(@command) 172 | print stdout_and_stderr 173 | puts (" built in %.03fs" % (Time.now - start_time)).rjust(80, "=") 174 | [stdout_and_stderr, status.success?] 175 | end 176 | 177 | def disk_state 178 | start_time = Time.now 179 | paths = [] 180 | 181 | Find.find(".") do |path| 182 | if ignore_path?(path) 183 | Find.prune 184 | elsif missing_symlink_target?(path) 185 | next 186 | else 187 | paths << path 188 | end 189 | end 190 | 191 | paths.map do |path| 192 | [path, File.stat(path).mtime.to_s] 193 | end.sort.tap do 194 | puts (" scanned in %.03fs" % (Time.now - start_time)).rjust(80, "=") 195 | end 196 | end 197 | 198 | def missing_symlink_target?(path) 199 | is_symlink = File.symlink?(path) 200 | return false unless is_symlink 201 | 202 | full_symlink_path = File.join(File.dirname(path), File.readlink(path)) 203 | return (not File.exist?(full_symlink_path)) 204 | end 205 | 206 | def ignore_path?(path) 207 | @ignored_paths.any? do |ignored_path| 208 | File.absolute_path(path) == File.absolute_path(ignored_path) 209 | end 210 | end 211 | 212 | class RebuildFailed < RuntimeError; end 213 | end 214 | end 215 | 216 | if __FILE__ == $PROGRAM_NAME 217 | ServeIt.main 218 | end 219 | --------------------------------------------------------------------------------