aboutsummaryrefslogtreecommitdiffstats
path: root/build.js
diff options
context:
space:
mode:
authorAdam Soltys <[email protected]>2026-08-26 12:06:00 -0700
committerAdam Soltys <[email protected]>2026-08-26 12:06:00 -0700
commit0427a1b02c21fd5fa9f5df8a86e435dbb84a8451 (patch)
tree32e5601682e6f65e46a8811cfc2ea7184ba17119 /build.js
parent6be9a45c4d085988c8bfaf210df762dbc83c8ab1 (diff)
downloadcoinosv3-0427a1b02c21fd5fa9f5df8a86e435dbb84a8451.tar.gz
coinosv3-0427a1b02c21fd5fa9f5df8a86e435dbb84a8451.zip
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-<hash>.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
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.js102
1 files changed, 84 insertions, 18 deletions
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 </script> inside the bundle closing our tag early.
- js = js.replaceAll('</script', '<\\/script');
- const css = await Bun.file('./src/style.css').text();
+ return result.outputs[0].text();
+}
+
+// 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(/'(<svg viewBox="0 0 224 72"[\s\S]*?<\/svg>)'/);
+ 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 `<div class="col" style="gap:16px"><div class="row between"><div class="brand"><div class="logo-full" aria-label="coinos" role="img">${logo}</div></div></div><div class="card col" id="boot-shell" style="gap:12px;display:none"><div class="row gap6" style="align-items:center"><div class="chat-avatar profile-avatar fallback loading"></div><div class="chat-title" id="boot-shell-name"></div></div></div></div>`;
+}
+const BOOT_SHELL_SCRIPT = `<script>try{var m=location.pathname.match(/^\\/([A-Za-z0-9._-]{1,64})\\/?$/);if(m){document.getElementById('boot-shell').style.display='';document.getElementById('boot-shell-name').textContent=decodeURIComponent(m[1]);}}catch(e){}</script>`;
+async function pageHtml({ css, staging, pwa, scriptHtml }) {
return `<!doctype html>
<html lang="en">
<head>
@@ -285,25 +319,56 @@ export async function buildHtml({ minify = true, pwa = minify, features = proces
${pwa ? PWA_HEAD : ''}<style>${css}</style>
</head>
<body>
-<div id="app"></div>
-<script>${js}</script>
+<div id="app">${bootShell(await brandSvg())}</div>
+${BOOT_SHELL_SCRIPT}
+${scriptHtml}
${pwa ? SW_REGISTER + '\n' : ''}</body>
-</html>`.replaceAll('{{COMMIT}}', gitCommit());
+</html>`;
+}
+
+// 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 </script> inside the bundle closing our tag early.
+ js = js.replaceAll('</script', '<\\/script');
+ const css = await Bun.file('./src/style.css').text();
+ const html = await pageHtml({ css, staging, pwa, scriptHtml: `<script>${js}</script>` });
+ 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-<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.
+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: '<script src="app-{{VERSION}}.js" defer></script>' }))
+ .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`);
}