From e50b6b4631c7e4e70a1725b6294e288dfc71a65c Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Sat, 13 Jun 2026 15:59:22 +0200 Subject: [PATCH] fix(web): vendor @noble/hashes/pbkdf2 + importmap entry (SPA black screen) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit B1.5 pulled deriveWallet into the sign-in path, so app.js's module graph now imports web/src/wallet/bip39.js -> '@noble/hashes/pbkdf2' for the first time in the BROWSER. That specifier was never in the index.html importmap (only node tests exercised bip39, where it resolves via node_modules), so the browser failed to resolve it and the whole graph never loaded — a black screen on boot (seen when pairing). Fix: vendor a bundled noble-hashes-pbkdf2.js, add the importmap entry, precache it in sw.js. Add web/test/importmap.test.js as a regression guard: every bare specifier under web/src must be in the importmap, and every importmap target must exist on disk — the check node tests couldn't do before. Verified in a real browser: the app boots to the sign-in gate. Co-Authored-By: Claude Opus 4.8 --- web/index.html | 1 + web/sw.js | 1 + web/test/importmap.test.js | 66 +++++++++++++++++++++++++++++++ web/vendor/noble-hashes-pbkdf2.js | 6 +++ 4 files changed, 74 insertions(+) create mode 100644 web/test/importmap.test.js create mode 100644 web/vendor/noble-hashes-pbkdf2.js diff --git a/web/index.html b/web/index.html index 6ccda51..e25a163 100644 --- a/web/index.html +++ b/web/index.html @@ -19,6 +19,7 @@ "@noble/ciphers/chacha": "/vendor/noble-ciphers-chacha.js", "@noble/hashes/sha2": "/vendor/noble-hashes-sha2.js", "@noble/hashes/hmac": "/vendor/noble-hashes-hmac.js", + "@noble/hashes/pbkdf2": "/vendor/noble-hashes-pbkdf2.js", "@noble/hashes/hkdf": "/vendor/noble-hashes-hkdf.js", "@noble/hashes/utils": "/vendor/noble-hashes-utils.js", "@xterm/xterm": "/vendor/xterm.js", diff --git a/web/sw.js b/web/sw.js index 1ce79ef..3a4219e 100644 --- a/web/sw.js +++ b/web/sw.js @@ -53,6 +53,7 @@ const SHELL = [ '/vendor/noble-curves-ed25519.js', '/vendor/noble-hashes-hkdf.js', '/vendor/noble-hashes-hmac.js', + '/vendor/noble-hashes-pbkdf2.js', '/vendor/noble-hashes-sha2.js', '/vendor/noble-hashes-utils.js', '/vendor/xterm-addon-fit.js', diff --git a/web/test/importmap.test.js b/web/test/importmap.test.js new file mode 100644 index 0000000..63034c6 --- /dev/null +++ b/web/test/importmap.test.js @@ -0,0 +1,66 @@ +// web/test/importmap.test.js — guards the gap that node tests cannot see: the +// browser resolves bare module specifiers (`@noble/...`) through the importmap in +// index.html, NOT node_modules. A specifier imported by app code but missing from +// the importmap loads fine under `node --test` yet fails at boot in the browser +// ("Failed to resolve module specifier") — a black screen. This test asserts every +// bare specifier used under web/src/ is mapped. (Regression guard for the missing +// @noble/hashes/pbkdf2 mapping that broke the SPA after wallet derivation moved +// into the sign-in path.) +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { readFileSync, readdirSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { dirname, join } from 'node:path'; + +const here = dirname(fileURLToPath(import.meta.url)); +const webRoot = join(here, '..'); + +function walk(dir) { + const out = []; + for (const e of readdirSync(dir, { withFileTypes: true })) { + const p = join(dir, e.name); + if (e.isDirectory()) out.push(...walk(p)); + else if (e.name.endsWith('.js')) out.push(p); + } + return out; +} + +// A bare specifier does not start with '.' or '/'. Those are the ones the browser +// resolves via the importmap; relative paths resolve as URLs directly. +function bareSpecifiers(src) { + const out = new Set(); + const re = /(?:^|\W)(?:import|export)[^'"]*?from\s*['"]([^'"]+)['"]/g; + let m; + while ((m = re.exec(src)) !== null) { + const spec = m[1]; + if (!spec.startsWith('.') && !spec.startsWith('/')) out.add(spec); + } + return out; +} + +test('every bare import under web/src is in the index.html importmap', () => { + const html = readFileSync(join(webRoot, 'index.html'), 'utf8'); + const mapJSON = html.match(/