#!/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 utils/compile_bible.py generates from source/. 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 and the shell around them is a template, so a browser asking for one directly would get a bare fragment with no chrome. Navigations are answered with boot.html, which loads the same renderer the service worker uses, composes the page that was asked for, and replaces itself with it -- so a first visit is the real app at the real URL, with no reload. Requests the worker and htmx make are ordinary fetches rather than navigations, so they pass straight through and get the fragment. Once a worker is installed it intercepts the navigation first and composes it there, and this page is not reached at all. 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. It also stamps in the list of pages the worker downloads to keep the whole corpus available offline. See stamp_worker(). The third is cache headers: the worker owns the offline copy, and if the worker or the boot page that registers 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. Without it the app still works -- boot.html composes every page in the browser -- but there is no offline copy: browsers only register a service worker in a secure context, and the bookmark lives in that worker, so over plain http:// from a LAN address the app reads online-only and the bookmark slot stays empty. """ 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"} # .njk is in here because the worker reads templates at runtime: a stale one # would outlive the version that replaced it. NO_CACHE_EXTS = {".html", ".njk", ".webmanifest"} # Served in place of any page a browser navigates to. BOOT_PAGE = "/boot.html" WORKER = "sw.js" # The three 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';" LIBRARY_VERSION_LINE = "const LIBRARY_VERSION = 'dev';" LIBRARY_LINE = "const LIBRARY = [];" 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 digest(files): """A short fingerprint of a set of files, by name, size and mtime. Not by 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 refetch, which is harmless. """ out = hashlib.sha256() for rel in sorted(files): st = os.stat(files[rel]) out.update(("%s\0%d\0%d\0" % (rel, st.st_size, st.st_mtime_ns)).encode()) return out.hexdigest()[:12] def page_url(rel): """The address a served file answers to. books/GEN.html is ./GEN, and books/index.html is the root itself -- there is no /index, the book list lives at /. """ if rel == "index.html": return "./" return "./" + (rel[:-len(".html")] if rel.endswith(".html") else rel) def stamp_worker(roots): """assets/sw.js with its two versions and its file list filled in. The split is by root, which is the same split the project already makes: the first root is the hand-written chrome -- the worker, the boot page, and the css/, js/ and templates/ directories -- and the rest is what compile_bible.py generates. They get separate versions because they change on completely different schedules -- editing the shell should not cost every installed device the whole corpus again, which one shared version would. LIBRARY is the list the worker downloads in the background. Stamped rather than written by hand for the same reason the versions are: what is on disk is the only honest source, and there is no build step to ask. It is written in the URL space, not the disk one -- books/GEN.html is "./GEN" and the book list is "./" -- so that the worker caches a page under the address the reader actually asks for. """ chrome = walk(roots[:1]) library = {rel: path for rel, path in walk(roots).items() if rel not in chrome} src = chrome.pop(WORKER, None) # the browser revalidates the worker itself 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(chrome)), (LIBRARY_VERSION_LINE, "const LIBRARY_VERSION = '%s';" % digest(library)), (LIBRARY_LINE, "const LIBRARY = %s;" % json.dumps(sorted(page_url(rel) for rel in library), 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", ".css": "text/css; charset=utf-8", ".js": "text/javascript; charset=utf-8", ".json": "application/json; charset=utf-8", # Only ever fetched as text, by the renderer. ".njk": "text/plain; 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.isfile(candidate): return candidate # Pages are addressed without an extension -- /GEN, not /GEN.html. # The worker's router owns a clean URL space and the file on disk # stays an ordinary fragment; this is the one line that ties the # two together. if os.path.isfile(candidate + ".html"): return candidate + ".html" if os.path.isfile(os.path.join(candidate, "index.html")): 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 opening a # URL from the worker or htmx fetching a fragment: neither of those is # ever in navigate mode. A browser too old to send it gets the bare # fragment, unstyled and without chrome -- readable, but not the app. # Guessing from Accept and HX-Request instead would misroute one of # htmx's own requests the first time it guessed wrong, which is worse. if self.headers.get("Sec-Fetch-Mode") == "navigate": 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() # A dot in the last segment means a file; anything else is a page. The # same test the worker's isFile() makes, and it has to stay the same # one. Matching on the extension alone was right while pages were # /GEN.html; the moment the URL space went extensionless it silently # handed every book a week of browser caching that no rebuild could # get past -- the worker's own fetch reads the browser cache too, so # even a fresh library cache was being filled from a stale one. if name in NO_CACHE_NAMES or ext in NO_CACHE_EXTS or "." not in name: self.send_header("Cache-Control", "no-cache") else: self.send_header("Cache-Control", "public, max-age=604800") # One URL, two bodies: a navigation to /GEN gets the boot page and a # fetch of it gets the fragment. Without this the browser cache keeps # one entry for both, and boot.html asking for its own fragment is # handed back a copy of itself -- a document nested inside a document. self.send_header("Vary", "Sec-Fetch-Mode") # 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())