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