aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--build.js102
-rw-r--r--src/concord.js4
-rw-r--r--src/features/nostrlogin.js19
-rw-r--r--src/nip46-global.js9
-rw-r--r--src/nostr-login.js23
-rw-r--r--tools/nostr-login-test.js3
6 files changed, 135 insertions, 25 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`);
}
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';