Files
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

5.5 KiB

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

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.