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.
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-vitescaffolds from it and seeds the program's default dev-verb commands. An existing frontend adopted withcastle adddoesn't need this stack — it declares its owncommands:. See @docs/registry.md forcommands:,stack:(optional), andrepo:.
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/viteplugin) - 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 rootcastle-appat/). Castle'sreact-vitebuild passes that prefix asVITE_BASE, so a frontend that reads it (above) works at its subpath with no manualbase. Frontends that don't readVITE_BASEmust hand-setbaseto 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:
castle deployruns the program'sbuild.commands, then copies eachbuild.outputsdirectory from~/.castle/code/<name>/into~/.castle/artifacts/content/<name>/(core/src/castle_core/deploy.py,_copy_app_static).- The Caddyfile generator scans
~/.castle/artifacts/content/; any directory containing anindex.htmlis served as a SPA at a path prefix matching its name.castle-appis 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