1use 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
14use crate::tune::{CompressLevel, DEFAULT_TUNE_TTL_MS};
15
16pub const DEFAULT_BASE_RETRY_MS: u32 = 500;
18pub const DEFAULT_MAX_RETRY_MS: u32 = 30_000;
20pub const DEFAULT_TIMEOUT_MS: u32 = 60_000;
22
23#[derive(Debug, Clone)]
25pub struct ClientConfig {
26 pub concurrency: usize,
34 pub upload_window: usize,
40 pub download_window: usize,
48 pub download_chunk_size: u64,
55 pub compress: bool,
57 pub chunk_size: u64,
64 pub max_retries: u32,
66 pub base_retry_delay_ms: u32,
68 pub max_retry_delay_ms: u32,
70 pub timeout_ms: u32,
72 pub auto_tune: bool,
75 pub tune_ttl_ms: u64,
77 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 download_chunk_size: DEFAULT_DOWNLOAD_CHUNK_SIZE,
93 compress: true,
94 chunk_size: CHUNK_SIZE,
95 max_retries: MAX_RETRIES,
96 base_retry_delay_ms: DEFAULT_BASE_RETRY_MS,
97 max_retry_delay_ms: DEFAULT_MAX_RETRY_MS,
98 timeout_ms: DEFAULT_TIMEOUT_MS,
99 auto_tune: false,
100 tune_ttl_ms: DEFAULT_TUNE_TTL_MS,
101 compress_level: CompressLevel::Balanced,
102 }
103 }
104}
105
106fn opt_u64(obj: &JsValue, key: &str) -> Option<u64> {
108 Reflect::get(obj, &JsValue::from_str(key))
109 .ok()
110 .and_then(|v| v.as_f64())
111 .map(|f| f as u64)
112}
113
114fn opt_usize(obj: &JsValue, key: &str) -> Option<usize> {
116 Reflect::get(obj, &JsValue::from_str(key))
117 .ok()
118 .and_then(|v| v.as_f64())
119 .map(|f| f as usize)
120}
121
122fn opt_u32(obj: &JsValue, key: &str) -> Option<u32> {
124 Reflect::get(obj, &JsValue::from_str(key))
125 .ok()
126 .and_then(|v| v.as_f64())
127 .map(|f| f as u32)
128}
129
130fn opt_bool(obj: &JsValue, key: &str) -> Option<bool> {
132 Reflect::get(obj, &JsValue::from_str(key))
133 .ok()
134 .and_then(|v| v.as_bool())
135}
136
137fn opt_compress_level(obj: &JsValue) -> Option<CompressLevel> {
140 let v = Reflect::get(obj, &JsValue::from_str("compressLevel")).ok()?;
141 if let Some(s) = v.as_string() {
142 return Some(match s.to_ascii_lowercase().as_str() {
143 "auto" => CompressLevel::Auto,
144 "fast" => CompressLevel::Fast,
145 "balanced" => CompressLevel::Balanced,
146 "max" => CompressLevel::Max,
147 _ => CompressLevel::Auto,
148 });
149 }
150 v.as_f64().map(|f| CompressLevel::Fixed(f as i32))
151}
152
153impl ClientConfig {
154 pub fn from_js(opts: &JsValue) -> ClientConfig {
160 let mut cfg = ClientConfig::default();
161 if !opts.is_object() {
162 return cfg;
163 }
164 if let Some(level) = opt_compress_level(opts) {
169 cfg.compress_level = level;
170 } else if let Some(compress) = opt_bool(opts, "compress") {
171 cfg.compress = compress;
172 if compress {
173 cfg.compress_level = CompressLevel::Balanced;
174 }
175 }
176 if let Some(v) = opt_usize(opts, "concurrency") {
177 if v > 0 {
178 cfg.concurrency = v;
179 }
180 }
181 if let Some(v) = opt_usize(opts, "uploadWindow") {
182 if v > 0 {
183 cfg.upload_window = v;
184 }
185 }
186 if let Some(v) = opt_usize(opts, "downloadWindow") {
187 if v > 0 {
188 cfg.download_window = v;
189 }
190 }
191 if let Some(v) = opt_u64(opts, "downloadChunkSize") {
192 if v > 0 {
193 cfg.download_chunk_size = v;
194 }
195 }
196 if let Some(v) = opt_bool(opts, "compress") {
197 cfg.compress = v;
198 }
199 if let Some(v) = opt_u64(opts, "chunkSize") {
200 if v > 0 {
201 cfg.chunk_size = v;
202 }
203 }
204 if let Some(v) = opt_u32(opts, "maxRetries") {
205 cfg.max_retries = v;
206 }
207 if let Some(v) = opt_u32(opts, "baseRetryDelayMs") {
208 cfg.base_retry_delay_ms = v;
209 }
210 if let Some(v) = opt_u32(opts, "maxRetryDelayMs") {
211 cfg.max_retry_delay_ms = v;
212 }
213 if let Some(v) = opt_u32(opts, "timeoutMs") {
214 cfg.timeout_ms = v;
215 }
216 if let Some(v) = opt_bool(opts, "autoTune") {
217 cfg.auto_tune = v;
218 }
219 if let Some(v) = opt_u64(opts, "tuneTtlMs").filter(|&v| v > 0) {
220 cfg.tune_ttl_ms = v;
221 }
222 cfg
223 }
224
225 pub fn backoff_ms(&self, attempt: u32) -> u32 {
230 backoff_ms(attempt, self.base_retry_delay_ms, self.max_retry_delay_ms)
231 }
232}
233
234pub fn backoff_ms(attempt: u32, base: u32, max: u32) -> u32 {
236 if attempt == 0 {
237 return base.min(max);
238 }
239 let shift = attempt.min(31);
240 let doubled = (base as u64) << shift;
241 doubled.min(max as u64) as u32
242}
243
244#[cfg(test)]
245mod tests {
246 use super::*;
247
248 #[test]
249 fn defaults_are_sane() {
250 let cfg = ClientConfig::default();
251 assert_eq!(cfg.concurrency, 4);
252 assert_eq!(cfg.upload_window, DEFAULT_UPLOAD_WINDOW);
253 assert_eq!(cfg.download_window, DEFAULT_DOWNLOAD_WINDOW);
254 assert_eq!(cfg.download_chunk_size, DEFAULT_DOWNLOAD_CHUNK_SIZE);
255 assert_eq!(cfg.chunk_size, CHUNK_SIZE);
256 assert_eq!(cfg.max_retries, MAX_RETRIES);
257 assert!(cfg.compress);
258 }
259
260 #[test]
261 fn backoff_grows_exponentially_then_clamps() {
262 let cfg = ClientConfig {
263 base_retry_delay_ms: 100,
264 max_retry_delay_ms: 1000,
265 ..ClientConfig::default()
266 };
267 assert_eq!(cfg.backoff_ms(0), 100);
268 assert_eq!(cfg.backoff_ms(1), 200);
269 assert_eq!(cfg.backoff_ms(2), 400);
270 assert_eq!(cfg.backoff_ms(3), 800);
271 assert_eq!(cfg.backoff_ms(4), 1000); assert_eq!(cfg.backoff_ms(99), 1000);
273 }
274
275 #[test]
276 fn pure_backoff_saturates() {
277 assert_eq!(backoff_ms(0, 500, 30_000), 500);
278 assert_eq!(backoff_ms(6, 500, 30_000), 30_000);
279 }
280
281 #[test]
282 #[cfg(target_arch = "wasm32")]
283 fn parses_js_options() {
284 let obj = js_sys::Object::new();
285 js_sys::Reflect::set(&obj, &JsValue::from_str("concurrency"), &JsValue::from_f64(8.0))
286 .unwrap();
287 js_sys::Reflect::set(
288 &obj,
289 &JsValue::from_str("uploadWindow"),
290 &JsValue::from_f64(16.0),
291 )
292 .unwrap();
293 js_sys::Reflect::set(
294 &obj,
295 &JsValue::from_str("downloadWindow"),
296 &JsValue::from_f64(6.0),
297 )
298 .unwrap();
299 js_sys::Reflect::set(
300 &obj,
301 &JsValue::from_str("downloadChunkSize"),
302 &JsValue::from_f64(131072.0),
303 )
304 .unwrap();
305 js_sys::Reflect::set(&obj, &JsValue::from_str("compress"), &JsValue::FALSE).unwrap();
306 js_sys::Reflect::set(
307 &obj,
308 &JsValue::from_str("chunkSize"),
309 &JsValue::from_f64(1024.0),
310 )
311 .unwrap();
312 let cfg = ClientConfig::from_js(&obj);
313 assert_eq!(cfg.concurrency, 8);
314 assert_eq!(cfg.upload_window, 16);
315 assert_eq!(cfg.download_window, 6);
316 assert_eq!(cfg.download_chunk_size, 131072);
317 assert!(!cfg.compress);
318 assert_eq!(cfg.chunk_size, 1024);
319 }
320
321 #[test]
322 #[cfg(target_arch = "wasm32")]
323 fn ignores_non_object_options() {
324 let cfg = ClientConfig::from_js(&JsValue::NULL);
325 assert_eq!(cfg.concurrency, DEFAULT_CONCURRENCY);
326 let cfg = ClientConfig::from_js(&JsValue::UNDEFINED);
327 assert_eq!(cfg.chunk_size, CHUNK_SIZE);
328 }
329}