libfw-client 0.4.3 → 0.4.4

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/README.md CHANGED
@@ -3,6 +3,11 @@
3
3
  The browser SDK for [libfw](../README.md): a zero-config wrapper around the
4
4
  WASM engine, the File System Access API and IndexedDB.
5
5
 
6
+ > The same protocol engine is also available as a **native Rust client** for
7
+ > non-browser programs — `libfw_client::native::NativeClient` (async
8
+ > `tokio` + `reqwest`), with a runnable CLI in
9
+ > [`examples/rust-client`](../examples/rust-client/README.md).
10
+
6
11
  ## Usage
7
12
 
8
13
  ```js
@@ -16,8 +21,12 @@ const client = new LibfwClient({
16
21
  downloadWindow: 4, // parallel byte-range GETs per single file download
17
22
  // (raise to reduce download stutter on high-latency links)
18
23
  compress: true, // zrip per-block compression
24
+ compressLevel: 'auto',// zrip level policy: 'auto' benchmarks uploads
25
+ // (needs autoTune) / 'fast' / 'balanced' / 'max' / N
19
26
  autoTune: true, // adaptive tuning: probes /capabilities and ramps
20
- // concurrency/windows/chunk sizes from real stats
27
+ tuneTtlMs: 3600000, // how long a settled result stays cached (browser,
28
+ // localStorage); 0 = never cache, re-ramp each time
29
+ // concurrency/windows/chunk size from real stats
21
30
  onEvent: (e) => {
22
31
  if (e.type === 'progress') updateProgressBar(e.done, e.total);
23
32
  else if (e.type === 'tuning') renderTuning(e.phase, e.params, e.stats);
@@ -71,8 +80,14 @@ client.cancel();
71
80
  into place. Only the chunks the server still misses are re-sent
72
81
  (`x-libfw-session-status` probe seeds resume), so interrupted uploads
73
82
  resume BitTorrent-style (only the broken/lost parts are re-transmitted).
83
+ A dropped connection (page refresh, crashed tab) keeps that partial on the
84
+ server, so a reloaded page resumes exactly where it stopped.
74
85
  - Resume state (`etag`, `offset`, `size`) is persisted per path in
75
- IndexedDB and re-validated on every retry.
86
+ IndexedDB and re-validated on every retry. `createWritable()` only
87
+ publishes a file on `close()`, so a download **checkpoints** its prefix to
88
+ disk every time the engine reports a durable offset (~4 MiB) — a hard page
89
+ refresh mid-download therefore resumes from the last checkpoint instead of
90
+ restarting from byte 0.
76
91
  - Pause/resume/cancel drive the WASM state machine
77
92
  (`idle → downloading/uploading → paused → resumed → completed/failed`).
78
93
 
@@ -106,21 +121,50 @@ dist/libfw-client.umd.js UMD bundle (after build:umd)
106
121
  both upload chunks and parallel download ranges; the engine reorders
107
122
  in-flight chunks in memory (worst case ≈ `downloadWindow * chunkSize`
108
123
  bytes) so the SDK still receives data in order.
124
+ - `compress: boolean` (default `true`) — master switch for zrip
125
+ compression; `false` sends every body as identity.
126
+ - `compressLevel: number | 'auto' | 'fast' | 'balanced' | 'max'` (default
127
+ `'balanced'`) — zrip level policy when `compress` is on. `'fast'` is the
128
+ advertised minimum (least CPU, worst ratio), `'balanced'` the advertised
129
+ default, `'max'` the advertised maximum (best ratio); a number is clamped
130
+ into the advertised range. `'auto'` additionally micro-benchmarks the
131
+ advertised range against a real sample of the first uploaded file (while
132
+ `autoTune` is enabled) and picks the best bytes-saved-vs-CPU-time
133
+ trade-off for the measured link speed; downloads request the resolved
134
+ level from the server.
109
135
  - `downloadMode: 'auto' | 'fs' | 'browser'` (default `'auto'`) — `'fs'`
110
136
  streams downloads through the File System Access API; `'browser'` buffers
111
137
  and triggers a traditional browser download (folders become `.zip`);
112
138
  `'auto'` uses `'fs'` when the API exists and falls back to `'browser'`.
139
+ **Download resume requires `'fs'`** (or an injected `directoryHandle`):
140
+ an interrupted fs-mode download is continued from the bytes already on
141
+ disk, while the memory-backed `'browser'` fallback always restarts from
142
+ byte 0 — there is no partial file to continue from.
113
143
  - `maxFallbackBytes: number` (default `536870912`, 512 MiB) — memory cap
114
144
  for the in-memory `'browser'` fallback. File sizes are pre-checked
115
145
  against it before buffering; a download that would exceed it rejects
116
146
  with a `too-large` `LibfwError` instead of risking an OOM. `0` disables.
117
147
  - `autoTune: boolean` (default `false`) — enable the adaptive tuning
118
148
  engine. The engine probes the server's `/capabilities` limits and
119
- TCP-style ramps concurrency / windows / chunk sizes (and the zrip
120
- level) from the advertised minimums using real transfer stats. When
121
- disabled, the configured static values are used as-is.
122
- - `tuneTtlMs: number` (default `3600000`, 1 h) how long a settled
123
- tuning result is reused for the same server origin before re-ramping.
149
+ TCP-style ramps the per-file window and cross-file concurrency from the
150
+ advertised minimums using real transfer stats; the chunk size follows the
151
+ measured throughput (~100 ms of it, clamped into the advertised range) and
152
+ the zrip level is a client policy from `compressLevel`, never ramped. When
153
+ disabled, the configured static values are used as-is. Tuning state is
154
+ **in memory** for the lifetime of the client (a settle is reused by later
155
+ transfers and dropped on failure) **and** is cached in `localStorage` per
156
+ origin + direction, so a page refresh does not re-ramp — see `tuneTtlMs`.
157
+ - `tuneTtlMs: number` (default `3600000`, 1 hour) — how long a cached
158
+ tuning result stays usable. The cache is keyed by origin **and**
159
+ direction (an upload settle says nothing about a download), is tagged with
160
+ the `/capabilities` it was measured against, and expires `tuneTtlMs`
161
+ *after the ramp settled* — not after the last reuse — so a link measured
162
+ long ago is re-measured even if it is used constantly. An entry is also
163
+ discarded early when the capabilities change or a transfer fails. `0`
164
+ disables the cache entirely (every transfer re-ramps). Ignored unless
165
+ `autoTune` is enabled. Storage failures (private mode, quota, disabled
166
+ storage) are swallowed: caching is an optimisation and never fails a
167
+ transfer.
124
168
  - `downloadFolder(token, dirPath?) → Promise<number>`
125
169
  - `downloadFile(token, filePath) → Promise<number>`
126
170
  - `upload(token, files?) → Promise<number>`
@@ -130,7 +174,9 @@ dist/libfw-client.umd.js UMD bundle (after build:umd)
130
174
  - `tuneStatus() → { phase, params, stats, capsHash } | null` — live
131
175
  adaptive-tuning status. `phase` is `uninitialized | ramping | settled |
132
176
  degraded`; `params` is `{ concurrency, uploadWindow, downloadWindow,
133
- chunkSize, compressLevel }`; `stats` is
177
+ chunkSize, compressLevel }` (the zrip level is the resolved *policy*, i.e.
178
+ what downloads request — uploads may use an `'auto'`-benchmarked level for
179
+ the session); `stats` is
134
180
  `{ rttMs, mbps }` (EWMA request RTT, last-window throughput). `null`
135
181
  until the WASM engine is initialised.
136
182
  - Events: with `autoTune` enabled, `onEvent` additionally receives
package/index.d.ts CHANGED
@@ -127,7 +127,19 @@ export interface LibfwClientOptions {
127
127
  downloadWindow?: number;
128
128
  /** Negotiate zrip compression. Default `true`. */
129
129
  compress?: boolean;
130
- /** Upload chunk size in bytes. Default 2 MiB. */
130
+ /**
131
+ * zrip level policy when `compress` is on. `'fast'` = advertised minimum
132
+ * (least CPU), `'balanced'` (default) = advertised default, `'max'` =
133
+ * advertised maximum (best ratio); a number is that level clamped into the
134
+ * advertised range.
135
+ *
136
+ * `'auto'` additionally micro-benchmarks the advertised range against a
137
+ * real sample of the first uploaded file while `autoTune` is enabled,
138
+ * choosing the level with the best bytes-saved-vs-CPU-time trade-off.
139
+ * Downloads request the resolved level from the server.
140
+ */
141
+ compressLevel?: number | 'auto' | 'fast' | 'balanced' | 'max';
142
+ /** Shared chunk size in bytes for uploads and parallel downloads. Default 2 MiB. */
131
143
  chunkSize?: number;
132
144
  /** Retries per chunk/file before failing. Default `3`. */
133
145
  maxRetries?: number;
@@ -160,15 +172,28 @@ export interface LibfwClientOptions {
160
172
  maxFallbackBytes?: number;
161
173
  /**
162
174
  * Enable the adaptive tuning engine: the engine probes the server's
163
- * `/capabilities` limits and TCP-style ramps concurrency / windows /
164
- * chunk sizes (and the zrip level) from the advertised minimums using
165
- * real transfer stats. When disabled the configured static values are
166
- * used as-is. Default `false`.
175
+ * `/capabilities` limits and TCP-style ramps the per-file window and
176
+ * cross-file concurrency from the advertised minimums using real transfer
177
+ * stats. The chunk size follows the measured throughput (~100 ms of it,
178
+ * clamped into the advertised range) and the zrip level is a client policy
179
+ * from `compressLevel`, never ramped. When disabled the configured static
180
+ * values are used as-is. Default `false`.
181
+ *
182
+ * Tuning state lives in memory for this client instance (a settle is reused
183
+ * by later transfers and dropped on failure) **and** is cached in
184
+ * `localStorage` per origin + direction, so a page reload does not re-ramp.
185
+ * See `tuneTtlMs` for the lifetime of that cache. Default `false`.
167
186
  */
168
187
  autoTune?: boolean;
169
188
  /**
170
- * How long (ms) a settled tuning result is reused for the same server
171
- * origin before re-ramping. Default `3600000` (1 hour).
189
+ * How long a cached tuning result stays usable, in milliseconds. Default
190
+ * `3600000` (1 hour); `0` disables the cache so every transfer re-ramps.
191
+ *
192
+ * The cache is browser-only (the Rust native client keeps its settle in
193
+ * memory) and is keyed by origin + direction. The TTL counts from the moment
194
+ * the ramp settled, and an entry is discarded early when the server's
195
+ * `/capabilities` change or a transfer fails. Ignored unless `autoTune` is
196
+ * enabled.
172
197
  */
173
198
  tuneTtlMs?: number;
174
199
  /** Optional progress/state listener. Tuning updates arrive as `{ type: 'tuning', phase, params, stats }`. */
package/index.js CHANGED
@@ -207,7 +207,20 @@ export class LibfwClient {
207
207
  * transfer), so a single file's throughput is bounded by bandwidth
208
208
  * instead of one connection's `chunkSize / RTT` on high-latency
209
209
  * links. `1` disables parallelism (sequential downloads).
210
- * @param {boolean} [options.compress=true] negotiate zrip compression
210
+ * @param {boolean} [options.compress=true] negotiate zrip compression.
211
+ * Master switch: `false` sends every body as identity, regardless of
212
+ * `compressLevel`.
213
+ * @param {number|'auto'|'fast'|'balanced'|'max'} [options.compressLevel='balanced']
214
+ * zrip level policy when `compress` is on. `'fast'` = advertised
215
+ * minimum (least CPU, worst ratio), `'balanced'`/`'auto'`-without-a-
216
+ * sample = advertised default, `'max'` = advertised maximum (best
217
+ * ratio), a number = that level clamped into the advertised range.
218
+ * `'auto'` additionally micro-benchmarks the advertised range against
219
+ * a real sample of the first uploaded file (only while `autoTune` is
220
+ * enabled) and picks the level with the best
221
+ * bytes-saved-vs-CPU-time trade-off for the measured link speed.
222
+ * Downloads request the resolved level from the server (no sample is
223
+ * available before the transfer starts).
211
224
  * @param {number} [options.chunkSize=2097152] shared chunk size in bytes for
212
225
  * both upload chunks and parallel download ranges. The same value is
213
226
  * used on both paths, and any value works as long as the server and
@@ -232,11 +245,23 @@ export class LibfwClient {
232
245
  * `0` disables the limit.
233
246
  * @param {boolean} [options.autoTune=false] enable the adaptive tuning
234
247
  * engine: the engine probes the server's `/capabilities` limits and
235
- * TCP-style ramps concurrency / windows / chunk sizes (and the zrip
236
- * level) from the advertised minimums using real transfer stats.
237
- * When disabled the configured static values are used as-is.
238
- * @param {number} [options.tuneTtlMs=3600000] how long a settled tuning
239
- * result is reused for the same server origin before re-ramping
248
+ * TCP-style ramps the per-file window and cross-file concurrency from
249
+ * the advertised minimums using real transfer stats; the chunk size
250
+ * follows the measured throughput (~100 ms of it, clamped into the
251
+ * advertised range) and the zrip level is a client policy from
252
+ * `compressLevel`, never ramped. When disabled the configured static
253
+ * values are used as-is. A settled result is kept in memory for this
254
+ * client instance (reused by later transfers, dropped on failure)
255
+ * **and** cached in `localStorage` per origin + direction, so a page
256
+ * reload does not re-ramp; see `tuneTtlMs` for how long that cache
257
+ * lives.
258
+ * @param {number} [options.tuneTtlMs=3600000] how long a cached tuning
259
+ * result (browser only — the Rust native client keeps it in memory)
260
+ * stays usable, in milliseconds. The TTL counts from the moment the
261
+ * ramp settled, is scoped to one origin + direction, and is
262
+ * invalidated early if the server's `/capabilities` change or a
263
+ * transfer fails. `0` disables the cache entirely, so every transfer
264
+ * (and every page load) re-ramps. Ignored unless `autoTune` is on.
240
265
  * @param {(event: {type: string, done: number, total: number, path?: string, error?: string}) => void} [options.onEvent]
241
266
  * optional progress/state listener. Tuning updates arrive as
242
267
  * `{ type: 'tuning', phase, params, stats }` events.
@@ -268,6 +293,7 @@ export class LibfwClient {
268
293
  uploadWindow: 8,
269
294
  downloadWindow: 4,
270
295
  compress: true,
296
+ compressLevel: null,
271
297
  chunkSize: 2 * 1024 * 1024,
272
298
  maxRetries: 3,
273
299
  baseRetryDelayMs: 500,
@@ -277,7 +303,7 @@ export class LibfwClient {
277
303
  downloadMode: 'auto',
278
304
  maxFallbackBytes: 512 * 1024 * 1024,
279
305
  autoTune: false,
280
- tuneTtlMs: 3600000,
306
+ tuneTtlMs: 60 * 60 * 1000,
281
307
  onEvent: null,
282
308
  directoryHandle: null,
283
309
  resolveDisplayName: null,
@@ -333,6 +359,7 @@ export class LibfwClient {
333
359
  uploadWindow: this._options.uploadWindow,
334
360
  downloadWindow: this._options.downloadWindow,
335
361
  compress: this._options.compress,
362
+ compressLevel: this._options.compressLevel,
336
363
  chunkSize: this._options.chunkSize,
337
364
  maxRetries: this._options.maxRetries,
338
365
  baseRetryDelayMs: this._options.baseRetryDelayMs,
@@ -417,7 +444,15 @@ export class LibfwClient {
417
444
  // poisons a later FS-API resume. Skip persisting download state
418
445
  // while a fallback transfer is active.
419
446
  if (direction === 'download' && this._fallback) return Promise.resolve();
420
- return Idb.saveState(`${direction}:${path}`, state);
447
+ return Idb.saveState(`${direction}:${path}`, state).then(() => {
448
+ // The engine reports a durable offset every few MiB: use it to
449
+ // COMMIT the prefix to disk, so an interrupted download (a tab
450
+ // refresh or a crash — no `finally` block runs) still has a partial
451
+ // to resume from instead of starting over.
452
+ if (direction === 'download') {
453
+ return this._checkpointDownload(path, Number(state?.offset) || 0);
454
+ }
455
+ });
421
456
  },
422
457
  getFileList: () => this._getFileList(),
423
458
  readFile: (path, offset, length) => this._readFile(path, offset, length),
@@ -818,6 +853,35 @@ export class LibfwClient {
818
853
  const writable = await handle.createWritable(
819
854
  isResume ? { keepExistingData: true } : undefined
820
855
  );
856
+ if (isResume) {
857
+ // `createWritable({ keepExistingData: true })` KEEPS the existing
858
+ // bytes but still positions the stream at 0 — it is not an append. A
859
+ // resumed tail written without seeking would overwrite the prefix and
860
+ // then close, leaving a file that is only the tail (measured: a
861
+ // 20 MiB download resumed at 6.8 MiB produced a 14.1 MiB, corrupt
862
+ // file). Seek past what is already on disk so the tail really
863
+ // appends; `_loadResumeState` clamped `offset` to the on-disk length.
864
+ const existing = (await handle.getFile()).size;
865
+ if (existing < offset) {
866
+ // The prefix we are asked to resume past is not on disk: appending
867
+ // would leave a hole. Fail loudly instead of committing a corrupt
868
+ // file (the caller can retry, which restarts cleanly).
869
+ await this._discardWritable(path, writable);
870
+ throw new LibfwError(
871
+ `cannot resume \`${path}\`: only ${existing} of ${offset} bytes are on disk`,
872
+ 'storage'
873
+ );
874
+ }
875
+ try {
876
+ await writable.seek(offset);
877
+ } catch (err) {
878
+ await this._discardWritable(path, writable);
879
+ throw new LibfwError(
880
+ `cannot resume \`${path}\`: the destination cannot be seeked (${err?.message ?? err})`,
881
+ 'storage'
882
+ );
883
+ }
884
+ }
821
885
  entry = { writable, dir, name, lastOffset: offset };
822
886
  this._writables.set(path, entry);
823
887
  }
@@ -825,6 +889,74 @@ export class LibfwClient {
825
889
  await entry.writable.write(data);
826
890
  }
827
891
 
892
+ /**
893
+ * Commit the bytes received so far for a download to disk.
894
+ *
895
+ * `createWritable()` only publishes the file on `close()` — everything else
896
+ * lives in a Chromium swap file (`.name.crswap`) that is DISCARDED when the
897
+ * page dies. A hard refresh mid-download would therefore find a 0-byte (or
898
+ * stale) target and restart from byte 0. The engine reports a durable
899
+ * offset every few MiB (`RESUME_SAVE_EVERY`), so this closes the writable
900
+ * (publishing the prefix) and immediately reopens it with
901
+ * `keepExistingData` + `seek`, leaving a resumable partial on disk at the
902
+ * cost of one close/reopen per checkpoint.
903
+ *
904
+ * Called from the `saveState` callback, which the engine awaits between
905
+ * chunks — no write for this path is in flight here.
906
+ * @param {string} path
907
+ * @param {number} offset durable offset reported by the engine
908
+ * @returns {Promise<void>}
909
+ * @private
910
+ */
911
+ async _checkpointDownload(path, offset) {
912
+ const entry = this._writables.get(path);
913
+ const handle = this._fileHandles.get(path);
914
+ if (!entry || !handle || offset <= 0) return;
915
+ let onDisk = 0;
916
+ try {
917
+ onDisk = (await handle.getFile()).size;
918
+ } catch {
919
+ return; // handle gone: nothing safe to commit
920
+ }
921
+ // Nothing new to publish (an earlier checkpoint already covers it), or
922
+ // the engine is behind the bytes we committed: leave the stream alone.
923
+ if (onDisk >= offset) return;
924
+ try {
925
+ await entry.writable.close();
926
+ } catch {
927
+ return; // keep the (still open) stream; the next chunk continues on it
928
+ }
929
+ try {
930
+ const writable = await handle.createWritable({ keepExistingData: true });
931
+ // The committed file now holds everything written so far; `close()`
932
+ // published exactly `lastOffset` bytes, so appending resumes there.
933
+ await writable.seek((await handle.getFile()).size);
934
+ entry.writable = writable;
935
+ } catch {
936
+ // Reopening failed: forget the stream so the next chunk re-opens it
937
+ // through the normal resume path (which re-validates the prefix).
938
+ this._writables.delete(path);
939
+ }
940
+ }
941
+
942
+ /**
943
+ * Drop an open writable (and its uncommitted swap file) without failing the
944
+ * caller. Used when a resume cannot be honoured so no partial/corrupt file
945
+ * is committed.
946
+ * @param {string} path
947
+ * @param {FileSystemWritableFileStream} writable
948
+ * @returns {Promise<void>}
949
+ * @private
950
+ */
951
+ async _discardWritable(path, writable) {
952
+ this._writables.delete(path);
953
+ try {
954
+ await writable.abort();
955
+ } catch {
956
+ /* best-effort discard */
957
+ }
958
+ }
959
+
828
960
  /**
829
961
  * Close the destination writable once a file's transfer completes.
830
962
  * @param {string} path virtual path
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "libfw-client",
3
- "version": "0.4.3",
3
+ "version": "0.4.4",
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",
@@ -592,7 +592,7 @@ function __wbg_get_imports() {
592
592
  return ret;
593
593
  }, arguments); },
594
594
  __wbindgen_cast_0000000000000001: function(arg0, arg1) {
595
- // Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [Externref], shim_idx: 209, ret: Result(Unit), inner_ret: Some(Result(Unit)) }, mutable: true }) -> Externref`.
595
+ // Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [Externref], shim_idx: 206, ret: Result(Unit), inner_ret: Some(Result(Unit)) }, mutable: true }) -> Externref`.
596
596
  const ret = makeMutClosure(arg0, arg1, wasm_bindgen_b2c787b844a3ac79___convert__closures_____invoke___wasm_bindgen_b2c787b844a3ac79___JsValue__core_ed718c3d60ebd546___result__Result_____wasm_bindgen_b2c787b844a3ac79___JsError___true_);
597
597
  return ret;
598
598
  },
Binary file
package/pkg/package.json CHANGED
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "name": "libfw-client",
3
3
  "type": "module",
4
- "description": "WASM engine + JS SDK for libfw browser clients",
5
- "version": "0.4.3",
4
+ "description": "libfw client: native Rust client + WASM engine + JS SDK",
5
+ "version": "0.4.4",
6
6
  "license": "MIT",
7
7
  "repository": {
8
8
  "type": "git",