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