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