//! The desktop's model layer, running in a browser. //! //! Nothing here is model logic. This crate registers a persistent VFS, opens //! the database through the same `init_standalone` the CLI uses, and answers //! the `models_*` commands by calling the same `ClientDb` queries the desktop //! does. A browser tab therefore stores exactly what a desktop install stores, //! migrations and all — the only thing that differs is where the SQLite pages //! live (IndexedDB) and who is calling in (a worker instead of Tauri). //! //! It is meant to be loaded once, in one place — a SharedWorker — because two //! SQLite instances over the same IndexedDB pages would corrupt them. The //! JavaScript side owns that; this crate assumes it is the only writer. //! //! The command surface is deliberately narrow: what the frontend needs to keep //! its model store coherent, and blob storage. Sending, plugins, git, sync and //! everything else with a socket or a filesystem behind it lives elsewhere. // Nothing in here means anything off wasm32, and building it there would drag // SQLite's wasm C shim into a native compile. So on any other target the crate // is empty — a workspace-wide `cargo test` passes through it. #![cfg(target_arch = "wasm32")] use std::cell::RefCell; use std::sync::mpsc; use serde::{Deserialize, Serialize}; use wasm_bindgen::prelude::*; use yaak_models::blob_manager::{BlobManager, BodyChunk}; use yaak_models::models::AnyModel; use yaak_models::models_ops; use yaak_models::query_manager::QueryManager; use yaak_models::util::{ModelPayload, UpdateSource}; /// Names inside the VFS, not paths on any disk. Two files because the desktop /// keeps two: models in one, blobs in the other. const DB_NAME: &str = "yaak.db"; const BLOB_DB_NAME: &str = "yaak-blobs.db"; const VFS_NAME: &str = "yaak-idb"; struct Host { queries: QueryManager, blobs: BlobManager, events: mpsc::Receiver, } thread_local! { static HOST: RefCell> = const { RefCell::new(None) }; } /* -------------------------------------------------------------------------- */ /* Errors */ /* -------------------------------------------------------------------------- */ /// What a failed command hands back to JavaScript: a real `Error`, so it /// throws like one, with `message` set to the model layer's own text. fn js_error(e: impl std::fmt::Display) -> JsValue { js_sys::Error::new(&e.to_string()).into() } type Result = std::result::Result; /* -------------------------------------------------------------------------- */ /* Boot */ /* -------------------------------------------------------------------------- */ /// Register the IndexedDB-backed VFS and open the database. /// /// Migrations run inside `init_standalone`, exactly as they do for the CLI. /// Safe to call more than once; later calls are no-ops. #[wasm_bindgen] pub async fn boot() -> Result<()> { console_error_panic_hook::set_once(); if HOST.with(|h| h.borrow().is_some()) { return Ok(()); } // "Relaxed" means writes land in memory first and are flushed to // IndexedDB shortly after, rather than on every commit. It is the right // trade for a client app: a tab closing mid-flush loses at most the last // few writes, and the alternative (OPFS sync access handles) needs a // dedicated worker per file and is not available everywhere. let cfg = sqlite_wasm_vfs::relaxed_idb::RelaxedIdbCfgBuilder::new() .vfs_name(VFS_NAME) .preload(sqlite_wasm_vfs::relaxed_idb::Preload::All) .build(); sqlite_wasm_vfs::relaxed_idb::install::(&cfg, true) .await .map_err(js_error)?; let (queries, blobs, events) = yaak_models::init_standalone(DB_NAME, BLOB_DB_NAME).map_err(js_error)?; HOST.with(|h| *h.borrow_mut() = Some(Host { queries, blobs, events })); Ok(()) } fn with_host(f: impl FnOnce(&Host) -> Result) -> Result { HOST.with(|h| { let h = h.borrow(); let host = h.as_ref().ok_or_else(|| js_error("yaak-web: call boot() before rpc()"))?; f(host) }) } /* -------------------------------------------------------------------------- */ /* Commands */ /* -------------------------------------------------------------------------- */ /// A command's outcome, plus every model write it caused. /// /// The writes ride along with the result rather than being fetched separately /// so the caller can announce them atomically with completion — a tab that /// awaits `models_upsert` must see its echo before or with the response, never /// after, or the store races the reply. #[derive(Serialize)] #[serde(rename_all = "camelCase")] pub struct RpcOutcome { result: serde_json::Value, events: Vec, } /// Run one command as `label` (the calling tab's identity, which stands in for /// the desktop's window label on every write it makes). /// /// The payload shapes match the `Cmd*Req` types in `yaak-rpc-schema`, but are /// declared locally and dispatched by name, which is the one place this host /// does not share the desktop's guarantees: the desktop builds its router from /// the schema, so every command has a handler by construction. Here a renamed /// command would surface as a runtime "not a command this host answers". /// /// The fix is `yaak-commands` (the `Host` trait), not more machinery here — /// its `models::*` handlers are already this file, typed. Three things have to /// give before a wasm host can register them: /// /// 1. `Host: Send + Sync`, which a browser cannot satisfy: there is one thread /// and the connection pool is an `Rc`. /// 2. `models_delete` reaches for `spawn_blocking`; there is nothing to spawn /// onto here. /// 3. `yaak-commands` depends on `yaak` and `yaak-plugins`, which pull the HTTP /// stack and the Node sidecar and do not build for wasm32. /// /// None of those are hard; they are just not this PR. #[wasm_bindgen] pub fn rpc(cmd: &str, payload: JsValue, label: &str) -> Result { let source = UpdateSource::from_window_label(label); let result = with_host(|host| dispatch(host, cmd, payload, &source))?; let events = with_host(|host| Ok(host.events.try_iter().collect::>()))?; use serde::Serialize as _; RpcOutcome { result, events } .serialize(&serde_wasm_bindgen::Serializer::json_compatible()) .map_err(js_error) } fn from_js Deserialize<'de>>(payload: JsValue) -> Result { serde_wasm_bindgen::from_value(payload).map_err(js_error) } fn to_json(value: T) -> Result { serde_json::to_value(value).map_err(js_error) } #[derive(Deserialize)] #[serde(rename_all = "camelCase")] struct WorkspaceModelsReq { workspace_id: Option, } #[derive(Deserialize)] #[serde(rename_all = "camelCase")] struct ModelReq { model: AnyModel, } #[derive(Deserialize)] #[serde(rename_all = "camelCase")] struct DuplicateReq { model_type: String, model_id: String, } #[derive(Deserialize)] #[serde(rename_all = "camelCase")] struct WorkspaceIdReq { workspace_id: String, } #[derive(Deserialize)] #[serde(rename_all = "camelCase")] struct RequestIdReq { request_id: String, } #[derive(Deserialize)] #[serde(rename_all = "camelCase")] struct UpsertIntrospectionReq { workspace_id: String, request_id: String, content: Option, } fn dispatch( host: &Host, cmd: &str, payload: JsValue, source: &UpdateSource, ) -> Result { match cmd { // The one big read. Same list, same order, and the same four lazy // creates (settings, first workspace, cookie jar, base environment) as // `models_workspace_models` on the desktop — this call is where an // empty database becomes a usable one. Returned as a JSON *string* // because that is what the desktop returns and what the store parses. "models_workspace_models" => { let req: WorkspaceModelsReq = from_js(payload)?; let db = host.queries.connect(); let mut list: Vec = Vec::new(); list.push(db.get_settings().into()); list.extend(db.list_workspaces().map_err(js_error)?.into_iter().map(Into::into)); list.extend(db.list_key_values().map_err(js_error)?.into_iter().map(Into::into)); // No plugin runtime to resolve these against; the rows are still // the truth about what is installed. list.extend(db.list_plugins().map_err(js_error)?.into_iter().map(Into::into)); if let Some(wid) = req.workspace_id.as_deref() { let e = js_error; list.extend(db.list_cookie_jars(wid).map_err(e)?.into_iter().map(Into::into)); list.extend( db.list_environments_ensure_base(wid).map_err(e)?.into_iter().map(Into::into), ); list.extend(db.list_folders(wid).map_err(e)?.into_iter().map(Into::into)); list.extend(db.list_grpc_connections(wid).map_err(e)?.into_iter().map(Into::into)); list.extend(db.list_grpc_requests(wid).map_err(e)?.into_iter().map(Into::into)); list.extend(db.list_http_requests(wid).map_err(e)?.into_iter().map(Into::into)); list.extend( db.list_http_responses(wid, None).map_err(e)?.into_iter().map(Into::into), ); list.extend( db.list_websocket_connections(wid).map_err(e)?.into_iter().map(Into::into), ); list.extend( db.list_websocket_requests(wid).map_err(e)?.into_iter().map(Into::into), ); list.extend(db.list_workspace_metas(wid).map_err(e)?.into_iter().map(Into::into)); } to_json(serde_json::to_string(&list).map_err(js_error)?) } "models_upsert" => { let req: ModelReq = from_js(payload)?; let db = host.queries.connect(); let id = models_ops::upsert_model(&db, &host.blobs, req.model, source).map_err(js_error)?; to_json(id) } // Deletes and duplicates cascade, so they run in a transaction, as on // the desktop. "models_delete" => { let req: ModelReq = from_js(payload)?; let id = host .queries .with_tx(|tx| models_ops::delete_model(tx, &host.blobs, req.model, source)) .map_err(js_error)?; to_json(id) } "models_duplicate" => { let req: DuplicateReq = from_js(payload)?; let id = host .queries .with_tx(|tx| { models_ops::duplicate_model(tx, &req.model_type, &req.model_id, source) }) .map_err(js_error)?; to_json(id) } "models_get_settings" => to_json(host.queries.connect().get_settings()), "models_get_graphql_introspection" => { let req: RequestIdReq = from_js(payload)?; to_json(host.queries.connect().get_graphql_introspection(&req.request_id)) } "models_upsert_graphql_introspection" => { let req: UpsertIntrospectionReq = from_js(payload)?; let saved = host .queries .connect() .upsert_graphql_introspection( &req.workspace_id, &req.request_id, req.content, source, ) .map_err(js_error)?; to_json(saved) } // Nothing here can open a socket, so no connection ever produced any. "models_grpc_events" | "models_websocket_events" => to_json(Vec::<()>::new()), "cmd_get_workspace_meta" => { let req: WorkspaceIdReq = from_js(payload)?; let db = host.queries.connect(); let workspace = db.get_workspace(&req.workspace_id).map_err(js_error)?; to_json(db.get_or_create_workspace_meta(&workspace.id).map_err(js_error)?) } other => Err(js_error(format!("yaak-web: `{other}` is not a command this host answers"))), } } /* -------------------------------------------------------------------------- */ /* Blobs */ /* -------------------------------------------------------------------------- */ /// The bytes stored under an id, or none. Ids are the desktop's: a response's /// own id for its body, `{responseId}.request` for the request that produced /// it. Bytes cross to JS as a `Uint8Array` rather than through JSON. #[wasm_bindgen] pub fn blob_get(id: &str) -> Result>> { with_host(|host| { let chunks = host.blobs.connect().get_chunks(id).map_err(js_error)?; if chunks.is_empty() { return Ok(None); } Ok(Some(chunks.into_iter().flat_map(|c| c.data).collect())) }) } /// Store bytes under an id, replacing anything already there. Chunked the way /// the desktop chunks, so a body written here reads back on a desktop that /// imports the database, and vice versa. #[wasm_bindgen] pub fn blob_put(id: &str, bytes: &[u8]) -> Result<()> { const CHUNK: usize = 512 * 1024; with_host(|host| { let ctx = host.blobs.connect(); ctx.delete_chunks(id).map_err(js_error)?; for (i, part) in bytes.chunks(CHUNK).enumerate() { ctx.insert_chunk(&BodyChunk::new(id, i as i32, part.to_vec())).map_err(js_error)?; } Ok(()) }) } #[wasm_bindgen] pub fn blob_delete(id: &str) -> Result<()> { with_host(|host| host.blobs.connect().delete_chunks(id).map_err(js_error)) }