libfw-client 0.2.0

WASM engine + JS SDK for libfw browser clients
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
//! Download scheduler over the WebSocket block transport.
//!
//! Download uses the **same** block protocol as upload: the server (sender)
//! pipelines fixed-size blocks without per-block acknowledgments, and the
//! browser (receiver) verifies every block (CRC32 + bounds) in real time,
//! marks bad blocks with `FRAME_NAK` and asks the sender to re-queue them.
//! A wave boundary reconciles: the receiver either completes (`FRAME_COMPLETE`)
//! or re-requests the missing blocks (`FRAME_REQ`).
//!
//! A folder download lists the tree over one short-lived control connection,
//! then transfers each file over its own connection (so `concurrency` files
//! run in parallel, exactly like the old HTTP path). The receiver reorders
//! out-of-order blocks in memory and hands them to the SDK **strictly in
//! order** so the append-mode writable stays correct.

use std::collections::BTreeMap;

use futures::StreamExt;
use libfw_core::compress::{CompressionFormat, decompressor};
use libfw_core::storage::DirEntry;
use libfw_core::ws::*;
use wasm_bindgen::{JsCast, JsValue};

use crate::config::ClientConfig;
use crate::error::LibfwError;
use crate::js::Callbacks;
use crate::plan::{total_bytes, FileEntry};
use crate::state::TaskControl;
use crate::ws::{parse_error, WsConnection, WsPool};

/// A single file download outcome, used for resume-state bookkeeping.
struct DownloadOutcome {
    /// Final absolute offset observed after a successful transfer.
    size: u64,
}

/// Persist download progress roughly every this many bytes so an
/// interrupted transfer can resume from a recent offset instead of
/// restarting from byte 0.
const RESUME_SAVE_EVERY: u64 = 4 * 1024 * 1024;

/// Sleep for `ms` milliseconds on the JS event loop.
async fn sleep_ms(ms: u32) {
    if ms == 0 {
        return;
    }
    let promise = js_sys::Promise::new(&mut |resolve, _reject| {
        let window = web_sys::window().expect("window");
        let f: &js_sys::Function = resolve.unchecked_ref();
        let _ = window.set_timeout_with_callback_and_timeout_and_arguments_0(f, ms as i32);
    });
    let _ = wasm_bindgen_futures::JsFuture::from(promise).await;
}

/// Recursively collect every file under `path` (server-side walk via WS).
///
/// Iterative with an explicit stack so deep directory trees neither blow
/// the compiler recursion limit nor the WASM call stack.
async fn collect_files(conn: &WsConnection, path: &str) -> Result<Vec<FileEntry>, LibfwError> {
    let mut out = Vec::new();
    let mut stack = vec![path.to_string()];
    while let Some(dir) = stack.pop() {
        let entries: Vec<DirEntry> = conn.list_dir(&dir).await?;
        for entry in entries {
            if entry.is_dir {
                stack.push(entry.path);
            } else {
                out.push(FileEntry {
                    path: entry.path,
                    size: entry.size,
                    mtime: entry.mtime,
                });
            }
        }
    }
    out.sort_by(|a, b| a.path.cmp(&b.path));
    Ok(out)
}

/// Decompress one independent zrip frame into raw bytes.
fn decompress_block(data: &[u8]) -> Result<Vec<u8>, LibfwError> {
    let mut dec = decompressor(CompressionFormat::Zrip);
    let mut out = Vec::new();
    dec.decompress(data, &mut out)
        .map_err(|e| LibfwError::Decompress(e.to_string()))?;
    dec.finish(&mut out)
        .map_err(|e| LibfwError::Decompress(e.to_string()))?;
    Ok(out)
}

/// Download one file with resume + retry.
///
/// Each attempt checks a WebSocket connection out of the shared [`WsPool`]
/// (opening one if the pool is empty) and hands it back when the transfer
/// finishes, so a folder transfer reuses connections across files instead of
/// opening/closing one per file. Resume state `{etag, offset}` is validated
/// against the server's authoritative ETag/size in `download_once`: a changed
/// file restarts from byte 0.
async fn download_file(
    base_url: &str,
    token: &str,
    file: &FileEntry,
    callbacks: &Callbacks,
    control: &TaskControl,
    config: &ClientConfig,
    pool: &WsPool,
) -> Result<DownloadOutcome, LibfwError> {
    callbacks.on_file_start(&file.path, file.size)?;

    // 1. Load persisted resume state: { etag, offset }.
    let mut resume: Option<(String, u64)> = None;
    if let Some(state) = callbacks.load_state("download", &file.path).await? {
        let etag = js_sys::Reflect::get(&state, &JsValue::from_str("etag"))
            .ok()
            .and_then(|v| v.as_string())
            .unwrap_or_default();
        let offset = js_sys::Reflect::get(&state, &JsValue::from_str("offset"))
            .ok()
            .and_then(|v| v.as_f64())
            .unwrap_or(0.0) as u64;
        if !etag.is_empty() && offset > 0 {
            resume = Some((etag, offset));
        }
    }

    let mut offset = resume.as_ref().map(|(_, o)| *o).unwrap_or(0);
    let mut etag = resume.as_ref().map(|(e, _)| e.clone()).unwrap_or_default();
    let mut attempts = 0u32;

    loop {
        control.wait_ready().await?;
        control.check()?;

        let conn = match pool
            .checkout(base_url, token, config.timeout_ms, config.ws_url.as_deref())
            .await
        {
            Ok(c) => c,
            Err(e) => {
                if attempts >= config.max_retries {
                    return Err(e);
                }
                attempts += 1;
                callbacks.log(&format!(
                    "reconnecting for `{}` (attempt {attempts}): {e}",
                    file.path
                ));
                sleep_ms(config.backoff_ms(attempts)).await;
                continue;
            }
        };
        let result = download_once(&conn, file, &etag, offset, callbacks, control, config).await;
        match result {
            // Persist the AUTHORITATIVE ETag learned from the server; the
            // connection is still healthy so hand it back to the pool.
            Ok((meta_etag, outcome)) => {
                pool.checkin(conn);
                return finish_download(file, callbacks, meta_etag, outcome).await;
            }
            // The remote file changed / shrank → restart from byte 0. The
            // connection is fine; reuse it.
            Err(e) if is_restart_err(&e) => {
                pool.checkin(conn);
                offset = 0;
                attempts = 0;
                etag = String::new();
                continue;
            }
            Err(e) => {
                // Network/protocol error: the connection may be in a broken
                // state, so drop it rather than reuse it.
                drop(conn);
                if attempts >= config.max_retries {
                    return Err(e);
                }
                attempts += 1;
                callbacks.log(&format!(
                    "retrying `{}` (attempt {attempts}): {e}",
                    file.path
                ));
                sleep_ms(config.backoff_ms(attempts)).await;
            }
        }
    }
}

/// A download error that means "the remote file changed / shrank — restart
/// from byte 0".
fn is_restart_err(e: &LibfwError) -> bool {
    matches!(e, LibfwError::Http { status: 416 | 200, .. })
}

/// One download attempt over an open connection.
///
/// Sends `FRAME_START` (download), awaits `FRAME_READY` (authoritative
/// size/etag), validates the resume offset, then runs the receiver role.
/// Returns `(etag, outcome)` where `etag` is the server's authoritative one.
async fn download_once(
    conn: &WsConnection,
    file: &FileEntry,
    resume_etag: &str,
    offset: u64,
    callbacks: &Callbacks,
    control: &TaskControl,
    config: &ClientConfig,
) -> Result<(String, DownloadOutcome), LibfwError> {
    let start = StartRequest {
        kind: TransferKind::Download,
        path: file.path.clone(),
        size: 0,
        mtime: 0,
        etag: String::new(),
        compress: config.compress,
        mode: String::new(),
        offset,
        block_size: config.download_chunk_size,
        window: config.download_window.max(1) as u32,
    };
    conn.send(&control_frame(FRAME_START, &start))?;

    let ready = loop {
        let frame = conn.next().await?;
        match frame_type(&frame) {
            Some(FRAME_READY) => {
                break parse_control::<ReadyReply>(&frame, FRAME_READY)
                    .ok_or_else(|| LibfwError::Protocol("bad READY frame".into()))?;
            }
            Some(FRAME_ERROR) => {
                return Err(parse_error(&frame)
                    .unwrap_or_else(|| LibfwError::Protocol("download start failed".into())));
            }
            _ => {}
        }
    };

    // Revalidate the resume offset against the server (source of truth): a
    // changed file (ETag mismatch) or a shrunk file restarts from byte 0.
    if (!resume_etag.is_empty() && resume_etag != ready.etag) || offset > ready.size {
        return Err(LibfwError::Http {
            status: 416,
            url: file.path.clone(),
        });
    }

    if control.total_bytes() == 0 {
        control.set_total(ready.size.max(file.size));
    }
    if ready.offset > 0 {
        // Seed progress with the contiguous bytes already on disk so a
        // resume reflects the true fraction.
        control.add_progress(ready.offset);
        control.report_progress_if(callbacks)?;
    }

    let outcome = receive_download(conn, file, callbacks, control, &ready).await?;
    Ok((ready.etag.clone(), outcome))
}

/// The download receiver role: verify every block, reorder in memory, hand
/// bytes to the SDK in order, and drive retransmission of bad/missing blocks.
async fn receive_download(
    conn: &WsConnection,
    file: &FileEntry,
    callbacks: &Callbacks,
    control: &TaskControl,
    ready: &ReadyReply,
) -> Result<DownloadOutcome, LibfwError> {
    let start_off = ready.offset;
    let block_size = ready.block_size.max(1);
    let total_blocks = ready.total_blocks;
    let compress = ready.compress;

    let mut verified = BlockSet::new(total_blocks);
    // Reorder buffer keyed by block index → we can accept out-of-order blocks.
    let mut buffer: BTreeMap<u32, Vec<u8>> = BTreeMap::new();
    let mut contiguous_index: u32 = 0;
    let mut last_saved = 0u64;

    loop {
        control.wait_ready().await?;
        control.check()?;
        let frame = conn.next().await?;
        match frame_type(&frame) {
            Some(FRAME_BLOCK) => {
                let Some(block) = parse_block(&frame) else {
                    continue;
                };
                if block.index >= total_blocks || verified.contains(block.index) {
                    continue; // out of range or duplicate → idempotent no-op
                }
                // Real-time verification: CRC + length + bounds.
                let crc_ok = crc32(&block.data) == block.crc;
                let raw: Vec<u8> = if compress {
                    match decompress_block(&block.data) {
                        Ok(d) => d,
                        Err(_) => {
                            // Mark bad → sender re-adds to its queue.
                            conn.send(&nak_frame(block.index))?;
                            continue;
                        }
                    }
                } else {
                    block.data
                };
                let len_ok = !compress || raw.len() as u32 == block.raw_len;
                let abs = block_offset(block.index, block_size, start_off);
                let in_bounds = abs.saturating_add(raw.len() as u64) <= ready.size;
                if !(crc_ok && len_ok && in_bounds) {
                    conn.send(&nak_frame(block.index))?;
                    continue;
                }
                verified.insert(block.index);
                buffer.insert(block.index, raw);

                // Emit strictly in order (append-safe for the SDK writable).
                while let Some(data) = buffer.remove(&contiguous_index) {
                    let abs = block_offset(contiguous_index, block_size, start_off);
                    callbacks.on_write_chunk(&file.path, abs, &data).await?;
                    control.add_progress(data.len() as u64);
                    control.report_progress_if(callbacks)?;
                    contiguous_index += 1;
                    let abs_done = block_offset(contiguous_index, block_size, start_off);
                    if abs_done >= last_saved.saturating_add(RESUME_SAVE_EVERY) {
                        last_saved = abs_done;
                        let _ = callbacks
                            .save_state(
                                "download",
                                &file.path,
                                &resume_state_obj(&ready.etag, abs_done),
                            )
                            .await;
                    }
                }

                if verified.count() == total_blocks {
                    // All verified → flush any remaining (out-of-order) tail,
                    // then complete. The final size is the server's
                    // authoritative `ready.size` — NOT the flush return value,
                    // which is 0 when every block already arrived in order.
                    flush_remaining(
                        &mut buffer,
                        &mut contiguous_index,
                        file,
                        callbacks,
                        control,
                        block_size,
                        start_off,
                    )
                    .await?;
                    let final_size = ready.size;
                    conn.send(&control_frame(
                        FRAME_COMPLETE,
                        &CompleteMessage::ok(final_size),
                    ))?;
                    return Ok(DownloadOutcome { size: final_size });
                }
            }
            Some(FRAME_WAVE_DONE) => {
                // Reconciliation: everything verified → complete, else ask
                // the sender to re-send the missing blocks (its queue).
                let missing = verified.missing();
                if missing.is_empty() {
                    flush_remaining(
                        &mut buffer,
                        &mut contiguous_index,
                        file,
                        callbacks,
                        control,
                        block_size,
                        start_off,
                    )
                    .await?;
                    let final_size = ready.size;
                    conn.send(&control_frame(
                        FRAME_COMPLETE,
                        &CompleteMessage::ok(final_size),
                    ))?;
                    return Ok(DownloadOutcome { size: final_size });
                }
                conn.send(&req_frame(&missing))?;
            }
            Some(FRAME_COMPLETE) => {
                // The server (sender) never completes a download; if it does
                // something went wrong.
                return Err(LibfwError::Protocol(
                    "server ended the download stream unexpectedly".into(),
                ));
            }
            Some(FRAME_ERROR) => {
                return Err(parse_error(&frame)
                    .unwrap_or_else(|| LibfwError::Protocol("download error".into())));
            }
            _ => {}
        }
    }
}

/// Write every buffered (verified) block in ascending order — the tail after
/// the contiguous prefix — returning the final absolute size.
async fn flush_remaining(
    buffer: &mut BTreeMap<u32, Vec<u8>>,
    contiguous_index: &mut u32,
    file: &FileEntry,
    callbacks: &Callbacks,
    control: &TaskControl,
    block_size: u64,
    start_off: u64,
) -> Result<u64, LibfwError> {
    let mut last_abs = 0u64;
    let tail: Vec<(u32, Vec<u8>)> = std::mem::take(buffer).into_iter().collect();
    for (index, data) in tail {
        let abs = block_offset(index, block_size, start_off);
        callbacks.on_write_chunk(&file.path, abs, &data).await?;
        control.add_progress(data.len() as u64);
        control.report_progress_if(callbacks)?;
        last_abs = abs.saturating_add(data.len() as u64);
        *contiguous_index = index.saturating_add(1);
    }
    Ok(last_abs)
}

/// Build a resume-state object for JS persistence.
fn resume_state_obj(etag: &str, offset: u64) -> JsValue {
    let state = js_sys::Object::new();
    let _ = js_sys::Reflect::set(&state, &JsValue::from_str("etag"), &JsValue::from_str(etag));
    let _ = js_sys::Reflect::set(
        &state,
        &JsValue::from_str("offset"),
        &JsValue::from_f64(offset as f64),
    );
    let _ = js_sys::Reflect::set(
        &state,
        &JsValue::from_str("size"),
        &JsValue::from_f64(offset as f64),
    );
    state.into()
}

/// Persist resume state (via the JS IndexedDB layer) and notify JS.
async fn finish_download(
    file: &FileEntry,
    callbacks: &Callbacks,
    etag: String,
    outcome: DownloadOutcome,
) -> Result<DownloadOutcome, LibfwError> {
    // Persist the ABSOLUTE end offset (`size`), not a per-request delta, so
    // a later resume re-requests the correct byte range.
    let offset = outcome.size;
    let size = outcome.size;
    let state = js_sys::Object::new();
    js_sys::Reflect::set(
        &state,
        &JsValue::from_str("etag"),
        &JsValue::from_str(&etag),
    )
    .map_err(|e| LibfwError::Js(format!("state etag: {e:?}")))?;
    js_sys::Reflect::set(
        &state,
        &JsValue::from_str("offset"),
        &JsValue::from_f64(offset as f64),
    )
    .map_err(|e| LibfwError::Js(format!("state offset: {e:?}")))?;
    js_sys::Reflect::set(
        &state,
        &JsValue::from_str("size"),
        &JsValue::from_f64(size as f64),
    )
    .map_err(|e| LibfwError::Js(format!("state size: {e:?}")))?;
    callbacks.save_state("download", &file.path, &state).await?;
    callbacks.on_file_completed(&file.path).await?;
    Ok(outcome)
}

/// Download an entire folder (or the root when `path` is empty).
pub async fn download_folder(
    base_url: &str,
    token: &str,
    path: &str,
    callbacks: &Callbacks,
    control: &TaskControl,
    config: &ClientConfig,
) -> Result<u64, LibfwError> {
    // One pool shared by the listing connection and every file transfer, so
    // the whole folder reuses a handful of WebSocket connections instead of
    // opening/closing one per file.
    let pool = WsPool::new();

    // 1. List the tree (reusing a pooled connection).
    let listing_conn = match pool
        .checkout(base_url, token, config.timeout_ms, config.ws_url.as_deref())
        .await
    {
        Ok(c) => c,
        Err(e) => return Err(e),
    };
    let files = match collect_files(&listing_conn, path).await {
        Ok(files) => files,
        Err(e) => {
            drop(listing_conn);
            return Err(e);
        }
    };
    pool.checkin(listing_conn);

    let total = total_bytes(&files);
    control.set_total(total);
    callbacks.on_progress(0, total)?;

    // 2. Transfer `concurrency` files at a time, each checking a connection
    //    out of the shared pool and handing it back when the file finishes.
    let mut stream = futures::stream::iter(files.into_iter().map(|file| {
        let base_url = base_url.to_string();
        let token = token.to_string();
        let callbacks = callbacks.clone();
        let control = control.clone();
        let config = config.clone();
        let pool = pool.clone();
        async move {
            download_file(
                &base_url, &token, &file, &callbacks, &control, &config, &pool,
            )
            .await
        }
    }))
    .buffer_unordered(config.concurrency);

    while let Some(result) = stream.next().await {
        result?;
        // Report progress from the shared control block so pause/resume and
        // the onProgress events stay consistent (one source of truth).
        callbacks.on_progress(control.done_bytes(), control.total_bytes())?;
    }
    Ok(control.done_bytes())
}

/// Download a single file at `path` (size/etag discovered from the server).
pub async fn download_single(
    base_url: &str,
    token: &str,
    path: &str,
    callbacks: &Callbacks,
    control: &TaskControl,
    config: &ClientConfig,
) -> Result<u64, LibfwError> {
    // A small shared pool (the metadata probe and the transfer may reuse the
    // same connection).
    let pool = WsPool::new();

    // Discover the authoritative size/etag over WS (server is the source of
    // truth) so `on_file_start` reports a real size.
    let meta_conn = match pool
        .checkout(base_url, token, config.timeout_ms, config.ws_url.as_deref())
        .await
    {
        Ok(c) => c,
        Err(e) => return Err(e),
    };
    let (_, size) = match meta_conn.file_meta(path).await {
        Ok(meta) => meta,
        Err(e) => {
            drop(meta_conn);
            return Err(e);
        }
    };
    pool.checkin(meta_conn);

    let file = FileEntry {
        path: path.to_string(),
        size,
        mtime: 0,
    };
    let outcome =
        download_file(base_url, token, &file, callbacks, control, config, &pool).await?;
    // Return the ABSOLUTE byte count (the final offset), consistent with
    // `download_folder`.
    Ok(outcome.size)
}