Skip to main content

libfw_client/
lib.rs

1//! libfw-client: WASM engine for browser file & folder transfers.
2//!
3//! This crate ships the *engine* that runs inside the browser: it performs
4//! the HTTP transfer (via `fetch`), slices files into chunks, keeps memory
5//! constant, retries with exponential backoff, and drives the task state
6//! machine (`idle → downloading/uploading → paused → resumed →
7//! completed/failed`).
8#![recursion_limit = "512"]
9//!
10//! The [`LibfwClient`] WASM class is exported through `wasm-bindgen` and is
11//! intended to be wrapped by the accompanying JS SDK (`sdk/`). The SDK owns
12//! WASM instantiation, the File System Access API, IndexedDB persistence and
13//! the `createWritable` byte sink — all data crosses the boundary through
14//! the callbacks installed via [`LibfwClient::set_callbacks`].
15//!
16//! # Callbacks object
17//!
18//! ```js
19//! engine.set_callbacks({
20//!   onFileStart(path, size) {},
21//!   onWriteChunk(path, offset, data) {},   // Uint8Array
22//!   onFileCompleted(path) {},
23//!   onProgress(done, total) {},
24//!   loadState(direction, path) { return Promise.resolve(null); }, // IndexedDB
25//!   saveState(direction, path, state) { return Promise.resolve(); },// IndexedDB
26//!   getFileList() { return Promise.resolve([]); },       // uploads
27//!   readFile(path, offset, length) { return Promise.resolve(new Uint8Array(0)); },
28//!   log(msg) {},
29//! });
30//! ```
31
32mod config;
33mod download;
34mod error;
35mod http;
36mod js;
37mod plan;
38mod state;
39mod upload;
40
41pub use config::{backoff_ms, ClientConfig};
42pub use error::LibfwError;
43pub use plan::FileEntry;
44
45use js_sys::Reflect;
46use wasm_bindgen::prelude::*;
47
48use crate::js::Callbacks;
49use crate::state::{TaskControl, TaskState};
50
51/// WASM engine facade. Construct via `new LibfwClient(options)`.
52#[wasm_bindgen]
53pub struct LibfwClient {
54    config: ClientConfig,
55    callbacks: Callbacks,
56    control: TaskControl,
57}
58
59#[wasm_bindgen]
60impl LibfwClient {
61    /// Create an engine. `options` may include:
62    /// `{ concurrency, compress, chunkSize, maxRetries, baseRetryDelayMs,
63    /// maxRetryDelayMs, timeoutMs }`.
64    #[wasm_bindgen(constructor)]
65    pub fn new(opts: JsValue) -> LibfwClient {
66        let config = ClientConfig::from_js(&opts);
67        LibfwClient {
68            config,
69            callbacks: Callbacks::new(),
70            control: TaskControl::new(),
71        }
72    }
73
74    /// Install the JS callbacks object (required before any transfer).
75    pub fn set_callbacks(&self, callbacks: JsValue) {
76        self.callbacks.set(callbacks);
77    }
78
79    /// Download every file under the virtual `dirPath` (empty = root).
80    ///
81    /// Resolves with the number of bytes written.
82    pub fn download_folder(&self, base_url: &str, token: &str, dir_path: &str) -> js_sys::Promise {
83        let base_url = base_url.to_string();
84        let token = token.to_string();
85        let dir_path = dir_path.to_string();
86        let config = self.config.clone();
87        let callbacks = self.callbacks.clone();
88        let control = self.control.clone();
89
90        wasm_bindgen_futures::future_to_promise(async move {
91            control.reset();
92            control.begin(TaskState::Downloading);
93            match download::download_folder(
94                &base_url,
95                &token,
96                &dir_path,
97                &callbacks,
98                &control,
99                &config,
100            )
101            .await
102            {
103                Ok(total) => {
104                    control.complete();
105                    Ok(JsValue::from_f64(total as f64))
106                }
107                Err(e) => {
108                    control.fail();
109                    Err(e.to_js())
110                }
111            }
112        })
113    }
114
115    /// Download a single file at `file_path` into the chosen local directory.
116    ///
117    /// Resolves with the number of bytes written.
118    pub fn download_file(&self, base_url: &str, token: &str, file_path: &str) -> js_sys::Promise {
119        let base_url = base_url.to_string();
120        let token = token.to_string();
121        let file_path = file_path.to_string();
122        let config = self.config.clone();
123        let callbacks = self.callbacks.clone();
124        let control = self.control.clone();
125
126        wasm_bindgen_futures::future_to_promise(async move {
127            control.reset();
128            control.begin(TaskState::Downloading);
129            match download::download_single(
130                &base_url,
131                &token,
132                &file_path,
133                &callbacks,
134                &control,
135                &config,
136            )
137            .await
138            {
139                Ok(total) => {
140                    control.complete();
141                    Ok(JsValue::from_f64(total as f64))
142                }
143                Err(e) => {
144                    control.fail();
145                    Err(e.to_js())
146                }
147            }
148        })
149    }
150
151    /// Upload the files reported by the JS `getFileList` callback.
152    ///
153    /// Resolves with the number of bytes uploaded.
154    pub fn upload(&self, base_url: &str, token: &str) -> js_sys::Promise {
155        let base_url = base_url.to_string();
156        let token = token.to_string();
157        let config = self.config.clone();
158        let callbacks = self.callbacks.clone();
159        let control = self.control.clone();
160
161        wasm_bindgen_futures::future_to_promise(async move {
162            control.reset();
163            control.begin(TaskState::Uploading);
164            match upload::upload(&base_url, &token, &callbacks, &control, &config).await {
165                Ok(total) => {
166                    control.complete();
167                    Ok(JsValue::from_f64(total as f64))
168                }
169                Err(e) => {
170                    control.fail();
171                    Err(e.to_js())
172                }
173            }
174        })
175    }
176
177    /// Pause the active transfer (state → `paused`).
178    pub fn pause(&self) {
179        self.control.pause();
180    }
181
182    /// Resume a paused transfer.
183    pub fn resume(&self) {
184        self.control.resume();
185    }
186
187    /// Cancel the active transfer (state → `failed`).
188    pub fn cancel(&self) {
189        self.control.cancel();
190    }
191
192    /// Current state: `idle | downloading | uploading | paused | completed |
193    /// failed`.
194    pub fn state(&self) -> String {
195        self.control.state().as_str().to_string()
196    }
197
198    /// Progress in `[0, 1]`.
199    pub fn progress(&self) -> f64 {
200        self.control.progress()
201    }
202
203    /// Bytes transferred so far.
204    pub fn done_bytes(&self) -> f64 {
205        self.control.done_bytes() as f64
206    }
207
208    /// Total bytes to transfer.
209    pub fn total_bytes(&self) -> f64 {
210        self.control.total_bytes() as f64
211    }
212
213    /// Whether callbacks have been installed.
214    pub fn has_callbacks(&self) -> bool {
215        self.callbacks.is_set()
216    }
217}
218
219/// Read an optional string field from a JS object (helper for the SDK).
220#[wasm_bindgen]
221pub fn js_option_string(obj: &JsValue, key: &str) -> Option<String> {
222    Reflect::get(obj, &JsValue::from_str(key))
223        .ok()
224        .and_then(|v| v.as_string())
225}
226
227#[cfg(test)]
228mod tests {
229    #[test]
230    #[cfg(target_arch = "wasm32")]
231    fn engine_default_state_is_idle() {
232        let engine = super::LibfwClient::new(wasm_bindgen::JsValue::NULL);
233        assert_eq!(engine.state(), "idle");
234        assert!(!engine.has_callbacks());
235    }
236
237    #[test]
238    #[cfg(target_arch = "wasm32")]
239    fn engine_options_parse() {
240        let opts = js_sys::Object::new();
241        js_sys::Reflect::set(&opts, &wasm_bindgen::JsValue::from_str("concurrency"), &wasm_bindgen::JsValue::from_f64(2.0))
242            .unwrap();
243        js_sys::Reflect::set(&opts, &wasm_bindgen::JsValue::from_str("compress"), &wasm_bindgen::JsValue::FALSE).unwrap();
244        let engine = super::LibfwClient::new(opts.into());
245        assert_eq!(engine.config.concurrency, 2);
246        assert!(!engine.config.compress);
247    }
248
249    #[test]
250    #[cfg(target_arch = "wasm32")]
251    fn state_transitions_via_public_api() {
252        use crate::state::TaskState;
253        let engine = super::LibfwClient::new(wasm_bindgen::JsValue::NULL);
254        engine.control.begin(TaskState::Downloading);
255        engine.pause();
256        assert_eq!(engine.state(), "paused");
257        engine.resume();
258        assert_eq!(engine.state(), "downloading");
259        engine.cancel();
260        assert_eq!(engine.state(), "failed");
261    }
262}