Fix
This commit is contained in:
1 parent
844f86cda0
commit
77bf8d9f2e
109 files changed
+934
-3325
No files matched your search
@@ -1,11 +1,30 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Static file server for the rendered Bible site.
|
||||
"""Static file server for the Bible site.
|
||||
|
||||
Stdlib only, so the container needs nothing but a Python base image. Its one
|
||||
job beyond serving files is getting cache headers right: the service worker
|
||||
owns the offline copy, and if the worker or the pages that register it are
|
||||
served stale, a new build can never reach a device that already installed the
|
||||
old one.
|
||||
Stdlib only, so the container needs nothing but a Python base image.
|
||||
|
||||
It serves two directories as one origin: assets/, which is hand-written, and
|
||||
books/, which render_books.py generates from bible.json. Nothing is copied
|
||||
between them -- every file exists once, in the directory that owns it.
|
||||
|
||||
Beyond serving files it has three jobs.
|
||||
|
||||
The first is the boot page. The pages here are fragments; the service worker
|
||||
assembles the shell around them. A browser asking for one directly, before any
|
||||
worker is installed, would get a bare fragment with no chrome, so navigations
|
||||
are answered with boot.html, which installs the worker and reloads into the
|
||||
real app. Requests the worker itself makes are ordinary fetches rather than
|
||||
navigations, so they pass straight through and get the fragment.
|
||||
|
||||
The second is stamping the service worker. A browser only notices a new worker
|
||||
when the bytes of sw.js change, so something has to record what is on disk.
|
||||
Doing it here rather than in a build step means editing a template is the
|
||||
whole job: save the file, reload, and the version the browser fetches already
|
||||
differs. See stamp_worker().
|
||||
|
||||
The third is cache headers: the worker owns the offline copy, and if the
|
||||
worker or the pages that register it are served stale, a new version can never
|
||||
reach a device that already installed the old one.
|
||||
|
||||
Expects to sit behind something that terminates TLS. That is not optional for
|
||||
the offline app: browsers only register a service worker in a secure context,
|
||||
@@ -16,7 +35,11 @@ page but never gets an offline copy.
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
import posixpath
|
||||
import signal
|
||||
import sys
|
||||
from functools import partial
|
||||
@@ -24,13 +47,72 @@ from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer
|
||||
|
||||
# Revalidate every time: these are what carry an update to an installed app.
|
||||
NO_CACHE_NAMES = {"sw.js"}
|
||||
NO_CACHE_EXTS = {".html", ".webmanifest"}
|
||||
NO_CACHE_EXTS = {".html", ".njk", ".webmanifest"}
|
||||
|
||||
# Served in place of any page a browser navigates to directly.
|
||||
BOOT_PAGE = "/boot.html"
|
||||
|
||||
WORKER = "sw.js"
|
||||
|
||||
# The two lines stamp_worker() rewrites. They are real statements rather than
|
||||
# placeholders so that assets/sw.js stays valid JavaScript on its own.
|
||||
VERSION_LINE = "const VERSION = 'dev';"
|
||||
ASSETS_LINE = 'const ASSETS = ["./"];'
|
||||
|
||||
|
||||
def walk(roots):
|
||||
"""Every served file, as url-relative paths mapped to their disk path.
|
||||
|
||||
Earlier roots win, which is what makes the two directories one origin.
|
||||
"""
|
||||
found = {}
|
||||
for root in roots:
|
||||
for dirpath, _dirs, names in os.walk(root):
|
||||
for name in sorted(names):
|
||||
path = os.path.join(dirpath, name)
|
||||
rel = os.path.relpath(path, root).replace(os.sep, "/")
|
||||
found.setdefault(rel, path)
|
||||
return found
|
||||
|
||||
|
||||
def stamp_worker(roots):
|
||||
"""assets/sw.js with VERSION and ASSETS filled in from what is on disk.
|
||||
|
||||
The version is a digest of every file's name, size and mtime rather than
|
||||
its contents: it has to change whenever anything changes, and it is
|
||||
recomputed on every request for the worker, so reading 9 MB of book
|
||||
fragments to hash them would be a poor trade. Touching a file without
|
||||
changing it costs one needless precache, which is harmless.
|
||||
"""
|
||||
files = walk(roots)
|
||||
digest = hashlib.sha256()
|
||||
assets = []
|
||||
for rel in sorted(files):
|
||||
if rel == WORKER:
|
||||
continue # the browser revalidates the worker itself
|
||||
st = os.stat(files[rel])
|
||||
digest.update(("%s\0%d\0%d\0" % (rel, st.st_size, st.st_mtime_ns)).encode())
|
||||
assets.append("./" + rel)
|
||||
assets.insert(0, "./") # a bare-root launch is its own cache entry
|
||||
|
||||
src = files.get(WORKER)
|
||||
if src is None:
|
||||
return None
|
||||
with open(src, encoding="utf-8") as fh:
|
||||
body = fh.read()
|
||||
for line, value in ((VERSION_LINE, "const VERSION = '%s';" % digest.hexdigest()[:12]),
|
||||
(ASSETS_LINE, "const ASSETS = %s;" % json.dumps(assets, indent=2))):
|
||||
if line not in body:
|
||||
raise RuntimeError("%s: expected to find %r to stamp" % (WORKER, line))
|
||||
body = body.replace(line, value, 1)
|
||||
return body.encode("utf-8")
|
||||
|
||||
|
||||
class Handler(SimpleHTTPRequestHandler):
|
||||
extensions_map = {
|
||||
**SimpleHTTPRequestHandler.extensions_map,
|
||||
".html": "text/html; charset=utf-8",
|
||||
".njk": "text/plain; charset=utf-8",
|
||||
".css": "text/css; charset=utf-8",
|
||||
".js": "text/javascript; charset=utf-8",
|
||||
".json": "application/json; charset=utf-8",
|
||||
@@ -40,11 +122,55 @@ class Handler(SimpleHTTPRequestHandler):
|
||||
}
|
||||
server_version = "bible-static"
|
||||
sys_version = ""
|
||||
roots = ()
|
||||
|
||||
def translate_path(self, path):
|
||||
"""Resolve against each root in turn, first hit wins.
|
||||
|
||||
The parent does the sanitising -- it is what keeps ".." from escaping
|
||||
the root -- so this only ever swaps which root it resolves against.
|
||||
"""
|
||||
for root in self.roots:
|
||||
self.directory = root
|
||||
candidate = super().translate_path(path)
|
||||
if os.path.exists(candidate):
|
||||
return candidate
|
||||
self.directory = self.roots[0]
|
||||
return super().translate_path(path)
|
||||
|
||||
def send_head(self):
|
||||
# Sec-Fetch-Mode is the one signal that separates a browser typing in
|
||||
# a URL from the worker fetching a fragment: a request the worker
|
||||
# constructs is never in navigate mode. Browsers without the header
|
||||
# also have no service worker, and get the fragment -- there is
|
||||
# nothing better to give them.
|
||||
if self.headers.get("Sec-Fetch-Mode") == "navigate" and self.path != BOOT_PAGE:
|
||||
self.path = BOOT_PAGE
|
||||
|
||||
if posixpath.basename(self.path.split("?", 1)[0]) == WORKER:
|
||||
return self.send_worker()
|
||||
return super().send_head()
|
||||
|
||||
def send_worker(self):
|
||||
try:
|
||||
body = stamp_worker(self.roots)
|
||||
except Exception as err: # a broken stamp is a 500, not a
|
||||
self.log_message("stamp failed: %s", err) # silently stale worker
|
||||
self.send_error(500, "service worker unavailable")
|
||||
return None
|
||||
if body is None:
|
||||
self.send_error(404, "no service worker")
|
||||
return None
|
||||
self.send_response(200)
|
||||
self.send_header("Content-Type", "text/javascript; charset=utf-8")
|
||||
self.send_header("Content-Length", str(len(body)))
|
||||
self.end_headers()
|
||||
return io.BytesIO(body)
|
||||
|
||||
def end_headers(self):
|
||||
path = self.path.split("?", 1)[0]
|
||||
name = os.path.basename(path)
|
||||
ext = os.path.splitext(name)[1].lower()
|
||||
name = posixpath.basename(path)
|
||||
ext = posixpath.splitext(name)[1].lower()
|
||||
if name in NO_CACHE_NAMES or ext in NO_CACHE_EXTS or path.endswith("/"):
|
||||
self.send_header("Cache-Control", "no-cache")
|
||||
else:
|
||||
@@ -62,18 +188,22 @@ class Handler(SimpleHTTPRequestHandler):
|
||||
def main(argv=None):
|
||||
ap = argparse.ArgumentParser(description=__doc__,
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter)
|
||||
ap.add_argument("--root", default=os.environ.get("SITE_ROOT", "books"),
|
||||
help="directory to serve (default: books)")
|
||||
ap.add_argument("--root", action="append", dest="roots", metavar="DIR",
|
||||
help="directory to serve; repeatable, earlier wins "
|
||||
"(default: assets then books, or $SITE_ROOTS)")
|
||||
ap.add_argument("--host", default=os.environ.get("HOST", "0.0.0.0"))
|
||||
ap.add_argument("--port", type=int, default=int(os.environ.get("PORT", "8080")))
|
||||
args = ap.parse_args(argv)
|
||||
|
||||
root = os.path.abspath(args.root)
|
||||
if not os.path.isdir(root):
|
||||
sys.exit("site root not found: %s" % root)
|
||||
names = args.roots or os.environ.get("SITE_ROOTS", "assets:books").split(":")
|
||||
roots = tuple(os.path.abspath(name) for name in names if name)
|
||||
for root in roots:
|
||||
if not os.path.isdir(root):
|
||||
sys.exit("site root not found: %s" % root)
|
||||
|
||||
httpd = ThreadingHTTPServer((args.host, args.port),
|
||||
partial(Handler, directory=root))
|
||||
partial(type("Rooted", (Handler,), {"roots": roots}),
|
||||
directory=roots[0]))
|
||||
httpd.daemon_threads = True
|
||||
|
||||
def stop(_signum, _frame):
|
||||
@@ -82,7 +212,7 @@ def main(argv=None):
|
||||
signal.signal(signal.SIGTERM, stop)
|
||||
signal.signal(signal.SIGINT, stop)
|
||||
|
||||
print("serving %s at http://%s:%d" % (root, args.host, args.port))
|
||||
print("serving %s at http://%s:%d" % (" + ".join(roots), args.host, args.port))
|
||||
sys.stdout.flush()
|
||||
try:
|
||||
httpd.serve_forever()
|
||||
|
||||
Reference in new issue
Block a user