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