mirror of
https://github.com/fabro-sh/fabro.git
synced 2026-09-19 00:03:30 +00:00
## Problem Loading the web UI from a remote server took **~11 seconds to first render on every refresh**. A HAR capture against a remote deployment showed the page downloading **13.5 MB of JavaScript across 356 files, uncompressed, on every single page load** — even though the assets are content-hashed and served with `Cache-Control: immutable`. Four compounding causes: 1. **`Pragma: no-cache` defeated the browser cache.** The security-headers middleware stamped `Pragma: no-cache` onto every response, including hashed assets that set a year-long immutable `Cache-Control`. Browsers treat a response `Pragma: no-cache` as `Cache-Control: no-cache` and check it *before* `max-age` (Chromium zeroes freshness on it), and since assets carried no validators, "revalidate" degraded into a full re-download. Empirically visible in the HAR: Google-Fonts woff2s served from cache (`transfer = 0`) during the same page load where all 356 of our assets re-downloaded in full. 2. **No response compression.** The server had no compression layer; 13.5 MB of JS compresses to ~2.5 MB with brotli. 3. **The HTML force-loaded every chunk.** `writeIndexHtml` emitted a `<script type="module">` tag for all 356 outputs. Only 2.9 MB is statically reachable from the entry; the other ~10.7 MB is dynamic-import-only code (syntax grammars, Graphviz WASM, xterm, diff file tree) that was being downloaded eagerly at high priority. 4. **The immutable heuristic over-matched.** Any dash in a filename counted as a content hash, so stable-named files (`pierre-diffs-worker/worker-portable.js`, `apple-touch-icon.png`) would be pinned in browser caches for a year across deploys once fix 1 made immutable caching effective. ## Changes - **`security_headers`**: apply the `no-store`/`Pragma: no-cache` defaults only when the handler didn't set its own `Cache-Control`. API responses keep the conservative defaults. - **Compression**: `tower-http` `CompressionLayer` (brotli + gzip) on both the main router and the install-mode router (install mode serves the same SPA bundle through a separate router). Default predicate keeps SSE (`text/event-stream`), gRPC, images, and tiny bodies identity-encoded. Quality pinned to `Precise(4)` — tower-http's default defers to the codec default, and brotli's default is quality 11 (seconds of CPU per multi-megabyte asset). - **Entry-only HTML**: `writeIndexHtml` emits script tags only for `kind === "entry-point"` outputs. The module graph pulls static imports (depth 1, so no waterfall); dynamic `import()` chunks load on demand. - **Cache-control classifier + validators**: only files matching the bundler's actual output shape (`assets/<stem>-<hash8>.js|css`, lowercase base-36) get `immutable`. Everything else is `no-cache` **with a strong ETag** and `If-None-Match` → `304` support, so index.html / app.css / the pierre worker revalidate in one cheap conditional request instead of a full re-download. ## Impact (measured on the built bundle) | | Before | After | |---|---|---| | Cold load, ~1 MB/s link | 13.5 MB raw ≈ **11–14 s** | ~0.8 MB compressed eager payload ≈ **~1 s** | | Refresh | full re-download, same 11–14 s | served from cache + one 304 ≈ **instant** | | Eager JS on first render | 13.56 MB / 356 files | 2.88 MB raw (0.79 MB gzip) / 6 files | ## Verification - 959 fabro-server tests pass (incl. new coverage); fmt + clippy clean; `bun run typecheck` passes (the 5 pre-existing bun test failures reproduce identically on `main` — missing `@pierre/diffs/dist/worker` fixture + flaky InstallApp timing tests). - New integration tests pin compression through **both** serving shapes that matter: regular routes and the SPA fallback service, each via tower `oneshot` **and** over a real TCP connection through hyper (raw-socket assertions, so no client auto-decompression can mask a regression). - Live-verified against a debug server: hashed assets get `immutable` + brotli and no `Pragma`; mutable assets get `no-cache` + ETag and answer conditionals with `304`; API responses keep `no-store`. - Headless Chrome boots the rebuilt SPA from the entry-only HTML and fully renders the UI. ## Notes for reviewers - The ETag is skipped for immutable assets deliberately — they never revalidate, so hashing multi-MB bodies per request would be pure overhead. - Install mode previously had **no** compression and shares the same bundle; it gets the same layer via a shared `compression_layer()` helper. - `bun test` has a pre-existing suite (`production build copies Pierre worker assets`) that fails without `@pierre/diffs/dist/worker` present locally; unrelated to this change. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
270 lines
8.2 KiB
TypeScript
270 lines
8.2 KiB
TypeScript
import { watch as fsWatch } from "node:fs";
|
|
import {
|
|
cp,
|
|
lstat,
|
|
mkdir,
|
|
readFile,
|
|
readdir,
|
|
rename,
|
|
rm,
|
|
symlink,
|
|
writeFile,
|
|
} from "node:fs/promises";
|
|
import { dirname, join, relative } from "node:path";
|
|
|
|
declare const Bun: any;
|
|
|
|
const root = new URL("..", import.meta.url);
|
|
const rootPath = Bun.fileURLToPath(root);
|
|
const buildsRootDir = join(rootPath, ".dist-builds");
|
|
const distPath = join(rootPath, "dist");
|
|
const publicDir = join(rootPath, "public");
|
|
const templatePath = join(rootPath, "index.template.html");
|
|
const watch = Bun.argv.includes("--watch");
|
|
|
|
// Locate dependencies through module resolution rather than hardcoded
|
|
// node_modules paths: where packages land on disk depends on the Bun install
|
|
// linker (hoisted puts them at the workspace root, isolated symlinks them into
|
|
// the app's node_modules), so any fixed path breaks on one of the layouts.
|
|
const pierreWorkerDir = join(dirname(Bun.resolveSync("@pierre/diffs", rootPath)), "worker");
|
|
|
|
const tailwindCliPackageJsonPath = Bun.resolveSync("@tailwindcss/cli/package.json", rootPath);
|
|
const tailwindCliBin = join(
|
|
dirname(tailwindCliPackageJsonPath),
|
|
JSON.parse(await readFile(tailwindCliPackageJsonPath, "utf8")).bin.tailwindcss,
|
|
);
|
|
|
|
function newBuildId(): string {
|
|
return `${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
|
|
}
|
|
|
|
async function buildOnce() {
|
|
const buildId = newBuildId();
|
|
const buildDir = join(buildsRootDir, buildId);
|
|
const buildAssetsDir = join(buildDir, "assets");
|
|
await mkdir(buildAssetsDir, { recursive: true });
|
|
|
|
const result = await Bun.build({
|
|
entrypoints: [join(rootPath, "app", "entry.tsx")],
|
|
outdir: buildAssetsDir,
|
|
naming: "[name]-[hash].[ext]",
|
|
minify: true,
|
|
splitting: true,
|
|
target: "browser",
|
|
});
|
|
|
|
if (!result.success) {
|
|
throw new Error(result.logs.map((log: any) => log.message).join("\n"));
|
|
}
|
|
|
|
const cssResult = await Bun.spawn([
|
|
process.execPath,
|
|
tailwindCliBin,
|
|
"-i",
|
|
"app/app.css",
|
|
"-o",
|
|
relative(rootPath, join(buildAssetsDir, "app.css")),
|
|
"--minify",
|
|
], {
|
|
cwd: rootPath,
|
|
stdout: "inherit",
|
|
stderr: "inherit",
|
|
}).exited;
|
|
|
|
if (cssResult !== 0) {
|
|
throw new Error("Tailwind build failed");
|
|
}
|
|
|
|
await cp(publicDir, buildDir, { recursive: true });
|
|
await copyPierreWorkerAssets(join(buildAssetsDir, "pierre-diffs-worker"));
|
|
await writeIndexHtml(
|
|
buildDir,
|
|
result.outputs.map((output: any) => ({
|
|
kind: output.kind,
|
|
path: relative(buildDir, output.path),
|
|
})),
|
|
);
|
|
|
|
await publishBuild(buildDir);
|
|
await pruneOldBuilds(buildId);
|
|
}
|
|
|
|
async function copyPierreWorkerAssets(targetDir: string) {
|
|
await mkdir(targetDir, { recursive: true });
|
|
await cp(
|
|
join(pierreWorkerDir, "worker-portable.js"),
|
|
join(targetDir, "worker-portable.js"),
|
|
);
|
|
|
|
const files = await readdir(pierreWorkerDir);
|
|
for (const file of files) {
|
|
if (!/^wasm-.*\.js$/.test(file)) continue;
|
|
await cp(join(pierreWorkerDir, file), join(targetDir, file));
|
|
}
|
|
}
|
|
|
|
// `kind` mirrors Bun's `BuildArtifact.kind`; the union keeps the
|
|
// "entry-point" comparison below typo-safe.
|
|
type IndexHtmlOutput = {
|
|
kind: "entry-point" | "chunk" | "asset" | "sourcemap" | "bytecode";
|
|
path: string;
|
|
};
|
|
|
|
async function writeIndexHtml(buildDir: string, outputs: IndexHtmlOutput[]) {
|
|
const template = await readFile(templatePath, "utf8");
|
|
// Only entry points get <script> tags. Bun's `splitting: true` emits
|
|
// hundreds of chunks reachable from the entry through static and dynamic
|
|
// imports; listing every chunk here force-downloads the whole bundle
|
|
// (13+ MB) before first render, defeating the code splitting. The
|
|
// browser's module graph pulls static imports itself, and dynamic
|
|
// import() chunks load on demand.
|
|
const scripts = outputs
|
|
.filter((output) => output.kind === "entry-point" && output.path.endsWith(".js"))
|
|
.map((output) => `<script type="module" src="/${output.path.replaceAll("\\\\", "/")}"></script>`)
|
|
.join("\n ");
|
|
const styles = [
|
|
"/assets/app.css",
|
|
...outputs
|
|
.filter((output) => output.path.endsWith(".css"))
|
|
.map((output) => `/${output.path.replaceAll("\\\\", "/")}`),
|
|
]
|
|
.filter((value, index, array) => array.indexOf(value) === index)
|
|
.map((path) => `<link rel="stylesheet" href="${path}" />`)
|
|
.join("\n ");
|
|
|
|
const html = template
|
|
.replace("{{styles}}", styles)
|
|
.replace("{{scripts}}", scripts);
|
|
|
|
await writeFile(join(buildDir, "index.html"), html, "utf8");
|
|
}
|
|
|
|
// Atomically point `dist` at the freshly-built directory. Symlink replacement
|
|
// via rename(2) is atomic on macOS and Linux, so readers never see a partial
|
|
// build: they either resolve through the old symlink or the new one.
|
|
async function publishBuild(buildDir: string) {
|
|
// Migrate from the pre-symlink layout: if `dist` exists as a real directory
|
|
// (left over from an older version of this script), remove it so we can
|
|
// replace it with a symlink. Hit at most once per machine.
|
|
const existing = await lstatOrNull(distPath);
|
|
if (existing && !existing.isSymbolicLink()) {
|
|
await rm(distPath, { recursive: true, force: true });
|
|
}
|
|
|
|
const tmpLink = `${distPath}.tmp.${process.pid}.${Date.now()}`;
|
|
await symlink(relative(rootPath, buildDir), tmpLink);
|
|
await rename(tmpLink, distPath);
|
|
}
|
|
|
|
async function lstatOrNull(path: string) {
|
|
try {
|
|
return await lstat(path);
|
|
} catch (error: any) {
|
|
if (error?.code === "ENOENT") return null;
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
async function pruneOldBuilds(currentId: string) {
|
|
let entries: string[];
|
|
try {
|
|
entries = await readdir(buildsRootDir);
|
|
} catch (error: any) {
|
|
if (error?.code === "ENOENT") return;
|
|
throw error;
|
|
}
|
|
|
|
for (const entry of entries) {
|
|
if (entry === currentId) continue;
|
|
try {
|
|
await rm(join(buildsRootDir, entry), { recursive: true, force: true });
|
|
} catch (error) {
|
|
console.error(`Failed to prune ${entry}:`, error);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Coalesce rapid filesystem events. macOS recursive `fs.watch` fires several
|
|
// events per logical save (atomic-write produces create + write + rename) and
|
|
// can emit spurious bubble events; without a quiet window the watcher thrashes.
|
|
const REBUILD_DEBOUNCE_MS = 75;
|
|
|
|
// Only file kinds the bundle actually consumes can change what gets built.
|
|
// Filtering noise (editor swap files, OS metadata, .tsbuildinfo, lock files)
|
|
// keeps the watcher quiet for events that can't change output.
|
|
const REBUILD_RELEVANT_EXTS = new Set([
|
|
".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs",
|
|
".css", ".html", ".json",
|
|
".svg", ".png", ".jpg", ".jpeg", ".webp", ".gif", ".ico", ".avif",
|
|
".woff", ".woff2", ".ttf", ".otf",
|
|
]);
|
|
|
|
function rebuildRelevant(filename: string | null | undefined): boolean {
|
|
if (!filename) return true;
|
|
const dot = filename.lastIndexOf(".");
|
|
if (dot < 0) return false;
|
|
return REBUILD_RELEVANT_EXTS.has(filename.slice(dot).toLowerCase());
|
|
}
|
|
|
|
async function main() {
|
|
if (!watch) {
|
|
await buildOnce();
|
|
return;
|
|
}
|
|
|
|
await buildOnce();
|
|
let building = false;
|
|
let rebuildQueued = false;
|
|
let debounceTimer: ReturnType<typeof setTimeout> | null = null;
|
|
const debug = !!process.env.FABRO_BUILD_DEBUG;
|
|
|
|
async function rebuild() {
|
|
if (building) {
|
|
rebuildQueued = true;
|
|
return;
|
|
}
|
|
|
|
building = true;
|
|
do {
|
|
rebuildQueued = false;
|
|
try {
|
|
await buildOnce();
|
|
} catch (error) {
|
|
console.error(error);
|
|
}
|
|
} while (rebuildQueued);
|
|
building = false;
|
|
}
|
|
|
|
function scheduleRebuild(eventType: string, filename: string | null) {
|
|
if (!rebuildRelevant(filename)) {
|
|
if (debug) console.log(`[watch] skip ${eventType} ${filename}`);
|
|
return;
|
|
}
|
|
if (debug) console.log(`[watch] queue ${eventType} ${filename}`);
|
|
if (debounceTimer) clearTimeout(debounceTimer);
|
|
debounceTimer = setTimeout(() => {
|
|
debounceTimer = null;
|
|
void rebuild();
|
|
}, REBUILD_DEBOUNCE_MS);
|
|
}
|
|
|
|
const watchers = [
|
|
fsWatch(join(rootPath, "app"), { recursive: true }, scheduleRebuild),
|
|
fsWatch(publicDir, { recursive: true }, scheduleRebuild),
|
|
fsWatch(templatePath, scheduleRebuild),
|
|
];
|
|
|
|
process.on("SIGINT", () => {
|
|
if (debounceTimer) clearTimeout(debounceTimer);
|
|
for (const watcher of watchers) {
|
|
watcher.close();
|
|
}
|
|
process.exit(0);
|
|
});
|
|
}
|
|
|
|
main().catch((error) => {
|
|
console.error(error);
|
|
process.exit(1);
|
|
});
|