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;
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    ///
66    /// Opting in is what makes the engine measure the link: without it the
67    /// knobs below are used exactly as configured.
68    ///
69    /// In the browser a settled result is persisted in `localStorage` (per
70    /// origin + direction) for up to `tune_ttl_ms`, so a page refresh does not
71    /// re-ramp; the native client keeps it in memory only.
72    pub auto_tune: bool,
73    /// How long a cached tuning result stays usable (milliseconds, default
74    /// 1 hour). `0` disables the cache entirely — every transfer re-ramps.
75    pub tune_ttl_ms: u64,
76    /// Client-side compression level policy, defaulting to
77    /// [`CompressLevel::Balanced`].
78    ///
79    /// Absent config resolves to whatever the server advertises as its own
80    /// default level, so a client that never sets the field negotiates the
81    /// server's choice. `compress: false` still forces identity regardless of
82    /// 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            compress: true,
93            chunk_size: CHUNK_SIZE,
94            max_retries: MAX_RETRIES,
95            base_retry_delay_ms: DEFAULT_BASE_RETRY_MS,
96            max_retry_delay_ms: DEFAULT_MAX_RETRY_MS,
97            timeout_ms: DEFAULT_TIMEOUT_MS,
98            auto_tune: false,
99            tune_ttl_ms: crate::tune::DEFAULT_TUNE_TTL_MS,
100            compress_level: CompressLevel::Balanced,
101        }
102    }
103}
104
105impl ClientConfig {
106    /// Concurrent HTTP requests the client may keep in flight:
107    /// `concurrency` files × the widest per-file window.
108    ///
109    /// The pool is a *bound*, not the parallelism knob: sized to
110    /// `concurrency` alone it would clamp every file to one request and make
111    /// the per-file window ineffective (see `tune::request_budget`).
112    pub fn request_budget(&self) -> usize {
113        crate::tune::request_budget(
114            self.concurrency,
115            self.upload_window.max(self.download_window),
116        )
117    }
118}
119
120/// Read an optional `u64`/`number` field from a JS object.
121fn opt_u64(obj: &JsValue, key: &str) -> Option<u64> {
122    Reflect::get(obj, &JsValue::from_str(key))
123        .ok()
124        .and_then(|v| v.as_f64())
125        .map(|f| f as u64)
126}
127
128/// Read an optional `usize` field from a JS object.
129fn opt_usize(obj: &JsValue, key: &str) -> Option<usize> {
130    Reflect::get(obj, &JsValue::from_str(key))
131        .ok()
132        .and_then(|v| v.as_f64())
133        .map(|f| f as usize)
134}
135
136/// Read an optional `u32` field from a JS object.
137fn opt_u32(obj: &JsValue, key: &str) -> Option<u32> {
138    Reflect::get(obj, &JsValue::from_str(key))
139        .ok()
140        .and_then(|v| v.as_f64())
141        .map(|f| f as u32)
142}
143
144/// Read an optional boolean field from a JS object.
145fn opt_bool(obj: &JsValue, key: &str) -> Option<bool> {
146    Reflect::get(obj, &JsValue::from_str(key))
147        .ok()
148        .and_then(|v| v.as_bool())
149}
150
151/// Read an optional `compressLevel` field: a string name
152/// (`auto|fast|balanced|max`) or a numeric level.
153fn opt_compress_level(obj: &JsValue) -> Option<CompressLevel> {
154    let v = Reflect::get(obj, &JsValue::from_str("compressLevel")).ok()?;
155    if let Some(s) = v.as_string() {
156        return Some(match s.to_ascii_lowercase().as_str() {
157            "auto" => CompressLevel::Auto,
158            "fast" => CompressLevel::Fast,
159            "balanced" => CompressLevel::Balanced,
160            "max" => CompressLevel::Max,
161            _ => CompressLevel::Auto,
162        });
163    }
164    v.as_f64().map(|f| CompressLevel::Fixed(f as i32))
165}
166
167impl ClientConfig {
168    /// Parse configuration from a JS object literal, e.g.
169    /// `{ concurrency: 4, compress: true, chunkSize: 2097152 }`.
170    ///
171    /// Safe to call with `null`/`undefined` (returns defaults) — this also
172    /// keeps native (non-wasm) unit tests runnable.
173    pub fn from_js(opts: &JsValue) -> ClientConfig {
174        let mut cfg = ClientConfig::default();
175        if !opts.is_object() {
176            return cfg;
177        }
178        // With no `compressLevel` in the options, the `compress: bool` flag
179        // picks the policy: `compress: true` maps to Balanced (the server's
180        // advertised default level) and `compress: false` maps to identity
181        // (the level is then unused anyway). An explicit `compressLevel`
182        // wins.
183        if let Some(level) = opt_compress_level(opts) {
184            cfg.compress_level = level;
185        } else if let Some(compress) = opt_bool(opts, "compress") {
186            cfg.compress = compress;
187            if compress {
188                cfg.compress_level = CompressLevel::Balanced;
189            }
190        }
191        if let Some(v) = opt_usize(opts, "concurrency") {
192            if v > 0 {
193                cfg.concurrency = v;
194            }
195        }
196        if let Some(v) = opt_usize(opts, "uploadWindow") {
197            if v > 0 {
198                cfg.upload_window = v;
199            }
200        }
201        if let Some(v) = opt_usize(opts, "downloadWindow") {
202            if v > 0 {
203                cfg.download_window = v;
204            }
205        }
206        if let Some(v) = opt_bool(opts, "compress") {
207            cfg.compress = v;
208        }
209        if let Some(v) = opt_u64(opts, "chunkSize") {
210            if v > 0 {
211                cfg.chunk_size = v;
212            }
213        }
214        if let Some(v) = opt_u32(opts, "maxRetries") {
215            cfg.max_retries = v;
216        }
217        if let Some(v) = opt_u32(opts, "baseRetryDelayMs") {
218            cfg.base_retry_delay_ms = v;
219        }
220        if let Some(v) = opt_u32(opts, "maxRetryDelayMs") {
221            cfg.max_retry_delay_ms = v;
222        }
223        if let Some(v) = opt_u32(opts, "timeoutMs") {
224            cfg.timeout_ms = v;
225        }
226        if let Some(v) = opt_bool(opts, "autoTune") {
227            cfg.auto_tune = v;
228        }
229        if let Some(v) = opt_u64(opts, "tuneTtlMs") {
230            cfg.tune_ttl_ms = v;
231        }
232        cfg
233    }
234
235    /// Exponential backoff delay for a failed attempt (0-based).
236    ///
237    /// `2^attempt * base`, clamped to `max`. Bounded so a hung peer cannot
238    /// grow the delay without limit.
239    pub fn backoff_ms(&self, attempt: u32) -> u32 {
240        backoff_ms(attempt, self.base_retry_delay_ms, self.max_retry_delay_ms)
241    }
242}
243
244/// Pure exponential backoff: `min(max, base << attempt)` with saturation.
245pub fn backoff_ms(attempt: u32, base: u32, max: u32) -> u32 {
246    if attempt == 0 {
247        return base.min(max);
248    }
249    let shift = attempt.min(31);
250    let doubled = (base as u64) << shift;
251    doubled.min(max as u64) as u32
252}
253
254#[cfg(test)]
255mod tests {
256    use super::*;
257
258    #[test]
259    fn defaults_are_sane() {
260        let cfg = ClientConfig::default();
261        assert_eq!(cfg.concurrency, 4);
262        assert_eq!(cfg.upload_window, DEFAULT_UPLOAD_WINDOW);
263        assert_eq!(cfg.download_window, DEFAULT_DOWNLOAD_WINDOW);
264        assert_eq!(cfg.chunk_size, CHUNK_SIZE);
265        assert_eq!(cfg.max_retries, MAX_RETRIES);
266        assert!(cfg.compress);
267    }
268
269    #[test]
270    fn backoff_grows_exponentially_then_clamps() {
271        let cfg = ClientConfig {
272            base_retry_delay_ms: 100,
273            max_retry_delay_ms: 1000,
274            ..ClientConfig::default()
275        };
276        assert_eq!(cfg.backoff_ms(0), 100);
277        assert_eq!(cfg.backoff_ms(1), 200);
278        assert_eq!(cfg.backoff_ms(2), 400);
279        assert_eq!(cfg.backoff_ms(3), 800);
280        assert_eq!(cfg.backoff_ms(4), 1000); // clamped
281        assert_eq!(cfg.backoff_ms(99), 1000);
282    }
283
284    #[test]
285    fn pure_backoff_saturates() {
286        assert_eq!(backoff_ms(0, 500, 30_000), 500);
287        assert_eq!(backoff_ms(6, 500, 30_000), 30_000);
288    }
289
290    #[test]
291    #[cfg(target_arch = "wasm32")]
292    fn parses_js_options() {
293        let obj = js_sys::Object::new();
294        js_sys::Reflect::set(&obj, &JsValue::from_str("concurrency"), &JsValue::from_f64(8.0))
295            .unwrap();
296        js_sys::Reflect::set(
297            &obj,
298            &JsValue::from_str("uploadWindow"),
299            &JsValue::from_f64(16.0),
300        )
301        .unwrap();
302        js_sys::Reflect::set(
303            &obj,
304            &JsValue::from_str("downloadWindow"),
305            &JsValue::from_f64(6.0),
306        )
307        .unwrap();
308        js_sys::Reflect::set(&obj, &JsValue::from_str("compress"), &JsValue::FALSE).unwrap();
309        js_sys::Reflect::set(
310            &obj,
311            &JsValue::from_str("chunkSize"),
312            &JsValue::from_f64(1024.0),
313        )
314        .unwrap();
315        let cfg = ClientConfig::from_js(&obj);
316        assert_eq!(cfg.concurrency, 8);
317        assert_eq!(cfg.upload_window, 16);
318        assert_eq!(cfg.download_window, 6);
319        assert!(!cfg.compress);
320        assert_eq!(cfg.chunk_size, 1024);
321    }
322
323    #[test]
324    #[cfg(target_arch = "wasm32")]
325    fn ignores_non_object_options() {
326        let cfg = ClientConfig::from_js(&JsValue::NULL);
327        assert_eq!(cfg.concurrency, DEFAULT_CONCURRENCY);
328        let cfg = ClientConfig::from_js(&JsValue::UNDEFINED);
329        assert_eq!(cfg.chunk_size, CHUNK_SIZE);
330    }
331
332    #[test]
333    #[cfg(target_arch = "wasm32")]
334    fn parses_compress_level_policy() {
335        let with = |key: &str, value: &JsValue| {
336            let obj = js_sys::Object::new();
337            js_sys::Reflect::set(&obj, &JsValue::from_str(key), value).unwrap();
338            ClientConfig::from_js(&obj)
339        };
340        // Named policies map to the matching enum arm.
341        assert_eq!(
342            with("compressLevel", &JsValue::from_str("max")).compress_level,
343            CompressLevel::Max
344        );
345        assert_eq!(
346            with("compressLevel", &JsValue::from_str("FAST")).compress_level,
347            CompressLevel::Fast
348        );
349        assert_eq!(
350            with("compressLevel", &JsValue::from_str("auto")).compress_level,
351            CompressLevel::Auto
352        );
353        // A number is an explicit level.
354        assert_eq!(
355            with("compressLevel", &JsValue::from_f64(3.0)).compress_level,
356            CompressLevel::Fixed(3)
357        );
358        // `null` (the SDK's default when the option is unset) falls back to
359        // the `compress` flag → Balanced.
360        assert_eq!(
361            with("compressLevel", &JsValue::NULL).compress_level,
362            CompressLevel::Balanced
363        );
364    }
365}