- SvelteKit with Svelte 5 runes,
@sveltejs/adapter-cloudflare, CSR-only (src/routes/+layout.tssetsssr = falseglobally). - Cloudflare Workers + Durable Objects + D1. The whole app runs on Workers. Live "room state" lives in a per-show Durable Object (
ShowRoom); a singletonShowDirectoryDO announces the live show. Durable, cross-show "registry" data lives in D1 (SQLite) via Drizzle (drizzle-orm/d1). - Better Auth (GitHub OAuth → JWT/JWKS) on D1 via the Drizzle adapter. A per-request
createAuth(env)factory insrc/lib/server/auth.ts(NOT a module-level singleton — the D1 binding is per-requestevent.platform.env). - Sentry: client via
@sentry/sveltekit(hooks.client.ts); server via@sentry/cloudflare(withSentrywraps the worker insrc/worker.ts;hooks.server.tshandleErrorcaptures). - Package manager:
pnpm@10(run binaries withpnx). Scripts:dev,build,check,lint,format,auth:schema,cf:types,db:generate,db:migrate,db:reset,cf:dev. - See
CONTEXT.md(domain language: Registry vs Room State, Overlay, etc.) anddocs/jazz-to-cloudflare-migration.md(how this stack came to be; intentional deviations).
- Durable Objects (SQLite, Hibernation API, Alarms): https://developers.cloudflare.com/durable-objects/
- D1: https://developers.cloudflare.com/d1/
- Better Auth Drizzle adapter: https://www.better-auth.com/docs/adapters/drizzle
- Drizzle ORM (SQLite/D1): https://orm.drizzle.team/
- SvelteKit: https://svelte.dev/docs/kit
- ADR 0002 (Hibernation + Alarm auto-hide), ADR 0003 (dev-auth bypass) in
docs/adr/.
- The dev/verification loop is
pnpm build+wrangler dev(real workerd, real local D1, real local DOs).wrangler devreads.dev.vars. Run on port 8787 → setORIGIN=http://localhost:8787. (Purevite devcannot run the DO-exporting worker; see the migration doc's deviations.) adapter-cloudflarewrites its worker to the config it is given (wrangler.adapter.jsonc). The realmainissrc/worker.ts— a custom entry that imports the adapter output, re-exportsShowRoom/ShowDirectory, and wraps the fetch handler with@sentry/cloudflare. It also routes/_ws/room/:showIdand/_ws/directoryWebSocket upgrades to the DOs.- After changing
wrangler.jsonc(bindings), runpnpm cf:types(regeneratesworker-configuration.d.tsand repoints DO type refs at their source).
- Registry (D1, Drizzle): Better Auth tables +
shows(incl.status, with a partial unique index enforcing oneliveshow) +showHosts+hostLinks. Schema insrc/lib/server/db/auth-schema.ts(regenerate-able viapnpm auth:schema) andsrc/lib/server/db/registry-schema.ts(hand-authored). Migrations:pnpm db:generate→pnpm db:migrate(local D1). Client-facing registry shapes are insrc/lib/components/shows/registry-types.ts(never import$lib/server/*into client code). - Room State (per-show
ShowRoomDO SQLite): ticker, active lower-third,submissionsOpen, submissions, votes, featured. The DO is the source of truth, persists to its own SQLite (survives hibernation), verifies the JWT against the live JWKS on connect (src/lib/server/verify-jwt.ts), classifies the socket anonymous/viewer/admin (stashed viaserializeAttachment()), enforces permissions in its message handler, and sends each consumer a pre-shaped snapshot on connect and on every change. No in-DOsetInterval/setTimeout— the lower-third auto-hide is a single Alarm (ADR 0002). - The wire protocol (snapshot shape + message unions) is
src/lib/components/shows/room-protocol.ts, shared by the DO and the client store.
- Room state: read the WebSocket store, not a query engine.
ShowRoomStore(a specific show, optionally authenticated) andLiveShowStore(follows the live show via the directory) insrc/lib/components/shows/room.svelte.ts. Components read fields offstore.snapshot(e.g.room.snapshot?.ticker); overlays useLiveShowStore, the admin console + homepage instantiate the store and pass it down. Connect/disconnect inonMount(overlays/homepage) or a route-param$effect(admin console, which is reused across shows). Surfacestore.error. - Registry: load it in a
+page.server.tsloadwithdrizzle(platform.env.DB); the page readsdata. (ssr = falsedoes not disable serverload— it still runs in the worker.) - Identity:
getClientSession()fromsrc/lib/session.svelte(claims decoded from the JWT; reactive).session.isAdmin/session.userId/session.isLoggedInare display/affordance state — authorization is server/DO-side.
- Room writes (ticker, lower-third broadcast/hide, submissions-open, submit, moderate, feature, vote) are typed messages to the show DO over the open socket:
room.send({ type: '...' }). The DO authorizes each from the verified JWT class. Viewers (not just admins) write over their already-open read socket; overlays are anonymous and rejected on any write. - Registry writes (create/delete show, status, host assignment, host titles, ban/unban) are SvelteKit form actions in
+page.server.ts, with a server-side admin gate viarequireAdmin(locals)(src/lib/server/require-admin.ts), writing D1 with Drizzle. This inverts the old "no endpoints for app data" rule — that only held because Jazz let the client write safely. - DO↔D1 coherence: a
status→liveform action does the atomic D1 write, thenannounceShowStatus(src/lib/server/show-status.ts) hydrates the room from the registry (ShowRoom.activate) and announces it to the directory. Registry edits while live write-through to the DO (syncHosts,setBan). The hot vote/submit path reads only the DO's SQLite, never D1. - API routes (
+server.ts) are for true server-only boundaries: auth/JWT bridge, secrets, external APIs (today the auth routes +src/routes/api/page-title/+server.ts). DO WebSocket upgrades are routed insrc/worker.ts, not via SvelteKit endpoints.
createAuth(env)(per request, inhooks.server.tsfromevent.platform.env) builds Better Auth on D1 via the Drizzle adapter, with the GitHub provider, the JWT/JWKS plugin,additionalFields(roles,githubUsername,banned,githubUserId),mapProfileToUser, anddefinePayload.hooks.server.tspopulatesevent.locals.{auth,user,session}.- Admin source of truth:
ADMIN_GITHUB_USERNAMES(comma/space-separated). On GitHub sign-in,mapProfileToUser→isAdminGithubUser(src/lib/server/github-auth.ts) writesroles: ['viewer','admin','host']for admins, else['viewer'].definePayloadderivesclaims.is_adminfromuser.roles.includes('admin'). Full claims:{ id, name, email, image, githubUsername, is_admin }. - Authorization is the server-side admin gate (registry form actions) and the DO's JWKS verification + connection class (room writes). UI affordances gate on
getClientSession().isAdmin(a display claim), never the authority. - Dev-auth bypass (ADR 0003):
DEV_AUTH=1in.dev.varsenables/dev-login?role=admin|viewer, which seeds two users and mints a real JWKS-signed JWT through the existing pipeline. Never gate this on the frameworkdevflag (false under workerd) and never setDEV_AUTHin a deployed env. - When debugging non-admin: check (1) the JWT claims (
/api/auth/token), (2) the GitHub login vsADMIN_GITHUB_USERNAMES, (3)roleson thebetter_auth_userD1 row.
You are able to use the Svelte MCP server, where you have access to comprehensive Svelte 5 and SvelteKit documentation. Here's how to use the available tools effectively:
Use this FIRST to discover all available documentation sections. Returns a structured list with titles, use_cases, and paths. When asked about Svelte or SvelteKit topics, ALWAYS use this tool at the start of the chat to find relevant sections.
Retrieves full documentation content for specific sections. Accepts single or multiple sections. After calling the list-sections tool, you MUST analyze the returned documentation sections (especially the use_cases field) and then use the get-documentation tool to fetch ALL documentation sections that are relevant for the user's task.
Analyzes Svelte code and returns issues and suggestions. You MUST use this tool whenever writing Svelte code before sending it to the user. Keep calling it until no issues or suggestions are returned.
- Routes are organized into groups:
(app)/(protected)for authenticated app UI,(auth)for login,(overlay)for transparent OBS browser-source overlays,(public)for unauthenticated pages. - Use nested SvelteKit layouts for shared route UI instead of thin wrapper components.
- Components own their own data: read
getClientSession()for identity and the room store'ssnapshotfor room state directly where used. The parent that owns the show's room connection (admin console page, homepage) instantiates the store and passes it down; overlays own aLiveShowStore. Do not prop-drill route params through pass-through components. - Props are for real generic boundaries (e.g.
let { show }: { show: Show } = $props();,let { room }: { room: ShowRoomStore } = $props();). ReadgetClientSession()directly at feature/state boundaries. - Use Svelte 5 runes in component state.
$statefor local reactive state,$derivedfor computed values. Do not use$derived/$derived.byfor rename-only aliases of existing values. - Avoid
$effectexcept for synchronizing an external resource with reactive state — e.g. the admin console route re-points its room socket whendata.show.idchanges via$effect. Never use it for derived state. - Event handlers:
onclick={handler}oronclick={() => handler(id)}for capturing an argument. Do not writeonclick={() => { void handler(); }}. - Form/input handlers (
onsubmit,onchange,oninput) extract values via the typedevent.currentTarget(withinstanceofnarrowing where the value comes from the DOM). SeeShowStatePanel.sveltefor the pattern. - Type-safe overlay routing: extend
OverlayFeatureinsrc/lib/utils/overlays.tsand add toOVERLAY_FEATURES. TheOverlayFeaturePathtemplate-literal type keeps paths in sync. - The root
+layout.svelteawaitsrefreshSession()at the top level to establish client identity from the JWT before rendering. Do not reintroduce SSR —+layout.tsexportsssr = falsefor a reason.
- The visual system is custom CSS in
src/app.csswith CSS custom properties (--color-accent,--color-danger,--color-success,--radius-md,--shadow-raised-md,--transition-fast, etc.) and Space Grotesk / Space Mono fonts. - Use data attributes for variants, not class proliferation:
data-depth="medium",data-state="warning",data-variant="danger",data-active="true". Selectors in component scoped styles target these (section[data-depth='medium'],li[data-active='true']). - Component-scoped
<style>blocks own their layout. Keep structural CSS (grid, flex, sizing, spacing) in the component; design tokens inapp.css. - Overlays render at 1920×1080 with transparent background via
html:has(.overlay-root)inapp.css. Overlay components live insrc/lib/components/shows/*Overlay.svelteand routes under(overlay)/overlay/<feature>/. - Do not add a
buttonclass to a<button>element. Style buttons viadata-variantor scoped selectors.
- All shared/pure utilities live in
src/lib/utils/. One pool — do not create new utility folders. - Current utils:
github-usernames.ts,lower-thirds.ts,overlays.ts,page-title.ts,roles.ts,shows.ts,submissions.ts,ticker-messages.ts,urls.ts. Check here first and extend an existing file before adding a new one. - Server-only helpers go in
src/lib/server/(auth.ts,github-auth.ts,page-title.ts,require-admin.ts,show-status.ts,verify-jwt.ts, the Durable Objectsshow-room.ts/show-directory.ts, and the Drizzle schema underdb/). Anything reachable from the worker bundle (the DOs and their imports) must use relative imports — wrangler's esbuild does not resolve$lib. - App-wide client state lives at
src/lib/(e.g.session.svelte.ts). Feature-specific stores (room.svelte.ts), types, components, and tests stay undersrc/lib/components/<feature>/. Do not place feature-specific files in global buckets. - Never write a utility function inline in the file where it is used if it is generic/pure. Lift it to
src/lib/utils/.
tsconfig.jsonhasstrict: true,allowJs: true,checkJs: true. Preserve strict typing — noany, preferunknownat trust boundaries, narrow before access.- Use
import typefor type-only imports. Group imports as: node built-ins, external packages, internal modules ($lib/...,$env/...,$app/...). - Never re-export code or types; always import from the source module.
- Naming:
PascalCasecomponents/types,camelCasevariables/functions,SCREAMING_SNAKE_CASEtop-level constants,is*/has*/should*for booleans (the codebase usesis_adminwith underscore to mirror the signed JWT claim name). - Add explicit return types on exported functions (
Promise<Show>,Promise<void>, etc.). - Error handling: log with context (
console.error('Unable to ...', error)) and surface a user-facing message via local$state. Throw early on bad input with a clear message. - Action files use option-object parameters (
interface CreateShowOptions { ... }) rather than positional arguments.
- There are no tests yet in this repo. When adding tests, colocate them with the feature (
*.test.ts/*.spec.tsinsrc/lib/components/<feature>/or next to the util). Use@testing-library/sveltefor component tests. Prefer role/text-based assertions. Keep tests deterministic and independent.
- ALWAYS ASK BEFORE UPDATING the schema. Changes to the Drizzle schema (
src/lib/server/db/registry-schema.ts,auth-schema.ts) and the room-state shape / permissions in the Durable Objects (src/lib/server/show-room.ts,room-protocol.ts) require confirmation. - Run the local app with
pnpm buildthenwrangler dev --port 8787(orpnpm cf:dev). It serves on 8787 and reads.dev.vars. - Do not run
pnpm db:resetwithout asking — it wipes and re-migrates the local D1 (.wrangler/state/v3/d1), dropping all local users and shows. - After auth-config changes,
pnpm auth:schemaregenerates the Drizzle auth schema; for any schema change runpnpm db:generatethenpnpm db:migrate. Afterwrangler.jsoncbinding changes runpnpm cf:types. After TS/Svelte edits runpnpm check.