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
14pub const DEFAULT_BASE_RETRY_MS: u32 = 500;
16pub const DEFAULT_MAX_RETRY_MS: u32 = 30_000;
18pub const DEFAULT_TIMEOUT_MS: u32 = 60_000;
20
21#[derive(Debug, Clone)]
23pub struct ClientConfig {
24 pub concurrency: usize,
32 pub upload_window: usize,
38 pub download_window: usize,
46 pub download_chunk_size: u64,
53 pub compress: bool,
55 pub chunk_size: u64,
62 pub max_retries: u32,
64 pub base_retry_delay_ms: u32,
66 pub max_retry_delay_ms: u32,
68 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
89fn 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
97fn 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
105fn 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
113fn 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 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 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
183pub 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); 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}