mirror of
https://github.com/mountain-loop/yaak.git
synced 2026-08-21 19:04:05 +02:00
357 lines
14 KiB
Rust
357 lines
14 KiB
Rust
//! 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<ModelPayload>,
|
|
}
|
|
|
|
thread_local! {
|
|
static HOST: RefCell<Option<Host>> = 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<T> = std::result::Result<T, JsValue>;
|
|
|
|
/* -------------------------------------------------------------------------- */
|
|
/* 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::<sqlite_wasm_rs::WasmOsCallback>(&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<T>(f: impl FnOnce(&Host) -> Result<T>) -> Result<T> {
|
|
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<ModelPayload>,
|
|
}
|
|
|
|
/// 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<JsValue> {
|
|
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::<Vec<_>>()))?;
|
|
|
|
use serde::Serialize as _;
|
|
RpcOutcome { result, events }
|
|
.serialize(&serde_wasm_bindgen::Serializer::json_compatible())
|
|
.map_err(js_error)
|
|
}
|
|
|
|
fn from_js<T: for<'de> Deserialize<'de>>(payload: JsValue) -> Result<T> {
|
|
serde_wasm_bindgen::from_value(payload).map_err(js_error)
|
|
}
|
|
|
|
fn to_json<T: Serialize>(value: T) -> Result<serde_json::Value> {
|
|
serde_json::to_value(value).map_err(js_error)
|
|
}
|
|
|
|
#[derive(Deserialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
struct WorkspaceModelsReq {
|
|
workspace_id: Option<String>,
|
|
}
|
|
|
|
#[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<String>,
|
|
}
|
|
|
|
fn dispatch(
|
|
host: &Host,
|
|
cmd: &str,
|
|
payload: JsValue,
|
|
source: &UpdateSource,
|
|
) -> Result<serde_json::Value> {
|
|
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<AnyModel> = 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<Option<Vec<u8>>> {
|
|
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))
|
|
}
|