mirror of
https://github.com/Matysh/houseplan-card
synced 2026-09-29 11:18:48 +00:00
Network failure of the editor runtime is no longer terminal: the loader
re-arms to idle and the next explicit press starts a fresh cycle, while a
fingerprint mismatch on either attempt stays terminal. The toast now says
what actually helps — retry advice for the network, refresh advice for a
foreign build — via one shared lazyLoadFailureMessage helper (new i18n key
editor.retry_advice in en/ru/de).
The field smoke caught a second, deeper bug on the way: Chromium records a
FAILED module in the page module map permanently, so retrying the same URL
(even the cache-busted one) never touched the network again. Every retry
now carries a per-cycle nonce and becomes a genuinely new module request.
A proxy-cached stale entry no longer kills the card silently: the entry
facade is rewritten at build time from a static re-export into a top-level
`try{await import(...)}catch{...}` — importers keep the happy-path
guarantee (await import(entry) still resolves only after
customElements.define), and the catch defines a fallback element with a
localized "reload the page" panel. Content-hashed chunks are served with
`public, max-age=31536000, immutable`, and verifyBundleTree now fails on
orphan chunks that the manifest does not name.
Proofs: loader units for re-arm/terminality/toast wording + an AST check
that both loaders forward the terminality flag; smoke_entry_stale (en/ru)
against a tree without the main chunk; smoke_lazy_editor_chunk extended —
second press after network failure now really opens the editor; pytest for
the immutable header; orphan-tree unit; five new registry mutants.
TESTING.md budget line updated to the #352 ceiling alongside.
Issue: #353
User-Visible: yes
95 lines
3.8 KiB
JavaScript
95 lines
3.8 KiB
JavaScript
#!/usr/bin/env node
|
||
import { createHash } from 'node:crypto';
|
||
import { existsSync, readFileSync, readdirSync } from 'node:fs';
|
||
import { dirname, relative, resolve } from 'node:path';
|
||
import { fileURLToPath } from 'node:url';
|
||
|
||
export const BUNDLE_MANIFEST = 'houseplan-assets.json';
|
||
|
||
export const sha256Bytes = (contents) => createHash('sha256').update(contents).digest('hex');
|
||
|
||
export function containedBundlePath(root, name) {
|
||
if (typeof name !== 'string' || !name || name.includes('\\')) {
|
||
throw new Error(`invalid bundle path: ${String(name)}`);
|
||
}
|
||
const path = resolve(root, name);
|
||
const rel = relative(resolve(root), path);
|
||
if (!rel || rel.startsWith('..') || rel.includes(':')) {
|
||
throw new Error(`bundle path escapes root: ${name}`);
|
||
}
|
||
return path;
|
||
}
|
||
|
||
export function readBundleManifest(root) {
|
||
const path = resolve(root, BUNDLE_MANIFEST);
|
||
if (!existsSync(path)) throw new Error(`${path}: bundle manifest is missing`);
|
||
const manifest = JSON.parse(readFileSync(path, 'utf8'));
|
||
if (manifest?.schema !== 1 || typeof manifest.entry !== 'string'
|
||
|| typeof manifest.fingerprint !== 'string' || !Array.isArray(manifest.files)) {
|
||
throw new Error(`${path}: invalid House Plan bundle manifest`);
|
||
}
|
||
const names = manifest.files.map((file) => file?.path);
|
||
if (new Set(names).size !== names.length || !names.includes(manifest.entry)) {
|
||
throw new Error(`${path}: duplicate assets or missing entry`);
|
||
}
|
||
return manifest;
|
||
}
|
||
|
||
export function verifyBundleTree(root) {
|
||
const manifest = readBundleManifest(root);
|
||
for (const file of manifest.files) {
|
||
if (typeof file?.path !== 'string' || !file.path.endsWith('.js')
|
||
|| !/^[a-zA-Z0-9._/-]+$/.test(file.path)) {
|
||
throw new Error(`invalid manifest asset path: ${String(file?.path)}`);
|
||
}
|
||
const path = containedBundlePath(root, file.path);
|
||
if (!existsSync(path)) throw new Error(`manifest asset is missing: ${file.path}`);
|
||
const actual = sha256Bytes(readFileSync(path));
|
||
if (actual !== file.sha256) {
|
||
throw new Error(`manifest hash mismatch: ${file.path} (${actual} != ${file.sha256})`);
|
||
}
|
||
}
|
||
// #353 К5: a chunk on disk that the manifest does not name is dead weight —
|
||
// it would ride into the release zip and mask sync bugs. Fail loudly.
|
||
const assetDir = resolve(root, 'houseplan-assets');
|
||
if (existsSync(assetDir)) {
|
||
const listed = new Set(manifest.files.map((file) => file.path));
|
||
for (const name of readdirSync(assetDir)) {
|
||
if (name.endsWith('.js') && !listed.has(`houseplan-assets/${name}`)) {
|
||
throw new Error(`orphan bundle asset: houseplan-assets/${name}`);
|
||
}
|
||
}
|
||
}
|
||
return manifest;
|
||
}
|
||
|
||
export function compareBundleTrees(sourceRoot, targetRoot) {
|
||
const source = verifyBundleTree(sourceRoot);
|
||
const target = verifyBundleTree(targetRoot);
|
||
if (JSON.stringify(target) !== JSON.stringify(source)) {
|
||
throw new Error('bundle manifests differ');
|
||
}
|
||
for (const file of source.files) {
|
||
const left = readFileSync(containedBundlePath(sourceRoot, file.path));
|
||
const right = readFileSync(containedBundlePath(targetRoot, file.path));
|
||
if (!left.equals(right)) throw new Error(`bundle asset differs: ${file.path}`);
|
||
}
|
||
return source;
|
||
}
|
||
|
||
const invokedDirectly = process.argv[1]
|
||
&& resolve(process.argv[1]) === resolve(fileURLToPath(import.meta.url));
|
||
if (invokedDirectly) {
|
||
try {
|
||
const [source, target] = process.argv.slice(2);
|
||
if (!source) throw new Error('usage: node scripts/bundle-tree.mjs <root> [matching-root]');
|
||
const manifest = target
|
||
? compareBundleTrees(resolve(source), resolve(target))
|
||
: verifyBundleTree(resolve(source));
|
||
console.log(`verified ${manifest.files.length} bundle assets`);
|
||
} catch (error) {
|
||
console.error(error instanceof Error ? error.message : String(error));
|
||
process.exitCode = 1;
|
||
}
|
||
}
|