Skip to content

Commit 012b811

Browse files
authored
WASM build running hadrian in the browser (#9)
* Snapshot * Use proper axum router * Snapshot * Snapshot * Snapshot * Simplify sqlite pattern * Serialize sqlite params better * Wasm fixes * Add model button visual cue * Nicer onboarding * Fix doctests * Review fixes * Add wasm build to CI * Update CLAUDE.md * Add link to wasm build in docs * Build fixes * Update greptile config * Build fixes * Fix schema tests * Review fixes * Address review comments
1 parent 1002838 commit 012b811

191 files changed

Lines changed: 8947 additions & 4047 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.github/workflows/ci.yml

Lines changed: 55 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,8 @@ jobs:
5252
run: cargo +nightly fmt -- --check
5353

5454
- name: Clippy
55-
run: cargo clippy --all-targets --all-features -- -D clippy::correctness -W clippy::style
55+
# Note: --all-features is not used because `wasm` is mutually exclusive with `server`
56+
run: cargo clippy --all-targets -- -D clippy::correctness -W clippy::style
5657

5758
- name: Install cargo-nextest
5859
uses: taiki-e/install-action@nextest
@@ -115,6 +116,59 @@ jobs:
115116
- name: Tests (integration)
116117
run: cargo test --no-default-features --features ${{ matrix.features }} -- --ignored
117118

119+
# WASM build check
120+
wasm-build:
121+
name: WASM Build
122+
runs-on: ubuntu-latest
123+
steps:
124+
- uses: actions/checkout@v4
125+
126+
- name: Install Rust toolchain
127+
uses: dtolnay/rust-toolchain@stable
128+
with:
129+
targets: wasm32-unknown-unknown
130+
131+
- name: Install wasm-pack
132+
run: cargo install wasm-pack@0.13.1 --locked
133+
134+
- name: Cache cargo
135+
uses: Swatinem/rust-cache@v2
136+
with:
137+
shared-key: wasm
138+
139+
- name: Create placeholder directories for rust-embed
140+
run: |
141+
mkdir -p ui/dist docs/out
142+
echo '<!DOCTYPE html><html><body>Placeholder</body></html>' > ui/dist/index.html
143+
echo '<!DOCTYPE html><html><body>Placeholder</body></html>' > docs/out/index.html
144+
145+
- name: Build WASM module
146+
run: ./scripts/build-wasm.sh --release
147+
148+
- name: Setup Node.js
149+
uses: actions/setup-node@v4
150+
with:
151+
node-version: "20"
152+
153+
- name: Setup pnpm
154+
uses: pnpm/action-setup@v4
155+
with:
156+
version: 9
157+
158+
- name: Install UI dependencies
159+
working-directory: ui
160+
run: pnpm install --frozen-lockfile
161+
162+
- name: Generate API client
163+
working-directory: ui
164+
run: pnpm run generate-api
165+
166+
- name: Build frontend (WASM mode)
167+
working-directory: ui
168+
run: pnpm build
169+
env:
170+
VITE_WASM_MODE: "true"
171+
118172
# Cross-platform builds
119173
cross-build:
120174
name: Cross Build (${{ matrix.target }})

.github/workflows/deploy-wasm.yml

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
name: Deploy WASM App
2+
3+
on:
4+
push:
5+
branches: [main]
6+
paths:
7+
- "src/**"
8+
- "ui/**"
9+
- "Cargo.toml"
10+
- "Cargo.lock"
11+
- "scripts/build-wasm.sh"
12+
- ".github/workflows/deploy-wasm.yml"
13+
workflow_dispatch:
14+
15+
concurrency:
16+
group: deploy-wasm
17+
cancel-in-progress: true
18+
19+
jobs:
20+
deploy:
21+
name: Build & Deploy
22+
runs-on: ubuntu-latest
23+
permissions:
24+
contents: read
25+
deployments: write
26+
steps:
27+
- uses: actions/checkout@v4
28+
29+
- name: Install Rust toolchain
30+
uses: dtolnay/rust-toolchain@stable
31+
with:
32+
targets: wasm32-unknown-unknown
33+
34+
- name: Install wasm-pack
35+
run: cargo install wasm-pack@0.13.1 --locked
36+
37+
- name: Cache cargo
38+
uses: Swatinem/rust-cache@v2
39+
with:
40+
shared-key: wasm
41+
42+
- name: Setup Node.js
43+
uses: actions/setup-node@v4
44+
with:
45+
node-version: "20"
46+
47+
- name: Setup pnpm
48+
uses: pnpm/action-setup@v4
49+
with:
50+
version: 9
51+
52+
- name: Get pnpm store directory
53+
shell: bash
54+
run: echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV
55+
56+
- name: Cache pnpm
57+
uses: actions/cache@v4
58+
with:
59+
path: ${{ env.STORE_PATH }}
60+
key: ${{ runner.os }}-pnpm-wasm-${{ hashFiles('ui/pnpm-lock.yaml') }}
61+
restore-keys: |
62+
${{ runner.os }}-pnpm-wasm-
63+
64+
- name: Install UI dependencies
65+
working-directory: ui
66+
run: pnpm install --frozen-lockfile
67+
68+
- name: Generate API client
69+
working-directory: ui
70+
run: pnpm run generate-api
71+
72+
- name: Build WASM module
73+
run: ./scripts/build-wasm.sh --release
74+
75+
- name: Build frontend (WASM mode)
76+
working-directory: ui
77+
run: pnpm build
78+
env:
79+
VITE_WASM_MODE: "true"
80+
81+
- name: Add headers for service worker
82+
run: |
83+
cat > ui/dist/_headers <<'EOF'
84+
/sw.js
85+
Service-Worker-Allowed: /
86+
Cache-Control: no-cache, no-store, must-revalidate
87+
EOF
88+
89+
- name: Deploy to Cloudflare Pages
90+
uses: cloudflare/wrangler-action@v3
91+
with:
92+
apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
93+
accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
94+
command: pages deploy ui/dist --project-name=hadrian

CLAUDE.md

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ Features:
2626
- Image generation, audio (TTS, transcription, translation)
2727
- Knowledge Bases / RAG: file upload, text extraction, chunking, vector search, re-ranking
2828
- Integrations: SQLite/Postgres, Redis, OpenTelemetry, Vault, S3
29+
- WASM build: runs entirely in the browser via service workers and sql.js (app.hadriangateway.com)
2930

3031
The backend is written in Rust and uses Axum for routing and middleware.
3132
The frontend is written in React and TypeScript, with TailwindCSS for styling.
@@ -84,13 +85,16 @@ Hierarchical feature profiles (default: `full`):
8485
- **`standard`** — minimal + Postgres, Redis, OTLP, Prometheus, SSO, CEL, doc extraction, OpenAPI docs, S3, secrets managers (AWS/Azure/GCP/Vault)
8586
- **`full`** — standard + SAML, Kreuzberg, ClamAV
8687
- **`headless`** — all `full` features except embedded assets (UI, docs, catalog). Used by `cargo install` and for deployments that serve the frontend separately.
88+
- **`wasm`** — Browser-only build targeting `wasm32-unknown-unknown`. OpenAI + Anthropic + Test providers, wasm-sqlite (sql.js FFI), no server/concurrency/CLI/JWT/SSO features. Built with `wasm-pack`.
8789

8890
```bash
8991
cargo build --no-default-features --features tiny # Smallest binary
9092
cargo build --no-default-features --features minimal # Fast compile
9193
cargo build --no-default-features --features standard # Typical deployment
9294
cargo build # Full (default)
9395
cargo build --no-default-features --features headless # Full features, no embedded assets
96+
./scripts/build-wasm.sh # WASM build (dev)
97+
./scripts/build-wasm.sh --release # WASM build (release)
9498
```
9599

96100
Run `hadrian features` to list enabled/disabled features at runtime. CI tests `minimal`, `standard`, and `headless` profiles; Windows uses `minimal` to avoid OpenSSL.
@@ -116,6 +120,7 @@ GitHub Actions workflow (`.github/workflows/ci.yml`) runs:
116120
- E2E tests (TypeScript/Playwright with testcontainers, needs Docker build)
117121
- OpenAPI conformance check
118122
- Documentation build
123+
- WASM build (compile to `wasm32-unknown-unknown` via `wasm-pack`, build frontend with `VITE_WASM_MODE=true`)
119124

120125
### Release Pipeline
121126

@@ -130,6 +135,12 @@ GitHub Actions workflow (`.github/workflows/release.yml`) triggers on version ta
130135
- Creates GitHub Release with archives and SHA256 checksums (tag push only)
131136
- Dry-run mode builds artifacts and prints a summary without creating a release
132137

138+
WASM deploy workflow (`.github/workflows/deploy-wasm.yml`):
139+
- Triggers on pushes to `main` touching `src/**`, `ui/**`, `Cargo.toml`, `Cargo.lock`, or `scripts/build-wasm.sh`
140+
- Builds WASM module + frontend with `VITE_WASM_MODE=true`
141+
- Deploys to Cloudflare Pages (app.hadriangateway.com)
142+
- Sets `Service-Worker-Allowed: /` and `Cache-Control: no-cache` headers on `sw.js`
143+
133144
Helm chart workflow (`.github/workflows/helm.yml`) runs:
134145
- `helm lint` (standard and strict mode)
135146
- `helm template` with matrix of configurations (PostgreSQL, Redis, Ingress, etc.)
@@ -215,6 +226,36 @@ Per-org SSO allows each organization to configure its own identity provider (OID
215226
4. **LLM Provider** forwards request, streams response
216227
5. **Usage Tracking** records tokens/cost asynchronously with full principal attribution (user, org, project, team, service account)
217228

229+
### WASM Build Architecture
230+
231+
The WASM build runs the full Hadrian Axum router inside a browser service worker, enabling a zero-backend deployment at app.hadriangateway.com.
232+
233+
**Request flow:**
234+
1. Service worker intercepts `fetch` events matching `/v1/`, `/admin/v1/`, `/health`, `/auth/`, `/api/`
235+
2. `web_sys::Request` is converted to `http::Request` (with `/api/v1/``/v1/` path rewriting)
236+
3. Request is dispatched through the same Axum `Router` used by the native server
237+
4. `http::Response` is converted back to `web_sys::Response`
238+
5. LLM API calls use `reqwest` which delegates to the browser's `fetch()` API
239+
240+
**Three-layer gating strategy:**
241+
1. **Cargo features** (`wasm` vs `server`) — Controls what modules/dependencies are included
242+
2. **`#[cfg(target_arch = "wasm32")]`** — Handles Send/Sync differences (`AssertSend`, `async_trait(?Send)`, `spawn_local` vs `tokio::spawn`)
243+
3. **`#[cfg(feature = "server")]`** / `#[cfg(feature = "concurrency")]`** — Gates server-only functionality (middleware layers, `TaskTracker`, `UsageLogBuffer`)
244+
245+
**Database:** `WasmSqlitePool` is a zero-size type; actual SQLite runs in JavaScript via sql.js. Queries cross the FFI boundary via `wasm_bindgen` extern functions. The `backend.rs` abstraction provides cfg-switched type aliases (`Pool`, `Row`, `BackendError`) and traits (`ColDecode`, `RowExt`) so SQLite repo code compiles against either `sqlx::SqlitePool` or `WasmSqlitePool` without changes.
246+
247+
**Persistence:** Database is persisted to IndexedDB with a debounced save (500ms) after write operations.
248+
249+
**Auth:** WASM mode uses `AuthMode::None` with a bootstrapped anonymous user and org. Permissive `AuthzContext` and `AdminAuth` extensions are injected as layers.
250+
251+
**Setup flow:** `WasmSetupGuard` detects if providers are configured; if not, shows a setup wizard (`WasmSetup`) supporting OpenRouter OAuth (PKCE), Ollama auto-detection, and manual API key entry for OpenAI/Anthropic/etc.
252+
253+
**Known limitations:**
254+
- Streaming responses are fully buffered (no real-time SSE token streaming for LLM calls)
255+
- No usage tracking (no `TaskTracker`/`UsageLogBuffer` in WASM)
256+
- No caching layer, rate limiting, or budget enforcement
257+
- Module service workers require Chrome 91+ / Edge 91+ (Firefox support may be limited)
258+
218259
### Document Processing Flow (RAG)
219260

220261
1. **File Upload** (`POST /v1/files`) — Store raw file in database
@@ -469,6 +510,17 @@ See `agent_instructions/adding_admin_endpoint.md` for implementation patterns (r
469510
- `src/validation/` — Response validation against OpenAI schema
470511
- `src/observability/siem/` — SIEM formatters
471512

513+
### Backend — WASM
514+
515+
- `src/wasm.rs` — WASM entry point: `HadrianGateway` struct, request/response conversion, router construction, default config
516+
- `src/compat.rs` — WASM compatibility: `AssertSend`, `WasmHandler`, `wasm_routing` module (drop-in replacements for `axum::routing`), `spawn_detached`, `impl_wasm_handler!` macro
517+
- `src/lib.rs` — Library exports (crate type `cdylib` + `rlib` for wasm-pack)
518+
- `src/db/wasm_sqlite/bridge.rs``wasm_bindgen` FFI to `globalThis.__hadrian_sqlite` (sql.js bridge)
519+
- `src/db/wasm_sqlite/types.rs``WasmParam`, `WasmValue`, `WasmRow`, `WasmDecode` trait with type conversions
520+
- `src/db/sqlite/backend.rs` — SQLite backend abstraction: cfg-switched `Pool`/`Row`/`BackendError` type aliases, `RowExt`/`ColDecode` traits for unified repo code
521+
- `src/middleware/types.rs` — Shared middleware types (`AuthzContext`, `AdminAuth`, `ClientInfo`) extracted from layers for WASM compatibility
522+
- `scripts/build-wasm.sh` — Build script (invokes `wasm-pack`, copies sql-wasm.wasm)
523+
472524
### Backend — Other
473525

474526
- `src/catalog/` — Model catalog registry
@@ -508,6 +560,17 @@ See `agent_instructions/adding_admin_endpoint.md` for implementation patterns (r
508560
- `ui/src/components/ToolExecution/` — Tool execution timeline UI
509561
- `ui/src/components/Artifact/` — Artifact rendering (charts, tables, images, code)
510562

563+
### Frontend — WASM / Service Worker
564+
565+
- `ui/src/service-worker/sw.ts` — Service worker: intercepts API calls, lazily initializes `HadrianGateway` WASM module, routes requests through Axum router
566+
- `ui/src/service-worker/sqlite-bridge.ts` — sql.js bridge: `globalThis.__hadrian_sqlite` with `init_database()`, `query()`, `execute()`, `execute_script()`; persists to IndexedDB with debounced save
567+
- `ui/src/service-worker/register.ts` — Service worker registration with `CLAIM` message handling for hard refreshes
568+
- `ui/src/service-worker/wasm.d.ts` — Type declarations for the WASM module exports
569+
- `ui/src/components/WasmSetup/WasmSetup.tsx` — Three-step setup wizard (welcome → providers → done) with OpenRouter OAuth, Ollama detection, manual API key entry
570+
- `ui/src/components/WasmSetup/WasmSetupGuard.tsx` — Guard component: auto-shows wizard when no providers configured, handles OAuth callback
571+
- `ui/src/components/WasmSetup/openrouter-oauth.ts` — OpenRouter OAuth PKCE flow (code verifier in sessionStorage)
572+
- `ui/src/routes/AppRoutes.tsx` — Routes extracted from App.tsx
573+
511574
### Frontend — Pages & Layout
512575

513576
- `ui/src/pages/studio/` — Studio feature (image gen, TTS, transcription)
@@ -561,6 +624,30 @@ pnpm test-storybook # Run Storybook tests with vitest
561624
pnpm openapi-ts # Regenerate from /api/openapi.json
562625
```
563626

627+
### WASM Frontend Development
628+
629+
The WASM mode is controlled by the `VITE_WASM_MODE=true` environment variable. When set:
630+
- The Vite dev server uses a custom service worker plugin instead of `VitePWA`
631+
- The proxy configuration is disabled (service worker handles API routing)
632+
- `main.tsx` registers the service worker before rendering React
633+
- `App.tsx` wraps the app in `WasmSetupGuard`
634+
635+
```bash
636+
# Build WASM module first (from repo root)
637+
./scripts/build-wasm.sh
638+
639+
# Then run frontend in WASM mode
640+
cd ui && VITE_WASM_MODE=true pnpm dev
641+
```
642+
643+
The service worker (`sw.ts`) is built separately from the Vite bundle using esbuild (via the custom `wasmServiceWorkerPlugin` in `vite.config.ts`). In dev mode it's compiled on each request; in production it's written to `dist/sw.js` during the `writeBundle` hook.
644+
645+
When modifying WASM-related code:
646+
- The `wasm_routing` module (`src/compat.rs`) provides drop-in replacements for `axum::routing::{get, post, put, patch, delete}` — route modules use cfg-switched imports
647+
- All async trait definitions use `#[cfg_attr(target_arch = "wasm32", async_trait(?Send))]` / `#[cfg_attr(not(target_arch = "wasm32"), async_trait)]`
648+
- The `backend.rs` abstraction means SQLite repo code is written once — modify repos normally and both native/WASM will compile
649+
- Server-only routes (multipart file upload, audio transcription/translation) are excluded with `#[cfg(feature = "server")]`
650+
564651
### Frontend Conventions
565652

566653
- Run the `./scripts/generate-openapi.sh` script to generate the OpenAPI client

0 commit comments

Comments
 (0)