Migrate webapp to SvelteKit (Svelte 5 + adapter-node) #6

Merged
lz merged 8 commits from worktree-svelte-migration into main 2026-05-14 18:17:43 +02:00
Owner

Closes #4.

Shape of the change

Two-codebase webapp → one SvelteKit app.

  • Before: Fastify (nexus/src/) + vanilla-TS SPA (nexus/web/), built into nexus/dist/{src,web}/, served together by Fastify with @fastify/static.
  • After: SvelteKit (Svelte 5 + adapter-node), built to nexus/build/, run via node build. Same external behavior, same UI.

Pure-logic modules (auth, crypto, db, services, providers) move under src/lib/server/ essentially untouched. The Fastify-only seams (server.ts, *routes.ts, guard.ts, types.d.ts, @fastify/cookie registration) are deleted and replaced by SvelteKit endpoints + hooks.server.ts.

API path changes

Auth grouped under /api/auth/:

/api/setup    →  /api/auth/setup
/api/unlock   →  /api/auth/unlock
/api/lock     →  /api/auth/lock

Everything else is byte-for-byte identical. Public allowlist in hooks.server.ts:

/api/state, /api/auth/setup, /api/auth/unlock, /api/auth/lock

Everything else under /api/* requires a valid session cookie or returns 401 {"error":"locked"}.

Frontend routing

Nested SvelteKit routes per phase/view, with +layout.server.ts redirecting by state.phase:

Phase Route
needs-setup /setup
needs-unlock /unlock
needs-claude-session /claude-session
needs-provider /providers (autofocus on)
ready /, /new, /providers (free nav)

The refresh() callback is replaced by invalidateAll() exposed via setContext('refresh', ...). The old WeakMap signature-diff in workers.ts/sessions.ts is gone — Svelte's keyed {#each} plus bind:value form state preserves input naturally; the 10s poll lives in +page.svelte's onMount and bumps a tick rune that cascades.

The issue body said "set cookies explicitly" without spelling out which defaults bite. Reassessed against SvelteKit ≥2 defaults — three options must be set, two are written for documentation:

Option SvelteKit default Override?
path the request URL pathname YES — defaults to /api/auth/unlock, cookie never sent on /api/state etc. Silent regression.
secure true if URL is HTTPS, else false YES — keep FORCE_SECURE_COOKIE env knob.
maxAge unset → session-only cookie YES — cookie dies on browser close otherwise.
httpOnly true No — written for clarity.
sameSite 'lax' No — loosened from Fastify's 'strict'. Strict is overkill for an internal tool with no cross-site entry points; lax matches the SvelteKit default.

auth/lock mirrors the secure flag on delete so browsers actually match and clear the cookie.

Bodyless POSTs

/api/auth/lock, /api/workers/[id]/start, /api/workers/[id]/stop work without the Fastify content-type-parser workaround — SvelteKit reads request bodies lazily.

Migrations

Migration files are statically imported in src/lib/server/db/migrate.ts so vite bundles them into the SSR build. adapter-node can't dynamic-import .ts files at runtime in production. runMigrations runs in a SvelteKit ServerInit hook (added in kit ≥2.10) — at server start, before the first request, but not at build time.

Per-session health probe

Per AGENTS.md fact #5: per-session probe in checkSessionHealth still matches the full --remote-control NAME so one process doesn't satisfy every session's probe. Untouched.

Test plan

  • pnpm install
  • pnpm typecheck — 0 errors
  • pnpm test — 88 passed (was 80, plus 8 new hooks.server.test.ts)
  • pnpm lint — clean
  • pnpm build — succeeds, ~5.8s
  • Local smoke: node build starts cleanly, runs migrations, serves:
    • GET /api/state → 200, phase: needs-setup
    • GET /api/workers (no session) → 401, {"error":"locked"}
    • POST /api/auth/lock (no session, no body) → 204 + Set-Cookie: nexus_sid=; Max-Age=0; Path=/; HttpOnly; Secure; SameSite=Lax
  • Manualdocker compose down -v && docker compose up -d --build, walk the full phase flow (setup → unlock → claude-session bootstrap → providers → workspaces → new workspace → session create → session health → lock → re-unlock).
  • Manual — focus the session-add input on /, type a name, wait 12s — value preserved across the 10s poll.
  • Manual — F5 while unlocked on / — stays on / (cookie survives).
  • Manual — kill claude in one tmux window of a multi-session workspace; verify only that session goes unhealthy, the other stays healthy.

Commit list

SvelteKit scaffold: deps, configs, app shell
Move pure-logic modules under src/lib/server/
SvelteKit endpoints + auth gate (hooks.server.ts)
Port SPA to Svelte 5 components + nested SvelteKit routes
Dockerfile: build SvelteKit, run via node build
Add hooks.server.test.ts; fix eslint browser globals
auth/lock: mirror secure flag from unlock on cookie delete
Closes #4. ## Shape of the change Two-codebase webapp → one SvelteKit app. - **Before**: Fastify (`nexus/src/`) + vanilla-TS SPA (`nexus/web/`), built into `nexus/dist/{src,web}/`, served together by Fastify with `@fastify/static`. - **After**: SvelteKit (Svelte 5 + adapter-node), built to `nexus/build/`, run via `node build`. Same external behavior, same UI. Pure-logic modules (auth, crypto, db, services, providers) move under `src/lib/server/` essentially untouched. The Fastify-only seams (`server.ts`, `*routes.ts`, `guard.ts`, `types.d.ts`, `@fastify/cookie` registration) are deleted and replaced by SvelteKit endpoints + `hooks.server.ts`. ## API path changes Auth grouped under `/api/auth/`: ``` /api/setup → /api/auth/setup /api/unlock → /api/auth/unlock /api/lock → /api/auth/lock ``` Everything else is byte-for-byte identical. Public allowlist in `hooks.server.ts`: ``` /api/state, /api/auth/setup, /api/auth/unlock, /api/auth/lock ``` Everything else under `/api/*` requires a valid session cookie or returns `401 {"error":"locked"}`. ## Frontend routing Nested SvelteKit routes per phase/view, with `+layout.server.ts` redirecting by `state.phase`: | Phase | Route | |--------------------------|----------------------| | `needs-setup` | `/setup` | | `needs-unlock` | `/unlock` | | `needs-claude-session` | `/claude-session` | | `needs-provider` | `/providers` (autofocus on) | | `ready` | `/`, `/new`, `/providers` (free nav) | The `refresh()` callback is replaced by `invalidateAll()` exposed via `setContext('refresh', ...)`. The old WeakMap signature-diff in `workers.ts`/`sessions.ts` is gone — Svelte's keyed `{#each}` plus `bind:value` form state preserves input naturally; the 10s poll lives in `+page.svelte`'s `onMount` and bumps a `tick` rune that cascades. ## Cookie contract — explicit options matter The issue body said "set cookies explicitly" without spelling out which defaults bite. Reassessed against SvelteKit ≥2 defaults — three options must be set, two are written for documentation: | Option | SvelteKit default | Override? | |------------|------------------------------------|-------------| | `path` | the request URL pathname | **YES** — defaults to `/api/auth/unlock`, cookie never sent on `/api/state` etc. Silent regression. | | `secure` | `true` if URL is HTTPS, else false | **YES** — keep `FORCE_SECURE_COOKIE` env knob. | | `maxAge` | unset → session-only cookie | **YES** — cookie dies on browser close otherwise. | | `httpOnly` | `true` | No — written for clarity. | | `sameSite` | `'lax'` | No — loosened from Fastify's `'strict'`. Strict is overkill for an internal tool with no cross-site entry points; lax matches the SvelteKit default. | `auth/lock` mirrors the `secure` flag on delete so browsers actually match and clear the cookie. ## Bodyless POSTs `/api/auth/lock`, `/api/workers/[id]/start`, `/api/workers/[id]/stop` work without the Fastify content-type-parser workaround — SvelteKit reads request bodies lazily. ## Migrations Migration files are statically imported in `src/lib/server/db/migrate.ts` so vite bundles them into the SSR build. adapter-node can't dynamic-import `.ts` files at runtime in production. `runMigrations` runs in a SvelteKit `ServerInit` hook (added in kit ≥2.10) — at server start, before the first request, but **not** at build time. ## Per-session health probe Per AGENTS.md fact #5: per-session probe in `checkSessionHealth` still matches the full `--remote-control NAME` so one process doesn't satisfy every session's probe. Untouched. ## Test plan - [x] `pnpm install` - [x] `pnpm typecheck` — 0 errors - [x] `pnpm test` — 88 passed (was 80, plus 8 new `hooks.server.test.ts`) - [x] `pnpm lint` — clean - [x] `pnpm build` — succeeds, ~5.8s - [x] Local smoke: `node build` starts cleanly, runs migrations, serves: - `GET /api/state` → 200, `phase: needs-setup` - `GET /api/workers` (no session) → 401, `{"error":"locked"}` - `POST /api/auth/lock` (no session, no body) → 204 + `Set-Cookie: nexus_sid=; Max-Age=0; Path=/; HttpOnly; Secure; SameSite=Lax` - [ ] **Manual** — `docker compose down -v && docker compose up -d --build`, walk the full phase flow (setup → unlock → claude-session bootstrap → providers → workspaces → new workspace → session create → session health → lock → re-unlock). - [ ] **Manual** — focus the session-add input on `/`, type a name, wait 12s — value preserved across the 10s poll. - [ ] **Manual** — F5 while unlocked on `/` — stays on `/` (cookie survives). - [ ] **Manual** — kill claude in one tmux window of a multi-session workspace; verify only that session goes unhealthy, the other stays healthy. ## Commit list ``` SvelteKit scaffold: deps, configs, app shell Move pure-logic modules under src/lib/server/ SvelteKit endpoints + auth gate (hooks.server.ts) Port SPA to Svelte 5 components + nested SvelteKit routes Dockerfile: build SvelteKit, run via node build Add hooks.server.test.ts; fix eslint browser globals auth/lock: mirror secure flag from unlock on cookie delete ```
lz added 7 commits 2026-05-14 07:35:49 +02:00
Add SvelteKit (Svelte 5 + adapter-node) build wiring alongside
the existing Fastify code. Subsequent commits move logic under
src/lib/server/, port endpoints to +server.ts, and replace the
SPA with Svelte 5 components.

- package.json: drop fastify/@fastify/cookie/@fastify/static,
  add @sveltejs/{kit,adapter-node,vite-plugin-svelte}, svelte 5,
  svelte-check, eslint-plugin-svelte. Scripts collapse to dev/
  build/start/typecheck.
- svelte.config.js: adapter-node + $server alias to src/lib/server.
- vite.config.ts: just the sveltekit() plugin.
- tsconfig.json: extends .svelte-kit/tsconfig.json.
- eslint.config.js: add svelte plugin + ignore build/.svelte-kit.
- vitest.config.ts: drop coverage exclusions for files that move.
- src/app.html, src/app.d.ts: SvelteKit shell + App.Locals.masterKey.
- web/styles.css → src/app.css.
- web/logo.svg  → static/logo.svg.
- Remove tsconfig.web.json (the SPA-only config).
- .gitignore/.dockerignore: ignore build/ and .svelte-kit/.

Refs issue #4.
The Fastify-only seams (server.ts, *routes.ts, guard.ts, types.d.ts)
get deleted entirely. Everything else moves as one subtree under
src/lib/server/, so internal relative imports stay valid without code
changes. SvelteKit endpoints in the next commit import from $server/...

- src/{auth,crypto,db,connections,workers,sessions,providers,testing}
  → src/lib/server/<same>
- src/lib/{cache,shell,logger}.ts → src/lib/server/lib/
- src/config.ts → src/lib/server/config.ts
- DELETE: src/server.ts, src/types.d.ts, src/auth/{guard,routes}.ts,
  src/{connections,workers,sessions,state}/routes.ts
- ADD: src/lib/server/singletons.ts — db + docker + sessions, runs
  migrations on module init, registers SIGTERM/SIGINT (adapter-node
  has no built-in shutdown hook).
- db/client.ts: migrationsDir() now resolves from CWD (or the
  MIGRATIONS_DIR env var) since adapter-node bundles modules and
  import.meta.dirname is no longer stable.

Refs issue #4.
The auth gate moves from per-route Fastify preHandler hooks to a
single handle in hooks.server.ts with a path-prefix check on /api/*
plus a public allowlist:
  - /api/state           (reads cookie itself; phase varies on session)
  - /api/auth/{setup,unlock,lock}

Auth API paths are grouped under /api/auth/ (was /api/setup,
/api/unlock, /api/lock at the Fastify root).

Cookie set in /api/auth/unlock spells out path/maxAge/secure
explicitly. SvelteKit's defaults bite otherwise: path defaults to
the request URL pathname (would scope the cookie to /api/auth/unlock
only), secure defaults to URL-scheme detection (we want the existing
FORCE_SECURE_COOKIE env knob), and maxAge unset gives a session-only
cookie. sameSite loosens from 'strict' to 'lax' — strict is overkill
for an internal tool with no cross-site entry points, lax is the
SvelteKit default and avoids edge cases on redirect chains.

Per-method endpoints under src/routes/api/:
  state, auth/{setup,unlock,lock}, claude-session/recheck,
  connections/{,[id]/,[id]/validate,[id]/repos},
  workers/{,[id]/,[id]/start,[id]/stop,[id]/health,[id]/logs,
            [id]/sessions/{,[sessionId]/,[sessionId]/health}}.

Each handler is a body-for-body translation of the corresponding
Fastify route: same status codes, same payload shapes, same Zod
validation. req.server.* → imports from $server/singletons;
req.masterKey → event.locals.masterKey.

Bodyless POSTs (auth/lock, workers/[id]/{start,stop}) work without
the Fastify content-type-parser workaround — SvelteKit reads request
bodies lazily.

testing/db.ts: bump migrations import paths after the move.

Refs issue #4.
The vanilla-TS SPA in web/ is replaced by a SvelteKit app with one
nested route per phase/view. +layout.server.ts loads /api/state and
redirects by phase so each page only renders when it's allowed:

  needs-setup            → /setup
  needs-unlock           → /unlock
  needs-claude-session   → /claude-session
  needs-provider         → /providers (autofocus on)
  ready                  → /, /new, /providers (free nav)

Layout exposes invalidateAll via setContext('refresh', ...) so child
components don't prop-drill the refresh callback.

The diff-render contortions in workers.ts and sessions.ts go away —
keyed {#each list as item (item.id)} plus form state held in
component runes (bind:value) replaces the WeakMap signature cache.
The 10s poll lives in +page.svelte's onMount and bumps a `tick` rune
that cascades into Workers/Sessions; the typing-skip check is
preserved.

Components ported: Setup, Unlock, ClaudeSession, Connections (with
optional autofocus prop for the dual-purpose case), Workers, NewWorker,
Sessions, Toast, Header, LogsModal.

Build infra:
- migrations are statically imported in src/lib/server/db/migrate.ts
  (`import * as m0001 from '../../../../migrations/0001_init.js'`)
  so vite bundles them — adapter-node can't dynamic-import .ts files
  at runtime in production.
- runMigrations moves out of singletons top-level (which fired during
  the build) into a SvelteKit ServerInit hook in hooks.server.ts.

DELETE: nexus/web/* (the old SPA).

Refs issue #4.
Multi-stage build now drives `pnpm run build` (vite) instead of the
old tsc + vite-build pair. The runtime entry is `node build` (the
adapter-node output), bound to PORT=3001 as before.

Migrations are statically bundled into the SSR build by the
migrate.ts refactor in the previous commit, so the runtime image
no longer needs the migrations/ source tree.

Refs issue #4.
8 new hook tests (88 total): the public allowlist (state/setup/
unlock/lock) passes through with and without a session, gated paths
(/api/workers, /api/connections/abc/repos, ...) return 401 without
a session and pass through with one, and non-/api paths are never
gated. Singletons mocked to keep the suite offline.

ESLint flat config now adds browser+node globals to .svelte files,
.ts files in src/routes/, src/lib/api/, and src/lib/stores/. Without
this, `setInterval`, `document`, `HTMLElement`, etc. trip no-undef
in the new components and +page.svelte.

Refs issue #4.
auth/lock: mirror secure flag from unlock on cookie delete
All checks were successful
ci / nexus (pull_request) Successful in 2m17s
ci / images (./nexus, agent-nexus) (pull_request) Successful in 7m7s
ci / images (./worker, nexus-worker) (pull_request) Successful in 1m6s
3e58f3f704
Browsers match Set-Cookie against existing cookies on
name+path+domain+secure. If unlock sets the cookie with
secure=FORCE_SECURE_COOKIE (false in dev) but lock deletes
without that flag, SvelteKit's default secure=true on delete
won't match the existing cookie — the browser keeps the old
one and the user stays "unlocked" client-side.

Refs issue #4.
Honor WORKER_IMAGE and CLAUDE_SESSION_VOLUME in bootstrap card
All checks were successful
ci / nexus (pull_request) Successful in 2m43s
ci / images (./nexus, agent-nexus) (pull_request) Successful in 3m17s
ci / images (./worker, nexus-worker) (pull_request) Successful in 50s
10c73a52f7
The Bootstrap Claude session card was hardcoding `nexus-worker:latest` and
`claude-session` instead of reading the configured image/volume. Plumb both
through the /api/state DTO and interpolate them into the docker run snippet.
lz merged commit 51e9eb8707 into main 2026-05-14 18:17:43 +02:00
lz deleted branch worktree-svelte-migration 2026-05-14 23:27:38 +02:00
Sign in to join this conversation.
No reviewers
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
lz/agent-nexus!6
No description provided.