diff options
| author | Adam Soltys <[email protected]> | 2026-08-26 12:06:00 -0700 |
|---|---|---|
| committer | Adam Soltys <[email protected]> | 2026-08-26 12:06:00 -0700 |
| commit | 0427a1b02c21fd5fa9f5df8a86e435dbb84a8451 (patch) | |
| tree | 32e5601682e6f65e46a8811cfc2ea7184ba17119 | |
| parent | 6be9a45c4d085988c8bfaf210df762dbc83c8ab1 (diff) | |
| download | coinosv3-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
| -rw-r--r-- | build.js | 102 | ||||
| -rw-r--r-- | src/concord.js | 4 | ||||
| -rw-r--r-- | src/features/nostrlogin.js | 19 | ||||
| -rw-r--r-- | src/nip46-global.js | 9 | ||||
| -rw-r--r-- | src/nostr-login.js | 23 | ||||
| -rw-r--r-- | tools/nostr-login-test.js | 3 |
6 files changed, 135 insertions, 25 deletions
@@ -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`); } diff --git a/src/concord.js b/src/concord.js index 9f6803d..74c1313 100644 --- a/src/concord.js +++ b/src/concord.js @@ -9,7 +9,9 @@ import { hkdf } from "@noble/hashes/hkdf"; import { schnorr, secp256k1 } from "@noble/curves/secp256k1"; import { bytesToHex, hexToBytes } from "@noble/hashes/utils"; import { finalizeEvent, generateSecretKey, getPublicKey, getEventHash, verifyEvent } from "nostr-tools/pure"; -import { nip44 } from "nostr-tools"; +// subpath, NOT the "nostr-tools" barrel — the barrel drags every NIP (and a +// second copy of the noble crypto stack) into the bundle for one import +import * as nip44 from "nostr-tools/nip44"; import * as nip19 from "nostr-tools/nip19"; import { base64urlnopad } from "@scure/base"; diff --git a/src/features/nostrlogin.js b/src/features/nostrlogin.js index 5195778..9c4156f 100644 --- a/src/features/nostrlogin.js +++ b/src/features/nostrlogin.js @@ -162,11 +162,19 @@ export function nostrLoginFeature(ctx) { // from another device); when the app connects, login continues by itself. let nc = null; let ncRun = null; - function startNostrConnect(run) { + let ncToken = null; // guards the async gap while the nip46 module loads + async function startNostrConnect(run) { if (nc) nc.cancel(); ncRun = run; - nc = nostrConnect(); - const mine = nc; + const token = (ncToken = {}); + let mine; + try { mine = await nostrConnect(); } catch { + if (token === ncToken && ui.nostrConnectOpen) { ui.nostrConnectOpen = false; render(); } + return; + } + if (token !== ncToken || !ncRun) { mine.cancel(); return; } + nc = mine; + render(); mine.ready.then((signer) => { if (nc !== mine || !ncRun) return; ui.nostrConnectOpen = false; @@ -179,6 +187,7 @@ export function nostrLoginFeature(ctx) { function stopNostrConnect() { if (nc) { nc.cancel(); nc = null; } ncRun = null; + ncToken = null; } function nostrConnectSection(run) { @@ -188,7 +197,9 @@ export function nostrLoginFeature(ctx) { onClick: () => { ui.nostrConnectOpen = true; startNostrConnect(run); render(); }, }, t('nlConnectApp')); } - if (!nc) return null; + // the nip46 module is still fetching — hold the spot with a spinner + if (!nc) return h('div', { class: 'row gap6', style: 'align-items:center;justify-content:center;padding:8px' }, + h('span', { class: 'spinner sm' })); return h('div', { class: 'col', style: 'gap:10px;align-items:center' }, h('a', { class: 'btn btn-block btn-primary', href: nc.uri, diff --git a/src/nip46-global.js b/src/nip46-global.js new file mode 100644 index 0000000..a841905 --- /dev/null +++ b/src/nip46-global.js @@ -0,0 +1,9 @@ +// Standalone bundle for the NIP-46 remote-signer client (bunker:// and +// nostrconnect:// logins) — emitted as a separate dist/nip46.js and loaded on +// demand by nostr-login.js, the first time a bunker interaction happens. +// It's the heaviest, rarest corner of the login stack (and drags a second +// copy of the noble crypto stack via nostr-tools' own pins), so it stays out +// of the main bundle and off the initial load. +import * as nip46 from 'nostr-tools/nip46'; + +window.__nip46 = nip46; diff --git a/src/nostr-login.js b/src/nostr-login.js index 301bbeb..6958da0 100644 --- a/src/nostr-login.js +++ b/src/nostr-login.js @@ -38,9 +38,25 @@ import { entropyToMnemonic } from '@scure/bip39'; import { wordlist } from '@scure/bip39/wordlists/english'; import { getPublicKey, finalizeEvent, nip44 } from './nostr.js'; import { SimplePool } from 'nostr-tools/pool'; -import { BunkerSigner, parseBunkerInput, createNostrConnectURI } from 'nostr-tools/nip46'; import { generateSecretKey } from 'nostr-tools/pure'; +// NIP-46 loads lazily from dist/nip46.js (same pattern as the QR decoder): +// bunker logins are rare, and the module is heavy. Tests running outside a +// browser preset globalThis.__nip46 with the real module instead. +let _nip46 = null; +function loadNip46() { + if (_nip46) return _nip46; + if (typeof globalThis !== 'undefined' && globalThis.__nip46) return (_nip46 = Promise.resolve(globalThis.__nip46)); + _nip46 = new Promise((resolve, reject) => { + const s = document.createElement('script'); + s.src = 'nip46.js'; + s.onload = () => (window.__nip46 ? resolve(window.__nip46) : reject(new Error('signer module unavailable'))); + s.onerror = () => { _nip46 = null; reject(new Error('could not load the remote-signer module')); }; + document.head.appendChild(s); + }); + return _nip46; +} + const SEED_DTAG = 'coinos:wallet:v1'; const BACKUP_KIND = 30078; const DERIVE_TAG = 'coinos:wallet-seed:v1'; @@ -91,6 +107,7 @@ export async function extensionSigner() { } export async function bunkerSigner(uri, { onAuth } = {}) { + const { BunkerSigner, parseBunkerInput } = await loadNip46(); const bp = await parseBunkerInput(String(uri || '').trim()); if (!bp) throw new Error('not a bunker:// address'); if (!bp.relays || !bp.relays.length) throw new Error('that bunker address lists no relays'); @@ -111,6 +128,7 @@ export async function bunkerSigner(uri, { onAuth } = {}) { // prompting the user again. export async function resumeBunker(session, { onAuth, timeoutMs = 8000 } = {}) { if (!session || !session.local || !session.bp || !session.bp.pubkey) return null; + const { BunkerSigner } = await loadNip46(); const local = hex.decode(session.local); const signer = BunkerSigner.fromBunker(local, session.bp, { onauth: (url) => { if (onAuth) onAuth(url); }, @@ -147,7 +165,8 @@ function bunkerAdapter(signer, pubkey, local) { // relay — no bunker URL to copy. Returns the URI (render it as a deep link // and a QR), a promise that resolves to a signer adapter when the app // answers, and a cancel. -export function nostrConnect({ relays = ['wss://relay.coinos.io', 'wss://nos.lol'], timeoutMs = 180_000 } = {}) { +export async function nostrConnect({ relays = ['wss://relay.coinos.io', 'wss://nos.lol'], timeoutMs = 180_000 } = {}) { + const { BunkerSigner, createNostrConnectURI } = await loadNip46(); const local = generateSecretKey(); const secret = hex.encode(crypto.getRandomValues(new Uint8Array(16))); const uri = createNostrConnectURI({ diff --git a/tools/nostr-login-test.js b/tools/nostr-login-test.js index 0440333..468ffb4 100644 --- a/tools/nostr-login-test.js +++ b/tools/nostr-login-test.js @@ -77,6 +77,9 @@ console.log('\n[bunker (NIP-46) wiring]'); { const { bunkerSigner } = await import('../src/nostr-login.js'); const { parseBunkerInput } = await import('nostr-tools/nip46'); + // outside a browser the lazy dist/nip46.js can't be script-injected — + // preset the module the way the loader's test seam expects + globalThis.__nip46 = await import('nostr-tools/nip46'); // a real Amethyst-issued address shape const uri = 'bunker://81029479a85c3f2f0c8d7ca9fbd92923942e2254e873d93c1c7095f068c83a85' + '?relay=wss%3A%2F%2Frelay.damus.io%2F&relay=wss%3A%2F%2Fnos.lol%2F&secret=4wdWEsG7RDzCaBt6p9ovvk0uMX6ENh2J'; |
