Skip to main content

Full-Stack Monorepo

CodeLeash runs Vite and FastAPI as a single application. In development, both run concurrently with hot module replacement. In production, Vite builds static assets and FastAPI serves everything.

Dual-Server Architecture

The npm run dev command starts two processes via concurrently:

concurrently -n vite,uvicorn \
vite \
"uv run python main.py"

package.json

  • Vite (port 5173) serves JavaScript/CSS with HMR
  • Uvicorn (port 8000) serves HTML pages and API routes

In production (npm run build then uv run uvicorn main:app), Vite compiles assets into dist/ and FastAPI serves them directly using the Vite manifest for cache-busted URLs.

The render_page() Pattern

Every page follows the same flow: a FastAPI route gathers data, passes it to render_page(), which renders a Jinja2 template that mounts a React component.

Route Layer

@router.get("/", response_class=HTMLResponse)
async def index(
request: Request,
greeting_service: GreetingService = Depends(get_greeting_service),
) -> HTMLResponse:
greetings = await greeting_service.get_all()
initial_data = {
"greetings": [g.model_dump(mode="json") for g in greetings],
}
return render_page(
request, "src/roots/index.tsx",
title="CodeLeash", initial_data=initial_data,
)

app/routes/index.py

The route calls a service (injected via Depends()), serializes the result to a dict, and passes it as initial_data.

Template Layer

render_page() JSON-serializes the initial data into the template context:

def render_page(request, component_path, title, initial_data=None, ...):
initial_data_json = json.dumps(initial_data or {})
return templates.TemplateResponse(request, "page.html", {
"component_path": component_path,
"title": title,
"initial_data_json": initial_data_json,
})

app/core/templates.py

The page.html template contains the critical bridge:

<div
id="root"
data-initial="{{ initial_data_json | escape }}"
class="{{ root_css_class }}"
></div>
{{ vite_hmr_client(request) }} {{ vite_asset(component_path, request) }}

The initial data is embedded as a data-initial attribute on the root div --- HTML-escaped JSON that React reads on mount.

React Layer

createReactRoot() parses the data-initial attribute and wraps the component in providers:

export const createReactRoot = (ComponentClass: React.ComponentType) => {
const initializeRoot = () => {
const rootElement = document.getElementById('root');
const initialData = rootElement.dataset.initial;
const data = initialData ? JSON.parse(initialData) : {};

createRoot(rootElement).render(
<React.StrictMode>
<ErrorBoundary>
<InitialDataProvider data={data}>
{React.createElement(ComponentClass)}
</InitialDataProvider>
</ErrorBoundary>
</React.StrictMode>
);
};
// ...
};

src/roots/util.tsx

Each page's root file is minimal:

import Index from '../pages/Index';
import { createReactRoot } from './util';
createReactRoot(Index);

src/roots/index.tsx

Components access the data via a useInitialData() hook provided by InitialDataProvider.

Complete Data Flow

Route handler
→ service.get_all()
→ initial_data dict
→ render_page()
→ json.dumps(initial_data)
→ page.html template
→ data-initial="..." attribute
→ createReactRoot()
→ JSON.parse(dataset.initial)
→ InitialDataProvider
→ useInitialData() hook
→ Component renders

Vite Integration

The vite_loader.py module handles both development and production modes:

Development (ENVIRONMENT != "production"):

vite_hmr_client() builds the Vite dev server URL from the request hostname, so HMR works regardless of how the browser reaches the server:

def get_vite_server_url(request: Request | None = None) -> str:
hostname = request.headers.get("host").split(":")[0]
return f"{scheme}://{hostname}:{VITE_SERVER_PORT}/"

app/core/vite_loader.py

Production:

vite_asset() reads dist/.vite/manifest.json to resolve cache-busted file paths, CSS dependencies, and module preload hints:

manifest = parse_manifest()
manifest_entry = manifest[path]

# Add CSS, vendor imports, the script itself, and modulepreload tags
tags.append(generate_stylesheet_tag(urljoin(STATIC_PATH, css_path)))
tags.append(generate_script_tag(
urljoin(STATIC_PATH, manifest_entry["file"]), attrs=scripts_attrs,
))

app/core/vite_loader.py

# Development: script points at Vite server
<script type="module" src="http://localhost:5173/src/roots/index.tsx"></script>

# Production: script points at built asset
<script type="module" async defer src="/dist/assets/index-a1b2c3d4.js"></script>
<link rel="stylesheet" href="/dist/assets/index-e5f6g7h8.css" />

Type Safety: Pydantic to TypeScript

The npm run types command runs scripts/generate_types.py, which converts Pydantic models to TypeScript interfaces. A pre-commit hook (check-initial-data) verifies these types stay in sync, so the data-initial JSON and TypeScript types never drift apart.

Rollup Entry Points

Vite is configured with three entry points in vite.config.js:

rollupOptions: {
input: {
main: './src/main.ts', // Global CSS and shared code
app: './src/app.ts', // Application-wide scripts
index: './src/roots/index.tsx', // Page-specific root
},
},

Adding a new page means adding a new root file in src/roots/ and a corresponding entry in the Vite config.