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