fabro/lib/crates/fabro-server/src
Scott Werner 1806e91d7e
Fix web app load performance: caching, compression, and eager chunk loading (#550)
## 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>
2026-07-02 16:58:47 -04:00
..
auth Demote control-plane config to plain String; native FABRO_WEB_URL read (#510) 2026-06-18 10:03:14 -04:00
demo Resolve run.prepare.steps env and interpolation at the run boundary (#530) 2026-07-01 10:31:26 -04:00
server Move environments to SQLite storage (#539) 2026-07-01 10:31:58 -04:00
worker_control Replace stdin JSONL control pipe with WebSocket worker control bus (#440) 2026-05-27 20:24:25 -04:00
automation_materializer.rs Unify @file inlining under an ImportableTemplate type (prompt + goal) (#527) 2026-06-25 15:51:46 -04:00
canonical_host.rs Demote control-plane config to plain String; native FABRO_WEB_URL read (#510) 2026-06-18 10:03:14 -04:00
canonical_origin.rs Demote control-plane config to plain String; native FABRO_WEB_URL read (#510) 2026-06-18 10:03:14 -04:00
csp.rs chore: update CSP 2026-06-01 19:42:39 -04:00
diagnostics.rs Move variables to SQLite storage (#537) 2026-06-30 12:17:46 -04:00
error.rs feat: Add batch archive/unarchive API endpoints and update web bulk act… (#380) 2026-05-23 23:32:35 -04:00
git_checkout.rs Unify @file inlining under an ImportableTemplate type (prompt + goal) (#527) 2026-06-25 15:51:46 -04:00
github_webhooks.rs refactor(static): centralize env var names 2026-04-24 12:29:51 -04:00
install.rs Fix web app load performance: caching, compression, and eager chunk loading (#550) 2026-07-02 16:58:47 -04:00
interp.rs Demote control-plane config to plain String; native FABRO_WEB_URL read (#510) 2026-06-18 10:03:14 -04:00
jwt_auth.rs Demote control-plane config to plain String; native FABRO_WEB_URL read (#510) 2026-06-18 10:03:14 -04:00
lib.rs refactor(server): extract reusable git-checkout/materialization core (#523) 2026-06-24 16:17:44 -04:00
manifest_validation.rs feat(server): mcp-servers HTTP API — handlers + AppState wiring (#532) 2026-06-30 15:06:24 -04:00
migrations.rs refactor: rationalize server secret scopes (vault-only for optional int… (#401) 2026-05-25 17:26:01 -04:00
principal_middleware.rs feat(web): add server-managed Environments CRUD settings UI (#462) 2026-06-13 08:44:38 -04:00
request_id.rs feat(server): correlate API errors with request ids 2026-04-29 12:20:56 -04:00
run_files.rs feat(web): add server-managed Environments CRUD settings UI (#462) 2026-06-13 08:44:38 -04:00
run_files_security.rs chore(clippy): require reasons on allow attributes 2026-04-19 20:24:24 -04:00
run_manifest.rs Resolve run.prepare.steps env and interpolation at the run boundary (#530) 2026-07-01 10:31:26 -04:00
run_selector.rs fix: close sandbox-native metadata gaps 2026-04-28 08:05:18 -07:00
run_title_generation.rs feat(llm): catalog-estimated completion cost on Response (#494) 2026-06-11 16:55:47 -04:00
run_tool_manifest.rs Interpolation foundation (InterpString v2) (#472) 2026-06-10 12:51:08 -04:00
security_headers.rs Fix web app load performance: caching, compression, and eager chunk loading (#550) 2026-07-02 16:58:47 -04:00
serve.rs Move environments to SQLite storage (#539) 2026-07-01 10:31:58 -04:00
server.rs Fix web app load performance: caching, compression, and eager chunk loading (#550) 2026-07-02 16:58:47 -04:00
server_secrets.rs refactor: rationalize server secret scopes (vault-only for optional int… (#401) 2026-05-25 17:26:01 -04:00
spawn_env.rs feat(llm): Amazon Bedrock provider — Converse codec, SigV4 + API-key auth (#459) 2026-06-16 11:46:49 -04:00
startup.rs refactor: rationalize server secret scopes (vault-only for optional int… (#401) 2026-05-25 17:26:01 -04:00
static_files.rs Fix web app load performance: caching, compression, and eager chunk loading (#550) 2026-07-02 16:58:47 -04:00
test_support.rs Move environments to SQLite storage (#539) 2026-07-01 10:31:58 -04:00
web_auth.rs Move variables to SQLite storage (#537) 2026-06-30 12:17:46 -04:00
worker_runtime.rs refactor: remove in-process IP allowlist and introduce WorkerRuntime (#444) 2026-05-27 23:17:23 -04:00
worker_token.rs feat: wire Ask Fabro sidebar to real session API with run-control tools (#349) 2026-05-22 09:43:31 -04:00