Skip to content

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.

ProcessDefault 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/.


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.

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:

PathGroup / kindPurpose
(app)/Authed groupEverything behind login: dashboard, libraries, search, notifications, profile, admin
(app)/libraries/[id]/…AuthedLibrary browser + per-tab pages (feed, map, objects, people, tags, timeline, settings, trash, edit/[fileId])
(app)/admin, (app)/admin/jobsAuthed, owner-gatedAdmin stats/settings + the Asynq job dashboard
login/, register/PublicAuth entry points
invites/[token]/PublicInvite-link landing/acceptance
s/[token]/Public, SSR-for-OGMoment share landing — server load fetches share metadata for crawlers
api/[...path]/Server endpointIn-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.


Authentication is resolved server-side, once per request, in src/hooks.server.ts.

The handle hook populates event.locals.user:

  • For app navigations it calls resolveUser(), which forwards the request’s cookie header to the Go API’s GET /api/_auth/session (an endpoint that never 401s — it returns { user: … | null }). The result is set on locals.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/* requests locals.user is left null; 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 /).

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-Host and X-Forwarded-Proto. The proto/host are load-bearing for share pages — the backend’s share.go builds 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.


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 call createApi(event.fetch) so SvelteKit’s event.fetch + handleFetch rewrite the relative /api/* path to the Go API and forward the cookie.
  • Browser code (components, rune stores) imports the api singleton from $lib/api, which is createApi((i, init) => fetch(i, init)) bound to the global fetch.
NamespaceCovers
api.authSession, login, register, logout, profile, avatar, active sessions, OAuth providers
api.librariesLibrary CRUD
api.filesFile CRUD, playback sources, image/video proxy, transcription, waveform, audio event detection
api.foldersFolder CRUD, move, trash, restore, purge
api.tagsTag CRUD, bulk sync
api.highlightFiltersHighlight filter CRUD
api.membersLibrary members and invite links
api.peopleFace-recognition people, merge, thumbnail URL builder
api.objectsObject-detection labels, reprocess
api.downloadsZIP download size estimate
api.searchCross-library search
api.invitesInvite lookup and acceptance
api.adminAdmin stats, settings, user management, job control
api.momentsMoment CRUD, sharing, export, download URL builder
api.metaPublic metadata (registration mode)

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.messagedata.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).

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 waysduplex: 'half' is set for request bodies so undici/Bun can stream TUS PATCH chunks.
  • Passes status/headers through verbatim, so Range/206, ETag, TUS, and Set-Cookie all work.
  • Strips hop-by-hop headers, and drops content-encoding/content-length on the way back because fetch transparently decodes the upstream body.

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). When PUBLIC_API_ORIGIN is set it returns a direct-to-Go absolute URL (avoiding Range mangling through the proxy); otherwise a same-origin relative path through the /api proxy.
  • dataRequestUrl(path) resolves JSON data fetches: relative on the server (so handleFetch rewrites it), and on the browser the same proxy-vs-direct choice.
  • clientUsesCrossOrigin() flips credentials to '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.


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.


  • Tailwind 4 + Skeleton UI v4, configured CSS-first in src/app.css (@import 'tailwindcss', @import '@skeletonlabs/skeleton' + the cerberus theme + skeleton-svelte). There is no tailwind.config — Tailwind is wired through @tailwindcss/vite.
  • Class-based dark mode. app.css redefines the dark variant with @custom-variant dark (&:where(.dark, .dark *)), so light/dark is driven by a .dark class on <html> toggled by a persisted preference (theme.svelte.ts, key alcoves.theme), not by prefers-color-scheme alone. app.html applies the persisted scheme before first paint to avoid a flash of the wrong theme.
  • Offline icons. Icons use @iconify/svelte rendered via AppIcon.svelte, which calls addCollection(@iconify-json/lineicons) in a module block 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.ts is the single registry: keys are semantic UI roles, values are lineicons:<glyph> strings, all validated against the installed set by icons.test.ts.

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.


vite.config.ts defines two Vitest projects so each test runs in the right environment:

ProjectEnvironmentFilesCovers
servernodesrc/**/*.{test,spec}.tsPure logic, hooks, load functions, the /api proxy, the API client
clientbrowser (vitest-browser-svelte + Playwright chromium, headless)src/**/*.svelte.{test,spec}.tsComponents 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.

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.

Terminal window
docker compose up # brings up postgres + dragonfly + Go API/worker (seeded) + SvelteKit
bun 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).


The client builds with @sveltejs/adapter-node (svelte.config.js) to build/ and is run under Bun (bun /app/build/index.js).

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.

VariablePurpose
INTERNAL_API_URLCo-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_ORIGINPublic API origin for direct browser binary streaming + the activity WS; empty → everything same-origin through the proxy
FRONTEND_HOST / FRONTEND_PORTadapter-node bind address (0.0.0.0 / 3000)
FRONTEND_PROTOCOL_HEADER / FRONTEND_HOST_HEADERx-forwarded-proto / x-forwarded-host — let adapter-node derive the request origin from the ingress
FRONTEND_BODY_SIZE_LIMITMust be Infinity or TUS chunk PATCH bodies streamed through the /api proxy are rejected

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).


ConcernFile
Session resolution + cookie/host rewriteclient/src/hooks.server.ts
Authed-area guard + sidebar dataclient/src/routes/(app)/+layout.server.ts
Owner-only admin guardclient/src/routes/(app)/admin/+layout.server.ts
Dashboard shellclient/src/routes/(app)/+layout.svelte
In-process /api proxyclient/src/routes/api/[...path]/+server.ts
Typed API client factory (15 namespaces)client/src/lib/api/client.ts
Isomorphic apiFetch + ApiErrorclient/src/lib/api/fetch.ts
URL resolution + PUBLIC_API_ORIGIN bypassclient/src/lib/api/url.ts
Backend contract typesclient/src/lib/types/api.ts
Rune storesclient/src/lib/state/*.svelte.ts
Icon registry + offline bundlingclient/src/lib/utils/icons.ts, client/src/lib/components/ui/AppIcon.svelte
Theme bootstrap + form guardclient/src/app.html, client/src/lib/state/theme.svelte.ts
Tailwind 4 + Skeleton CSS-first configclient/src/app.css
Public share page (SSR for OG)client/src/routes/s/[token]/+page.server.ts
adapter-node + envPrefixclient/svelte.config.js
Vitest dual projects + coverageclient/vite.config.ts, client/scripts/coverage-floor.mjs
Real-stack e2eclient/playwright.config.ts, client/test/e2e/*.e2e.ts