feat(client): restore PWA install + offline shell via Expo (#208) #223

Merged
james merged 3 commits from 208-pwa-install-offline into main 2026-06-21 18:16:27 +00:00
Owner

Summary

Restores PWA install + offline shell for the Expo Web bundle after #185 deleted the previous @serwist/next setup.

  • Adds apps/client/public/{manifest.webmanifest,sw.js,offline.html,icon.svg,icon-192.png,icon-512.png}. Expo's static export copies apps/client/public/ into apps/client/dist/, so all the new files reach the browser through the API container's [...spa] catch-all with the right Content-Type from apps/api/lib/spa/serve.ts (the MIME table already covered .webmanifest, .js, .html, .png, .svg — no touch needed there).
  • Adds apps/client/app/+html.tsx — Expo Router's document shell. Injects <link rel="manifest">, <meta name="theme-color" content="#2f6bff">, <link rel="apple-touch-icon">, and an inline navigator.serviceWorker.register('/sw.js') so registration fires before the entry bundle parses.
  • Adds /manifest.webmanifest, /sw.js, and /offline.html to the public-route allowlist; flips the proxy test assertions that previously asserted these paths were NOT public.

Service-worker approach: hand-rolled, ~150 LOC

Picked over the alternatives:

  • serwist standalone. Its primary distribution is @serwist/next; the standalone build path adds a separate bundler step that the Expo client otherwise doesn't need. Not worth the complexity for one SW.
  • workbox-window + handwritten sw.js. Pulls in ~50KB of runtime for caching behaviour we can express in 150 lines.
  • Hand-rolled. Wins on simplicity, transparency, and zero new deps. The Expo entry JS chunk is content-hashed per deploy — naming it in a precache list would bake a stale deploy into the SW, so cache-first at runtime is actually the right call regardless of which tool we'd reach for. Documented inline so the next reader knows the tradeoffs.

Strategy:

  • Precache the offline shell, manifest, and icons on install.
  • Cache-first for /_expo/static/ and /assets/ (content-hashed, immutable).
  • Network-first with a 3s timeout for /api/* GETs, falling back to cache; non-GET /api/* bypasses the SW so writes never silently succeed against stale data.
  • Network-first navigation; on failure serve the cached HTML for that route, then /offline.html, then a synthetic 503.
  • CACHE_VERSION bump invalidates prior caches on activate.

Files touched

  • apps/client/app/+html.tsx (new)
  • apps/client/public/manifest.webmanifest (new)
  • apps/client/public/sw.js (new)
  • apps/client/public/offline.html (new)
  • apps/client/public/{icon.svg,icon-192.png,icon-512.png} (ported verbatim from apps/api/public/)
  • apps/api/lib/auth/public-routes.ts
  • apps/api/tests/proxy.test.ts

API-side icons under apps/api/public/ left in place per the ticket's "out of scope" notes.

Test plan

  • pnpm install --frozen-lockfile
  • pnpm -F @carol/api typecheck / lint / test / openapi:check / openapi:coverage (556 passed / 107 skipped)
  • pnpm -F @carol/api-client typecheck / lint / test / check
  • pnpm -F @carol/client typecheck / lint / test / export:web (manifest, sw.js, offline.html, icons all land in apps/client/dist/)
  • podman build -t carol:208 . — clean build
  • Container smoke: curl -I against the running container returns 200 + correct Content-Type for:
    • /manifest.webmanifestapplication/manifest+json
    • /sw.jsapplication/javascript; charset=utf-8
    • /offline.htmltext/html; charset=utf-8
    • /icon-192.pngimage/png
  • curl / confirms the <head> carries the manifest link, theme-color, apple-touch-icon, and the inline SW registration.
  • Manual browser verification not run locally — no browser harness available in the agent environment. Worth a human pass before merge: open the running container in Chrome, confirm the install icon appears in the address bar (criteria are met: served-over-HTTPS-or-localhost + manifest + 192/512 icons + SW registered), and confirm /offline.html is served when the network is killed. CI also can't test this; it has to be a human.

Follow-ups worth filing

  • Maskable icon artwork audit. I declared the existing PNGs as purpose: "any maskable". The artwork is a centered C on a solid blue rounded square — it survives Chrome's 40% safe-zone trim cleanly, but a designer should sign off rather than relying on visual inspection. Worth a small ticket to either confirm or ship dedicated maskable variants.
  • SW update UX. Today CACHE_VERSION bumps clear prior caches on activate, but there's no "a new version is available — reload?" prompt. For a self-hosted app that's tolerable, but if Carol grows a deploy cadence it'd be worth a workbox-window-style update toast.
  • Manifest i18n. The name/short_name/description strings are hardcoded English. Ticket #208's brief calls the offline shell exempt from i18n (it's static HTML and Carol's voice rules cover it), but the manifest could reasonably be templated per locale at build time. Probably not worth doing until Carol ships in more than two languages.

🤖 Generated with Claude Code

## Summary Restores PWA install + offline shell for the Expo Web bundle after #185 deleted the previous `@serwist/next` setup. - Adds `apps/client/public/{manifest.webmanifest,sw.js,offline.html,icon.svg,icon-192.png,icon-512.png}`. Expo's static export copies `apps/client/public/` into `apps/client/dist/`, so all the new files reach the browser through the API container's `[...spa]` catch-all with the right `Content-Type` from `apps/api/lib/spa/serve.ts` (the MIME table already covered `.webmanifest`, `.js`, `.html`, `.png`, `.svg` — no touch needed there). - Adds `apps/client/app/+html.tsx` — Expo Router's document shell. Injects `<link rel="manifest">`, `<meta name="theme-color" content="#2f6bff">`, `<link rel="apple-touch-icon">`, and an inline `navigator.serviceWorker.register('/sw.js')` so registration fires before the entry bundle parses. - Adds `/manifest.webmanifest`, `/sw.js`, and `/offline.html` to the public-route allowlist; flips the proxy test assertions that previously asserted these paths were NOT public. ## Service-worker approach: hand-rolled, ~150 LOC Picked over the alternatives: - **serwist standalone.** Its primary distribution is `@serwist/next`; the standalone build path adds a separate bundler step that the Expo client otherwise doesn't need. Not worth the complexity for one SW. - **workbox-window + handwritten sw.js.** Pulls in ~50KB of runtime for caching behaviour we can express in 150 lines. - **Hand-rolled.** Wins on simplicity, transparency, and zero new deps. The Expo entry JS chunk is content-hashed per deploy — naming it in a precache list would bake a stale deploy into the SW, so cache-first at runtime is actually the right call regardless of which tool we'd reach for. Documented inline so the next reader knows the tradeoffs. Strategy: - Precache the offline shell, manifest, and icons on install. - Cache-first for `/_expo/static/` and `/assets/` (content-hashed, immutable). - Network-first with a 3s timeout for `/api/*` GETs, falling back to cache; non-GET `/api/*` bypasses the SW so writes never silently succeed against stale data. - Network-first navigation; on failure serve the cached HTML for that route, then `/offline.html`, then a synthetic 503. - `CACHE_VERSION` bump invalidates prior caches on activate. ## Files touched - `apps/client/app/+html.tsx` (new) - `apps/client/public/manifest.webmanifest` (new) - `apps/client/public/sw.js` (new) - `apps/client/public/offline.html` (new) - `apps/client/public/{icon.svg,icon-192.png,icon-512.png}` (ported verbatim from `apps/api/public/`) - `apps/api/lib/auth/public-routes.ts` - `apps/api/tests/proxy.test.ts` API-side icons under `apps/api/public/` left in place per the ticket's "out of scope" notes. ## Test plan - [x] `pnpm install --frozen-lockfile` - [x] `pnpm -F @carol/api typecheck` / `lint` / `test` / `openapi:check` / `openapi:coverage` (556 passed / 107 skipped) - [x] `pnpm -F @carol/api-client typecheck` / `lint` / `test` / `check` - [x] `pnpm -F @carol/client typecheck` / `lint` / `test` / `export:web` (manifest, sw.js, offline.html, icons all land in `apps/client/dist/`) - [x] `podman build -t carol:208 .` — clean build - [x] Container smoke: `curl -I` against the running container returns 200 + correct `Content-Type` for: - `/manifest.webmanifest` → `application/manifest+json` - `/sw.js` → `application/javascript; charset=utf-8` - `/offline.html` → `text/html; charset=utf-8` - `/icon-192.png` → `image/png` - [x] `curl /` confirms the `<head>` carries the manifest link, theme-color, apple-touch-icon, and the inline SW registration. - [ ] **Manual browser verification not run locally** — no browser harness available in the agent environment. Worth a human pass before merge: open the running container in Chrome, confirm the install icon appears in the address bar (criteria are met: served-over-HTTPS-or-localhost + manifest + 192/512 icons + SW registered), and confirm `/offline.html` is served when the network is killed. CI also can't test this; it has to be a human. ## Follow-ups worth filing - **Maskable icon artwork audit.** I declared the existing PNGs as `purpose: "any maskable"`. The artwork is a centered C on a solid blue rounded square — it survives Chrome's 40% safe-zone trim cleanly, but a designer should sign off rather than relying on visual inspection. Worth a small ticket to either confirm or ship dedicated maskable variants. - **SW update UX.** Today `CACHE_VERSION` bumps clear prior caches on activate, but there's no "a new version is available — reload?" prompt. For a self-hosted app that's tolerable, but if Carol grows a deploy cadence it'd be worth a `workbox-window`-style update toast. - **Manifest i18n.** The `name`/`short_name`/`description` strings are hardcoded English. Ticket #208's brief calls the offline shell exempt from i18n (it's static HTML and Carol's voice rules cover it), but the manifest could reasonably be templated per locale at build time. Probably not worth doing until Carol ships in more than two languages. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
Add the PWA manifest, icons, and the document-shell wiring for the
Expo Web target. Step 1 of restoring PWA install + offline shell
after #185 deleted the @serwist/next setup.

- apps/client/public/{icon.svg,icon-192.png,icon-512.png} ported
  verbatim from apps/api/public/. Expo's static export copies
  apps/client/public/ into apps/client/dist/ so these reach the
  browser via the API container's [...spa] catch-all.
- apps/client/public/manifest.webmanifest declares name "Carol",
  display "standalone", theme color #2f6bff (the DS light-accent),
  and the three icons with the 192/512 PNGs marked "any maskable".
- apps/client/app/+html.tsx is Expo Router's document shell. Injects
  <link rel="manifest">, theme-color, apple-touch-icon, and the
  inline service-worker registration script that loads /sw.js.
  Registering inline (rather than from the entry bundle) fires SW
  installation before the heavy JS chunk parses.

The /sw.js path is wired in the next commit; until then the
registration script logs a benign console warning if the SW 404s.

Refs #208, ADR-0027.
Add the hand-rolled service worker and offline shell that pair with
the manifest from the previous commit. Together they restore PWA
install + offline behaviour for the Expo Web bundle.

apps/client/public/sw.js — single-file SW, no build step:
- Precaches the offline shell, manifest, and icons on install.
- Cache-first for /_expo/static/ and /assets/ (content-hashed,
  immutable).
- Network-first with a 3s timeout for /api/* GETs, falling back to
  cache; non-GET /api/* requests bypass the SW so writes never
  silently succeed against a stale cache.
- Network-first navigation; on failure serves the cached HTML for
  that route, then /offline.html, then a synthetic 503.
- CACHE_VERSION bump invalidates prior caches on activate.

apps/client/public/offline.html — static branded fallback. Plain
HTML so it works without hydrating the React tree. Voice follows
Carol's first-person, sentence-case guidance; auto light/dark via
prefers-color-scheme so the page works without theme resolution.

Why hand-rolled over serwist or workbox-window:
- serwist's primary distribution is @serwist/next; the standalone
  build adds a separate bundler step the client otherwise doesn't
  need.
- workbox-window + workbox-sw pulls in ~50KB of runtime for caching
  behaviour we can express in 150 lines.
- The entry JS chunk is content-hashed per deploy, so naming it in a
  precache list would bake a stale deploy into the SW; cache-first
  at runtime sidesteps the bundler-aware precache step entirely.

The next two commits add the SW + manifest paths to the public-route
allowlist and verify the MIME table.

Refs #208, ADR-0027.
feat(api): public-route allowlist for new SW + manifest paths (#208)
Some checks failed
Commits / Conventional Commits (pull_request) Successful in 36s
PR / Typecheck (pull_request) Successful in 3m29s
PR / OpenAPI (pull_request) Successful in 4m7s
PR / Static analysis (pull_request) Successful in 3m49s
PR / Client (web export smoke) (pull_request) Successful in 3m59s
PR / Lint (pull_request) Successful in 4m24s
PR / Test (sqlite) (pull_request) Successful in 4m13s
PR / Build (pull_request) Successful in 4m23s
PR / Test (postgres) (pull_request) Successful in 4m20s
PR / pnpm audit (pull_request) Successful in 1m57s
PR / Package age policy (soft) (pull_request) Successful in 48s
PR / OSV-Scanner (pull_request) Successful in 1m28s
Secrets / gitleaks (pull_request) Successful in 40s
PR / Coverage (soft) (pull_request) Successful in 1m18s
PR / Trivy (image) (pull_request) Failing after 1m56s
efdbfefb7e
Add /manifest.webmanifest, /sw.js, and /offline.html to the
public-route allowlist so the browser can fetch them before any user
session exists. Without this, `proxy.ts` redirects the manifest
fetch to /login, which breaks Chrome's install-eligibility check.

These paths are static files in the Expo Web bundle
(apps/client/public/*) — none carries per-user data, so they're safe
to expose unauthenticated, same justification as the existing icon
entries.

Flip the proxy test that previously asserted these paths were NOT
public (post-#185 state) to assert they ARE public (post-#208
state). Keep negative assertions for `/offline` and
`/workbox-runtime.js` — the Expo SW emits neither, so an unmatched
request shouldn't smuggle past the auth gate.

Refs #208, ADR-0027.

📊 Test coverage

Patch coverage: no testable lines changed.

Overall (app/, lib/, db/, excluding UI per ADR-0019):

Metric Value Soft target
Lines 82.9% ≥ 50%
Branches 76.0% ≥ 75%
Functions 91.3% informational

Soft thresholds per ADR-0019. Coverage is informational and does not block merge.

<!-- coverage-comment --> ## 📊 Test coverage **Patch coverage:** no testable lines changed. **Overall** (`app/`, `lib/`, `db/`, excluding UI per ADR-0019): | Metric | Value | Soft target | |---|---|---| | Lines | 82.9% ✅ | ≥ 50% | | Branches | 76.0% ✅ | ≥ 75% | | Functions | 91.3% | informational | Soft thresholds per [ADR-0019](docs/adr/0019-coverage-soft-targets.md). Coverage is informational and does not block merge.

Trivy (container image)

Threshold: high  ·  Total findings: 121  ·  At/above threshold: 1

critical high medium low
0 1 50 70
severity id package installed / range fix
high CVE-2026-12151 undici 6.25.0 6.27.0, 7.28.0, 8.5.0
<!-- scanner-comment: trivy --> ### Trivy (container image) **Threshold:** `high` &nbsp;·&nbsp; **Total findings:** 121 &nbsp;·&nbsp; **At/above threshold:** 1 | critical | high | medium | low | |---:|---:|---:|---:| | 0 | 1 | 50 | 70 | | severity | id | package | installed / range | fix | |---|---|---|---|---| | high | [CVE-2026-12151](https://avd.aquasec.com/nvd/cve-2026-12151) | undici | 6.25.0 | `6.27.0, 7.28.0, 8.5.0` |
james merged commit 270d9ff242 into main 2026-06-21 18:16:27 +00:00
james deleted branch 208-pwa-install-offline 2026-06-21 18:16:27 +00:00
Sign in to join this conversation.
No description provided.