Files
bible/server.py
T
2026-09-03 17:24:22 -04:00

226 lines
8.7 KiB
Python

#!/usr/bin/env python3
"""Static file server for the Bible site.
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,
so a device loading this over plain http:// from a LAN address installs the
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
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", ".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",
".webmanifest": "application/manifest+json; charset=utf-8",
".png": "image/png",
".svg": "image/svg+xml",
}
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 = 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:
self.send_header("Cache-Control", "public, max-age=604800")
# Lets the worker claim the whole origin even if it moves off root.
self.send_header("Service-Worker-Allowed", "/")
self.send_header("X-Content-Type-Options", "nosniff")
super().end_headers()
def log_message(self, fmt, *args):
sys.stdout.write("%s %s\n" % (self.address_string(), fmt % args))
sys.stdout.flush()
def main(argv=None):
ap = argparse.ArgumentParser(description=__doc__,
formatter_class=argparse.RawDescriptionHelpFormatter)
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)
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(type("Rooted", (Handler,), {"roots": roots}),
directory=roots[0]))
httpd.daemon_threads = True
def stop(_signum, _frame):
httpd.shutdown()
signal.signal(signal.SIGTERM, stop)
signal.signal(signal.SIGINT, stop)
print("serving %s at http://%s:%d" % (" + ".join(roots), args.host, args.port))
sys.stdout.flush()
try:
httpd.serve_forever()
finally:
httpd.server_close()
return 0
if __name__ == "__main__":
sys.exit(main())