151 lines
5.6 KiB
Python
151 lines
5.6 KiB
Python
|
import re
|
||
|
import logging
|
||
|
from pathlib import Path
|
||
|
|
||
|
from inotify_simple import flags
|
||
|
from starlette.endpoints import WebSocketEndpoint
|
||
|
|
||
|
|
||
|
logger = logging.getLogger(__name__)
|
||
|
|
||
|
#page_re = re.compile(r'https?:\/\/.*?\/(.*?)(?:\?|\.html|$)')
|
||
|
page_re = re.compile(r'https?:\/\/.*?\/(.*?)$')
|
||
|
|
||
|
def client_reload_wrap(reloaded_file):
|
||
|
rpath = Path(reloaded_file)
|
||
|
static_extensions = ['.js', '.css']
|
||
|
|
||
|
if rpath.suffix in static_extensions:
|
||
|
return lambda _: True
|
||
|
else:
|
||
|
return lambda c: Path(c).with_suffix('.html') == rpath
|
||
|
|
||
|
|
||
|
class Handler(WebSocketEndpoint):
|
||
|
'''
|
||
|
Subclasses WebSocketEndpoint to be attached to live reload endpoints.
|
||
|
|
||
|
Note: Reload model
|
||
|
- Served HTML files are generated from templates that include livereload JS and the
|
||
|
target livereload server (port manually set prior to site build).
|
||
|
- When pages are visited (be they served from NGINX or via the development
|
||
|
server), the livereload.js attempts to connect to the known livereload WS
|
||
|
endpoint.
|
||
|
- FastAPI routes the request to _this_ endpoint, and `on_connect` is called.
|
||
|
- Upon successful connection, the livereload JS client sends a "hello" message.
|
||
|
This is picked up as the first post-acceptance message, and captured by the
|
||
|
`on_receive` method.
|
||
|
- `on_receive` subsequently initiates a formal handshake, sending back a "hello"
|
||
|
command and waiting the "info" command from the client.
|
||
|
- If the "info" command is received successfully and contains the requesting
|
||
|
page's URL, the handshake completes and the websocket is added to the class'
|
||
|
`live_clients` tracker.
|
||
|
- Later, when a file in a watch path of the server's watcher is _modified_,
|
||
|
`reload_clients` will be called from within the originating server's event loop,
|
||
|
and pass in the FS event associated with the file change. `client_reload_wrap`
|
||
|
is used to wrap a boolean checker method for whether or not to reload clients
|
||
|
given the FS event.
|
||
|
|
||
|
TODO: flesh out the reload wrapper to incorporate more complex filters and/or
|
||
|
transformations when determining when to reload certain clients.
|
||
|
'''
|
||
|
encoding = 'json'
|
||
|
live_clients = {}
|
||
|
|
||
|
async def on_connect(self, websocket):
|
||
|
await websocket.accept()
|
||
|
|
||
|
async def on_receive(self, websocket, data):
|
||
|
'''
|
||
|
Note: On page names
|
||
|
When websockets connect, they simply communicate the exact URL from the origin
|
||
|
page. The websocket is then indexed to possibly variable page names (often
|
||
|
without an `.html` suffix, but occasionally with). The `client_reload_wrap` is
|
||
|
then responsible for taking this client page name and normalizing it to be
|
||
|
matched with full file names (i.e., suffix always included).
|
||
|
'''
|
||
|
url = await self._lr_handshake(websocket, data)
|
||
|
|
||
|
if url is None:
|
||
|
logger.warning('Client handshake failed, ignoring')
|
||
|
return
|
||
|
|
||
|
origin_m = page_re.search(url)
|
||
|
if origin_m is not None:
|
||
|
origin_page = origin_m.group(1)
|
||
|
|
||
|
# assume index.html if connected to empty name
|
||
|
if origin_page == '':
|
||
|
origin_page = 'index.html'
|
||
|
else:
|
||
|
origin_page = '<unidentified>.null'
|
||
|
|
||
|
self.live_clients[origin_page] = websocket
|
||
|
logger.info(f'Reloader connected to [{origin_page}] ({len(self.live_clients)} live clients)')
|
||
|
|
||
|
async def on_disconnect(self, websocket, close_code):
|
||
|
remove_page = None
|
||
|
for page, ws in self.live_clients.items():
|
||
|
if ws == websocket:
|
||
|
remove_page = page
|
||
|
|
||
|
if remove_page is not None:
|
||
|
logger.info(f'Client for [{remove_page}] disconnected, removing')
|
||
|
self.live_clients.pop(remove_page)
|
||
|
|
||
|
@classmethod
|
||
|
async def reload_clients(cls, event):
|
||
|
'''
|
||
|
Method targeted as a watcher callback. This async method is scheduled in a
|
||
|
thread-safe manner by the watcher to be ran in the FastAPI event loop.
|
||
|
'''
|
||
|
logger.info(f'> [{event.name}] changed on disk')
|
||
|
should_reload = client_reload_wrap(event.name)
|
||
|
|
||
|
for page, ws in cls.live_clients.items():
|
||
|
if should_reload(page):
|
||
|
logger.info(f'>> Reloading client for [{page}]')
|
||
|
await ws.send_json({
|
||
|
'command' : 'reload',
|
||
|
'path' : page,
|
||
|
'liveCSS' : True,
|
||
|
'liveImg' : True,
|
||
|
})
|
||
|
|
||
|
@staticmethod
|
||
|
async def _lr_handshake(websocket, hello):
|
||
|
'''
|
||
|
Handshake with livereload.js
|
||
|
|
||
|
1. client send 'hello'
|
||
|
2. server reply 'hello'
|
||
|
3. client send 'info'
|
||
|
'''
|
||
|
# 1. await client hello after accept
|
||
|
#hello = await websocket.receive_json()
|
||
|
|
||
|
if hello.get('command') != 'hello':
|
||
|
logger.warning('Client handshake failed at "hello" stage')
|
||
|
return
|
||
|
|
||
|
# 2. send hello to client
|
||
|
await websocket.send_json({
|
||
|
'command': 'hello',
|
||
|
'protocols': [
|
||
|
'http://livereload.com/protocols/official-7',
|
||
|
],
|
||
|
'serverName': 'livereload-tornado',
|
||
|
})
|
||
|
|
||
|
# 3. await info response
|
||
|
info = await websocket.receive_json()
|
||
|
|
||
|
if info.get('command') != 'info':
|
||
|
logger.warning('Client handshake failed at "info" stage')
|
||
|
return None
|
||
|
elif 'url' not in info:
|
||
|
logger.warning('Info received from client, but no URL provided')
|
||
|
return None
|
||
|
|
||
|
return info['url']
|