mirror of
https://github.com/Matysh/houseplan-card
synced 2026-09-29 03:09:36 +00:00
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
181 lines
7.8 KiB
JavaScript
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;
|
|
}
|
|
}
|