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,
26 pub upload_window: usize,
32 pub download_window: usize,
40 pub download_chunk_size: u64,
47 pub compress: bool,
49 pub chunk_size: u64,
51 pub max_retries: u32,
53 pub base_retry_delay_ms: u32,
55 pub max_retry_delay_ms: u32,
57 pub timeout_ms: u32,
59 pub ws_url: Option<String>,
64}
65
66impl Default for ClientConfig {
67 fn default() -> Self {
68 ClientConfig {
69 concurrency: DEFAULT_CONCURRENCY,
70 upload_window: DEFAULT_UPLOAD_WINDOW,
71 download_window: DEFAULT_DOWNLOAD_WINDOW,
72 download_chunk_size: DEFAULT_DOWNLOAD_CHUNK_SIZE,
73 compress: true,
74 chunk_size: CHUNK_SIZE,
75 max_retries: MAX_RETRIES,
76 base_retry_delay_ms: DEFAULT_BASE_RETRY_MS,
77 max_retry_delay_ms: DEFAULT_MAX_RETRY_MS,
78 timeout_ms: DEFAULT_TIMEOUT_MS,
79 ws_url: None,
80 }
81 }
82}
83
84fn opt_u64(obj: &JsValue, key: &str) -> Option<u64> {
86 Reflect::get(obj, &JsValue::from_str(key))
87 .ok()
88 .and_then(|v| v.as_f64())
89 .map(|f| f as u64)
90}
91
92fn opt_usize(obj: &JsValue, key: &str) -> Option<usize> {
94 Reflect::get(obj, &JsValue::from_str(key))
95 .ok()
96 .and_then(|v| v.as_f64())
97 .map(|f| f as usize)
98}
99
100fn opt_u32(obj: &JsValue, key: &str) -> Option<u32> {
102 Reflect::get(obj, &JsValue::from_str(key))
103 .ok()
104 .and_then(|v| v.as_f64())
105 .map(|f| f as u32)
106}
107
108fn opt_bool(obj: &JsValue, key: &str) -> Option<bool> {
110 Reflect::get(obj, &JsValue::from_str(key))
111 .ok()
112 .and_then(|v| v.as_bool())
113}
114
115impl ClientConfig {
116 pub fn from_js(opts: &JsValue) -> ClientConfig {
122 let mut cfg = ClientConfig::default();
123 if !opts.is_object() {
124 return cfg;
125 }
126 if let Some(v) = opt_usize(opts, "concurrency") {
127 if v > 0 {
128 cfg.concurrency = v;
129 }
130 }
131 if let Some(v) = opt_usize(opts, "uploadWindow") {
132 if v > 0 {
133 cfg.upload_window = v;
134 }
135 }
136 if let Some(v) = opt_usize(opts, "downloadWindow") {
137 if v > 0 {
138 cfg.download_window = v;
139 }
140 }
141 if let Some(v) = opt_u64(opts, "downloadChunkSize") {
142 if v > 0 {
143 cfg.download_chunk_size = v;
144 }
145 }
146 if let Some(v) = opt_bool(opts, "compress") {
147 cfg.compress = v;
148 }
149 if let Some(v) = opt_u64(opts, "chunkSize") {
150 if v > 0 {
151 cfg.chunk_size = v;
152 }
153 }
154 if let Some(v) = opt_u32(opts, "maxRetries") {
155 cfg.max_retries = v;
156 }
157 if let Some(v) = opt_u32(opts, "baseRetryDelayMs") {
158 cfg.base_retry_delay_ms = v;
159 }
160 if let Some(v) = opt_u32(opts, "maxRetryDelayMs") {
161 cfg.max_retry_delay_ms = v;
162 }
163 if let Some(v) = opt_u32(opts, "timeoutMs") {
164 cfg.timeout_ms = v;
165 }
166 if let Some(v) = Reflect::get(opts, &JsValue::from_str("wsUrl"))
167 .ok()
168 .and_then(|v| v.as_string())
169 {
170 if !v.is_empty() {
171 cfg.ws_url = Some(v);
172 }
173 }
174 cfg
175 }
176
177 pub fn backoff_ms(&self, attempt: u32) -> u32 {
182 backoff_ms(attempt, self.base_retry_delay_ms, self.max_retry_delay_ms)
183 }
184}
185
186pub fn backoff_ms(attempt: u32, base: u32, max: u32) -> u32 {
188 if attempt == 0 {
189 return base.min(max);
190 }
191 let shift = attempt.min(31);
192 let doubled = (base as u64) << shift;
193 doubled.min(max as u64) as u32
194}
195
196#[cfg(test)]
197mod tests {
198 use super::*;
199
200 #[test]
201 fn defaults_are_sane() {
202 let cfg = ClientConfig::default();
203 assert_eq!(cfg.concurrency, 4);
204 assert_eq!(cfg.upload_window, DEFAULT_UPLOAD_WINDOW);
205 assert_eq!(cfg.download_window, DEFAULT_DOWNLOAD_WINDOW);
206 assert_eq!(cfg.download_chunk_size, DEFAULT_DOWNLOAD_CHUNK_SIZE);
207 assert_eq!(cfg.chunk_size, CHUNK_SIZE);
208 assert_eq!(cfg.max_retries, MAX_RETRIES);
209 assert!(cfg.compress);
210 }
211
212 #[test]
213 fn backoff_grows_exponentially_then_clamps() {
214 let cfg = ClientConfig {
215 base_retry_delay_ms: 100,
216 max_retry_delay_ms: 1000,
217 ..ClientConfig::default()
218 };
219 assert_eq!(cfg.backoff_ms(0), 100);
220 assert_eq!(cfg.backoff_ms(1), 200);
221 assert_eq!(cfg.backoff_ms(2), 400);
222 assert_eq!(cfg.backoff_ms(3), 800);
223 assert_eq!(cfg.backoff_ms(4), 1000); assert_eq!(cfg.backoff_ms(99), 1000);
225 }
226
227 #[test]
228 fn pure_backoff_saturates() {
229 assert_eq!(backoff_ms(0, 500, 30_000), 500);
230 assert_eq!(backoff_ms(6, 500, 30_000), 30_000);
231 }
232
233 #[test]
234 #[cfg(target_arch = "wasm32")]
235 fn parses_js_options() {
236 let obj = js_sys::Object::new();
237 js_sys::Reflect::set(&obj, &JsValue::from_str("concurrency"), &JsValue::from_f64(8.0))
238 .unwrap();
239 js_sys::Reflect::set(
240 &obj,
241 &JsValue::from_str("uploadWindow"),
242 &JsValue::from_f64(16.0),
243 )
244 .unwrap();
245 js_sys::Reflect::set(
246 &obj,
247 &JsValue::from_str("downloadWindow"),
248 &JsValue::from_f64(6.0),
249 )
250 .unwrap();
251 js_sys::Reflect::set(
252 &obj,
253 &JsValue::from_str("downloadChunkSize"),
254 &JsValue::from_f64(131072.0),
255 )
256 .unwrap();
257 js_sys::Reflect::set(&obj, &JsValue::from_str("compress"), &JsValue::FALSE).unwrap();
258 js_sys::Reflect::set(
259 &obj,
260 &JsValue::from_str("chunkSize"),
261 &JsValue::from_f64(1024.0),
262 )
263 .unwrap();
264 let cfg = ClientConfig::from_js(&obj);
265 assert_eq!(cfg.concurrency, 8);
266 assert_eq!(cfg.upload_window, 16);
267 assert_eq!(cfg.download_window, 6);
268 assert_eq!(cfg.download_chunk_size, 131072);
269 assert!(!cfg.compress);
270 assert_eq!(cfg.chunk_size, 1024);
271 }
272
273 #[test]
274 #[cfg(target_arch = "wasm32")]
275 fn ignores_non_object_options() {
276 let cfg = ClientConfig::from_js(&JsValue::NULL);
277 assert_eq!(cfg.concurrency, DEFAULT_CONCURRENCY);
278 let cfg = ClientConfig::from_js(&JsValue::UNDEFINED);
279 assert_eq!(cfg.chunk_size, CHUNK_SIZE);
280 }
281}