diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index b53b1d8..e2fc866 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -52,7 +52,8 @@ jobs:
run: cargo +nightly fmt -- --check
- name: Clippy
- run: cargo clippy --all-targets --all-features -- -D clippy::correctness -W clippy::style
+ # Note: --all-features is not used because `wasm` is mutually exclusive with `server`
+ run: cargo clippy --all-targets -- -D clippy::correctness -W clippy::style
- name: Install cargo-nextest
uses: taiki-e/install-action@nextest
@@ -115,6 +116,59 @@ jobs:
- name: Tests (integration)
run: cargo test --no-default-features --features ${{ matrix.features }} -- --ignored
+ # WASM build check
+ wasm-build:
+ name: WASM Build
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+
+ - name: Install Rust toolchain
+ uses: dtolnay/rust-toolchain@stable
+ with:
+ targets: wasm32-unknown-unknown
+
+ - name: Install wasm-pack
+ run: cargo install wasm-pack@0.13.1 --locked
+
+ - name: Cache cargo
+ uses: Swatinem/rust-cache@v2
+ with:
+ shared-key: wasm
+
+ - name: Create placeholder directories for rust-embed
+ run: |
+ mkdir -p ui/dist docs/out
+ echo '
Placeholder' > ui/dist/index.html
+ echo 'Placeholder' > docs/out/index.html
+
+ - name: Build WASM module
+ run: ./scripts/build-wasm.sh --release
+
+ - name: Setup Node.js
+ uses: actions/setup-node@v4
+ with:
+ node-version: "20"
+
+ - name: Setup pnpm
+ uses: pnpm/action-setup@v4
+ with:
+ version: 9
+
+ - name: Install UI dependencies
+ working-directory: ui
+ run: pnpm install --frozen-lockfile
+
+ - name: Generate API client
+ working-directory: ui
+ run: pnpm run generate-api
+
+ - name: Build frontend (WASM mode)
+ working-directory: ui
+ run: pnpm build
+ env:
+ VITE_WASM_MODE: "true"
+
# Cross-platform builds
cross-build:
name: Cross Build (${{ matrix.target }})
diff --git a/.github/workflows/deploy-wasm.yml b/.github/workflows/deploy-wasm.yml
new file mode 100644
index 0000000..8144270
--- /dev/null
+++ b/.github/workflows/deploy-wasm.yml
@@ -0,0 +1,94 @@
+name: Deploy WASM App
+
+on:
+ push:
+ branches: [main]
+ paths:
+ - "src/**"
+ - "ui/**"
+ - "Cargo.toml"
+ - "Cargo.lock"
+ - "scripts/build-wasm.sh"
+ - ".github/workflows/deploy-wasm.yml"
+ workflow_dispatch:
+
+concurrency:
+ group: deploy-wasm
+ cancel-in-progress: true
+
+jobs:
+ deploy:
+ name: Build & Deploy
+ runs-on: ubuntu-latest
+ permissions:
+ contents: read
+ deployments: write
+ steps:
+ - uses: actions/checkout@v4
+
+ - name: Install Rust toolchain
+ uses: dtolnay/rust-toolchain@stable
+ with:
+ targets: wasm32-unknown-unknown
+
+ - name: Install wasm-pack
+ run: cargo install wasm-pack@0.13.1 --locked
+
+ - name: Cache cargo
+ uses: Swatinem/rust-cache@v2
+ with:
+ shared-key: wasm
+
+ - name: Setup Node.js
+ uses: actions/setup-node@v4
+ with:
+ node-version: "20"
+
+ - name: Setup pnpm
+ uses: pnpm/action-setup@v4
+ with:
+ version: 9
+
+ - name: Get pnpm store directory
+ shell: bash
+ run: echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV
+
+ - name: Cache pnpm
+ uses: actions/cache@v4
+ with:
+ path: ${{ env.STORE_PATH }}
+ key: ${{ runner.os }}-pnpm-wasm-${{ hashFiles('ui/pnpm-lock.yaml') }}
+ restore-keys: |
+ ${{ runner.os }}-pnpm-wasm-
+
+ - name: Install UI dependencies
+ working-directory: ui
+ run: pnpm install --frozen-lockfile
+
+ - name: Generate API client
+ working-directory: ui
+ run: pnpm run generate-api
+
+ - name: Build WASM module
+ run: ./scripts/build-wasm.sh --release
+
+ - name: Build frontend (WASM mode)
+ working-directory: ui
+ run: pnpm build
+ env:
+ VITE_WASM_MODE: "true"
+
+ - name: Add headers for service worker
+ run: |
+ cat > ui/dist/_headers <<'EOF'
+ /sw.js
+ Service-Worker-Allowed: /
+ Cache-Control: no-cache, no-store, must-revalidate
+ EOF
+
+ - name: Deploy to Cloudflare Pages
+ uses: cloudflare/wrangler-action@v3
+ with:
+ apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
+ accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
+ command: pages deploy ui/dist --project-name=hadrian
diff --git a/.greptile/config.json b/.greptile/config.json
new file mode 100644
index 0000000..b0448dd
--- /dev/null
+++ b/.greptile/config.json
@@ -0,0 +1,7 @@
+{
+ "strictness": 1,
+ "commentTypes": ["syntax", "logic", "style", "info"],
+ "fileChangeLimit": 300,
+ "triggerOnUpdates": true,
+ "fixWithAI": true
+}
diff --git a/CLAUDE.md b/CLAUDE.md
index f54bd09..3968af7 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -26,6 +26,7 @@ Features:
- Image generation, audio (TTS, transcription, translation)
- Knowledge Bases / RAG: file upload, text extraction, chunking, vector search, re-ranking
- Integrations: SQLite/Postgres, Redis, OpenTelemetry, Vault, S3
+- WASM build: runs entirely in the browser via service workers and sql.js (app.hadriangateway.com)
The backend is written in Rust and uses Axum for routing and middleware.
The frontend is written in React and TypeScript, with TailwindCSS for styling.
@@ -84,6 +85,7 @@ Hierarchical feature profiles (default: `full`):
- **`standard`** — minimal + Postgres, Redis, OTLP, Prometheus, SSO, CEL, doc extraction, OpenAPI docs, S3, secrets managers (AWS/Azure/GCP/Vault)
- **`full`** — standard + SAML, Kreuzberg, ClamAV
- **`headless`** — all `full` features except embedded assets (UI, docs, catalog). Used by `cargo install` and for deployments that serve the frontend separately.
+- **`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`.
```bash
cargo build --no-default-features --features tiny # Smallest binary
@@ -91,6 +93,8 @@ cargo build --no-default-features --features minimal # Fast compile
cargo build --no-default-features --features standard # Typical deployment
cargo build # Full (default)
cargo build --no-default-features --features headless # Full features, no embedded assets
+./scripts/build-wasm.sh # WASM build (dev)
+./scripts/build-wasm.sh --release # WASM build (release)
```
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:
- E2E tests (TypeScript/Playwright with testcontainers, needs Docker build)
- OpenAPI conformance check
- Documentation build
+- WASM build (compile to `wasm32-unknown-unknown` via `wasm-pack`, build frontend with `VITE_WASM_MODE=true`)
### Release Pipeline
@@ -130,6 +135,12 @@ GitHub Actions workflow (`.github/workflows/release.yml`) triggers on version ta
- Creates GitHub Release with archives and SHA256 checksums (tag push only)
- Dry-run mode builds artifacts and prints a summary without creating a release
+WASM deploy workflow (`.github/workflows/deploy-wasm.yml`):
+- Triggers on pushes to `main` touching `src/**`, `ui/**`, `Cargo.toml`, `Cargo.lock`, or `scripts/build-wasm.sh`
+- Builds WASM module + frontend with `VITE_WASM_MODE=true`
+- Deploys to Cloudflare Pages (app.hadriangateway.com)
+- Sets `Service-Worker-Allowed: /` and `Cache-Control: no-cache` headers on `sw.js`
+
Helm chart workflow (`.github/workflows/helm.yml`) runs:
- `helm lint` (standard and strict mode)
- `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
4. **LLM Provider** forwards request, streams response
5. **Usage Tracking** records tokens/cost asynchronously with full principal attribution (user, org, project, team, service account)
+### WASM Build Architecture
+
+The WASM build runs the full Hadrian Axum router inside a browser service worker, enabling a zero-backend deployment at app.hadriangateway.com.
+
+**Request flow:**
+1. Service worker intercepts `fetch` events matching `/v1/`, `/admin/v1/`, `/health`, `/auth/`, `/api/`
+2. `web_sys::Request` is converted to `http::Request` (with `/api/v1/` → `/v1/` path rewriting)
+3. Request is dispatched through the same Axum `Router` used by the native server
+4. `http::Response` is converted back to `web_sys::Response`
+5. LLM API calls use `reqwest` which delegates to the browser's `fetch()` API
+
+**Three-layer gating strategy:**
+1. **Cargo features** (`wasm` vs `server`) — Controls what modules/dependencies are included
+2. **`#[cfg(target_arch = "wasm32")]`** — Handles Send/Sync differences (`AssertSend`, `async_trait(?Send)`, `spawn_local` vs `tokio::spawn`)
+3. **`#[cfg(feature = "server")]`** / `#[cfg(feature = "concurrency")]`** — Gates server-only functionality (middleware layers, `TaskTracker`, `UsageLogBuffer`)
+
+**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.
+
+**Persistence:** Database is persisted to IndexedDB with a debounced save (500ms) after write operations.
+
+**Auth:** WASM mode uses `AuthMode::None` with a bootstrapped anonymous user and org. Permissive `AuthzContext` and `AdminAuth` extensions are injected as layers.
+
+**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.
+
+**Known limitations:**
+- Streaming responses are fully buffered (no real-time SSE token streaming for LLM calls)
+- No usage tracking (no `TaskTracker`/`UsageLogBuffer` in WASM)
+- No caching layer, rate limiting, or budget enforcement
+- Module service workers require Chrome 91+ / Edge 91+ (Firefox support may be limited)
+
### Document Processing Flow (RAG)
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
- `src/validation/` — Response validation against OpenAI schema
- `src/observability/siem/` — SIEM formatters
+### Backend — WASM
+
+- `src/wasm.rs` — WASM entry point: `HadrianGateway` struct, request/response conversion, router construction, default config
+- `src/compat.rs` — WASM compatibility: `AssertSend`, `WasmHandler`, `wasm_routing` module (drop-in replacements for `axum::routing`), `spawn_detached`, `impl_wasm_handler!` macro
+- `src/lib.rs` — Library exports (crate type `cdylib` + `rlib` for wasm-pack)
+- `src/db/wasm_sqlite/bridge.rs` — `wasm_bindgen` FFI to `globalThis.__hadrian_sqlite` (sql.js bridge)
+- `src/db/wasm_sqlite/types.rs` — `WasmParam`, `WasmValue`, `WasmRow`, `WasmDecode` trait with type conversions
+- `src/db/sqlite/backend.rs` — SQLite backend abstraction: cfg-switched `Pool`/`Row`/`BackendError` type aliases, `RowExt`/`ColDecode` traits for unified repo code
+- `src/middleware/types.rs` — Shared middleware types (`AuthzContext`, `AdminAuth`, `ClientInfo`) extracted from layers for WASM compatibility
+- `scripts/build-wasm.sh` — Build script (invokes `wasm-pack`, copies sql-wasm.wasm)
+
### Backend — Other
- `src/catalog/` — Model catalog registry
@@ -508,6 +560,17 @@ See `agent_instructions/adding_admin_endpoint.md` for implementation patterns (r
- `ui/src/components/ToolExecution/` — Tool execution timeline UI
- `ui/src/components/Artifact/` — Artifact rendering (charts, tables, images, code)
+### Frontend — WASM / Service Worker
+
+- `ui/src/service-worker/sw.ts` — Service worker: intercepts API calls, lazily initializes `HadrianGateway` WASM module, routes requests through Axum router
+- `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
+- `ui/src/service-worker/register.ts` — Service worker registration with `CLAIM` message handling for hard refreshes
+- `ui/src/service-worker/wasm.d.ts` — Type declarations for the WASM module exports
+- `ui/src/components/WasmSetup/WasmSetup.tsx` — Three-step setup wizard (welcome → providers → done) with OpenRouter OAuth, Ollama detection, manual API key entry
+- `ui/src/components/WasmSetup/WasmSetupGuard.tsx` — Guard component: auto-shows wizard when no providers configured, handles OAuth callback
+- `ui/src/components/WasmSetup/openrouter-oauth.ts` — OpenRouter OAuth PKCE flow (code verifier in sessionStorage)
+- `ui/src/routes/AppRoutes.tsx` — Routes extracted from App.tsx
+
### Frontend — Pages & Layout
- `ui/src/pages/studio/` — Studio feature (image gen, TTS, transcription)
@@ -561,6 +624,30 @@ pnpm test-storybook # Run Storybook tests with vitest
pnpm openapi-ts # Regenerate from /api/openapi.json
```
+### WASM Frontend Development
+
+The WASM mode is controlled by the `VITE_WASM_MODE=true` environment variable. When set:
+- The Vite dev server uses a custom service worker plugin instead of `VitePWA`
+- The proxy configuration is disabled (service worker handles API routing)
+- `main.tsx` registers the service worker before rendering React
+- `App.tsx` wraps the app in `WasmSetupGuard`
+
+```bash
+# Build WASM module first (from repo root)
+./scripts/build-wasm.sh
+
+# Then run frontend in WASM mode
+cd ui && VITE_WASM_MODE=true pnpm dev
+```
+
+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.
+
+When modifying WASM-related code:
+- 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
+- All async trait definitions use `#[cfg_attr(target_arch = "wasm32", async_trait(?Send))]` / `#[cfg_attr(not(target_arch = "wasm32"), async_trait)]`
+- The `backend.rs` abstraction means SQLite repo code is written once — modify repos normally and both native/WASM will compile
+- Server-only routes (multipart file upload, audio transcription/translation) are excluded with `#[cfg(feature = "server")]`
+
### Frontend Conventions
- Run the `./scripts/generate-openapi.sh` script to generate the OpenAPI client
diff --git a/Cargo.lock b/Cargo.lock
index 9f7c288..0222250 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -3541,6 +3541,7 @@ dependencies = [
"flate2",
"futures",
"futures-util",
+ "getrandom 0.2.17",
"google-cloud-auth 0.17.2",
"google-cloud-secretmanager-v1",
"google-cloud-token",
@@ -3550,6 +3551,7 @@ dependencies = [
"http 1.4.0",
"http-body-util",
"ipnet",
+ "js-sys",
"jsonschema",
"jsonwebtoken",
"kreuzberg",
@@ -3573,6 +3575,7 @@ dependencies = [
"samael",
"schemars 0.8.22",
"serde",
+ "serde-wasm-bindgen",
"serde_json",
"serial_test",
"sha2",
@@ -3595,6 +3598,7 @@ dependencies = [
"tracing",
"tracing-opentelemetry",
"tracing-subscriber",
+ "tracing-wasm",
"unicode-normalization",
"url",
"utoipa",
@@ -3602,6 +3606,10 @@ dependencies = [
"uuid",
"validator",
"vaultrs",
+ "wasm-bindgen",
+ "wasm-bindgen-futures",
+ "wasm-streams",
+ "web-sys",
"wiremock",
]
@@ -6784,9 +6792,9 @@ dependencies = [
[[package]]
name = "quinn-proto"
-version = "0.11.13"
+version = "0.11.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "f1906b49b0c3bc04b5fe5d86a77925ae6524a19b816ae38ce1e426255f1d8a31"
+checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098"
dependencies = [
"aws-lc-rs",
"bytes",
@@ -7908,6 +7916,17 @@ dependencies = [
"serde_derive",
]
+[[package]]
+name = "serde-wasm-bindgen"
+version = "0.6.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8302e169f0eddcc139c70f139d19d6467353af16f9fce27e8c30158036a1e16b"
+dependencies = [
+ "js-sys",
+ "serde",
+ "wasm-bindgen",
+]
+
[[package]]
name = "serde_core"
version = "1.0.228"
@@ -9480,6 +9499,17 @@ dependencies = [
"tracing-serde",
]
+[[package]]
+name = "tracing-wasm"
+version = "0.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4575c663a174420fa2d78f4108ff68f65bf2fbb7dd89f33749b6e826b3626e07"
+dependencies = [
+ "tracing",
+ "tracing-subscriber",
+ "wasm-bindgen",
+]
+
[[package]]
name = "try-lock"
version = "0.2.5"
diff --git a/Cargo.toml b/Cargo.toml
index 4225448..28dc294 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -20,11 +20,62 @@ include = [
"README.md",
]
+[lib]
+name = "hadrian"
+path = "src/lib.rs"
+crate-type = ["cdylib", "rlib"]
+
[features]
default = ["full"]
+# ─────────────────────────────────────────────────────────────────────────────
+# Execution environment features
+# ─────────────────────────────────────────────────────────────────────────────
+
+# CLI argument parsing (clap)
+cli = ["dep:clap"]
+
+# Native server: socket binding, filesystem serving, config file loading
+server = [
+ "cli",
+ "native-http",
+ "native-async",
+ "concurrency",
+ "jwt",
+ "dep:toml",
+ "dep:tracing-subscriber",
+ "axum/tokio",
+ "axum/http1",
+ "axum/http2",
+ "axum/ws",
+ "axum/multipart",
+ "tower-http/fs",
+ "tokio/full",
+ "tokio-util/rt",
+]
+
+# Native HTTP client features (TLS, HTTP/2)
+native-http = [
+ "reqwest/rustls-tls",
+ "reqwest/http2",
+ "reqwest/charset",
+ "reqwest/macos-system-configuration",
+]
+
+# Native async runtime features (filesystem, networking, signals)
+native-async = ["tokio/net", "tokio/fs", "tokio/signal", "tokio/process"]
+
+# High-performance concurrent data structures (parking_lot, crossbeam)
+concurrency = ["dep:parking_lot", "dep:crossbeam-channel"]
+
+# JWT authentication (jsonwebtoken requires native crypto)
+jwt = ["dep:jsonwebtoken"]
+
+# ─────────────────────────────────────────────────────────────────────────────
# Meta profiles for different deployment scenarios
-tiny = ["provider-openai", "provider-test"]
+# ─────────────────────────────────────────────────────────────────────────────
+
+tiny = ["server", "provider-openai", "provider-test"]
minimal = [
"tiny",
"database-sqlite",
@@ -68,6 +119,7 @@ full = [
# Suitable for `cargo install` users who don't have build artifacts,
# and for deployments that serve the frontend separately.
headless = [
+ "server",
"cel",
"csv-export",
"database-postgres",
@@ -98,6 +150,28 @@ headless = [
"wizard",
]
+# ─────────────────────────────────────────────────────────────────────────────
+# WASM browser build — runs entirely in the browser via service worker
+# ─────────────────────────────────────────────────────────────────────────────
+
+wasm = [
+ "provider-openai",
+ "provider-anthropic",
+ "provider-test",
+ "database-wasm-sqlite",
+ "dep:wasm-bindgen",
+ "dep:wasm-bindgen-futures",
+ "dep:js-sys",
+ "dep:web-sys",
+ "dep:serde-wasm-bindgen",
+ "dep:wasm-streams",
+ "dep:tracing-wasm",
+]
+
+# ─────────────────────────────────────────────────────────────────────────────
+# Component features
+# ─────────────────────────────────────────────────────────────────────────────
+
# Providers (always-available: openai, anthropic, test)
provider-openai = []
provider-anthropic = []
@@ -139,6 +213,11 @@ embed-catalog = ["dep:rust-embed"]
# Databases
database-sqlite = ["dep:sqlx", "sqlx/sqlite"]
database-postgres = ["dep:sqlx", "sqlx/postgres"]
+database-wasm-sqlite = [
+ "dep:wasm-bindgen",
+ "dep:js-sys",
+ "dep:serde-wasm-bindgen",
+]
# Authorization
cel = ["dep:cel-interpreter"]
@@ -175,15 +254,17 @@ otlp = [
virus-scan = ["dep:clamav-client"]
[dependencies]
-# Mandatory dependencies
+# ─────────────────────────────────────────────────────────────────────────────
+# Always-required dependencies (work on both native and wasm32)
+# ─────────────────────────────────────────────────────────────────────────────
async-trait = "0.1.89"
-axum = { version = "0.8.7", features = ["ws", "multipart"] }
+axum = { version = "0.8.7", default-features = false, features = [
+ "json", "matched-path", "original-uri", "query", "form", "tracing",
+] }
axum-valid = "0.24.0"
base64 = "0.22"
bytes = "1.11.0"
chrono = { version = "0.4.39", features = ["serde"] }
-clap = { version = "4.5.53", features = ["derive"] }
-crossbeam-channel = "0.5"
dashmap = "6.0"
futures = "0.3.31"
futures-util = "0.3.31"
@@ -191,32 +272,61 @@ hex = "0.4"
http = "1.3.1"
http-body-util = "0.1.3"
ipnet = "2"
-jsonwebtoken = { version = "9", features = ["use_pem"] }
once_cell = "1.21"
-parking_lot = "0.12.5"
rand = "0.8"
regex = "1.12.2"
-reqwest = { version = "0.12.24", default-features = false, features = ["json", "stream", "rustls-tls", "http2", "charset", "macos-system-configuration", "multipart"] }
+reqwest = { version = "0.12.24", default-features = false, features = [
+ "json", "stream", "multipart",
+] }
rust_decimal = { version = "1.40.0", features = ["macros"] }
serde = { version = "1.0.228", features = ["derive"] }
serde_json = "1.0.145"
sha2 = "0.10"
subtle = "2.6.1"
thiserror = "2.0.17"
-tokio = { version = "1.48.0", features = ["full"] }
-tokio-util = { version = "0.7.17", features = ["rt"] }
-toml = "0.9.8"
+tokio = { version = "1.48.0", features = [
+ "rt", "macros", "sync", "time", "io-util",
+] }
+tokio-util = { version = "0.7.17" }
tower = "0.5.2"
tower-cookies = "0.11"
-tower-http = { version = "0.6", features = ["cors", "trace", "request-id", "propagate-header", "fs", "set-header", "limit"] }
+tower-http = { version = "0.6", features = [
+ "cors", "trace", "request-id", "propagate-header", "set-header", "limit",
+] }
tracing = "0.1.41"
-tracing-subscriber = { version = "0.3.20", features = ["env-filter", "json"] }
unicode-normalization = "0.1"
url = "2"
uuid = { version = "1.18.1", features = ["v4", "v5", "serde"] }
validator = { version = "0.20.0", features = ["derive"] }
-# Optional dependencies
+# ─────────────────────────────────────────────────────────────────────────────
+# Optional: native-only dependencies (made optional for WASM compatibility)
+# ─────────────────────────────────────────────────────────────────────────────
+clap = { version = "4.5.53", features = ["derive"], optional = true }
+crossbeam-channel = { version = "0.5", optional = true }
+jsonwebtoken = { version = "9", features = ["use_pem"], optional = true }
+parking_lot = { version = "0.12.5", optional = true }
+toml = { version = "0.9.8", optional = true }
+tracing-subscriber = { version = "0.3.20", features = ["env-filter", "json"], optional = true }
+
+# ─────────────────────────────────────────────────────────────────────────────
+# Optional: WASM-specific dependencies
+# ─────────────────────────────────────────────────────────────────────────────
+js-sys = { version = "0.3", optional = true }
+serde-wasm-bindgen = { version = "0.6", optional = true }
+tracing-wasm = { version = "0.2", optional = true }
+wasm-bindgen = { version = "0.2", optional = true }
+wasm-bindgen-futures = { version = "0.4", optional = true }
+wasm-streams = { version = "0.4", optional = true }
+web-sys = { version = "0.3", features = [
+ "Headers", "Request", "RequestInit", "Response", "ResponseInit",
+ "ReadableStream", "ServiceWorkerGlobalScope", "FetchEvent",
+ "ExtendableEvent", "Url",
+], optional = true }
+
+# ─────────────────────────────────────────────────────────────────────────────
+# Optional: feature-gated dependencies
+# ─────────────────────────────────────────────────────────────────────────────
augurs = { version = "0.10.1", features = ["ets", "mstl", "forecaster"], optional = true }
aws-config = { version = "1", features = ["behavior-version-latest"], optional = true }
aws-credential-types = { version = "1", features = ["hardcoded-credentials"], optional = true }
@@ -262,6 +372,13 @@ utoipa = { version = "5", features = ["chrono", "uuid", "axum_extras"], optional
utoipa-scalar = { version = "0.3", features = ["axum"], optional = true }
vaultrs = { version = "0.7.4", features = ["rustls"], optional = true }
+# ─────────────────────────────────────────────────────────────────────────────
+# Target-specific: WASM needs JS-backed getrandom for uuid/rand
+# ─────────────────────────────────────────────────────────────────────────────
+[target.'cfg(target_arch = "wasm32")'.dependencies]
+getrandom = { version = "0.2", features = ["js"] }
+uuid = { version = "1.18.1", features = ["v4", "v5", "serde", "js"] }
+
[dev-dependencies]
rstest = "0.24"
serial_test = "3.2"
diff --git a/Dockerfile b/Dockerfile
index f53f6c7..27d4d68 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -71,7 +71,8 @@ COPY Cargo.toml Cargo.lock ./
# Create dummy src to build dependencies
RUN mkdir -p src/bin \
&& echo "fn main() {}" > src/main.rs \
- && echo "fn main() {}" > src/bin/record_fixtures.rs
+ && echo "fn main() {}" > src/bin/record_fixtures.rs \
+ && echo "" > src/lib.rs
# Build dependencies only (cached layer)
RUN --mount=type=cache,target=/usr/local/cargo/registry \
diff --git a/docs/app/(home)/page.tsx b/docs/app/(home)/page.tsx
index 2f5a5bd..609826c 100644
--- a/docs/app/(home)/page.tsx
+++ b/docs/app/(home)/page.tsx
@@ -243,11 +243,19 @@ export default function HomePage() {
MIT and Apache-2.0 licensed. No proprietary code, no upgrade tiers, no restrictions.
-
+ Try in Browser
+
+
Get Started
Building Hadrian WASM module (profile: $PROFILE)"
+
+# Ensure wasm32 target is installed
+if ! rustup target list --installed | grep -q wasm32-unknown-unknown; then
+ echo "==> Installing wasm32-unknown-unknown target"
+ rustup target add wasm32-unknown-unknown
+fi
+
+# Build with wasm-pack
+# --dev skips wasm-opt (avoids bulk-memory feature mismatch)
+# --release runs wasm-opt for size optimization
+cd "$ROOT_DIR"
+wasm-pack build \
+ --target web \
+ --out-dir "$OUT_DIR" \
+ $WASM_PACK_FLAGS \
+ -- \
+ --no-default-features \
+ --features wasm
+
+# Copy sql.js WASM binary alongside the Hadrian WASM output.
+# The sqlite-bridge.ts service worker code loads it from /wasm/sql-wasm.wasm.
+SQLJS_WASM="$ROOT_DIR/ui/node_modules/sql.js/dist/sql-wasm.wasm"
+if [ -f "$SQLJS_WASM" ]; then
+ cp "$SQLJS_WASM" "$OUT_DIR/sql-wasm.wasm"
+ echo "==> Copied sql-wasm.wasm to $OUT_DIR"
+else
+ echo "WARNING: sql-wasm.wasm not found at $SQLJS_WASM — run 'pnpm install' in ui/ first"
+fi
+
+echo "==> WASM build complete: $OUT_DIR"
+echo " Files:"
+ls -lh "$OUT_DIR"/*.wasm "$OUT_DIR"/*.js 2>/dev/null || true
diff --git a/src/app.rs b/src/app.rs
index 1a76833..dae0bca 100644
--- a/src/app.rs
+++ b/src/app.rs
@@ -6,22 +6,25 @@ use axum::Json;
use axum::response::Response;
#[cfg(any(feature = "sso", feature = "saml"))]
use axum::routing::post;
+#[cfg(feature = "server")]
use axum::{Router, routing::get};
#[cfg(any(feature = "embed-ui", feature = "embed-docs"))]
use axum::{body::Body, response::IntoResponse};
#[cfg(any(feature = "embed-ui", feature = "embed-docs"))]
use http::StatusCode;
+#[cfg(any(feature = "server", feature = "embed-ui", feature = "embed-docs"))]
use http::header;
use reqwest::Client;
#[cfg(any(feature = "embed-ui", feature = "embed-docs"))]
use rust_embed::Embed;
+#[cfg(feature = "server")]
use tokio_util::task::TaskTracker;
-use tower_http::{
- limit::RequestBodyLimitLayer,
- services::{ServeDir, ServeFile},
- set_header::SetResponseHeaderLayer,
- trace::TraceLayer,
-};
+#[cfg(feature = "server")]
+use tower_http::services::{ServeDir, ServeFile};
+#[cfg(feature = "server")]
+use tower_http::set_header::SetResponseHeaderLayer;
+#[cfg(feature = "server")]
+use tower_http::{limit::RequestBodyLimitLayer, trace::TraceLayer};
#[cfg(feature = "utoipa")]
use utoipa_scalar::{Scalar, Servable};
@@ -31,9 +34,11 @@ use crate::observability;
use crate::openapi;
use crate::{
auth, authz, cache, catalog, config, db, dlq, events, guardrails,
- init::create_provider_instance, jobs, middleware, models, pricing, providers, routes, secrets,
- services, usage_buffer,
+ init::create_provider_instance, jobs, models, pricing, providers, secrets, services,
+ usage_buffer,
};
+#[cfg(feature = "server")]
+use crate::{middleware, routes};
/// Embedded UI assets from ui/dist directory.
/// These are compiled into the binary at build time.
@@ -89,12 +94,14 @@ fn serve_embedded_file(path: &str) -> Response {
}
/// Add routes for serving static UI files
+#[cfg(feature = "server")]
fn add_ui_routes(app: Router
, config: &config::GatewayConfig) -> Router {
use config::AssetSource;
let ui_path = config.ui.path.trim_end_matches('/');
match &config.ui.assets.source {
+ #[cfg(feature = "server")]
AssetSource::Filesystem { path } => {
let assets_path = std::path::Path::new(path);
let index_file = assets_path.join("index.html");
@@ -128,6 +135,13 @@ fn add_ui_routes(app: Router, config: &config::GatewayConfig) -> Route
app.nest_service(ui_path, serve_dir_with_headers)
}
}
+ #[cfg(not(feature = "server"))]
+ AssetSource::Filesystem { .. } => {
+ tracing::warn!(
+ "Filesystem UI assets requested but 'server' feature is not enabled, skipping"
+ );
+ app
+ }
#[cfg(feature = "embed-ui")]
AssetSource::Embedded => {
tracing::info!(ui_path = %ui_path, "Serving UI from embedded assets");
@@ -218,12 +232,14 @@ fn build_docs_response(content: rust_embed::EmbeddedFile) -> Response {
}
/// Add routes for serving static documentation files
+#[cfg(feature = "server")]
fn add_docs_routes(app: Router, config: &config::GatewayConfig) -> Router {
use config::AssetSource;
let docs_path = config.docs.path.trim_end_matches('/');
match &config.docs.assets.source {
+ #[cfg(feature = "server")]
AssetSource::Filesystem { path } => {
let assets_path = std::path::Path::new(path);
@@ -251,6 +267,13 @@ fn add_docs_routes(app: Router, config: &config::GatewayConfig) -> Rou
// Docs are always at a specific path (never root)
app.nest_service(docs_path, serve_dir_with_headers)
}
+ #[cfg(not(feature = "server"))]
+ AssetSource::Filesystem { .. } => {
+ tracing::warn!(
+ "Filesystem docs assets requested but 'server' feature is not enabled, skipping"
+ );
+ app
+ }
#[cfg(feature = "embed-docs")]
AssetSource::Embedded => {
tracing::info!(docs_path = %docs_path, "Serving documentation from embedded assets");
@@ -295,6 +318,7 @@ pub struct AppState {
pub provider_health: jobs::ProviderHealthStateRegistry,
/// Task tracker for background tasks (usage logging, etc.)
/// Ensures all spawned tasks complete during graceful shutdown.
+ #[cfg(feature = "server")]
pub task_tracker: TaskTracker,
/// Registry of per-organization OIDC authenticators.
/// Loaded from org_sso_configs table at startup for multi-tenant SSO.
@@ -306,12 +330,14 @@ pub struct AppState {
pub saml_registry: Option>,
/// Registry of per-org gateway JWT validators.
/// Routes incoming JWTs to the correct org-scoped validator by issuer.
+ #[cfg(feature = "jwt")]
pub gateway_jwt_registry: Option>,
/// Registry of per-organization RBAC policies.
/// Loaded from org_rbac_policies table at startup for per-org authorization.
pub policy_registry: Option>,
/// Async buffer for usage log entries.
/// Batches writes to reduce database pressure.
+ #[cfg(feature = "concurrency")]
pub usage_buffer: Option>,
/// Response cache for chat completions.
/// Caches deterministic responses to reduce latency and costs.
@@ -753,6 +779,7 @@ impl AppState {
// Initialize per-org gateway JWT registry for multi-tenant JWT auth on /v1/*.
// Validators are pre-loaded in a background task so server startup isn't
// blocked by N sequential OIDC discovery HTTP requests.
+ #[cfg(feature = "jwt")]
let gateway_jwt_registry = if db.is_some() {
Some(Arc::new(auth::GatewayJwtRegistry::new()))
} else {
@@ -831,6 +858,7 @@ impl AppState {
};
// Initialize usage log buffer with configured buffer settings and EventBus
+ #[cfg(feature = "concurrency")]
let usage_buffer = {
let buffer_config =
usage_buffer::UsageBufferConfig::from(&config.observability.usage.buffer);
@@ -866,9 +894,11 @@ impl AppState {
};
// Create the task tracker for background tasks
+ #[cfg(feature = "server")]
let task_tracker = TaskTracker::new();
// Initialize semantic cache if configured
+ #[cfg(feature = "server")]
let semantic_cache = Self::init_semantic_cache(
&config,
cache.as_ref(),
@@ -878,6 +908,8 @@ impl AppState {
&task_tracker,
)
.await;
+ #[cfg(not(feature = "server"))]
+ let semantic_cache: Option> = None;
// Initialize input guardrails if configured
let input_guardrails = match &config.features.guardrails {
@@ -1038,13 +1070,16 @@ impl AppState {
pricing,
circuit_breakers,
provider_health: jobs::ProviderHealthStateRegistry::new(),
+ #[cfg(feature = "server")]
task_tracker,
#[cfg(feature = "sso")]
oidc_registry,
#[cfg(feature = "saml")]
saml_registry,
+ #[cfg(feature = "jwt")]
gateway_jwt_registry,
policy_registry,
+ #[cfg(feature = "concurrency")]
usage_buffer,
response_cache,
semantic_cache,
@@ -1067,7 +1102,7 @@ impl AppState {
/// Ensure a default user exists for anonymous access when auth is disabled.
/// Uses a well-known external_id so the same user is used across restarts.
/// Race-safe: tries to create first, falls back to lookup on conflict.
- async fn ensure_default_user(
+ pub(crate) async fn ensure_default_user(
services: &services::Services,
) -> Result> {
use crate::db::DbError;
@@ -1099,7 +1134,7 @@ impl AppState {
/// Ensure a default organization exists for anonymous access when auth is disabled.
/// Uses a well-known slug so the same organization is used across restarts.
/// Race-safe: tries to create first, falls back to lookup on conflict.
- async fn ensure_default_org(
+ pub(crate) async fn ensure_default_org(
services: &services::Services,
) -> Result> {
use crate::db::DbError;
@@ -1128,7 +1163,7 @@ impl AppState {
}
/// Ensure the default user is a member of the default organization.
- async fn ensure_default_org_membership(
+ pub(crate) async fn ensure_default_org_membership(
services: &services::Services,
user_id: uuid::Uuid,
org_id: uuid::Uuid,
@@ -1161,6 +1196,7 @@ impl AppState {
/// Initialize semantic cache if configured.
///
/// Spawns the background embedding worker on the provided task tracker.
+ #[cfg(feature = "server")]
async fn init_semantic_cache(
config: &config::GatewayConfig,
cache: Option<&Arc>,
@@ -1782,6 +1818,7 @@ impl AppState {
}
}
+#[cfg(feature = "server")]
pub fn build_app(config: &config::GatewayConfig, state: AppState) -> Router {
let mut app = Router::new()
// Health check endpoint
@@ -1960,6 +1997,7 @@ pub fn build_app(config: &config::GatewayConfig, state: AppState) -> Router {
}
// Add WebSocket route for real-time event subscriptions if enabled
+ #[cfg(feature = "server")]
if config.features.websocket.enabled {
app = app.route("/ws/events", get(routes::ws_handler));
tracing::info!("WebSocket event subscriptions enabled at /ws/events");
diff --git a/src/auth/gateway_jwt.rs b/src/auth/gateway_jwt.rs
index fd659e6..8170cf3 100644
--- a/src/auth/gateway_jwt.rs
+++ b/src/auth/gateway_jwt.rs
@@ -44,6 +44,12 @@ pub struct GatewayJwtRegistry {
load_mutex: Mutex<()>,
}
+impl Default for GatewayJwtRegistry {
+ fn default() -> Self {
+ Self::new()
+ }
+}
+
impl GatewayJwtRegistry {
/// Create an empty registry.
pub fn new() -> Self {
@@ -237,6 +243,11 @@ impl GatewayJwtRegistry {
pub async fn len(&self) -> usize {
self.inner.read().await.validators.len()
}
+
+ /// Whether the registry has no validators.
+ pub async fn is_empty(&self) -> bool {
+ self.inner.read().await.validators.is_empty()
+ }
}
/// Clean up issuer index entries for a given org_id. Operates on `&mut RegistryInner`
diff --git a/src/auth/mod.rs b/src/auth/mod.rs
index 41fd7d1..2d48d05 100644
--- a/src/auth/mod.rs
+++ b/src/auth/mod.rs
@@ -1,8 +1,10 @@
#[cfg(feature = "sso")]
mod discovery;
mod error;
+#[cfg(feature = "jwt")]
pub mod gateway_jwt;
mod identity;
+#[cfg(feature = "jwt")]
pub mod jwt;
#[cfg(feature = "sso")]
pub mod oidc;
@@ -19,6 +21,7 @@ pub mod session_store;
#[cfg(feature = "sso")]
pub use discovery::fetch_jwks_uri;
pub use error::AuthError;
+#[cfg(feature = "jwt")]
pub use gateway_jwt::GatewayJwtRegistry;
pub use identity::{ApiKeyAuth, AuthenticatedRequest, Identity, IdentityKind};
#[cfg(feature = "sso")]
diff --git a/src/auth/session_store.rs b/src/auth/session_store.rs
index 9c5b5c4..c432e71 100644
--- a/src/auth/session_store.rs
+++ b/src/auth/session_store.rs
@@ -242,7 +242,8 @@ impl AuthorizationState {
/// Trait for OIDC session storage.
///
/// Implementations must be thread-safe and handle concurrent access.
-#[async_trait]
+#[cfg_attr(not(target_arch = "wasm32"), async_trait)]
+#[cfg_attr(target_arch = "wasm32", async_trait(?Send))]
pub trait SessionStore: Send + Sync {
/// Store a new session.
async fn create_session(&self, session: OidcSession) -> SessionResult;
@@ -319,7 +320,8 @@ impl Default for MemorySessionStore {
}
}
-#[async_trait]
+#[cfg_attr(not(target_arch = "wasm32"), async_trait)]
+#[cfg_attr(target_arch = "wasm32", async_trait(?Send))]
impl SessionStore for MemorySessionStore {
async fn create_session(&self, session: OidcSession) -> SessionResult {
let id = session.id;
@@ -462,7 +464,8 @@ impl CacheSessionStore {
}
}
-#[async_trait]
+#[cfg_attr(not(target_arch = "wasm32"), async_trait)]
+#[cfg_attr(target_arch = "wasm32", async_trait(?Send))]
impl SessionStore for CacheSessionStore {
async fn create_session(&self, session: OidcSession) -> SessionResult {
let id = session.id;
diff --git a/src/authz/engine.rs b/src/authz/engine.rs
index 797b6f5..92baf98 100644
--- a/src/authz/engine.rs
+++ b/src/authz/engine.rs
@@ -311,12 +311,7 @@ pub struct TimeContext {
impl TimeContext {
/// Create a new TimeContext with the current time.
pub fn now() -> Self {
- use std::time::{SystemTime, UNIX_EPOCH};
-
- let now = SystemTime::now()
- .duration_since(UNIX_EPOCH)
- .unwrap_or_default();
- let timestamp = now.as_secs() as i64;
+ let timestamp = Self::current_timestamp();
// Calculate hour and day_of_week from timestamp
// This is a simplified calculation - in production you might want chrono
@@ -345,6 +340,24 @@ impl TimeContext {
timestamp,
}
}
+
+ /// Current Unix timestamp in seconds.
+ ///
+ /// On native uses `SystemTime`; on wasm32 uses `js_sys::Date` since
+ /// `SystemTime::now()` panics in the browser.
+ #[cfg(not(target_arch = "wasm32"))]
+ fn current_timestamp() -> i64 {
+ use std::time::{SystemTime, UNIX_EPOCH};
+ SystemTime::now()
+ .duration_since(UNIX_EPOCH)
+ .unwrap_or_default()
+ .as_secs() as i64
+ }
+
+ #[cfg(target_arch = "wasm32")]
+ fn current_timestamp() -> i64 {
+ (js_sys::Date::now() / 1000.0) as i64
+ }
}
impl Default for TimeContext {
diff --git a/src/cache/memory.rs b/src/cache/memory.rs
index c4e8478..cadf4b1 100644
--- a/src/cache/memory.rs
+++ b/src/cache/memory.rs
@@ -144,7 +144,8 @@ impl MemoryCache {
}
}
-#[async_trait]
+#[cfg_attr(not(target_arch = "wasm32"), async_trait)]
+#[cfg_attr(target_arch = "wasm32", async_trait(?Send))]
impl Cache for MemoryCache {
async fn get_bytes(&self, key: &str) -> CacheResult