This commit is contained in:
mark committed 2026-09-04 09:05:14 -04:00
1 parent ea193e1264
commit 2fea308b38
106 files changed
+1234 -878

No files matched your search

+60 -25
View File
@@ -9,30 +9,32 @@ between them -- every file exists once, in the directory that owns it.
Beyond serving files it has three jobs.
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 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 the shell is the whole
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 shell that registers it are served stale, a new version can never
reach a device that already installed the old one.
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 -- 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.
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
@@ -50,10 +52,12 @@ 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"}
# .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.
SHELL_PAGE = "/shell.html"
BOOT_PAGE = "/boot.html"
WORKER = "sw.js"
@@ -94,18 +98,33 @@ def digest(files):
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 rest is what render_books.py
generates. They get separate versions because they change on completely
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
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.
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}
@@ -120,7 +139,7 @@ def stamp_worker(roots):
(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)),
% 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))
@@ -135,6 +154,8 @@ 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",
".svg": "image/svg+xml",
@@ -152,7 +173,15 @@ class Handler(SimpleHTTPRequestHandler):
for root in self.roots:
self.directory = root
candidate = super().translate_path(path)
if os.path.exists(candidate):
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)
@@ -160,11 +189,12 @@ class Handler(SimpleHTTPRequestHandler):
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. Browsers without the header also have no
# service worker, and get the fragment -- there is nothing better to
# give them.
# 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 = SHELL_PAGE
self.path = BOOT_PAGE
if posixpath.basename(self.path.split("?", 1)[0]) == WORKER:
return self.send_worker()
@@ -194,6 +224,11 @@ class Handler(SimpleHTTPRequestHandler):
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.html 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")