Frontend architecture (SvelteKit)
The Alcoves frontend is a SvelteKit (Svelte 5) application living in client/, built on Skeleton UI v4 + Tailwind 4 and run under Bun. The Go backend is a pure API — it does not embed or serve frontend assets. The SvelteKit server reaches the co-located Go API through an in-process proxy and server-side load functions; it never touches the database directly.
| Process | Default port |
|---|---|
| SvelteKit (adapter-node, run under Bun) | 3000 |
| Go API (backend) | 3001 |
This document is aimed at contributors. Read it before working in client/src/.
SSR topology
Section titled “SSR topology”Unlike the previous client-render-everything Nuxt app, this frontend uses SvelteKit’s default: server-render + hydrate. Every page renders on the server first, then hydrates in the browser. Server load functions and hooks.server.ts fetch the Go API (never the DB), so the initial HTML is populated with real data and the public share page (/s/:token) emits correct Open Graph / Twitter meta for social crawlers.
There is no global ssr: false override and no per-route opt-out — SSR is the topology, not the exception. The unit that historically required SSR (the share page) is just a normal SSR page among many.
Route structure
Section titled “Route structure”Routes follow the standard SvelteKit src/routes filesystem layout. The app is organised with route groups — parenthesised directories that share a layout without adding a URL segment:
| Path | Group / kind | Purpose |
|---|---|---|
(app)/ | Authed group | Everything behind login: dashboard, libraries, search, notifications, profile, admin |
(app)/libraries/[id]/… | Authed | Library browser + per-tab pages (feed, map, objects, people, tags, timeline, settings, trash, edit/[fileId]) |
(app)/admin, (app)/admin/jobs | Authed, owner-gated | Admin stats/settings + the Asynq job dashboard |
login/, register/ | Public | Auth entry points |
invites/[token]/ | Public | Invite-link landing/acceptance |
s/[token]/ | Public, SSR-for-OG | Moment share landing — server load fetches share metadata for crawlers |
api/[...path]/ | Server endpoint | In-process catch-all proxy to the Go API (below) |
The (app) group exists so a single +layout.server.ts can guard the entire authenticated surface in one place (see Auth), and a single +layout.svelte can render the dashboard shell (sidebar, header, search, notification bell, user menu). Public pages sit outside the group so they render without that guard or shell.
(app)/libraries/[id]/+layout.svelte nests inside the dashboard shell automatically and adds the library header + tabs. Child tab pages read data.library from the subtree’s server load and page.params.id directly — there is no Vue-style provide/inject for libraryId. The video editor (edit/[fileId]) breaks out of the library layout with a +page@(app).svelte layout reset so it gets full viewport width.
Auth via hooks.server.ts
Section titled “Auth via hooks.server.ts”Authentication is resolved server-side, once per request, in src/hooks.server.ts.
handle — session resolution
Section titled “handle — session resolution”The handle hook populates event.locals.user:
- For app navigations it calls
resolveUser(), which forwards the request’scookieheader to the Go API’sGET /api/_auth/session(an endpoint that never401s — it returns{ user: … | null }). The result is set onlocals.user. - If there is no cookie, the round trip is skipped (anonymous).
- A backend hiccup is swallowed and treated as anonymous — a flaky API must not turn every page into a 500.
- For
/api/*requestslocals.useris leftnull; the proxy forwards the raw cookie itself, so resolving the session there would be wasted work.
The root +layout.server.ts exposes locals.user to every page as data.user. The authed-area guard lives in (app)/+layout.server.ts: it throws redirect(302, '/login?redirect=…') when locals.user is null, then loads the libraries list the sidebar renders (degrading to an empty list on failure rather than failing the shell). (app)/admin/+layout.server.ts adds the owner-only guard (locals.user?.role !== 'owner' → redirect to /).
handleFetch — cookie + host rewrite for server load
Section titled “handleFetch — cookie + host rewrite for server load”The handleFetch hook intercepts fetch calls made inside server load/actions. When a same-origin /api/* URL is requested:
- it rewrites the target to
INTERNAL_API_URL(the co-located Go API); - it forwards the session
cookie; - it forwards
X-Forwarded-HostandX-Forwarded-Proto. The proto/host are load-bearing for share pages — the backend’sshare.gobuilds absolute OG/share URLs from the forwarded host, so they must match the public origin.
This is why a server load can write await fetch('/api/share/…') with a relative path and have it transparently reach the backend with auth attached.
API client and transport
Section titled “API client and transport”createApi(fetch)
Section titled “createApi(fetch)”src/lib/api/client.ts exports a createApi(fetchImpl) factory that returns a typed api object composed of 15 namespaced sub-objects. Every method wraps the underlying apiFetch/apiUrl. This is the only place route paths are written — code calls api.files.list(...) rather than hand-writing URLs.
The factory pattern is what makes the client isomorphic:
- Server
load/actions callcreateApi(event.fetch)so SvelteKit’sevent.fetch+handleFetchrewrite the relative/api/*path to the Go API and forward the cookie. - Browser code (components, rune stores) imports the
apisingleton from$lib/api, which iscreateApi((i, init) => fetch(i, init))bound to the globalfetch.
| Namespace | Covers |
|---|---|
api.auth | Session, login, register, logout, profile, avatar, active sessions, OAuth providers |
api.libraries | Library CRUD |
api.files | File CRUD, playback sources, image/video proxy, transcription, waveform, audio event detection |
api.folders | Folder CRUD, move, trash, restore, purge |
api.tags | Tag CRUD, bulk sync |
api.highlightFilters | Highlight filter CRUD |
api.members | Library members and invite links |
api.people | Face-recognition people, merge, thumbnail URL builder |
api.objects | Object-detection labels, reprocess |
api.downloads | ZIP download size estimate |
api.search | Cross-library search |
api.invites | Invite lookup and acceptance |
api.admin | Admin stats, settings, user management, job control |
api.moments | Moment CRUD, sharing, export, download URL builder |
api.meta | Public metadata (registration mode) |
apiFetch and ApiError
Section titled “apiFetch and ApiError”src/lib/api/fetch.ts builds the apiFetch bound to a fetch implementation. It appends query params (dropping undefined/null), sends JSON bodies (skipping Content-Type for FormData so the multipart boundary is browser-set), supports json / blob / text response types (an empty body parses to null), and throws ApiError(status, data) on any non-OK response. ApiError carries status: number and data, with the message resolved from data.message → data.statusMessage → a generic fallback.
Cookie forwarding is intentionally not in apiFetch: on the server it lives in handleFetch; in the browser the cookie rides along automatically (same-origin) or via credentials: 'include' (cross-origin, when PUBLIC_API_ORIGIN is set).
The in-process /api proxy
Section titled “The in-process /api proxy”src/routes/api/[...path]/+server.ts is a catch-all SvelteKit endpoint exporting every HTTP verb. It streams the request to INTERNAL_API_URL/api/<path> and streams the response back, preserving the unified single-origin topology so a same-origin /api/* call (cookie auto-sent) reaches the Go API. It is deliberately byte-faithful:
- Streams bodies both ways —
duplex: 'half'is set for request bodies so undici/Bun can stream TUSPATCHchunks. - Passes status/headers through verbatim, so Range/
206, ETag, TUS, andSet-Cookieall work. - Strips hop-by-hop headers, and drops
content-encoding/content-lengthon the way back becausefetchtransparently decodes the upstream body.
PUBLIC_API_ORIGIN bypass
Section titled “PUBLIC_API_ORIGIN bypass”src/lib/api/url.ts decides per-request whether the browser talks to the proxy or directly to the Go API:
apiUrl(path)builds browser-facing asset/stream URLs (<img>/<video>src, downloads, thumbnails). WhenPUBLIC_API_ORIGINis set it returns a direct-to-Go absolute URL (avoiding Range mangling through the proxy); otherwise a same-origin relative path through the/apiproxy.dataRequestUrl(path)resolves JSON data fetches: relative on the server (sohandleFetchrewrites it), and on the browser the same proxy-vs-direct choice.clientUsesCrossOrigin()flipscredentialsto'include'when the browser is crossing the API origin.
So binary streaming and the activity WebSocket bypass the proxy when PUBLIC_API_ORIGIN is set, hitting the Go API directly. In the single-port unified (all-role) deployment where no separate API origin is exposed, the activity WebSocket can’t upgrade through the single SvelteKit port; the notifications socket degrades to its poll-fallback. Real-time WS works when reaching the API directly — via a Kubernetes ingress that routes /api/ws to the API service, or by setting PUBLIC_API_ORIGIN.
Svelte 5 rune stores ($lib/state)
Section titled “Svelte 5 rune stores ($lib/state)”The Vue composables (useLibraryExplorer, useUploadQueue, useNotifications, …) are replaced by Svelte 5 rune stores under src/lib/state/. Stateful stores are .svelte.ts files (so the compiler enables runes), exporting a single module-level instance whose reactive fields use $state/$derived and are exposed through getters so reactivity survives the module boundary. Consumers import the singleton and drive it from onMount/onDestroy; the stores themselves avoid $effect/lifecycle hooks.
Representative stores: auth, theme, toast, libraries-list, library-explorer, library-timeline/-map/-feed/-members/-people/-tags/-moments/-folder-path/-folder-actions, upload-queue (TUS), notifications + notifications-socket, transcript/transcribe-job, audio-detections/audio-detect-job, waveform/waveform-job/waveform-renderer, highlight-filters, editor-highlights/editor-shortcuts, download-zip, moment-downloads, async-job-status, file-drop. Pure non-reactive helpers (e.g. async-job-status.ts, editor-shortcuts.ts, toast.ts) keep a plain .ts extension.
Styling, theme, and icons
Section titled “Styling, theme, and icons”- Tailwind 4 + Skeleton UI v4, configured CSS-first in
src/app.css(@import 'tailwindcss',@import '@skeletonlabs/skeleton'+ thecerberustheme +skeleton-svelte). There is notailwind.config— Tailwind is wired through@tailwindcss/vite. - Class-based dark mode.
app.cssredefines the dark variant with@custom-variant dark (&:where(.dark, .dark *)), so light/dark is driven by a.darkclass on<html>toggled by a persisted preference (theme.svelte.ts, keyalcoves.theme), not byprefers-color-schemealone.app.htmlapplies the persisted scheme before first paint to avoid a flash of the wrong theme. - Offline icons. Icons use
@iconify/svelterendered viaAppIcon.svelte, which callsaddCollection(@iconify-json/lineicons)in amoduleblock so the Lineicons set is bundled and rendered fully offline — no requests to the Iconify API (privacy-first, per the project vision).src/lib/utils/icons.tsis the single registry: keys are semantic UI roles, values arelineicons:<glyph>strings, all validated against the installed set byicons.test.ts.
Pre-hydration form guard
Section titled “Pre-hydration form guard”app.html installs an inline capturing submit listener that calls e.preventDefault() while window.__alcovesReady is falsy. The root +layout.svelte sets __alcovesReady = true and releases the guard in onMount. This closes the SSR→hydration window in which a native <form> POST could fire before the app is interactive. E2E tests read window.__alcovesReady to know the app is interactive before asserting.
Testing strategy
Section titled “Testing strategy”Unit tests — Vitest dual projects
Section titled “Unit tests — Vitest dual projects”vite.config.ts defines two Vitest projects so each test runs in the right environment:
| Project | Environment | Files | Covers |
|---|---|---|---|
server | node | src/**/*.{test,spec}.ts | Pure logic, hooks, load functions, the /api proxy, the API client |
client | browser (vitest-browser-svelte + Playwright chromium, headless) | src/**/*.svelte.{test,spec}.ts | Components and DOM-touching rune stores |
There are ~1,591 unit tests. Coverage is v8 with global thresholds of 90% lines/functions/statements and 80% branches; scripts/coverage-floor.mjs enforces the complementary per-file rule that no file is below 60%. A short coverage-exclude list covers files the unit harness can’t meaningfully exercise — LibraryMap.svelte and VideoEditorPlayer.svelte (thin wrappers around browser-only libs whose onMount dynamic imports can’t run in unit tests) and the two trivial libraries/[id] +page.svelte passthroughs — all of which are exercised by the full-stack e2e instead.
E2E — real-stack Playwright
Section titled “E2E — real-stack Playwright”client/playwright.config.ts runs client/test/e2e/*.e2e.ts against a real, running, full stack — Postgres + Dragonfly + the Go API/worker (seeded) behind the SvelteKit server. There is no mock backend.
docker compose up # brings up postgres + dragonfly + Go API/worker (seeded) + SvelteKitbun run test:e2e # Playwright against http://localhost:3000 (or E2E_BASE_URL)Seed login: [email protected] / password123 (see backend/internal/seed). Tests run sequentially (workers: 1).
Deployment (adapter-node)
Section titled “Deployment (adapter-node)”The client builds with @sveltejs/adapter-node (svelte.config.js) to build/ and is run under Bun (bun /app/build/index.js).
envPrefix — avoiding a PORT collision
Section titled “envPrefix — avoiding a PORT collision”The adapter is configured with envPrefix: 'FRONTEND_'. In the unified single-image all role the SvelteKit server and the Go API run side by side, and the Go API owns the unprefixed PORT. Prefixing means the SvelteKit server reads FRONTEND_HOST / FRONTEND_PORT / FRONTEND_ORIGIN / FRONTEND_BODY_SIZE_LIMIT (and FRONTEND_PROTOCOL_HEADER / FRONTEND_HOST_HEADER) without colliding with the Go process.
| Variable | Purpose |
|---|---|
INTERNAL_API_URL | Co-located Go API base for server load + the /api proxy target (default http://localhost:3001; http://127.0.0.1:3001 in the unified image) |
PUBLIC_API_ORIGIN | Public API origin for direct browser binary streaming + the activity WS; empty → everything same-origin through the proxy |
FRONTEND_HOST / FRONTEND_PORT | adapter-node bind address (0.0.0.0 / 3000) |
FRONTEND_PROTOCOL_HEADER / FRONTEND_HOST_HEADER | x-forwarded-proto / x-forwarded-host — let adapter-node derive the request origin from the ingress |
FRONTEND_BODY_SIZE_LIMIT | Must be Infinity or TUS chunk PATCH bodies streamed through the /api proxy are rejected |
Build pipeline
Section titled “Build pipeline”The root Dockerfile builds the client in stage 3 (oven/bun:1): bun run build emits build/, then prod deps are pruned to a lean node_modules (vite/eslint/playwright dropped, but adapter-node’s runtime deps kept since the server imports from build/). Stage 4 copies build/ + the pruned node_modules + package.json into the runtime image. docker/entrypoint.sh runs bun /app/build/index.js for the web and all roles; in all it supervises that alongside the Go binary and exits non-zero if either child dies.
Dev uses the docker-compose frontend service built from client/Dockerfile.dev (Vite dev server on :3000 with HMR, INTERNAL_API_URL=http://backend:3001).
The Helm chart’s frontend Deployment runs the one image with args: ["web"] and sets FRONTEND_HOST/FRONTEND_PORT/FRONTEND_PROTOCOL_HEADER/FRONTEND_HOST_HEADER/FRONTEND_BODY_SIZE_LIMIT, INTERNAL_API_URL (the in-cluster API service), and PUBLIC_API_ORIGIN (so browsers reach the API directly for streaming and the activity WebSocket).
Reference: key files
Section titled “Reference: key files”| Concern | File |
|---|---|
| Session resolution + cookie/host rewrite | client/src/hooks.server.ts |
| Authed-area guard + sidebar data | client/src/routes/(app)/+layout.server.ts |
| Owner-only admin guard | client/src/routes/(app)/admin/+layout.server.ts |
| Dashboard shell | client/src/routes/(app)/+layout.svelte |
In-process /api proxy | client/src/routes/api/[...path]/+server.ts |
| Typed API client factory (15 namespaces) | client/src/lib/api/client.ts |
Isomorphic apiFetch + ApiError | client/src/lib/api/fetch.ts |
URL resolution + PUBLIC_API_ORIGIN bypass | client/src/lib/api/url.ts |
| Backend contract types | client/src/lib/types/api.ts |
| Rune stores | client/src/lib/state/*.svelte.ts |
| Icon registry + offline bundling | client/src/lib/utils/icons.ts, client/src/lib/components/ui/AppIcon.svelte |
| Theme bootstrap + form guard | client/src/app.html, client/src/lib/state/theme.svelte.ts |
| Tailwind 4 + Skeleton CSS-first config | client/src/app.css |
| Public share page (SSR for OG) | client/src/routes/s/[token]/+page.server.ts |
adapter-node + envPrefix | client/svelte.config.js |
| Vitest dual projects + coverage | client/vite.config.ts, client/scripts/coverage-floor.mjs |
| Real-stack e2e | client/playwright.config.ts, client/test/e2e/*.e2e.ts |