libfw-client 0.2.1 → 0.2.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/index.js CHANGED
@@ -371,7 +371,7 @@ export class LibfwClient {
371
371
  onWriteChunk: (path, offset, data) => this._onWriteChunk(path, offset, data),
372
372
  onFileCompleted: (path) => this._onFileCompleted(path),
373
373
  onProgress: (done, total) => this._emit({ type: 'progress', done, total }),
374
- loadState: (direction, path) => Idb.loadState(`${direction}:${path}`),
374
+ loadState: (direction, path) => this._loadResumeState(direction, path),
375
375
  saveState: (direction, path, state) => {
376
376
  // The in-memory browser-download fallback never commits bytes to
377
377
  // disk, so a persisted download offset would be a phantom that
@@ -521,7 +521,7 @@ export class LibfwClient {
521
521
  for (const p of order) {
522
522
  entries.push({
523
523
  name: this._safeEntryName(p),
524
- data: this._concatBuffers(buffers.get(p) || []),
524
+ data: this._concatBuffers(buffers.get(p)?.chunks || []),
525
525
  });
526
526
  }
527
527
  // Include files that were announced but produced no bytes (empty).
@@ -532,7 +532,7 @@ export class LibfwClient {
532
532
  }
533
533
  this._triggerBrowserDownload(createZip(entries), this._archiveName(path));
534
534
  } else {
535
- const data = this._concatBuffers(buffers.get(path) || []);
535
+ const data = this._concatBuffers(buffers.get(path)?.chunks || []);
536
536
  this._triggerBrowserDownload(new Blob([data], { type: 'application/octet-stream' }), this._downloadName(path));
537
537
  }
538
538
  return total;
@@ -663,16 +663,39 @@ export class LibfwClient {
663
663
  if (this._fallback) {
664
664
  // Browser-download fallback: collect chunks in memory instead of
665
665
  // streaming to disk. Chunks arrive in order, so a plain append works.
666
- let bufs = this._fallback.buffers.get(path);
667
- if (!bufs) {
668
- bufs = [];
669
- this._fallback.buffers.set(path, bufs);
666
+ let buf = this._fallback.buffers.get(path);
667
+ if (!buf) {
668
+ buf = { chunks: [], len: 0 };
669
+ this._fallback.buffers.set(path, buf);
670
670
  this._fallback.order.push(path);
671
671
  }
672
- bufs.push(data);
672
+ // A chunk whose absolute offset is at or before the last one delivered
673
+ // means the engine restarted this file (an internal retry re-delivers
674
+ // the same byte range) — drop the partial buffer so the prefix is not
675
+ // duplicated in the produced blob/zip.
676
+ if (offset <= buf.len) {
677
+ buf.chunks = [];
678
+ buf.len = 0;
679
+ }
680
+ buf.chunks.push(data);
681
+ buf.len += data.length;
673
682
  return;
674
683
  }
675
684
  let entry = this._writables.get(path);
685
+ if (entry && offset <= entry.lastOffset) {
686
+ // The engine restarted this file (a network/protocol error triggered
687
+ // an internal retry that re-delivers the same byte range). Abort the
688
+ // still-open writable — discarding its uncommitted swap file — and
689
+ // reopen fresh, otherwise the re-delivered prefix would be appended a
690
+ // second time and the committed file silently corrupted.
691
+ this._writables.delete(path);
692
+ try {
693
+ await entry.writable.abort();
694
+ } catch {
695
+ /* best-effort discard */
696
+ }
697
+ entry = undefined;
698
+ }
676
699
  if (!entry) {
677
700
  const { dir, name, handle } = await this._ensureFileHandle(path);
678
701
  this._fileHandles.set(path, handle);
@@ -687,9 +710,10 @@ export class LibfwClient {
687
710
  const writable = await handle.createWritable(
688
711
  isResume ? { keepExistingData: true } : undefined
689
712
  );
690
- entry = { writable, dir, name };
713
+ entry = { writable, dir, name, lastOffset: offset };
691
714
  this._writables.set(path, entry);
692
715
  }
716
+ entry.lastOffset = offset;
693
717
  await entry.writable.write(data);
694
718
  }
695
719
 
@@ -765,6 +789,66 @@ export class LibfwClient {
765
789
  }
766
790
  }
767
791
 
792
+ /**
793
+ * Resolve a file handle for a virtual path WITHOUT creating it, so a
794
+ * missing file returns `null` instead of throwing.
795
+ * @param {string} path
796
+ * @returns {Promise<FileSystemFileHandle|null>}
797
+ * @private
798
+ */
799
+ async _resolveFileHandle(path) {
800
+ if (!this._dirHandle) return null;
801
+ try {
802
+ const segments = splitPath(path);
803
+ if (segments.length === 0) return null;
804
+ let dir = this._dirHandle;
805
+ for (let i = 0; i < segments.length - 1; i += 1) {
806
+ dir = await dir.getDirectoryHandle(segments[i], { create: false });
807
+ }
808
+ return await dir.getFileHandle(segments[segments.length - 1], { create: false });
809
+ } catch {
810
+ return null; // missing dir or file, or no FS API
811
+ }
812
+ }
813
+
814
+ /**
815
+ * Load a persisted resume-state `{etag, offset}`, clamping the download
816
+ * `offset` down to the ACTUAL number of bytes committed on disk.
817
+ *
818
+ * `createWritable()` only commits to the real file on `close()`, so a
819
+ * download interrupted by a hard page kill leaves the persisted offset
820
+ * AHEAD of the bytes actually on disk. Resuming from that stale offset
821
+ * with `keepExistingData` would write past a prefix that isn't there,
822
+ * leaving a silent gap in the file. Clamping to the real on-disk length
823
+ * makes the resume pick up exactly where the file really ends.
824
+ * @param {string} direction
825
+ * @param {string} path
826
+ * @returns {Promise<object|null>}
827
+ * @private
828
+ */
829
+ async _loadResumeState(direction, path) {
830
+ const state = await Idb.loadState(`${direction}:${path}`);
831
+ if (!state) return null;
832
+ // Only downloads that will append onto an on-disk file need the clamp;
833
+ // uploads drive resume from the server's own received ranges.
834
+ if (direction !== 'download') return state;
835
+ const offset = Number(state.offset) || 0;
836
+ if (offset <= 0) return state;
837
+ const handle = await this._resolveFileHandle(path);
838
+ if (!handle) return state; // can't know the on-disk length → trust state
839
+ let diskLen = 0;
840
+ try {
841
+ diskLen = (await handle.getFile()).size;
842
+ } catch {
843
+ return state;
844
+ }
845
+ // Clamp to the bytes actually on disk. If nothing was committed (hard
846
+ // kill before any close), resume from 0 so the whole file is fetched
847
+ // again rather than leaving a gap.
848
+ const clamped = Math.min(offset, diskLen);
849
+ return { ...state, offset: clamped, size: clamped };
850
+ }
851
+
768
852
  /**
769
853
  * Close all still-open writable streams (flush to disk). Called on
770
854
  * success, failure or cancellation of a transfer.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "libfw-client",
3
- "version": "0.2.1",
3
+ "version": "0.2.3",
4
4
  "description": "High-performance streaming file & folder transfer SDK for the browser (libfw WASM engine + File System Access API + IndexedDB resume).",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -231,6 +231,10 @@ function __wbg_get_imports() {
231
231
  const ret = arg0.apply(arg1, arg2);
232
232
  return ret;
233
233
  }, arguments); },
234
+ __wbg_bufferedAmount_2f95480ad75d4988: function(arg0) {
235
+ const ret = arg0.bufferedAmount;
236
+ return ret;
237
+ },
234
238
  __wbg_call_a6e5c5dce5018821: function() { return handleError(function (arg0, arg1, arg2) {
235
239
  const ret = arg0.call(arg1, arg2);
236
240
  return ret;
@@ -360,6 +364,10 @@ function __wbg_get_imports() {
360
364
  state0.a = 0;
361
365
  }
362
366
  },
367
+ __wbg_now_86c0d4ba3fa605b8: function() {
368
+ const ret = Date.now();
369
+ return ret;
370
+ },
363
371
  __wbg_of_5f1b88183ddb5d94: function(arg0, arg1) {
364
372
  const ret = Array.of(arg0, arg1);
365
373
  return ret;
Binary file
package/pkg/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "libfw-client",
3
3
  "type": "module",
4
4
  "description": "WASM engine + JS SDK for libfw browser clients",
5
- "version": "0.2.1",
5
+ "version": "0.2.3",
6
6
  "license": "MIT",
7
7
  "repository": {
8
8
  "type": "git",