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
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
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">
<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>
<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>
<script>
@@ -50,7 +59,7 @@
}
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) {}
navigator.serviceWorker.register('sw.js').then(function (registration) {
@@ -58,70 +67,26 @@
// controlled yet. Otherwise wait for the one installing to get there.
if (registration.active) return go();
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) {
if (event.target.state === 'activated') go();
});
}, 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
// without them, or -- much more likely -- the app served over plain http://
// from a LAN address, which is not a secure context. Rather than spin here
// forever, the page composes itself, using the same renderer and the same
// templates the worker would have used. No offline copy and no bookmark, but
// the text reads. Delete this if the app is only ever served over TLS.
function compose(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();
// The end of the road. Every page in this app is put together by the
// worker, so a device that cannot install one has nothing to read here --
// and a spinner that turns forever says less than the reason does.
function fail(why) {
var spinner = document.querySelector('.boot .spinner-border');
if (spinner) spinner.remove();
status(why);
}
if ('serviceWorker' in navigator) start();
else compose('This browser has no service worker support.');
else fail('This browser has no service worker support.');
})();
</script>
</body>
+10 -27
View File
@@ -1,7 +1,7 @@
// Page startup.
//
// Pages arrive whole -- composed by the service worker, or by boot.html on the
// visit before there is one -- so there is nothing here that builds a page.
// Pages arrive whole -- the service worker composes every one of them -- so
// there is nothing here that builds a page.
// 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
// 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 anchor itself. One composed by boot.html is not: it replaced a
// document the browser had already finished with, so the fragment identifier
// has been spent. Repeating it costs nothing in the first case and is what
// makes a link to GEN#v1_1 land on the verse in the second.
// The browser scrolls to the anchor itself on a document it navigated to,
// so this is for the one it does not: a reload that landed on boot.html and
// was reloaded again into the real page. Costs nothing when the browser has
// already been there.
function scrollToHash() {
var el = hashTarget();
if (el) jumpTo(el);
@@ -166,25 +165,12 @@
//
// Toggling the class rather than the style because Bootstrap declares
// .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() {
var el = document.getElementById('app-version');
if (!el) return;
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,
// and it lands after htmx has processed the shell.
document.addEventListener('DOMContentLoaded', function () {
@@ -192,15 +178,12 @@
showVersion();
// 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
// already the real app -- the worker installs alongside it, and takes over
// composing, the bookmark and the offline download from the next
// navigation on.
// own: this load is already the real app, composed by the worker that is
// controlling it. What this catches is the copy on disk having changed
// since -- the browser refetches sw.js, sees different bytes, and installs
// the new one alongside, which takes over from the next navigation on.
if ('serviceWorker' in navigator) {
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
// worker, which composes every navigation once it is installed, and boot.html,
// which composes the one navigation that arrives before there is a worker to
// do it.
// The renderer: the templates in templates/, and the two things sw.js does
// with them -- compose a fragment into the shell, and stand a notice in for a
// page that could not be delivered.
//
// Sharing it is the whole point. A shell only the worker could build is what
// made a first visit show a spinner and reload; with the same templates and
// the same code reachable from the page, the first visit is already the app
// and the worker is what makes the second one fast and the hundredth one
// offline.
// A file of its own rather than more of sw.js because it is the half with
// nothing to do with the network: no routes, no caches, no requests. It was
// loaded by boot.html as well once, which composed pages in the browser for a
// device that could not install a worker; nothing but the worker builds a page
// now.
//
// Loaded as a plain script by both -- importScripts in the worker, a <script>
// tag in the page -- so it defines a global rather than exporting. Nunjucks
// must already be loaded.
// Loaded with importScripts, so it defines a global rather than exporting.
// Nunjucks must already be loaded.
(function (scope) {
'use strict';
@@ -123,10 +121,9 @@
//
// chrome is what the shell needs and the fragment cannot carry: the
// 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
// to ask for any of them; the first two render as a hidden slot that
// fills itself if one ever appears, and the third renders as nothing,
// because no worker is the whole of what there is to report.
// was built with. reference is null on the book list, which is the
// picker's hidden state; the other two are always there, because the
// worker composing this is the thing that knows them.
compose: function (fragment, chrome) {
var title = (TITLE_RE.exec(fragment) || ['', ''])[1].trim();
var content = fragment.replace(TITLE_RE, '');
@@ -134,9 +131,9 @@
return render(name, {
title: title,
content: content,
mark: (chrome && chrome.mark) || null,
reference: (chrome && chrome.reference) || null,
version: (chrome && chrome.version) || null
mark: chrome.mark,
reference: chrome.reference,
version: chrome.version
});
},
+14 -24
View File
@@ -9,8 +9,7 @@
//
// GET / the book list
// GET /:page one book -- /GEN, /EXO
// GET /api/bookmark the navbar slot
// POST /api/bookmark set the bookmark, and return the slot
// POST /api/bookmark set the bookmark, and return the navbar slot
// GET /api/reference the navbar's chapter and verse picker
// GET /api/verse the dialog for one verse
// GET /api/toast one confirmation, by name
@@ -28,11 +27,9 @@
// navigation -- one route, both callers, which is the whole reason the request
// object carries `navigate`.
//
// The composing is in js/render.js, which boot.html can load too. Normally it
// does not: boot.html registers this worker and reloads into it. It composes in
// the page when no worker is possible at all -- no secure context, or a browser
// without them -- and then it does so from these same templates rather than
// leaving a spinner up forever.
// The composing is in js/render.js. Every page in the app comes through it:
// boot.html registers this worker and reloads into it, and from there nothing
// else ever builds one.
//
// 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
@@ -461,16 +458,12 @@ router.get('/', (call) => servePage('./', '', call));
router.get('/:page', (call) =>
servePage('./' + call.params.page, call.params.page, call));
router.get('/api/bookmark', () => bookmarkFragment(''));
// The picker, and the two moves it makes.
//
// 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
// straight in and processes no out-of-band elements -- and
// when a worker first appears under a page boot.html
// composed.
// straight in and processes no out-of-band elements.
// 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.
//
@@ -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,
// and that request is the one carrying the anchor.
//
// Which also puts the picker where it belongs after a back or forward, and
// under a page boot.html composed once a worker turns up, for nothing extra:
// those requests carry the address bar too.
// Which also puts the picker where it belongs after a back or forward, for
// nothing extra: those requests carry the address bar too.
//
// Two response headers carry what a change means beyond the options:
//
@@ -734,13 +726,11 @@ function getBookmark() {
.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) {
let html = await view.render('bookmark.njk', { mark: await getBookmark() });
if (say) {
// 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;
return await view.render('bookmark.njk', { mark: await getBookmark() })
+ '\n' + await toastFragment(say, true);
}
+6 -17
View File
@@ -2,22 +2,12 @@
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.
mark is null in one case only: boot.html composed this page, so there was
no worker to ask. The slot is then hidden rather than absent and fetches
itself once app.js reports a worker is controlling -- which is why the htmx
attributes are on the empty state and not the filled one. A filled slot is
only ever replaced by the modal's form, which names #bookmark itself.
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 %}
Always filled: getBookmark() answers with the default when nothing has been
set and when storage cannot be read at all, so there is no empty state to
render. There was one -- a hidden slot that fetched itself once a worker
turned up -- for pages boot.html composed before there was a worker to ask.
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">
{# 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
anchor so that only the reference is underlined -- Reboot underlines an
@@ -28,5 +18,4 @@
anchor, which a swap into #content would not do. #}
<a class="bookmark-link link-light text-truncate" href="{{ mark.href }}"
hx-boost="false">{{ mark.ref }}</a>
{%- endif %}
</span>
+2 -3
View File
@@ -28,8 +28,7 @@
line, so one response moves both.
a restore htmx swaps the history element straight in and processes
no out-of-band elements at all, so the picker asks for
itself instead. worker-ready is in the same trigger, for a
page boot.html composed before there was a worker to ask.
itself instead.
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
asks for /GEN and keeps the rest. So a picker that arrived
@@ -57,7 +56,7 @@
id="reference"
{%- if oob %} hx-swap-oob="true"{% endif %}
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">
{%- if reference %}
<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
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
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
@@ -22,10 +17,8 @@
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
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">
<p class="app-version-digests form-text m-0">
shell {{ version.shell }} &middot; library {{ version.library }}
</p>
</div>
{%- endif %}