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.
355 lines
9.8 KiB
Markdown
355 lines
9.8 KiB
Markdown
# 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
|
|
|
|
```bash
|
|
# 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
|
|
|
|
```ts
|
|
// 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
|
|
|
|
```bash
|
|
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.
|
|
|
|
```yaml
|
|
# 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](#serving-with-caddy) below.
|
|
|
|
For development with Vite's dev server, add a service entry:
|
|
|
|
```yaml
|
|
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`):
|
|
|
|
```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_`:
|
|
|
|
```bash
|
|
# .env
|
|
VITE_API_BASE_URL=http://localhost:9001
|
|
```
|
|
|
|
```ts
|
|
// 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
|
|
|
|
```ts
|
|
// 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,
|
|
},
|
|
},
|
|
})
|
|
```
|
|
|
|
```ts
|
|
// 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:
|
|
|
|
```json
|
|
{
|
|
"$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:
|
|
|
|
```bash
|
|
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>`:
|
|
|
|
```css
|
|
/* 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
|
|
|
|
```ts
|
|
// 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") },
|
|
},
|
|
})
|
|
```
|
|
|
|
```bash
|
|
pnpm run test # Single run
|
|
pnpm run test:ui # Interactive UI
|
|
pnpm run test:coverage # With coverage report
|
|
```
|