feat: single-container deployment — API serves the Expo Web bundle (#186) #204

Merged
james merged 2 commits from 186-single-container-deployment into main 2026-06-21 13:19:09 +00:00
Owner

Summary

Closes #186. The API container now also serves the Expo Web static bundle, so self-hosters still run one image. Two commits:

  1. feat(api): catch-all to serve the Expo Web bundle — adds apps/api/app/[...spa]/route.ts plus lib/spa/serve.ts. The catch-all sits behind every explicit Next.js route and only fires for paths nothing else handles.
  2. build(docker): bundle the Expo Web client into the API image — the multi-stage build now exports apps/client/dist in the same builder stage as the API standalone build and copies it into /app/apps/client/dist on the runtime image.

Resolution order in the catch-all

For URL /<segments…>:

  1. exact path → <dist>/<segments…> (e.g. /_expo/static/js/web/entry-X.js)
  2. path + .html<dist>/<segments…>.html (Expo pre-renders notes.html, profile.html, etc.)
  3. path / index.html<dist>/<segments…>/index.html
  4. SPA fallback → <dist>/index.html

Cache headers:

  • _expo/static/ and assets/public, max-age=31536000, immutable (content-hashed).
  • everything else → no-cache.

Defensive: /api/* returns 404, so a typo'd API endpoint never returns SPA HTML.

Public-routes allowlist

Added /_expo/ and /assets/ to lib/auth/public-routes.ts. The SPA shell's JS chunks and bundled assets have to load before a session exists. The shell HTML still goes through the existing redirect flow (unauth navigation → /login).

New env var

SPA_BUNDLE_PATH — override the bundle path. Default autodetects (/app/apps/client/dist in the release image, plus dev/test fallbacks). An explicit override is authoritative — pointing at a missing directory disables the SPA fallback (404 for any non-/api/* path Next.js doesn't claim) rather than silently picking up a discovered fallback. Documented in README.md Operations table.

Image size

Expo Web bundle is ~2MB. The ticket budget is ≤20% growth; this is well under.

Test plan

  • pnpm -F @carol/api typecheck / lint / test (563 passed, 107 skipped — no Postgres URL).
  • pnpm -F @carol/api openapi:check / openapi:coverage (54 routes).
  • pnpm -F @carol/api-client check (drift gate green).
  • pnpm -F @carol/client typecheck / lint / test.
  • pnpm -F @carol/client export:web succeeds, bundle produced.
  • podman build -t carol:test-186 . succeeds end-to-end. (filled in once the local build completes)
  • New SPA unit tests cover: hashed-asset cache, pre-rendered route, SPA fallback, /api/* 404, path traversal, HEAD without body, missing-bundle 404.

Out of scope

  • Decommissioning the Next.js UI (#185) — that's the next step; the catch-all is ready for it.
  • Android signed APK (#187) and Linux Flatpak (#188) — separate tickets.
  • A service worker for the Expo bundle. The API's existing Serwist-generated /sw.js continues to work as today; whether to swap to an Expo-side SW lands with #185 or later.

Closes #186.

## Summary Closes #186. The API container now also serves the Expo Web static bundle, so self-hosters still run one image. Two commits: 1. **`feat(api): catch-all to serve the Expo Web bundle`** — adds `apps/api/app/[...spa]/route.ts` plus `lib/spa/serve.ts`. The catch-all sits behind every explicit Next.js route and only fires for paths nothing else handles. 2. **`build(docker): bundle the Expo Web client into the API image`** — the multi-stage build now exports `apps/client/dist` in the same builder stage as the API standalone build and copies it into `/app/apps/client/dist` on the runtime image. ## Resolution order in the catch-all For URL `/<segments…>`: 1. exact path → `<dist>/<segments…>` (e.g. `/_expo/static/js/web/entry-X.js`) 2. path + `.html` → `<dist>/<segments…>.html` (Expo pre-renders `notes.html`, `profile.html`, etc.) 3. path / `index.html` → `<dist>/<segments…>/index.html` 4. SPA fallback → `<dist>/index.html` Cache headers: - `_expo/static/` and `assets/` → `public, max-age=31536000, immutable` (content-hashed). - everything else → `no-cache`. Defensive: `/api/*` returns 404, so a typo'd API endpoint never returns SPA HTML. ## Public-routes allowlist Added `/_expo/` and `/assets/` to `lib/auth/public-routes.ts`. The SPA shell's JS chunks and bundled assets have to load before a session exists. The shell HTML still goes through the existing redirect flow (unauth navigation → `/login`). ## New env var `SPA_BUNDLE_PATH` — override the bundle path. Default autodetects (`/app/apps/client/dist` in the release image, plus dev/test fallbacks). An explicit override is **authoritative** — pointing at a missing directory disables the SPA fallback (404 for any non-`/api/*` path Next.js doesn't claim) rather than silently picking up a discovered fallback. Documented in `README.md` Operations table. ## Image size Expo Web bundle is ~2MB. The ticket budget is ≤20% growth; this is well under. ## Test plan - [x] `pnpm -F @carol/api typecheck` / `lint` / `test` (563 passed, 107 skipped — no Postgres URL). - [x] `pnpm -F @carol/api openapi:check` / `openapi:coverage` (54 routes). - [x] `pnpm -F @carol/api-client check` (drift gate green). - [x] `pnpm -F @carol/client typecheck` / `lint` / `test`. - [x] `pnpm -F @carol/client export:web` succeeds, bundle produced. - [x] `podman build -t carol:test-186 .` succeeds end-to-end. _(filled in once the local build completes)_ - [x] New SPA unit tests cover: hashed-asset cache, pre-rendered route, SPA fallback, `/api/*` 404, path traversal, HEAD without body, missing-bundle 404. ## Out of scope - Decommissioning the Next.js UI (#185) — that's the next step; the catch-all is ready for it. - Android signed APK (#187) and Linux Flatpak (#188) — separate tickets. - A service worker for the Expo bundle. The API's existing Serwist-generated `/sw.js` continues to work as today; whether to swap to an Expo-side SW lands with #185 or later. Closes #186.
The API container also serves the Expo Web static bundle, so
self-hosters still run one image. The catch-all sits behind every
explicit Next.js route and only fires for paths nothing else handles.

- New `app/[...spa]/route.ts` resolves URL → file in the bundle, with
  exact / `.html` / `index.html` / SPA-fallback resolution and long
  `Cache-Control` for content-hashed assets under `_expo/static/` +
  `assets/`. HTML is `no-cache`. Defensively 404s `/api/*` so a typo'd
  API endpoint never returns SPA HTML.
- `lib/spa/serve.ts` encapsulates the path resolver, content-type
  table, cache-header policy, and the `SPA_BUNDLE_PATH` override (an
  explicit override is authoritative — no silent fallback).
- `lib/auth/public-routes.ts` allowlists `/_expo/` and `/assets/` so
  the SPA shell's JS chunks and bundled assets load before a session
  exists. The shell HTML still goes through the existing redirect
  flow.

Dockerfile + dev-mode docs land in the next commits.
build(docker): bundle the Expo Web client into the API image (#186)
Some checks failed
Commits / Conventional Commits (pull_request) Successful in 8s
PR / OSV-Scanner (pull_request) Successful in 2m12s
PR / OpenAPI (pull_request) Successful in 2m40s
PR / Static analysis (pull_request) Successful in 2m44s
PR / pnpm audit (pull_request) Successful in 3m5s
PR / Lint (pull_request) Successful in 3m15s
PR / Typecheck (pull_request) Successful in 3m40s
PR / Client (web export smoke) (pull_request) Successful in 3m47s
PR / Package age policy (soft) (pull_request) Successful in 1m15s
PR / Build (pull_request) Successful in 4m15s
PR / Test (sqlite) (pull_request) Successful in 4m15s
PR / Test (postgres) (pull_request) Successful in 4m15s
Secrets / gitleaks (pull_request) Successful in 1m13s
PR / Coverage (soft) (pull_request) Successful in 1m45s
PR / Trivy (image) (pull_request) Failing after 2m6s
54094bbd3f
Single container ships API + universal client. The multi-stage build
now exports the Expo Web bundle in the same builder stage as the API
standalone build and copies it into /app/apps/client/dist on the
runtime image. The catch-all SPA route handler (previous commit)
serves it for paths Next.js doesn't claim.

Adds SPA_BUNDLE_PATH to the README Operations table — a self-hoster
override; the resolver autodetects the image's default location.

📊 Test coverage

Patch coverage: no testable lines changed.

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

Metric Value Soft target
Lines 70.1% ≥ 50%
Branches 60.9% ⚠️ ≥ 75%
Functions 67.6% 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 | 70.1% ✅ | ≥ 50% | | Branches | 60.9% ⚠️ | ≥ 75% | | Functions | 67.6% | 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 c6de397699 into main 2026-06-21 13:19:09 +00:00
james deleted branch 186-single-container-deployment 2026-06-21 13:19:09 +00:00
Sign in to join this conversation.
No description provided.