Files
yaak-mountain-loop/crates/yaak-wasm/src/lib.rs
T

585 lines
23 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, 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<ModelPayload>,
}
fn lifecycle_host() -> yaak_lifecycle::Host {
yaak_lifecycle::Host::owner()
}
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)?;
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<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>,
}
#[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<Cookie>,
after: Vec<Cookie>,
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct InsertResponseEventsReq {
response_id: String,
workspace_id: String,
events: Vec<HttpResponseEventData>,
}
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()),
"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(())
}
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<String>,
cookie_jar_id: Option<String>,
}
/// 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,
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<HttpResponseEventData>,
/// The jar the send starts with, so the tab can write it back with the proxy's changes.
cookie_jar: Option<CookieJar>,
}
/// A template callback for a host with no plugins. Variables render; a function is a clear
/// refusal naming the function, so the user knows what the request needs rather than seeing
/// an empty string sent in its place.
struct NoPluginsCallback;
impl TemplateCallback for NoPluginsCallback {
fn run(
&self,
fn_name: &str,
_args: HashMap<String, serde_json::Value>,
) -> impl std::future::Future<Output = yaak_templates::error::Result<String>> + Send {
let message = format!(
"This request uses the template function \"{fn_name}\", which needs plugins. \
Plugins aren't available in the browser yet"
);
async move { Err(yaak_templates::error::Error::RenderError(message)) }
}
fn transform_arg(
&self,
_fn_name: &str,
_arg_name: &str,
arg_value: &str,
) -> yaak_templates::error::Result<String> {
Ok(arg_value.to_string())
}
}
/// 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 Yaak server.
///
/// Refuses, with a message the user can act on, when the request needs something this host
/// doesn't have: an authentication plugin, or a template function.
#[wasm_bindgen]
pub async fn prepare_http_send(payload: JsValue) -> Result<JsValue> {
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) = 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))
})?;
let rendered = render_http_request(
&request,
environment_chain,
&NoPluginsCallback,
&RenderOptions::throw(),
)
.await
.map_err(js_error)?;
// Authentication is applied by a plugin on the desktop. There is no plugin here, and a
// request sent without the auth it asked for is worse than one refused with the reason.
let auth_disabled =
rendered.authentication.get("disabled").and_then(|v| v.as_bool()) == Some(true);
if let Some(auth_type) = rendered.authentication_type.as_deref()
&& auth_type != "none"
&& !auth_disabled
{
return Err(js_error(format!(
"This request uses {auth_type} authentication, which needs plugins. \
Plugins aren't available in the browser yet"
)));
}
let prepared = PreparedHttpSend {
request: rendered,
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)
}
/* -------------------------------------------------------------------------- */
/* 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))
}