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
88 lines
5.5 KiB
Markdown
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.
|