Decent
This commit is contained in:
1 parent
77bf8d9f2e
commit
ea193e1264
53 files changed
+545
-347
No files matched your search
@@ -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()
|
||||
|
||||
Reference in new issue
Block a user