Better
This commit is contained in:
1 parent
ea193e1264
commit
2fea308b38
106 files changed
+1234
-878
No files matched your search
@@ -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")
|
||||
|
||||
Reference in new issue
Block a user