//! Typed HTTP client for the filebrowser-ng API. //! //! Endpoint paths, query params and wire types all come from the shared //! `api_types` crate (the same one the server's route table and handlers //! use), so the two sides cannot drift apart. use leptos::prelude::Callable; use serde::Serialize; use serde::de::DeserializeOwned; use wasm_bindgen::JsCast; use wasm_bindgen::JsValue; use wasm_bindgen::closure::Closure; use wasm_bindgen_futures::JsFuture; use api_types::{ ACTION_CONTENT, ACTION_CREATE_FILE, ACTION_DOWNLOAD, ACTION_MKDIR, ACTION_PREVIEW, ACTION_THUMB, ADMIN_SETTINGS, ADMIN_SHARES, ADMIN_USERS, AUTH_LOGIN, AUTH_LOGOUT, AUTH_ME, AUTH_SETUP, CreateShare, CreateUser, Credentials, FILES, Mutation, P_ACTION, P_FORMAT, P_OVERWRITE, P_PATH, P_Q, P_ROOT, P_SCOPE, P_SHARE, Root, SEARCH, SHARE, SHARE_UNLOCK_SUFFIX, SHARES, Settings, UnlockShare, UpdateUser, }; pub use api_types::{ AdminShare, AdminUser, Entry, FilesResp, Me, Mode, OkResp, Op, RootInfo, SaveResp, ShareInfo, UserInfo, }; #[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, /// The request never got a response (server down, connection lost) or /// the response could not be used. Carries a message ready to display. #[error("{0}")] Net(String), } /// A JS error's `message` (the whole `Debug` output includes the stack). fn js_msg(e: &JsValue) -> String { e.dyn_ref::() .map(|e| String::from(e.message())) .unwrap_or_else(|| format!("{e:?}")) } 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, } } } /// Server error body: `{"error": "...", "code": "..."?, "skipped": [...]?}`. /// The message is already localized in [`fetch_checked`]; `code` carries the /// machine-readable identifier the server sends for known failures. #[derive(serde::Deserialize, Default)] struct ErrBody { #[serde(default)] error: Option, #[serde(default)] code: Option, #[serde(default)] skipped: Option>, } // --------------------------------------------------------------------------- // Auth // --------------------------------------------------------------------------- pub fn me() -> impl std::future::Future> { request("GET", AUTH_ME.to_string(), None::<()>) } /// PUT /api/auth/me — update the signed-in user's profile settings. /// Omitted fields are left unchanged; `language: Some(None)` means "follow /// the browser". #[derive(Serialize, Default)] struct ProfilePatch { #[serde(skip_serializing_if = "Option::is_none")] single_click_open: Option, #[serde(skip_serializing_if = "Option::is_none")] thumbnails: Option, #[serde(skip_serializing_if = "Option::is_none")] language: Option>, } pub fn update_profile( single_click_open: Option, thumbnails: Option, language: Option>, ) -> impl std::future::Future> { request( "PUT", AUTH_ME.to_string(), Some(ProfilePatch { single_click_open, thumbnails, language, }), ) } pub fn login( name: String, password: String, ) -> impl std::future::Future> { request( "POST", AUTH_LOGIN.to_string(), Some(Credentials { name, password }), ) } pub fn setup( name: String, password: String, ) -> impl std::future::Future> { request( "POST", AUTH_SETUP.to_string(), Some(Credentials { name, password }), ) } pub fn logout() -> impl std::future::Future> { request("POST", 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!("?{P_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!("{FILES}/{root_id}") } else { let encoded: Vec = path .split('/') .map(|s| js_sys::encode_uri_component(s).into()) .collect(); format!("{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 an empty file. `path` is relative to the root (may contain /// subfolders); the file must not exist yet. pub fn create_file( root_id: i64, path: &str, ) -> impl std::future::Future> { let url = append_query( &files_url(root_id, path), &format!("{P_ACTION}={ACTION_CREATE_FILE}"), ); request("POST", url, 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> { let url = append_query( &files_url(root_id, path), &format!("{P_ACTION}={ACTION_MKDIR}"), ); request("POST", url, None::<()>) } 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: Op::Rename, 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, Op::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, Op::Copy, dst_root_id, dst, overwrite) } fn mutation( root_id: i64, path: &str, op: Op, dst_root_id: i64, dst: &str, overwrite: bool, ) -> impl std::future::Future> { request( "POST", files_url(root_id, path), Some(Mutation { op, new_name: None, dst_root_id: Some(dst_root_id), dst: Some(dst.to_string()), overwrite, }), ) } pub fn delete_item( root_id: i64, path: &str, ) -> impl std::future::Future> { request("DELETE", files_url(root_id, path), None::<()>) } // --------------------------------------------------------------------------- // 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), &format!("{P_ACTION}={ACTION_DOWNLOAD}"), ); if let Some(f) = format { base = append_query(&base, &format!("{P_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), &format!("{P_ACTION}={ACTION_PREVIEW}"), ) } /// `...?action=thumb` — a small WebP for the grid. /// /// `mtime` is in the query so a changed file is a new URL. The server marks /// the response immutable, which depends on that. pub fn thumb_url(root_id: i64, path: &str, mtime: &str) -> String { append_query( &files_url(root_id, path), &format!( "{P_ACTION}={ACTION_THUMB}&v={}", js_sys::encode_uri_component(mtime) ), ) } /// `...?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), &format!("{P_ACTION}={ACTION_CONTENT}"), ) } /// Fetch a file's raw text content plus its mtime (unix seconds), for the /// preview and 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 opts = web_sys::RequestInit::new(); opts.set_method("GET"); opts.set_mode(web_sys::RequestMode::SameOrigin); let resp = fetch_checked(&content_url(root_id, path), &opts, "could not read file").await?; 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(js_msg(&e)))?; let js = JsFuture::from(tp) .await .map_err(|e| ApiError::Net(js_msg(&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 url = content_url(root_id, path); 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(js_msg(&e)))?; headers .set("Content-Type", "text/plain; charset=utf-8") .map_err(|e| ApiError::Net(js_msg(&e)))?; if !force && let Some(m) = expected_mtime { headers .set("X-Expected-Mtime", &m.to_string()) .map_err(|e| ApiError::Net(js_msg(&e)))?; } opts.set_headers_headers(&headers); // 409 is the server's "changed on disk" answer, not a generic HTTP error. let resp = match fetch_checked(&url, &opts, "could not save file").await { Ok(resp) => resp, Err(e) if e.status() == Some(409) => return Err(ApiError::Conflict), Err(e) => return Err(e), }; let save: SaveResp = read_json(&resp).await?; Ok(save.mtime) } /// Open a URL in a new tab. /// /// `noopener` severs the `window.opener` link, so the opened page cannot /// script this one. That matters here because the target is a *user file*: /// HTML and SVG render as real documents (under the server's sandbox CSP, see /// `FILE_CSP`), and this is the browser-side half of the same isolation. pub fn open_in_new_tab(url: &str) { if let Some(w) = web_sys::window() { let _ = w.open_with_url_and_target_and_features(url, "_blank", "noopener"); } } /// 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<(), ApiError> { 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(js_msg(&e)))?; let url = append_query( &files_url(root_id, dir), &format!("{P_OVERWRITE}={}", if overwrite { "true" } else { "false" }), ); let headers = web_sys::Headers::new().map_err(|e| ApiError::Net(js_msg(&e)))?; headers .set( "Content-Type", &format!("multipart/form-data; boundary={boundary}"), ) .map_err(|e| ApiError::Net(js_msg(&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)); // The error carries the server's `skipped` list on an upload conflict. fetch_checked(&url, &opts, "upload failed").await?; Ok(()) } // --------------------------------------------------------------------------- // Shares (milestone 6) // --------------------------------------------------------------------------- pub fn list_shares() -> impl std::future::Future, ApiError>> { request("GET", SHARES.to_string(), None::<()>) } pub fn create_share( root_id: i64, path: &str, writable: bool, expires_at: Option<&str>, password: Option<&str>, ) -> impl std::future::Future> { request( "POST", SHARES.to_string(), Some(CreateShare { root_id, path: path.to_string(), writable, expires_at: expires_at.map(|s| s.to_string()), password: password.map(|s| s.to_string()), }), ) } pub fn delete_share(id: i64) -> impl std::future::Future> { request("DELETE", format!("{SHARES}/{id}"), None::<()>) } /// Admin only: every share on the server with its creator. pub fn list_all_shares() -> impl std::future::Future, ApiError>> { request("GET", ADMIN_SHARES.to_string(), None::<()>) } /// Admin only: end a share whoever created it. pub fn admin_delete_share(id: i64) -> impl std::future::Future> { request("DELETE", format!("{ADMIN_SHARES}/{id}"), None::<()>) } /// Public: resolve a share (no session required). A password-protected /// share answers 401 until [`unlock_share`] has run in this browser. pub fn resolve_share( token: &str, ) -> impl std::future::Future> { request("GET", format!("{SHARE}/{token}"), None::<()>) } /// Public: submit a protected share's password. The proof of the unlock is /// a cookie the server sets, so nothing has to be kept here. pub fn unlock_share( token: &str, password: &str, ) -> impl std::future::Future> { request( "POST", format!("{SHARE}/{token}{SHARE_UNLOCK_SUFFIX}"), Some(UnlockShare { password: password.to_string(), }), ) } // --------------------------------------------------------------------------- // Admin (milestone 7): user management + settings // --------------------------------------------------------------------------- fn roots_to_bodies(roots: &[(String, Mode)]) -> Vec { roots .iter() .map(|(path, mode)| Root { path: path.clone(), mode: *mode, }) .collect() } pub fn list_admin_users() -> impl std::future::Future, ApiError>> { request("GET", ADMIN_USERS.to_string(), None::<()>) } pub fn create_admin_user( name: &str, password: &str, is_admin: bool, roots: &[(String, Mode)], ) -> impl std::future::Future> { request( "POST", ADMIN_USERS.to_string(), Some(CreateUser { 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!("{ADMIN_USERS}/{id}"), Some(UpdateUser { password, is_admin, active, roots: roots.map(|r| roots_to_bodies(&r)), }), ) } pub fn delete_admin_user(id: i64) -> impl std::future::Future> { request("DELETE", format!("{ADMIN_USERS}/{id}"), None::<()>) } pub fn get_admin_settings() -> impl std::future::Future> { request("GET", ADMIN_SETTINGS.to_string(), None::<()>) } /// PUT replaces the whole settings object, so every setting has to be /// passed. Omitting one would reset it. pub fn update_admin_settings( allow_writable_shares: bool, search_excludes: Vec, ) -> impl std::future::Future> { request( "PUT", ADMIN_SETTINGS.to_string(), Some(Settings { allow_writable_shares, search_excludes, }), ) } // --------------------------------------------------------------------------- // 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 } /// 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", ""); } // Known small leak, deliberate: the input holds the listener, the // listener holds this closure, and the closure captures the input — a // cycle that nothing frees. `forget()` detaches the Rust side, so the // element + JS function + closure (~1 KB) survive until reload even // when the dialog is used (not only when cancelled). Dropping the // closure from Rust while the JS listener still references it would // leave a dangling callback, and a closure cannot drop itself; a clean // fix needs the caller to own it (e.g. a `StoredValue` released in // `on_cleanup`), which is not worth it at this size. 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 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(&url, &opts).await } /// Run one fetch and return the response, turning any non-2xx status into /// [`ApiError::Http`]. `fallback_msg` is used when the error body has no /// message. Callers that need the raw response (text, a header, or nothing at /// all) use this directly; JSON callers go through [`do_fetch`]. async fn fetch_checked( url: &str, opts: &web_sys::RequestInit, fallback_msg: &str, ) -> Result { let window = web_sys::window().ok_or_else(|| ApiError::Net("no window available".to_string()))?; let req = web_sys::Request::new_with_str_and_init(url, opts) .map_err(|e| ApiError::Net(js_msg(&e)))?; let promise = window.fetch_with_request(&req); // `fetch` only rejects when no response arrived at all (server down, // connection lost, blocked): one short localized line instead of the // JS stack. let resp_val = JsFuture::from(promise).await.map_err(|_| { ApiError::Net(crate::i18n::t(crate::i18n::k::SERVER_UNREACHABLE).to_string()) })?; 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; let fallback = body .error .clone() .unwrap_or_else(|| fallback_msg.to_string()); return Err(ApiError::Http { status, // Known server errors carry a code the client maps to a // localized message; unknown ones fall back to the raw text. message: crate::i18n::error_text(body.code.as_deref(), &fallback), skipped: body.skipped, }); } Ok(resp) } async fn do_fetch( url: &str, opts: &web_sys::RequestInit, ) -> Result { let resp = fetch_checked(url, opts, "request failed").await?; read_json(&resp).await } /// Read a response body as text and parse it with serde_json. /// /// Going through text rather than `Response::json()` keeps one JSON /// implementation in play. It also keeps the server's 64-bit integers exact: /// a detour through a JS value would round file sizes through an f64. async fn read_json(resp: &web_sys::Response) -> Result { let text = response_text(resp) .await .ok_or_else(|| ApiError::Net("could not read the response body".to_string()))?; serde_json::from_str(&text).map_err(|e| ApiError::Net(format!("response is not JSON: {e}"))) } async fn response_text(resp: &web_sys::Response) -> Option { JsFuture::from(resp.text().ok()?).await.ok()?.as_string() } /// Parse the server's error JSON (message + optional conflict list). /// An unparseable body yields the default, and the caller's fallback message. async fn parse_error_body(resp: &web_sys::Response) -> ErrBody { response_text(resp) .await .and_then(|t| serde_json::from_str(&t).ok()) .unwrap_or_default() } // --------------------------------------------------------------------------- // Search (streamed) // --------------------------------------------------------------------------- /// Start a search and stream its results as they are found. /// /// [`web_sys::EventSource`] is the platform's SSE client: it frames the /// stream and parses the events. `on_event` runs once per event on the main /// thread; `.close()` on the returned source stops the search (the server /// notices the dropped connection and unwinds its walk). /// /// A stream that ends or dies mid-way simply stops, without a `done` event. /// An `EventSource` cannot read the server's JSON error body, so a rejected /// request reports one generic message. pub fn search_stream( q: String, scope: &str, root: i64, // `path`: folder inside the root to start in ("" = the whole root). path: &str, on_event: leptos::prelude::Callback, // A plain closure, not a `Callback`: the caller may run from a render // closure whose owner is disposed on the next re-render, which would // dispose a `Callback` created there before the stream fails. on_error: impl Fn(String) + 'static, ) -> Result { let url = format!( "{SEARCH}?{P_Q}={}&{P_SCOPE}={scope}&{P_ROOT}={root}&{P_PATH}={}", js_sys::encode_uri_component(&q), js_sys::encode_uri_component(path), ); let src = web_sys::EventSource::new(&url).map_err(|e| ApiError::Net(js_msg(&e)))?; // The server always ends a search with `Done`. `error` fires after that // for the normal end of the stream too (a reconnect pending), so the flag // tells a finished search from a dropped or refused connection. let done = std::rc::Rc::new(std::cell::Cell::new(false)); let done2 = done.clone(); let on_msg = Closure::::new(move |ev: web_sys::MessageEvent| { let Some(data) = ev.data().as_string() else { return; }; if let Ok(ev) = serde_json::from_str::(&data) { if matches!(ev, api_types::SearchEvent::Done { .. }) { done2.set(true); } on_event.run(ev); } }); src.set_onmessage(Some(on_msg.as_ref().unchecked_ref())); on_msg.forget(); // A reconnect would re-run the whole search, so close the source either // way. `CLOSED` means the server answered and rejected the request (401, // 403, 400); anything else with no `Done` is a lost connection. let s = src.clone(); let on_err = Closure::::new(move |_| { let rejected = s.ready_state() == web_sys::EventSource::CLOSED; s.close(); if done.get() { return; } let key = if rejected { crate::i18n::k::SEARCH_FAILED } else { crate::i18n::k::SERVER_UNREACHABLE }; on_error(crate::i18n::t(key).to_string()); }); src.set_onerror(Some(on_err.as_ref().unchecked_ref())); on_err.forget(); Ok(src) } /// 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() }