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, uploadWindow, downloadWindow, downloadChunkSize,
63    /// compress, chunkSize, maxRetries, baseRetryDelayMs, maxRetryDelayMs,
64    /// timeoutMs }`.
65    #[wasm_bindgen(constructor)]
66    pub fn new(opts: JsValue) -> LibfwClient {
67        let config = ClientConfig::from_js(&opts);
68        LibfwClient {
69            config: config.clone(),
70            callbacks: Callbacks::new(),
71            // The global in-flight HTTP pool is sized by `concurrency`, so it
72            // bounds total network parallelism (not just concurrent files).
73            control: TaskControl::with_max_parallel(config.concurrency.max(1)),
74        }
75    }
76
77    /// Install the JS callbacks object (required before any transfer).
78    pub fn set_callbacks(&self, callbacks: JsValue) {
79        self.callbacks.set(callbacks);
80    }
81
82    /// Download every file under the virtual `dirPath` (empty = root).
83    ///
84    /// Resolves with the number of bytes written.
85    pub fn download_folder(&self, base_url: &str, token: &str, dir_path: &str) -> js_sys::Promise {
86        let base_url = base_url.to_string();
87        let token = token.to_string();
88        let dir_path = dir_path.to_string();
89        let config = self.config.clone();
90        let callbacks = self.callbacks.clone();
91        let control = self.control.clone();
92
93        wasm_bindgen_futures::future_to_promise(async move {
94            control.reset();
95            control.begin(TaskState::Downloading);
96            match download::download_folder(
97                &base_url,
98                &token,
99                &dir_path,
100                &callbacks,
101                &control,
102                &config,
103            )
104            .await
105            {
106                Ok(total) => {
107                    control.complete();
108                    Ok(JsValue::from_f64(total as f64))
109                }
110                Err(e) => {
111                    control.fail();
112                    Err(e.to_js())
113                }
114            }
115        })
116    }
117
118    /// Download a single file at `file_path` into the chosen local directory.
119    ///
120    /// Resolves with the number of bytes written.
121    pub fn download_file(&self, base_url: &str, token: &str, file_path: &str) -> js_sys::Promise {
122        let base_url = base_url.to_string();
123        let token = token.to_string();
124        let file_path = file_path.to_string();
125        let config = self.config.clone();
126        let callbacks = self.callbacks.clone();
127        let control = self.control.clone();
128
129        wasm_bindgen_futures::future_to_promise(async move {
130            control.reset();
131            control.begin(TaskState::Downloading);
132            match download::download_single(
133                &base_url,
134                &token,
135                &file_path,
136                &callbacks,
137                &control,
138                &config,
139            )
140            .await
141            {
142                Ok(total) => {
143                    control.complete();
144                    Ok(JsValue::from_f64(total as f64))
145                }
146                Err(e) => {
147                    control.fail();
148                    Err(e.to_js())
149                }
150            }
151        })
152    }
153
154    /// Upload the files reported by the JS `getFileList` callback.
155    ///
156    /// Resolves with the number of bytes uploaded.
157    pub fn upload(&self, base_url: &str, token: &str) -> js_sys::Promise {
158        let base_url = base_url.to_string();
159        let token = token.to_string();
160        let config = self.config.clone();
161        let callbacks = self.callbacks.clone();
162        let control = self.control.clone();
163
164        wasm_bindgen_futures::future_to_promise(async move {
165            control.reset();
166            control.begin(TaskState::Uploading);
167            match upload::upload(&base_url, &token, &callbacks, &control, &config).await {
168                Ok(total) => {
169                    control.complete();
170                    Ok(JsValue::from_f64(total as f64))
171                }
172                Err(e) => {
173                    control.fail();
174                    Err(e.to_js())
175                }
176            }
177        })
178    }
179
180    /// Pause the active transfer (state → `paused`).
181    pub fn pause(&self) {
182        self.control.pause();
183    }
184
185    /// Resume a paused transfer.
186    pub fn resume(&self) {
187        self.control.resume();
188    }
189
190    /// Cancel the active transfer (state → `failed`).
191    pub fn cancel(&self) {
192        self.control.cancel();
193    }
194
195    /// Current state: `idle | downloading | uploading | paused | completed |
196    /// failed`.
197    pub fn state(&self) -> String {
198        self.control.state().as_str().to_string()
199    }
200
201    /// Progress in `[0, 1]`.
202    pub fn progress(&self) -> f64 {
203        self.control.progress()
204    }
205
206    /// Bytes transferred so far.
207    pub fn done_bytes(&self) -> f64 {
208        self.control.done_bytes() as f64
209    }
210
211    /// Total bytes to transfer.
212    pub fn total_bytes(&self) -> f64 {
213        self.control.total_bytes() as f64
214    }
215
216    /// Whether callbacks have been installed.
217    pub fn has_callbacks(&self) -> bool {
218        self.callbacks.is_set()
219    }
220}
221
222/// Read an optional string field from a JS object (helper for the SDK).
223#[wasm_bindgen]
224pub fn js_option_string(obj: &JsValue, key: &str) -> Option<String> {
225    Reflect::get(obj, &JsValue::from_str(key))
226        .ok()
227        .and_then(|v| v.as_string())
228}
229
230#[cfg(test)]
231mod tests {
232    #[test]
233    #[cfg(target_arch = "wasm32")]
234    fn engine_default_state_is_idle() {
235        let engine = super::LibfwClient::new(wasm_bindgen::JsValue::NULL);
236        assert_eq!(engine.state(), "idle");
237        assert!(!engine.has_callbacks());
238    }
239
240    #[test]
241    #[cfg(target_arch = "wasm32")]
242    fn engine_options_parse() {
243        let opts = js_sys::Object::new();
244        js_sys::Reflect::set(&opts, &wasm_bindgen::JsValue::from_str("concurrency"), &wasm_bindgen::JsValue::from_f64(2.0))
245            .unwrap();
246        js_sys::Reflect::set(&opts, &wasm_bindgen::JsValue::from_str("compress"), &wasm_bindgen::JsValue::FALSE).unwrap();
247        let engine = super::LibfwClient::new(opts.into());
248        assert_eq!(engine.config.concurrency, 2);
249        assert!(!engine.config.compress);
250    }
251
252    #[test]
253    #[cfg(target_arch = "wasm32")]
254    fn state_transitions_via_public_api() {
255        use crate::state::TaskState;
256        let engine = super::LibfwClient::new(wasm_bindgen::JsValue::NULL);
257        engine.control.begin(TaskState::Downloading);
258        engine.pause();
259        assert_eq!(engine.state(), "paused");
260        engine.resume();
261        assert_eq!(engine.state(), "downloading");
262        engine.cancel();
263        assert_eq!(engine.state(), "failed");
264    }
265}