diff options
| author | Adam Soltys <[email protected]> | 2026-08-26 14:16:41 -0700 |
|---|---|---|
| committer | Adam Soltys <[email protected]> | 2026-08-26 14:16:41 -0700 |
| commit | e18a74b936e43aeb7f74df433d88f294f9bcc877 (patch) | |
| tree | f50c34564d964555ee44a3b1e199e10240016d43 /build.js | |
| parent | 0427a1b02c21fd5fa9f5df8a86e435dbb84a8451 (diff) | |
| download | coinosv3-e18a74b936e43aeb7f74df433d88f294f9bcc877.tar.gz coinosv3-e18a74b936e43aeb7f74df433d88f294f9bcc877.zip | |
The app arrives in movements: real ESM chunks split the bundle (entry 333KB, shared chunks alongside, 61KB of gifts/NWC/hats deferred past first paint into slots that keep their hook seniority), the lazy scripts wear IIFE coats so their names can't collide with the page's — which is what silently broke the remote-signer button — nip98 moves out of the login module so minimal builds stop hauling backup relays, and the smoke test learns the front door's new steps while the regtest stack rises from its IBD nap
Co-Authored-By: Claude Fable 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_019eA8JRq9Wr7z2fRQk99HLi
Diffstat (limited to 'build.js')
| -rw-r--r-- | build.js | 82 |
1 files changed, 63 insertions, 19 deletions
@@ -42,7 +42,7 @@ const SW = `const CACHE = 'cold-{{VERSION}}'; // 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']; +const SHELL = ['./', 'manifest.webmanifest', 'icon-192.png', 'icon-512.png', 'jsqr.js', 'nip46.js', 'app-{{VERSION}}.js'{{EXTRA_SHELL}}]; self.addEventListener('install', (e) => { self.skipWaiting(); @@ -146,6 +146,12 @@ const SW_REGISTER = `navigator.serviceWorker.addEventListener('controllerchange',function(){_ask(navigator.serviceWorker.controller)});` + `addEventListener('load',function(){navigator.serviceWorker.register('sw.js',{updateViaCache:'none'}).catch(function(){})})}</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 +// 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({ @@ -157,7 +163,7 @@ export async function buildJsQr({ minify = true } = {}) { for (const log of result.logs) console.error(log); throw new Error('jsqr bundle failed'); } - return result.outputs[0].text(); + return iife(await result.outputs[0].text()); } // Standalone bundle for the NIP-46 remote-signer client — loaded on demand by @@ -172,7 +178,7 @@ export async function buildNip46({ minify = true } = {}) { for (const log of result.logs) console.error(log); throw new Error('nip46 bundle failed'); } - return result.outputs[0].text(); + return iife(await result.outputs[0].text()); } // The notify-only push handler for builds without the ark+nwc features (the @@ -228,14 +234,36 @@ export async function buildSwNwc({ minify = true } = {}) { // 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' }; +// 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. +const DEFERRED_FEATURES = ['gifts', 'nwc', 'hats']; + 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); } -function featureIndexSource(enabled) { - return enabled.map((f) => `import { ${ALL_FEATURES[f]} } from './${f}.js';`).join('\n') - + `\nexport function buildFeatures(ctx) { return [${enabled.map((f) => `${ALL_FEATURES[f]}(ctx)`).join(', ')}]; }\n`; +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) => ({ @@ -274,18 +302,21 @@ function gitCommit() { return 'dev'; } -async function bundleAppJs({ minify, features, staging }) { +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)], }); if (!result.success) { for (const log of result.logs) console.error(log); throw new Error('bundle failed'); } - return result.outputs[0].text(); + return result.outputs; } // The brand mark for the static boot shell, lifted from brandHeader in @@ -328,7 +359,8 @@ ${pwa ? SW_REGISTER + '\n' : ''}</body> // 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 }); + const outputs = await bundleApp({ minify, features, staging }); + let js = await outputs[0].text(); // Guard against a literal </script> inside the bundle closing our tag early. js = js.replaceAll('</script', '<\\/script'); const css = await Bun.file('./src/style.css').text(); @@ -337,15 +369,23 @@ export async function buildHtml({ minify = true, pwa = minify, features = proces } // The hosted split build: a small no-cache index.html (CSS + boot shell) that -// loads the bundle from a content-addressed app-<version>.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. +// 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 js = (await bundleAppJs({ minify, features, staging })).replaceAll('{{COMMIT}}', gitCommit()); + 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(); - const html = (await pageHtml({ css, staging, pwa: true, scriptHtml: '<script src="app-{{VERSION}}.js" defer></script>' })) + // module scripts defer by default; the boot shell paints while this loads + const html = (await pageHtml({ css, staging, pwa: true, scriptHtml: '<script type="module" src="app-{{VERSION}}.js"></script>' })) .replaceAll('{{COMMIT}}', gitCommit()); - return { html, js }; + return { html, js, chunks }; } if (import.meta.main) { @@ -353,15 +393,17 @@ if (import.meta.main) { // 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, 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-[a-z0-9]+\.js$/.test(f) && f !== `app-${version}.js`) await Bun.file('dist/' + f).delete(); + if (/^(app|chunk)-[A-Za-z0-9]+\.js$/.test(f) && !keep.has(f)) await Bun.file('dist/' + f).delete(); } // Lazy-loaded QR decoder — kept out of index.html, fetched only when a @@ -387,6 +429,7 @@ if (import.meta.main) { const nwcSw = feats.includes('nwc') && feats.includes('ark'); const swBody = SW .replaceAll('{{VERSION}}', version) + .replaceAll('{{EXTRA_SHELL}}', chunks.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)); @@ -396,6 +439,7 @@ if (import.meta.main) { const kb = (Buffer.byteLength(html) / 1024).toFixed(0); 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)`); + const ckb = (chunks.reduce((n, c) => n + Buffer.byteLength(c.text), 0) / 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(`✓ PWA: manifest.webmanifest, sw.js (cold-${version}), ${STATIC.length} icons, jsqr.js, nip46.js, ${locales.length} locales`); } |
