PWA delivery pipeline
How the browser build of apps/frontend-pwa gets from vite build to a client, and
the rules that keep it fast. Every defect this page guards against is silent: the
build succeeds and the app works — it is only slower, larger or leakier than it should be.
Why it exists
A mobile PageSpeed run scored 53, with FCP 17.4 s and LCP 20.7 s on Slow 4G. The
cause was byte delivery, not JavaScript: Cloud Run serves the PWA with no CDN in front,
and the server sent every file uncompressed (the 2.5 MB entry chunk went over the
wire as 2.5 MB) with Cache-Control: max-age=0. At ~200 KB/s the page could not paint
before the bundle finished downloading.
The pipeline
| Stage | File | What it does |
|---|---|---|
| Build | vite-plugin-precompress.ts → scripts/precompress.mjs | Writes .br and .gz beside every compressible file ≥ 1 KB, at maximum ratio. Browser build only. |
| Build | vite-plugin-preconnect.ts | Adds <link rel="preconnect"> for the Firebase auth host, the Sentry ingest host and apis.google.com, derived from the build env. Browser build only. |
| Build | vite.config.ts (Sentry plugin) | Uploads hidden sourcemaps to Sentry and deletes them — only when opted in (see below). |
| Gate | scripts/check-build-output.mjs | Fails the image build if a variant is missing or not smaller, a sourcemap survived, or a removed third-party script is back in index.html. --shell mode for the mobile/desktop builds. |
| Serve | server/app.js, server/cache-policy.js | Serves the .br/.gz variant the client accepts; sets cache, security and Vary headers. |
The compression rules live in one module, scripts/precompress.mjs, shared by the
plugin that writes the variants and the check that verifies them — so the two cannot
disagree about what "fully precompressed" means.
Rules
Only assets/ is cached immutably. Vite content-hashes everything under assets/,
so a changed file always gets a new URL. Everything else — index.html, sw.js, the
manifest, public/ files such as the webcam scanner worker — keeps its URL across
deploys and is served no-cache. Never put an unhashed file under public/assets/:
it would be cached for a year.
Compression happens at build time, never per request. Precompressed files cost no
CPU on Cloud Run and get brotli quality 11. If you add a file type the browser fetches,
check whether COMPRESSIBLE in scripts/precompress.mjs should include it.
Vary: Accept-Encoding on every response. The brotli, gzip and identity variants
of one file carry different ETags; without Vary a shared cache could serve brotli to
a client that did not ask for it. Clients that send no Accept-Encoding (some POS
WebViews) get the original bytes.
The shells get none of this. The mobile and desktop builds are zipped and read off
the local filesystem: precompressed files would only double the update payload, and
preconnect hints would make an offline cold start reach for the network. Both build
scripts run the checker in --shell mode.
No third-party scripts in index.html. Google Maps is loaded on demand by
src/lib/google-maps/load-google-maps.ts, only by the components that need it. The
checker fails the build if maps.googleapis.com or cdn.gpteng.co reappears in the
document head.
Google Maps
loadGoogleMaps() injects one script (shared by concurrent callers), resolves through
a real callback, rejects when no key is configured, and allows a retry after a script
error. Components that need Maps call it themselves; useLoadGoogleMaps() is a thin
wrapper for components that gate rendering on readiness.
The key comes from VITE_GOOGLE_MAPS_API_KEY, baked in at build time. The Dockerfile
refuses to build without it, because the failure mode is silent: address
autocomplete simply disappears for every merchant. The shell builds set it empty on
purpose (FR-010: no third-party script in the shell's origin), so there the loader
rejects and callers keep their no-Maps fallback.
A browser Maps key is always public — HTTP-referrer restrictions on the key are what protect it, not keeping it out of the bundle.
Sourcemaps and Sentry
Sourcemaps are emitted only when they will be uploaded: build env VITE_PUBLIC_DEPLOY_ENV
is staging, production or beta, and SENTRY_AUTH_TOKEN, SENTRY_ORG and
SENTRY_PROJECT are all set. They are emitted hidden and deleted after the upload
attempt — even a failed one — and upload failures only warn, so Sentry can never block
a deploy. Every other build emits none. The server 404s .map requests regardless.
The token reaches the Docker build as a BuildKit secret (--secret id=sentry_auth_token), never a build arg: build args are recorded in the build history.
Stale chunks after a deploy
Every deploy replaces the hashed chunk files. A tab opened before the deploy — a POS or
KDS screen left running all day — asks for a chunk that no longer exists the first time
it opens a route it has not loaded. src/lib/stale-chunk-reload.ts listens for Vite's
vite:preloadError and reloads once, guarded by a sessionStorage timestamp so a
genuinely missing chunk cannot cause a reload loop.
Deploy configuration
| Name | Kind | Where | Purpose |
|---|---|---|---|
VITE_GOOGLE_MAPS_API_KEY | Secret | Every GitHub environment that deploys the PWA | Required — the image build fails without it. |
SENTRY_PROJECT_PWA | Variable | GitHub environments | Opt-in for the sourcemap upload. Unset → no sourcemaps. |
SENTRY_AUTH_TOKEN | Secret | Already present | Used only when SENTRY_PROJECT_PWA is set. |
Doppler does not sync to GitHub Actions — set values in both by hand.
Verifying changes
cd apps/frontend-pwa
pnpm exec vitest run --project pipeline # server, plugins, checker (runs in premerge CI)
pnpm build && node scripts/check-build-output.mjs dist
pnpm exec vite build --mode mobile --outDir dist-mobile \
&& node scripts/check-build-output.mjs dist-mobile --shell
To check the served headers, run PORT=5199 node server.js against the build and
request an asset with and without Accept-Encoding.
The pipeline tests follow the repo's gate rules: each was confirmed to fail against a
planted defect (cache policy marking sw.js immutable, Vary removed, brotli disabled,
a variant check skipped, the forbidden-script list emptied, the reload guard disabled,
the Maps callback removed).
Known traps
- Classes built at runtime are invisible to Tailwind.
getAsymmetricTwoPanelGridClass()insrc/hooks/useTwoPanelLayoutPreference.tsbuildslg:grid-cols-[minmax(0,6fr)_minmax(0,4fr)]from numbers. The CSS for it exists in production only because a test file happens to contain the same literal — excluding tests from Tailwind'scontent, or deleting that test, would silently break the Add Item split layout. Write full class names in source. - The root
brace-expansionoverride breaksminimatch@3."brace-expansion@<2": ">=1.1.13"in the rootpackage.jsonis open-ended, so pnpm resolves it to 5.x, whose APIminimatch@3cannot call. workbox-build's glob fails withexpand is not a functionand the service worker precaches nothing app-related. Pin it with^.