Remove non-TLS support

This commit is contained in:
mark committed 2026-09-09 10:24:27 -04:00
1 parent 84c07a912b
commit b59ef914da
9 files changed
+88 -171

No files matched your search

+22 -57
View File
@@ -19,13 +19,22 @@
The pages on disk are fragments and the shell is a template, and it is the The pages on disk are fragments and the shell is a template, and it is the
worker that puts them together. So this page has one job: register the worker that puts them together. So this page has one job: register the
worker and reload once it is running, which lands on the real app at the worker and reload once it is running, which lands on the real app at the
address that was asked for. It is only ever seen once per device. --> address that was asked for. It is only ever seen once per device.
There is no fallback for a device that cannot install one. There used to
be -- this page loaded the renderer itself and composed the page in the
browser -- and it existed for one case: the app served over plain http://
from a LAN address, which is not a secure context, so no worker can
register. This app is served over TLS, where that case cannot arise, and
the fallback was a second way of building every page that nothing
exercised. What is left when registration fails is a sentence saying so,
because there is nothing honest to show instead. -->
<main class="boot container py-5 text-center"> <main class="boot container py-5 text-center">
<div class="spinner-border text-secondary mb-3" role="status" aria-hidden="true"></div> <div class="spinner-border text-secondary mb-3" role="status" aria-hidden="true"></div>
<p id="boot-status" class="text-body-secondary">Preparing the offline copy…</p> <p id="boot-status" class="text-body-secondary">Preparing the offline copy…</p>
<noscript><p class="alert alert-warning d-inline-block">This app builds its <noscript><p class="alert alert-warning d-inline-block">This app builds its
pages in the browser and needs JavaScript.</p></noscript> pages on the device and needs JavaScript.</p></noscript>
</main> </main>
<script> <script>
@@ -50,7 +59,7 @@
} }
function start() { function start() {
if (tries >= 2) return compose('The offline copy could not start.'); if (tries >= 2) return fail('The offline copy could not start.');
try { sessionStorage.setItem('boot:tries', String(tries + 1)); } catch (err) {} try { sessionStorage.setItem('boot:tries', String(tries + 1)); } catch (err) {}
navigator.serviceWorker.register('sw.js').then(function (registration) { navigator.serviceWorker.register('sw.js').then(function (registration) {
@@ -58,70 +67,26 @@
// controlled yet. Otherwise wait for the one installing to get there. // controlled yet. Otherwise wait for the one installing to get there.
if (registration.active) return go(); if (registration.active) return go();
var worker = registration.installing || registration.waiting; var worker = registration.installing || registration.waiting;
if (!worker) return compose('The offline copy did not install.'); if (!worker) return fail('The offline copy did not install.');
worker.addEventListener('statechange', function (event) { worker.addEventListener('statechange', function (event) {
if (event.target.state === 'activated') go(); if (event.target.state === 'activated') go();
}); });
}, function (err) { }, function (err) {
compose('Could not install the offline copy: ' + err.message); fail('Could not install the offline copy: ' + err.message);
}); });
} }
// Everything below is the path where there will never be a worker: a browser // The end of the road. Every page in this app is put together by the
// without them, or -- much more likely -- the app served over plain http:// // worker, so a device that cannot install one has nothing to read here --
// from a LAN address, which is not a secure context. Rather than spin here // and a spinner that turns forever says less than the reason does.
// forever, the page composes itself, using the same renderer and the same function fail(why) {
// templates the worker would have used. No offline copy and no bookmark, but var spinner = document.querySelector('.boot .spinner-border');
// the text reads. Delete this if the app is only ever served over TLS. if (spinner) spinner.remove();
function compose(why) { status(why);
console.warn(why);
status('Loading…');
script('js/nunjucks.min.js')
.then(function () { return script('js/render.js'); })
.then(function () {
var view = createRenderer(readText);
var page = location.pathname.split('/').pop();
return readText('./' + page + location.search)
.catch(function () {
return view.notice('Not available',
'This page could not be loaded. Check your connection and try again.');
})
// mark is null: the bookmark lives in the worker's IndexedDB, and
// there is no worker to ask. bookmark.njk renders the slot hidden
// and it fills itself the moment one appears.
.then(function (fragment) { return view.compose(fragment, null); });
})
.then(become, function (err) { status('Could not start: ' + err.message); });
}
function script(src) {
return new Promise(function (resolve, reject) {
var el = document.createElement('script');
el.src = src;
el.onload = function () { resolve(); };
el.onerror = function () { reject(new Error('cannot load ' + src)); };
document.head.appendChild(el);
});
}
function readText(path) {
return fetch(path, { credentials: 'same-origin' }).then(function (res) {
if (!res.ok) throw new Error(path + ': ' + res.status);
return res.text();
});
}
// Replaces this document in place: same URL, same history entry, no second
// request. Written rather than assigned into the DOM because the shell
// carries <script> tags, and scripts put into a page any other way never run.
function become(html) {
document.open();
document.write(html);
document.close();
} }
if ('serviceWorker' in navigator) start(); if ('serviceWorker' in navigator) start();
else compose('This browser has no service worker support.'); else fail('This browser has no service worker support.');
})(); })();
</script> </script>
</body> </body>
+10 -27
View File
@@ -1,7 +1,7 @@
// Page startup. // Page startup.
// //
// Pages arrive whole -- composed by the service worker, or by boot.html on the // Pages arrive whole -- the service worker composes every one of them -- so
// visit before there is one -- so there is nothing here that builds a page. // there is nothing here that builds a page.
// What is left is the three things a composed document cannot do for itself: // What is left is the three things a composed document cannot do for itself:
// put a swapped-in page where it belongs before it is painted, land the // put a swapped-in page where it belongs before it is painted, land the
// reference picker where the reader asked to go, and tell the chrome when a // reference picker where the reader asked to go, and tell the chrome when a
@@ -94,11 +94,10 @@
} }
} }
// A worker-composed page is an ordinary document and the browser scrolls to // The browser scrolls to the anchor itself on a document it navigated to,
// the anchor itself. One composed by boot.html is not: it replaced a // so this is for the one it does not: a reload that landed on boot.html and
// document the browser had already finished with, so the fragment identifier // was reloaded again into the real page. Costs nothing when the browser has
// has been spent. Repeating it costs nothing in the first case and is what // already been there.
// makes a link to GEN#v1_1 land on the verse in the second.
function scrollToHash() { function scrollToHash() {
var el = hashTarget(); var el = hashTarget();
if (el) jumpTo(el); if (el) jumpTo(el);
@@ -166,25 +165,12 @@
// //
// Toggling the class rather than the style because Bootstrap declares // Toggling the class rather than the style because Bootstrap declares
// .d-none !important, which an inline display would lose to. // .d-none !important, which an inline display would lose to.
//
// Only the digest line is missing on a page boot.html composed -- there was
// no worker to name a version -- so the element itself is always here.
function showVersion() { function showVersion() {
var el = document.getElementById('app-version'); var el = document.getElementById('app-version');
if (!el) return; if (!el) return;
el.classList.toggle('d-none', !document.querySelector('#content .index')); el.classList.toggle('d-none', !document.querySelector('#content .index'));
} }
// api/bookmark has no origin server -- it is answered by the worker -- so
// there is nothing to ask until the worker is controlling this page.
// controller, not ready: ready can resolve a tick before clients.claim()
// takes effect, and a request sent in that gap reaches the network and 404s.
function whenControlled(fn) {
if (!('serviceWorker' in navigator)) return;
if (navigator.serviceWorker.controller) return fn();
navigator.serviceWorker.addEventListener('controllerchange', fn, { once: true });
}
// Deferred scripts run before DOMContentLoaded fires, so this always lands, // Deferred scripts run before DOMContentLoaded fires, so this always lands,
// and it lands after htmx has processed the shell. // and it lands after htmx has processed the shell.
document.addEventListener('DOMContentLoaded', function () { document.addEventListener('DOMContentLoaded', function () {
@@ -192,15 +178,12 @@
showVersion(); showVersion();
// Registered from the page it is going to serve, not from a page of its // Registered from the page it is going to serve, not from a page of its
// own: there is nothing to wait for and nothing to reload. This load is // own: this load is already the real app, composed by the worker that is
// already the real app -- the worker installs alongside it, and takes over // controlling it. What this catches is the copy on disk having changed
// composing, the bookmark and the offline download from the next // since -- the browser refetches sw.js, sees different bytes, and installs
// navigation on. // the new one alongside, which takes over from the next navigation on.
if ('serviceWorker' in navigator) { if ('serviceWorker' in navigator) {
navigator.serviceWorker.register('sw.js').catch(function () {}); navigator.serviceWorker.register('sw.js').catch(function () {});
} }
whenControlled(function () {
document.body.dispatchEvent(new CustomEvent('worker-ready'));
});
}); });
})(); })();
+16 -19
View File
@@ -1,17 +1,15 @@
// The renderer, shared by the two things that ever build a page: the service // The renderer: the templates in templates/, and the two things sw.js does
// worker, which composes every navigation once it is installed, and boot.html, // with them -- compose a fragment into the shell, and stand a notice in for a
// which composes the one navigation that arrives before there is a worker to // page that could not be delivered.
// do it.
// //
// Sharing it is the whole point. A shell only the worker could build is what // A file of its own rather than more of sw.js because it is the half with
// made a first visit show a spinner and reload; with the same templates and // nothing to do with the network: no routes, no caches, no requests. It was
// the same code reachable from the page, the first visit is already the app // loaded by boot.html as well once, which composed pages in the browser for a
// and the worker is what makes the second one fast and the hundredth one // device that could not install a worker; nothing but the worker builds a page
// offline. // now.
// //
// Loaded as a plain script by both -- importScripts in the worker, a <script> // Loaded with importScripts, so it defines a global rather than exporting.
// tag in the page -- so it defines a global rather than exporting. Nunjucks // Nunjucks must already be loaded.
// must already be loaded.
(function (scope) { (function (scope) {
'use strict'; 'use strict';
@@ -123,10 +121,9 @@
// //
// chrome is what the shell needs and the fragment cannot carry: the // chrome is what the shell needs and the fragment cannot carry: the
// bookmark, the navbar picker's model, and the two versions the worker // bookmark, the navbar picker's model, and the two versions the worker
// was built with. All three are null from boot.html, which has no worker // was built with. reference is null on the book list, which is the
// to ask for any of them; the first two render as a hidden slot that // picker's hidden state; the other two are always there, because the
// fills itself if one ever appears, and the third renders as nothing, // worker composing this is the thing that knows them.
// because no worker is the whole of what there is to report.
compose: function (fragment, chrome) { compose: function (fragment, chrome) {
var title = (TITLE_RE.exec(fragment) || ['', ''])[1].trim(); var title = (TITLE_RE.exec(fragment) || ['', ''])[1].trim();
var content = fragment.replace(TITLE_RE, ''); var content = fragment.replace(TITLE_RE, '');
@@ -134,9 +131,9 @@
return render(name, { return render(name, {
title: title, title: title,
content: content, content: content,
mark: (chrome && chrome.mark) || null, mark: chrome.mark,
reference: (chrome && chrome.reference) || null, reference: chrome.reference,
version: (chrome && chrome.version) || null version: chrome.version
}); });
}, },
+14 -24
View File
@@ -9,8 +9,7 @@
// //
// GET / the book list // GET / the book list
// GET /:page one book -- /GEN, /EXO // GET /:page one book -- /GEN, /EXO
// GET /api/bookmark the navbar slot // POST /api/bookmark set the bookmark, and return the navbar slot
// POST /api/bookmark set the bookmark, and return the slot
// GET /api/reference the navbar's chapter and verse picker // GET /api/reference the navbar's chapter and verse picker
// GET /api/verse the dialog for one verse // GET /api/verse the dialog for one verse
// GET /api/toast one confirmation, by name // GET /api/toast one confirmation, by name
@@ -28,11 +27,9 @@
// navigation -- one route, both callers, which is the whole reason the request // navigation -- one route, both callers, which is the whole reason the request
// object carries `navigate`. // object carries `navigate`.
// //
// The composing is in js/render.js, which boot.html can load too. Normally it // The composing is in js/render.js. Every page in the app comes through it:
// does not: boot.html registers this worker and reloads into it. It composes in // boot.html registers this worker and reloads into it, and from there nothing
// the page when no worker is possible at all -- no secure context, or a browser // else ever builds one.
// without them -- and then it does so from these same templates rather than
// leaving a spinner up forever.
// //
// Every same-origin GET is network-first: ask the network, fall back to the // Every same-origin GET is network-first: ask the network, fall back to the
// cache when it fails. Offline, fetch rejects immediately, so the fallback is // cache when it fails. Offline, fetch rejects immediately, so the fallback is
@@ -461,16 +458,12 @@ router.get('/', (call) => servePage('./', '', call));
router.get('/:page', (call) => router.get('/:page', (call) =>
servePage('./' + call.params.page, call.params.page, call)); servePage('./' + call.params.page, call.params.page, call));
router.get('/api/bookmark', () => bookmarkFragment(''));
// The picker, and the two moves it makes. // The picker, and the two moves it makes.
// //
// no parameters the whole picker, standing where the anchor in the address // no parameters the whole picker, standing where the anchor in the address
// bar puts it. Asked for by the picker itself: on load, // bar puts it. Asked for by the picker itself: on load, and
// after a history restore -- which swaps the history element // after a history restore -- which swaps the history element
// straight in and processes no out-of-band elements -- and // straight in and processes no out-of-band elements.
// when a worker first appears under a page boot.html
// composed.
// at=ch3 the verses of that chapter, and land on the chapter. // at=ch3 the verses of that chapter, and land on the chapter.
// at=v3_16 the same verses, that one selected, and land on the verse. // at=v3_16 the same verses, that one selected, and land on the verse.
// //
@@ -490,9 +483,8 @@ router.get('/api/bookmark', () => bookmarkFragment(''));
// is rendered at 1:1 like any other. The page then asks this route for itself, // is rendered at 1:1 like any other. The page then asks this route for itself,
// and that request is the one carrying the anchor. // and that request is the one carrying the anchor.
// //
// Which also puts the picker where it belongs after a back or forward, and // Which also puts the picker where it belongs after a back or forward, for
// under a page boot.html composed once a worker turns up, for nothing extra: // nothing extra: those requests carry the address bar too.
// those requests carry the address bar too.
// //
// Two response headers carry what a change means beyond the options: // Two response headers carry what a change means beyond the options:
// //
@@ -734,13 +726,11 @@ function getBookmark() {
.then((mark) => mark || DEFAULT_BOOKMARK); .then((mark) => mark || DEFAULT_BOOKMARK);
} }
// The navbar slot, and the toast that says what just happened to it. The
// toast rides out of band, so one response updates the slot and raises it
// without the page having to ask for the second one -- same template and same
// table api/toast answers from, so the two cannot drift apart.
async function bookmarkFragment(say) { async function bookmarkFragment(say) {
let html = await view.render('bookmark.njk', { mark: await getBookmark() }); return await view.render('bookmark.njk', { mark: await getBookmark() })
if (say) { + '\n' + await toastFragment(say, true);
// Out of band, so one response updates the navbar slot and raises the
// toast without the page having to ask for the second one. Same template
// and same table api/toast answers from, so the two cannot drift apart.
html += '\n' + await toastFragment(say, true);
}
return html;
} }
+6 -17
View File
@@ -2,22 +2,12 @@
worker holds, and returned on its own by api/bookmark after a write. Both worker holds, and returned on its own by api/bookmark after a write. Both
paths must produce the same element for the swap to be seamless. paths must produce the same element for the swap to be seamless.
mark is null in one case only: boot.html composed this page, so there was Always filled: getBookmark() answers with the default when nothing has been
no worker to ask. The slot is then hidden rather than absent and fetches set and when storage cannot be read at all, so there is no empty state to
itself once app.js reports a worker is controlling -- which is why the htmx render. There was one -- a hidden slot that fetched itself once a worker
attributes are on the empty state and not the filled one. A filled slot is turned up -- for pages boot.html composed before there was a worker to ask.
only ever replaced by the modal's form, which names #bookmark itself. Nothing composes a page but the worker now, and the worker always knows. #}
<span class="bookmark navbar-text d-flex align-items-center gap-1" id="bookmark">
Empty state is d-none, not the hidden attribute: Bootstrap declares .d-flex
after Reboot's [hidden] rule, both !important and at the same specificity,
so the utility would win. #}
<span class="bookmark navbar-text {% if mark %}d-flex{% else %}d-none{% endif %} align-items-center gap-1"
id="bookmark"
{%- if not mark %}
hx-get="api/bookmark" hx-trigger="worker-ready from:body"
hx-target="this" hx-swap="outerHTML"
{%- endif %}>
{%- if mark %}
{# A word and a colon, not a badge: this is one line of text that happens to {# A word and a colon, not a badge: this is one line of text that happens to
end in a link, and a pill in front of it read as a control. Outside the end in a link, and a pill in front of it read as a control. Outside the
anchor so that only the reference is underlined -- Reboot underlines an anchor so that only the reference is underlined -- Reboot underlines an
@@ -28,5 +18,4 @@
anchor, which a swap into #content would not do. #} anchor, which a swap into #content would not do. #}
<a class="bookmark-link link-light text-truncate" href="{{ mark.href }}" <a class="bookmark-link link-light text-truncate" href="{{ mark.href }}"
hx-boost="false">{{ mark.ref }}</a> hx-boost="false">{{ mark.ref }}</a>
{%- endif %}
</span> </span>
+2 -3
View File
@@ -28,8 +28,7 @@
line, so one response moves both. line, so one response moves both.
a restore htmx swaps the history element straight in and processes a restore htmx swaps the history element straight in and processes
no out-of-band elements at all, so the picker asks for no out-of-band elements at all, so the picker asks for
itself instead. worker-ready is in the same trigger, for a itself instead.
page boot.html composed before there was a worker to ask.
an anchor a link to GEN#v3_16 should open with the picker reading an anchor a link to GEN#v3_16 should open with the picker reading
3:16, and the fragment never reaches the worker: a browser 3:16, and the fragment never reaches the worker: a browser
asks for /GEN and keeps the rest. So a picker that arrived asks for /GEN and keeps the rest. So a picker that arrived
@@ -57,7 +56,7 @@
id="reference" id="reference"
{%- if oob %} hx-swap-oob="true"{% endif %} {%- if oob %} hx-swap-oob="true"{% endif %}
hx-get="api/reference" hx-get="api/reference"
hx-trigger="{% if reference and not settled %}load[!!location.hash], {% endif %}worker-ready from:body, htmx:historyRestore from:body" hx-trigger="{% if reference and not settled %}load[!!location.hash], {% endif %}htmx:historyRestore from:body"
hx-target="this" hx-swap="outerHTML"> hx-target="this" hx-swap="outerHTML">
{%- if reference %} {%- if reference %}
<span class="reference-book text-light fw-semibold" <span class="reference-book text-light fw-semibold"
-7
View File
@@ -10,11 +10,6 @@
without this document being rebuilt, so the value only becomes current on without this document being rebuilt, so the value only becomes current on
the next full load -- which is the reload you would do to check it anyway. the next full load -- which is the reload you would do to check it anyway.
version is null in one case: boot.html composed this page, so there was no
worker to ask. Nothing is rendered at all then, which is the honest answer
-- no worker means no version, and the bookmark slot in the navbar is empty
for the same reason. app.js finds no element and leaves it at that.
Rendered on every page and shown under the book list alone. Populating it Rendered on every page and shown under the book list alone. Populating it
only for the index would look like the smaller change and would not work: only for the index would look like the smaller change and would not work:
the shell is composed once per full navigation, and a boosted link to the the shell is composed once per full navigation, and a boosted link to the
@@ -22,10 +17,8 @@
decides, not the composer -- app.js looks for the index in #content after decides, not the composer -- app.js looks for the index in #content after
every swap. The body class this was rendered with cannot be asked, for the every swap. The body class this was rendered with cannot be asked, for the
same reason: it goes stale the moment a link is clicked. #} same reason: it goes stale the moment a link is clicked. #}
{%- if version %}
<div class="app-version d-none text-center mt-5 mb-2" id="app-version"> <div class="app-version d-none text-center mt-5 mb-2" id="app-version">
<p class="app-version-digests form-text m-0"> <p class="app-version-digests form-text m-0">
shell {{ version.shell }} &middot; library {{ version.library }} shell {{ version.shell }} &middot; library {{ version.library }}
</p> </p>
</div> </div>
{%- endif %}
+16 -14
View File
@@ -11,13 +11,13 @@ Beyond serving files it has three jobs.
The first is the boot page. The pages here are fragments and the shell around 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 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 fragment with no chrome. Navigations are answered with boot.html instead, which
the same renderer the service worker uses, composes the page that was asked registers the service worker and reloads once it is running -- landing on the
for, and replaces itself with it -- so a first visit is the real app at the real app at the address that was asked for. Requests the worker and htmx make
real URL, with no reload. Requests the worker and htmx make are ordinary are ordinary fetches rather than navigations, so they pass straight through and
fetches rather than navigations, so they pass straight through and get the get the fragment. Once a worker is installed it intercepts the navigation
fragment. Once a worker is installed it intercepts the navigation first and first and composes it there, and this page is not reached at all: it is seen
composes it there, and this page is not reached at all. once per device.
The second is stamping the service worker. A browser only notices a new worker 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. when the bytes of sw.js change, so something has to record what is on disk.
@@ -30,11 +30,12 @@ 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 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. never reach a device that already installed the old one.
Expects to sit behind something that terminates TLS. Without it the app still Expects to sit behind something that terminates TLS, and does not work without
works -- boot.html composes every page in the browser -- but there is no it. Every page is composed by the service worker, browsers only register one in
offline copy: browsers only register a service worker in a secure context, and a secure context, and boot.html has no fallback that builds a page without one
the bookmark lives in that worker, so over plain http:// from a LAN address the -- so over plain http:// from a LAN address the boot page says it could not
app reads online-only and the bookmark slot stays empty. install and stops there. localhost is a secure context, which is why running
this directly for development still works.
""" """
from __future__ import annotations from __future__ import annotations
@@ -235,8 +236,9 @@ class Handler(SimpleHTTPRequestHandler):
self.send_header("Cache-Control", "public, max-age=604800") self.send_header("Cache-Control", "public, max-age=604800")
# One URL, two bodies: a navigation to /GEN gets the boot page and a # 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 # 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 # one entry for both, and the worker asking for the fragment it is
# handed back a copy of itself -- a document nested inside a document. # 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") self.send_header("Vary", "Sec-Fetch-Mode")
# Lets the worker claim the whole origin even if it moves off root. # Lets the worker claim the whole origin even if it moves off root.
self.send_header("Service-Worker-Allowed", "/") self.send_header("Service-Worker-Allowed", "/")
+2 -3
View File
@@ -23,9 +23,8 @@ different jobs and the seam is where a second output format would attach:
WRITING That tree becomes one semantic fragment per book: a <title> and WRITING That tree becomes one semantic fragment per book: a <title> and
one root element carrying data-template, not a document. The one root element carrying data-template, not a document. The
shell around it is assembled from the nunjucks templates in shell around it is assembled from the nunjucks templates in
assets/templates/ -- by the service worker, or by boot.html when assets/templates/ by the service worker, so chrome changes never
there can never be one -- so chrome changes never require running require running this. The markup carries structure and no presentation: every
this. The markup carries structure and no presentation: every
USFM distinction survives as a class or data attribute, so the USFM distinction survives as a class or data attribute, so the
whole corpus can be restyled from the stylesheet without being whole corpus can be restyled from the stylesheet without being
regenerated. regenerated.