diff options
Diffstat (limited to 'build.js')
| -rw-r--r-- | build.js | 151 |
1 files changed, 125 insertions, 26 deletions
@@ -14,7 +14,7 @@ import { mkdir, readdir } from 'node:fs/promises'; const FAVICON = 'data:image/svg+xml,%3Csvg%20width%3D%2272%22%20height%3D%2272%22%20viewBox%3D%220%200%2072%2072%22%20fill%3D%22none%22%20xmlns%3D%22http%3A//www.w3.org/2000/svg%22%3E%20%3Cpath%20d%3D%22M69.8824%2036C69.8824%2054.7127%2054.7127%2069.8824%2036%2069.8824C17.2873%2069.8824%202.11765%2054.7127%202.11765%2036C2.11765%2017.2873%2017.2873%202.11765%2036%202.11765C54.7127%202.11765%2069.8824%2017.2873%2069.8824%2036Z%22%20fill%3D%22white%22/%3E%20%3Cpath%20fill-rule%3D%22evenodd%22%20clip-rule%3D%22evenodd%22%20d%3D%22M36%204.23529C18.4568%204.23529%204.23529%2018.4568%204.23529%2036C4.23529%2053.5432%2018.4568%2067.7647%2036%2067.7647C53.5432%2067.7647%2067.7647%2053.5432%2067.7647%2036C67.7647%2018.4568%2053.5432%204.23529%2036%204.23529ZM0%2036C0%2016.1177%2016.1177%200%2036%200C55.8823%200%2072%2016.1177%2072%2036C72%2055.8823%2055.8823%2072%2036%2072C16.1177%2072%200%2055.8823%200%2036Z%22%20fill%3D%22black%22/%3E%20%3Cpath%20fill-rule%3D%22evenodd%22%20clip-rule%3D%22evenodd%22%20d%3D%22M36%2058.5882C48.4751%2058.5882%2058.5882%2048.4751%2058.5882%2036C58.5882%2023.5248%2048.4751%2013.4117%2036%2013.4117C23.5249%2013.4117%2013.4118%2023.5248%2013.4118%2036C13.4118%2048.4751%2023.5249%2058.5882%2036%2058.5882ZM36%2054C45.9411%2054%2054%2045.9411%2054%2036C54%2026.0589%2045.9411%2018%2036%2018C26.0589%2018%2018%2026.0589%2018%2036C18%2045.9411%2026.0589%2054%2036%2054Z%22%20fill%3D%22black%22/%3E%20%3Cpath%20fill-rule%3D%22evenodd%22%20clip-rule%3D%22evenodd%22%20d%3D%22M36.0001%2022.8988C36.0001%2022.8988%2036%2022.8988%2036%2022.8988C28.7644%2022.8988%2022.8988%2028.7644%2022.8988%2036C22.8988%2043.2356%2028.7644%2049.1012%2036%2049.1012C36%2049.1012%2036.0001%2049.1012%2036.0001%2049.1012V22.8988Z%22%20fill%3D%22black%22/%3E%20%3C/svg%3E'; // Static assets copied verbatim into dist/ (icons referenced by the manifest). -const STATIC = ['icon-192.png', 'icon-512.png', 'icon-maskable-512.png', 'icon.svg', 'badge-96.png', 'PublicSans.woff2', 'PublicSans-Italic.woff2', 'PublicSans-OFL.txt']; +const STATIC = ['icon-192.png', 'icon-512.png', 'icon-maskable-512.png', 'icon.svg', 'badge-96.png', 'og-image.png', 'PublicSans.woff2', 'PublicSans-Italic.woff2', 'PublicSans-OFL.txt']; const MANIFEST = { id: '/', @@ -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,25 @@ 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 builds' 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(); + // only builds' caches: the thumbnail and call caches outlive a deploy + await Promise.all(keys.filter((k) => k.startsWith('cold-') && !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 @@ -86,20 +99,58 @@ self.addEventListener('fetch', (e) => { // Feed snapshots have their own short HTTP lifetime; never freeze one in Cache Storage. if (u.pathname === '/api/feed') return; + // Streams: a live playlist grows every two seconds (frozen at its first + // copy, the player stalled and failed), and video segments would fill the + // cache with what's already been watched. + if (u.pathname.startsWith('/live/') || u.pathname.startsWith('/vmedia/hls')) return; + // The developer docs are their own page, not the app: never the offline shell. + 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; } @@ -133,9 +184,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. // @@ -149,10 +199,29 @@ 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>`; +// Crash-loop guard. iOS kills a page that dies twice in a row while opening +// ("A problem repeatedly occurred") and the wallet is locked out with nothing +// to go on. Count page starts here, before the app runs; the app clears the +// count once it has lived a while or is left cleanly (hidden / pagehide) and +// notes the boot phase it reaches. Three starts with no clear = the app +// opens in safe mode: no automatic connections, a notice naming the last +// phase, and a way back to a normal boot. +export const BOOT_GUARD = `<script>try{var _bk='coinos-boot',_bn=+(localStorage.getItem(_bk)||0)+1;localStorage.setItem(_bk,String(_bn));window.__bootPhase=localStorage.getItem('coinos-boot-phase')||'';if(_bn>=3)window.__safeBoot=_bn;}catch(e){}</script>`; + // Lazy bundles load as classic scripts into the page's global scope, where // the main bundle's minified top-level names already live — two Bun outputs // can mint the same identifier and die with "already been declared". An IIFE @@ -350,7 +419,9 @@ async function bundleApp({ minify, features, staging, splitting = false }) { // (Mediabunny, ~700 KB) would ride along in a file meant to be small // and offline — there it uploads the original, as before ...(splitting ? [] : [{ name: 'no-video-compress', setup(build) { - build.onLoad({ filter: /src\/video-compress\.js$/ }, () => ({ loader: 'js', contents: 'export async function compressVideo(f) { return f; }' })); + build.onLoad({ filter: /src\/video-compress\.js$/ }, () => ({ loader: 'js', contents: 'export async function compressVideo(f) { return f; } export async function streamVideo() { return null; } export const STREAM_MIN_S = 90;' })); + // likewise hls.js (~400 KB): Firefox's player for live streams + build.onLoad({ filter: /src\/hls-player\.js$/ }, () => ({ loader: 'js', contents: 'export async function attachHls() { return null; }' })); } }])], }); if (!result.success) { @@ -386,21 +457,47 @@ const BOOT_SHELL_SCRIPT = `<script>try{var m=location.pathname.match(/^\\/([A-Za // fetch the live site over the top of them. const NO_SW = !!env('NO_SW'); +// What a link to the site unfurls as (chat apps, social posts, search). The +// server-rendered note and profile pages swap this block for their own +// (feed/note-page.js siteTagsOut), so the markers must stay. +const SHARE_IMAGE = 'https://v3.coinos.io/og-image.png'; +const SHARE_DESC = 'Self-custody Bitcoin wallet in your browser: Lightning, Ark and Nostr, with your own keys.'; +const siteTags = (staging) => `<!--site-tags--> +<meta name="description" content="${SHARE_DESC}"> +<meta property="og:type" content="website"> +<meta property="og:site_name" content="Coinos"> +<meta property="og:title" content="${staging ? 'Coinos Staging' : 'Coinos'}"> +<meta property="og:description" content="${SHARE_DESC}"> +<meta property="og:image" content="${SHARE_IMAGE}"> +<meta property="og:image:width" content="1200"> +<meta property="og:image:height" content="630"> +<meta property="og:image:alt" content="The coinos logo"> +<meta name="twitter:card" content="summary_large_image"> +<meta name="twitter:image" content="${SHARE_IMAGE}"> +<!--/site-tags--> +`; + async function pageHtml({ css, staging, pwa, scriptHtml }) { + // On https the page asks for its http:// pictures and clips (and ws:// + // relays) over https: a post's one http:// clip made Chrome call the whole + // page "not secure" (displayed mixed content), and the Android app (a TWA) + // then showed the address bar. Not on http (local test servers). return `<!doctype html> <html lang="en"> <head> <meta charset="utf-8"> +<script>if(location.protocol==='https:')document.write('<meta http-equiv="Content-Security-Policy" content="upgrade-insecure-requests">')</script> <meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, interactive-widget=resizes-content"> <meta name="color-scheme" content="light dark"> <title>${staging ? 'Coinos Staging' : 'Coinos'}</title> <link rel="icon" href="${FAVICON}"> -${pwa ? PWA_HEAD : ''}<script>try{var t=localStorage.getItem('btc-wallet-theme');if(t!=='dark'&&t!=='light')t=matchMedia('(prefers-color-scheme:dark)').matches?'dark':'light';document.documentElement.dataset.theme=t;var m=document.querySelector('meta[name="theme-color"]');if(m)m.setAttribute('content',t==='dark'?'#15171a':'#eef0f3');}catch(e){}</script> +${siteTags(staging)}${pwa ? PWA_HEAD : ''}<script>try{var t=localStorage.getItem('btc-wallet-theme');if(t!=='dark'&&t!=='light')t=matchMedia('(prefers-color-scheme:dark)').matches?'dark':'light';document.documentElement.dataset.theme=t;var m=document.querySelector('meta[name="theme-color"]');if(m)m.setAttribute('content',t==='dark'?'#15171a':'#eef0f3');}catch(e){}</script> <style>${css}</style> </head> <body> <div id="app">${bootShell(await brandSvg(), staging)}</div> ${BOOT_SHELL_SCRIPT} +${BOOT_GUARD} ${scriptHtml} ${pwa && !NO_SW ? SW_REGISTER + '\n' : ''}</body> </html>`; @@ -511,7 +608,7 @@ if (import.meta.main) { // The Android builds. coinos.apk is the TWA (Chrome renders the site); // graphene.apk runs the wallet in its own WebView so a phone without // Google Play Services can be woken — see android/. - for (const apk of ['coinos.apk', 'graphene.apk']) { + for (const apk of ['coinos.apk', 'graphene.apk', 'coinos-beta.apk']) { if (await Bun.file('static/' + apk).exists()) { await Bun.write('dist/' + apk, Bun.file('static/' + apk)); } @@ -524,6 +621,8 @@ if (import.meta.main) { // ...and the default profile covers, full size and the wizard's preview copies for (const d of ['punks', 'punks-sm', 'covers', 'covers-sm']) for (const f of readdirSync('static/' + d)) await Bun.write(`dist/${d}/${f}`, Bun.file(`static/${d}/${f}`)); + // the developer docs, a plain page at /docs/ + for (const f of readdirSync('static/docs')) await Bun.write(`dist/docs/${f}`, Bun.file(`static/docs/${f}`)); const kb = (Buffer.byteLength(html) / 1024).toFixed(0); const jkb = (Buffer.byteLength(rawJs) / 1024).toFixed(0); |
