Skip to content
Snippets Groups Projects
Unverified Commit 50ff219b authored by Alex Wood's avatar Alex Wood Committed by Frank Taillandier
Browse files

Add LiveReload functionality to Jekyll. (#5142)

Merge pull request 5142
parent e3142e4c
No related branches found
No related tags found
No related merge requests found
Loading
Loading
@@ -24,6 +24,7 @@ end
group :test do
gem "codeclimate-test-reporter", "~> 1.0.5"
gem "cucumber", RUBY_VERSION >= "2.2" ? "~> 3.0" : "3.0.1"
gem "httpclient"
gem "jekyll_test_plugin"
gem "jekyll_test_plugin_malicious"
# nokogiri v1.8 does not work with ruby 2.1 and below
Loading
Loading
Loading
Loading
@@ -32,6 +32,7 @@ Gem::Specification.new do |s|
 
s.add_runtime_dependency("addressable", "~> 2.4")
s.add_runtime_dependency("colorator", "~> 1.0")
s.add_runtime_dependency("em-websocket", "~> 0.5")
s.add_runtime_dependency("i18n", "~> 0.7")
s.add_runtime_dependency("jekyll-sass-converter", "~> 1.0")
s.add_runtime_dependency("jekyll-watch", "~> 2.0")
Loading
Loading
# frozen_string_literal: true
 
require "thread"
module Jekyll
module Commands
class Serve < Command
# Similar to the pattern in Utils::ThreadEvent except we are maintaining the
# state of @running instead of just signaling an event. We have to maintain this
# state since Serve is just called via class methods instead of an instance
# being created each time.
@mutex = Mutex.new
@run_cond = ConditionVariable.new
@running = false
class << self
COMMAND_OPTIONS = {
"ssl_cert" => ["--ssl-cert [CERT]", "X.509 (SSL) certificate."],
"host" => ["host", "-H", "--host [HOST]", "Host to bind to"],
"open_url" => ["-o", "--open-url", "Launch your site in a browser"],
"detach" => ["-B", "--detach", "Run the server in the background"],
"ssl_key" => ["--ssl-key [KEY]", "X.509 (SSL) Private Key."],
"port" => ["-P", "--port [PORT]", "Port to listen on"],
"show_dir_listing" => ["--show-dir-listing",
"ssl_cert" => ["--ssl-cert [CERT]", "X.509 (SSL) certificate."],
"host" => ["host", "-H", "--host [HOST]", "Host to bind to"],
"open_url" => ["-o", "--open-url", "Launch your site in a browser"],
"detach" => ["-B", "--detach",
"Run the server in the background",],
"ssl_key" => ["--ssl-key [KEY]", "X.509 (SSL) Private Key."],
"port" => ["-P", "--port [PORT]", "Port to listen on"],
"show_dir_listing" => ["--show-dir-listing",
"Show a directory listing instead of loading your index file.",],
"skip_initial_build" => ["skip_initial_build", "--skip-initial-build",
"skip_initial_build" => ["skip_initial_build", "--skip-initial-build",
"Skips the initial site build which occurs before the server is started.",],
"livereload" => ["-l", "--livereload",
"Use LiveReload to automatically refresh browsers",],
"livereload_ignore" => ["--livereload-ignore ignore GLOB1[,GLOB2[,...]]",
Array,
"Files for LiveReload to ignore. Remember to quote the values so your shell "\
"won't expand them",],
"livereload_min_delay" => ["--livereload-min-delay [SECONDS]",
"Minimum reload delay",],
"livereload_max_delay" => ["--livereload-max-delay [SECONDS]",
"Maximum reload delay",],
"livereload_port" => ["--livereload-port [PORT]", Integer,
"Port for LiveReload to listen on",],
}.freeze
 
DIRECTORY_INDEX = %w(
Loading
Loading
@@ -26,7 +49,11 @@ module Jekyll
index.json
).freeze
 
#
LIVERELOAD_PORT = 35_729
LIVERELOAD_DIR = File.join(__dir__, "serve", "livereload_assets")
attr_reader :mutex, :run_cond, :running
alias_method :running?, :running
 
def init_with_program(prog)
prog.command(:serve) do |cmd|
Loading
Loading
@@ -41,20 +68,34 @@ module Jekyll
end
 
cmd.action do |_, opts|
opts["livereload_port"] ||= LIVERELOAD_PORT
opts["serving"] = true
opts["watch" ] = true unless opts.key?("watch")
 
config = configuration_from_options(opts)
if Jekyll.env == "development"
config["url"] = default_url(config)
end
[Build, Serve].each { |klass| klass.process(config) }
start(opts)
end
end
end
 
#
 
def start(opts)
# Set the reactor to nil so any old reactor will be GCed.
# We can't unregister a hook so in testing when Serve.start is
# called multiple times we don't want to inadvertently keep using
# a reactor created by a previous test when our test might not
@reload_reactor = nil
register_reload_hooks(opts) if opts["livereload"]
config = configuration_from_options(opts)
if Jekyll.env == "development"
config["url"] = default_url(config)
end
[Build, Serve].each { |klass| klass.process(config) }
end
#
def process(opts)
opts = configuration_from_options(opts)
destination = opts["destination"]
Loading
Loading
@@ -63,6 +104,76 @@ module Jekyll
start_up_webrick(opts, destination)
end
 
def shutdown
@server.shutdown if running?
end
# Perform logical validation of CLI options
private
def validate_options(opts)
if opts["livereload"]
if opts["detach"]
Jekyll.logger.warn "Warning:",
"--detach and --livereload are mutually exclusive. Choosing --livereload"
opts["detach"] = false
end
if opts["ssl_cert"] || opts["ssl_key"]
# This is not technically true. LiveReload works fine over SSL, but
# EventMachine's SSL support in Windows requires building the gem's
# native extensions against OpenSSL and that proved to be a process
# so tedious that expecting users to do it is a non-starter.
Jekyll.logger.abort_with "Error:", "LiveReload does not support SSL"
end
unless opts["watch"]
# Using livereload logically implies you want to watch the files
opts["watch"] = true
end
elsif %w(livereload_min_delay
livereload_max_delay
livereload_ignore
livereload_port).any? { |o| opts[o] }
Jekyll.logger.abort_with "--livereload-min-delay, "\
"--livereload-max-delay, --livereload-ignore, and "\
"--livereload-port require the --livereload option."
end
end
#
private
# rubocop:disable Metrics/AbcSize
def register_reload_hooks(opts)
require_relative "serve/live_reload_reactor"
@reload_reactor = LiveReloadReactor.new
Jekyll::Hooks.register(:site, :post_render) do |site|
regenerator = Jekyll::Regenerator.new(site)
@changed_pages = site.pages.select do |p|
regenerator.regenerate?(p)
end
end
# A note on ignoring files: LiveReload errs on the side of reloading when it
# comes to the message it gets. If, for example, a page is ignored but a CSS
# file linked in the page isn't, the page will still be reloaded if the CSS
# file is contained in the message sent to LiveReload. Additionally, the
# path matching is very loose so that a message to reload "/" will always
# lead the page to reload since every page starts with "/".
Jekyll::Hooks.register(:site, :post_write) do
if @changed_pages && @reload_reactor && @reload_reactor.running?
ignore, @changed_pages = @changed_pages.partition do |p|
Array(opts["livereload_ignore"]).any? do |filter|
File.fnmatch(filter, Jekyll.sanitized_path(p.relative_path))
end
end
Jekyll.logger.debug "LiveReload:", "Ignoring #{ignore.map(&:relative_path)}"
@reload_reactor.reload(@changed_pages)
end
@changed_pages = nil
end
end
# Do a base pre-setup of WEBRick so that everything is in place
# when we get ready to party, checking for an setting up an error page
# and making sure our destination exists.
Loading
Loading
@@ -92,6 +203,7 @@ module Jekyll
:MimeTypes => mime_types,
:DocumentRoot => opts["destination"],
:StartCallback => start_callback(opts["detach"]),
:StopCallback => stop_callback(opts["detach"]),
:BindAddress => opts["host"],
:Port => opts["port"],
:DirectoryIndex => DIRECTORY_INDEX,
Loading
Loading
@@ -108,11 +220,16 @@ module Jekyll
 
private
def start_up_webrick(opts, destination)
server = WEBrick::HTTPServer.new(webrick_opts(opts)).tap { |o| o.unmount("") }
server.mount(opts["baseurl"].to_s, Servlet, destination, file_handler_opts)
Jekyll.logger.info "Server address:", server_address(server, opts)
launch_browser server, opts if opts["open_url"]
boot_or_detach server, opts
if opts["livereload"]
@reload_reactor.start(opts)
end
@server = WEBrick::HTTPServer.new(webrick_opts(opts)).tap { |o| o.unmount("") }
@server.mount(opts["baseurl"].to_s, Servlet, destination, file_handler_opts)
Jekyll.logger.info "Server address:", server_address(@server, opts)
launch_browser @server, opts if opts["open_url"]
boot_or_detach @server, opts
end
 
# Recreate NondisclosureName under utf-8 circumstance
Loading
Loading
@@ -227,7 +344,29 @@ module Jekyll
def start_callback(detached)
unless detached
proc do
Jekyll.logger.info("Server running...", "press ctrl-c to stop.")
mutex.synchronize do
# Block until EventMachine reactor starts
@reload_reactor.started_event.wait unless @reload_reactor.nil?
@running = true
Jekyll.logger.info("Server running...", "press ctrl-c to stop.")
@run_cond.broadcast
end
end
end
end
private
def stop_callback(detached)
unless detached
proc do
mutex.synchronize do
unless @reload_reactor.nil?
@reload_reactor.stop
@reload_reactor.stopped_event.wait
end
@running = false
@run_cond.broadcast
end
end
end
end
Loading
Loading
# frozen_string_literal: true
require "json"
require "em-websocket"
require_relative "websockets"
module Jekyll
module Commands
class Serve
class LiveReloadReactor
attr_reader :started_event
attr_reader :stopped_event
attr_reader :thread
def initialize
@thread = nil
@websockets = []
@connections_count = 0
@started_event = Utils::ThreadEvent.new
@stopped_event = Utils::ThreadEvent.new
end
def stop
# There is only one EventMachine instance per Ruby process so stopping
# it here will stop the reactor thread we have running.
EM.stop if EM.reactor_running?
Jekyll.logger.debug("LiveReload Server:", "halted")
end
def running?
EM.reactor_running?
end
def handle_websockets_event(ws)
ws.onopen do |handshake|
connect(ws, handshake)
end
ws.onclose do
disconnect(ws)
end
ws.onmessage do |msg|
print_message(msg)
end
ws.onerror do |error|
log_error(error)
end
end
# rubocop:disable Metrics/MethodLength
def start(opts)
@thread = Thread.new do
# Use epoll if the kernel supports it
EM.epoll
EM.run do
EM.error_handler do |e|
log_error(e)
end
EM.start_server(
opts["host"],
opts["livereload_port"],
HttpAwareConnection,
opts
) do |ws|
handle_websockets_event(ws)
end
# Notify blocked threads that EventMachine has started or shutdown
EM.schedule do
@started_event.set
end
EM.add_shutdown_hook do
@stopped_event.set
end
Jekyll.logger.info(
"LiveReload address:", "#{opts["host"]}:#{opts["livereload_port"]}"
)
end
end
@thread.abort_on_exception = true
end
# For a description of the protocol see
# http://feedback.livereload.com/knowledgebase/articles/86174-livereload-protocol
def reload(pages)
pages.each do |p|
msg = {
:command => "reload",
:path => p.url,
:liveCSS => true,
}
Jekyll.logger.debug("LiveReload:", "Reloading #{p.url}")
Jekyll.logger.debug(JSON.dump(msg))
@websockets.each do |ws|
ws.send(JSON.dump(msg))
end
end
end
private
def connect(ws, handshake)
@connections_count += 1
if @connections_count == 1
message = "Browser connected"
message += " over SSL/TLS" if handshake.secure?
Jekyll.logger.info("LiveReload:", message)
end
ws.send(
JSON.dump(
:command => "hello",
:protocols => ["http://livereload.com/protocols/official-7"],
:serverName => "jekyll"
)
)
@websockets << ws
end
private
def disconnect(ws)
@websockets.delete(ws)
end
private
def print_message(json_message)
msg = JSON.parse(json_message)
# Not sure what the 'url' command even does in LiveReload. The spec is silent
# on its purpose.
if msg["command"] == "url"
Jekyll.logger.info("LiveReload:", "Browser URL: #{msg["url"]}")
end
end
private
def log_error(e)
Jekyll.logger.warn(
"LiveReload experienced an error. "\
"Run with --verbose for more information."
)
Jekyll.logger.debug("LiveReload Error:", e.message)
Jekyll.logger.debug("LiveReload Error:", e.backtrace.join("\n"))
end
end
end
end
end
This diff is collapsed.
Loading
Loading
@@ -5,6 +5,128 @@ require "webrick"
module Jekyll
module Commands
class Serve
# This class is used to determine if the Servlet should modify a served file
# to insert the LiveReload script tags
class SkipAnalyzer
BAD_USER_AGENTS = [%r!MSIE!].freeze
def self.skip_processing?(request, response, options)
new(request, response, options).skip_processing?
end
def initialize(request, response, options)
@options = options
@request = request
@response = response
end
def skip_processing?
!html? || chunked? || inline? || bad_browser?
end
def chunked?
@response["Transfer-Encoding"] == "chunked"
end
def inline?
@response["Content-Disposition"] =~ %r!^inline!
end
def bad_browser?
BAD_USER_AGENTS.any? { |pattern| @request["User-Agent"] =~ pattern }
end
def html?
@response["Content-Type"] =~ %r!text/html!
end
end
# This class inserts the LiveReload script tags into HTML as it is served
class BodyProcessor
HEAD_TAG_REGEX = %r!<head>|<head[^(er)][^<]*>!
attr_reader :content_length, :new_body, :livereload_added
def initialize(body, options)
@body = body
@options = options
@processed = false
end
def processed?
@processed
end
# rubocop:disable Metrics/MethodLength
def process!
@new_body = []
# @body will usually be a File object but Strings occur in rare cases
if @body.respond_to?(:each)
begin
@body.each { |line| @new_body << line.to_s }
ensure
@body.close
end
else
@new_body = @body.lines
end
@content_length = 0
@livereload_added = false
@new_body.each do |line|
if !@livereload_added && line["<head"]
line.gsub!(HEAD_TAG_REGEX) do |match|
%(#{match}#{template.result(binding)})
end
@livereload_added = true
end
@content_length += line.bytesize
@processed = true
end
@new_body = @new_body.join
end
def template
# Unclear what "snipver" does. Doc at
# https://github.com/livereload/livereload-js states that the recommended
# setting is 1.
# Complicated JavaScript to ensure that livereload.js is loaded from the
# same origin as the page. Mostly useful for dealing with the browser's
# distinction between 'localhost' and 127.0.0.1
template = <<-TEMPLATE
<script>
document.write(
'<script src="http://' +
(location.host || 'localhost').split(':')[0] +
':<%=@options["livereload_port"] %>/livereload.js?snipver=1<%= livereload_args %>"' +
'></' +
'script>');
</script>
TEMPLATE
ERB.new(Jekyll::Utils.strip_heredoc(template))
end
def livereload_args
# XHTML standard requires ampersands to be encoded as entities when in
# attributes. See http://stackoverflow.com/a/2190292
src = ""
if @options["livereload_min_delay"]
src += "&amp;mindelay=#{@options["livereload_min_delay"]}"
end
if @options["livereload_max_delay"]
src += "&amp;maxdelay=#{@options["livereload_max_delay"]}"
end
if @options["livereload_port"]
src += "&amp;port=#{@options["livereload_port"]}"
end
src
end
end
class Servlet < WEBrick::HTTPServlet::FileHandler
DEFAULTS = {
"Cache-Control" => "private, max-age=0, proxy-revalidate, " \
Loading
Loading
@@ -34,6 +156,21 @@ module Jekyll
# rubocop:disable Naming/MethodName
def do_GET(req, res)
rtn = super
if @jekyll_opts["livereload"]
return rtn if SkipAnalyzer.skip_processing?(req, res, @jekyll_opts)
processor = BodyProcessor.new(res.body, @jekyll_opts)
processor.process!
res.body = processor.new_body
res.content_length = processor.content_length.to_s
if processor.livereload_added
# Add a header to indicate that the page content has been modified
res["X-Rack-LiveReload"] = "1"
end
end
validate_and_ensure_charset(req, res)
res.header.merge!(@headers)
rtn
Loading
Loading
# frozen_string_literal: true
require "http/parser"
module Jekyll
module Commands
class Serve
# The LiveReload protocol requires the server to serve livereload.js over HTTP
# despite the fact that the protocol itself uses WebSockets. This custom connection
# class addresses the dual protocols that the server needs to understand.
class HttpAwareConnection < EventMachine::WebSocket::Connection
attr_reader :reload_body, :reload_size
def initialize(_opts)
# If EventMachine SSL support on Windows ever gets better, the code below will
# set up the reactor to handle SSL
#
# @ssl_enabled = opts["ssl_cert"] && opts["ssl_key"]
# if @ssl_enabled
# em_opts[:tls_options] = {
# :private_key_file => Jekyll.sanitized_path(opts["source"], opts["ssl_key"]),
# :cert_chain_file => Jekyll.sanitized_path(opts["source"], opts["ssl_cert"])
# }
# em_opts[:secure] = true
# end
# This is too noisy even for --verbose, but uncomment if you need it for
# a specific WebSockets issue. Adding ?LR-verbose=true onto the URL will
# enable logging on the client side.
# em_opts[:debug] = true
em_opts = {}
super(em_opts)
reload_file = File.join(Serve.singleton_class::LIVERELOAD_DIR, "livereload.js")
@reload_body = File.read(reload_file)
@reload_size = @reload_body.bytesize
end
# rubocop:disable Metrics/MethodLength
def dispatch(data)
parser = Http::Parser.new
parser << data
# WebSockets requests will have a Connection: Upgrade header
if parser.http_method != "GET" || parser.upgrade?
super
elsif parser.request_url =~ %r!^\/livereload.js!
headers = [
"HTTP/1.1 200 OK",
"Content-Type: application/javascript",
"Content-Length: #{reload_size}",
"",
"",
].join("\r\n")
send_data(headers)
# stream_file_data would free us from keeping livereload.js in memory
# but JRuby blocks on that call and never returns
send_data(reload_body)
close_connection_after_writing
else
body = "This port only serves livereload.js over HTTP.\n"
headers = [
"HTTP/1.1 400 Bad Request",
"Content-Type: text/plain",
"Content-Length: #{body.bytesize}",
"",
"",
].join("\r\n")
send_data(headers)
send_data(body)
close_connection_after_writing
end
end
end
end
end
end
Loading
Loading
@@ -8,6 +8,7 @@ module Jekyll
autoload :Internet, "jekyll/utils/internet"
autoload :Platforms, "jekyll/utils/platforms"
autoload :Rouge, "jekyll/utils/rouge"
autoload :ThreadEvent, "jekyll/utils/thread_event"
autoload :WinTZ, "jekyll/utils/win_tz"
 
# Constants for use in #slugify
Loading
Loading
# frozen_string_literal: true
require "thread"
module Jekyll
module Utils
# Based on the pattern and code from
# https://emptysqua.re/blog/an-event-synchronization-primitive-for-ruby/
class ThreadEvent
attr_reader :flag
def initialize
@lock = Mutex.new
@cond = ConditionVariable.new
@flag = false
end
def set
@lock.synchronize do
yield if block_given?
@flag = true
@cond.broadcast
end
end
def wait
@lock.synchronize do
unless @flag
@cond.wait(@lock)
end
end
end
end
end
end
Loading
Loading
@@ -3,7 +3,10 @@
require "webrick"
require "mercenary"
require "helper"
require "httpclient"
require "openssl"
require "thread"
require "tmpdir"
 
class TestCommandsServe < JekyllUnitTest
def custom_opts(what)
Loading
Loading
@@ -12,6 +15,128 @@ class TestCommandsServe < JekyllUnitTest
)
end
 
def start_server(opts)
@thread = Thread.new do
merc = nil
cmd = Jekyll::Commands::Serve
Mercenary.program(:jekyll) do |p|
merc = cmd.init_with_program(p)
end
merc.execute(:serve, opts)
end
@thread.abort_on_exception = true
Jekyll::Commands::Serve.mutex.synchronize do
unless Jekyll::Commands::Serve.running?
Jekyll::Commands::Serve.run_cond.wait(Jekyll::Commands::Serve.mutex)
end
end
end
def serve(opts)
allow(Jekyll).to receive(:configuration).and_return(opts)
allow(Jekyll::Commands::Build).to receive(:process)
start_server(opts)
opts
end
context "using LiveReload" do
setup do
@temp_dir = Dir.mktmpdir("jekyll_livereload_test")
@destination = File.join(@temp_dir, "_site")
Dir.mkdir(@destination) || flunk("Could not make directory #{@destination}")
@client = HTTPClient.new
@client.connect_timeout = 5
@standard_options = {
"port" => 4000,
"host" => "localhost",
"baseurl" => "",
"detach" => false,
"livereload" => true,
"source" => @temp_dir,
"destination" => @destination,
}
site = instance_double(Jekyll::Site)
simple_page = <<-HTML.gsub(%r!^\s*!, "")
<!DOCTYPE HTML>
<html lang="en-US">
<head>
<meta charset="UTF-8">
<title>Hello World</title>
</head>
<body>
<p>Hello! I am a simple web page.</p>
</body>
</html>
HTML
File.open(File.join(@destination, "hello.html"), "w") do |f|
f.write(simple_page)
end
allow(Jekyll::Site).to receive(:new).and_return(site)
end
teardown do
capture_io do
Jekyll::Commands::Serve.shutdown
end
Jekyll::Commands::Serve.mutex.synchronize do
if Jekyll::Commands::Serve.running?
Jekyll::Commands::Serve.run_cond.wait(Jekyll::Commands::Serve.mutex)
end
end
FileUtils.remove_entry_secure(@temp_dir, true)
end
should "serve livereload.js over HTTP on the default LiveReload port" do
skip_if_windows "EventMachine support on Windows is limited"
opts = serve(@standard_options)
content = @client.get_content(
"http://#{opts["host"]}:#{opts["livereload_port"]}/livereload.js"
)
assert_match(%r!LiveReload.on!, content)
end
should "serve nothing else over HTTP on the default LiveReload port" do
skip_if_windows "EventMachine support on Windows is limited"
opts = serve(@standard_options)
res = @client.get("http://#{opts["host"]}:#{opts["livereload_port"]}/")
assert_equal(400, res.status_code)
assert_match(%r!only serves livereload.js!, res.content)
end
should "insert the LiveReload script tags" do
skip_if_windows "EventMachine support on Windows is limited"
opts = serve(@standard_options)
content = @client.get_content(
"http://#{opts["host"]}:#{opts["port"]}/#{opts["baseurl"]}/hello.html"
)
assert_match(
%r!livereload.js\?snipver=1&amp;port=#{opts["livereload_port"]}!,
content
)
assert_match(%r!I am a simple web page!, content)
end
should "apply the max and min delay options" do
skip_if_windows "EventMachine support on Windows is limited"
opts = serve(@standard_options.merge(
"livereload_max_delay" => "1066",
"livereload_min_delay" => "3"
))
content = @client.get_content(
"http://#{opts["host"]}:#{opts["port"]}/#{opts["baseurl"]}/hello.html"
)
assert_match(%r!&amp;mindelay=3!, content)
assert_match(%r!&amp;maxdelay=1066!, content)
end
end
context "with a program" do
setup do
@merc = nil
Loading
Loading
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment