// Build a single, self-contained dist/index.html (plus PWA sidecar files). // // Everything — app code, the @scure/@noble crypto libs, the QR generator, and // all CSS — is bundled and inlined so the result is one file you can save and // open offline straight from the filesystem (file://). No server, no network. // // For the hosted site we also emit a web manifest, icons, and a small service // worker so the page can be installed as a PWA and still work offline once // installed. index.html stays self-contained; these are optional extras that // simply 404 (harmlessly) when the file is opened on its own from file://. 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 MANIFEST = { id: '/', name: 'Coinos', short_name: 'Coinos', description: 'Self-custody Bitcoin wallet that runs entirely in your browser.', start_url: './', scope: './', display: 'standalone', background_color: '#eef0f3', theme_color: '#15171a', icons: [ { src: 'icon-192.png', sizes: '192x192', type: 'image/png', purpose: 'any' }, { src: 'icon-512.png', sizes: '512x512', type: 'image/png', purpose: 'any' }, { src: 'icon-maskable-512.png', sizes: '512x512', type: 'image/png', purpose: 'maskable' }, ], }; // 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 // 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. 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) => { self.skipWaiting(); e.waitUntil(caches.open(CACHE).then((c) => c.addAll(SHELL))); }); // The page asks which build we are, and reloads only if it's running an older // one. Without this it reloads on every controller change, including the very // common case where the navigation that woke us already fetched the new page. self.addEventListener('message', (e) => { if (e.data && e.data.type === 'sw-version' && e.source) { e.source.postMessage({ type: 'sw-version', version: '{{VERSION}}' }); } }); 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()) ); }); // NWC push handling lives in src/sw-nwc.js (bundled and appended below when // the nwc+ark features ship): it tries to ANSWER the request from the pouch // and falls back to a notification. The placeholder below carries a // notify-only handler for builds without those features. {{NWC_WAKE_FALLBACK}} self.addEventListener('fetch', (e) => { const req = e.request; if (req.method !== 'GET') return; const u = new URL(req.url); if (u.origin !== self.location.origin) return; // explorer/ws → network // Live API paths served from OUR origin (esplora, sp indexer, lnurl, // well-known) must never be cached: a chain answer frozen at first sight // wedges every flow that polls it (a refresh once waited forever on a // cached "unconfirmed"). Straight to network, always. if (/^\\/(esplora|sp|lnurlp|electrum)\\//.test(u.pathname) || u.pathname.startsWith('/.well-known/')) return; // Feed snapshots have their own short HTTP lifetime; never freeze one in Cache Storage. if (u.pathname === '/api/feed') 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); e.respondWith( fetch(req) .then((res) => { if (notePage) return res; const copy = res.clone(); caches.open(CACHE).then((c) => c.put('./', copy)); return res; }) .catch(() => caches.match('./')) ); return; } // Cache-first, but cache same-origin assets (e.g. the chosen locale, icons) // on first fetch so they're available offline without precaching them all. e.respondWith( caches.match(req).then( (hit) => hit || fetch(req).then((res) => { if (res.ok) { const copy = res.clone(); caches.open(CACHE).then((c) => c.put(req, copy)); } return res; }) ) ); }); `; const PWA_HEAD = ` `; // 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 // usually delivered the NEW page already — reloading then is pure // interruption, a second unasked-for refresh a beat after your own. // // So ask the new worker which build it is and reload only on a genuine // mismatch. The page's own token is stamped in at build time; both come from // the same {{VERSION}}, hashed before substitution so the two agree. const SW_REGISTER = ``; // 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 // wrapper gives each its own scope; they publish through window.* on purpose. const iife = (js) => `(function(){${js}\n})();`; // Bundle the standalone jsQR decoder (sets window.jsQR) for lazy loading. export async function buildJsQr({ minify = true } = {}) { const result = await Bun.build({ entrypoints: ['./src/jsqr-global.js'], target: 'browser', minify, }); if (!result.success) { for (const log of result.logs) console.error(log); throw new Error('jsqr bundle failed'); } return iife(await result.outputs[0].text()); } // Standalone bundle for the NIP-46 remote-signer client — loaded on demand by // nostr-login.js the first time a bunker/nostrconnect login happens. export async function buildNip46({ minify = true } = {}) { const result = await Bun.build({ entrypoints: ['./src/nip46-global.js'], target: 'browser', minify, }); if (!result.success) { for (const log of result.logs) console.error(log); throw new Error('nip46 bundle failed'); } return iife(await result.outputs[0].text()); } // Off-main-thread nostr event verification worker (classic Worker, its own // bundle of nostr-tools verify + noble crypto). NOT wrapped in an IIFE — // worker code runs at top level and needs its self.onmessage handler live. export async function buildVerifyWorker({ minify = true } = {}) { const result = await Bun.build({ entrypoints: ['./src/verify-worker.js'], target: 'browser', minify, }); if (!result.success) { for (const log of result.logs) console.error(log); throw new Error('verify-worker bundle failed'); } return await result.outputs[0].text(); } // The notify-only push handler for builds without the ark+nwc features (the // full builds get the bundled auto-answering handler from src/sw-nwc.js). const SW_WAKE_ONLY = `self.addEventListener('push', (e) => { let data = {}; try { data = e.data ? e.data.json() : {}; } catch (_) {} if (data.type !== 'nwc') return; e.waitUntil((async () => { const clients = await self.clients.matchAll({ type: 'window', includeUncontrolled: true }); if (clients.length) { for (const c of clients) c.postMessage({ type: 'nwc-wake', servicePubkey: data.servicePubkey }); return; } await self.registration.showNotification('Payment request', { body: 'An app is asking your wallet to pay. Open Coinos to approve.', icon: 'icon-192.png', badge: 'badge-96.png', tag: 'nwc-' + (data.servicePubkey || 'req'), renotify: false, data: { url: './' }, }); })()); }); self.addEventListener('notificationclick', (e) => { e.notification.close(); const d = (e.notification && e.notification.data) || {}; e.waitUntil((async () => { const all = await self.clients.matchAll({ type: 'window', includeUncontrolled: true }); const open = all.find((c) => 'focus' in c); if (open) { if (d.view) { try { open.postMessage({ type: 'open', view: d.view }); } catch (_) {} } return open.focus(); } if (self.clients.openWindow) return self.clients.openWindow(d.url || './'); })()); });`; // The auto-answering NWC worker module, bundled for the SW global scope. export async function buildSwNwc({ minify = true } = {}) { const result = await Bun.build({ entrypoints: ['./src/sw-nwc.js'], target: 'browser', minify, }); if (!result.success) { for (const log of result.logs) console.error(log); throw new Error('sw-nwc bundle failed'); } return result.outputs[0].text(); } // Optional-feature selection: HAL_FEATURES is a comma list of enabled // features (gifts,ark,zaps,nwc,…). Unset means all; "none"/"" means a minimal // on-chain-only wallet. A build plugin swaps src/features/index.js for a // generated module that only imports the enabled features, so a disabled // feature's code (and its network endpoints) never enters the bundle. // NB order matters and must match src/features/index.js — nwc sits after ark // because it drives ark's headless pay/balance seam. const ALL_FEATURES = { gifts: 'giftsFeature', ark: 'arkFeature', nostrlogin: 'nostrLoginFeature', names: 'namesFeature', zaps: 'zapsFeature', nwc: 'nwcFeature', hats: 'hatsFeature', sync: 'syncFeature', messages: 'messagesFeature', pos: 'posFeature' }; // Features with no first-frame surface load as separate chunks right after // boot (dynamic import; real files in the split build, inlined in the // single-file build). Hook precedence is POSITION in the features array, so // each deferred feature holds its slot with an empty placeholder object — // every `f[hook]` probe skips it — and is filled in place when it arrives. // NOT deferrable: ark (balance on the home screen), messages (deep-linked // profiles + the boot shell), gifts is deferrable because app.js awaits the // deferred load before the bootUrl check when the path looks like a gift. // Hats decorate the header avatar on the first frame. Loading their art in a // later chunk makes the header change after the rest of the page has painted. const DEFERRED_FEATURES = ['gifts', 'nwc', 'pos']; export function enabledFeatures(spec = process.env.HAL_FEATURES) { if (spec == null) return Object.keys(ALL_FEATURES); return spec.split(',').map((x) => x.trim().toLowerCase()).filter((x) => x in ALL_FEATURES); } export function featureIndexSource(enabled) { const lazy = enabled.filter((f) => DEFERRED_FEATURES.includes(f)); const eager = enabled.filter((f) => !lazy.includes(f)); return eager.map((f) => `import { ${ALL_FEATURES[f]} } from './${f}.js';`).join('\n') + ` export function buildFeatures(ctx) { return [${enabled.map((f) => (lazy.includes(f) ? '{}' : `${ALL_FEATURES[f]}(ctx)`)).join(', ')}]; } export async function loadDeferredFeatures(ctx, features) { const late = []; await Promise.all([${lazy.map((f) => ` import('./${f}.js').then((m) => { late.push(features[${enabled.indexOf(f)}] = m.${ALL_FEATURES[f]}(ctx)); })`).join(',')} ]); return late; } `; } const featurePlugin = (enabled) => ({ name: 'coinos-features', setup(b) { b.onLoad({ filter: /src[\/\\]features[\/\\]index\.js$/ }, () => ({ contents: featureIndexSource(enabled), loader: 'js', })); }, }); // HAL_TARGET=staging flips src/build-flags.js in the bundle: mutinynet-first // defaults and a visible staging stamp, without a diverging branch. export const isStaging = (target = process.env.HAL_TARGET) => target === 'staging'; const flagsPlugin = (staging) => ({ name: 'coinos-build-flags', setup(b) { b.onLoad({ filter: /src[\/\\]build-flags\.js$/ }, () => ({ contents: `export const STAGING = ${!!staging};\n`, loader: 'js', })); }, }); // The commit this build came from: the deploy hook passes GIT_COMMIT (the // worktree it builds in has no .git), local builds ask git directly. function gitCommit() { if (process.env.GIT_COMMIT) return process.env.GIT_COMMIT.slice(0, 7); try { const r = Bun.spawnSync(['git', 'rev-parse', '--short', 'HEAD']); const out = r.stdout.toString().trim(); if (/^[0-9a-f]{6,}$/.test(out)) return out; } catch {} return 'dev'; } async function bundleApp({ minify, features, staging, splitting = false }) { const result = await Bun.build({ entrypoints: ['./src/app.js'], target: 'browser', minify, // The split build emits real ESM chunks (deferred features arrive as their // own files); the single-file build inlines the dynamic imports instead. ...(splitting ? { splitting: true, format: 'esm', naming: { chunk: 'chunk-[hash].[ext]' } } : {}), plugins: [featurePlugin(enabledFeatures(features)), flagsPlugin(staging), // the single file inlines every dynamic import: the video compressor // (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; }' })); } }])], }); if (!result.success) { for (const log of result.logs) console.error(log); throw new Error('bundle failed'); } return result.outputs; } // The brand mark for the static boot shell, lifted from brandHeader in // src/app.js at build time so the two can never drift apart. Missing match // degrades to an empty (invisible) logo, never a broken build. async function brandSvg() { const src = await Bun.file('./src/app.js').text(); const m = src.match(/'()'/); return m ? m[1] : ''; } // A static boot shell inside #app: painted the instant HTML+CSS arrive, // before any JS parses — no blank page, no flash. It mirrors the DOM the // app's own first render produces (brand row; plus the profile shell when // the path deep-links a profile), so the morph replaces it invisibly. function bootShell(logo, staging) { const badge = staging ? 'staging' : 'v3 beta'; return `
${badge}
`; } const BOOT_SHELL_SCRIPT = ``; // HAL_NO_SW=1: the page registers no service worker. The Android bundled // build serves this page from its own assets, where a worker would only // fetch the live site over the top of them. const NO_SW = !!process.env.HAL_NO_SW; async function pageHtml({ css, staging, pwa, scriptHtml }) { return ` ${staging ? 'Coinos Staging' : 'Coinos'} ${pwa ? PWA_HEAD : ''}
${bootShell(await brandSvg(), staging)}
${BOOT_SHELL_SCRIPT} ${scriptHtml} ${pwa && !NO_SW ? SW_REGISTER + '\n' : ''} `; } // Self-contained single file (dev server, test tools, save-and-open-offline). export async function buildHtml({ minify = true, pwa = minify, features = process.env.HAL_FEATURES, staging = isStaging(), inlineFonts = false } = {}) { const outputs = await bundleApp({ minify, features, staging }); let js = await outputs[0].text(); // Guard against a literal inside the bundle closing our tag early. js = js.replaceAll('${js}` }); return html.replaceAll('{{COMMIT}}', gitCommit()); } // The hosted split build: a small no-cache index.html (CSS + boot shell) that // loads the app as a content-addressed ESM entry plus chunks — the deferred // features ride in their own files, fetched right after boot. The page stays // tiny on every load while the heavy bundles are cached by name — a repeat // visit re-downloads kilobytes, not the app. export async function buildSplit({ minify = true, features = process.env.HAL_FEATURES, staging = isStaging() } = {}) { const outputs = await bundleApp({ minify, features, staging, splitting: true }); const entry = outputs.find((o) => o.kind === 'entry-point') || outputs[0]; const js = (await entry.text()).replaceAll('{{COMMIT}}', gitCommit()); const chunks = await Promise.all(outputs.filter((o) => o !== entry).map(async (o) => ({ name: o.path.split('/').pop(), text: (await o.text()).replaceAll('{{COMMIT}}', gitCommit()), }))); const css = await Bun.file('./src/style.css').text(); // module scripts defer by default; the boot shell paints while this loads const html = (await pageHtml({ css, staging, pwa: true, // root-absolute: the page can be served from any path (/ag/…, /g/…) and a // relative src would resolve into that subpath, get the index.html // fallback, and die on strict module MIME checking — a gift link once // showed nothing but the boot shell this way scriptHtml: '' })) .replaceAll('{{COMMIT}}', gitCommit()); return { html, js, chunks }; } if (import.meta.main) { await mkdir('dist', { recursive: true }); // Hashed with {{VERSION}} still a placeholder, so the page, the bundle // filename and the worker are all stamped with the same token — the page // cannot contain a hash of itself. const { html: rawHtml, js: rawJs, chunks } = await buildSplit({ minify: true }); const version = Bun.hash(rawHtml + rawJs + chunks.map((c) => c.text).join('')).toString(36); const html = rawHtml.replaceAll('{{VERSION}}', version); await Bun.write('dist/index.html', html); await Bun.write(`dist/app-${version}.js`, rawJs.replaceAll('{{VERSION}}', version)); for (const c of chunks) await Bun.write('dist/' + c.name, c.text); // Drop stale content-addressed bundles from earlier local builds so dist/ // mirrors a deploy (the deploy hook rsyncs dist/ wholesale). const keep = new Set([`app-${version}.js`, ...chunks.map((c) => c.name)]); for (const f of await readdir('dist')) { if (/^(app|chunk)-[A-Za-z0-9]+\.js$/.test(f) && !keep.has(f)) await Bun.file('dist/' + f).delete(); } // The standalone single-file wallet: everything inlined (deferred features // included), no PWA plumbing — meant to be downloaded from /standalone.html // and opened straight from a disk. Network features still reach out when // online; the wallet itself needs no server. await Bun.write('dist/standalone.html', await buildHtml({ minify: true, pwa: false, inlineFonts: true })); // Lazy-loaded QR decoder — kept out of index.html, fetched only when a // browser without BarcodeDetector opens the scanner. await Bun.write('dist/verify-worker.js', await buildVerifyWorker()); await Bun.write('dist/jsqr.js', await buildJsQr()); // Lazy-loaded NIP-46 remote-signer client — fetched on first bunker login. await Bun.write('dist/nip46.js', await buildNip46()); // Per-language strings — fetched on demand so visitors only download theirs. await mkdir('dist/locales', { recursive: true }); const locales = await readdir('src/locales'); for (const f of locales) await Bun.write('dist/locales/' + f, Bun.file('src/locales/' + f)); // PWA sidecars. The staging install gets its own name so the two icons are // tellable apart on a phone's home screen (identity is per-origin anyway). const manifest = isStaging() ? { ...MANIFEST, name: 'Coinos Staging', short_name: 'Staging' } : MANIFEST; await Bun.write('dist/manifest.webmanifest', JSON.stringify(manifest, null, 2)); // Full builds get the auto-answering NWC worker; feature-stripped builds // keep the notify-only handler (the responder would just dead-weight them). const feats = enabledFeatures(); const nwcSw = feats.includes('nwc') && feats.includes('ark'); const swBody = SW .replaceAll('{{VERSION}}', version) // precached for offline use — except what only works online anyway: // the video compressor (Mediabunny, ~700 KB) loads when a video is // picked for upload, not on every install .replaceAll('{{EXTRA_SHELL}}', chunks.filter((c) => !c.text.includes('Mediabunny Codec Registry')).map((c) => `, '${c.name}'`).join('')) .replaceAll('{{NWC_WAKE_FALLBACK}}', nwcSw ? '' : SW_WAKE_ONLY); await Bun.write('dist/sw.js', nwcSw ? swBody + '\n' + await buildSwNwc() : swBody); for (const f of STATIC) await Bun.write('dist/' + f, Bun.file('static/' + f)); // Android app plumbing: assetlinks.json vouches for the TWA wrapper's // signing cert (URL-bar-less fullscreen), and the wrapper APK itself is // served for sideloading when a build has been dropped in static/. if (await Bun.file('static/.well-known/assetlinks.json').exists()) { await Bun.write('dist/.well-known/assetlinks.json', Bun.file('static/.well-known/assetlinks.json')); } // 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']) { if (await Bun.file('static/' + apk).exists()) { await Bun.write('dist/' + apk, Bun.file('static/' + apk)); } } // default avatars, self-hosted since coinos.io's copies are half-broken. // punks-sm carries a 128px copy of each: the art is 240px because that's // what a punk published as someone's nostr picture should be, but the // circles the app draws are 18-64px and shouldn't pay for the rest. const { readdirSync } = await import('node:fs'); for (const d of ['punks', 'punks-sm']) for (const f of readdirSync('static/' + d)) await Bun.write(`dist/${d}/${f}`, Bun.file(`static/${d}/${f}`)); const kb = (Buffer.byteLength(html) / 1024).toFixed(0); const jkb = (Buffer.byteLength(rawJs) / 1024).toFixed(0); const ckb = (chunks.reduce((n, c) => n + Buffer.byteLength(c.text), 0) / 1024).toFixed(0); const skb = ((await Bun.file('dist/standalone.html').arrayBuffer()).byteLength / 1024).toFixed(0); console.log(`✓ dist/index.html (${kb} KB) + app-${version}.js (${jkb} KB) + ${chunks.length} deferred chunk(s) (${ckb} KB) — offline use: install the PWA`); console.log(`✓ dist/standalone.html (${skb} KB) — the single-file save-and-run wallet`); console.log(`✓ PWA: manifest.webmanifest, sw.js (cold-${version}), ${STATIC.length} icons, jsqr.js, nip46.js, ${locales.length} locales`); }