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, DEFAULT_TUNE_TTL_MS};
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,
67 pub tune_ttl_ms: u64,
69 pub compress_level: CompressLevel,
76}
77
78impl Default for ClientConfig {
79 fn default() -> Self {
80 ClientConfig {
81 concurrency: DEFAULT_CONCURRENCY,
82 upload_window: DEFAULT_UPLOAD_WINDOW,
83 download_window: DEFAULT_DOWNLOAD_WINDOW,
84 compress: true,
85 chunk_size: CHUNK_SIZE,
86 max_retries: MAX_RETRIES,
87 base_retry_delay_ms: DEFAULT_BASE_RETRY_MS,
88 max_retry_delay_ms: DEFAULT_MAX_RETRY_MS,
89 timeout_ms: DEFAULT_TIMEOUT_MS,
90 auto_tune: false,
91 tune_ttl_ms: DEFAULT_TUNE_TTL_MS,
92 compress_level: CompressLevel::Balanced,
93 }
94 }
95}
96
97fn opt_u64(obj: &JsValue, key: &str) -> Option<u64> {
99 Reflect::get(obj, &JsValue::from_str(key))
100 .ok()
101 .and_then(|v| v.as_f64())
102 .map(|f| f as u64)
103}
104
105fn opt_usize(obj: &JsValue, key: &str) -> Option<usize> {
107 Reflect::get(obj, &JsValue::from_str(key))
108 .ok()
109 .and_then(|v| v.as_f64())
110 .map(|f| f as usize)
111}
112
113fn opt_u32(obj: &JsValue, key: &str) -> Option<u32> {
115 Reflect::get(obj, &JsValue::from_str(key))
116 .ok()
117 .and_then(|v| v.as_f64())
118 .map(|f| f as u32)
119}
120
121fn opt_bool(obj: &JsValue, key: &str) -> Option<bool> {
123 Reflect::get(obj, &JsValue::from_str(key))
124 .ok()
125 .and_then(|v| v.as_bool())
126}
127
128fn opt_compress_level(obj: &JsValue) -> Option<CompressLevel> {
131 let v = Reflect::get(obj, &JsValue::from_str("compressLevel")).ok()?;
132 if let Some(s) = v.as_string() {
133 return Some(match s.to_ascii_lowercase().as_str() {
134 "auto" => CompressLevel::Auto,
135 "fast" => CompressLevel::Fast,
136 "balanced" => CompressLevel::Balanced,
137 "max" => CompressLevel::Max,
138 _ => CompressLevel::Auto,
139 });
140 }
141 v.as_f64().map(|f| CompressLevel::Fixed(f as i32))
142}
143
144impl ClientConfig {
145 pub fn from_js(opts: &JsValue) -> ClientConfig {
151 let mut cfg = ClientConfig::default();
152 if !opts.is_object() {
153 return cfg;
154 }
155 if let Some(level) = opt_compress_level(opts) {
160 cfg.compress_level = level;
161 } else if let Some(compress) = opt_bool(opts, "compress") {
162 cfg.compress = compress;
163 if compress {
164 cfg.compress_level = CompressLevel::Balanced;
165 }
166 }
167 if let Some(v) = opt_usize(opts, "concurrency") {
168 if v > 0 {
169 cfg.concurrency = v;
170 }
171 }
172 if let Some(v) = opt_usize(opts, "uploadWindow") {
173 if v > 0 {
174 cfg.upload_window = v;
175 }
176 }
177 if let Some(v) = opt_usize(opts, "downloadWindow") {
178 if v > 0 {
179 cfg.download_window = v;
180 }
181 }
182 if let Some(v) = opt_bool(opts, "compress") {
183 cfg.compress = v;
184 }
185 if let Some(v) = opt_u64(opts, "chunkSize") {
186 if v > 0 {
187 cfg.chunk_size = v;
188 }
189 }
190 if let Some(v) = opt_u32(opts, "maxRetries") {
191 cfg.max_retries = v;
192 }
193 if let Some(v) = opt_u32(opts, "baseRetryDelayMs") {
194 cfg.base_retry_delay_ms = v;
195 }
196 if let Some(v) = opt_u32(opts, "maxRetryDelayMs") {
197 cfg.max_retry_delay_ms = v;
198 }
199 if let Some(v) = opt_u32(opts, "timeoutMs") {
200 cfg.timeout_ms = v;
201 }
202 if let Some(v) = opt_bool(opts, "autoTune") {
203 cfg.auto_tune = v;
204 }
205 if let Some(v) = opt_u64(opts, "tuneTtlMs").filter(|&v| v > 0) {
206 cfg.tune_ttl_ms = v;
207 }
208 cfg
209 }
210
211 pub fn backoff_ms(&self, attempt: u32) -> u32 {
216 backoff_ms(attempt, self.base_retry_delay_ms, self.max_retry_delay_ms)
217 }
218}
219
220pub fn backoff_ms(attempt: u32, base: u32, max: u32) -> u32 {
222 if attempt == 0 {
223 return base.min(max);
224 }
225 let shift = attempt.min(31);
226 let doubled = (base as u64) << shift;
227 doubled.min(max as u64) as u32
228}
229
230#[cfg(test)]
231mod tests {
232 use super::*;
233
234 #[test]
235 fn defaults_are_sane() {
236 let cfg = ClientConfig::default();
237 assert_eq!(cfg.concurrency, 4);
238 assert_eq!(cfg.upload_window, DEFAULT_UPLOAD_WINDOW);
239 assert_eq!(cfg.download_window, DEFAULT_DOWNLOAD_WINDOW);
240 assert_eq!(cfg.chunk_size, CHUNK_SIZE);
241 assert_eq!(cfg.max_retries, MAX_RETRIES);
242 assert!(cfg.compress);
243 }
244
245 #[test]
246 fn backoff_grows_exponentially_then_clamps() {
247 let cfg = ClientConfig {
248 base_retry_delay_ms: 100,
249 max_retry_delay_ms: 1000,
250 ..ClientConfig::default()
251 };
252 assert_eq!(cfg.backoff_ms(0), 100);
253 assert_eq!(cfg.backoff_ms(1), 200);
254 assert_eq!(cfg.backoff_ms(2), 400);
255 assert_eq!(cfg.backoff_ms(3), 800);
256 assert_eq!(cfg.backoff_ms(4), 1000); assert_eq!(cfg.backoff_ms(99), 1000);
258 }
259
260 #[test]
261 fn pure_backoff_saturates() {
262 assert_eq!(backoff_ms(0, 500, 30_000), 500);
263 assert_eq!(backoff_ms(6, 500, 30_000), 30_000);
264 }
265
266 #[test]
267 #[cfg(target_arch = "wasm32")]
268 fn parses_js_options() {
269 let obj = js_sys::Object::new();
270 js_sys::Reflect::set(&obj, &JsValue::from_str("concurrency"), &JsValue::from_f64(8.0))
271 .unwrap();
272 js_sys::Reflect::set(
273 &obj,
274 &JsValue::from_str("uploadWindow"),
275 &JsValue::from_f64(16.0),
276 )
277 .unwrap();
278 js_sys::Reflect::set(
279 &obj,
280 &JsValue::from_str("downloadWindow"),
281 &JsValue::from_f64(6.0),
282 )
283 .unwrap();
284 js_sys::Reflect::set(&obj, &JsValue::from_str("compress"), &JsValue::FALSE).unwrap();
285 js_sys::Reflect::set(
286 &obj,
287 &JsValue::from_str("chunkSize"),
288 &JsValue::from_f64(1024.0),
289 )
290 .unwrap();
291 let cfg = ClientConfig::from_js(&obj);
292 assert_eq!(cfg.concurrency, 8);
293 assert_eq!(cfg.upload_window, 16);
294 assert_eq!(cfg.download_window, 6);
295 assert!(!cfg.compress);
296 assert_eq!(cfg.chunk_size, 1024);
297 }
298
299 #[test]
300 #[cfg(target_arch = "wasm32")]
301 fn ignores_non_object_options() {
302 let cfg = ClientConfig::from_js(&JsValue::NULL);
303 assert_eq!(cfg.concurrency, DEFAULT_CONCURRENCY);
304 let cfg = ClientConfig::from_js(&JsValue::UNDEFINED);
305 assert_eq!(cfg.chunk_size, CHUNK_SIZE);
306 }
307}