Saltar al contenido principal

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​

StageFileWhat it does
Buildvite-plugin-precompress.ts → scripts/precompress.mjsWrites .br and .gz beside every compressible file ≥ 1 KB, at maximum ratio. Browser build only.
Buildvite-plugin-preconnect.tsAdds <link rel="preconnect"> for the Firebase auth host, the Sentry ingest host and apis.google.com, derived from the build env. Browser build only.
Buildvite.config.ts (Sentry plugin)Uploads hidden sourcemaps to Sentry and deletes them — only when opted in (see below).
Gatescripts/check-build-output.mjsFails 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.
Serveserver/app.js, server/cache-policy.jsServes 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​

NameKindWherePurpose
VITE_GOOGLE_MAPS_API_KEYSecretEvery GitHub environment that deploys the PWARequired — the image build fails without it.
SENTRY_PROJECT_PWAVariableGitHub environmentsOpt-in for the sourcemap upload. Unset → no sourcemaps.
SENTRY_AUTH_TOKENSecretAlready presentUsed 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() in src/hooks/useTwoPanelLayoutPreference.ts builds lg: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's content, or deleting that test, would silently break the Add Item split layout. Write full class names in source.
  • The root brace-expansion override breaks minimatch@3. "brace-expansion@<2": ">=1.1.13" in the root package.json is open-ended, so pnpm resolves it to 5.x, whose API minimatch@3 cannot call. workbox-build's glob fails with expand is not a function and the service worker precaches nothing app-related. Pin it with ^.