This commit is contained in:
mark committed 2026-09-04 08:08:32 -04:00
1 parent 77bf8d9f2e
commit ea193e1264
53 files changed
+545 -347

No files matched your search

+62 -43
View File
@@ -9,27 +9,30 @@ 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 first is the shell. The pages here are fragments; shell.html is the one
document that wraps them, and app.js fills it in from the URL. So every
navigation is answered with shell.html, whatever path it asked for -- exactly
what the service worker does once it is installed, which is why a first visit
needs no special handling and no reload. Requests the worker and htmx make 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().
Doing it here rather than in a build step means editing the shell 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 pages that register it are served stale, a new version can never
worker or the shell 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. 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.
Expects to sit behind something that terminates TLS. Without it the app still
works -- the shell and the fragments are plain files -- 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
@@ -47,17 +50,18 @@ 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"}
NO_CACHE_EXTS = {".html", ".webmanifest"}
# Served in place of any page a browser navigates to directly.
BOOT_PAGE = "/boot.html"
# Served in place of any page a browser navigates to.
SHELL_PAGE = "/shell.html"
WORKER = "sw.js"
# The two lines stamp_worker() rewrites. They are real statements rather than
# 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';"
ASSETS_LINE = 'const ASSETS = ["./"];'
LIBRARY_VERSION_LINE = "const LIBRARY_VERSION = 'dev';"
LIBRARY_LINE = "const LIBRARY = [];"
def walk(roots):
@@ -75,33 +79,49 @@ def walk(roots):
return found
def stamp_worker(roots):
"""assets/sw.js with VERSION and ASSETS filled in from what is on disk.
def digest(files):
"""A short fingerprint of a set of files, by name, size and mtime.
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
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 precache, which is harmless.
changing it costs one needless refetch, which is harmless.
"""
files = walk(roots)
digest = hashlib.sha256()
assets = []
out = hashlib.sha256()
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
out.update(("%s\0%d\0%d\0" % (rel, st.st_size, st.st_mtime_ns)).encode())
return out.hexdigest()[:12]
src = files.get(WORKER)
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 rest is what render_books.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.
"""
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.hexdigest()[:12]),
(ASSETS_LINE, "const ASSETS = %s;" % json.dumps(assets, indent=2))):
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("./" + 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)
@@ -112,7 +132,6 @@ 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",
@@ -139,13 +158,13 @@ class Handler(SimpleHTTPRequestHandler):
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
# 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. 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":
self.path = SHELL_PAGE
if posixpath.basename(self.path.split("?", 1)[0]) == WORKER:
return self.send_worker()