feat(web): SvelteKit PWA shell with login
Phase 0 scaffolding for the frontend: SvelteKit + adapter-static in SPA mode
(fallback index.html, ssr disabled in the root layout — no Node process in
production, Caddy serves build/ directly per docs/PLAN.md), a login page and
auth store backed by /api/v1/auth/{login,me,logout}, an installable-PWA shell
(hand-written manifest, iOS meta/safe-area handling, an install-onboarding
banner), and a Dockerfile whose only job is to produce a buildable /app/build
artifact for deploy/ to consume.
Service worker notes, since the wiring isn't obvious from the diff:
- injectManifest, not generateSW: the caching policy needs to say "never
cache /api/*", which generateSW's declarative config can't express as
precisely as hand-written Workbox routes can.
- Uses the base `vite-plugin-pwa` plugin, not `@vite-pwa/sveltekit`'s
SvelteKit-specific wrapper. That wrapper's injectManifest build expects
SvelteKit's own built-in src/service-worker.{js,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,
which our SW needs. The base plugin bundles src/service-worker.ts directly
instead, which works. SvelteKit's native service-worker convention is
explicitly disabled (`kit.files.serviceWorker` pointed at a path that
doesn't exist) so the two builds can't collide and silently clobber each
other's output — they do, if both are left enabled, and the failure mode is
silent (the SW builds fine, just precaches nothing).
- workbox-core/precaching/routing/strategies had to be added as direct
devDependencies even though workbox-build depends on them — pnpm doesn't
hoist transitive deps into the top-level node_modules, so the SW bundle
step couldn't resolve them otherwise.
- The SPA fallback index.html doesn't exist yet at service-worker-build time
(adapter-static writes it after all Vite plugins finish), so it can't be
glob-hashed into the precache manifest normally. It gets a synthetic
manifest entry instead, revisioned by a per-build-invocation timestamp
(see the swIndexRevision comment in vite.config.ts) so the cached shell
still invalidates correctly on every deploy.
Full rationale for each choice is inline as comments in vite.config.ts and
apps/web/README.md.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
+99
-1
@@ -1 +1,99 @@
|
||||
SvelteKit static SPA, installable PWA. Not yet scaffolded — Phase 0.
|
||||
# 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.
|
||||
|
||||
## Dockerfile
|
||||
|
||||
This image's **only** purpose is to produce `/app/build` (the static SPA output) as a buildable
|
||||
artifact — see the comment block at the top of `Dockerfile` for the full rationale. There is no
|
||||
Node runtime in production; Caddy serves the built files directly and proxies `/api/*` to the API
|
||||
container (`docs/PLAN.md`, "Service topology"). This image is never run as a long-lived container.
|
||||
|
||||
Build it with the `apps/web` directory as context:
|
||||
|
||||
```sh
|
||||
docker build -f apps/web/Dockerfile -t velodrome-web-build apps/web
|
||||
```
|
||||
|
||||
How `deploy/` is expected to consume it (my assumption — the `deploy/` work is happening in a
|
||||
parallel worktree, so this may get adjusted there): a multi-stage `COPY --from=velodrome-web-build
|
||||
/app/build /srv/web` in whatever image serves Caddy, or a one-shot `docker create` +
|
||||
`docker cp` / bind-mount step in the deploy pipeline that populates the volume Caddy reads from
|
||||
before it starts. Went with a single plain builder stage (no `scratch`/`busybox` artifact-holder
|
||||
final stage) because nothing here needs to _run_ — the only thing anyone needs from this image is
|
||||
the files in `/app/build`, and a second stage would add complexity without adding anything.
|
||||
|
||||
Reference in New Issue
Block a user