//! 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, blob storage, and the "prepare" half of a send //! (resolve, inherit, render — see [`prepare_http_send`]). Putting bytes on the //! network, 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 std::collections::HashMap; use wasm_bindgen::prelude::*; use yaak_models::blob_manager::{BlobManager, BodyChunk}; use yaak_models::cookies::apply_cookie_changes; use yaak_models::models::{ AnyModel, Cookie, CookieJar, HttpRequest, HttpResponseEvent, HttpResponseEventData, HttpSendSettings, }; use yaak_models::models_ops; use yaak_models::query_manager::QueryManager; use yaak_models::render::render_http_request; use yaak_models::util::{ModelPayload, UpdateSource}; use yaak_templates::{RenderOptions, TemplateCallback}; /// 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, } fn lifecycle_host() -> yaak_lifecycle::Host { yaak_lifecycle::Host::owner() } 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)?; if let Err(e) = yaak_lifecycle::on_launch(&lifecycle_host(), &queries.connect(), &blobs) { web_sys::console::warn_2(&"on_launch hook failed".into(), &js_error(e)); } 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, } #[derive(Deserialize)] #[serde(rename_all = "camelCase")] struct ResponseIdReq { response_id: String, } #[derive(Deserialize)] #[serde(rename_all = "camelCase")] struct PersistSendCookiesReq { cookie_jar_id: String, before: Vec, after: Vec, } #[derive(Deserialize)] #[serde(rename_all = "camelCase")] struct PluginKeyValueReq { plugin_name: String, key: String, value: Option, } #[derive(Deserialize)] #[serde(rename_all = "camelCase")] struct InsertResponseEventsReq { response_id: String, workspace_id: String, events: Vec, } 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()), "web_get_http_request" => { let req: RequestIdReq = from_js(payload)?; to_json(host.queries.connect().get_http_request(&req.request_id).map_err(js_error)?) } "cmd_get_http_response_events" => { let req: ResponseIdReq = from_js(payload)?; to_json( host.queries .connect() .list_http_response_events(&req.response_id) .map_err(js_error)?, ) } // The cookies a send set or cleared, applied to the jar as it is *now* rather than // written over it, so an edit made while the send was in flight survives. "web_persist_send_cookies" => { let req: PersistSendCookiesReq = from_js(payload)?; if req.before == req.after { return to_json(()); } let db = host.queries.connect(); let jar = db.get_cookie_jar(&req.cookie_jar_id).map_err(js_error)?; let cookies = apply_cookie_changes(jar.cookies.clone(), &req.before, &req.after); db.upsert_cookie_jar(&CookieJar { cookies, ..jar }, source).map_err(js_error)?; to_json(()) } // The tab's half of the send timeline: the events the proxy streamed back, recorded // under the response they belong to. Same rows the desktop's send task writes, and the // writes fan out to every tab as `model_writes` like any other. "web_insert_http_response_events" => { let req: InsertResponseEventsReq = from_js(payload)?; let db = host.queries.connect(); for event in req.events { let model = HttpResponseEvent::new(&req.response_id, &req.workspace_id, event); db.upsert_http_response_event(&model, source).map_err(js_error)?; } to_json(()) } "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)?) } "cmd_delete_all_http_responses" => { let req: RequestIdReq = from_js(payload)?; host.queries .connect() .delete_all_http_responses_for_request(&req.request_id, source) .map_err(js_error)?; to_json(()) } "cmd_delete_send_history" => { let req: WorkspaceIdReq = from_js(payload)?; host.queries .with_tx(|tx| { tx.delete_all_http_responses_for_workspace(&req.workspace_id, source)?; tx.delete_all_grpc_connections_for_workspace(&req.workspace_id, source)?; tx.delete_all_websocket_connections_for_workspace(&req.workspace_id, source)?; Ok::<(), yaak_models::error::Error>(()) }) .map_err(js_error)?; to_json(()) } // A plugin's own storage, namespaced by plugin name exactly as the desktop namespaces // it (`build_shared_reply` in crates/yaak/src/plugin_events.rs), so a plugin that keeps // a token here finds it under the same key on either host. "web_plugin_kv_get" => { let req: PluginKeyValueReq = from_js(payload)?; let found = host.queries.connect().get_plugin_key_value(&req.plugin_name, &req.key); to_json(found.map(|kv| kv.value)) } "web_plugin_kv_set" => { let req: PluginKeyValueReq = from_js(payload)?; host.queries.connect().set_plugin_key_value( &req.plugin_name, &req.key, &req.value.unwrap_or_default(), ); to_json(()) } "web_plugin_kv_delete" => { let req: PluginKeyValueReq = from_js(payload)?; let deleted = host .queries .connect() .delete_plugin_key_value(&req.plugin_name, &req.key) .map_err(js_error)?; to_json(deleted) } other => Err(js_error(format!("yaak-web: `{other}` is not a command this host answers"))), } } /* -------------------------------------------------------------------------- */ /* Preparing a send */ /* -------------------------------------------------------------------------- */ #[derive(Deserialize)] #[serde(rename_all = "camelCase")] struct PrepareHttpSendReq { request_id: String, environment_id: Option, cookie_jar_id: Option, } /// Everything a send needs that lives in the database, resolved and rendered: the desktop's /// `HttpSendInputs`, in the shape a tab hands to the proxy and keeps for itself. #[derive(Serialize)] #[serde(rename_all = "camelCase")] struct PreparedHttpSend { /// The request with inherited headers and authentication applied and every template /// rendered. What the proxy sends, and what the response records as its request. request: HttpRequest, /// Identifies whichever model the authentication was inherited from, hashed the way the /// desktop hashes it. Plugins key their stored state on it — an OAuth token cache belongs /// to the folder that declared the auth, not to each request under it. auth_context_id: String, settings: HttpSendSettings, /// The `* Setting name=value` timeline lines the desktop writes at the top of a send, /// sources and all. The tab records them before the proxy's own events. setting_events: Vec, /// The jar the send starts with, so the tab can write it back with the proxy's changes. cookie_jar: Option, } /// A template callback that calls a template function wherever the caller keeps them. /// /// The desktop's equivalent (`PluginTemplateCallback`) reaches a plugin process; this one /// reaches a JavaScript function the worker installed, which forwards to the sandbox and back. /// Both hand the renderer the same thing — a string, or an error naming what failed — so a /// template renders identically on either host or fails for the same reason. /// /// Without a function to call, a template function is a clear refusal naming it, which tells /// the user what the request needs rather than sending an empty string in its place. struct JsTemplateCallback { call: Option, } impl TemplateCallback for JsTemplateCallback { fn run( &self, fn_name: &str, args: HashMap, ) -> impl std::future::Future> { // Built before the async block so the future holds only owned values. let call = self.call.clone(); let fn_name = fn_name.to_string(); let args = serde_json::to_string(&args).unwrap_or_else(|_| "{}".into()); async move { use yaak_templates::error::Error::RenderError; let Some(call) = call else { return Err(RenderError(format!( "This request uses the template function \"{fn_name}\", which needs plugins. \ No plugin provides it" ))); }; let promise = call .call2(&JsValue::NULL, &JsValue::from_str(&fn_name), &JsValue::from_str(&args)) .map_err(|e| RenderError(js_message(&e)))?; let value = wasm_bindgen_futures::JsFuture::from(js_sys::Promise::from(promise)) .await .map_err(|e| RenderError(js_message(&e)))?; value.as_string().ok_or_else(|| { RenderError(format!("Template function \"{fn_name}\" did not return a string")) }) } } fn transform_arg( &self, _fn_name: &str, _arg_name: &str, arg_value: &str, ) -> yaak_templates::error::Result { Ok(arg_value.to_string()) } } /// The message out of a rejected promise or a thrown error, without the `Error:` wrapper. fn js_message(value: &JsValue) -> String { if let Some(text) = value.as_string() { return text; } let message = js_sys::Reflect::get(value, &JsValue::from_str("message")) .ok() .and_then(|m| m.as_string()); message.unwrap_or_else(|| format!("{value:?}")) } /// The template function bridge, or none when the caller passed nothing. fn template_callback(plugins: JsValue) -> JsTemplateCallback { JsTemplateCallback { call: plugins.dyn_into::().ok() } } /// Resolve and render a request for sending, exactly as the desktop does before it puts the /// request on the network: the environment chain, inherited headers and auth, request /// settings, the cookie jar. Nothing here touches a socket. What comes back is what the tab /// posts to the send proxy. /// /// `plugins` is the template function bridge — a JavaScript function taking a name and its /// JSON arguments and resolving to the rendered string. Passing nothing is allowed and makes /// every template function a refusal naming it. /// /// Authentication is *not* applied here even though it is part of preparing a send. It is /// applied to the rendered request by the caller, because the plugin that applies it wants to /// see the request as it will be sent, and the caller is the side that knows that. #[wasm_bindgen] pub async fn prepare_http_send(payload: JsValue, plugins: JsValue) -> Result { let req: PrepareHttpSendReq = from_js(payload)?; // Everything from the database first, then release the host borrow before rendering. let (request, environment_chain, settings, cookie_jar, auth_context_id) = with_host(|host| { let db = host.queries.connect(); let request = db.get_http_request(&req.request_id).map_err(js_error)?; let environment_chain = db .resolve_environments( &request.workspace_id, request.folder_id.as_deref(), req.environment_id.as_deref(), ) .map_err(js_error)?; let (authentication_type, authentication, auth_context_id) = db.resolve_auth_for_http_request(&request).map_err(js_error)?; let headers = db.resolve_headers_for_http_request(&request).map_err(js_error)?; let settings = db.resolve_settings_for_http_request(&request).map_err(js_error)?; let cookie_jar = match req.cookie_jar_id.as_deref() { Some(id) => Some(db.get_cookie_jar(id).map_err(js_error)?), None => None, }; let request = HttpRequest { authentication_type, authentication, headers, ..request }; Ok((request, environment_chain, settings, cookie_jar, auth_context_id)) })?; let rendered = render_http_request( &request, environment_chain, &template_callback(plugins), &RenderOptions::throw(), ) .await .map_err(js_error)?; let prepared = PreparedHttpSend { request: rendered, auth_context_id: format!("{:x}", md5::compute(auth_context_id)), settings: HttpSendSettings::from(&settings), setting_events: settings.timeline_events(), cookie_jar, }; // JSON-compatible, as `rpc` does: the tab posts this to the proxy with `JSON.stringify`, // and the default serializer's `Map` for the request body would stringify to `{}`. use serde::Serialize as _; prepared.serialize(&serde_wasm_bindgen::Serializer::json_compatible()).map_err(js_error) } #[derive(Deserialize)] #[serde(rename_all = "camelCase")] struct RenderTemplateReq { template: String, workspace_id: String, environment_id: Option, ignore_error: Option, } /// Render one template string against an environment chain. /// /// What `cmd_render_template` does on the desktop, for the same callers: the value previews /// under an editor, and anywhere the app shows what a template will become. `ignore_error` /// picks the same behaviour it picks there — a preview shows an empty string where a send /// would refuse, because a half-typed template is not yet a mistake. #[wasm_bindgen] pub async fn render_template(payload: JsValue, plugins: JsValue) -> Result { let req: RenderTemplateReq = from_js(payload)?; let environment_chain = with_host(|host| { host.queries .connect() .resolve_environments(&req.workspace_id, None, req.environment_id.as_deref()) .map_err(js_error) })?; let vars = yaak_models::render::make_vars_hashmap(environment_chain); let options = if req.ignore_error == Some(true) { RenderOptions::return_empty() } else { RenderOptions::throw() }; let rendered = yaak_templates::parse_and_render(&req.template, &vars, &template_callback(plugins), &options) .await .map_err(js_error)?; to_json(rendered).map(|v| JsValue::from_str(v.as_str().unwrap_or_default())) } /* -------------------------------------------------------------------------- */ /* 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)) }