Remove comments
This commit is contained in:
1 parent
872175153a
commit
057b4be7d7
18 files changed
+66
-1350
No files matched your search
@@ -1,42 +1,4 @@
|
||||
#!/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
|
||||
bible/, 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 instead, which
|
||||
registers the service worker and reloads once it is running -- landing on the
|
||||
real app at the address that was asked for. 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: it is seen
|
||||
once per device.
|
||||
|
||||
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, and does not work without
|
||||
it. Every page is composed by the service worker, browsers only register one in
|
||||
a secure context, and boot.html has no fallback that builds a page without one
|
||||
-- so over plain http:// from a LAN address the boot page says it could not
|
||||
install and stops there. localhost is a secure context, which is why running
|
||||
this directly for development still works.
|
||||
"""
|
||||
"""Static file server for the Bible site."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -51,31 +13,18 @@ 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. .json is verses.json, which
|
||||
# compile_bible.py generates beside the pages and which describes them, so it
|
||||
# goes stale exactly when they do.
|
||||
NO_CACHE_EXTS = {".html", ".njk", ".json", ".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):
|
||||
@@ -85,54 +34,23 @@ def walk(roots):
|
||||
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.
|
||||
|
||||
bible/GEN.html is ./GEN, and bible/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 -- bible/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
|
||||
src = chrome.pop(WORKER, None)
|
||||
if src is None:
|
||||
return None
|
||||
with open(src, encoding="utf-8") as fh:
|
||||
@@ -149,7 +67,6 @@ def stamp_worker(roots):
|
||||
body = body.replace(line, value, 1)
|
||||
return body.encode("utf-8")
|
||||
|
||||
|
||||
class Handler(SimpleHTTPRequestHandler):
|
||||
extensions_map = {
|
||||
**SimpleHTTPRequestHandler.extensions_map,
|
||||
@@ -157,7 +74,6 @@ class Handler(SimpleHTTPRequestHandler):
|
||||
".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",
|
||||
@@ -168,20 +84,11 @@ class Handler(SimpleHTTPRequestHandler):
|
||||
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")):
|
||||
@@ -190,12 +97,6 @@ class Handler(SimpleHTTPRequestHandler):
|
||||
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
|
||||
|
||||
@@ -206,8 +107,8 @@ class Handler(SimpleHTTPRequestHandler):
|
||||
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
|
||||
except Exception as err:
|
||||
self.log_message("stamp failed: %s", err)
|
||||
self.send_error(500, "service worker unavailable")
|
||||
return None
|
||||
if body is None:
|
||||
@@ -223,24 +124,13 @@ class Handler(SimpleHTTPRequestHandler):
|
||||
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.
|
||||
# Pages have no extension, so testing the extension alone treats them
|
||||
# as files and caches them for a week.
|
||||
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 the worker asking for the fragment it is
|
||||
# about to compose is handed back the boot page instead -- which is a
|
||||
# document nested inside a document, or a spinner where the text goes.
|
||||
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()
|
||||
@@ -249,7 +139,6 @@ class Handler(SimpleHTTPRequestHandler):
|
||||
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)
|
||||
@@ -285,6 +174,5 @@ def main(argv=None):
|
||||
httpd.server_close()
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
Reference in new issue
Block a user