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::{CHUNK_SIZE, DEFAULT_CONCURRENCY, MAX_RETRIES};
10
11/// Default delay before the first retry (milliseconds).
12pub const DEFAULT_BASE_RETRY_MS: u32 = 500;
13/// Upper bound for exponential backoff (milliseconds).
14pub const DEFAULT_MAX_RETRY_MS: u32 = 30_000;
15/// Default per-request timeout (milliseconds).
16pub const DEFAULT_TIMEOUT_MS: u32 = 60_000;
17
18/// Runtime configuration of the WASM engine.
19#[derive(Debug, Clone)]
20pub struct ClientConfig {
21    /// Maximum number of concurrent chunk/file transfers (default 4).
22    pub concurrency: usize,
23    /// Request `zrip` compression from the server / compress uploads.
24    pub compress: bool,
25    /// Fixed chunk size used to slice files (default 2 MiB).
26    pub chunk_size: u64,
27    /// Maximum retries per chunk/file (default 3).
28    pub max_retries: u32,
29    /// Initial backoff delay in ms (default 500).
30    pub base_retry_delay_ms: u32,
31    /// Backoff ceiling in ms (default 30s).
32    pub max_retry_delay_ms: u32,
33    /// Per-request timeout in ms (default 60s).
34    pub timeout_ms: u32,
35}
36
37impl Default for ClientConfig {
38    fn default() -> Self {
39        ClientConfig {
40            concurrency: DEFAULT_CONCURRENCY,
41            compress: true,
42            chunk_size: CHUNK_SIZE,
43            max_retries: MAX_RETRIES,
44            base_retry_delay_ms: DEFAULT_BASE_RETRY_MS,
45            max_retry_delay_ms: DEFAULT_MAX_RETRY_MS,
46            timeout_ms: DEFAULT_TIMEOUT_MS,
47        }
48    }
49}
50
51/// Read an optional `u64`/`number` field from a JS object.
52fn opt_u64(obj: &JsValue, key: &str) -> Option<u64> {
53    Reflect::get(obj, &JsValue::from_str(key))
54        .ok()
55        .and_then(|v| v.as_f64())
56        .map(|f| f as u64)
57}
58
59/// Read an optional `usize` field from a JS object.
60fn opt_usize(obj: &JsValue, key: &str) -> Option<usize> {
61    Reflect::get(obj, &JsValue::from_str(key))
62        .ok()
63        .and_then(|v| v.as_f64())
64        .map(|f| f as usize)
65}
66
67/// Read an optional `u32` field from a JS object.
68fn opt_u32(obj: &JsValue, key: &str) -> Option<u32> {
69    Reflect::get(obj, &JsValue::from_str(key))
70        .ok()
71        .and_then(|v| v.as_f64())
72        .map(|f| f as u32)
73}
74
75/// Read an optional boolean field from a JS object.
76fn opt_bool(obj: &JsValue, key: &str) -> Option<bool> {
77    Reflect::get(obj, &JsValue::from_str(key))
78        .ok()
79        .and_then(|v| v.as_bool())
80}
81
82impl ClientConfig {
83    /// Parse configuration from a JS object literal, e.g.
84    /// `{ concurrency: 4, compress: true, chunkSize: 2097152 }`.
85    ///
86    /// Safe to call with `null`/`undefined` (returns defaults) — this also
87    /// keeps native (non-wasm) unit tests runnable.
88    pub fn from_js(opts: &JsValue) -> ClientConfig {
89        let mut cfg = ClientConfig::default();
90        if !opts.is_object() {
91            return cfg;
92        }
93        if let Some(v) = opt_usize(opts, "concurrency") {
94            if v > 0 {
95                cfg.concurrency = v;
96            }
97        }
98        if let Some(v) = opt_bool(opts, "compress") {
99            cfg.compress = v;
100        }
101        if let Some(v) = opt_u64(opts, "chunkSize") {
102            if v > 0 {
103                cfg.chunk_size = v;
104            }
105        }
106        if let Some(v) = opt_u32(opts, "maxRetries") {
107            cfg.max_retries = v;
108        }
109        if let Some(v) = opt_u32(opts, "baseRetryDelayMs") {
110            cfg.base_retry_delay_ms = v;
111        }
112        if let Some(v) = opt_u32(opts, "maxRetryDelayMs") {
113            cfg.max_retry_delay_ms = v;
114        }
115        if let Some(v) = opt_u32(opts, "timeoutMs") {
116            cfg.timeout_ms = v;
117        }
118        cfg
119    }
120
121    /// Exponential backoff delay for a failed attempt (0-based).
122    ///
123    /// `2^attempt * base`, clamped to `max`. Bounded so a hung peer cannot
124    /// grow the delay without limit.
125    pub fn backoff_ms(&self, attempt: u32) -> u32 {
126        backoff_ms(attempt, self.base_retry_delay_ms, self.max_retry_delay_ms)
127    }
128}
129
130/// Pure exponential backoff: `min(max, base << attempt)` with saturation.
131pub fn backoff_ms(attempt: u32, base: u32, max: u32) -> u32 {
132    if attempt == 0 {
133        return base.min(max);
134    }
135    let shift = attempt.min(31);
136    let doubled = (base as u64) << shift;
137    doubled.min(max as u64) as u32
138}
139
140#[cfg(test)]
141mod tests {
142    use super::*;
143
144    #[test]
145    fn defaults_are_sane() {
146        let cfg = ClientConfig::default();
147        assert_eq!(cfg.concurrency, 4);
148        assert_eq!(cfg.chunk_size, CHUNK_SIZE);
149        assert_eq!(cfg.max_retries, MAX_RETRIES);
150        assert!(cfg.compress);
151    }
152
153    #[test]
154    fn backoff_grows_exponentially_then_clamps() {
155        let cfg = ClientConfig {
156            base_retry_delay_ms: 100,
157            max_retry_delay_ms: 1000,
158            ..ClientConfig::default()
159        };
160        assert_eq!(cfg.backoff_ms(0), 100);
161        assert_eq!(cfg.backoff_ms(1), 200);
162        assert_eq!(cfg.backoff_ms(2), 400);
163        assert_eq!(cfg.backoff_ms(3), 800);
164        assert_eq!(cfg.backoff_ms(4), 1000); // clamped
165        assert_eq!(cfg.backoff_ms(99), 1000);
166    }
167
168    #[test]
169    fn pure_backoff_saturates() {
170        assert_eq!(backoff_ms(0, 500, 30_000), 500);
171        assert_eq!(backoff_ms(6, 500, 30_000), 30_000);
172    }
173
174    #[test]
175    #[cfg(target_arch = "wasm32")]
176    fn parses_js_options() {
177        let obj = js_sys::Object::new();
178        js_sys::Reflect::set(&obj, &JsValue::from_str("concurrency"), &JsValue::from_f64(8.0))
179            .unwrap();
180        js_sys::Reflect::set(&obj, &JsValue::from_str("compress"), &JsValue::FALSE).unwrap();
181        js_sys::Reflect::set(
182            &obj,
183            &JsValue::from_str("chunkSize"),
184            &JsValue::from_f64(1024.0),
185        )
186        .unwrap();
187        let cfg = ClientConfig::from_js(&obj);
188        assert_eq!(cfg.concurrency, 8);
189        assert!(!cfg.compress);
190        assert_eq!(cfg.chunk_size, 1024);
191    }
192
193    #[test]
194    #[cfg(target_arch = "wasm32")]
195    fn ignores_non_object_options() {
196        let cfg = ClientConfig::from_js(&JsValue::NULL);
197        assert_eq!(cfg.concurrency, DEFAULT_CONCURRENCY);
198        let cfg = ClientConfig::from_js(&JsValue::UNDEFINED);
199        assert_eq!(cfg.chunk_size, CHUNK_SIZE);
200    }
201}