Files
bike-app/apps/web/README.md
T
BBergleandClaude Sonnet 5 6c48000d7b
CI / Repo hygiene (pull_request) Successful in 2s
CI / Web (lint, typecheck, build) (pull_request) Successful in 15s
CI / Migrations reversible (pull_request) Successful in 6s
CI / API (lint, types, tests) (pull_request) Successful in 53s
chore(deploy): single-container Dockerfile, Caddy, and Unraid template
Builds the container the "1 container" decision (D15) actually needs, which
D15 itself deferred as follow-up work: Caddy + the FastAPI app + the static
SvelteKit build in one image, SQLite on a mounted volume. See docs/DECISIONS.md
D16 for the specific choices and why (entrypoint-run migrations instead of a
separate deploy-pipeline step, tini + a small supervisor script instead of
s6-overlay/supervisord, copying the Caddy binary out of its official image).

Removes apps/api/Dockerfile and apps/web/Dockerfile from the old 4-container
compose plan (PR #4, closed as superseded) — the root Dockerfile replaces both
with one multi-stage build.

deploy/unraid-template.xml turns VELODROME_PUBLIC_URL, VELODROME_SECRET_KEY,
etc. into fillable Unraid Community Applications web UI fields, per the
earlier decision to keep config there instead of a .env file.

.gitea/workflows/release.yml builds and pushes the image to the Gitea registry
on a version tag or manual dispatch; it does not touch the running container.

Verified by actually running the built image, not just building it: the
health endpoint responds through Caddy's proxy, the SPA serves with working
client-route fallback, alembic ran and produced a real (non-empty) SQLite file
under /data, the process runs as the non-root velodrome user, and killing the
uvicorn process brings the whole container down (exit 143) rather than
leaving Caddy serving alone — confirming the entrypoint's coupled-lifetime
behavior actually holds, not just that it reads correctly.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01R2ZKeWkZV7ehf7fivrAkkG
2026-09-21 15:55:50 -04:00

88 lines
5.5 KiB
Markdown

# apps/web — Velodrome PWA shell
SvelteKit static SPA, installable PWA. Talks to the API at `/api/v1` — same-origin in production
(Caddy proxies it), same-origin locally via the dev proxy in `vite.config.ts`. See
`docs/PLAN.md` ("Stack", "The PWA decision") for why this shape was chosen; this file is just the
how.
## Dev commands
```sh
pnpm install
pnpm run dev # vite dev server, proxies /api -> http://localhost:8000
pnpm run check # svelte-kit sync + svelte-check
pnpm run lint # prettier --check + eslint
pnpm run format # prettier --write
pnpm run build # production build -> build/
pnpm run preview # serve the production build locally
```
`pnpm run lint`, `pnpm run check`, and `pnpm run build` are exactly the three steps CI's `web` job
runs (`.gitea/workflows/ci.yml`) — keep those script names stable.
Requires Node 22 and pnpm (via corepack). The service worker is disabled in dev
(`devOptions.enabled: false` in `vite.config.ts`) — dev already has instant HMR, and a dev-mode SW
mostly just causes "why isn't my change showing up" confusion.
## Shape of the app
- **adapter-static, SPA mode, no prerendering.** `src/routes/+layout.ts` sets `export const ssr =
false`, and `vite.config.ts` configures `@sveltejs/adapter-static` with `fallback: 'index.html'`.
This is a client-only app: there's no Node process in production (see "Dockerfile" below), so
every route has to resolve client-side against a single served shell, not be prerendered.
- **Auth.** `src/lib/stores/auth.ts` is a tiny Svelte store backed by `GET /api/v1/auth/me`.
`src/routes/+layout.svelte` calls `auth.refresh()` once on load; `/` and `/login` react to the
store to decide what to show/redirect to. Login posts to `/api/v1/auth/login`
(`src/lib/api/auth.ts`) and relies on the API setting an HttpOnly cookie — the app never touches
the token directly.
- **PWA / service worker.** `@vite-pwa/sveltekit` is _not_ used here — see the long comment block at
the top of `vite.config.ts` for why (its `injectManifest` build expects SvelteKit's own built-in
`src/service-worker.ts` convention to have already transpiled the file, but that native build only
permits importing SvelteKit's own three virtual modules and hard-rejects `workbox-*` imports). We
use the base `vite-plugin-pwa` (`VitePWA(...)`) instead, in `injectManifest` mode, and explicitly
disable SvelteKit's native service-worker convention (`kit.files.serviceWorker` pointed at a
nonexistent path) so only vite-plugin-pwa's build runs.
`injectManifest`, not `generateSW`: the caching policy is hand-written in `src/service-worker.ts`
using Workbox primitives directly, because the policy needs to say "never, ever cache `/api/*`" —
more precisely than `generateSW`'s declarative config expresses. The policy, straight from
`docs/PLAN.md`'s caching table:
- Precache the injected build manifest (hashed JS/CSS/icons/`manifest.webmanifest`), plus a
synthetic entry for the SPA fallback `index.html` (see the `swIndexRevision` comment in
`vite.config.ts` — the real `build/index.html` doesn't exist yet when the SW is built, since
the adapter writes it afterward, so its precache revision is a per-build-invocation timestamp
instead of a content hash).
- Navigations: cache-first, served from the precached `index.html` via
`NavigationRoute(createHandlerBoundToURL('index.html'))` — the app opens instantly and works
offline.
- `/api/*`: `NetworkOnly`, unconditionally. Never cached, per CLAUDE.md's invariants.
- Nothing else yet — tile/stream caching is a later phase; the file is structured so those are
additional `registerRoute()` calls, not a rewrite.
`skipWaiting()` + `clientsClaim()` run immediately. `src/lib/components/UpdateToast.svelte` uses
`virtual:pwa-register/svelte`'s `useRegisterSW()` to show a "New version available — reload"
toast instead of silently swapping the SW under the user.
- **Manifest / icons.** `static/manifest.webmanifest` is hand-written (not plugin-generated) and
linked explicitly from `src/app.html`, along with the `apple-touch-icon`,
`apple-mobile-web-app-capable` meta, and `viewport-fit=cover` (paired with
`env(safe-area-inset-*)` padding in the root layout) for iOS standalone mode. Icons in
`static/icons/` are placeholder solid-color PNGs — fine for Phase 0, swap them for real artwork
later.
- **iOS install banner.** `src/lib/components/IosInstallBanner.svelte` — informational only
("Add to Home Screen" instructions), shown when not already standalone and the UA looks like iOS
Safari. Dismissible per session (`sessionStorage`), reappears next session until actually
installed. No `Notification.requestPermission()` here — that's gated behind standalone mode in a
later phase, per `docs/PLAN.md`'s PWA-decision table.
- **Styling.** Plain CSS (`src/app.css`), CSS custom properties for theming, `prefers-color-scheme`
for dark mode. No Tailwind — kept the dependency surface small for this scaffolding phase.
## Docker build
There is no `apps/web/Dockerfile` anymore. Per D15/D16 (`docs/DECISIONS.md`), the whole app ships
as one container, so building this SPA is a stage in the root `Dockerfile`
(`web-builder`, `apps/web` as its `COPY` source), not a standalone image — the built output
(`/app/build`) is copied straight into the runtime stage at `/srv/web`, which is what the root
`Dockerfile`'s Caddy config (`deploy/Caddyfile`) serves. There is no Node runtime in production.
See `deploy/README.md` for the actual single-container build/run instructions.