Skip to main content

libfw_client/
config.rs

1//! Client configuration, parsed from the JS-side options object.
2//!
3//! All knobs are optional; defaults follow the protocol constants from
4//! `libfw-core` (2 MiB chunks, 4 concurrent connections, 3 retries).
5
6use js_sys::Reflect;
7use wasm_bindgen::prelude::*;
8
9use libfw_core::{
10    CHUNK_SIZE, DEFAULT_CONCURRENCY, DEFAULT_DOWNLOAD_CHUNK_SIZE, DEFAULT_DOWNLOAD_WINDOW,
11    DEFAULT_UPLOAD_WINDOW, MAX_RETRIES,
12};
13
14/// Default delay before the first retry (milliseconds).
15pub const DEFAULT_BASE_RETRY_MS: u32 = 500;
16/// Upper bound for exponential backoff (milliseconds).
17pub const DEFAULT_MAX_RETRY_MS: u32 = 30_000;
18/// Default per-request timeout (milliseconds).
19pub const DEFAULT_TIMEOUT_MS: u32 = 60_000;
20
21/// Runtime configuration of the WASM engine.
22#[derive(Debug, Clone)]
23pub struct ClientConfig {
24    /// Maximum number of concurrently-transferring files (default 4).
25    pub concurrency: usize,
26    /// In-flight chunk window for a single file's upload (default 8).
27    ///
28    /// Independent of `concurrency`: one file keeps up to `upload_window`
29    /// chunks in flight, so a high-latency link stays saturated (throughput
30    /// is bounded by bandwidth, not `chunk_size / RTT`).
31    pub upload_window: usize,
32    /// In-flight byte-range window for a single file's **download** (default
33    /// 4).
34    ///
35    /// A large file is downloaded as `download_window` concurrent `Range`
36    /// GETs so its throughput is bounded by bandwidth instead of one
37    /// connection's `chunk_size / RTT` (tus-style parallel transfer). Set to
38    /// `1` to fall back to the sequential single-connection path.
39    pub download_window: usize,
40    /// Chunk size for parallel downloads (default 256 KiB).
41    ///
42    /// Smaller than the upload chunk on purpose: the WASM engine reorders
43    /// in-flight chunks in memory so the SDK keeps receiving them in order
44    /// (append-mode writes), and `download_window * download_chunk_size` is
45    /// that buffer's worst-case size.
46    pub download_chunk_size: u64,
47    /// Request `zrip` compression from the server / compress uploads.
48    pub compress: bool,
49    /// Fixed chunk size used to slice files (default 2 MiB).
50    pub chunk_size: u64,
51    /// Maximum retries per chunk/file (default 3).
52    pub max_retries: u32,
53    /// Initial backoff delay in ms (default 500).
54    pub base_retry_delay_ms: u32,
55    /// Backoff ceiling in ms (default 30s).
56    pub max_retry_delay_ms: u32,
57    /// Per-request timeout in ms (default 60s).
58    pub timeout_ms: u32,
59    /// Optional explicit WebSocket endpoint (e.g. `wss://host/ws`).
60    ///
61    /// When `None` the engine derives the `ws(s)://` URL from the HTTP
62    /// `baseUrl` passed to each transfer method.
63    pub ws_url: Option<String>,
64}
65
66impl Default for ClientConfig {
67    fn default() -> Self {
68        ClientConfig {
69            concurrency: DEFAULT_CONCURRENCY,
70            upload_window: DEFAULT_UPLOAD_WINDOW,
71            download_window: DEFAULT_DOWNLOAD_WINDOW,
72            download_chunk_size: DEFAULT_DOWNLOAD_CHUNK_SIZE,
73            compress: true,
74            chunk_size: CHUNK_SIZE,
75            max_retries: MAX_RETRIES,
76            base_retry_delay_ms: DEFAULT_BASE_RETRY_MS,
77            max_retry_delay_ms: DEFAULT_MAX_RETRY_MS,
78            timeout_ms: DEFAULT_TIMEOUT_MS,
79            ws_url: None,
80        }
81    }
82}
83
84/// Read an optional `u64`/`number` field from a JS object.
85fn opt_u64(obj: &JsValue, key: &str) -> Option<u64> {
86    Reflect::get(obj, &JsValue::from_str(key))
87        .ok()
88        .and_then(|v| v.as_f64())
89        .map(|f| f as u64)
90}
91
92/// Read an optional `usize` field from a JS object.
93fn opt_usize(obj: &JsValue, key: &str) -> Option<usize> {
94    Reflect::get(obj, &JsValue::from_str(key))
95        .ok()
96        .and_then(|v| v.as_f64())
97        .map(|f| f as usize)
98}
99
100/// Read an optional `u32` field from a JS object.
101fn opt_u32(obj: &JsValue, key: &str) -> Option<u32> {
102    Reflect::get(obj, &JsValue::from_str(key))
103        .ok()
104        .and_then(|v| v.as_f64())
105        .map(|f| f as u32)
106}
107
108/// Read an optional boolean field from a JS object.
109fn opt_bool(obj: &JsValue, key: &str) -> Option<bool> {
110    Reflect::get(obj, &JsValue::from_str(key))
111        .ok()
112        .and_then(|v| v.as_bool())
113}
114
115impl ClientConfig {
116    /// Parse configuration from a JS object literal, e.g.
117    /// `{ concurrency: 4, compress: true, chunkSize: 2097152 }`.
118    ///
119    /// Safe to call with `null`/`undefined` (returns defaults) — this also
120    /// keeps native (non-wasm) unit tests runnable.
121    pub fn from_js(opts: &JsValue) -> ClientConfig {
122        let mut cfg = ClientConfig::default();
123        if !opts.is_object() {
124            return cfg;
125        }
126        if let Some(v) = opt_usize(opts, "concurrency") {
127            if v > 0 {
128                cfg.concurrency = v;
129            }
130        }
131        if let Some(v) = opt_usize(opts, "uploadWindow") {
132            if v > 0 {
133                cfg.upload_window = v;
134            }
135        }
136        if let Some(v) = opt_usize(opts, "downloadWindow") {
137            if v > 0 {
138                cfg.download_window = v;
139            }
140        }
141        if let Some(v) = opt_u64(opts, "downloadChunkSize") {
142            if v > 0 {
143                cfg.download_chunk_size = v;
144            }
145        }
146        if let Some(v) = opt_bool(opts, "compress") {
147            cfg.compress = v;
148        }
149        if let Some(v) = opt_u64(opts, "chunkSize") {
150            if v > 0 {
151                cfg.chunk_size = v;
152            }
153        }
154        if let Some(v) = opt_u32(opts, "maxRetries") {
155            cfg.max_retries = v;
156        }
157        if let Some(v) = opt_u32(opts, "baseRetryDelayMs") {
158            cfg.base_retry_delay_ms = v;
159        }
160        if let Some(v) = opt_u32(opts, "maxRetryDelayMs") {
161            cfg.max_retry_delay_ms = v;
162        }
163        if let Some(v) = opt_u32(opts, "timeoutMs") {
164            cfg.timeout_ms = v;
165        }
166        if let Some(v) = Reflect::get(opts, &JsValue::from_str("wsUrl"))
167            .ok()
168            .and_then(|v| v.as_string())
169        {
170            if !v.is_empty() {
171                cfg.ws_url = Some(v);
172            }
173        }
174        cfg
175    }
176
177    /// Exponential backoff delay for a failed attempt (0-based).
178    ///
179    /// `2^attempt * base`, clamped to `max`. Bounded so a hung peer cannot
180    /// grow the delay without limit.
181    pub fn backoff_ms(&self, attempt: u32) -> u32 {
182        backoff_ms(attempt, self.base_retry_delay_ms, self.max_retry_delay_ms)
183    }
184}
185
186/// Pure exponential backoff: `min(max, base << attempt)` with saturation.
187pub fn backoff_ms(attempt: u32, base: u32, max: u32) -> u32 {
188    if attempt == 0 {
189        return base.min(max);
190    }
191    let shift = attempt.min(31);
192    let doubled = (base as u64) << shift;
193    doubled.min(max as u64) as u32
194}
195
196#[cfg(test)]
197mod tests {
198    use super::*;
199
200    #[test]
201    fn defaults_are_sane() {
202        let cfg = ClientConfig::default();
203        assert_eq!(cfg.concurrency, 4);
204        assert_eq!(cfg.upload_window, DEFAULT_UPLOAD_WINDOW);
205        assert_eq!(cfg.download_window, DEFAULT_DOWNLOAD_WINDOW);
206        assert_eq!(cfg.download_chunk_size, DEFAULT_DOWNLOAD_CHUNK_SIZE);
207        assert_eq!(cfg.chunk_size, CHUNK_SIZE);
208        assert_eq!(cfg.max_retries, MAX_RETRIES);
209        assert!(cfg.compress);
210    }
211
212    #[test]
213    fn backoff_grows_exponentially_then_clamps() {
214        let cfg = ClientConfig {
215            base_retry_delay_ms: 100,
216            max_retry_delay_ms: 1000,
217            ..ClientConfig::default()
218        };
219        assert_eq!(cfg.backoff_ms(0), 100);
220        assert_eq!(cfg.backoff_ms(1), 200);
221        assert_eq!(cfg.backoff_ms(2), 400);
222        assert_eq!(cfg.backoff_ms(3), 800);
223        assert_eq!(cfg.backoff_ms(4), 1000); // clamped
224        assert_eq!(cfg.backoff_ms(99), 1000);
225    }
226
227    #[test]
228    fn pure_backoff_saturates() {
229        assert_eq!(backoff_ms(0, 500, 30_000), 500);
230        assert_eq!(backoff_ms(6, 500, 30_000), 30_000);
231    }
232
233    #[test]
234    #[cfg(target_arch = "wasm32")]
235    fn parses_js_options() {
236        let obj = js_sys::Object::new();
237        js_sys::Reflect::set(&obj, &JsValue::from_str("concurrency"), &JsValue::from_f64(8.0))
238            .unwrap();
239        js_sys::Reflect::set(
240            &obj,
241            &JsValue::from_str("uploadWindow"),
242            &JsValue::from_f64(16.0),
243        )
244        .unwrap();
245        js_sys::Reflect::set(
246            &obj,
247            &JsValue::from_str("downloadWindow"),
248            &JsValue::from_f64(6.0),
249        )
250        .unwrap();
251        js_sys::Reflect::set(
252            &obj,
253            &JsValue::from_str("downloadChunkSize"),
254            &JsValue::from_f64(131072.0),
255        )
256        .unwrap();
257        js_sys::Reflect::set(&obj, &JsValue::from_str("compress"), &JsValue::FALSE).unwrap();
258        js_sys::Reflect::set(
259            &obj,
260            &JsValue::from_str("chunkSize"),
261            &JsValue::from_f64(1024.0),
262        )
263        .unwrap();
264        let cfg = ClientConfig::from_js(&obj);
265        assert_eq!(cfg.concurrency, 8);
266        assert_eq!(cfg.upload_window, 16);
267        assert_eq!(cfg.download_window, 6);
268        assert_eq!(cfg.download_chunk_size, 131072);
269        assert!(!cfg.compress);
270        assert_eq!(cfg.chunk_size, 1024);
271    }
272
273    #[test]
274    #[cfg(target_arch = "wasm32")]
275    fn ignores_non_object_options() {
276        let cfg = ClientConfig::from_js(&JsValue::NULL);
277        assert_eq!(cfg.concurrency, DEFAULT_CONCURRENCY);
278        let cfg = ClientConfig::from_js(&JsValue::UNDEFINED);
279        assert_eq!(cfg.chunk_size, CHUNK_SIZE);
280    }
281}