Files
yaak-mountain-loop/crates/yaak-web/src/lib.rs
T
Gregory Schier 422b1724c9 Run plugins in a QuickJS sandbox in the browser
Adds packages/plugin-sandbox: QuickJS-ng compiled to wasm, running in a
dedicated worker, with a runtime shell inside it that loads a plugin bundle
and answers the same InternalEventPayload events the Node runtime answers.
Plugins are unmodified.

Wires the browser host's template function, authentication, cURL import and
template render commands to it, and relaxes TemplateCallback's Send bound on
wasm32 so the engine's renderer can call back out to a plugin.
2026-08-17 18:48:21 -07:00

703 lines
28 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 PluginKeyValueReq {
plugin_name: String,
key: String,
value: Option<String>,
}
#[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(())
}
// 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<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,
/// 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<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 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<js_sys::Function>,
}
impl TemplateCallback for JsTemplateCallback {
fn run(
&self,
fn_name: &str,
args: HashMap<String, serde_json::Value>,
) -> impl std::future::Future<Output = yaak_templates::error::Result<String>> {
// 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<String> {
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::<js_sys::Function>().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<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, 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<String>,
ignore_error: Option<bool>,
}
/// 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<JsValue> {
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<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))
}