Commit graph

10 commits

Author SHA1 Message Date
Bryan Helmkamp
2902b8c773
fix(web): harden build version detection 2026-07-25 15:15:25 -04:00
Bryan Helmkamp
695a981f42
feat(web): tell open tabs when a new build ships
A tab left open across a deploy keeps running the previous build's
JavaScript indefinitely. index.html is fetched only on a full page load,
all later navigation is client-side, and hashed bundles are served
`immutable`, so nothing reveals that the code is stale. This produced a
false-positive bug report where two correctly-deployed fixes appeared to
be missing.

Publishes a build id and offers a reload when the running document falls
behind. The toast never reloads on its own; the only automatic reload is
recovery from a chunk that no longer exists.

Build id derivation
-------------------
The obvious approach — hash the emitted asset filenames, which already
embed content hashes — does not work: Bun's minified identifier naming is
not deterministic. Building an unchanged tree twice produces byte-different
output roughly one run in three (same length, ~100k differing bytes, all of
it mangled names). Output hashes therefore move with no source change,
which would fire the toast on redeploys of identical code and train people
to ignore it.

The id is instead derived from the bundle's source inputs, so it changes if
and only if something we control changed. Verified stable across eight
consecutive builds while the entry hash flipped between both variants.

This non-determinism also means two builds of the same commit embed
different bytes into the server binary, which is worth addressing
separately for reproducible builds.

Detection
---------
SWR with `refreshInterval` + `revalidateOnFocus`, per the repo's React
effects policy. SWR does not poll while the document is hidden, so
background tabs stay quiet without extra gating. Unknown state on either
side — missing meta tag, failed fetch, 503 during a dev rebuild — never
produces a prompt.

Stylesheet hashing
------------------
Tailwind's output was stable-named and therefore served `no-cache`, letting
a tab revalidate into new CSS while running old JS. Tailwind purges unused
classes per build, so classes the old bundle still emits could silently
lose their styles. It is now content-hashed and moves with the build.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-25 14:45:26 -04:00
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
Zach Feldman
bc70da1a22
fix(web): resolve Bun workspace-hoisted node_modules in build script (#495)
## Why

The build script in `apps/fabro-web/scripts/build.ts` hardcoded two
paths
that assumed packages live in `apps/fabro-web/node_modules/`:

- `./node_modules/.bin/tailwindcss` (the Tailwind CLI invocation)
- `join(rootPath, "node_modules", "@pierre", "diffs", ...)` (the worker
asset copy)

This repo uses Bun workspaces (root `package.json` has `workspaces:
['apps/*',
'lib/packages/*']`), so `bun install` hoists all packages to the repo
root.
Any fresh contributor install broke `bun run dev` immediately with:

```
ENOENT: no such file or directory, posix_spawn './node_modules/.bin/tailwindcss'
```

followed by:

```
ENOENT: no such file or directory, lstat '.../apps/fabro-web/node_modules/@pierre/diffs/...'
```

## What changed

- `tailwindcss` is now resolved via `Bun.which("tailwindcss")`, which
searches
`PATH` and the workspace root `node_modules/.bin/`, with the old path as
fallback.
- `pierreWorkerDir` now resolves from a `workspaceRoot` derived via
`new URL("../../..", import.meta.url)` (repo root), matching where Bun
actually
  installs workspace dependencies.

## Verification

`bun run dev` from `apps/fabro-web/` completes a full build successfully
after a
clean `bun install` from the repo root.

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-authored-by: Bryan Helmkamp <bryan@brynary.com>
2026-06-12 08:23:56 -04:00
Bryan Helmkamp
67ad1f520f
fix(web): debounce dev watcher and ignore non-source events
macOS recursive fs.watch fires multiple events per logical save and emits
spurious "bubble" events for sibling directories. With the previous slow
~10s tailwind step those re-fires were absorbed between rebuilds; with
~60ms rebuilds the watcher entered a continuous-rebuild loop instead.

- Coalesce events with a 75ms debounce window so one save fires one
  rebuild even when the editor produces several FS events.
- Filter to source-relevant extensions (ts/tsx/css/html/images/fonts);
  ignore .DS_Store, .tsbuildinfo, swap files, and the extensionless
  bubble events (e.g. "rename images") that were the dominant source
  of the loop.
- Optional FABRO_BUILD_DEBUG=1 logs which path queued or skipped each
  rebuild for future diagnosis.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 11:01:06 -04:00
Bryan Helmkamp
f4cfa50bc4
fix(server): make --watch-web honest and fast
In --watch-web dev mode the server silently fell back to the embedded SPA
snapshot whenever the disk dist/ was missing or partial, so edits to the
web app appeared not to take effect with no error anywhere. This change
makes the dev loop visible and quick:

- static_files plumbs a dev_disk_only flag from RouterOptions.watch_web
  into the fallback handler. When set, embedded fallback is skipped and
  a miss returns 503 with a "build in progress" auto-refresh page.
- The web build script writes each rebuild into apps/fabro-web/.dist-builds/<id>/
  and atomically replaces the dist symlink via rename(2), so requests
  never observe a partially-populated dist tree.
- Tailwind is invoked through node_modules/.bin/tailwindcss directly
  instead of bunx, removing a per-rebuild bun add @latest --force round-
  trip and dropping rebuild time from ~10s to ~250ms.
- load_asset no longer falls through to the workspace dist/ when an
  explicit asset_root is provided, restoring test isolation when a
  real dev build is sitting next door.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 10:36:01 -04:00
Bryan Helmkamp
603a64810c
fix(web): virtualize run file diffs consistently
Some checks are pending
Rust / Format (push) Waiting to run
Rust / Clippy (push) Waiting to run
Rust / Generated Docs (push) Waiting to run
Rust / Test (Linux) (push) Waiting to run
Rust / Test (macOS) (push) Waiting to run
TypeScript / Typecheck (push) Waiting to run
TypeScript / Test (push) Waiting to run
TypeScript / Build (push) Waiting to run
Always route non-empty Files Changed views through Pierre's Virtualizer and worker pool, with full-height layout propagation and stable per-file cache keys. Copy Pierre worker assets during the web build so the static worker URL resolves in production.
2026-05-05 16:41:39 -04:00
Bryan Helmkamp
ecdfdd82d8
feat(install): add browser-based setup flow
Implement the web-first install experience across the server, CLI, API spec,
web app, and packaged SPA assets.

This also removes test-side process env mutation by pushing env-dependent
decision points behind explicit helpers and test wiring.
2026-04-19 11:20:58 -04:00
Bryan Helmkamp
5003fb5c2e fix(fabro-web): restore local watch rebuilds
Replace the unsupported Bun.watch call in the SPA build script with
node:fs.watch so `bun run dev` keeps running in local development.
Add a regression test that verifies watch mode stays alive until
interrupted.
2026-04-08 15:27:27 -04:00
Bryan Helmkamp
b56b82d34b Cut over Fabro web app to a server-backed SPA
Replace the old React Router SSR setup with a static SPA build served by
fabro-server, move setup and GitHub auth handling into Rust, and update the
default local web URL and stale Arc-era references to match the Fabro name.
2026-04-01 21:36:01 -07:00