WASM build running hadrian in the browser#9
Conversation
Greptile SummaryThis PR adds a full WASM build of the Hadrian gateway that runs entirely in the browser via a service worker. The same Axum router, route handlers, and provider logic used by the native server are reused; a compatibility shim ( Key findings from the review:
Confidence Score: 2/5
Important Files Changed
Sequence DiagramsequenceDiagram
participant Page as Browser Page
participant SW as Service Worker (sw.ts)
participant Bridge as sqlite-bridge.ts
participant WASM as HadrianGateway (Rust/WASM)
participant IDB as IndexedDB
participant Provider as AI Provider (OpenAI/Anthropic/etc.)
Page->>SW: navigator.serviceWorker.register("/sw.js")
SW->>SW: install → skipWaiting()
SW->>SW: activate → clients.claim()
Note over SW,WASM: Lazy init on first intercepted request
Page->>SW: fetch(/api/v1/chat/completions)
SW->>Bridge: import sqlite-bridge (registers __hadrian_sqlite)
SW->>WASM: wasmInit("/wasm/hadrian_bg.wasm")
WASM->>Bridge: init_database()
Bridge->>IDB: loadFromIndexedDB()
IDB-->>Bridge: saved Uint8Array (or null)
Bridge->>Bridge: new SQL.Database(saved?)
WASM->>Bridge: execute_script(migration SQL)
Bridge-->>WASM: ok
SW->>WASM: new HadrianGateway()
Note over SW,WASM: Request handling (subsequent requests)
Page->>SW: fetch(/api/v1/*)
SW->>WASM: gateway.handle(web_sys::Request)
WASM->>WASM: convert_request() — /api/v1/* → /v1/*
WASM->>WASM: Router dispatch (Axum)
WASM->>Bridge: query/execute (SQLite reads/writes)
Bridge->>IDB: debouncedSave() after writes
WASM->>Provider: reqwest (browser fetch)
Provider-->>WASM: streaming response
WASM->>WASM: convert_response()
WASM-->>SW: web_sys::Response
SW-->>Page: Response
Last reviewed commit: 08080d7 |
| pub async fn run_migrations(&self) -> Result<(), WasmDbError> { | ||
| let migration_sql = | ||
| include_str!("../../../migrations_sqlx/sqlite/20250101000000_initial.sql"); | ||
|
|
||
| // Create migrations tracking table | ||
| self.execute_statement( | ||
| "CREATE TABLE IF NOT EXISTS _wasm_migrations ( | ||
| id INTEGER PRIMARY KEY, | ||
| name TEXT NOT NULL UNIQUE, | ||
| applied_at TEXT NOT NULL DEFAULT (datetime('now')) | ||
| )", | ||
| &[], | ||
| ) | ||
| .await?; | ||
|
|
||
| // Check if migration already applied | ||
| let rows = self | ||
| .execute_query( | ||
| "SELECT id FROM _wasm_migrations WHERE name = ?", | ||
| &[WasmParam::Text("20250101000000_initial".to_string())], | ||
| ) | ||
| .await?; | ||
|
|
||
| if !rows.is_empty() { | ||
| tracing::debug!("WASM SQLite migrations already applied"); | ||
| return Ok(()); | ||
| } | ||
|
|
||
| // Use execute_script to run the entire migration as one batch. | ||
| // sql.js's db.exec() handles multiple statements natively, avoiding | ||
| // issues with semicolons inside SQL comments. | ||
| execute_script(migration_sql) | ||
| .await | ||
| .map_err(|e| WasmDbError::Query(js_error_to_string(&e)))?; | ||
|
|
||
| // Record migration | ||
| self.execute_statement( | ||
| "INSERT INTO _wasm_migrations (name) VALUES (?)", | ||
| &[WasmParam::Text("20250101000000_initial".to_string())], | ||
| ) | ||
| .await?; | ||
|
|
||
| tracing::info!("WASM SQLite migrations applied successfully"); | ||
| Ok(()) |
There was a problem hiding this comment.
Single hardcoded migration — no path to apply future schema changes
run_migrations only ever applies one migration ("20250101000000_initial"). Once a user's IndexedDB database has that migration recorded, the function returns early on every subsequent boot. Any new migration SQL files added to migrations_sqlx/sqlite/ will be silently ignored on existing WASM databases.
As the schema evolves (new columns, tables, or indexes), WASM users will be stuck on the initial schema, causing ColumnNotFound/TypeMismatch errors whenever code assumes a column that doesn't exist in their database.
A minimal improvement is to iterate over all known migration names in order, skipping only those already recorded:
const MIGRATIONS: &[(&str, &str)] = &[
("20250101000000_initial",
include_str!("../../../migrations_sqlx/sqlite/20250101000000_initial.sql")),
// ("20250201000000_next", include_str!("...")),
];
pub async fn run_migrations(&self) -> Result<(), WasmDbError> {
// create _wasm_migrations table …
for (name, sql) in MIGRATIONS {
let rows = self.execute_query(
"SELECT id FROM _wasm_migrations WHERE name = ?",
&[WasmParam::Text(name.to_string())],
).await?;
if !rows.is_empty() { continue; }
execute_script(sql).await
.map_err(|e| WasmDbError::Query(js_error_to_string(&e)))?;
self.execute_statement(
"INSERT INTO _wasm_migrations (name) VALUES (?)",
&[WasmParam::Text(name.to_string())],
).await?;
}
Ok(())
}Prompt To Fix With AI
This is a comment left during a code review.
Path: src/db/wasm_sqlite/bridge.rs
Line: 146-189
Comment:
**Single hardcoded migration — no path to apply future schema changes**
`run_migrations` only ever applies one migration (`"20250101000000_initial"`). Once a user's IndexedDB database has that migration recorded, the function returns early on every subsequent boot. Any new migration SQL files added to `migrations_sqlx/sqlite/` will be silently ignored on existing WASM databases.
As the schema evolves (new columns, tables, or indexes), WASM users will be stuck on the initial schema, causing `ColumnNotFound`/`TypeMismatch` errors whenever code assumes a column that doesn't exist in their database.
A minimal improvement is to iterate over all known migration names in order, skipping only those already recorded:
```rust
const MIGRATIONS: &[(&str, &str)] = &[
("20250101000000_initial",
include_str!("../../../migrations_sqlx/sqlite/20250101000000_initial.sql")),
// ("20250201000000_next", include_str!("...")),
];
pub async fn run_migrations(&self) -> Result<(), WasmDbError> {
// create _wasm_migrations table …
for (name, sql) in MIGRATIONS {
let rows = self.execute_query(
"SELECT id FROM _wasm_migrations WHERE name = ?",
&[WasmParam::Text(name.to_string())],
).await?;
if !rows.is_empty() { continue; }
execute_script(sql).await
.map_err(|e| WasmDbError::Query(js_error_to_string(&e)))?;
self.execute_statement(
"INSERT INTO _wasm_migrations (name) VALUES (?)",
&[WasmParam::Text(name.to_string())],
).await?;
}
Ok(())
}
```
How can I resolve this? If you propose a fix, please make it concise.| async execute_script(sql: string): Promise<void> { | ||
| if (!db) throw new Error("Database not initialized — call init_database() first"); | ||
| db.exec(sql); | ||
| debouncedSave(); | ||
| }, |
There was a problem hiding this comment.
Migration failure leaves database in a partially-applied state
db.exec(sql) (used by execute_script) runs multiple SQL statements sequentially without wrapping them in a transaction. If the call throws partway through (e.g., a duplicate-table error or OOM), some CREATE TABLE statements will have committed while others haven't.
On the next startup, run_migrations will try to re-run the full migration (the tracking row was never written), but now it will fail immediately on the already-created tables — permanently locking the user's database in an unusable state.
The initial migration SQL should be wrapped in an explicit transaction so the whole script is atomic:
async execute_script(sql: string): Promise<void> {
if (!db) throw new Error("Database not initialized — call init_database() first");
db.run("BEGIN");
try {
db.exec(sql);
db.run("COMMIT");
} catch (err) {
db.run("ROLLBACK");
throw err;
}
debouncedSave();
},Alternatively, wrap the migration SQL itself in BEGIN; … COMMIT; on the Rust side before passing it to execute_script.
Prompt To Fix With AI
This is a comment left during a code review.
Path: ui/src/service-worker/sqlite-bridge.ts
Line: 175-179
Comment:
**Migration failure leaves database in a partially-applied state**
`db.exec(sql)` (used by `execute_script`) runs multiple SQL statements sequentially without wrapping them in a transaction. If the call throws partway through (e.g., a duplicate-table error or OOM), some `CREATE TABLE` statements will have committed while others haven't.
On the next startup, `run_migrations` will try to re-run the full migration (the tracking row was never written), but now it will fail immediately on the already-created tables — permanently locking the user's database in an unusable state.
The initial migration SQL should be wrapped in an explicit transaction so the whole script is atomic:
```typescript
async execute_script(sql: string): Promise<void> {
if (!db) throw new Error("Database not initialized — call init_database() first");
db.run("BEGIN");
try {
db.exec(sql);
db.run("COMMIT");
} catch (err) {
db.run("ROLLBACK");
throw err;
}
debouncedSave();
},
```
Alternatively, wrap the migration SQL itself in `BEGIN; … COMMIT;` on the Rust side before passing it to `execute_script`.
How can I resolve this? If you propose a fix, please make it concise.| setOllamaConnected(true); | ||
| setManualOpen(true); | ||
| } catch (err) { | ||
| console.error("Ollama connect failed:", err); | ||
| } finally { | ||
| setOllamaConnecting(false); | ||
| } |
There was a problem hiding this comment.
Ollama connection errors are silently swallowed — user receives no feedback
When createProvider.mutateAsync(...) throws, the error is only sent to console.error. ollamaConnecting is set back to false, so the button becomes clickable again, but the user has no indication that the connection attempt failed or why.
Consider adding an ollamaError state (mirroring oauthError) so the WasmSetup component can display the failure reason:
const [ollamaError, setOllamaError] = useState<string | null>(null);
const handleOllamaConnect = useCallback(async () => {
setOllamaConnecting(true);
setOllamaError(null);
try {
// ...
} catch (err) {
console.error("Ollama connect failed:", err);
setOllamaError(String(err)); // surface in UI
} finally {
setOllamaConnecting(false);
}
}, [createProvider, queryClient]);Prompt To Fix With AI
This is a comment left during a code review.
Path: ui/src/components/WasmSetup/WasmSetupGuard.tsx
Line: 100-106
Comment:
**Ollama connection errors are silently swallowed — user receives no feedback**
When `createProvider.mutateAsync(...)` throws, the error is only sent to `console.error`. `ollamaConnecting` is set back to `false`, so the button becomes clickable again, but the user has no indication that the connection attempt failed or why.
Consider adding an `ollamaError` state (mirroring `oauthError`) so the `WasmSetup` component can display the failure reason:
```tsx
const [ollamaError, setOllamaError] = useState<string | null>(null);
const handleOllamaConnect = useCallback(async () => {
setOllamaConnecting(true);
setOllamaError(null);
try {
// ...
} catch (err) {
console.error("Ollama connect failed:", err);
setOllamaError(String(err)); // surface in UI
} finally {
setOllamaConnecting(false);
}
}, [createProvider, queryClient]);
```
How can I resolve this? If you propose a fix, please make it concise.| async function handleRequest(request: Request): Promise<Response> { | ||
| // Lazy-init the WASM gateway on first intercepted request | ||
| if (!gateway) { | ||
| if (!initPromise) { | ||
| initPromise = ensureGateway(); | ||
| } | ||
| try { | ||
| await initPromise; | ||
| } catch (error) { | ||
| initPromise = null; // Allow retry on next request | ||
| console.error("Failed to initialize Hadrian WASM gateway:", error); | ||
| return new Response( | ||
| JSON.stringify({ | ||
| error: { | ||
| message: `Gateway initialization failed: ${String(error)}`, | ||
| type: "server_error", | ||
| code: 503, | ||
| }, | ||
| }), | ||
| { | ||
| status: 503, | ||
| headers: { "Content-Type": "application/json" }, | ||
| } | ||
| ); | ||
| } | ||
| } |
There was a problem hiding this comment.
WASM gateway initialized lazily — first intercepted request pays full init latency
The gateway (which downloads hadrian_bg.wasm and runs DB migrations) is only initialised on the first intercepted API request. Depending on the WASM bundle size and network conditions, this can add several seconds of latency to the very first request the user sees, with no loading indicator in the UI.
Consider moving the init into the install event so the WASM is ready before the service worker activates:
self.addEventListener("install", (event) => {
event.waitUntil(
ensureGateway()
.catch((err) => console.error("Preload init failed — will retry on first request", err))
.then(() => self.skipWaiting())
);
});If preloading fails (e.g., network unavailable), the lazy-init path in handleRequest still acts as a fallback, so the retry logic is preserved.
Prompt To Fix With AI
This is a comment left during a code review.
Path: ui/src/service-worker/sw.ts
Line: 64-89
Comment:
**WASM gateway initialized lazily — first intercepted request pays full init latency**
The gateway (which downloads `hadrian_bg.wasm` and runs DB migrations) is only initialised on the first intercepted API request. Depending on the WASM bundle size and network conditions, this can add several seconds of latency to the very first request the user sees, with no loading indicator in the UI.
Consider moving the init into the `install` event so the WASM is ready before the service worker activates:
```typescript
self.addEventListener("install", (event) => {
event.waitUntil(
ensureGateway()
.catch((err) => console.error("Preload init failed — will retry on first request", err))
.then(() => self.skipWaiting())
);
});
```
If preloading fails (e.g., network unavailable), the lazy-init path in `handleRequest` still acts as a fallback, so the retry logic is preserved.
How can I resolve this? If you propose a fix, please make it concise.
No description provided.