aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--build.js97
-rw-r--r--tools/sw-reload-test.js111
2 files changed, 154 insertions, 54 deletions
diff --git a/build.js b/build.js
index 838e891..4cb68f3 100644
--- a/build.js
+++ b/build.js
@@ -33,15 +33,16 @@ const MANIFEST = {
],
};
-// Service worker: precache the app shell, network-first for the page (so a new
-// deploy shows up), cache-first for the icons/manifest, and never touch the
+// Service worker: precache the app shell, cache-first for opening the app at /
+// (a new deploy opens on the launch after it installs; a refresh goes to the
+// network), network-first for other pages, cache-first for assets, and never touch the
// cross-origin explorer/Nostr requests. CACHE carries a build token so each
// deploy supersedes the previous cache. {{VERSION}} is filled in at build time.
const SW = `const CACHE = 'cold-{{VERSION}}';
// jsqr.js / nip46.js are precached so the lazy QR decoder and remote-signer
// module are available offline too. app-{{VERSION}}.js is the main bundle —
-// content-addressed, so a new deploy precaches a new name and the old cache
-// is dropped wholesale.
+// content-addressed, so a new deploy precaches a new name; the previous
+// build's cache is kept one deploy longer (see activate).
const SHELL = ['./', 'manifest.webmanifest', 'icon-192.png', 'icon-512.png', 'jsqr.js', 'nip46.js', 'verify-worker.js', 'PublicSans.woff2', 'PublicSans-Italic.woff2', 'app-{{VERSION}}.js'{{EXTRA_SHELL}}];
self.addEventListener('install', (e) => {
@@ -58,13 +59,24 @@ self.addEventListener('message', (e) => {
}
});
+// Activating keeps the previous build's cache beside the new one: a page
+// launched from the old shell (navigations to / are cache-first, below) keeps
+// running the old build until its next launch, and its lazily imported chunks
+// must still be found. Older caches go. Navigation preload starts a
+// navigation's network request while the worker itself is still starting.
+const META = 'coinos-sw-meta';
self.addEventListener('activate', (e) => {
- e.waitUntil(
- caches
- .keys()
- .then((keys) => Promise.all(keys.filter((k) => k !== CACHE).map((k) => caches.delete(k))))
- .then(() => self.clients.claim())
- );
+ e.waitUntil((async () => {
+ try { if (self.registration.navigationPreload) await self.registration.navigationPreload.enable(); } catch (_) {}
+ const meta = await caches.open(META);
+ let order = [];
+ try { const r = await meta.match('cache-order'); if (r) order = await r.json(); } catch (_) {}
+ const keep = [CACHE, ...order.filter((k) => k !== CACHE)].slice(0, 2);
+ await meta.put('cache-order', new Response(JSON.stringify(keep)));
+ const keys = await caches.keys();
+ await Promise.all(keys.filter((k) => k !== META && !keep.includes(k)).map((k) => caches.delete(k)));
+ await self.clients.claim();
+ })());
});
// NWC push handling lives in src/sw-nwc.js (bundled and appended below when
@@ -94,18 +106,50 @@ self.addEventListener('fetch', (e) => {
if (u.pathname === '/docs' || u.pathname.startsWith('/docs/')) return;
if (req.mode === 'navigate') {
- // A note page comes back with its thread rendered into it (the server's
- // note-page); that copy must not become the offline shell for every path.
- const notePage = /^\\/(note|nevent)1/i.test(u.pathname);
+ // Opening the app (/, the installed app's and the Android app's start
+ // page) paints this build's shell from the cache, with no network wait:
+ // network-first made every launch wait on a round trip, and the first
+ // launch after each deploy download the whole new build (~650 KB
+ // compressed) before showing anything — seconds on a phone's data. The
+ // browser checks sw.js on the same navigation; a new build installs in
+ // the background (shell, entry and chunks together) and the next launch
+ // opens it. A refresh (pull-to-refresh, reload) still goes to the network.
+ // Other paths (note and profile pages come rendered by the server) stay
+ // network-first, falling back to the shell offline.
+ const root = u.pathname === '/';
+ const refresh = req.cache === 'reload' || req.cache === 'no-cache';
+ // The preload is answered with an empty 204 when it's for a launch (no
+ // Cache-Control on the request; a refresh sends max-age=0), so opening the
+ // app spends no bytes on a page it paints from the cache (cs nginx).
+ const preload = e.preloadResponse ? e.preloadResponse.catch(() => null) : Promise.resolve(null);
+ e.waitUntil(preload); // an unused preload must still settle
+ const net = () => preload.then((r) => (r && r.status !== 204 ? r : fetch(req)));
+ const shell = () => caches.open(CACHE).then((c) => c.match('./')).then((hit) => hit || caches.match('./'));
+ if (root && !refresh) {
+ e.respondWith(
+ caches.open(CACHE).then((c) => c.match('./'))
+ .then((hit) => hit || net())
+ .catch(() => shell())
+ );
+ return;
+ }
e.respondWith(
- fetch(req)
+ net()
.then((res) => {
- if (notePage) return res;
- const copy = res.clone();
- caches.open(CACHE).then((c) => c.put('./', copy));
+ // Only this build's own page becomes its shell: a server-rendered
+ // note or profile page, or a newer build's page fetched before its
+ // worker installed, must not be what the next launch opens.
+ if (root && res.ok) {
+ const copy = res.clone();
+ e.waitUntil(copy.text().then((t) => {
+ if (t.includes('app-{{VERSION}}.js')) {
+ return caches.open(CACHE).then((c) => c.put('./', new Response(t, { headers: copy.headers })));
+ }
+ }).catch(() => {}));
+ }
return res;
})
- .catch(() => caches.match('./'))
+ .catch(() => shell())
);
return;
}
@@ -139,9 +183,8 @@ const PWA_HEAD = `<link rel="manifest" href="/manifest.webmanifest">
// Register with updateViaCache:'none' so the browser always re-fetches sw.js
// (never from HTTP cache) and detects a new build immediately.
//
-// When a new worker takes control we may need to reload, because the app is
-// inlined in the page: a page serving the old build would keep running it. But
-// navigations are network-first, so the refresh that triggered the update
+// When a new worker takes control, a page serving the old build keeps running
+// it. A refresh goes to the network, so the refresh that triggered the update
// usually delivered the NEW page already — reloading then is pure
// interruption, a second unasked-for refresh a beat after your own.
//
@@ -155,7 +198,17 @@ const SW_REGISTER =
`if(!e.data||e.data.type!=='sw-version')return;` +
// _v.charAt(0)==='{' means the token never got substituted; treating that as
// a mismatch would reload forever.
- `if(_had&&!_ref&&e.data.version&&_v.charAt(0)!=='{'&&e.data.version!==_v){_ref=true;location.reload()}});` +
+ //
+ // Opening the app paints the cached shell, so a build that installs while
+ // it's open finds it running the previous one. The new build then waits for
+ // the next launch — unless nothing has been touched yet: then it reloads as
+ // the page is put away (or now, if it already is), with nothing to lose.
+ // Never under someone's hands: a send half-typed while they fetch an
+ // invoice from another app must still be there when they come back.
+ `if(_had&&!_ref&&e.data.version&&_v.charAt(0)!=='{'&&e.data.version!==_v){_ref=true;_stale=true;_go()}});` +
+ `var _stale=false,_used=false;function _go(){if(_stale&&!_used&&document.visibilityState==='hidden')location.reload()}` +
+ `['pointerdown','keydown'].forEach(function(t){addEventListener(t,function(){_used=true},{capture:true,passive:true})});` +
+ `document.addEventListener('visibilitychange',_go);` +
`navigator.serviceWorker.addEventListener('controllerchange',function(){_ask(navigator.serviceWorker.controller)});` +
`addEventListener('load',function(){navigator.serviceWorker.register('/sw.js',{updateViaCache:'none'}).catch(function(){})})}</script>`;
diff --git a/tools/sw-reload-test.js b/tools/sw-reload-test.js
index 6ad4fed..d626514 100644
--- a/tools/sw-reload-test.js
+++ b/tools/sw-reload-test.js
@@ -1,11 +1,12 @@
-// A deploy should not refresh the page out from under you.
+// Opening the app is instant, and a deploy never refreshes the page out from
+// under you.
//
-// The app is inlined in the page, so when a new worker takes over, a page
-// still running the old build has to reload. But navigations are
-// network-first: the refresh that triggered the update has usually already
-// fetched the new page, and reloading then is a second, unasked-for refresh a
-// beat after your own. Both halves are asserted here — the quiet case and the
-// case where a reload is genuinely owed.
+// Navigations to / are cache-first: a launch paints the installed build's
+// shell without waiting on the network, and a deploy that lands meanwhile
+// installs in the background and opens on the next launch. A refresh goes to
+// the network and gets the new build at once, without a second reload. A page
+// that installs a newer build while open reloads only if nothing was touched,
+// and only as it is put away.
//
// Run: bun tools/sw-reload-test.js (needs a built dist/)
import puppeteer from './puppeteer.js';
@@ -21,15 +22,25 @@ if (!token) { console.log('no build token in dist/index.html — run bun run bui
// A "new deploy" is the same app under a different build token.
const restamp = (tok) => ({ html: html1.replaceAll(token, tok), sw: sw1.replaceAll(token, tok) });
-const builds = { v1: { html: html1, sw: sw1 }, v2: restamp('deadbeef00001'), v3: restamp('deadbeef00002') };
+const builds = { v1: { html: html1, sw: sw1 }, v2: restamp('deadbeef00001'), v3: restamp('deadbeef00002'), v4: restamp('deadbeef00003') };
let current = builds.v1;
+let pageHits = 0; // network requests for the page itself
+let preloads = 0;
const server = Bun.serve({
port: 5223,
fetch(req) {
const p = new URL(req.url).pathname;
if (p === '/sw.js') return new Response(current.sw, { headers: { 'content-type': 'text/javascript', 'cache-control': 'no-store' } });
- if (p === '/' || p === '/index.html') return new Response(current.html, { headers: { 'content-type': 'text/html', 'cache-control': 'no-store' } });
+ if (p === '/' || p === '/index.html') {
+ pageHits++;
+ // as cs nginx does: a launch's preload (no Cache-Control) gets no body
+ if (req.headers.get('service-worker-navigation-preload')) {
+ preloads++;
+ if (!req.headers.get('cache-control')) return new Response(null, { status: 204 });
+ }
+ return new Response(current.html, { headers: { 'content-type': 'text/html', 'cache-control': 'no-store' } });
+ }
// The worker precaches the shell during install and addAll rejects on any
// miss — a 404 here means it never activates and the test proves nothing.
// The split build names its entry after the token (app-<token>.js), so
@@ -43,9 +54,9 @@ const browser = await puppeteer.launch({ executablePath: '/usr/bin/google-chrome
const page = await browser.newPage();
let loads = 0;
page.on('load', () => { loads++; });
-page.on('console', (m) => { if (m.type() === 'error') console.log(' [page]', m.text().slice(0, 160)); });
page.on('pageerror', (e) => console.log(' [pageerror]', e.message.slice(0, 160)));
+const running = () => page.evaluate(() => (document.documentElement.outerHTML.match(/_v='([a-z0-9]+)'/) || [])[1]);
const controlled = async (ms = 15000) => {
for (let i = 0; i < ms / 250; i++) {
if (await page.evaluate(() => !!navigator.serviceWorker.controller)) return true;
@@ -53,45 +64,81 @@ const controlled = async (ms = 15000) => {
}
return false;
};
+// the worker's own build, once the newest one has taken over
+const workerBuild = () => page.evaluate(() => new Promise((res) => {
+ const c = navigator.serviceWorker.controller;
+ if (!c) return res(null);
+ const on = (e) => { if (e.data && e.data.type === 'sw-version') { navigator.serviceWorker.removeEventListener('message', on); res(e.data.version); } };
+ navigator.serviceWorker.addEventListener('message', on);
+ c.postMessage({ type: 'sw-version' });
+ setTimeout(() => res(null), 2000);
+}));
+const waitWorker = async (tok) => { for (let i = 0; i < 60; i++) { if ((await workerBuild()) === tok) return true; await sleep(250); } return false; };
+const putAway = () => page.evaluate(() => {
+ Object.defineProperty(document, 'visibilityState', { configurable: true, get: () => 'hidden' });
+ document.dispatchEvent(new Event('visibilitychange'));
+});
try {
console.log('[first visit]');
await page.goto('http://localhost:5223/', { waitUntil: 'load' });
- const reg = await page.evaluate(async () => {
- try {
- const r = await navigator.serviceWorker.register('sw.js', { updateViaCache: 'none' });
- return { ok: true, scope: r.scope, active: !!r.active, installing: !!r.installing, waiting: !!r.waiting };
- } catch (e) { return { ok: false, err: String(e).slice(0, 200) }; }
- });
- console.log(' register:', JSON.stringify(reg));
+ await page.evaluate(() => navigator.serviceWorker.register('sw.js', { updateViaCache: 'none' }));
check('worker takes control', await controlled());
await sleep(3000);
check('a first visit does not reload itself', loads === 1, `${loads} load(s)`);
- console.log('\n[refreshing after a deploy]');
+ console.log('\n[opening the app after a deploy]');
current = builds.v2;
- const before = loads;
+ let hits = pageHits, pre = preloads, before = loads;
+ await page.goto('http://localhost:5223/?u=x', { waitUntil: 'load' });
+ check('the launch paints the installed build', (await running()) === token, `running ${await running()}`);
+ check('without fetching the page (only an empty launch preload)', pageHits - hits === preloads - pre, `${pageHits - hits} page request(s), ${preloads - pre} of them preloads`);
+ check('the new build installs meanwhile', await waitWorker('deadbeef00001'));
+ await sleep(1000);
+ check('and does not reload a page on screen', loads === before + 1, `${loads - before} load(s)`);
+ before = loads;
+ await page.goto('http://localhost:5223/', { waitUntil: 'load' });
+ check('the next launch opens the new build', (await running()) === 'deadbeef00001', `running ${await running()}`);
+
+ console.log('\n[refreshing after a deploy]');
+ current = builds.v3;
+ before = loads;
await page.reload({ waitUntil: 'load' });
- await sleep(6000); // well past install + activate + claim
+ check('a refresh gets the new build at once', (await running()) === 'deadbeef00002', `running ${await running()}`);
+ await waitWorker('deadbeef00002');
+ await sleep(2000);
check('your refresh is the only refresh', loads === before + 1, `${loads - before} load(s) — one is yours`);
- check('and the new build is what is running', await page.evaluate(() => document.documentElement.outerHTML.includes("_v='deadbeef00001'")));
- console.log('\n[a deploy while the page sits open]');
- current = builds.v3;
- const before2 = loads;
- // no navigation: just let the worker notice, the way the periodic update
- // check would
+ console.log('\n[a deploy while the page sits open, untouched]');
+ current = builds.v4;
+ before = loads;
await page.evaluate(() => navigator.serviceWorker.getRegistration().then((r) => r && r.update()));
- for (let i = 0; i < 40 && loads === before2; i++) await sleep(250);
- check('a stale page still gets reloaded', loads === before2 + 1, `${loads - before2} reload(s)`);
- await sleep(3000);
- check('and only once', loads === before2 + 1, `${loads - before2} reload(s)`);
- check('now running the newest build', await page.evaluate(() => document.documentElement.outerHTML.includes("_v='deadbeef00002'")));
+ check('the new build installs', await waitWorker('deadbeef00003'));
+ await sleep(1000);
+ check('no reload while on screen', loads === before, `${loads - before} reload(s)`);
+ await putAway();
+ for (let i = 0; i < 40 && loads === before; i++) await sleep(250);
+ check('it reloads as it is put away', loads === before + 1, `${loads - before} reload(s)`);
+ check('now running the newest build', (await running()) === 'deadbeef00003');
+
+ console.log('\n[a deploy while someone is using the page]');
+ current = builds.v1;
+ before = loads;
+ await page.mouse.click(5, 5);
+ await page.evaluate(() => navigator.serviceWorker.getRegistration().then((r) => r && r.update()));
+ check('the new build installs', await waitWorker(token));
+ await putAway();
+ await sleep(2000);
+ check('a page in use is never reloaded', loads === before, `${loads - before} reload(s)`);
+ check('an older build\'s chunks are still cached', await page.evaluate(async () => {
+ const tok = (document.documentElement.outerHTML.match(/_v='([a-z0-9]+)'/) || [])[1];
+ return !!(await caches.match('app-' + tok + '.js'));
+ }));
} catch (e) {
check('run completed', false, e.message);
} finally {
await browser.close();
server.stop(true);
}
-console.log(ok ? '\n✅ sw reload behaviour is right' : '\n❌ failures above');
+console.log(ok ? '\n✅ sw launch + reload behaviour is right' : '\n❌ failures above');
process.exit(ok ? 0 : 1);