use serde::de::DeserializeOwned; use serde::{Deserialize, Serialize}; use wasm_bindgen::JsCast; use wasm_bindgen::JsValue; use wasm_bindgen_futures::JsFuture; #[derive(Debug, thiserror::Error)] pub enum ApiError { /// Non-2xx response. `skipped` carries the server's conflict file list /// when present (upload conflicts). #[error("{message}")] Http { #[allow(dead_code)] status: u16, message: String, skipped: Option>, }, /// The file changed on disk since it was read (save conflict, HTTP 409). #[error("the file was changed on disk")] Conflict, #[error("network error: {0}")] Net(String), } impl ApiError { pub fn skipped(&self) -> Option<&[String]> { match self { ApiError::Http { skipped: Some(s), .. } => Some(s), _ => None, } } /// The HTTP status code, when this was an HTTP (non-2xx) error. pub fn status(&self) -> Option { match self { ApiError::Http { status, .. } => Some(*status), _ => None, } } } #[derive(Deserialize, Clone)] pub struct Me { pub first_boot: bool, #[serde(default)] pub user: Option, #[serde(default)] pub roots: Vec, /// Whether the server allows users to create writable (read-write) shares. #[serde(default)] pub allow_writable_shares: bool, } #[derive(Deserialize, Clone)] pub struct UserInfo { #[allow(dead_code)] // used from milestone 7 onwards pub id: i64, pub name: String, pub is_admin: bool, } #[derive(Deserialize, Clone)] pub struct RootInfo { pub id: i64, pub name: String, pub path: String, pub mode: String, } #[derive(Deserialize, Clone, Debug, PartialEq)] pub struct Entry { pub name: String, pub is_dir: bool, pub size: u64, pub mtime: String, } #[derive(Deserialize)] pub struct FilesResp { pub entries: Vec, } #[derive(Deserialize)] pub struct UploadResp { #[allow(dead_code)] pub uploaded: usize, } /// Acknowledges a successful 2xx response whose body we don't care about. /// Accepts any JSON value (the server sends `{"ok": true}`). pub struct OkResp; impl<'de> Deserialize<'de> for OkResp { fn deserialize>(d: D) -> Result { serde::de::IgnoredAny::deserialize(d)?; Ok(OkResp) } } #[derive(Serialize)] struct CredentialsBody { name: String, password: String, } /// Server error body: `{"error": "...", "skipped": [...]?}`. #[derive(Deserialize, Default)] struct ErrBody { #[serde(default)] error: Option, #[serde(default)] skipped: Option>, } // --------------------------------------------------------------------------- // Auth // --------------------------------------------------------------------------- pub fn me() -> impl std::future::Future> { request("GET", "/api/auth/me".to_string(), None::<()>) } pub fn login( name: String, password: String, ) -> impl std::future::Future> { request( "POST", "/api/auth/login".to_string(), Some(CredentialsBody { name, password }), ) } pub fn setup( name: String, password: String, ) -> impl std::future::Future> { request( "POST", "/api/auth/setup".to_string(), Some(CredentialsBody { name, password }), ) } pub fn logout() -> impl std::future::Future> { request("POST", "/api/auth/logout".to_string(), Some(())) } // --------------------------------------------------------------------------- // Files // --------------------------------------------------------------------------- /// The public share token while the app is showing a share page. Every file /// API call appends `?share=` (or `&share=`). Only one share is /// shown per page, so a plain static suffices (wasm is single-threaded). use std::sync::Mutex; static SHARE_TOKEN: Mutex> = Mutex::new(None); /// Set (or clear, with `None`) the share token used by file API calls. pub fn set_share_token(token: Option<&str>) { *SHARE_TOKEN.lock().unwrap() = token.map(|s| s.to_string()); } fn share_suffix() -> String { SHARE_TOKEN .lock() .unwrap() .as_deref() .map(|t| format!("?share={t}")) .unwrap_or_default() } /// Append `key=value` to a URL, using `&` when a query string already exists. fn append_query(url: &str, kv: &str) -> String { if url.contains('?') { format!("{url}&{kv}") } else { format!("{url}?{kv}") } } fn files_url(root_id: i64, path: &str) -> String { let base = if path.is_empty() { format!("/api/files/{root_id}") } else { let encoded: Vec = path .split('/') .map(|s| js_sys::encode_uri_component(s).into()) .collect(); format!("/api/files/{root_id}/{}", encoded.join("/")) }; format!("{base}{}", share_suffix()) } pub fn list_files( root_id: i64, path: &str, ) -> impl std::future::Future> { request("GET", files_url(root_id, path), None::<()>) } /// Create a folder. `path` is relative to the root (may contain subfolders). pub fn mkdir( root_id: i64, path: &str, ) -> impl std::future::Future> { request_no_body("POST", files_url(root_id, path)) } pub fn rename_item( root_id: i64, path: &str, new_name: String, overwrite: bool, ) -> impl std::future::Future> { request( "POST", files_url(root_id, path), Some(Mutation { op: "rename".to_string(), new_name: Some(new_name), dst_root_id: None, dst: None, overwrite, }), ) } pub fn move_item( root_id: i64, path: &str, dst_root_id: i64, dst: &str, overwrite: bool, ) -> impl std::future::Future> { mutation(root_id, path, "move", dst_root_id, dst, overwrite) } pub fn copy_item( root_id: i64, path: &str, dst_root_id: i64, dst: &str, overwrite: bool, ) -> impl std::future::Future> { mutation(root_id, path, "copy", dst_root_id, dst, overwrite) } fn mutation( root_id: i64, path: &str, op: &str, dst_root_id: i64, dst: &str, overwrite: bool, ) -> impl std::future::Future> { request( "POST", files_url(root_id, path), Some(Mutation { op: op.to_string(), new_name: None, dst_root_id: Some(dst_root_id), dst: Some(dst.to_string()), overwrite, }), ) } #[derive(Serialize)] struct Mutation { op: String, #[serde(skip_serializing_if = "Option::is_none")] new_name: Option, #[serde(skip_serializing_if = "Option::is_none")] dst_root_id: Option, #[serde(skip_serializing_if = "Option::is_none")] dst: Option, overwrite: bool, } pub fn delete_item( root_id: i64, path: &str, ) -> impl std::future::Future> { request_no_body("DELETE", files_url(root_id, path)) } // --------------------------------------------------------------------------- // Download / preview / content (milestone 4) // --------------------------------------------------------------------------- /// `...?action=download` — a single file as-is, or a folder as `format`. pub fn download_url(root_id: i64, path: &str, format: Option<&str>) -> String { let mut base = append_query(&files_url(root_id, path), "action=download"); if let Some(f) = format { base = append_query(&base, &format!("format={f}")); } base } /// `...?action=preview` — a single file, inline (native media). pub fn preview_url(root_id: i64, path: &str) -> String { append_query(&files_url(root_id, path), "action=preview") } /// `...?action=content` — raw file bytes for the text preview/editor. pub fn content_url(root_id: i64, path: &str) -> String { append_query(&files_url(root_id, path), "action=content") } /// Fetch a file's raw text content (for the CodeMirror preview/editor). pub async fn fetch_content(root_id: i64, path: &str) -> Result { let window = web_sys::window().ok_or_else(|| ApiError::Net("no window available".to_string()))?; let opts = web_sys::RequestInit::new(); opts.set_method("GET"); opts.set_mode(web_sys::RequestMode::SameOrigin); let req = web_sys::Request::new_with_str_and_init(&content_url(root_id, path), &opts) .map_err(|e| ApiError::Net(format!("{e:?}")))?; let promise = window.fetch_with_request(&req); let resp_val = JsFuture::from(promise) .await .map_err(|e| ApiError::Net(format!("{e:?}")))?; let resp: web_sys::Response = resp_val .dyn_into() .map_err(|_| ApiError::Net("fetch did not return a Response".to_string()))?; let status = resp.status(); if !(200..300).contains(&status) { let body = parse_error_body(&resp).await; return Err(ApiError::Http { status, message: body .error .unwrap_or_else(|| "could not read file".to_string()), skipped: None, }); } let tp = resp.text().map_err(|e| ApiError::Net(format!("{e:?}")))?; let js = JsFuture::from(tp) .await .map_err(|e| ApiError::Net(format!("{e:?}")))?; js.as_string() .ok_or_else(|| ApiError::Net("content is not a string".to_string())) } /// Fetch a file's raw text content plus its mtime (unix seconds), for the /// editor. The mtime anchors the save-time conflict check. pub async fn fetch_content_meta( root_id: i64, path: &str, ) -> Result<(String, Option), ApiError> { let window = web_sys::window().ok_or_else(|| ApiError::Net("no window available".to_string()))?; let opts = web_sys::RequestInit::new(); opts.set_method("GET"); opts.set_mode(web_sys::RequestMode::SameOrigin); let req = web_sys::Request::new_with_str_and_init(&content_url(root_id, path), &opts) .map_err(|e| ApiError::Net(format!("{e:?}")))?; let promise = window.fetch_with_request(&req); let resp_val = JsFuture::from(promise) .await .map_err(|e| ApiError::Net(format!("{e:?}")))?; let resp: web_sys::Response = resp_val .dyn_into() .map_err(|_| ApiError::Net("fetch did not return a Response".to_string()))?; let status = resp.status(); if !(200..300).contains(&status) { let body = parse_error_body(&resp).await; return Err(ApiError::Http { status, message: body .error .unwrap_or_else(|| "could not read file".to_string()), skipped: None, }); } let mtime: Option = resp .headers() .get("x-file-mtime") .ok() .flatten() .and_then(|s| s.parse::().ok()); let tp = resp.text().map_err(|e| ApiError::Net(format!("{e:?}")))?; let js = JsFuture::from(tp) .await .map_err(|e| ApiError::Net(format!("{e:?}")))?; let text = js .as_string() .ok_or_else(|| ApiError::Net("content is not a string".to_string()))?; Ok((text, mtime)) } /// Save a file's text content (the editor's write path). /// /// When `force` is false, `expected_mtime` is sent and the server rejects the /// save with [`ApiError::Conflict`] if the file changed on disk since it was /// read. When `force` is true the check is skipped (overwrite). Returns the /// file's new mtime (unix seconds) to anchor the next check. pub async fn save_content( root_id: i64, path: &str, text: &str, expected_mtime: Option, force: bool, ) -> Result { let window = web_sys::window().ok_or_else(|| ApiError::Net("no window available".to_string()))?; let url = append_query(&files_url(root_id, path), "action=content"); let opts = web_sys::RequestInit::new(); opts.set_method("PUT"); opts.set_mode(web_sys::RequestMode::SameOrigin); opts.set_body_opt_str(Some(text)); let headers = web_sys::Headers::new() .map_err(|e| ApiError::Net(format!("could not create headers: {e:?}")))?; headers .set("Content-Type", "text/plain; charset=utf-8") .map_err(|e| ApiError::Net(format!("could not set header: {e:?}")))?; if !force && let Some(m) = expected_mtime { headers .set("X-Expected-Mtime", &m.to_string()) .map_err(|e| ApiError::Net(format!("could not set header: {e:?}")))?; } opts.set_headers_headers(&headers); let req = web_sys::Request::new_with_str_and_init(&url, &opts) .map_err(|e| ApiError::Net(format!("{e:?}")))?; let promise = window.fetch_with_request(&req); let resp_val = JsFuture::from(promise) .await .map_err(|e| ApiError::Net(format!("{e:?}")))?; let resp: web_sys::Response = resp_val .dyn_into() .map_err(|_| ApiError::Net("fetch did not return a Response".to_string()))?; let status = resp.status(); if status == 409 { return Err(ApiError::Conflict); } if !(200..300).contains(&status) { let body = parse_error_body(&resp).await; return Err(ApiError::Http { status, message: body .error .unwrap_or_else(|| "could not save file".to_string()), skipped: None, }); } let js = resp.json().map_err(|e| ApiError::Net(format!("{e:?}")))?; let js: JsValue = JsFuture::from(js) .await .map_err(|e| ApiError::Net(format!("response is not JSON: {e:?}")))?; #[derive(Deserialize)] struct SaveResp { #[serde(default)] mtime: Option, } let save: SaveResp = serde_wasm_bindgen::from_value(js).map_err(|e| ApiError::Net(e.to_string()))?; Ok(save.mtime.unwrap_or(0)) } /// Trigger a browser download of a same-origin URL via a temporary anchor. /// No data is pulled into JS memory — the browser streams it. pub fn trigger_download(url: &str, filename: &str) { let Some(doc) = web_sys::window().and_then(|w| w.document()) else { return; }; let Ok(el) = doc.create_element("a") else { return; }; let Ok(a) = el.dyn_into::() else { return; }; a.set_href(url); a.set_download(filename); if let Some(body) = doc.body() { let _ = body.append_child(&a); } a.click(); a.remove(); } /// Upload files into a directory. Each part is `(relative_path, file)`; /// the relative path may contain subfolders (created on the server). /// /// The multipart body is assembled by hand into a `Blob` (instead of using /// `FormData` directly as the fetch body): a FormData body makes Chrome send /// the request as a *streaming* body, which forces the HTTP/2-cleartext /// (h2c/ALPN) path and fails against an HTTP/1.1-only server with /// `ERR_ALPN_NEGOTIATION_FAILED`. A pre-assembled Blob has a known size, so /// it goes out as a regular length-prefixed HTTP/1.1 request. pub async fn upload( root_id: i64, dir: &str, overwrite: bool, parts: Vec<(String, web_sys::File)>, ) -> Result { let window = web_sys::window().ok_or_else(|| ApiError::Net("no window available".to_string()))?; let boundary = format!("----fbng{}", random_boundary_suffix()); let segments = js_sys::Array::new(); for (name, file) in &parts { let basename = name.rsplit('/').next().unwrap_or(name); segments.push(&JsValue::from_str(&format!( "--{boundary}\r\n\ Content-Disposition: form-data; name=\"{name}\"; filename=\"{basename}\"\r\n\ Content-Type: application/octet-stream\r\n\r\n" ))); let f: JsValue = file.clone().unchecked_into(); segments.push(&f); segments.push(&JsValue::from_str("\r\n")); } segments.push(&JsValue::from_str(&format!("--{boundary}--\r\n"))); let body = web_sys::Blob::new_with_buffer_source_sequence(&segments) .map_err(|e| ApiError::Net(format!("could not build upload body: {e:?}")))?; let url = append_query( &files_url(root_id, dir), &format!("overwrite={}", if overwrite { "true" } else { "false" }), ); let headers = web_sys::Headers::new() .map_err(|e| ApiError::Net(format!("could not create headers: {e:?}")))?; headers .set( "Content-Type", &format!("multipart/form-data; boundary={boundary}"), ) .map_err(|e| ApiError::Net(format!("could not set content-type: {e:?}")))?; let opts = web_sys::RequestInit::new(); opts.set_method("POST"); opts.set_mode(web_sys::RequestMode::SameOrigin); opts.set_headers_headers(&headers); opts.set_body_opt_blob(Some(&body)); let promise = window.fetch_with_str_and_init(&url, &opts); let resp_val = JsFuture::from(promise) .await .map_err(|e| ApiError::Net(format!("{e:?}")))?; let resp: web_sys::Response = resp_val .dyn_into() .map_err(|_| ApiError::Net("fetch did not return a Response".to_string()))?; let status = resp.status(); if !(200..300).contains(&status) { let body = parse_error_body(&resp).await; return Err(ApiError::Http { status, message: body.error.unwrap_or_else(|| "upload failed".to_string()), skipped: body.skipped, }); } let js = resp.json().map_err(|e| ApiError::Net(format!("{e:?}")))?; let js: JsValue = JsFuture::from(js) .await .map_err(|e| ApiError::Net(format!("response is not JSON: {e:?}")))?; serde_wasm_bindgen::from_value(js).map_err(|e| ApiError::Net(e.to_string())) } // --------------------------------------------------------------------------- // Shares (milestone 6) // --------------------------------------------------------------------------- #[derive(Deserialize, Clone)] pub struct ShareInfo { pub id: i64, pub token: String, pub name: String, pub is_file: bool, pub writable: bool, pub target: String, pub created_at: String, #[serde(default)] pub expires_at: Option, /// The synthetic root id to use in file API calls. #[serde(default)] pub root_id: Option, } #[derive(Serialize)] struct CreateShareBody { root_id: i64, path: String, writable: bool, #[serde(skip_serializing_if = "Option::is_none")] expires_at: Option, } pub fn list_shares() -> impl std::future::Future, ApiError>> { request("GET", "/api/shares".to_string(), None::<()>) } pub fn create_share( root_id: i64, path: &str, writable: bool, expires_at: Option<&str>, ) -> impl std::future::Future> { request( "POST", "/api/shares".to_string(), Some(CreateShareBody { root_id, path: path.to_string(), writable, expires_at: expires_at.map(|s| s.to_string()), }), ) } pub fn delete_share(id: i64) -> impl std::future::Future> { request_no_body("DELETE", format!("/api/shares/{id}")) } /// Public: resolve a share (no session required). pub fn resolve_share( token: &str, ) -> impl std::future::Future> { request("GET", format!("/api/share/{token}"), None::<()>) } // --------------------------------------------------------------------------- // Admin (milestone 7): user management + settings // --------------------------------------------------------------------------- #[derive(Deserialize, Clone)] pub struct AdminUser { pub id: i64, pub name: String, pub is_admin: bool, pub active: bool, pub roots: Vec, } #[derive(Serialize)] struct AdminRootBody { path: String, mode: String, } #[derive(Serialize)] struct CreateAdminUserBody { name: String, password: String, is_admin: bool, roots: Vec, } #[derive(Serialize)] struct UpdateAdminUserBody { #[serde(skip_serializing_if = "Option::is_none")] password: Option, #[serde(skip_serializing_if = "Option::is_none")] is_admin: Option, #[serde(skip_serializing_if = "Option::is_none")] active: Option, #[serde(skip_serializing_if = "Option::is_none")] roots: Option>, } #[derive(Serialize, Deserialize, Clone)] pub struct AdminSettings { pub allow_writable_shares: bool, } fn roots_to_bodies(roots: &[(String, String)]) -> Vec { roots .iter() .map(|(path, mode)| AdminRootBody { path: path.clone(), mode: mode.clone(), }) .collect() } pub fn list_admin_users() -> impl std::future::Future, ApiError>> { request("GET", "/api/admin/users".to_string(), None::<()>) } pub fn create_admin_user( name: &str, password: &str, is_admin: bool, roots: &[(String, String)], ) -> impl std::future::Future> { request( "POST", "/api/admin/users".to_string(), Some(CreateAdminUserBody { name: name.to_string(), password: password.to_string(), is_admin, roots: roots_to_bodies(roots), }), ) } pub fn update_admin_user( id: i64, password: Option, is_admin: Option, active: Option, roots: Option>, ) -> impl std::future::Future> { request( "PUT", format!("/api/admin/users/{id}"), Some(UpdateAdminUserBody { password, is_admin, active, roots: roots.map(|r| roots_to_bodies(&r)), }), ) } pub fn delete_admin_user(id: i64) -> impl std::future::Future> { request_no_body("DELETE", format!("/api/admin/users/{id}")) } pub fn get_admin_settings() -> impl std::future::Future> { request("GET", "/api/admin/settings".to_string(), None::<()>) } pub fn update_admin_settings( allow_writable_shares: bool, ) -> impl std::future::Future> { request( "PUT", "/api/admin/settings".to_string(), Some(AdminSettings { allow_writable_shares, }), ) } // --------------------------------------------------------------------------- // File picker (imperative, one at a time) // --------------------------------------------------------------------------- fn random_boundary_suffix() -> String { let mut s = String::with_capacity(16); for _ in 0..16 { let n = (js_sys::Math::random() * 36.0) as u32; s.push(char::from_digit(n, 36).unwrap_or('a')); } s } use wasm_bindgen::closure::Closure; /// Open the native file dialog and run `on_files` with the picked files once /// the user confirms. `directory` uses webkitdirectory (folder upload). fn webkit_relative_path(file: &web_sys::File) -> String { let js: JsValue = file.into(); js_sys::Reflect::get(&js, &JsValue::from_str("webkitRelativePath")) .ok() .and_then(|v| v.as_string()) .filter(|s| !s.is_empty()) .unwrap_or_default() } pub fn pick_files( multiple: bool, directory: bool, on_files: impl Fn(Vec<(String, web_sys::File)>) + 'static, ) { let Some(doc) = web_sys::window().and_then(|w| w.document()) else { return; }; let Ok(el) = doc.create_element("input") else { return; }; let Ok(input) = el.dyn_into::() else { return; }; input.set_type("file"); if multiple { input.set_multiple(true); } if directory { let _ = input.set_attribute("webkitdirectory", ""); } // The listener keeps the JS callback alive while the input exists; // detach from Rust (the input is removed after the dialog is used). // A cancelled dialog leaks the hidden input until reload — acceptable. let input2 = input.clone(); let closure = Closure::::new(move || { let mut files: Vec<(String, web_sys::File)> = Vec::new(); if let Some(list) = input.files() { for i in 0..list.length() { if let Some(f) = list.get(i) { let rel = webkit_relative_path(&f); let name = if rel.is_empty() { f.name() } else { rel }; files.push((name, f)); } } } input.remove(); if !files.is_empty() { on_files(files); } }); let listener: &js_sys::Function = closure.as_js_value().unchecked_ref(); let _ = input2.add_event_listener_with_callback("change", listener); closure.forget(); if let Some(body) = doc.body() { let _ = body.append_child(&input2); } input2.click(); } // --------------------------------------------------------------------------- // Low-level request helpers // --------------------------------------------------------------------------- async fn request( method: &str, url: String, body: Option, ) -> Result { let window = web_sys::window().ok_or_else(|| ApiError::Net("no window available".to_string()))?; let opts = web_sys::RequestInit::new(); opts.set_method(method); opts.set_mode(web_sys::RequestMode::SameOrigin); if let Some(body) = body { let json = serde_json_to_string(&body).map_err(|e| ApiError::Net(e.to_string()))?; opts.set_body_opt_str(Some(&json)); let headers = web_sys::Headers::new().expect("Headers constructor failed"); let _ = headers.set("Content-Type", "application/json"); opts.set_headers_headers(&headers); } do_fetch(window, url, opts).await } /// Request without a body (mkdir / delete). async fn request_no_body(method: &str, url: String) -> Result { let window = web_sys::window().ok_or_else(|| ApiError::Net("no window available".to_string()))?; let opts = web_sys::RequestInit::new(); opts.set_method(method); opts.set_mode(web_sys::RequestMode::SameOrigin); do_fetch(window, url, opts).await } async fn do_fetch( window: web_sys::Window, url: String, opts: web_sys::RequestInit, ) -> Result { let req = web_sys::Request::new_with_str_and_init(&url, &opts) .map_err(|e| ApiError::Net(format!("{e:?}")))?; let promise = window.fetch_with_request(&req); let resp_val = JsFuture::from(promise) .await .map_err(|e| ApiError::Net(format!("{e:?}")))?; let resp: web_sys::Response = resp_val .dyn_into() .map_err(|_| ApiError::Net("fetch did not return a Response".to_string()))?; let status = resp.status(); if !(200..300).contains(&status) { let body = parse_error_body(&resp).await; return Err(ApiError::Http { status, message: body.error.unwrap_or_else(|| "request failed".to_string()), skipped: body.skipped, }); } let json_promise = resp.json().map_err(|e| ApiError::Net(format!("{e:?}")))?; let js: JsValue = JsFuture::from(json_promise) .await .map_err(|e| ApiError::Net(format!("response is not JSON: {e:?}")))?; serde_wasm_bindgen::from_value(js).map_err(|e| ApiError::Net(e.to_string())) } /// Parse the server's error JSON (message + optional conflict list). async fn parse_error_body(resp: &web_sys::Response) -> ErrBody { let Ok(promise) = resp.text() else { return ErrBody::default(); }; let Ok(js) = JsFuture::from(promise).await else { return ErrBody::default(); }; let Some(text) = js.as_string() else { return ErrBody::default(); }; if let Ok(v) = js_sys::JSON::parse(&text) && let Ok(body) = serde_wasm_bindgen::from_value::(v) { return body; } // Fallback: naive extraction of the "error" string. const MARKER: &str = "\"error\":\""; let error = text.find(MARKER).and_then(|start| { let rest = &text[start + MARKER.len()..]; rest.find('"').map(|end| rest[..end].replace("\\\"", "\"")) }); ErrBody { error, skipped: None, } } /// Read a form input's value by element id. pub fn input_value(id: &str) -> String { web_sys::window() .and_then(|w| w.document()) .and_then(|d| { d.get_element_by_id(id) .and_then(|el| el.dyn_into::().ok()) }) .map(|i| i.value()) .unwrap_or_default() } fn serde_json_to_string(v: &impl Serialize) -> Result { // Reuse the wasm-bindgen JSON serializer; the body must be a plain string // so we convert via JSON text. let value = serde_wasm_bindgen::to_value(v)?; let s = js_sys::JSON::stringify(&value) .map_err(|e| serde_wasm_bindgen::Error::new(format!("JSON.stringify failed: {e:?}")))?; s.as_string() .ok_or_else(|| serde_wasm_bindgen::Error::new("stringify returned a non-string")) }