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