aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorAdam Soltys <[email protected]>2026-08-26 14:16:41 -0700
committerAdam Soltys <[email protected]>2026-08-26 14:16:41 -0700
commite18a74b936e43aeb7f74df433d88f294f9bcc877 (patch)
treef50c34564d964555ee44a3b1e199e10240016d43
parent0427a1b02c21fd5fa9f5df8a86e435dbb84a8451 (diff)
downloadcoinosv3-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
-rw-r--r--build.js82
-rw-r--r--src/app.js25
-rw-r--r--src/features/hats.js2
-rw-r--r--src/features/index.js35
-rw-r--r--src/features/names.js2
-rw-r--r--src/nip98.js15
-rw-r--r--src/nostr-login.js8
-rw-r--r--tools/feature-registry-check.js51
-rw-r--r--tools/minimal-smoke.js7
-rw-r--r--tools/nostr-login-test.js2
10 files changed, 151 insertions, 78 deletions
diff --git a/build.js b/build.js
index 06dbe6f..e566dc6 100644
--- a/build.js
+++ b/build.js
@@ -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`);
}
diff --git a/src/app.js b/src/app.js
index 237808e..2e52e56 100644
--- a/src/app.js
+++ b/src/app.js
@@ -8,12 +8,12 @@ import { Wallet, newMnemonic, isValidMnemonic, accountXpubFor, cacheKeyFor, utxo
import { qrSvg } from './qr.js';
import { makeSearcher, resultRows, searchable, punkUrl } from './recipient-search.js';
import { npubOf } from './nostr.js';
-import { nip98Header } from './nostr-login.js';
+import { nip98Header } from './nip98.js';
import { NOSTR_MARK } from './features/nostrlogin.js';
import { scanQr } from './scan.js';
import { dataSources, getSource, setSource, getNetwork, setNetwork, NETWORKS } from './api.js';
import { STAGING } from './build-flags.js';
-import { buildFeatures } from './features/index.js';
+import { buildFeatures, loadDeferredFeatures } from './features/index.js';
import { t, LANGS, getLang, setLang, isRTL, loadLocale } from './i18n.js';
import {
fmtBtc,
@@ -1093,6 +1093,7 @@ async function activateAccount(acc, opts = {}) {
// threads, decrypted DMs) belong to the old keys, and surviving into this
// account they'd both leak the old account's messages on screen and block
// the new account's own subscriptions from ever starting.
+ _featuresInited = false;
for (const f of FEATURES) { try { f.stop && f.stop(); } catch {} }
activeId = acc.id;
// A gift link generates this wallet only to claim into. Keep it provisional
@@ -1119,6 +1120,7 @@ async function activateAccount(acc, opts = {}) {
persistAccounts();
autoSave(acc);
for (const f of FEATURES) { try { f.init && f.init(); } catch {} } // per-feature wallet lifecycle
+ _featuresInited = true; // a deferred feature landing after this point gets its init() on arrival
// A freshly generated identity provably has no profile yet — let features
// skip the "is there a kind-0?" wait (avatar would sit blank meanwhile).
if (opts.generated) featureHook('identityGenerated');
@@ -1783,6 +1785,7 @@ function lock({ offerPassword = false } = {}) {
wallet.stopRealtime();
// A locked session must not keep listening as the account that just left —
// and whatever logs in next must not inherit its threads or subscriptions.
+ _featuresInited = false;
for (const f of FEATURES) { try { f.stop && f.stop(); } catch {} }
clearAccounts();
vaultPassword = null;
@@ -1909,6 +1912,7 @@ function softLock() {
}
}
const keepId = activeId;
+ _featuresInited = false;
for (const f of FEATURES) { try { f.stop && f.stop(); } catch {} }
vaultPassword = null;
accounts = watch.slice();
@@ -4398,6 +4402,18 @@ const ctx = {
},
};
const FEATURES = buildFeatures(ctx);
+// Deferred features (gifts, NWC, hats) hold placeholder slots in FEATURES —
+// hook precedence is position — and arrive as separate chunks right after
+// boot, filled in place. If a wallet opened before they landed, each newcomer
+// gets the init() it missed; if the wallet locked again, it doesn't.
+let _featuresInited = false;
+const _deferredReady = (async () => {
+ try {
+ const late = await loadDeferredFeatures(ctx, FEATURES);
+ if (_featuresInited) for (const f of late) { try { f.init && f.init(); } catch {} }
+ if (late.length) render();
+ } catch (e) { console.error('deferred features failed to load', e); }
+})();
// apply text direction, then restore a wallet left open in this tab — otherwise
// show the unlock screen.
@@ -4467,8 +4483,11 @@ document.addEventListener('visibilitychange', () => {
else onAppVisible();
});
window.addEventListener('load', () => setTimeout(() => { if (activeAccount()) claimMigratedName(); }, 1500));
-loadLocale(getLang()).finally(() => {
+loadLocale(getLang()).finally(async () => {
applyBootAutoLogout(); // clear an overdue session before we read it for claim targets
+ // A gift link's feature is deferred — wait for it before asking who owns
+ // the URL, or the claim would fall through to a normal boot and be lost.
+ if (/^\/(g|ag|lg)\//.test(location.pathname)) await _deferredReady;
if (featureHook('bootUrl')) return; // a feature consumed the URL (e.g. a gift claim)
if (!restoreAccountsState()) render();
});
diff --git a/src/features/hats.js b/src/features/hats.js
index bb9b8b0..43151ad 100644
--- a/src/features/hats.js
+++ b/src/features/hats.js
@@ -13,7 +13,7 @@
import { t } from '../i18n.js';
import { fmtSats } from '../format.js';
-import { nip98Header } from '../nostr-login.js';
+import { nip98Header } from '../nip98.js';
import { getNetwork } from '../api.js';
const REGISTRAR = 'https://names.coinos.io';
diff --git a/src/features/index.js b/src/features/index.js
index 5ecb7c0..a32bced 100644
--- a/src/features/index.js
+++ b/src/features/index.js
@@ -3,24 +3,33 @@
// build.js replaces this module's contents based on HAL_FEATURES, so a
// disabled feature's code — and its network endpoints — never enters the
// bundle. See featureIndexSource() in build.js.
-
-import { giftsFeature } from './gifts.js';
-import { nostrLoginFeature } from './nostrlogin.js';
+//
+// This checked-in copy must stay byte-equivalent (comments aside) to
+// featureIndexSource(all features) — tools/feature-registry-check.js enforces
+// it. Deferred features (gifts, nwc, hats) hold their array position with an
+// empty placeholder — hook precedence is position — and are filled in place
+// by loadDeferredFeatures right after boot, as separate chunks in the split
+// build. NB order is meaningful where hooks stack: gifts' receive takeover
+// and balance line come before ark's; zaps sits after ark so an npub prefers
+// an instant Ark zap; nwc sits after ark because it drives ark's headless
+// pay/balance seam; names sits between ark and zaps so a pasted user@domain
+// resolves over DNS (BIP-353) before zaps treats it as a Lightning address.
import { arkFeature } from './ark.js';
+import { nostrLoginFeature } from './nostrlogin.js';
import { namesFeature } from './names.js';
import { zapsFeature } from './zaps.js';
import { syncFeature } from './sync.js';
-import { nwcFeature } from './nwc.js';
-import { hatsFeature } from './hats.js';
import { messagesFeature } from './messages.js';
-// NB order is meaningful where hooks stack: gifts' receive takeover and
-// balance line come before ark's, matching the pre-plugin layout. zaps sits
-// after ark so an npub prefers an instant Ark zap and only falls through to a
-// Lightning zap when Ark can't serve it. nwc sits after ark because it
-// drives ark's headless pay/balance seam.
-// names sits between ark and zaps: a pasted user@domain resolves over DNS
-// (BIP-353) before the zaps feature treats it as a plain Lightning address.
export function buildFeatures(ctx) {
- return [giftsFeature(ctx), arkFeature(ctx), nostrLoginFeature(ctx), namesFeature(ctx), zapsFeature(ctx), nwcFeature(ctx), hatsFeature(ctx), syncFeature(ctx), messagesFeature(ctx)];
+ return [{}, arkFeature(ctx), nostrLoginFeature(ctx), namesFeature(ctx), zapsFeature(ctx), {}, {}, syncFeature(ctx), messagesFeature(ctx)];
+}
+export async function loadDeferredFeatures(ctx, features) {
+ const late = [];
+ await Promise.all([
+ import('./gifts.js').then((m) => { late.push(features[0] = m.giftsFeature(ctx)); }),
+ import('./nwc.js').then((m) => { late.push(features[5] = m.nwcFeature(ctx)); }),
+ import('./hats.js').then((m) => { late.push(features[6] = m.hatsFeature(ctx)); })
+ ]);
+ return late;
}
diff --git a/src/features/names.js b/src/features/names.js
index cf2aa3c..56fc77d 100644
--- a/src/features/names.js
+++ b/src/features/names.js
@@ -12,7 +12,7 @@
// record falls back to the Lightning-address flow (zaps feature).
import { resolveBip353, parsePaymentName, parseBip21 } from '../bip353.js';
-import { nip98Header } from '../nostr-login.js';
+import { nip98Header } from '../nip98.js';
import { qrSvg } from '../qr.js';
import { isArkAddress } from './ark.js';
import { getNetwork } from '../api.js';
diff --git a/src/nip98.js b/src/nip98.js
new file mode 100644
index 0000000..aa0343e
--- /dev/null
+++ b/src/nip98.js
@@ -0,0 +1,15 @@
+// NIP-98 HTTP Auth header (kind 27235): the signature covers the exact URL,
+// method and body hash, so a captured header is useless anywhere else. Its
+// own module — every build needs it (registrar calls), but only some builds
+// need the login/backup machinery around it in nostr-login.js.
+import { hex } from '@scure/base';
+import { sha256 } from '@noble/hashes/sha256';
+
+export async function nip98Header(signer, url, method, bodyText) {
+ const tags = [['u', url], ['method', method.toUpperCase()]];
+ if (bodyText) tags.push(['payload', hex.encode(sha256(new TextEncoder().encode(bodyText)))]);
+ const evt = await signer.signEvent({
+ kind: 27235, created_at: Math.floor(Date.now() / 1000), tags, content: '',
+ });
+ return 'Nostr ' + btoa(JSON.stringify(evt));
+}
diff --git a/src/nostr-login.js b/src/nostr-login.js
index 6958da0..b032c73 100644
--- a/src/nostr-login.js
+++ b/src/nostr-login.js
@@ -270,11 +270,3 @@ export async function walletForSigner(signer) {
// request with a nostr key. `u` and `method` bind the signature to this exact
// request and `payload` to its body, so a captured header can't be replayed
// against a different endpoint or a tampered body.
-export async function nip98Header(signer, url, method, bodyText) {
- const tags = [['u', url], ['method', method.toUpperCase()]];
- if (bodyText) tags.push(['payload', hex.encode(sha256(new TextEncoder().encode(bodyText)))]);
- const evt = await signer.signEvent({
- kind: 27235, created_at: Math.floor(Date.now() / 1000), tags, content: '',
- });
- return 'Nostr ' + btoa(JSON.stringify(evt));
-}
diff --git a/tools/feature-registry-check.js b/tools/feature-registry-check.js
index 9b7ac5e..0931156 100644
--- a/tools/feature-registry-check.js
+++ b/tools/feature-registry-check.js
@@ -1,40 +1,29 @@
-// Guard: src/features/index.js and build.js's ALL_FEATURES must agree.
+// Guard: src/features/index.js and build.js's featureIndexSource must agree.
// They drifted once (nwc was added to index.js only, so its i18n strings
// shipped but its code never did — the settings card silently never rendered,
// because build.js SWAPS OUT features/index.js for a generated module).
+//
+// The strongest form of agreement is equality: the checked-in module must be
+// code-identical (comments and whitespace aside) to what featureIndexSource
+// generates for the full feature set — imports, construction order, deferred
+// placeholder slots, and the deferred loader all included.
// bun tools/feature-registry-check.js
import { readFileSync } from 'node:fs';
+import { featureIndexSource, enabledFeatures } from '../build.js';
-const idx = readFileSync(new URL('../src/features/index.js', import.meta.url), 'utf8');
-const build = readFileSync(new URL('../build.js', import.meta.url), 'utf8');
+const strip = (s) => s
+ .replace(/\/\/[^\n]*/g, '') // line comments
+ .replace(/\s+/g, ' ') // whitespace runs
+ .replace(/, /g, ',').replace(/ ,/g, ',')
+ .trim();
-const inIndex = [...idx.matchAll(/import \{ (\w+Feature) \} from '\.\/([\w-]+)\.js'/g)]
- .map((m) => ({ fn: m[1], name: m[2] }));
-const registry = build.match(/const ALL_FEATURES = \{([^}]*)\}/s)?.[1] || '';
-const inBuild = [...registry.matchAll(/(\w+):\s*'(\w+Feature)'/g)].map((m) => ({ name: m[1], fn: m[2] }));
+const idx = readFileSync(new URL('../src/features/index.js', import.meta.url), 'utf8');
+const gen = featureIndexSource(enabledFeatures(undefined));
-let ok = true;
-for (const f of inIndex) {
- if (!inBuild.find((b) => b.fn === f.fn)) {
- console.log(`x ${f.fn} (${f.name}.js) is in features/index.js but NOT in build.js ALL_FEATURES — it will not ship`);
- ok = false;
- }
-}
-for (const b of inBuild) {
- if (!inIndex.find((f) => f.fn === b.fn)) {
- console.log(`x ${b.fn} is in build.js ALL_FEATURES but not imported in features/index.js`);
- ok = false;
- }
-}
-// Compare the ORDER FEATURES ARE CONSTRUCTED IN (hook precedence depends on
-// it), not the order they happen to be imported in.
-const built = [...(idx.match(/return \[([^\]]*)\]/s)?.[1] || '')
- .matchAll(/(\w+Feature)\(ctx\)/g)].map((m) => m[1]);
-const orderIdx = built.join(',');
-const orderBuild = inBuild.map((b) => b.fn).join(',');
-if (ok && orderIdx !== orderBuild) {
- console.log(`x feature ORDER differs (hook precedence depends on it)\n index.js: ${orderIdx}\n build.js: ${orderBuild}`);
- ok = false;
+if (strip(idx) !== strip(gen)) {
+ console.log('x src/features/index.js does not match build.js featureIndexSource (full set)');
+ console.log('\n--- generated (normalized) ---\n' + strip(gen));
+ console.log('\n--- checked in (normalized) ---\n' + strip(idx));
+ process.exit(1);
}
-if (ok) console.log(`ok ${inIndex.length} features registered consistently`);
-process.exit(ok ? 0 : 1);
+console.log(`ok features/index.js matches the generator (${enabledFeatures(undefined).length} features, deferred slots included)`);
diff --git a/tools/minimal-smoke.js b/tools/minimal-smoke.js
index bd0d588..f3f896c 100644
--- a/tools/minimal-smoke.js
+++ b/tools/minimal-smoke.js
@@ -29,7 +29,10 @@ check('no ark protocol code', !html.includes('Ark VTXO mailbox authorization'));
check('no swap protocol code', !html.includes('swap/reverse'));
check('no musig code', !html.includes('MuSig/noncecoef'));
check('no silent-payment engine', !html.includes('BIP0352') && !html.includes('__SP_WORKER__'));
-check('no nostr sync code', !html.includes('30078') && !html.includes('relay.coinos.io'));
+// The sync EVENT KIND must be gone; the coinos relay hostname alone is not a
+// sync marker anymore — recipient search (send-to-npub, core UX) carries
+// PROFILE_RELAYS for profile lookups even in the minimal build.
+check('no nostr sync code', !html.includes('30078'));
const server = Bun.serve({ port: 5198, fetch: () => new Response(html, { headers: { 'content-type': 'text/html' } }) });
@@ -62,6 +65,8 @@ try {
await sleep(400);
console.log('\n[1] import + core wallet');
+ await clickText('button', 'Get started'); // the front door gained a step
+ await sleep(300);
await clickText('button', 'Import existing');
await sleep(200);
await page.type('textarea', generateMnemonic(wordlist));
diff --git a/tools/nostr-login-test.js b/tools/nostr-login-test.js
index 468ffb4..25983a4 100644
--- a/tools/nostr-login-test.js
+++ b/tools/nostr-login-test.js
@@ -58,7 +58,7 @@ check('another account cannot decrypt it', denied);
console.log('\n[NIP-98 auth]');
{
- const { nip98Header } = await import('../src/nostr-login.js');
+ const { nip98Header } = await import('../src/nip98.js');
const { verifyEvent } = await import('nostr-tools/pure');
const { sha256: sha } = await import('@noble/hashes/sha256');
const body = JSON.stringify({ name: 'x' });