Skip to content

WASM build running hadrian in the browser#9

Merged
ScriptSmith merged 21 commits intomainfrom
wasm
Mar 10, 2026
Merged

WASM build running hadrian in the browser#9
ScriptSmith merged 21 commits intomainfrom
wasm

Conversation

@ScriptSmith
Copy link
Owner

No description provided.

@ScriptSmith
Copy link
Owner Author

@greptile-apps

@greptile-apps
Copy link
Contributor

greptile-apps bot commented Mar 10, 2026

Greptile Summary

This 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 (src/compat.rs) handles the !Send futures that wasm32 requires. A sql.js-backed SQLite layer (src/db/wasm_sqlite/) replaces sqlx, with an IndexedDB persistence bridge in TypeScript. The UI gains an onboarding wizard (WasmSetupGuard) that auto-detects Ollama and supports OpenRouter OAuth PKCE. Deployment is handled by a new Cloudflare Pages workflow.

Key findings from the review:

  • Migration system cannot evolve (src/db/wasm_sqlite/bridge.rs): run_migrations is hardcoded to a single migration name. Once applied, any future migration SQL files are silently skipped on existing WASM databases — users will encounter schema mismatches as the application develops.
  • Non-atomic migration execution (ui/src/service-worker/sqlite-bridge.ts): execute_script runs the migration SQL via db.exec() without a wrapping transaction. A mid-run failure leaves the database in a partially-applied state; on next startup the migration re-runs and fails on the already-created tables, permanently corrupting the user's local database.
  • Ollama error feedback missing (ui/src/components/WasmSetup/WasmSetupGuard.tsx): Provider creation failures during Ollama auto-connect are only logged to the console — the user sees the button un-disable with no explanation.
  • Lazy WASM initialization (ui/src/service-worker/sw.ts): The gateway is initialized on the first intercepted request rather than during the service worker install event, adding noticeable latency to the first API call.

Confidence Score: 2/5

  • Not safe to merge as-is — the non-atomic migration and single-migration-only design can permanently corrupt or stall a user's local database.
  • The core WASM architecture (compat shim, request/response conversion, binary body handling, PKCE OAuth) is well-implemented. However, two migration-related issues interact to create a real data-loss path: (1) if the initial migration SQL partially fails the database is left in a state that blocks all future startup attempts, and (2) the migration runner has no mechanism to apply future schema changes to existing databases. Both issues will surface as the project evolves and will be very hard to recover from in user-held IndexedDB state.
  • Pay close attention to src/db/wasm_sqlite/bridge.rs (migration runner) and ui/src/service-worker/sqlite-bridge.ts (execute_script transaction safety).

Important Files Changed

Filename Overview
src/wasm.rs New WASM entry point: builds the Axum router for browser use, converts web_sys::Request↔http::Request, injects AdminAuth/AuthzContext extensions. Binary body reading via array_buffer() is correct. Path rewriting (/api/v1/ → /v1/) is sound.
src/db/wasm_sqlite/bridge.rs JS FFI bridge to sql.js. Critical issue: run_migrations hardcodes a single migration with no provision for future schema changes — existing WASM databases will never receive schema updates.
ui/src/service-worker/sqlite-bridge.ts sql.js bridge implementing globalThis.__hadrian_sqlite. Critical issue: execute_script runs migration SQL without a wrapping transaction — partial failures leave the DB in an unrecoverable partially-migrated state. Debounced persistence to IndexedDB is sound.
ui/src/service-worker/sw.ts Service worker orchestrator: intercepts GATEWAY_PATHS, lazily inits HadrianGateway on first request. Init not triggered during install, adding significant latency to the first intercepted call.
ui/src/components/WasmSetup/WasmSetupGuard.tsx Onboarding wizard guard: handles provider detection, Ollama auto-connect, and OpenRouter OAuth PKCE flow. Ollama connection errors are silently swallowed with no user-visible feedback.
src/compat.rs Compatibility shims: AssertSend/WasmHandler for !Send axum handler futures on wasm32, spawn_detached abstraction, and std-based Mutex/RwLock fallbacks. The unsafe Send impl is correctly justified by wasm32 single-threading.
.github/workflows/deploy-wasm.yml CI/CD pipeline to Cloudflare Pages. wasm-pack installed via cargo install --locked (versioned). GitHub Actions are not pinned to commit SHAs, but this is common practice for internal workflows.

Sequence Diagram

sequenceDiagram
    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
Loading

Last reviewed commit: 08080d7

@ScriptSmith
Copy link
Owner Author

@greptile-apps

Comment on lines +146 to +189
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(())
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines +175 to +179
async execute_script(sql: string): Promise<void> {
if (!db) throw new Error("Database not initialized — call init_database() first");
db.exec(sql);
debouncedSave();
},
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines +100 to +106
setOllamaConnected(true);
setManualOpen(true);
} catch (err) {
console.error("Ollama connect failed:", err);
} finally {
setOllamaConnecting(false);
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines +64 to +89
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" },
}
);
}
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@ScriptSmith ScriptSmith merged commit 012b811 into main Mar 10, 2026
20 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant