From 0427a1b02c21fd5fa9f5df8a86e435dbb84a8451 Mon Sep 17 00:00:00 2001 From: Adam Soltys Date: Wed, 26 Aug 2026 12:06:00 -0700 Subject: The page arrives light and paints at once: index.html shrinks to 46KB with a static boot shell baked in — brand row, and the profile frame when the path names one — while the 789KB bundle moves to a content-addressed app-.js the browser keeps (a repeat visit re-downloads kilobytes, not the app); the nostr-tools barrel import goes subpath, and the NIP-46 signer client ships as its own lazy file fetched on first bunker login MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_019eA8JRq9Wr7z2fRQk99HLi --- build.js | 102 ++++++++++++++++++++++++++++++++++++++++++++++++++++----------- 1 file changed, 84 insertions(+), 18 deletions(-) (limited to 'build.js') diff --git a/build.js b/build.js index 6e1829d..06dbe6f 100644 --- a/build.js +++ b/build.js @@ -38,9 +38,11 @@ const MANIFEST = { // 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 is precached so the lazy QR decoder is available offline too (for -// installed PWAs on browsers without a native BarcodeDetector). -const SHELL = ['./', 'manifest.webmanifest', 'icon-192.png', 'icon-512.png', 'jsqr.js']; +// 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', 'app-{{VERSION}}.js']; self.addEventListener('install', (e) => { self.skipWaiting(); @@ -158,6 +160,21 @@ export async function buildJsQr({ minify = true } = {}) { return 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 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) => { @@ -257,7 +274,7 @@ function gitCommit() { return 'dev'; } -export async function buildHtml({ minify = true, pwa = minify, features = process.env.HAL_FEATURES, staging = isStaging() } = {}) { +async function bundleAppJs({ minify, features, staging }) { const result = await Bun.build({ entrypoints: ['./src/app.js'], target: 'browser', @@ -268,11 +285,28 @@ export async function buildHtml({ minify = true, pwa = minify, features = proces for (const log of result.logs) console.error(log); throw new Error('bundle failed'); } - let js = await result.outputs[0].text(); - // Guard against a literal inside the bundle closing our tag early. - js = js.replaceAll(')'/); + 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) { + return `
`; +} +const BOOT_SHELL_SCRIPT = ``; +async function pageHtml({ css, staging, pwa, scriptHtml }) { return ` @@ -285,25 +319,56 @@ export async function buildHtml({ minify = true, pwa = minify, features = proces ${pwa ? PWA_HEAD : ''} -
- +
${bootShell(await brandSvg())}
+${BOOT_SHELL_SCRIPT} +${scriptHtml} ${pwa ? SW_REGISTER + '\n' : ''} -`.replaceAll('{{COMMIT}}', gitCommit()); +`; +} + +// 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() } = {}) { + let js = await bundleAppJs({ minify, features, staging }); + // 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 bundle from a content-addressed app-.js. The page stays +// tiny on every load while the heavy bundle is cached by name — a repeat +// visit re-downloads kilobytes, not the whole app. +export async function buildSplit({ minify = true, features = process.env.HAL_FEATURES, staging = isStaging() } = {}) { + const js = (await bundleAppJs({ minify, features, staging })).replaceAll('{{COMMIT}}', gitCommit()); + const css = await Bun.file('./src/style.css').text(); + const html = (await pageHtml({ css, staging, pwa: true, scriptHtml: '' })) + .replaceAll('{{COMMIT}}', gitCommit()); + return { html, js }; } if (import.meta.main) { await mkdir('dist', { recursive: true }); - // Hashed with {{VERSION}} still a placeholder, so the page and the worker - // can both be stamped with the same token — the page cannot contain a hash - // of itself. - const rawHtml = await buildHtml({ minify: true }); - const version = Bun.hash(rawHtml).toString(36); + // 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 } = await buildSplit({ minify: true }); + const version = Bun.hash(rawHtml + rawJs).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)); + // Drop stale content-addressed bundles from earlier local builds so dist/ + // mirrors a deploy (the deploy hook rsyncs dist/ wholesale). + for (const f of await readdir('dist')) { + if (/^app-[a-z0-9]+\.js$/.test(f) && f !== `app-${version}.js`) await Bun.file('dist/' + f).delete(); + } // Lazy-loaded QR decoder — kept out of index.html, fetched only when a // browser without BarcodeDetector opens the scanner. 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 }); @@ -330,6 +395,7 @@ if (import.meta.main) { for (const f of readdirSync('static/punks')) await Bun.write('dist/punks/' + f, Bun.file('static/punks/' + f)); const kb = (Buffer.byteLength(html) / 1024).toFixed(0); - console.log(`✓ dist/index.html written (${kb} KB) — open it offline, no server needed`); - console.log(`✓ PWA: manifest.webmanifest, sw.js (cold-${version}), ${STATIC.length} icons, jsqr.js, ${locales.length} locales`); + const jkb = (Buffer.byteLength(rawJs) / 1024).toFixed(0); + console.log(`✓ dist/index.html written (${kb} KB) + app-${version}.js (${jkb} KB) — offline: keep the two side by side (or install the PWA)`); + console.log(`✓ PWA: manifest.webmanifest, sw.js (cold-${version}), ${STATIC.length} icons, jsqr.js, nip46.js, ${locales.length} locales`); } -- cgit v1.2.3