diff options
| -rw-r--r-- | build.js | 97 | ||||
| -rw-r--r-- | tools/sw-reload-test.js | 111 |
2 files changed, 154 insertions, 54 deletions
@@ -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); |
