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    /// Upload the files reported by the JS `getFileList` callback.
116    ///
117    /// Resolves with the number of bytes uploaded.
118    pub fn upload(&self, base_url: &str, token: &str) -> js_sys::Promise {
119        let base_url = base_url.to_string();
120        let token = token.to_string();
121        let config = self.config.clone();
122        let callbacks = self.callbacks.clone();
123        let control = self.control.clone();
124
125        wasm_bindgen_futures::future_to_promise(async move {
126            control.reset();
127            control.begin(TaskState::Uploading);
128            match upload::upload(&base_url, &token, &callbacks, &control, &config).await {
129                Ok(total) => {
130                    control.complete();
131                    Ok(JsValue::from_f64(total as f64))
132                }
133                Err(e) => {
134                    control.fail();
135                    Err(e.to_js())
136                }
137            }
138        })
139    }
140
141    /// Pause the active transfer (state → `paused`).
142    pub fn pause(&self) {
143        self.control.pause();
144    }
145
146    /// Resume a paused transfer.
147    pub fn resume(&self) {
148        self.control.resume();
149    }
150
151    /// Cancel the active transfer (state → `failed`).
152    pub fn cancel(&self) {
153        self.control.cancel();
154    }
155
156    /// Current state: `idle | downloading | uploading | paused | completed |
157    /// failed`.
158    pub fn state(&self) -> String {
159        self.control.state().as_str().to_string()
160    }
161
162    /// Progress in `[0, 1]`.
163    pub fn progress(&self) -> f64 {
164        self.control.progress()
165    }
166
167    /// Bytes transferred so far.
168    pub fn done_bytes(&self) -> f64 {
169        self.control.done_bytes() as f64
170    }
171
172    /// Total bytes to transfer.
173    pub fn total_bytes(&self) -> f64 {
174        self.control.total_bytes() as f64
175    }
176
177    /// Whether callbacks have been installed.
178    pub fn has_callbacks(&self) -> bool {
179        self.callbacks.is_set()
180    }
181}
182
183/// Read an optional string field from a JS object (helper for the SDK).
184#[wasm_bindgen]
185pub fn js_option_string(obj: &JsValue, key: &str) -> Option<String> {
186    Reflect::get(obj, &JsValue::from_str(key))
187        .ok()
188        .and_then(|v| v.as_string())
189}
190
191#[cfg(test)]
192mod tests {
193    #[test]
194    #[cfg(target_arch = "wasm32")]
195    fn engine_default_state_is_idle() {
196        let engine = super::LibfwClient::new(wasm_bindgen::JsValue::NULL);
197        assert_eq!(engine.state(), "idle");
198        assert!(!engine.has_callbacks());
199    }
200
201    #[test]
202    #[cfg(target_arch = "wasm32")]
203    fn engine_options_parse() {
204        let opts = js_sys::Object::new();
205        js_sys::Reflect::set(&opts, &wasm_bindgen::JsValue::from_str("concurrency"), &wasm_bindgen::JsValue::from_f64(2.0))
206            .unwrap();
207        js_sys::Reflect::set(&opts, &wasm_bindgen::JsValue::from_str("compress"), &wasm_bindgen::JsValue::FALSE).unwrap();
208        let engine = super::LibfwClient::new(opts.into());
209        assert_eq!(engine.config.concurrency, 2);
210        assert!(!engine.config.compress);
211    }
212
213    #[test]
214    #[cfg(target_arch = "wasm32")]
215    fn state_transitions_via_public_api() {
216        use crate::state::TaskState;
217        let engine = super::LibfwClient::new(wasm_bindgen::JsValue::NULL);
218        engine.control.begin(TaskState::Downloading);
219        engine.pause();
220        assert_eq!(engine.state(), "paused");
221        engine.resume();
222        assert_eq!(engine.state(), "downloading");
223        engine.cancel();
224        assert_eq!(engine.state(), "failed");
225    }
226}