The guard's logic was a src/lib file the config project also listed. The app project, which references the config project, then refuses that source on a clean build (TS6305) — while every local run reused the referenced project's cached output and stayed green; only the Docker image build failed. The logic is back inside chunkGuard.ts, its test beside it at the frontend root (vitest now also collects `*.test.ts` there), and a gate contract fails when the config project's include reaches into src/ again. The guard was re-proved on a build with a leaking re-export.
74 lines
2.9 KiB
TypeScript
74 lines
2.9 KiB
TypeScript
import type { Plugin } from "vite";
|
|
|
|
// A lazily imported page, rail, dialog or player must stay out of the chunks loaded at boot. A page
|
|
// is imported with import()/lazy() so it is fetched only when it is opened, and Rollup honours that
|
|
// only while nothing on the boot path imports the same module statically: one static import — a
|
|
// module's index.ts re-exporting it is the easy way — and the module joins a boot chunk, the import()
|
|
// becomes a no-op, and nothing says so. That rule used to be a hope; this makes it a build failure,
|
|
// naming each module and the boot chunk it landed in. The boot path is the entry chunks and what they
|
|
// import, plus the shell (App, itself loaded lazily but fetched at boot alongside the identity probe)
|
|
// and what it imports.
|
|
const SHELL = "src/shell/App.tsx";
|
|
|
|
export interface ChunkLike {
|
|
fileName: string;
|
|
isEntry: boolean;
|
|
/** The chunks this one imports statically. */
|
|
imports: string[];
|
|
moduleIds: string[];
|
|
}
|
|
|
|
const posix = (id: string) => id.split("\\").join("/");
|
|
|
|
/** Every lazily imported module (`lazy`: module ids) that sits in a boot chunk, as "id (chunk)". The
|
|
* shell's own module (`shell`, a path under the project) is exempt: it is lazy so that the public
|
|
* pages never load it, and its chunk is the shell. */
|
|
export function bootLeaks(chunks: ChunkLike[], lazy: Set<string>, shell: string): string[] {
|
|
const byName = new Map(chunks.map((c) => [c.fileName, c]));
|
|
const isShell = (id: string) => posix(id).endsWith(`/${shell}`);
|
|
const boot = new Set<ChunkLike>();
|
|
const visit = (c: ChunkLike | undefined) => {
|
|
if (!c || boot.has(c)) return;
|
|
boot.add(c);
|
|
c.imports.forEach((name) => visit(byName.get(name)));
|
|
};
|
|
chunks.filter((c) => c.isEntry || c.moduleIds.some(isShell)).forEach(visit);
|
|
const leaks: string[] = [];
|
|
for (const c of boot)
|
|
for (const id of c.moduleIds)
|
|
if (lazy.has(id) && !isShell(id)) leaks.push(`${posix(id)} (${c.fileName})`);
|
|
return leaks.sort();
|
|
}
|
|
|
|
export function chunkGuard(): Plugin {
|
|
return {
|
|
name: "siftlode-chunk-guard",
|
|
apply: "build",
|
|
generateBundle(_options, bundle) {
|
|
const lazy = new Set(
|
|
[...this.getModuleIds()].filter(
|
|
(id) => (this.getModuleInfo(id)?.dynamicImporters.length ?? 0) > 0,
|
|
),
|
|
);
|
|
const chunks: ChunkLike[] = Object.values(bundle).flatMap((o) =>
|
|
o.type === "chunk"
|
|
? [
|
|
{
|
|
fileName: o.fileName,
|
|
isEntry: o.isEntry,
|
|
imports: o.imports,
|
|
moduleIds: o.moduleIds,
|
|
},
|
|
]
|
|
: [],
|
|
);
|
|
const leaks = bootLeaks(chunks, lazy, SHELL);
|
|
if (leaks.length)
|
|
this.error(
|
|
`lazily imported, yet loaded at boot: ${leaks.join(", ")}. ` +
|
|
"Something on the boot path imports it statically (an index.ts re-export?): import it lazily there too, or not at all.",
|
|
);
|
|
},
|
|
};
|
|
}
|