Files
houseplan-card/scripts/bundle-tree.mjs
T
Claudeandclaude[bot] 063de0eef4 Let the panel reach the card by its hashed name, not by the one address without a version
The sidebar page could serve a previous card for hours. It imported the
card through the stable facade, `./houseplan-card.js` — a relative
specifier, and relative resolution does not inherit a query. A dashboard
reaches the same file as `houseplan-card.js?v=1.74.0`, so an upgrade
changes its URL and the browser must refetch. The panel always asked for
the same address, and entries are served with no Cache-Control at all —
only ETag and Last-Modified — so the browser applies heuristic freshness
and may answer from cache without asking. A stale 1164-byte loader names
a stale chunk, chunks are immutable for a year, and the panel then ran a
previous card against the current backend without a single error. The
version banner was telling the truth; reloading could not help, because
the address never changed.

Rollup already emits the right edge: the panel's side-effect import
points straight at the shared implementation. The rewrite in
entryFallbackPlugin replaced it with the facade for a fallback that the
hashed name gives anyway — and better: a chunk the manifest no longer
serves now raises the panel's own "House Plan was updated" notice
instead of silently working on old code.

Two #486 assertions change meaning and are rewritten, not adjusted: the
panel no longer routes through the facade, and its initial graph no
longer contains it. The invariant they defended — the panel reuses the
exact card graph, never a second copy — is now stated over the
implementation, and a new test pins that no built entry reaches the card
by an address without a version.

Issue: #535
User-Visible: yes
2026-09-11 22:12:11 +00:00

181 lines
7.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 CARD_ENTRY = 'houseplan-card.js';
export const PANEL_ENTRY = 'houseplan-panel.js';
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;
}
const assertUniqueGraph = (manifest, field, listed, label) => {
const graph = manifest[field];
if (!Array.isArray(graph) || graph.some((path) => typeof path !== 'string')) {
throw new Error(`${label}: ${field} must be an array of bundle paths`);
}
if (new Set(graph).size !== graph.length) {
throw new Error(`${label}: ${field} contains duplicate assets`);
}
const missing = graph.filter((path) => !listed.has(path));
if (missing.length) throw new Error(`${label}: ${field} references missing asset ${missing[0]}`);
return graph;
};
/** Validate the additive two-entry manifest contract independently of disk I/O. */
export function assertBundleManifest(manifest, label = BUNDLE_MANIFEST) {
if (manifest?.schema !== 1 || typeof manifest.fingerprint !== 'string'
|| !Array.isArray(manifest.files)) {
throw new Error(`${label}: invalid House Plan bundle manifest`);
}
if (manifest.entry !== CARD_ENTRY || manifest.panelEntry !== PANEL_ENTRY) {
throw new Error(`${label}: expected entries ${CARD_ENTRY} and ${PANEL_ENTRY}`);
}
const names = manifest.files.map((file) => file?.path);
const listed = new Set(names);
if (listed.size !== names.length || !listed.has(CARD_ENTRY) || !listed.has(PANEL_ENTRY)) {
throw new Error(`${label}: duplicate assets or missing stable entry`);
}
const declaredEntries = manifest.files
.filter((file) => file?.isEntry === true)
.map((file) => file.path)
.sort();
if (JSON.stringify(declaredEntries) !== JSON.stringify([CARD_ENTRY, PANEL_ENTRY].sort())) {
throw new Error(`${label}: isEntry inventory must contain exactly both stable entries`);
}
const initialView = assertUniqueGraph(manifest, 'initialViewFiles', listed, label);
const initialPanel = assertUniqueGraph(manifest, 'initialPanelFiles', listed, label);
const initialPanelOnly = assertUniqueGraph(manifest, 'initialPanelOnlyFiles', listed, label);
if (!initialView.includes(CARD_ENTRY) || initialView.includes(PANEL_ENTRY)) {
throw new Error(`${label}: initial View graph must contain only the card stable entry`);
}
// #535: the panel reaches the implementation by its content-hashed name, so
// the card's stable facade is no longer part of what the panel loads. That is
// the point: the facade is the one address with no version in it, and the
// panel must not depend on a URL a browser may keep for hours. What still has
// to hold is that the panel reuses the exact card IMPLEMENTATION graph.
if (initialPanel.includes(CARD_ENTRY) || !initialPanel.includes(PANEL_ENTRY)) {
throw new Error(`${label}: initial panel graph must contain its own stable entry only`);
}
if (initialView.some((path) => path !== CARD_ENTRY && !initialPanel.includes(path))) {
throw new Error(`${label}: initial View implementation is not a subset of initial panel graph`);
}
const expectedPanelOnly = initialPanel
.filter((path) => !initialView.includes(path))
.sort();
if (JSON.stringify([...initialPanelOnly].sort()) !== JSON.stringify(expectedPanelOnly)) {
throw new Error(`${label}: initialPanelOnlyFiles is not panel minus View`);
}
const gzip = (paths) => paths.reduce((total, path) => {
const value = manifest.files.find((file) => file.path === path)?.gzipBytes;
if (!Number.isSafeInteger(value) || value < 0) {
throw new Error(`${label}: missing gzip size for ${path}`);
}
return total + value;
}, 0);
for (const [field, graph] of [
['initialViewGzipBytes', initialView],
['initialPanelGzipBytes', initialPanel],
['initialPanelOnlyGzipBytes', initialPanelOnly],
]) {
if (manifest[field] !== gzip(graph)) {
throw new Error(`${label}: ${field} does not match its graph inventory`);
}
}
return manifest;
}
/** Copy immutable dependencies first, then both replaceable stable entries. */
export function orderedBundlePayload(manifest) {
const stableEntries = new Set([manifest.entry, manifest.panelEntry]);
return manifest.files
.map((file) => file.path)
.sort((left, right) => (stableEntries.has(left) ? 1 : 0)
- (stableEntries.has(right) ? 1 : 0) || left.localeCompare(right));
}
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'));
return assertBundleManifest(manifest, path);
}
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 K5 / #486: a managed chunk on disk that the manifest does not name is
// dead weight. Stable-looking root entries are just as dangerous as old
// hashed chunks: both ride into the HACS zip and can mask a stale sync.
const listed = new Set(manifest.files.map((file) => file.path));
for (const name of readdirSync(root)) {
if (/^houseplan-.*\.js$/.test(name) && !listed.has(name)) {
throw new Error(`orphan bundle asset: ${name}`);
}
}
const assetDir = resolve(root, 'houseplan-assets');
if (existsSync(assetDir)) {
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;
}
}