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 tune;
40mod upload;
41
42pub use config::{backoff_ms, ClientConfig};
43pub use error::LibfwError;
44pub use plan::FileEntry;
45pub use tune::{
46    CompressLevel, LocalStore, TuneCache, TuneEvent, TuneHandle, TuneParams, TunePhase,
47    TuneStats, TuneStore, TransferKind, tune_key,
48};
49
50use js_sys::Reflect;
51use wasm_bindgen::prelude::*;
52
53use crate::js::Callbacks;
54
55#[wasm_bindgen(start)]
56pub fn start() {
57    console_error_panic_hook::set_once();
58}
59use crate::state::{TaskControl, TaskState};
60use crate::tune::{TuningEngine, now_ms};
61
62/// WASM engine facade. Construct via `new LibfwClient(options)`.
63#[wasm_bindgen]
64pub struct LibfwClient {
65    config: ClientConfig,
66    callbacks: Callbacks,
67    control: TaskControl,
68    tune: TuneHandle,
69}
70
71#[wasm_bindgen]
72impl LibfwClient {
73    /// Create an engine. `options` may include:
74    /// `{ concurrency, uploadWindow, downloadWindow, downloadChunkSize,
75    /// compress, compressLevel, chunkSize, maxRetries, baseRetryDelayMs,
76    /// maxRetryDelayMs, timeoutMs, autoTune, tuneTtlMs }`.
77    #[wasm_bindgen(constructor)]
78    pub fn new(opts: JsValue) -> LibfwClient {
79        let config = ClientConfig::from_js(&opts);
80        LibfwClient {
81            config: config.clone(),
82            callbacks: Callbacks::new(),
83            // The global in-flight HTTP pool is sized by `concurrency`, so it
84            // bounds total network parallelism (not just concurrent files).
85            control: TaskControl::with_max_parallel(config.concurrency.max(1)),
86            tune: std::rc::Rc::new(std::cell::RefCell::new(TuningEngine::new(
87                config.auto_tune,
88                config.tune_ttl_ms,
89                config.compress_level,
90            ))),
91        }
92    }
93
94    /// Install the JS callbacks object (required before any transfer).
95    pub fn set_callbacks(&self, callbacks: JsValue) {
96        self.callbacks.set(callbacks);
97    }
98
99    /// Install an `onTuning(phase, params, stats)` callback, invoked on
100    /// every tuning state change (phase transitions and window evaluations).
101    pub fn set_tune_callback(&self, cb: JsValue) {
102        let f = cb.dyn_ref::<js_sys::Function>().cloned();
103        self.tune.borrow_mut().set_on_tuning(f);
104    }
105
106    /// Snapshot the tuning engine: `{ phase, params, stats, capsHash }`.
107    pub fn tune_state(&self) -> JsValue {
108        self.tune.borrow().state_js()
109    }
110
111    /// Download every file under the virtual `dirPath` (empty = root).
112    ///
113    /// Resolves with the number of bytes written.
114    pub fn download_folder(&self, base_url: &str, token: &str, dir_path: &str) -> js_sys::Promise {
115        let base_url = base_url.to_string();
116        let token = token.to_string();
117        let dir_path = dir_path.to_string();
118        let config = self.config.clone();
119        let callbacks = self.callbacks.clone();
120        let control = self.control.clone();
121        let tune = self.tune.clone();
122
123        wasm_bindgen_futures::future_to_promise(async move {
124            control.reset();
125            control.begin(TaskState::Downloading);
126            let level = match prepare_transfer(
127                &base_url, &config, &tune, &control, TransferKind::Download,
128            )
129            .await
130            {
131                Ok(l) => l,
132                Err(e) => {
133                    control.fail();
134                    return Err(e.to_js());
135                }
136            };
137            match download::download_folder(
138                &base_url,
139                &token,
140                &dir_path,
141                &callbacks,
142                &control,
143                &config,
144                &tune,
145                level,
146            )
147            .await
148            {
149                Ok(total) => {
150                    tune.borrow_mut().transfer_end(&base_url, &LocalStore, now_ms(), true);
151                    control.complete();
152                    Ok(JsValue::from_f64(total as f64))
153                }
154                Err(e) => {
155                    tune.borrow_mut().transfer_end(&base_url, &LocalStore, now_ms(), false);
156                    control.fail();
157                    Err(e.to_js())
158                }
159            }
160        })
161    }
162
163    /// Download a single file at `file_path` into the chosen local directory.
164    ///
165    /// Resolves with the number of bytes written.
166    pub fn download_file(&self, base_url: &str, token: &str, file_path: &str) -> js_sys::Promise {
167        let base_url = base_url.to_string();
168        let token = token.to_string();
169        let file_path = file_path.to_string();
170        let config = self.config.clone();
171        let callbacks = self.callbacks.clone();
172        let control = self.control.clone();
173        let tune = self.tune.clone();
174
175        wasm_bindgen_futures::future_to_promise(async move {
176            control.reset();
177            control.begin(TaskState::Downloading);
178            let level = match prepare_transfer(
179                &base_url, &config, &tune, &control, TransferKind::Download,
180            )
181            .await
182            {
183                Ok(l) => l,
184                Err(e) => {
185                    control.fail();
186                    return Err(e.to_js());
187                }
188            };
189            match download::download_single(
190                &base_url,
191                &token,
192                &file_path,
193                &callbacks,
194                &control,
195                &config,
196                &tune,
197                level,
198            )
199            .await
200            {
201                Ok(total) => {
202                    tune.borrow_mut().transfer_end(&base_url, &LocalStore, now_ms(), true);
203                    control.complete();
204                    Ok(JsValue::from_f64(total as f64))
205                }
206                Err(e) => {
207                    tune.borrow_mut().transfer_end(&base_url, &LocalStore, now_ms(), false);
208                    control.fail();
209                    Err(e.to_js())
210                }
211            }
212        })
213    }
214
215    /// Upload the files reported by the JS `getFileList` callback.
216    ///
217    /// Resolves with the number of bytes uploaded.
218    pub fn upload(&self, base_url: &str, token: &str) -> js_sys::Promise {
219        let base_url = base_url.to_string();
220        let token = token.to_string();
221        let config = self.config.clone();
222        let callbacks = self.callbacks.clone();
223        let control = self.control.clone();
224        let tune = self.tune.clone();
225
226        wasm_bindgen_futures::future_to_promise(async move {
227            control.reset();
228            control.begin(TaskState::Uploading);
229            let _level = match prepare_transfer(
230                &base_url, &config, &tune, &control, TransferKind::Upload,
231            )
232            .await
233            {
234                Ok(l) => l,
235                Err(e) => {
236                    control.fail();
237                    return Err(e.to_js());
238                }
239            };
240            match upload::upload(&base_url, &token, &callbacks, &control, &config, &tune).await {
241                Ok(total) => {
242                    tune.borrow_mut().transfer_end(&base_url, &LocalStore, now_ms(), true);
243                    control.complete();
244                    Ok(JsValue::from_f64(total as f64))
245                }
246                Err(e) => {
247                    tune.borrow_mut().transfer_end(&base_url, &LocalStore, now_ms(), false);
248                    control.fail();
249                    Err(e.to_js())
250                }
251            }
252        })
253    }
254
255    /// Pause the active transfer (state → `paused`).
256    pub fn pause(&self) {
257        self.control.pause();
258    }
259
260    /// Resume a paused transfer.
261    pub fn resume(&self) {
262        self.control.resume();
263    }
264
265    /// Cancel the active transfer (state → `failed`).
266    pub fn cancel(&self) {
267        self.control.cancel();
268    }
269
270    /// Current state: `idle | downloading | uploading | paused | completed |
271    /// failed`.
272    pub fn state(&self) -> String {
273        self.control.state().as_str().to_string()
274    }
275
276    /// Progress in `[0, 1]`.
277    pub fn progress(&self) -> f64 {
278        self.control.progress()
279    }
280
281    /// Bytes transferred so far.
282    pub fn done_bytes(&self) -> f64 {
283        self.control.done_bytes() as f64
284    }
285
286    /// Total bytes to transfer.
287    pub fn total_bytes(&self) -> f64 {
288        self.control.total_bytes() as f64
289    }
290
291    /// Whether callbacks have been installed.
292    pub fn has_callbacks(&self) -> bool {
293        self.callbacks.is_set()
294    }
295}
296
297/// Read an optional string field from a JS object (helper for the SDK).
298#[wasm_bindgen]
299pub fn js_option_string(obj: &JsValue, key: &str) -> Option<String> {
300    Reflect::get(obj, &JsValue::from_str(key))
301        .ok()
302        .and_then(|v| v.as_string())
303}
304
305/// Shared transfer preamble.
306///
307/// 1. Fetch `/capabilities` when auto-tuning (a 404 means a legacy server —
308///    tuning is disabled for this client's lifetime and defaults are used).
309/// 2. Seed the tuning engine (cache reuse → Settled, else Ramping from the
310///    advertised minimums), returning the starting parameter table.
311/// 3. Apply the starting concurrency to the global permit pool.
312///
313/// Returns the resolved download compression level.
314async fn prepare_transfer(
315    base_url: &str,
316    config: &ClientConfig,
317    tune: &TuneHandle,
318    control: &TaskControl,
319    direction: TransferKind,
320) -> Result<i32, LibfwError> {
321    let caps = if config.auto_tune {
322        match http::fetch_capabilities(base_url, config.timeout_ms).await {
323            Ok(c) => c,
324            Err(LibfwError::Http { status: 404, .. }) => {
325                // Legacy server without the /capabilities route: no tuning.
326                tune.borrow_mut().set_enabled(false);
327                libfw_core::Capabilities::default()
328            }
329            Err(e) => return Err(e),
330        }
331    } else {
332        libfw_core::Capabilities::default()
333    };
334
335    let level = tune::resolve_level(config.compress_level, &caps);
336    let static_params = TuneParams::from_config(
337        config.concurrency,
338        config.upload_window,
339        config.download_window,
340        config.chunk_size,
341        config.download_chunk_size,
342        level,
343        &caps,
344    );
345    let params = {
346        let mut t = tune.borrow_mut();
347        t.set_direction(direction);
348        t.begin_transfer(base_url, &caps, &LocalStore, now_ms(), &static_params)
349    };
350    control.set_max_parallel(params.concurrency);
351    Ok(params.compress_level)
352}
353
354#[cfg(test)]
355mod tests {
356    #[test]
357    #[cfg(target_arch = "wasm32")]
358    fn engine_default_state_is_idle() {
359        let engine = super::LibfwClient::new(wasm_bindgen::JsValue::NULL);
360        assert_eq!(engine.state(), "idle");
361        assert!(!engine.has_callbacks());
362    }
363
364    #[test]
365    #[cfg(target_arch = "wasm32")]
366    fn engine_options_parse() {
367        let opts = js_sys::Object::new();
368        js_sys::Reflect::set(&opts, &wasm_bindgen::JsValue::from_str("concurrency"), &wasm_bindgen::JsValue::from_f64(2.0))
369            .unwrap();
370        js_sys::Reflect::set(&opts, &wasm_bindgen::JsValue::from_str("compress"), &wasm_bindgen::JsValue::FALSE).unwrap();
371        let engine = super::LibfwClient::new(opts.into());
372        assert_eq!(engine.config.concurrency, 2);
373        assert!(!engine.config.compress);
374    }
375
376    #[test]
377    #[cfg(target_arch = "wasm32")]
378    fn state_transitions_via_public_api() {
379        use crate::state::TaskState;
380        let engine = super::LibfwClient::new(wasm_bindgen::JsValue::NULL);
381        engine.control.begin(TaskState::Downloading);
382        engine.pause();
383        assert_eq!(engine.state(), "paused");
384        engine.resume();
385        assert_eq!(engine.state(), "downloading");
386        engine.cancel();
387        assert_eq!(engine.state(), "failed");
388    }
389}