1use 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
15pub const DEFAULT_BASE_RETRY_MS: u32 = 500;
17pub const DEFAULT_MAX_RETRY_MS: u32 = 30_000;
19pub const DEFAULT_TIMEOUT_MS: u32 = 60_000;
21
22#[derive(Debug, Clone)]
24pub struct ClientConfig {
25 pub concurrency: usize,
33 pub upload_window: usize,
39 pub download_window: usize,
47 pub compress: bool,
49 pub chunk_size: u64,
56 pub max_retries: u32,
58 pub base_retry_delay_ms: u32,
60 pub max_retry_delay_ms: u32,
62 pub timeout_ms: u32,
64 pub auto_tune: bool,
73 pub tune_ttl_ms: u64,
76 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 compress: true,
93 chunk_size: CHUNK_SIZE,
94 max_retries: MAX_RETRIES,
95 base_retry_delay_ms: DEFAULT_BASE_RETRY_MS,
96 max_retry_delay_ms: DEFAULT_MAX_RETRY_MS,
97 timeout_ms: DEFAULT_TIMEOUT_MS,
98 auto_tune: false,
99 tune_ttl_ms: crate::tune::DEFAULT_TUNE_TTL_MS,
100 compress_level: CompressLevel::Balanced,
101 }
102 }
103}
104
105impl ClientConfig {
106 pub fn request_budget(&self) -> usize {
113 crate::tune::request_budget(
114 self.concurrency,
115 self.upload_window.max(self.download_window),
116 )
117 }
118}
119
120fn opt_u64(obj: &JsValue, key: &str) -> Option<u64> {
122 Reflect::get(obj, &JsValue::from_str(key))
123 .ok()
124 .and_then(|v| v.as_f64())
125 .map(|f| f as u64)
126}
127
128fn opt_usize(obj: &JsValue, key: &str) -> Option<usize> {
130 Reflect::get(obj, &JsValue::from_str(key))
131 .ok()
132 .and_then(|v| v.as_f64())
133 .map(|f| f as usize)
134}
135
136fn opt_u32(obj: &JsValue, key: &str) -> Option<u32> {
138 Reflect::get(obj, &JsValue::from_str(key))
139 .ok()
140 .and_then(|v| v.as_f64())
141 .map(|f| f as u32)
142}
143
144fn opt_bool(obj: &JsValue, key: &str) -> Option<bool> {
146 Reflect::get(obj, &JsValue::from_str(key))
147 .ok()
148 .and_then(|v| v.as_bool())
149}
150
151fn opt_compress_level(obj: &JsValue) -> Option<CompressLevel> {
154 let v = Reflect::get(obj, &JsValue::from_str("compressLevel")).ok()?;
155 if let Some(s) = v.as_string() {
156 return Some(match s.to_ascii_lowercase().as_str() {
157 "auto" => CompressLevel::Auto,
158 "fast" => CompressLevel::Fast,
159 "balanced" => CompressLevel::Balanced,
160 "max" => CompressLevel::Max,
161 _ => CompressLevel::Auto,
162 });
163 }
164 v.as_f64().map(|f| CompressLevel::Fixed(f as i32))
165}
166
167impl ClientConfig {
168 pub fn from_js(opts: &JsValue) -> ClientConfig {
174 let mut cfg = ClientConfig::default();
175 if !opts.is_object() {
176 return cfg;
177 }
178 if let Some(level) = opt_compress_level(opts) {
184 cfg.compress_level = level;
185 } else if let Some(compress) = opt_bool(opts, "compress") {
186 cfg.compress = compress;
187 if compress {
188 cfg.compress_level = CompressLevel::Balanced;
189 }
190 }
191 if let Some(v) = opt_usize(opts, "concurrency") {
192 if v > 0 {
193 cfg.concurrency = v;
194 }
195 }
196 if let Some(v) = opt_usize(opts, "uploadWindow") {
197 if v > 0 {
198 cfg.upload_window = v;
199 }
200 }
201 if let Some(v) = opt_usize(opts, "downloadWindow") {
202 if v > 0 {
203 cfg.download_window = v;
204 }
205 }
206 if let Some(v) = opt_bool(opts, "compress") {
207 cfg.compress = v;
208 }
209 if let Some(v) = opt_u64(opts, "chunkSize") {
210 if v > 0 {
211 cfg.chunk_size = v;
212 }
213 }
214 if let Some(v) = opt_u32(opts, "maxRetries") {
215 cfg.max_retries = v;
216 }
217 if let Some(v) = opt_u32(opts, "baseRetryDelayMs") {
218 cfg.base_retry_delay_ms = v;
219 }
220 if let Some(v) = opt_u32(opts, "maxRetryDelayMs") {
221 cfg.max_retry_delay_ms = v;
222 }
223 if let Some(v) = opt_u32(opts, "timeoutMs") {
224 cfg.timeout_ms = v;
225 }
226 if let Some(v) = opt_bool(opts, "autoTune") {
227 cfg.auto_tune = v;
228 }
229 if let Some(v) = opt_u64(opts, "tuneTtlMs") {
230 cfg.tune_ttl_ms = v;
231 }
232 cfg
233 }
234
235 pub fn backoff_ms(&self, attempt: u32) -> u32 {
240 backoff_ms(attempt, self.base_retry_delay_ms, self.max_retry_delay_ms)
241 }
242}
243
244pub fn backoff_ms(attempt: u32, base: u32, max: u32) -> u32 {
246 if attempt == 0 {
247 return base.min(max);
248 }
249 let shift = attempt.min(31);
250 let doubled = (base as u64) << shift;
251 doubled.min(max as u64) as u32
252}
253
254#[cfg(test)]
255mod tests {
256 use super::*;
257
258 #[test]
259 fn defaults_are_sane() {
260 let cfg = ClientConfig::default();
261 assert_eq!(cfg.concurrency, 4);
262 assert_eq!(cfg.upload_window, DEFAULT_UPLOAD_WINDOW);
263 assert_eq!(cfg.download_window, DEFAULT_DOWNLOAD_WINDOW);
264 assert_eq!(cfg.chunk_size, CHUNK_SIZE);
265 assert_eq!(cfg.max_retries, MAX_RETRIES);
266 assert!(cfg.compress);
267 }
268
269 #[test]
270 fn backoff_grows_exponentially_then_clamps() {
271 let cfg = ClientConfig {
272 base_retry_delay_ms: 100,
273 max_retry_delay_ms: 1000,
274 ..ClientConfig::default()
275 };
276 assert_eq!(cfg.backoff_ms(0), 100);
277 assert_eq!(cfg.backoff_ms(1), 200);
278 assert_eq!(cfg.backoff_ms(2), 400);
279 assert_eq!(cfg.backoff_ms(3), 800);
280 assert_eq!(cfg.backoff_ms(4), 1000); assert_eq!(cfg.backoff_ms(99), 1000);
282 }
283
284 #[test]
285 fn pure_backoff_saturates() {
286 assert_eq!(backoff_ms(0, 500, 30_000), 500);
287 assert_eq!(backoff_ms(6, 500, 30_000), 30_000);
288 }
289
290 #[test]
291 #[cfg(target_arch = "wasm32")]
292 fn parses_js_options() {
293 let obj = js_sys::Object::new();
294 js_sys::Reflect::set(&obj, &JsValue::from_str("concurrency"), &JsValue::from_f64(8.0))
295 .unwrap();
296 js_sys::Reflect::set(
297 &obj,
298 &JsValue::from_str("uploadWindow"),
299 &JsValue::from_f64(16.0),
300 )
301 .unwrap();
302 js_sys::Reflect::set(
303 &obj,
304 &JsValue::from_str("downloadWindow"),
305 &JsValue::from_f64(6.0),
306 )
307 .unwrap();
308 js_sys::Reflect::set(&obj, &JsValue::from_str("compress"), &JsValue::FALSE).unwrap();
309 js_sys::Reflect::set(
310 &obj,
311 &JsValue::from_str("chunkSize"),
312 &JsValue::from_f64(1024.0),
313 )
314 .unwrap();
315 let cfg = ClientConfig::from_js(&obj);
316 assert_eq!(cfg.concurrency, 8);
317 assert_eq!(cfg.upload_window, 16);
318 assert_eq!(cfg.download_window, 6);
319 assert!(!cfg.compress);
320 assert_eq!(cfg.chunk_size, 1024);
321 }
322
323 #[test]
324 #[cfg(target_arch = "wasm32")]
325 fn ignores_non_object_options() {
326 let cfg = ClientConfig::from_js(&JsValue::NULL);
327 assert_eq!(cfg.concurrency, DEFAULT_CONCURRENCY);
328 let cfg = ClientConfig::from_js(&JsValue::UNDEFINED);
329 assert_eq!(cfg.chunk_size, CHUNK_SIZE);
330 }
331
332 #[test]
333 #[cfg(target_arch = "wasm32")]
334 fn parses_compress_level_policy() {
335 let with = |key: &str, value: &JsValue| {
336 let obj = js_sys::Object::new();
337 js_sys::Reflect::set(&obj, &JsValue::from_str(key), value).unwrap();
338 ClientConfig::from_js(&obj)
339 };
340 assert_eq!(
342 with("compressLevel", &JsValue::from_str("max")).compress_level,
343 CompressLevel::Max
344 );
345 assert_eq!(
346 with("compressLevel", &JsValue::from_str("FAST")).compress_level,
347 CompressLevel::Fast
348 );
349 assert_eq!(
350 with("compressLevel", &JsValue::from_str("auto")).compress_level,
351 CompressLevel::Auto
352 );
353 assert_eq!(
355 with("compressLevel", &JsValue::from_f64(3.0)).compress_level,
356 CompressLevel::Fixed(3)
357 );
358 assert_eq!(
361 with("compressLevel", &JsValue::NULL).compress_level,
362 CompressLevel::Balanced
363 );
364 }
365}