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
14/// Default delay before the first retry (milliseconds).
15pub const DEFAULT_BASE_RETRY_MS: u32 = 500;
16/// Upper bound for exponential backoff (milliseconds).
17pub const DEFAULT_MAX_RETRY_MS: u32 = 30_000;
18/// Default per-request timeout (milliseconds).
19pub const DEFAULT_TIMEOUT_MS: u32 = 60_000;
20
21/// Runtime configuration of the WASM engine.
22#[derive(Debug, Clone)]
23pub struct ClientConfig {
24    /// Maximum number of concurrently-transferring files (default 4).
25    pub concurrency: usize,
26    /// In-flight chunk window for a single file's upload (default 8).
27    ///
28    /// Independent of `concurrency`: one file keeps up to `upload_window`
29    /// chunks in flight, so a high-latency link stays saturated (throughput
30    /// is bounded by bandwidth, not `chunk_size / RTT`).
31    pub upload_window: usize,
32    /// In-flight byte-range window for a single file's **download** (default
33    /// 4).
34    ///
35    /// A large file is downloaded as `download_window` concurrent `Range`
36    /// GETs so its throughput is bounded by bandwidth instead of one
37    /// connection's `chunk_size / RTT` (tus-style parallel transfer). Set to
38    /// `1` to fall back to the sequential single-connection path.
39    pub download_window: usize,
40    /// Chunk size for parallel downloads (default 256 KiB).
41    ///
42    /// Smaller than the upload chunk on purpose: the WASM engine reorders
43    /// in-flight chunks in memory so the SDK keeps receiving them in order
44    /// (append-mode writes), and `download_window * download_chunk_size` is
45    /// that buffer's worst-case size.
46    pub download_chunk_size: u64,
47    /// Request `zrip` compression from the server / compress uploads.
48    pub compress: bool,
49    /// Fixed chunk size used to slice files (default 2 MiB).
50    pub chunk_size: u64,
51    /// Maximum retries per chunk/file (default 3).
52    pub max_retries: u32,
53    /// Initial backoff delay in ms (default 500).
54    pub base_retry_delay_ms: u32,
55    /// Backoff ceiling in ms (default 30s).
56    pub max_retry_delay_ms: u32,
57    /// Per-request timeout in ms (default 60s).
58    pub timeout_ms: u32,
59}
60
61impl Default for ClientConfig {
62    fn default() -> Self {
63        ClientConfig {
64            concurrency: DEFAULT_CONCURRENCY,
65            upload_window: DEFAULT_UPLOAD_WINDOW,
66            download_window: DEFAULT_DOWNLOAD_WINDOW,
67            download_chunk_size: DEFAULT_DOWNLOAD_CHUNK_SIZE,
68            compress: true,
69            chunk_size: CHUNK_SIZE,
70            max_retries: MAX_RETRIES,
71            base_retry_delay_ms: DEFAULT_BASE_RETRY_MS,
72            max_retry_delay_ms: DEFAULT_MAX_RETRY_MS,
73            timeout_ms: DEFAULT_TIMEOUT_MS,
74        }
75    }
76}
77
78/// Read an optional `u64`/`number` field from a JS object.
79fn opt_u64(obj: &JsValue, key: &str) -> Option<u64> {
80    Reflect::get(obj, &JsValue::from_str(key))
81        .ok()
82        .and_then(|v| v.as_f64())
83        .map(|f| f as u64)
84}
85
86/// Read an optional `usize` field from a JS object.
87fn opt_usize(obj: &JsValue, key: &str) -> Option<usize> {
88    Reflect::get(obj, &JsValue::from_str(key))
89        .ok()
90        .and_then(|v| v.as_f64())
91        .map(|f| f as usize)
92}
93
94/// Read an optional `u32` field from a JS object.
95fn opt_u32(obj: &JsValue, key: &str) -> Option<u32> {
96    Reflect::get(obj, &JsValue::from_str(key))
97        .ok()
98        .and_then(|v| v.as_f64())
99        .map(|f| f as u32)
100}
101
102/// Read an optional boolean field from a JS object.
103fn opt_bool(obj: &JsValue, key: &str) -> Option<bool> {
104    Reflect::get(obj, &JsValue::from_str(key))
105        .ok()
106        .and_then(|v| v.as_bool())
107}
108
109impl ClientConfig {
110    /// Parse configuration from a JS object literal, e.g.
111    /// `{ concurrency: 4, compress: true, chunkSize: 2097152 }`.
112    ///
113    /// Safe to call with `null`/`undefined` (returns defaults) — this also
114    /// keeps native (non-wasm) unit tests runnable.
115    pub fn from_js(opts: &JsValue) -> ClientConfig {
116        let mut cfg = ClientConfig::default();
117        if !opts.is_object() {
118            return cfg;
119        }
120        if let Some(v) = opt_usize(opts, "concurrency") {
121            if v > 0 {
122                cfg.concurrency = v;
123            }
124        }
125        if let Some(v) = opt_usize(opts, "uploadWindow") {
126            if v > 0 {
127                cfg.upload_window = v;
128            }
129        }
130        if let Some(v) = opt_usize(opts, "downloadWindow") {
131            if v > 0 {
132                cfg.download_window = v;
133            }
134        }
135        if let Some(v) = opt_u64(opts, "downloadChunkSize") {
136            if v > 0 {
137                cfg.download_chunk_size = v;
138            }
139        }
140        if let Some(v) = opt_bool(opts, "compress") {
141            cfg.compress = v;
142        }
143        if let Some(v) = opt_u64(opts, "chunkSize") {
144            if v > 0 {
145                cfg.chunk_size = v;
146            }
147        }
148        if let Some(v) = opt_u32(opts, "maxRetries") {
149            cfg.max_retries = v;
150        }
151        if let Some(v) = opt_u32(opts, "baseRetryDelayMs") {
152            cfg.base_retry_delay_ms = v;
153        }
154        if let Some(v) = opt_u32(opts, "maxRetryDelayMs") {
155            cfg.max_retry_delay_ms = v;
156        }
157        if let Some(v) = opt_u32(opts, "timeoutMs") {
158            cfg.timeout_ms = v;
159        }
160        cfg
161    }
162
163    /// Exponential backoff delay for a failed attempt (0-based).
164    ///
165    /// `2^attempt * base`, clamped to `max`. Bounded so a hung peer cannot
166    /// grow the delay without limit.
167    pub fn backoff_ms(&self, attempt: u32) -> u32 {
168        backoff_ms(attempt, self.base_retry_delay_ms, self.max_retry_delay_ms)
169    }
170}
171
172/// Pure exponential backoff: `min(max, base << attempt)` with saturation.
173pub fn backoff_ms(attempt: u32, base: u32, max: u32) -> u32 {
174    if attempt == 0 {
175        return base.min(max);
176    }
177    let shift = attempt.min(31);
178    let doubled = (base as u64) << shift;
179    doubled.min(max as u64) as u32
180}
181
182#[cfg(test)]
183mod tests {
184    use super::*;
185
186    #[test]
187    fn defaults_are_sane() {
188        let cfg = ClientConfig::default();
189        assert_eq!(cfg.concurrency, 4);
190        assert_eq!(cfg.upload_window, DEFAULT_UPLOAD_WINDOW);
191        assert_eq!(cfg.download_window, DEFAULT_DOWNLOAD_WINDOW);
192        assert_eq!(cfg.download_chunk_size, DEFAULT_DOWNLOAD_CHUNK_SIZE);
193        assert_eq!(cfg.chunk_size, CHUNK_SIZE);
194        assert_eq!(cfg.max_retries, MAX_RETRIES);
195        assert!(cfg.compress);
196    }
197
198    #[test]
199    fn backoff_grows_exponentially_then_clamps() {
200        let cfg = ClientConfig {
201            base_retry_delay_ms: 100,
202            max_retry_delay_ms: 1000,
203            ..ClientConfig::default()
204        };
205        assert_eq!(cfg.backoff_ms(0), 100);
206        assert_eq!(cfg.backoff_ms(1), 200);
207        assert_eq!(cfg.backoff_ms(2), 400);
208        assert_eq!(cfg.backoff_ms(3), 800);
209        assert_eq!(cfg.backoff_ms(4), 1000); // clamped
210        assert_eq!(cfg.backoff_ms(99), 1000);
211    }
212
213    #[test]
214    fn pure_backoff_saturates() {
215        assert_eq!(backoff_ms(0, 500, 30_000), 500);
216        assert_eq!(backoff_ms(6, 500, 30_000), 30_000);
217    }
218
219    #[test]
220    #[cfg(target_arch = "wasm32")]
221    fn parses_js_options() {
222        let obj = js_sys::Object::new();
223        js_sys::Reflect::set(&obj, &JsValue::from_str("concurrency"), &JsValue::from_f64(8.0))
224            .unwrap();
225        js_sys::Reflect::set(
226            &obj,
227            &JsValue::from_str("uploadWindow"),
228            &JsValue::from_f64(16.0),
229        )
230        .unwrap();
231        js_sys::Reflect::set(
232            &obj,
233            &JsValue::from_str("downloadWindow"),
234            &JsValue::from_f64(6.0),
235        )
236        .unwrap();
237        js_sys::Reflect::set(
238            &obj,
239            &JsValue::from_str("downloadChunkSize"),
240            &JsValue::from_f64(131072.0),
241        )
242        .unwrap();
243        js_sys::Reflect::set(&obj, &JsValue::from_str("compress"), &JsValue::FALSE).unwrap();
244        js_sys::Reflect::set(
245            &obj,
246            &JsValue::from_str("chunkSize"),
247            &JsValue::from_f64(1024.0),
248        )
249        .unwrap();
250        let cfg = ClientConfig::from_js(&obj);
251        assert_eq!(cfg.concurrency, 8);
252        assert_eq!(cfg.upload_window, 16);
253        assert_eq!(cfg.download_window, 6);
254        assert_eq!(cfg.download_chunk_size, 131072);
255        assert!(!cfg.compress);
256        assert_eq!(cfg.chunk_size, 1024);
257    }
258
259    #[test]
260    #[cfg(target_arch = "wasm32")]
261    fn ignores_non_object_options() {
262        let cfg = ClientConfig::from_js(&JsValue::NULL);
263        assert_eq!(cfg.concurrency, DEFAULT_CONCURRENCY);
264        let cfg = ClientConfig::from_js(&JsValue::UNDEFINED);
265        assert_eq!(cfg.chunk_size, CHUNK_SIZE);
266    }
267}