Files
wild-pc/docs/stacks/react-vite.md
Paul Payne 4840cbe72a feat: castle UX fixes from the lakehouse adoption test
Driven by adopting lakehouse (an existing daemon that bundles its own SPA) —
see docs/findings-lakehouse.md.

#3 deploy reloads the gateway. castle deploy regenerated the Caddyfile but
   left the running Caddy on the old config, so new proxy routes were silently
   dead. Deploy now reloads the gateway when it's running.

#1 castle expose <program>. Turns an adopted program into a service
   (run/port/health/proxy/systemd) in one command — the missing
   daemon-to-service step. Flags: --port --health --path --run --port-env
   --host --no-proxy.

#2 port_env mapping. A service can declare expose.http.internal.port_env so
   castle sets the env var the program actually reads (e.g. lakehoused reads
   LAKEHOUSED_DAEMON_PORT, not castle's convention LAKEHOUSE_PORT). Castle now
   genuinely drives an adopted daemon's bind port.

#4a auto-base for react-vite. The build passes VITE_BASE = the gateway serve
   prefix (/<name>/, or / for castle-app); vite.config reads it. A castle-built
   frontend now works at its subpath with no hand-tuned base. castle-app's
   vite.config updated as the reference.

#4b host-based routing. proxy.caddy.host routes a whole hostname to the backend
   root via a host matcher inside the :9000 site, so a root-based SPA (base="/")
   serves unchanged — the fix for proxying an app castle can't rebuild. Caddyfile
   now emits 'auto_https off' (HTTP-only gateway on a non-standard port).

Nit: activate skips the editable reinstall when the tool is already on PATH.

Tests: core 94, cli 24, api 52; ruff + app build clean. Verified live: lakehouse
runs under systemd, API at /lakehouse, full UI (SPA boots) via host routing.
2026-06-14 13:16:34 -07:00

9.8 KiB

Web Frontends in Castle

This is a stack — creation-time guidance for writing new frontends. A stack is a template + conventions, not a runtime requirement. castle create --stack react-vite scaffolds from it and seeds the program's default dev-verb commands. An existing frontend adopted with castle add doesn't need this stack — it declares its own commands:. See @docs/registry.md for commands:, stack: (optional), and repo:.

How to build, serve, and manage web frontends as castle components.

Stack

  • Build: Vite 6
  • Language: TypeScript 5.8 (strict)
  • Framework: React 19
  • Routing: React Router 7
  • Styling: Tailwind CSS 4 (@tailwindcss/vite plugin)
  • Components: shadcn/ui (new-york style) + Radix UI primitives
  • Icons: Lucide React
  • Server state: TanStack React Query 5
  • Forms: React Hook Form + Zod validation
  • Testing: Vitest + Testing Library
  • Package manager: pnpm

Scaffolding a new frontend

# Create the project
mkdir ~/.castle/code/my-frontend && cd ~/.castle/code/my-frontend
pnpm create vite . --template react-ts

# Core dependencies
pnpm add react-router react-router-dom \
         @tanstack/react-query \
         tailwindcss @tailwindcss/vite \
         class-variance-authority clsx tailwind-merge \
         lucide-react sonner zod \
         react-hook-form @hookform/resolvers

# Dev dependencies
pnpm add -D vitest jsdom @testing-library/react @testing-library/jest-dom \
            @testing-library/user-event

# shadcn/ui
pnpm dlx shadcn@latest init

Vite config

// vite.config.ts
import path from "path"
import tailwindcss from "@tailwindcss/vite"
import { defineConfig } from "vite"
import react from "@vitejs/plugin-react"

export default defineConfig({
  // Castle sets VITE_BASE to the gateway serve prefix at build time (`/<name>/`,
  // or `/` for the root app). Reading it here makes the bundle's absolute asset
  // URLs resolve when served behind the gateway at a subpath — no hand-tuning.
  base: process.env.VITE_BASE ?? "/",
  plugins: [react(), tailwindcss()],
  resolve: {
    alias: {
      "@": path.resolve(__dirname, "./src"),
    },
  },
})

Serving behind the gateway. A static frontend mounts at /<name>/ (the root castle-app at /). Castle's react-vite build passes that prefix as VITE_BASE, so a frontend that reads it (above) works at its subpath with no manual base. Frontends that don't read VITE_BASE must hand-set base to match, or they'll request assets from the wrong path.

Project layout

my-frontend/
├── src/
│   ├── main.tsx                 # ReactDOM.createRoot mount
│   ├── App.tsx                  # Root: QueryClientProvider + RouterProvider
│   ├── index.css                # Tailwind imports + CSS custom properties
│   ├── router/
│   │   ├── index.tsx            # createBrowserRouter
│   │   └── routes.tsx           # Route tree
│   ├── components/
│   │   └── ui/                  # shadcn/ui components
│   ├── services/api/
│   │   ├── client.ts            # Typed fetch wrapper
│   │   └── hooks/               # React Query hooks per resource
│   ├── hooks/                   # App-level hooks
│   ├── contexts/                # React context providers
│   ├── lib/
│   │   ├── utils.ts             # cn() helper
│   │   └── queryClient.ts       # Query client config
│   ├── types/                   # Shared TypeScript types
│   └── schemas/                 # Zod schemas
├── public/                      # Static assets (favicon, manifest.json)
├── index.html                   # SPA entry point
├── package.json
├── vite.config.ts
├── tsconfig.json
├── vitest.config.ts
├── components.json              # shadcn/ui config
└── .env                         # VITE_API_BASE_URL etc.

Build commands

pnpm run dev          # Vite dev server (:5173), HMR
pnpm run build        # tsc -b && vite build → dist/
pnpm run preview      # Serve production build locally
pnpm run type-check   # tsc --noEmit
pnpm run lint         # ESLint
pnpm run test         # Vitest
pnpm run check        # lint + type-check + test

The build output is a static SPA in dist/ — just HTML, JS, and CSS files.

Registering as a castle component

A frontend component has a build spec (produces static output). Register it in the programs: section of castle.yaml. No run block needed if Caddy handles serving directly from the build output.

# castle.yaml
programs:
  my-frontend:
    description: Web dashboard
    source: code/my-frontend
    build:
      commands:
        - ["pnpm", "build"]
      outputs:
        - dist/

For production, castle deploy copies the build output to ~/.castle/artifacts/content/<name>/ and Caddy serves it from there — no Node process needed. See Serving with Caddy below.

For development with Vite's dev server, add a service entry:

services:
  my-frontend:
    component: my-frontend
    run:
      runner: node
      script: dev
      package_manager: pnpm
    expose:
      http:
        internal: { port: 5173 }
    proxy:
      caddy: { path_prefix: /app }

See @docs/registry.md for the full registry reference.

Serving with Caddy

For production, the static build output is served by Caddy rather than a Node process. You do not write this block by hand — castle deploy generates it. The flow:

  1. castle deploy runs the program's build.commands, then copies each build.outputs directory from ~/.castle/code/<name>/ into ~/.castle/artifacts/content/<name>/ (core/src/castle_core/deploy.py, _copy_app_static).
  2. The Caddyfile generator scans ~/.castle/artifacts/content/; any directory containing an index.html is served as a SPA at a path prefix matching its name. castle-app is special-cased to serve at the root /.

The generated block looks like this (written to ~/.castle/artifacts/specs/Caddyfile):

handle_path /my-frontend/* {
    root * /home/payne/.castle/artifacts/content/my-frontend
    try_files {path} /index.html
    file_server
}

The try_files {path} /index.html directive is essential for SPA routing — it falls back to index.html for any path that doesn't match a static file, letting React Router handle client-side routes. The serving prefix is derived from the program name, not hand-configured.

API integration

Frontends talk to castle services via environment variables injected at build time. Vite exposes variables prefixed with VITE_:

# .env
VITE_API_BASE_URL=http://localhost:9001
// src/services/api/client.ts
const BASE_URL = import.meta.env.VITE_API_BASE_URL ?? "http://localhost:9001"

class ApiClient {
  private baseUrl: string

  constructor(baseUrl = BASE_URL) {
    this.baseUrl = baseUrl
  }

  async get<T>(path: string): Promise<T> {
    const resp = await fetch(`${this.baseUrl}${path}`)
    if (!resp.ok) throw new ApiError(resp.status, await resp.text())
    return resp.json()
  }

  // post<T>, put<T>, delete<T>, etc.
}

export const apiClient = new ApiClient()

When served behind the castle gateway, the API base URL can use the gateway's proxy paths (e.g., /central-context/) instead of direct ports, avoiding CORS.

React Query setup

// src/lib/queryClient.ts
import { QueryClient } from "@tanstack/react-query"

export const queryClient = new QueryClient({
  defaultOptions: {
    queries: {
      staleTime: 5 * 60 * 1000,    // 5 minutes
      gcTime: 10 * 60 * 1000,      // 10 minutes
      retry: 1,
    },
  },
})
// src/services/api/hooks/useThings.ts
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"
import { apiClient } from "../client"

export function useThings() {
  return useQuery({
    queryKey: ["things"],
    queryFn: () => apiClient.get<Thing[]>("/things"),
  })
}

export function useCreateThing() {
  const qc = useQueryClient()
  return useMutation({
    mutationFn: (data: CreateThing) => apiClient.post<Thing>("/things", data),
    onSuccess: () => qc.invalidateQueries({ queryKey: ["things"] }),
  })
}

shadcn/ui setup

Initialize with the components.json config:

{
  "$schema": "https://ui.shadcn.com/schema.json",
  "style": "new-york",
  "rsc": false,
  "tsx": true,
  "tailwind": {
    "config": "",
    "css": "src/index.css",
    "baseColor": "neutral",
    "cssVariables": true
  },
  "aliases": {
    "components": "@/components",
    "utils": "@/lib/utils",
    "ui": "@/components/ui",
    "lib": "@/lib",
    "hooks": "@/hooks"
  },
  "iconLibrary": "lucide"
}

Add components as needed:

pnpm dlx shadcn@latest add button card dialog sidebar

Components are copied into src/components/ui/ as source files you own and can modify. They use Radix UI primitives underneath, with Tailwind for styling.

Dark mode

Use CSS custom properties with a .dark class on <html>:

/* src/index.css */
@import "tailwindcss";

:root {
  --background: oklch(1 0 0);
  --foreground: oklch(0.145 0 0);
  --primary: oklch(0.205 0 0);
  /* ... */
}

.dark {
  --background: oklch(0.145 0 0);
  --foreground: oklch(0.985 0 0);
  --primary: oklch(0.922 0 0);
  /* ... */
}

Toggle via a React context that persists the preference to localStorage.

Testing

// vitest.config.ts
import { defineConfig } from "vitest/config"
import path from "path"

export default defineConfig({
  test: {
    environment: "jsdom",
    setupFiles: ["src/test/setup.ts"],
    exclude: ["dist/**"],
  },
  resolve: {
    alias: { "@": path.resolve(__dirname, "./src") },
  },
})
pnpm run test              # Single run
pnpm run test:ui           # Interactive UI
pnpm run test:coverage     # With coverage report