--auto-reload now automatically refreshes the page in browsers as well

Uses a WebSocket to send a signal to all browsers that have loaded the
page.
This commit is contained in:
Kovid Goyal 2015-10-27 20:33:56 +05:30
parent ce9ff2a0de
commit 6e874b5377
8 changed files with 234 additions and 42 deletions

View File

@ -0,0 +1,45 @@
/* vim:fileencoding=utf-8
*
* Copyright (C) 2015 Kovid Goyal <kovid at kovidgoyal.net>
*
* Distributed under terms of the GPLv3 license
*/
(function(autoreload_port) {
"use strict;";
var url = 'ws://127.0.0.1:' + autoreload_port;
function ReconnectingWebSocket() {
self = this;
self.retries = 0;
self.interval = 100;
self.reconnect = function() {
self.ws = new WebSocket(url);
self.ws.onopen = function(event) {
self.retries = 0;
self.interval = 100;
console.log('Connected to reloading WebSocket server at port: ' + autoreload_port);
};
self.ws.onmessage = function(event) {
console.log('Received mesasge from reload server: ' + event.data);
if (event.data === 'reload') window.location.reload(true);
};
self.ws.onclose = function(event) {
console.log('Connection to reload server closed with code: ' + event.code + ' and reason: ' + event.reason);
self.retries += 1;
if (self.retries < 60) {
self.interval *= 2;
setTimeout(self.reconnect, self.interval);
}
};
};
self.reconnect();
}
var sock = new ReconnectingWebSocket();
})(AUTORELOAD_PORT);

View File

@ -3,27 +3,27 @@
<head> <head>
<title>calibre</title> <title>calibre</title>
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="robots" content="noindex"> <meta name="robots" content="noindex">
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="icon" type="image/png" href="favicon.png"> <link rel="icon" type="image/png" href="favicon.png">
<script>window.interface_data_url = 'ajax/interface-data';</script> <script>window.calibre_entry_point = 'ENTRY_POINT';</script>
<script type="text/javascript" src="static/main.js"></script> <script type="text/javascript" src="static/main.js"></script>
<link rel="stylesheet" href="static/reset.css"></link> <link rel="stylesheet" href="static/reset.css"></link>
<link rel="stylesheet" href="static/font-awesome/fa.css"></link> <link rel="stylesheet" href="static/font-awesome/fa.css"></link>
</head> </head>
<body> <body>
<div id="page_load_progress"> <div id="page_load_progress">
<progress> <progress>
</progress> </progress>
<div>Loading library, please wait&hellip;<div> <div>LOADING_MSG&hellip;<div>
<style type="text/css"> <style type="text/css">
#page_load_progress { #page_load_progress {
position:relative; position:relative;
top: 50px; top: 50px;
margin-left: auto; margin-right: auto; margin-left: auto; margin-right: auto;
text-align: center; text-align: center;
} }
</style> </style>
</div> </div>
</body> </body>
</html> </html>

View File

@ -6,37 +6,44 @@ from __future__ import (unicode_literals, division, absolute_import,
__license__ = 'GPL v3' __license__ = 'GPL v3'
__copyright__ = '2015, Kovid Goyal <kovid at kovidgoyal.net>' __copyright__ = '2015, Kovid Goyal <kovid at kovidgoyal.net>'
import os, sys, subprocess, signal, time, errno import os, sys, subprocess, signal, time, errno, socket
from threading import Thread from threading import Thread, Lock
from calibre.constants import islinux, iswindows, isosx from calibre.constants import islinux, iswindows, isosx
from calibre.srv.http_response import create_http_handler
from calibre.srv.loop import ServerLoop
from calibre.srv.opts import Options
from calibre.srv.standalone import create_option_parser
from calibre.srv.web_socket import DummyHandler
from calibre.utils.monotonic import monotonic
class NoAutoReload(EnvironmentError): class NoAutoReload(EnvironmentError):
pass pass
# Filesystem watcher {{{
class WatcherBase(object): class WatcherBase(object):
EXTENSIONS_TO_WATCH = frozenset('py pyj'.split()) EXTENSIONS_TO_WATCH = frozenset('py pyj'.split())
BOUNCE_INTERVAL = 2 # seconds BOUNCE_INTERVAL = 2 # seconds
def __init__(self, server, log): def __init__(self, worker, log):
self.server, self.log = server, log self.worker, self.log = worker, log
fpath = os.path.abspath(__file__) fpath = os.path.abspath(__file__)
d = os.path.dirname d = os.path.dirname
self.base = d(d(d(d(fpath)))) self.base = d(d(d(d(fpath))))
self.last_restart_time = time.time() self.last_restart_time = monotonic()
def handle_modified(self, modified): def handle_modified(self, modified):
if modified: if modified:
if time.time() - self.last_restart_time > self.BOUNCE_INTERVAL: if monotonic() - self.last_restart_time > self.BOUNCE_INTERVAL:
modified = {os.path.relpath(x, self.base) if x.startswith(self.base) else x for x in modified if x} modified = {os.path.relpath(x, self.base) if x.startswith(self.base) else x for x in modified if x}
changed = os.pathsep.join(sorted(modified)) changed = os.pathsep.join(sorted(modified))
self.log('') self.log('')
self.log.warn('Restarting server because of changed files:', changed) self.log.warn('Restarting server because of changed files:', changed)
self.log('') self.log('')
self.server.restart() self.worker.restart()
self.last_restart_time = time.time() self.last_restart_time = monotonic()
def file_is_watched(self, fname): def file_is_watched(self, fname):
return fname and fname.rpartition('.')[-1] in self.EXTENSIONS_TO_WATCH return fname and fname.rpartition('.')[-1] in self.EXTENSIONS_TO_WATCH
@ -47,8 +54,8 @@ if islinux:
class Watcher(WatcherBase): class Watcher(WatcherBase):
def __init__(self, root_dirs, server, log): def __init__(self, root_dirs, worker, log):
WatcherBase.__init__(self, server, log) WatcherBase.__init__(self, worker, log)
self.fd_map = {} self.fd_map = {}
for d in frozenset(root_dirs): for d in frozenset(root_dirs):
w = INotifyTreeWatcher(d, self.ignore_event) w = INotifyTreeWatcher(d, self.ignore_event)
@ -113,8 +120,8 @@ elif iswindows:
class Watcher(WatcherBase): class Watcher(WatcherBase):
def __init__(self, root_dirs, server, log): def __init__(self, root_dirs, worker, log):
WatcherBase.__init__(self, server, log) WatcherBase.__init__(self, worker, log)
self.watchers = [] self.watchers = []
self.modified_queue = Queue() self.modified_queue = Queue()
for d in frozenset(root_dirs): for d in frozenset(root_dirs):
@ -135,8 +142,8 @@ elif isosx:
class Watcher(WatcherBase): class Watcher(WatcherBase):
def __init__(self, root_dirs, server, log): def __init__(self, root_dirs, worker, log):
WatcherBase.__init__(self, server, log) WatcherBase.__init__(self, worker, log)
self.stream = Stream(self.notify, *(x.encode('utf-8') for x in root_dirs), file_events=True) self.stream = Stream(self.notify, *(x.encode('utf-8') for x in root_dirs), file_events=True)
def loop(self): def loop(self):
@ -178,6 +185,7 @@ def find_dirs_to_watch(fpath, dirs, add_default_dirs):
add(os.path.join(base, 'src', 'calibre', 'db')) add(os.path.join(base, 'src', 'calibre', 'db'))
add(os.path.join(base, 'src', 'pyj')) add(os.path.join(base, 'src', 'pyj'))
return dirs return dirs
# }}}
def join_process(p, timeout=5): def join_process(p, timeout=5):
t = Thread(target=p.wait, name='JoinProcess') t = Thread(target=p.wait, name='JoinProcess')
@ -188,11 +196,32 @@ def join_process(p, timeout=5):
class Worker(object): class Worker(object):
def __init__(self, cmd, log, timeout=5): def __init__(self, cmd, log, server, timeout=5):
self.cmd = cmd self.cmd = cmd
self.log = log self.log = log
self.server = server
self.p = None self.p = None
self.timeout = timeout self.timeout = timeout
cmd = self.cmd
if 'calibre-debug' in cmd[0].lower():
try:
idx = cmd.index('--')
except ValueError:
cmd = ['srv']
else:
cmd = ['srv'] + cmd[idx+1:]
opts = create_option_parser().parse_args(cmd)[0]
self.port = opts.port
self.connection_timeout = opts.timeout
t = Thread(name='PingThread', target=self.ping_thread)
t.daemon = True
t.start()
def ping_thread(self):
while True:
self.server.ping()
time.sleep(0.9 * self.connection_timeout)
def __enter__(self): def __enter__(self):
self.restart() self.restart()
@ -222,8 +251,8 @@ class Worker(object):
# Happens if the editor deletes and replaces a file being edited # Happens if the editor deletes and replaces a file being edited
if e.errno != errno.ENOENT or not getattr(e, 'filename', False): if e.errno != errno.ENOENT or not getattr(e, 'filename', False):
raise raise
st = time.time() st = monotonic()
while not os.path.exists(e.filename) and time.time() - st < 3: while not os.path.exists(e.filename) and monotonic() - st < 3:
time.sleep(0.01) time.sleep(0.01)
compile_srv() compile_srv()
except CompileFailure as e: except CompileFailure as e:
@ -234,6 +263,82 @@ class Worker(object):
break break
self.p = subprocess.Popen(self.cmd, creationflags=getattr(subprocess, 'CREATE_NEW_PROCESS_GROUP', 0)) self.p = subprocess.Popen(self.cmd, creationflags=getattr(subprocess, 'CREATE_NEW_PROCESS_GROUP', 0))
self.wait_for_listen()
self.server.notify_reload()
def wait_for_listen(self):
st = monotonic()
while monotonic() - st < 5:
try:
return socket.create_connection(('localhost', self.port), 5).close()
except socket.error:
time.sleep(0.01)
self.log.error('Restarted server did not start listening on:', self.port)
# WebSocket reload notifier {{{
class ReloadHandler(DummyHandler):
def __init__(self, *args, **kw):
DummyHandler.__init__(self, *args, **kw)
self.connections = {}
self.conn_lock = Lock()
def handle_websocket_upgrade(self, connection_id, connection_ref, inheaders):
with self.conn_lock:
self.connections[connection_id] = connection_ref
def handle_websocket_close(self, connection_id):
with self.conn_lock:
self.connections.pop(connection_id, None)
def notify_reload(self):
with self.conn_lock:
for connref in self.connections.itervalues():
conn = connref()
if conn is not None and conn.ready:
conn.send_websocket_message('reload')
def ping(self):
with self.conn_lock:
for connref in self.connections.itervalues():
conn = connref()
if conn is not None and conn.ready:
conn.send_websocket_ping()
class ReloadServer(Thread):
daemon = True
def __init__(self):
Thread.__init__(self, name='ReloadServer')
self.reload_handler = ReloadHandler()
self.loop = ServerLoop(
create_http_handler(websocket_handler=self.reload_handler),
opts=Options(shutdown_timeout=0.1, listen_on='127.0.0.1', port=0))
self.loop.LISTENING_MSG = None
self.notify_reload = self.reload_handler.notify_reload
self.ping = self.reload_handler.ping
self.start()
def run(self):
try:
self.loop.serve_forever()
except KeyboardInterrupt:
pass
def __enter__(self):
while not self.loop.ready and self.is_alive():
time.sleep(0.01)
self.address = self.loop.bound_address[:2]
os.environ['CALIBRE_AUTORELOAD_PORT'] = str(self.address[1])
return self
def __exit__(self, *args):
self.loop.stop()
self.join(self.loop.opts.shutdown_timeout)
# }}}
def auto_reload(log, dirs=frozenset(), cmd=None, add_default_dirs=True): def auto_reload(log, dirs=frozenset(), cmd=None, add_default_dirs=True):
if Watcher is None: if Watcher is None:
@ -247,8 +352,8 @@ def auto_reload(log, dirs=frozenset(), cmd=None, add_default_dirs=True):
dirs = find_dirs_to_watch(fpath, dirs, add_default_dirs) dirs = find_dirs_to_watch(fpath, dirs, add_default_dirs)
log('Auto-restarting server on changes press Ctrl-C to quit') log('Auto-restarting server on changes press Ctrl-C to quit')
log('Watching %d directory trees for changes' % len(dirs)) log('Watching %d directory trees for changes' % len(dirs))
with Worker(cmd, log) as server: with ReloadServer() as server, Worker(cmd, log, server) as worker:
w = Watcher(dirs, server, log) w = Watcher(dirs, worker, log)
try: try:
w.loop() w.loop()
except KeyboardInterrupt: except KeyboardInterrupt:

View File

@ -4,9 +4,43 @@
from __future__ import (unicode_literals, division, absolute_import, from __future__ import (unicode_literals, division, absolute_import,
print_function) print_function)
import re
from functools import partial
from threading import Lock
from calibre.srv.routes import endpoint from calibre.srv.routes import endpoint
html_cache = {}
cache_lock = Lock()
autoreload_js = None
def get_html(name, auto_reload_port, **replacements):
global autoreload_js
key = (name, auto_reload_port, tuple(replacements.iteritems()))
with cache_lock:
try:
return html_cache[key]
except KeyError:
with lopen(P(name), 'rb') as f:
raw = f.read()
for k, val in key[-1]:
if isinstance(val, type('')):
val = val.encode('utf-8')
if isinstance(k, type('')):
k = k.encode('utf-8')
raw = raw.replace(k, val)
if auto_reload_port > 0:
if autoreload_js is None:
autoreload_js = P('content-server/autoreload.js', data=True, allow_user_override=False).replace(
b'AUTORELOAD_PORT', bytes(str(auto_reload_port)))
raw = re.sub(
br'(<\s*/\s*head\s*>)', br'<script type="text/javascript">%s</script>\1' % autoreload_js,
raw, flags=re.IGNORECASE)
html_cache[key] = raw
return raw
@endpoint('', auth_required=False) @endpoint('', auth_required=False)
def index(ctx, rd): def index(ctx, rd):
return lopen(P('content-server/index.html'), 'rb') return rd.generate_static_output('/', partial(
get_html, 'content-server/index.html', getattr(rd.opts, 'auto_reload_port', 0),
ENTRY_POINT='book list', LOADING_MSG=_('Loading library, please wait')))

View File

@ -211,10 +211,13 @@ class RequestData(object): # {{{
self.tdir = tdir self.tdir = tdir
self.allowed_book_ids = {} self.allowed_book_ids = {}
def generate_static_output(self, name, generator): def generate_static_output(self, name, generator, content_type='text/html; charset=UTF-8'):
ans = self.static_cache.get(name) ans = self.static_cache.get(name)
if ans is None: if ans is None:
ans = self.static_cache[name] = StaticOutput(generator()) ans = self.static_cache[name] = StaticOutput(generator())
ct = self.outheaders.get('Content-Type')
if not ct:
self.outheaders.set('Content-Type', content_type, replace_all=True)
return ans return ans
def filesystem_file_with_custom_etag(self, output, *etag_parts): def filesystem_file_with_custom_etag(self, output, *etag_parts):

View File

@ -272,6 +272,8 @@ class Connection(object): # {{{
class ServerLoop(object): class ServerLoop(object):
LISTENING_MSG = 'calibre server listening on'
def __init__( def __init__(
self, self,
handler, handler,
@ -383,7 +385,8 @@ class ServerLoop(object):
with TemporaryDirectory(prefix='srv-') as tdir: with TemporaryDirectory(prefix='srv-') as tdir:
self.tdir = tdir self.tdir = tdir
self.ready = True self.ready = True
self.log('calibre server listening on', ba) if self.LISTENING_MSG:
self.log(self.LISTENING_MSG, ba)
self.plugin_pool.start() self.plugin_pool.start()
while self.ready: while self.ready:

View File

@ -121,6 +121,7 @@ def main(args=sys.argv):
return auto_reload(default_log) return auto_reload(default_log)
except NoAutoReload as e: except NoAutoReload as e:
raise SystemExit(e.message) raise SystemExit(e.message)
opts.auto_reload_port = int(os.environ.get('CALIBRE_AUTORELOAD_PORT', 0))
try: try:
server = Server(libraries, opts) server = Server(libraries, opts)
except InvalidCredentials as e: except InvalidCredentials as e:

View File

@ -23,10 +23,11 @@ def on_library_load_progress(loaded, total):
p.max = total p.max = total
p.value = loaded p.value = loaded
def on_document_loaded(): def on_load():
ajax(window.interface_data_url, on_library_loaded, on_library_load_progress).send() if window.calibre_entry_point == 'book list':
ajax('ajax/interface-data', on_library_loaded, on_library_load_progress).send()
# We wait for all page elements to load, since this is a single page app # We wait for all page elements to load, since this is a single page app
# with a largely empty starting document, we can use this to preload any resources # with a largely empty starting document, we can use this to preload any resources
# we know are going to be needed immediately. # we know are going to be needed immediately.
window.addEventListener("load", on_document_loaded) window.addEventListener("load", on_load)