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_WINDOW, DEFAULT_UPLOAD_WINDOW, MAX_RETRIES,
11};
12
13use crate::tune::{CompressLevel, DEFAULT_TUNE_TTL_MS};
14
15/// Default delay before the first retry (milliseconds).
16pub const DEFAULT_BASE_RETRY_MS: u32 = 500;
17/// Upper bound for exponential backoff (milliseconds).
18pub const DEFAULT_MAX_RETRY_MS: u32 = 30_000;
19/// Default per-request timeout (milliseconds).
20pub const DEFAULT_TIMEOUT_MS: u32 = 60_000;
21
22/// Runtime configuration of the WASM engine.
23#[derive(Debug, Clone)]
24pub struct ClientConfig {
25    /// Global cap on concurrent in-flight HTTP transfers (default 4).
26    ///
27    /// Every upload chunk POST and download range GET acquires a permit from
28    /// a shared pool, so this bounds the engine's TOTAL network parallelism
29    /// — across all files AND all per-file windows combined (a single file
30    /// can still open up to `upload_window` / `download_window` requests, but
31    /// never more than `concurrency` in total).
32    pub concurrency: usize,
33    /// In-flight chunk window for a single file's upload (default 8).
34    ///
35    /// Independent of `concurrency`: one file keeps up to `upload_window`
36    /// chunks in flight, so a high-latency link stays saturated (throughput
37    /// is bounded by bandwidth, not `chunk_size / RTT`).
38    pub upload_window: usize,
39    /// In-flight byte-range window for a single file's **download** (default
40    /// 4).
41    ///
42    /// A large file is downloaded as `download_window` concurrent `Range`
43    /// GETs so its throughput is bounded by bandwidth instead of one
44    /// connection's `chunk_size / RTT` (tus-style parallel transfer). Set to
45    /// `1` to fall back to the sequential single-connection path.
46    pub download_window: usize,
47    /// Request `zrip` compression from the server / compress uploads.
48    pub compress: bool,
49    /// Fixed chunk size used to slice files and byte-range downloads (default 2 MiB).
50    ///
51    /// The same value is used for both upload chunking and parallel download
52    /// range splits, so a single config option represents the budget for both
53    /// paths (larger = fewer, bigger transfers; bounded by the server's upload
54    /// limit, memory, and the per-file window budget).
55    pub chunk_size: u64,
56    /// Maximum retries per chunk/file (default 3).
57    pub max_retries: u32,
58    /// Initial backoff delay in ms (default 500).
59    pub base_retry_delay_ms: u32,
60    /// Backoff ceiling in ms (default 30s).
61    pub max_retry_delay_ms: u32,
62    /// Per-request timeout in ms (default 60s).
63    pub timeout_ms: u32,
64    /// Adaptive tuning (TCP-style ramp over real transfers). Off by default
65    /// so legacy callers behave exactly as before.
66    pub auto_tune: bool,
67    /// TTL of the persisted tuning cache (default 1h).
68    pub tune_ttl_ms: u64,
69    /// Client-side compression level policy.
70    ///
71    /// Absent config resolves to [`CompressLevel::Balanced`] (the server's
72    /// advertised default = the historical level-1 behavior), so callers
73    /// that never set it are byte-identical to pre-0.3.3. `compress: false`
74    /// still forces identity regardless of this field.
75    pub compress_level: CompressLevel,
76}
77
78impl Default for ClientConfig {
79    fn default() -> Self {
80        ClientConfig {
81            concurrency: DEFAULT_CONCURRENCY,
82            upload_window: DEFAULT_UPLOAD_WINDOW,
83            download_window: DEFAULT_DOWNLOAD_WINDOW,
84            compress: true,
85            chunk_size: CHUNK_SIZE,
86            max_retries: MAX_RETRIES,
87            base_retry_delay_ms: DEFAULT_BASE_RETRY_MS,
88            max_retry_delay_ms: DEFAULT_MAX_RETRY_MS,
89            timeout_ms: DEFAULT_TIMEOUT_MS,
90            auto_tune: false,
91            tune_ttl_ms: DEFAULT_TUNE_TTL_MS,
92            compress_level: CompressLevel::Balanced,
93        }
94    }
95}
96
97/// Read an optional `u64`/`number` field from a JS object.
98fn opt_u64(obj: &JsValue, key: &str) -> Option<u64> {
99    Reflect::get(obj, &JsValue::from_str(key))
100        .ok()
101        .and_then(|v| v.as_f64())
102        .map(|f| f as u64)
103}
104
105/// Read an optional `usize` field from a JS object.
106fn opt_usize(obj: &JsValue, key: &str) -> Option<usize> {
107    Reflect::get(obj, &JsValue::from_str(key))
108        .ok()
109        .and_then(|v| v.as_f64())
110        .map(|f| f as usize)
111}
112
113/// Read an optional `u32` field from a JS object.
114fn opt_u32(obj: &JsValue, key: &str) -> Option<u32> {
115    Reflect::get(obj, &JsValue::from_str(key))
116        .ok()
117        .and_then(|v| v.as_f64())
118        .map(|f| f as u32)
119}
120
121/// Read an optional boolean field from a JS object.
122fn opt_bool(obj: &JsValue, key: &str) -> Option<bool> {
123    Reflect::get(obj, &JsValue::from_str(key))
124        .ok()
125        .and_then(|v| v.as_bool())
126}
127
128/// Read an optional `compressLevel` field: a string name
129/// (`auto|fast|balanced|max`) or a numeric level.
130fn opt_compress_level(obj: &JsValue) -> Option<CompressLevel> {
131    let v = Reflect::get(obj, &JsValue::from_str("compressLevel")).ok()?;
132    if let Some(s) = v.as_string() {
133        return Some(match s.to_ascii_lowercase().as_str() {
134            "auto" => CompressLevel::Auto,
135            "fast" => CompressLevel::Fast,
136            "balanced" => CompressLevel::Balanced,
137            "max" => CompressLevel::Max,
138            _ => CompressLevel::Auto,
139        });
140    }
141    v.as_f64().map(|f| CompressLevel::Fixed(f as i32))
142}
143
144impl ClientConfig {
145    /// Parse configuration from a JS object literal, e.g.
146    /// `{ concurrency: 4, compress: true, chunkSize: 2097152 }`.
147    ///
148    /// Safe to call with `null`/`undefined` (returns defaults) — this also
149    /// keeps native (non-wasm) unit tests runnable.
150    pub fn from_js(opts: &JsValue) -> ClientConfig {
151        let mut cfg = ClientConfig::default();
152        if !opts.is_object() {
153            return cfg;
154        }
155        // Legacy `compress: bool` compatibility: when `compressLevel` is
156        // absent, `compress: true` maps to Balanced (the old fixed level-1
157        // behavior) and `compress: false` maps to identity (the level is
158        // then unused anyway). An explicit `compressLevel` wins.
159        if let Some(level) = opt_compress_level(opts) {
160            cfg.compress_level = level;
161        } else if let Some(compress) = opt_bool(opts, "compress") {
162            cfg.compress = compress;
163            if compress {
164                cfg.compress_level = CompressLevel::Balanced;
165            }
166        }
167        if let Some(v) = opt_usize(opts, "concurrency") {
168            if v > 0 {
169                cfg.concurrency = v;
170            }
171        }
172        if let Some(v) = opt_usize(opts, "uploadWindow") {
173            if v > 0 {
174                cfg.upload_window = v;
175            }
176        }
177        if let Some(v) = opt_usize(opts, "downloadWindow") {
178            if v > 0 {
179                cfg.download_window = v;
180            }
181        }
182        if let Some(v) = opt_bool(opts, "compress") {
183            cfg.compress = v;
184        }
185        if let Some(v) = opt_u64(opts, "chunkSize") {
186            if v > 0 {
187                cfg.chunk_size = v;
188            }
189        }
190        if let Some(v) = opt_u32(opts, "maxRetries") {
191            cfg.max_retries = v;
192        }
193        if let Some(v) = opt_u32(opts, "baseRetryDelayMs") {
194            cfg.base_retry_delay_ms = v;
195        }
196        if let Some(v) = opt_u32(opts, "maxRetryDelayMs") {
197            cfg.max_retry_delay_ms = v;
198        }
199        if let Some(v) = opt_u32(opts, "timeoutMs") {
200            cfg.timeout_ms = v;
201        }
202        if let Some(v) = opt_bool(opts, "autoTune") {
203            cfg.auto_tune = v;
204        }
205        if let Some(v) = opt_u64(opts, "tuneTtlMs").filter(|&v| v > 0) {
206            cfg.tune_ttl_ms = v;
207        }
208        cfg
209    }
210
211    /// Exponential backoff delay for a failed attempt (0-based).
212    ///
213    /// `2^attempt * base`, clamped to `max`. Bounded so a hung peer cannot
214    /// grow the delay without limit.
215    pub fn backoff_ms(&self, attempt: u32) -> u32 {
216        backoff_ms(attempt, self.base_retry_delay_ms, self.max_retry_delay_ms)
217    }
218}
219
220/// Pure exponential backoff: `min(max, base << attempt)` with saturation.
221pub fn backoff_ms(attempt: u32, base: u32, max: u32) -> u32 {
222    if attempt == 0 {
223        return base.min(max);
224    }
225    let shift = attempt.min(31);
226    let doubled = (base as u64) << shift;
227    doubled.min(max as u64) as u32
228}
229
230#[cfg(test)]
231mod tests {
232    use super::*;
233
234    #[test]
235    fn defaults_are_sane() {
236        let cfg = ClientConfig::default();
237        assert_eq!(cfg.concurrency, 4);
238        assert_eq!(cfg.upload_window, DEFAULT_UPLOAD_WINDOW);
239        assert_eq!(cfg.download_window, DEFAULT_DOWNLOAD_WINDOW);
240        assert_eq!(cfg.chunk_size, CHUNK_SIZE);
241        assert_eq!(cfg.max_retries, MAX_RETRIES);
242        assert!(cfg.compress);
243    }
244
245    #[test]
246    fn backoff_grows_exponentially_then_clamps() {
247        let cfg = ClientConfig {
248            base_retry_delay_ms: 100,
249            max_retry_delay_ms: 1000,
250            ..ClientConfig::default()
251        };
252        assert_eq!(cfg.backoff_ms(0), 100);
253        assert_eq!(cfg.backoff_ms(1), 200);
254        assert_eq!(cfg.backoff_ms(2), 400);
255        assert_eq!(cfg.backoff_ms(3), 800);
256        assert_eq!(cfg.backoff_ms(4), 1000); // clamped
257        assert_eq!(cfg.backoff_ms(99), 1000);
258    }
259
260    #[test]
261    fn pure_backoff_saturates() {
262        assert_eq!(backoff_ms(0, 500, 30_000), 500);
263        assert_eq!(backoff_ms(6, 500, 30_000), 30_000);
264    }
265
266    #[test]
267    #[cfg(target_arch = "wasm32")]
268    fn parses_js_options() {
269        let obj = js_sys::Object::new();
270        js_sys::Reflect::set(&obj, &JsValue::from_str("concurrency"), &JsValue::from_f64(8.0))
271            .unwrap();
272        js_sys::Reflect::set(
273            &obj,
274            &JsValue::from_str("uploadWindow"),
275            &JsValue::from_f64(16.0),
276        )
277        .unwrap();
278        js_sys::Reflect::set(
279            &obj,
280            &JsValue::from_str("downloadWindow"),
281            &JsValue::from_f64(6.0),
282        )
283        .unwrap();
284        js_sys::Reflect::set(&obj, &JsValue::from_str("compress"), &JsValue::FALSE).unwrap();
285        js_sys::Reflect::set(
286            &obj,
287            &JsValue::from_str("chunkSize"),
288            &JsValue::from_f64(1024.0),
289        )
290        .unwrap();
291        let cfg = ClientConfig::from_js(&obj);
292        assert_eq!(cfg.concurrency, 8);
293        assert_eq!(cfg.upload_window, 16);
294        assert_eq!(cfg.download_window, 6);
295        assert!(!cfg.compress);
296        assert_eq!(cfg.chunk_size, 1024);
297    }
298
299    #[test]
300    #[cfg(target_arch = "wasm32")]
301    fn ignores_non_object_options() {
302        let cfg = ClientConfig::from_js(&JsValue::NULL);
303        assert_eq!(cfg.concurrency, DEFAULT_CONCURRENCY);
304        let cfg = ClientConfig::from_js(&JsValue::UNDEFINED);
305        assert_eq!(cfg.chunk_size, CHUNK_SIZE);
306    }
307}