libfw-client 0.1.2 → 0.1.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/README.md CHANGED
@@ -15,7 +15,9 @@ const client = new LibfwClient({
15
15
  onEvent: (e) => console.log(e), // { type: 'progress', done, total }
16
16
  });
17
17
 
18
- // Download a whole folder (showDirectoryPicker, preserves structure)
18
+ // Download a whole folder. Uses showDirectoryPicker when the File System
19
+ // Access API is available; otherwise the folder is zipped and saved via a
20
+ // traditional browser download — no feature detection needed by the caller.
19
21
  await client.downloadFolder('your_token_here');
20
22
 
21
23
  // Upload a FileList
@@ -33,10 +35,13 @@ client.cancel();
33
35
 
34
36
  ## How it works
35
37
 
36
- - `downloadFolder(token, dirPath?)` the engine lists the folder on the
37
- server (recursively), then downloads each file with `Range`/`If-Range`
38
- resume, decompresses the zrip stream, and pushes `Uint8Array` chunks to
39
- the SDK, which writes them with `fileHandle.createWritable()`.
38
+ - `downloadFolder(token, dirPath?)` / `downloadFile(token, filePath)` the
39
+ engine lists (for folders) and downloads each file with `Range`/`If-Range`
40
+ resume, decompresses the zrip stream, and pushes `Uint8Array` chunks to the
41
+ SDK. With the File System Access API the SDK streams them to disk via
42
+ `fileHandle.createWritable()`; without it (or with `downloadMode: 'browser'`)
43
+ the SDK buffers the chunks and saves the result through a traditional
44
+ browser download — a single file as-is, a folder packed into a `.zip`.
40
45
  - `upload(token, files?)` — the engine slices each file into fixed-size
41
46
  chunks, reads them via `readFile`, compresses each chunk into one zstd
42
47
  frame, and POSTs them with `x-libfw-offset` for server-side resume
@@ -61,6 +66,7 @@ The resulting package contains:
61
66
  ```
62
67
  pkg/ wasm-pack output (wasm + wasm-bindgen web glue)
63
68
  index.js ESM SDK
69
+ zip.js dependency-free ZIP writer (browser-download fallback)
64
70
  index.d.ts TypeScript types
65
71
  dist/libfw-client.umd.js UMD bundle (after build:umd)
66
72
  ```
@@ -68,8 +74,18 @@ dist/libfw-client.umd.js UMD bundle (after build:umd)
68
74
  ## API
69
75
 
70
76
  - `new LibfwClient(options?)`
77
+ - `downloadMode: 'auto' | 'fs' | 'browser'` (default `'auto'`) — `'fs'`
78
+ streams downloads through the File System Access API; `'browser'` buffers
79
+ and triggers a traditional browser download (folders become `.zip`);
80
+ `'auto'` uses `'fs'` when the API exists and falls back to `'browser'`.
81
+ - `maxFallbackBytes: number` (default `536870912`, 512 MiB) — memory cap
82
+ for the in-memory `'browser'` fallback. File sizes are pre-checked
83
+ against it before buffering; a download that would exceed it rejects
84
+ with a `too-large` `LibfwError` instead of risking an OOM. `0` disables.
71
85
  - `downloadFolder(token, dirPath?) → Promise<number>`
86
+ - `downloadFile(token, filePath) → Promise<number>`
72
87
  - `upload(token, files?) → Promise<number>`
88
+ - `clearResumeStore(direction?) → Promise<number>`
73
89
  - `pause()`, `resume()`, `cancel()`
74
90
  - `state()`, `progress()`, `doneBytes()`, `totalBytes()`
75
91
  - Errors: every rejection is a `LibfwError` with a stable `code`.
package/index.d.ts CHANGED
@@ -16,7 +16,8 @@ export type LibfwErrorCode =
16
16
  | 'decompress'
17
17
  | 'compress'
18
18
  | 'protocol'
19
- | 'cancelled';
19
+ | 'cancelled'
20
+ | 'too-large';
20
21
 
21
22
  /** Uniform error type thrown by every SDK operation. */
22
23
  export declare class LibfwError extends Error {
@@ -65,6 +66,27 @@ export interface LibfwClientOptions {
65
66
  maxRetryDelayMs?: number;
66
67
  /** Per-request timeout (ms). Default `60000`. */
67
68
  timeoutMs?: number;
69
+ /**
70
+ * Explicit URL of `libfw_client_bg.wasm`. When omitted it is resolved
71
+ * automatically for both ESM and classic-`<script>`/UMD consumers.
72
+ */
73
+ wasmUrl?: string;
74
+ /**
75
+ * How downloads reach the user's disk.
76
+ *
77
+ * - `'fs'` — File System Access API (`showDirectoryPicker`), streaming to disk.
78
+ * - `'browser'` — buffer each file, then trigger a traditional browser
79
+ * download; folders are packed into a `.zip` archive.
80
+ * - `'auto'` (default) — `'fs'` when the API exists, else `'browser'`.
81
+ */
82
+ downloadMode?: 'auto' | 'fs' | 'browser';
83
+ /**
84
+ * Memory cap (bytes) for the in-memory `'browser'` download fallback.
85
+ * File sizes are pre-checked against it before buffering; a download that
86
+ * would exceed it is rejected with a `too-large` error. `0` disables the
87
+ * limit. Default `536870912` (512 MiB).
88
+ */
89
+ maxFallbackBytes?: number;
68
90
  /** Optional progress/state listener. */
69
91
  onEvent?: (event: LibfwEvent) => void;
70
92
  }
@@ -142,6 +164,17 @@ export declare class LibfwClient {
142
164
 
143
165
  /** Total bytes to transfer. */
144
166
  totalBytes(): number;
167
+
168
+ /**
169
+ * Delete persisted resume state (IndexedDB).
170
+ *
171
+ * Pass a direction to wipe only that transfer's state (`'download'` or
172
+ * `'upload'`); omit it to clear the whole store.
173
+ *
174
+ * @param direction restrict the wipe to one transfer direction
175
+ * @returns number of records removed
176
+ */
177
+ clearResumeStore(direction?: 'upload' | 'download'): Promise<number>;
145
178
  }
146
179
 
147
180
  export default LibfwClient;
package/index.js CHANGED
@@ -4,7 +4,10 @@
4
4
  * A thin, dependency-free wrapper around the libfw WASM engine that owns:
5
5
  * - WASM instantiation (via the wasm-bindgen `web` glue),
6
6
  * - the File System Access API (`showDirectoryPicker`, `getFileHandle`,
7
- * `createWritable`),
7
+ * `createWritable`) for streaming downloads,
8
+ * - a traditional browser-download fallback when the File System Access API
9
+ * is unavailable: single files download directly, folders are packed into
10
+ * a `.zip` archive (see `downloadMode`),
8
11
  * - IndexedDB resume-state persistence,
9
12
  * - converting engine callbacks (`onWriteChunk`, `getFileList`, …) into
10
13
  * real file I/O.
@@ -16,6 +19,7 @@
16
19
  */
17
20
 
18
21
  import init, { LibfwClient as WasmEngine } from './pkg/libfw_client.js';
22
+ import { createZip } from './zip.js';
19
23
 
20
24
  /** Database / store names used by the IndexedDB resume-state layer. */
21
25
  const IDB_NAME = 'libfw';
@@ -106,6 +110,47 @@ const Idb = {
106
110
  tx.onerror = () => reject(new LibfwError(`idb put: ${tx.error}`, 'idb'));
107
111
  });
108
112
  },
113
+
114
+ /**
115
+ * Delete every key whose `direction:` prefix matches (e.g. all
116
+ * `download:*` keys) while leaving the other direction intact.
117
+ * @param {string} direction `'upload'` | `'download'`
118
+ * @returns {Promise<number>} number of records removed
119
+ */
120
+ async clearDirection(direction) {
121
+ const db = await Idb.open();
122
+ const prefix = `${direction}:`;
123
+ const keys = await new Promise((resolve, reject) => {
124
+ const tx = db.transaction(IDB_STORE, 'readonly');
125
+ const req = tx.objectStore(IDB_STORE).getAllKeys();
126
+ req.onsuccess = () => resolve(req.result);
127
+ req.onerror = () => reject(new LibfwError(`idb keys: ${req.error}`, 'idb'));
128
+ });
129
+ const matches = keys.filter((key) => String(key).startsWith(prefix));
130
+ if (matches.length === 0) return 0;
131
+ await new Promise((resolve, reject) => {
132
+ const tx = db.transaction(IDB_STORE, 'readwrite');
133
+ const store = tx.objectStore(IDB_STORE);
134
+ for (const key of matches) store.delete(key);
135
+ tx.oncomplete = () => resolve();
136
+ tx.onerror = () => reject(new LibfwError(`idb clear direction: ${tx.error}`, 'idb'));
137
+ });
138
+ return matches.length;
139
+ },
140
+
141
+ /**
142
+ * Wipe the whole resume store.
143
+ * @returns {Promise<void>}
144
+ */
145
+ async clear() {
146
+ const db = await Idb.open();
147
+ await new Promise((resolve, reject) => {
148
+ const tx = db.transaction(IDB_STORE, 'readwrite');
149
+ tx.objectStore(IDB_STORE).clear();
150
+ tx.oncomplete = () => resolve();
151
+ tx.onerror = () => reject(new LibfwError(`idb clear: ${tx.error}`, 'idb'));
152
+ });
153
+ },
109
154
  };
110
155
 
111
156
  /** Split a POSIX virtual path into segments. */
@@ -123,6 +168,22 @@ function splitPath(path) {
123
168
  * await client.downloadFolder('your_token_here');
124
169
  * await client.upload('your_token_here', fileInput.files);
125
170
  */
171
+
172
+ /**
173
+ * `<script src>` of the bundle that is currently evaluating this module.
174
+ *
175
+ * `document.currentScript` is only non-null during a classic script's
176
+ * synchronous evaluation, so it is captured here once, at load time, rather
177
+ * than read later from an async callback (where it would already be `null`).
178
+ * In ESM this is `null` (modules never set `currentScript`), which is
179
+ * correct — the ESM path resolves the `.wasm` via `import.meta.url` instead.
180
+ * @type {string|null}
181
+ */
182
+ const BUNDLE_SCRIPT_SRC =
183
+ typeof document !== 'undefined' && document.currentScript && document.currentScript.src
184
+ ? document.currentScript.src
185
+ : null;
186
+
126
187
  export class LibfwClient {
127
188
  /**
128
189
  * @param {object} [options]
@@ -134,6 +195,20 @@ export class LibfwClient {
134
195
  * @param {number} [options.baseRetryDelayMs=500] initial backoff (ms)
135
196
  * @param {number} [options.maxRetryDelayMs=30000] backoff ceiling (ms)
136
197
  * @param {number} [options.timeoutMs=60000] per-request timeout (ms)
198
+ * @param {string} [options.wasmUrl] explicit URL of `libfw_client_bg.wasm`;
199
+ * when omitted it is resolved automatically for both ESM and
200
+ * classic-`<script>`/UMD consumers (see {@link LibfwClient#_wasmUrl})
201
+ * @param {'auto'|'fs'|'browser'} [options.downloadMode='auto'] how downloads
202
+ * reach the user's disk: `'fs'` uses the File System Access API
203
+ * (`showDirectoryPicker`); `'browser'` buffers each file and triggers
204
+ * a traditional browser download (folders are packed into a `.zip`);
205
+ * `'auto'` picks `'fs'` when the API exists, otherwise `'browser'`.
206
+ * @param {number} [options.maxFallbackBytes=536870912] memory cap (bytes)
207
+ * for the in-memory `'browser'` download fallback. Each file's size
208
+ * (and the cumulative buffered total) is pre-checked against it
209
+ * before any bytes are buffered; a download that would exceed it is
210
+ * rejected with a `too-large` error instead of risking an OOM.
211
+ * `0` disables the limit.
137
212
  * @param {(event: {type: string, done: number, total: number, path?: string, error?: string}) => void} [options.onEvent]
138
213
  * optional progress/state listener
139
214
  */
@@ -147,6 +222,9 @@ export class LibfwClient {
147
222
  baseRetryDelayMs: 500,
148
223
  maxRetryDelayMs: 30000,
149
224
  timeoutMs: 60000,
225
+ wasmUrl: null,
226
+ downloadMode: 'auto',
227
+ maxFallbackBytes: 512 * 1024 * 1024,
150
228
  onEvent: null,
151
229
  ...options,
152
230
  };
@@ -164,6 +242,11 @@ export class LibfwClient {
164
242
  this._uploadFiles = new Map();
165
243
  /** @type {Array<{path:string,size:number,mtime:number}>} upload plan */
166
244
  this._uploadPlan = [];
245
+ /**
246
+ * Active browser-download fallback state, or `null`.
247
+ * @type {{isFolder:boolean, buffers:Map<string,Uint8Array[]>, order:string[], sizes:Map<string,number>}|null}
248
+ */
249
+ this._fallback = null;
167
250
  }
168
251
 
169
252
  // ------------------------------------------------------------------ setup
@@ -176,7 +259,12 @@ export class LibfwClient {
176
259
  async _ready() {
177
260
  if (this._engine) return this._engine;
178
261
  if (!this._initPromise) {
179
- this._initPromise = init().catch((err) => {
262
+ // Always pass the .wasm location explicitly so the generated glue's
263
+ // ESM-only `import.meta.url` fallback is never exercised — that
264
+ // fallback throws when the SDK is bundled for a classic <script>/UMD
265
+ // context. See _wasmUrl() for how the URL is resolved. The
266
+ // `{ module_or_path }` object form is the glue's non-deprecated API.
267
+ this._initPromise = init({ module_or_path: this._wasmUrl() }).catch((err) => {
180
268
  this._initPromise = null;
181
269
  throw toLibfwError(err);
182
270
  });
@@ -196,6 +284,31 @@ export class LibfwClient {
196
284
  return engine;
197
285
  }
198
286
 
287
+ /**
288
+ * Resolve the `.wasm` file URL without relying on `import.meta` (which is
289
+ * ESM-only and a parse error in a classic `<script>`).
290
+ *
291
+ * Order: explicit `wasmUrl` option → classic-script `document.currentScript`
292
+ * → ESM `import.meta.url`. The `wasmUrl` option is the escape hatch for
293
+ * deployments where neither auto-detection applies.
294
+ * @returns {string|URL}
295
+ * @private
296
+ */
297
+ _wasmUrl() {
298
+ if (this._options.wasmUrl) return this._options.wasmUrl;
299
+ // Classic <script> / UMD: the bundle's own <script src> (captured at load
300
+ // time in BUNDLE_SCRIPT_SRC) tells us where the sibling `.wasm` lives.
301
+ if (BUNDLE_SCRIPT_SRC) {
302
+ return new URL('libfw_client_bg.wasm', BUNDLE_SCRIPT_SRC);
303
+ }
304
+ // ESM: relative to this module.
305
+ if (typeof import.meta !== 'undefined' && import.meta.url) {
306
+ return new URL('./pkg/libfw_client_bg.wasm', import.meta.url);
307
+ }
308
+ // Last resort: same-origin relative path.
309
+ return 'libfw_client_bg.wasm';
310
+ }
311
+
199
312
  /**
200
313
  * Build the callbacks object handed to the WASM engine.
201
314
  * @returns {object}
@@ -203,12 +316,42 @@ export class LibfwClient {
203
316
  */
204
317
  _makeCallbacks() {
205
318
  return {
206
- onFileStart: (path, size) => this._emit({ type: 'fileStart', path, done: 0, total: size }),
319
+ onFileStart: (path, size) => {
320
+ if (this._fallback) {
321
+ this._fallback.sizes.set(path, size);
322
+ // Pre-check the in-memory cap BEFORE any bytes are buffered so an
323
+ // oversized download is rejected instead of exhausting memory.
324
+ const max = this._maxFallbackBytes();
325
+ if (max > 0) {
326
+ if (size > max) {
327
+ throw new LibfwError(
328
+ `file too large for browser download (${size} > ${max} bytes): ${path}`,
329
+ 'too-large'
330
+ );
331
+ }
332
+ this._fallback.total += size;
333
+ if (this._fallback.total > max) {
334
+ throw new LibfwError(
335
+ `browser download would buffer more than the ${max}-byte in-memory limit`,
336
+ 'too-large'
337
+ );
338
+ }
339
+ }
340
+ }
341
+ this._emit({ type: 'fileStart', path, done: 0, total: size });
342
+ },
207
343
  onWriteChunk: (path, offset, data) => this._onWriteChunk(path, offset, data),
208
344
  onFileCompleted: (path) => this._onFileCompleted(path),
209
345
  onProgress: (done, total) => this._emit({ type: 'progress', done, total }),
210
346
  loadState: (direction, path) => Idb.loadState(`${direction}:${path}`),
211
- saveState: (direction, path, state) => Idb.saveState(`${direction}:${path}`, state),
347
+ saveState: (direction, path, state) => {
348
+ // The in-memory browser-download fallback never commits bytes to
349
+ // disk, so a persisted download offset would be a phantom that
350
+ // poisons a later FS-API resume. Skip persisting download state
351
+ // while a fallback transfer is active.
352
+ if (direction === 'download' && this._fallback) return Promise.resolve();
353
+ return Idb.saveState(`${direction}:${path}`, state);
354
+ },
212
355
  getFileList: () => this._getFileList(),
213
356
  readFile: (path, offset, length) => this._readFile(path, offset, length),
214
357
  log: (msg) => {
@@ -228,24 +371,54 @@ export class LibfwClient {
228
371
  }
229
372
  }
230
373
 
374
+ /**
375
+ * Whether the File System Access API is available in this browser.
376
+ * @returns {boolean}
377
+ * @private
378
+ */
379
+ _supportsFsAccess() {
380
+ return (
381
+ typeof window !== 'undefined' &&
382
+ typeof window.showDirectoryPicker === 'function' &&
383
+ typeof FileSystemFileHandle !== 'undefined' &&
384
+ typeof FileSystemDirectoryHandle !== 'undefined'
385
+ );
386
+ }
387
+
388
+ /**
389
+ * Resolve the effective download mode from the `downloadMode` option:
390
+ * an explicit `'fs'`/`'browser'` wins; `'auto'` falls back to the browser
391
+ * download when the File System Access API is missing.
392
+ * @returns {'fs'|'browser'}
393
+ * @private
394
+ */
395
+ _effectiveMode() {
396
+ const mode = this._options.downloadMode || 'auto';
397
+ if (mode === 'fs' || mode === 'browser') return mode;
398
+ return this._supportsFsAccess() ? 'fs' : 'browser';
399
+ }
400
+
231
401
  // ------------------------------------------------------------ downloads
232
402
 
233
403
  /**
234
- * Download a whole folder from the server into a local directory chosen
235
- * by the user via `showDirectoryPicker()`.
404
+ * Download a whole folder from the server.
236
405
  *
237
- * Folder structure (including nested directories) is preserved; bytes
238
- * are streamed to disk through `createWritable({ type: 'write' })`.
406
+ * With the File System Access API available the folder is streamed into a
407
+ * user-selected local directory (`showDirectoryPicker`), preserving the
408
+ * structure through one `createWritable()` per file. Without FS API (or
409
+ * with `downloadMode: 'browser'`) the folder is buffered in memory, packed
410
+ * into a `.zip` and saved via a traditional browser download — no manual
411
+ * feature detection needed by the caller.
239
412
  *
240
413
  * @param {string} token bearer token
241
414
  * @param {string} [dirPath=''] virtual server path to download (root by default)
242
- * @returns {Promise<number>} total bytes written
415
+ * @returns {Promise<number>} total bytes transferred
243
416
  * @throws {LibfwError}
244
417
  */
245
418
  async downloadFolder(token, dirPath = '') {
246
419
  const engine = await this._ready();
247
- if (typeof window === 'undefined' || typeof window.showDirectoryPicker !== 'function') {
248
- throw new LibfwError('File System Access API is not available in this browser', 'unsupported');
420
+ if (this._effectiveMode() === 'browser') {
421
+ return this._downloadViaBrowser(engine, token, dirPath, true);
249
422
  }
250
423
  this._dirHandle = await window.showDirectoryPicker();
251
424
  this._fileHandles.clear();
@@ -255,24 +428,29 @@ export class LibfwClient {
255
428
  throw toLibfwError(err);
256
429
  } finally {
257
430
  await this._flushWritables();
431
+ await this._syncResumeOffsets();
258
432
  }
259
433
  }
260
434
 
261
435
  /**
262
- * Download a single file from the server at `filePath` into a local
263
- * directory chosen via `showDirectoryPicker()`.
436
+ * Download a single file from the server at `filePath`.
437
+ *
438
+ * With the File System Access API available the file is streamed into the
439
+ * directory chosen via `showDirectoryPicker()`. Without FS API (or with
440
+ * `downloadMode: 'browser'`) the file is buffered and saved through a
441
+ * traditional browser download.
264
442
  *
265
443
  * @param {string} token bearer token
266
444
  * @param {string} filePath virtual server path of the file to download
267
- * @returns {Promise<number>} total bytes written
445
+ * @returns {Promise<number>} total bytes transferred
268
446
  * @throws {LibfwError}
269
447
  */
270
448
  async downloadFile(token, filePath) {
271
449
  const engine = await this._ready();
272
- if (typeof window === 'undefined' || typeof window.showDirectoryPicker !== 'function') {
273
- throw new LibfwError('File System Access API is not available in this browser', 'unsupported');
274
- }
275
450
  if (!filePath) throw new LibfwError('downloadFile requires a file path', 'path');
451
+ if (this._effectiveMode() === 'browser') {
452
+ return this._downloadViaBrowser(engine, token, filePath, false);
453
+ }
276
454
  this._dirHandle = await window.showDirectoryPicker();
277
455
  this._fileHandles.clear();
278
456
  try {
@@ -281,36 +459,210 @@ export class LibfwClient {
281
459
  throw toLibfwError(err);
282
460
  } finally {
283
461
  await this._flushWritables();
462
+ await this._syncResumeOffsets();
463
+ }
464
+ }
465
+
466
+ /**
467
+ * Buffer-chunk fallback download used when the File System Access API is
468
+ * unavailable (or `downloadMode: 'browser'`).
469
+ *
470
+ * `onWriteChunk` chunks are collected per path in memory (the engine keeps
471
+ * calling them in order). When the transfer finishes: a single file is
472
+ * emitted as a `Blob` and saved via a normal browser download; a folder is
473
+ * packed into a `.zip` (STORE method) and downloaded. Progress/state events
474
+ * keep flowing as usual. Note this buffers the whole transfer in memory —
475
+ * the cost of not having FS API to stream to disk.
476
+ *
477
+ * @param {WasmEngine} engine
478
+ * @param {string} token
479
+ * @param {string} path virtual path
480
+ * @param {boolean} isFolder
481
+ * @returns {Promise<number>} total bytes transferred
482
+ * @private
483
+ */
484
+ async _downloadViaBrowser(engine, token, path, isFolder) {
485
+ this._fallback = { isFolder, buffers: new Map(), order: [], sizes: new Map(), total: 0 };
486
+ try {
487
+ const total = isFolder
488
+ ? await engine.download_folder(this._options.baseUrl, token, path)
489
+ : await engine.download_file(this._options.baseUrl, token, path);
490
+ const { buffers, order, sizes } = this._fallback;
491
+ if (isFolder) {
492
+ const entries = [];
493
+ for (const p of order) {
494
+ entries.push({
495
+ name: this._safeEntryName(p),
496
+ data: this._concatBuffers(buffers.get(p) || []),
497
+ });
498
+ }
499
+ // Include files that were announced but produced no bytes (empty).
500
+ for (const p of sizes.keys()) {
501
+ if (!buffers.has(p)) {
502
+ entries.push({ name: this._safeEntryName(p), data: new Uint8Array(0) });
503
+ }
504
+ }
505
+ this._triggerBrowserDownload(createZip(entries), this._archiveName(path));
506
+ } else {
507
+ const data = this._concatBuffers(buffers.get(path) || []);
508
+ this._triggerBrowserDownload(new Blob([data], { type: 'application/octet-stream' }), this._downloadName(path));
509
+ }
510
+ return total;
511
+ } catch (err) {
512
+ throw toLibfwError(err);
513
+ } finally {
514
+ this._fallback = null;
515
+ }
516
+ }
517
+
518
+ /**
519
+ * Concatenate buffered chunks into one `Uint8Array`.
520
+ * @param {Uint8Array[]} bufs
521
+ * @returns {Uint8Array}
522
+ * @private
523
+ */
524
+ _concatBuffers(bufs) {
525
+ if (bufs.length === 0) return new Uint8Array(0);
526
+ if (bufs.length === 1) return bufs[0];
527
+ const len = bufs.reduce((n, b) => n + b.length, 0);
528
+ const out = new Uint8Array(len);
529
+ let off = 0;
530
+ for (const b of bufs) {
531
+ out.set(b, off);
532
+ off += b.length;
533
+ }
534
+ return out;
535
+ }
536
+
537
+ /**
538
+ * Strip a leading `/` so an entry path is archive/OS friendly.
539
+ * @param {string} path
540
+ * @returns {string}
541
+ * @private
542
+ */
543
+ _cleanPath(path) {
544
+ return String(path).replace(/^\/+/, '');
545
+ }
546
+
547
+ /**
548
+ * Validate a virtual path for use as a ZIP entry name, rejecting any
549
+ * traversal (`..`), absolute/drive-letter prefixes or Windows-style
550
+ * separators that could escape the archive on extraction (zip-slip).
551
+ * @param {string} path
552
+ * @returns {string}
553
+ * @private
554
+ */
555
+ _safeEntryName(path) {
556
+ const cleaned = this._cleanPath(path);
557
+ const segs = String(cleaned).split('/');
558
+ if (segs.some((seg) => seg === '..' || seg.includes('\\') || /^[a-zA-Z]:/.test(seg))) {
559
+ throw new LibfwError(`unsafe path in download: ${path}`, 'path');
284
560
  }
561
+ return cleaned;
562
+ }
563
+
564
+ /**
565
+ * The configured in-memory cap for the browser-download fallback.
566
+ * @returns {number} 0 disables the limit.
567
+ * @private
568
+ */
569
+ _maxFallbackBytes() {
570
+ const max = Number(this._options.maxFallbackBytes);
571
+ return Number.isFinite(max) && max > 0 ? max : 0;
572
+ }
573
+
574
+ /**
575
+ * Derive a safe file name from a virtual path.
576
+ * @param {string} path
577
+ * @returns {string}
578
+ * @private
579
+ */
580
+ _downloadName(path) {
581
+ const name = this._cleanPath(path).split('/').pop();
582
+ return name || 'download';
285
583
  }
286
584
 
287
585
  /**
288
- * Stream a decompressed chunk straight to disk at its absolute byte
289
- * offset, keeping memory bounded regardless of file size (no whole-file
290
- * buffering). The engine awaits this callback, so writes for a file are
291
- * applied strictly in order.
586
+ * Derive the `.zip` archive name for a folder download.
587
+ * @param {string} path
588
+ * @returns {string}
589
+ * @private
590
+ */
591
+ _archiveName(path) {
592
+ const base = this._cleanPath(path).split('/').pop() || 'download';
593
+ return `${base.replace(/[^\w.\- ]+/g, '_') || 'download'}.zip`;
594
+ }
595
+
596
+ /**
597
+ * Trigger a traditional browser download via a temporary `<a download>`.
598
+ * @param {Blob} blob
599
+ * @param {string} filename
600
+ * @returns {void}
601
+ * @private
602
+ */
603
+ _triggerBrowserDownload(blob, filename) {
604
+ const url = URL.createObjectURL(blob);
605
+ const a = document.createElement('a');
606
+ a.href = url;
607
+ a.download = filename;
608
+ document.body.appendChild(a);
609
+ a.click();
610
+ a.remove();
611
+ // Revoke once the download has had a chance to start.
612
+ setTimeout(() => URL.revokeObjectURL(url), 60000);
613
+ }
614
+
615
+ /**
616
+ * Stream a decompressed chunk to disk, keeping memory bounded regardless
617
+ * of file size (no whole-file buffering).
618
+ *
619
+ * The destination writable is opened exactly once per file and written in
620
+ * **append mode** (`writable.write(data)` without an explicit `position`).
621
+ * The engine awaits this callback, so chunks for a file arrive strictly in
622
+ * order, making append writes correct for both fresh and resumed
623
+ * downloads. Crucially, this avoids per-write
624
+ * `{ type: 'write', position }` calls, which in Chromium can spawn a fresh
625
+ * `.crswap` swap file per write and leave the target file empty on close —
626
+ * the single `createWritable()` + sequential writes + one `close()` below
627
+ * commits the swap file atomically.
292
628
  * @param {string} path virtual path
293
- * @param {number} offset byte offset
629
+ * @param {number} offset byte offset (informational; writes append)
294
630
  * @param {Uint8Array} data decompressed chunk
295
631
  * @returns {Promise<void>}
296
632
  * @private
297
633
  */
298
634
  async _onWriteChunk(path, offset, data) {
299
- let writable = this._writables.get(path);
300
- if (!writable) {
301
- // Open the destination once per file. A true resume (first chunk at
302
- // offset > 0) keeps the existing prefix on disk; a fresh download opens
303
- // a truncating writable. Always close the writable on completion/abort
304
- // so no orphaned `.crswap` swap files are left behind.
305
- const isResume = offset > 0;
306
- const handle = await this._ensureFileHandle(path);
635
+ if (this._fallback) {
636
+ // Browser-download fallback: collect chunks in memory instead of
637
+ // streaming to disk. Chunks arrive in order, so a plain append works.
638
+ let bufs = this._fallback.buffers.get(path);
639
+ if (!bufs) {
640
+ bufs = [];
641
+ this._fallback.buffers.set(path, bufs);
642
+ this._fallback.order.push(path);
643
+ }
644
+ bufs.push(data);
645
+ return;
646
+ }
647
+ let entry = this._writables.get(path);
648
+ if (!entry) {
649
+ const { dir, name, handle } = await this._ensureFileHandle(path);
307
650
  this._fileHandles.set(path, handle);
308
- writable = await handle.createWritable(
651
+ // A true resume (first chunk at offset > 0) keeps the existing prefix
652
+ // on disk; a fresh download opens a truncating writable.
653
+ const isResume = offset > 0;
654
+ if (!isResume) {
655
+ // Remove any orphaned `.crswap` left behind by a crashed/aborted run
656
+ // so a stale swap file can never shadow the new write.
657
+ await this._removeSwapFile(dir, name);
658
+ }
659
+ const writable = await handle.createWritable(
309
660
  isResume ? { keepExistingData: true } : undefined
310
661
  );
311
- this._writables.set(path, writable);
662
+ entry = { writable, dir, name };
663
+ this._writables.set(path, entry);
312
664
  }
313
- await writable.write({ type: 'write', position: offset, data });
665
+ await entry.writable.write(data);
314
666
  }
315
667
 
316
668
  /**
@@ -320,22 +672,28 @@ export class LibfwClient {
320
672
  * @private
321
673
  */
322
674
  async _onFileCompleted(path) {
675
+ if (this._fallback) {
676
+ // Nothing is open to flush in browser-download mode.
677
+ this._emit({ type: 'fileCompleted', path });
678
+ return;
679
+ }
323
680
  await this._closeWritable(path);
324
681
  this._emit({ type: 'fileCompleted', path });
325
682
  }
326
683
 
327
684
  /**
328
- * Close (and forget) a file's writable, flushing buffered bytes to disk.
685
+ * Close (and forget) a file's writable, atomically committing the swap
686
+ * file to its final name. Best-effort so failure/abort never throws.
329
687
  * @param {string} path virtual path
330
688
  * @returns {Promise<void>}
331
689
  * @private
332
690
  */
333
691
  async _closeWritable(path) {
334
- const writable = this._writables.get(path);
335
- if (writable) {
692
+ const entry = this._writables.get(path);
693
+ if (entry) {
336
694
  this._writables.delete(path);
337
695
  try {
338
- await writable.close();
696
+ await entry.writable.close();
339
697
  } catch {
340
698
  /* best-effort flush on failure/abort */
341
699
  }
@@ -346,7 +704,7 @@ export class LibfwClient {
346
704
  * Resolve (and create, if needed) the file handle for a virtual path,
347
705
  * creating any parent directories along the way.
348
706
  * @param {string} path
349
- * @returns {Promise<FileSystemFileHandle>}
707
+ * @returns {Promise<{dir: FileSystemDirectoryHandle, name: string, handle: FileSystemFileHandle}>}
350
708
  * @private
351
709
  */
352
710
  async _ensureFileHandle(path) {
@@ -358,7 +716,25 @@ export class LibfwClient {
358
716
  for (let i = 0; i < segments.length - 1; i += 1) {
359
717
  dir = await dir.getDirectoryHandle(segments[i], { create: true });
360
718
  }
361
- return dir.getFileHandle(segments[segments.length - 1], { create: true });
719
+ const name = segments[segments.length - 1];
720
+ const handle = await dir.getFileHandle(name, { create: true });
721
+ return { dir, name, handle };
722
+ }
723
+
724
+ /**
725
+ * Delete a leftover Chromium swap file (`.<name>.crswap`) next to a file,
726
+ * ignoring any error (no swap file, or permission denied).
727
+ * @param {FileSystemDirectoryHandle} dir parent directory
728
+ * @param {string} name target file name
729
+ * @returns {Promise<void>}
730
+ * @private
731
+ */
732
+ async _removeSwapFile(dir, name) {
733
+ try {
734
+ await dir.removeEntry(`.${name}.crswap`, { recursive: false });
735
+ } catch {
736
+ /* nothing to clean up */
737
+ }
362
738
  }
363
739
 
364
740
  /**
@@ -368,10 +744,10 @@ export class LibfwClient {
368
744
  * @private
369
745
  */
370
746
  async _flushWritables() {
371
- const pending = [...this._writables.entries()].map(async ([path, writable]) => {
747
+ const pending = [...this._writables.entries()].map(async ([path, entry]) => {
372
748
  this._writables.delete(path);
373
749
  try {
374
- await writable.close();
750
+ await entry.writable.close();
375
751
  } catch {
376
752
  /* best-effort flush */
377
753
  }
@@ -379,6 +755,34 @@ export class LibfwClient {
379
755
  await Promise.allSettled(pending);
380
756
  }
381
757
 
758
+ /**
759
+ * Reconcile persisted download resume offsets with the bytes actually
760
+ * committed to disk.
761
+ *
762
+ * `createWritable()` only commits to the real file on `close()`, so an
763
+ * interrupted download's on-disk length can be ahead of (or behind) the
764
+ * engine's periodically-saved offset. Overwriting each file's stored
765
+ * offset with its real size keeps the append-based resume consistent:
766
+ * the next transfer resumes exactly where the file on disk ends.
767
+ * @returns {Promise<void>}
768
+ * @private
769
+ */
770
+ async _syncResumeOffsets() {
771
+ for (const [path, handle] of this._fileHandles) {
772
+ try {
773
+ const file = await handle.getFile();
774
+ const size = file.size;
775
+ const state = await Idb.loadState(`download:${path}`);
776
+ if (state && typeof state.etag === 'string') {
777
+ await Idb.saveState(`download:${path}`, { ...state, offset: size, size });
778
+ }
779
+ } catch {
780
+ /* best-effort */
781
+ }
782
+ }
783
+ this._fileHandles.clear();
784
+ }
785
+
382
786
  // -------------------------------------------------------------- uploads
383
787
 
384
788
  /**
@@ -544,6 +948,36 @@ export class LibfwClient {
544
948
  totalBytes() {
545
949
  return this._engine ? this._engine.total_bytes() : 0;
546
950
  }
951
+
952
+ // ------------------------------------------------------------- resume store
953
+
954
+ /**
955
+ * Delete persisted resume state (IndexedDB).
956
+ *
957
+ * Pass a direction to wipe only that transfer's state, leaving the other
958
+ * direction intact — the targeted replacement for clearing the whole store
959
+ * before every transfer:
960
+ *
961
+ * - `await client.clearResumeStore('download')` — drop all download state.
962
+ * - `await client.clearResumeStore('upload')` — drop all upload state.
963
+ * - `await client.clearResumeStore()` — wipe everything (whole-store clear).
964
+ *
965
+ * @param {'upload'|'download'} [direction] restrict to one direction
966
+ * @returns {Promise<number>} number of records removed
967
+ */
968
+ async clearResumeStore(direction) {
969
+ if (direction !== undefined && direction !== 'upload' && direction !== 'download') {
970
+ throw new LibfwError(
971
+ `clearResumeStore: expected 'upload' | 'download' | undefined, got ${JSON.stringify(direction)}`,
972
+ 'path'
973
+ );
974
+ }
975
+ if (direction === undefined) {
976
+ await Idb.clear();
977
+ return 0;
978
+ }
979
+ return Idb.clearDirection(direction);
980
+ }
547
981
  }
548
982
 
549
983
  export default LibfwClient;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "libfw-client",
3
- "version": "0.1.2",
3
+ "version": "0.1.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",
@@ -17,6 +17,7 @@
17
17
  },
18
18
  "files": [
19
19
  "index.js",
20
+ "zip.js",
20
21
  "index.d.ts",
21
22
  "pkg/libfw_client.js",
22
23
  "pkg/libfw_client_bg.wasm",
@@ -26,7 +27,7 @@
26
27
  ],
27
28
  "sideEffects": false,
28
29
  "scripts": {
29
- "build:wasm": "wasm-pack build ../crates/libfw-client --target web --out-dir ../sdk/pkg --release",
30
+ "build:wasm": "wasm-pack build ../crates/libfw-client --target web --out-dir ../../sdk/pkg --release",
30
31
  "build:umd": "rollup -c rollup.config.mjs",
31
32
  "build": "npm run build:wasm && npm run build:umd"
32
33
  },
@@ -379,6 +379,10 @@ function __wbg_get_imports() {
379
379
  const ret = new Request(getStringFromWasm0(arg0, arg1), arg2);
380
380
  return ret;
381
381
  }, arguments); },
382
+ __wbg_of_5f1b88183ddb5d94: function(arg0, arg1) {
383
+ const ret = Array.of(arg0, arg1);
384
+ return ret;
385
+ },
382
386
  __wbg_prototypesetcall_4770620bbe4688a0: function(arg0, arg1, arg2) {
383
387
  Uint8Array.prototype.set.call(getArrayU8FromWasm0(arg0, arg1), arg2);
384
388
  },
@@ -393,6 +397,10 @@ function __wbg_get_imports() {
393
397
  __wbg_queueMicrotask_6a09b7bc46549209: function(arg0) {
394
398
  queueMicrotask(arg0);
395
399
  },
400
+ __wbg_race_ac5c7b465abcfa15: function(arg0) {
401
+ const ret = Promise.race(arg0);
402
+ return ret;
403
+ },
396
404
  __wbg_read_8afa15f12a160ef8: function(arg0) {
397
405
  const ret = arg0.read();
398
406
  return ret;
@@ -401,6 +409,10 @@ function __wbg_get_imports() {
401
409
  const ret = Promise.resolve(arg0);
402
410
  return ret;
403
411
  },
412
+ __wbg_setTimeout_725a27c387d005c7: function() { return handleError(function (arg0, arg1, arg2, arg3) {
413
+ const ret = arg0.setTimeout(arg1, arg2, arg3);
414
+ return ret;
415
+ }, arguments); },
404
416
  __wbg_setTimeout_cfa2cf195c3738db: function() { return handleError(function (arg0, arg1, arg2) {
405
417
  const ret = arg0.setTimeout(arg1, arg2);
406
418
  return ret;
@@ -450,7 +462,7 @@ function __wbg_get_imports() {
450
462
  return ret;
451
463
  },
452
464
  __wbindgen_cast_0000000000000001: function(arg0, arg1) {
453
- // Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [Externref], shim_idx: 133, ret: Result(Unit), inner_ret: Some(Result(Unit)) }, mutable: true }) -> Externref`.
465
+ // Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [Externref], shim_idx: 135, ret: Result(Unit), inner_ret: Some(Result(Unit)) }, mutable: true }) -> Externref`.
454
466
  const ret = makeMutClosure(arg0, arg1, wasm_bindgen_1e05ddb24c0b7df4___convert__closures_____invoke___wasm_bindgen_1e05ddb24c0b7df4___JsValue__core_9b3796e30d99ddb7___result__Result_____wasm_bindgen_1e05ddb24c0b7df4___JsError___true_);
455
467
  return ret;
456
468
  },
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.1.2",
5
+ "version": "0.1.3",
6
6
  "license": "MIT",
7
7
  "repository": {
8
8
  "type": "git",
package/zip.js ADDED
@@ -0,0 +1,123 @@
1
+ /**
2
+ * Minimal, dependency-free ZIP writer for the browser-download fallback.
3
+ *
4
+ * When the File System Access API is unavailable, folder downloads are
5
+ * buffered in memory and packed into a single `.zip` archive via
6
+ * {@link createZip}. The archive uses the STORE method (no compression) —
7
+ * the SDK stays dependency-free (no deflate implementation) and CPU cost is
8
+ * negligible. Entries carry the full virtual path (with `/` separators), so
9
+ * extractors recreate the folder structure automatically.
10
+ *
11
+ * @module libfw/zip
12
+ */
13
+
14
+ /** CRC-32 (IEEE 802.3, reflected) table, generated once. */
15
+ const CRC_TABLE = (() => {
16
+ const table = new Uint32Array(256);
17
+ for (let n = 0; n < 256; n += 1) {
18
+ let c = n;
19
+ for (let k = 0; k < 8; k += 1) {
20
+ c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
21
+ }
22
+ table[n] = c >>> 0;
23
+ }
24
+ return table;
25
+ })();
26
+
27
+ /**
28
+ * Compute the CRC-32 checksum of a byte array.
29
+ * @param {Uint8Array} bytes
30
+ * @returns {number} unsigned 32-bit CRC
31
+ */
32
+ function crc32(bytes) {
33
+ let crc = 0xffffffff;
34
+ for (let i = 0; i < bytes.length; i += 1) {
35
+ crc = CRC_TABLE[(crc ^ bytes[i]) & 0xff] ^ (crc >>> 8);
36
+ }
37
+ return (crc ^ 0xffffffff) >>> 0;
38
+ }
39
+
40
+ /**
41
+ * Pack a list of files into a ZIP `Blob` (STORE method).
42
+ *
43
+ * @param {Array<{name: string, data: Uint8Array}>} entries
44
+ * `name` is the virtual path inside the archive (`/` separators);
45
+ * `data` is the file content (may be empty).
46
+ * @returns {Blob} `application/zip` blob
47
+ */
48
+ export function createZip(entries) {
49
+ const encoder = new TextEncoder();
50
+ const body = [];
51
+ const central = [];
52
+ let offset = 0;
53
+
54
+ for (const entry of entries) {
55
+ const nameBytes = encoder.encode(entry.name);
56
+ const data = entry.data;
57
+ const crc = crc32(data);
58
+
59
+ // Local file header (30 bytes + name).
60
+ const local = new Uint8Array(30 + nameBytes.length);
61
+ const dv = new DataView(local.buffer);
62
+ dv.setUint32(0, 0x04034b50, true); // "PK\x03\x04"
63
+ dv.setUint16(4, 20, true); // version needed to extract
64
+ dv.setUint16(6, 0, true); // general purpose flags
65
+ dv.setUint16(8, 0, true); // compression method: STORE
66
+ dv.setUint16(10, 0, true); // last mod time
67
+ dv.setUint16(12, 0x0021, true); // last mod date (1980-01-01)
68
+ dv.setUint32(14, crc, true);
69
+ dv.setUint32(18, data.length, true); // compressed size
70
+ dv.setUint32(22, data.length, true); // uncompressed size
71
+ dv.setUint16(26, nameBytes.length, true);
72
+ dv.setUint16(28, 0, true); // extra field length
73
+ local.set(nameBytes, 30);
74
+
75
+ body.push(local, data);
76
+ central.push({ nameBytes, crc, size: data.length, offset });
77
+ offset += local.length + data.length;
78
+ }
79
+
80
+ // Central directory.
81
+ const dir = [];
82
+ let cdSize = 0;
83
+ for (const c of central) {
84
+ const cd = new Uint8Array(46 + c.nameBytes.length);
85
+ const dv = new DataView(cd.buffer);
86
+ dv.setUint32(0, 0x02014b50, true); // "PK\x01\x02"
87
+ dv.setUint16(4, 20, true); // version made by
88
+ dv.setUint16(6, 20, true); // version needed to extract
89
+ dv.setUint16(8, 0, true); // flags
90
+ dv.setUint16(10, 0, true); // method: STORE
91
+ dv.setUint16(12, 0, true); // mod time
92
+ dv.setUint16(14, 0x0021, true); // mod date
93
+ dv.setUint32(16, c.crc, true);
94
+ dv.setUint32(20, c.size, true); // compressed size
95
+ dv.setUint32(24, c.size, true); // uncompressed size
96
+ dv.setUint16(28, c.nameBytes.length, true);
97
+ dv.setUint16(30, 0, true); // extra field length
98
+ dv.setUint16(32, 0, true); // comment length
99
+ dv.setUint16(34, 0, true); // disk number start
100
+ dv.setUint16(36, 0, true); // internal file attributes
101
+ dv.setUint32(38, 0, true); // external file attributes
102
+ dv.setUint32(42, c.offset, true); // local header offset
103
+ cd.set(c.nameBytes, 46);
104
+ dir.push(cd);
105
+ cdSize += cd.length;
106
+ }
107
+
108
+ const cdOffset = offset;
109
+
110
+ // End of central directory record (22 bytes).
111
+ const eocd = new Uint8Array(22);
112
+ const edv = new DataView(eocd.buffer);
113
+ edv.setUint32(0, 0x06054b50, true); // "PK\x05\x06"
114
+ edv.setUint16(4, 0, true); // disk number
115
+ edv.setUint16(6, 0, true); // disk with central dir
116
+ edv.setUint16(8, central.length, true); // entries on this disk
117
+ edv.setUint16(10, central.length, true); // total entries
118
+ edv.setUint32(12, cdSize, true); // central dir size
119
+ edv.setUint32(16, cdOffset, true); // central dir offset
120
+ edv.setUint16(20, 0, true); // comment length
121
+
122
+ return new Blob([...body, ...dir, eocd], { type: 'application/zip' });
123
+ }