225 lines
8.1 KiB
TypeScript
225 lines
8.1 KiB
TypeScript
/**
|
|
* libsodium-not-in-baseline-closure-smoke.ts
|
|
*
|
|
* Byte-budget invariant (cp267 — memory Priority #4, TINY FOOTPRINT):
|
|
*
|
|
* libsodium-wrappers-sumo is ~1 MB (the WASM is inlined into the JS,
|
|
* no separate .wasm). It must NEVER appear in the STATIC import
|
|
* closure of the shared layouts, because everything in a layout's
|
|
* closure is `<link rel="modulepreload">`-ed on EVERY page (home,
|
|
* orderbook, …) — including pages that never touch crypto.
|
|
*
|
|
* Before cp267 it WAS in the baseline. `[lang]/+layout.svelte` reached
|
|
* it two ways:
|
|
* 1. → $stores/identity → $crypto/keystore + $crypto/keygen, which
|
|
* did `import sodium from 'libsodium-wrappers-sumo'` at top level.
|
|
* 2. → $lib/trades/tradeEventListener → $lib/chat/crypto (same
|
|
* static libsodium import).
|
|
* Measured per-page baseline was 59 chunks / 1358 KB, of which 1040 KB
|
|
* was libsodium. cp267 fixed it:
|
|
* - keygen.ts + keystore.ts now `import { sodium } from './sodium'`,
|
|
* a lazy holder populated by a DYNAMIC `import()` inside
|
|
* ensureSodium(). The sodium.* call sites are byte-for-byte
|
|
* unchanged (ESM live binding).
|
|
* - tradeEventListener.ts dynamically `import()`s chat/crypto inside
|
|
* its event handler instead of importing it statically.
|
|
* Baseline dropped to 357 KB; libsodium is now a lazy chunk that loads
|
|
* only when crypto actually runs (unlock / onboarding "Create" / chat /
|
|
* import).
|
|
*
|
|
* This smoke walks the layout STATIC import graph as text (no build, no
|
|
* runtime, no transpile) and FAILS if libsodium re-enters it — e.g.
|
|
* someone adds a component to +layout that statically pulls a crypto
|
|
* module, or reverts keygen/keystore to a static libsodium import. It
|
|
* also pins the specific mechanisms so a regression yields a precise
|
|
* message rather than a mysterious +1 MB.
|
|
*
|
|
* OUT OF SCOPE: route-specific closures (chat, onboarding/import,
|
|
* settings/yubikey) MAY still statically pull libsodium — that is "what
|
|
* those pages legitimately need". This smoke guards only the every-page
|
|
* BASELINE (the shared layouts).
|
|
*
|
|
* Runner cwd is apps/web (see scripts/run-smokes.sh), so all paths are
|
|
* relative to apps/web.
|
|
*/
|
|
import { existsSync, readFileSync, statSync } from 'node:fs';
|
|
import { dirname, join } from 'node:path';
|
|
|
|
const LIB = 'libsodium-wrappers-sumo';
|
|
|
|
// Mirror of svelte.config.js → kit.alias. Kept small + local on
|
|
// purpose; if an alias is renamed there, the closure walk simply won't
|
|
// resolve through it and the (still-correct) baseline measurement in
|
|
// the build remains the backstop.
|
|
const ALIAS: Record<string, string> = {
|
|
$lib: 'src/lib',
|
|
$components: 'src/lib/components',
|
|
$crypto: 'src/lib/crypto',
|
|
$i18n: 'src/lib/i18n',
|
|
$stores: 'src/lib/stores',
|
|
$utils: 'src/lib/utils',
|
|
$net: 'src/lib/net',
|
|
$blurt: 'src/lib/blurt',
|
|
$indexer: 'src/lib/indexer',
|
|
$seo: 'src/lib/seo',
|
|
$prices: 'src/lib/prices'
|
|
};
|
|
|
|
function resolveSpec(spec: string, fromFile: string): string | null {
|
|
let base: string | null = null;
|
|
if (spec.startsWith('$')) {
|
|
const seg = spec.split('/');
|
|
const a = ALIAS[seg[0]];
|
|
if (!a) return null;
|
|
base = join(a, ...seg.slice(1));
|
|
} else if (spec.startsWith('.')) {
|
|
base = join(dirname(fromFile), spec);
|
|
} else {
|
|
return null; // bare node_modules specifier — not a source file we walk
|
|
}
|
|
for (const ext of ['', '.ts', '.js', '.svelte', '/index.ts', '/index.js']) {
|
|
const f = base + ext;
|
|
if (existsSync(f) && statSync(f).isFile()) return f;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Static `import ... from '...'`, side-effect `import '...'`, and
|
|
* `export ... from '...'` specifiers. EXCLUDES `import type` (erased at
|
|
* build) and dynamic `import(...)` (the whole point — those don't pull
|
|
* the module into the static closure).
|
|
*/
|
|
function staticImportSpecs(file: string): string[] {
|
|
let src = readFileSync(file, 'utf8');
|
|
// strip block + line comments so commented-out imports don't count
|
|
src = src.replace(/\/\*[\s\S]*?\*\//g, '').replace(/(^|[^:])\/\/.*$/gm, '$1');
|
|
const specs: string[] = [];
|
|
// `import\s+` requires whitespace after `import`, so `import(` (dynamic)
|
|
// never matches.
|
|
const reImport = /^\s*import\s+(?:type\s+)?(?:[^;'"]*?\sfrom\s+)?['"]([^'"]+)['"]/gm;
|
|
let m: RegExpExecArray | null;
|
|
while ((m = reImport.exec(src))) {
|
|
const line = src.slice(m.index, reImport.lastIndex);
|
|
if (/^\s*import\s+type\b/.test(line)) continue;
|
|
specs.push(m[1]);
|
|
}
|
|
const reExport = /^\s*export\s+(?:\{[^}]*\}|\*)\s+from\s+['"]([^'"]+)['"]/gm;
|
|
while ((m = reExport.exec(src))) specs.push(m[1]);
|
|
return specs;
|
|
}
|
|
|
|
/** If libsodium is reachable via static imports from `entry`, returns
|
|
* the import chain (entry → … → importer); otherwise null. */
|
|
function findLibsodiumChain(entry: string): string[] | null {
|
|
const seen = new Set<string>();
|
|
const parent: Record<string, string> = {};
|
|
let hit: string | null = null;
|
|
(function walk(file: string) {
|
|
if (hit || seen.has(file)) return;
|
|
seen.add(file);
|
|
let specs: string[];
|
|
try {
|
|
specs = staticImportSpecs(file);
|
|
} catch {
|
|
return;
|
|
}
|
|
for (const s of specs) {
|
|
if (s === LIB) {
|
|
hit = file;
|
|
return;
|
|
}
|
|
const r = resolveSpec(s, file);
|
|
if (!r) continue;
|
|
if (!parent[r]) parent[r] = file;
|
|
walk(r);
|
|
}
|
|
})(entry);
|
|
if (!hit) return null;
|
|
const chain = [hit];
|
|
let c: string = hit;
|
|
while (parent[c] && chain.length < 40) {
|
|
chain.push(parent[c]);
|
|
c = parent[c];
|
|
}
|
|
return chain.reverse();
|
|
}
|
|
|
|
const failures: string[] = [];
|
|
let scenarios = 0;
|
|
|
|
// (1)(2) The shared layouts' static closures must NOT reach libsodium.
|
|
for (const layout of ['src/routes/[lang]/+layout.svelte', 'src/routes/+layout.svelte']) {
|
|
scenarios++;
|
|
if (!existsSync(layout)) {
|
|
failures.push(`layout not found: ${layout}`);
|
|
continue;
|
|
}
|
|
const chain = findLibsodiumChain(layout);
|
|
if (chain) {
|
|
failures.push(
|
|
`${LIB} is in the STATIC import closure of ${layout} → it would modulepreload on EVERY page.\n` +
|
|
` chain: ${chain.join('\n -> ')}\n` +
|
|
` Fix: lazy-load libsodium via $crypto/sodium inside the crypto module, or dynamically import() the offending module.`
|
|
);
|
|
}
|
|
}
|
|
|
|
// (3) The lazy loader must use a DYNAMIC import (not static).
|
|
{
|
|
scenarios++;
|
|
const f = 'src/lib/crypto/sodium.ts';
|
|
if (!existsSync(f)) {
|
|
failures.push(`${f} missing — it is the single lazy libsodium loader.`);
|
|
} else {
|
|
const src = readFileSync(f, 'utf8');
|
|
const hasDynamic = new RegExp(`import\\(\\s*['"]${LIB}['"]`).test(src);
|
|
const hasStatic = new RegExp(`^\\s*import\\s+[^;]*\\sfrom\\s+['"]${LIB}['"]`, 'm').test(src);
|
|
if (!hasDynamic) failures.push(`${f} must load ${LIB} via a dynamic import() (lazy).`);
|
|
if (hasStatic)
|
|
failures.push(`${f} must NOT statically import ${LIB} — that defeats the lazy load.`);
|
|
}
|
|
}
|
|
|
|
// (4)(5) keygen/keystore must route through ./sodium, never a direct
|
|
// static libsodium import.
|
|
for (const f of ['src/lib/crypto/keygen.ts', 'src/lib/crypto/keystore.ts']) {
|
|
scenarios++;
|
|
if (!existsSync(f)) {
|
|
failures.push(`${f} missing`);
|
|
continue;
|
|
}
|
|
const src = readFileSync(f, 'utf8');
|
|
if (new RegExp(`^\\s*import\\s+[^;]*\\sfrom\\s+['"]${LIB}['"]`, 'm').test(src)) {
|
|
failures.push(
|
|
`${f} statically imports ${LIB} — it must \`import { sodium } from './sodium'\` instead (cp267).`
|
|
);
|
|
}
|
|
}
|
|
|
|
// (6) tradeEventListener runs from +layout, so it must dynamically
|
|
// import chat/crypto (which pulls libsodium), never statically.
|
|
{
|
|
scenarios++;
|
|
const f = 'src/lib/trades/tradeEventListener.ts';
|
|
if (!existsSync(f)) {
|
|
failures.push(`${f} missing`);
|
|
} else {
|
|
const specs = staticImportSpecs(f);
|
|
if (specs.some((s) => s === '$lib/chat/crypto' || /(^|\/)chat\/crypto$/.test(s))) {
|
|
failures.push(
|
|
`${f} statically imports chat/crypto — it must dynamically import() it; it runs from +layout, so a static import drags libsodium into the every-page baseline.`
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
if (failures.length) {
|
|
console.error('✗ libsodium-not-in-baseline-closure-smoke FAILED:');
|
|
for (const f of failures) console.error(` - ${f}`);
|
|
process.exit(1);
|
|
}
|
|
|
|
console.log(
|
|
`✓ all ${scenarios} byte-budget invariants hold: libsodium stays out of the every-page baseline closure (lazy via $crypto/sodium; chat/crypto dynamically imported in tradeEventListener)`
|
|
);
|