├── .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 |
--------------------------------------------------------------------------------