libfw-client 0.1.1 → 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
  }
@@ -94,6 +116,16 @@ export declare class LibfwClient {
94
116
  */
95
117
  downloadFolder(token: string, dirPath?: string): Promise<number>;
96
118
 
119
+ /**
120
+ * Download a single file from the server at `filePath` into a user-selected
121
+ * local directory.
122
+ *
123
+ * @param token bearer token
124
+ * @param filePath virtual server path of the file to download
125
+ * @returns total bytes written
126
+ */
127
+ downloadFile(token: string, filePath: string): Promise<number>;
128
+
97
129
  /**
98
130
  * Upload files to the server.
99
131
  *
@@ -132,6 +164,17 @@ export declare class LibfwClient {
132
164
 
133
165
  /** Total bytes to transfer. */
134
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>;
135
178
  }
136
179
 
137
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
  };
@@ -158,12 +236,17 @@ export class LibfwClient {
158
236
  this._dirHandle = null;
159
237
  /** @type {Map<string, FileSystemFileHandle>} path → file handle */
160
238
  this._fileHandles = new Map();
161
- /** @type {Map<string, FileSystemWritableFileStream>} path → writable stream */
239
+ /** @type {Map<string, FileSystemWritableFileStream>} path → open writable stream */
162
240
  this._writables = new Map();
163
241
  /** @type {Map<string, File>} path → File (upload) */
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
- onFileCompleted: (path) => this._emit({ type: 'fileCompleted', path }),
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,41 +428,283 @@ 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
  /**
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.
442
+ *
443
+ * @param {string} token bearer token
444
+ * @param {string} filePath virtual server path of the file to download
445
+ * @returns {Promise<number>} total bytes transferred
446
+ * @throws {LibfwError}
447
+ */
448
+ async downloadFile(token, filePath) {
449
+ const engine = await this._ready();
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
+ }
454
+ this._dirHandle = await window.showDirectoryPicker();
455
+ this._fileHandles.clear();
456
+ try {
457
+ return await engine.download_file(this._options.baseUrl, token, filePath);
458
+ } catch (err) {
459
+ throw toLibfwError(err);
460
+ } finally {
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
262
479
  * @param {string} path virtual path
263
- * @param {number} offset byte offset
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');
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';
583
+ }
584
+
585
+ /**
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.
628
+ * @param {string} path virtual path
629
+ * @param {number} offset byte offset (informational; writes append)
264
630
  * @param {Uint8Array} data decompressed chunk
265
631
  * @returns {Promise<void>}
266
632
  * @private
267
633
  */
268
634
  async _onWriteChunk(path, offset, data) {
269
- let writable = this._writables.get(path);
270
- if (!writable) {
271
- 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);
272
650
  this._fileHandles.set(path, handle);
273
- // Only a true resume (first chunk arrives at offset > 0) needs to
274
- // keep the existing file's prefix. For a fresh download we open the
275
- // writable WITHOUT keepExistingData, which truncates and lets us
276
- // write sequentially. In real Chromium the keepExistingData +
277
- // position-write path can leave empty target files behind a trail of
278
- // orphaned `.crswap` swap files, so we avoid it unless required.
651
+ // A true resume (first chunk at offset > 0) keeps the existing prefix
652
+ // on disk; a fresh download opens a truncating writable.
279
653
  const isResume = offset > 0;
280
- writable = await handle.createWritable(
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(
281
660
  isResume ? { keepExistingData: true } : undefined
282
661
  );
283
- this._writables.set(path, writable);
662
+ entry = { writable, dir, name };
663
+ this._writables.set(path, entry);
664
+ }
665
+ await entry.writable.write(data);
666
+ }
667
+
668
+ /**
669
+ * Close the destination writable once a file's transfer completes.
670
+ * @param {string} path virtual path
671
+ * @returns {Promise<void>}
672
+ * @private
673
+ */
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
+ }
680
+ await this._closeWritable(path);
681
+ this._emit({ type: 'fileCompleted', path });
682
+ }
683
+
684
+ /**
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.
687
+ * @param {string} path virtual path
688
+ * @returns {Promise<void>}
689
+ * @private
690
+ */
691
+ async _closeWritable(path) {
692
+ const entry = this._writables.get(path);
693
+ if (entry) {
694
+ this._writables.delete(path);
695
+ try {
696
+ await entry.writable.close();
697
+ } catch {
698
+ /* best-effort flush on failure/abort */
699
+ }
284
700
  }
285
- await writable.write({ type: 'write', position: offset, data });
286
701
  }
287
702
 
288
703
  /**
289
704
  * Resolve (and create, if needed) the file handle for a virtual path,
290
705
  * creating any parent directories along the way.
291
706
  * @param {string} path
292
- * @returns {Promise<FileSystemFileHandle>}
707
+ * @returns {Promise<{dir: FileSystemDirectoryHandle, name: string, handle: FileSystemFileHandle}>}
293
708
  * @private
294
709
  */
295
710
  async _ensureFileHandle(path) {
@@ -301,20 +716,73 @@ export class LibfwClient {
301
716
  for (let i = 0; i < segments.length - 1; i += 1) {
302
717
  dir = await dir.getDirectoryHandle(segments[i], { create: true });
303
718
  }
304
- 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 };
305
722
  }
306
723
 
307
724
  /**
308
- * Close all open writable streams (flush to disk).
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
+ }
738
+ }
739
+
740
+ /**
741
+ * Close all still-open writable streams (flush to disk). Called on
742
+ * success, failure or cancellation of a transfer.
309
743
  * @returns {Promise<void>}
310
744
  * @private
311
745
  */
312
746
  async _flushWritables() {
313
- const pending = [...this._writables.values()].map((w) => w.close().catch(() => {}));
314
- this._writables.clear();
747
+ const pending = [...this._writables.entries()].map(async ([path, entry]) => {
748
+ this._writables.delete(path);
749
+ try {
750
+ await entry.writable.close();
751
+ } catch {
752
+ /* best-effort flush */
753
+ }
754
+ });
315
755
  await Promise.allSettled(pending);
316
756
  }
317
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
+
318
786
  // -------------------------------------------------------------- uploads
319
787
 
320
788
  /**
@@ -480,6 +948,36 @@ export class LibfwClient {
480
948
  totalBytes() {
481
949
  return this._engine ? this._engine.total_bytes() : 0;
482
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
+ }
483
981
  }
484
982
 
485
983
  export default LibfwClient;
package/package.json CHANGED
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "name": "libfw-client",
3
- "version": "0.1.1",
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
- "license": "MIT OR Apache-2.0",
5
+ "license": "MIT",
6
6
  "type": "module",
7
7
  "main": "index.js",
8
8
  "module": "index.js",
@@ -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
  },
@@ -15,6 +15,12 @@ export class LibfwClient {
15
15
  * Bytes transferred so far.
16
16
  */
17
17
  done_bytes(): number;
18
+ /**
19
+ * Download a single file at `file_path` into the chosen local directory.
20
+ *
21
+ * Resolves with the number of bytes written.
22
+ */
23
+ download_file(base_url: string, token: string, file_path: string): Promise<any>;
18
24
  /**
19
25
  * Download every file under the virtual `dirPath` (empty = root).
20
26
  *
@@ -77,6 +83,7 @@ export interface InitOutput {
77
83
  readonly js_option_string: (a: any, b: number, c: number) => [number, number];
78
84
  readonly libfwclient_cancel: (a: number) => void;
79
85
  readonly libfwclient_done_bytes: (a: number) => number;
86
+ readonly libfwclient_download_file: (a: number, b: number, c: number, d: number, e: number, f: number, g: number) => any;
80
87
  readonly libfwclient_download_folder: (a: number, b: number, c: number, d: number, e: number, f: number, g: number) => any;
81
88
  readonly libfwclient_has_callbacks: (a: number) => number;
82
89
  readonly libfwclient_new: (a: any) => number;
@@ -28,6 +28,25 @@ export class LibfwClient {
28
28
  const ret = wasm.libfwclient_done_bytes(this.__wbg_ptr);
29
29
  return ret;
30
30
  }
31
+ /**
32
+ * Download a single file at `file_path` into the chosen local directory.
33
+ *
34
+ * Resolves with the number of bytes written.
35
+ * @param {string} base_url
36
+ * @param {string} token
37
+ * @param {string} file_path
38
+ * @returns {Promise<any>}
39
+ */
40
+ download_file(base_url, token, file_path) {
41
+ const ptr0 = passStringToWasm0(base_url, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc);
42
+ const len0 = WASM_VECTOR_LEN;
43
+ const ptr1 = passStringToWasm0(token, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc);
44
+ const len1 = WASM_VECTOR_LEN;
45
+ const ptr2 = passStringToWasm0(file_path, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc);
46
+ const len2 = WASM_VECTOR_LEN;
47
+ const ret = wasm.libfwclient_download_file(this.__wbg_ptr, ptr0, len0, ptr1, len1, ptr2, len2);
48
+ return ret;
49
+ }
31
50
  /**
32
51
  * Download every file under the virtual `dirPath` (empty = root).
33
52
  *
@@ -360,6 +379,10 @@ function __wbg_get_imports() {
360
379
  const ret = new Request(getStringFromWasm0(arg0, arg1), arg2);
361
380
  return ret;
362
381
  }, arguments); },
382
+ __wbg_of_5f1b88183ddb5d94: function(arg0, arg1) {
383
+ const ret = Array.of(arg0, arg1);
384
+ return ret;
385
+ },
363
386
  __wbg_prototypesetcall_4770620bbe4688a0: function(arg0, arg1, arg2) {
364
387
  Uint8Array.prototype.set.call(getArrayU8FromWasm0(arg0, arg1), arg2);
365
388
  },
@@ -374,6 +397,10 @@ function __wbg_get_imports() {
374
397
  __wbg_queueMicrotask_6a09b7bc46549209: function(arg0) {
375
398
  queueMicrotask(arg0);
376
399
  },
400
+ __wbg_race_ac5c7b465abcfa15: function(arg0) {
401
+ const ret = Promise.race(arg0);
402
+ return ret;
403
+ },
377
404
  __wbg_read_8afa15f12a160ef8: function(arg0) {
378
405
  const ret = arg0.read();
379
406
  return ret;
@@ -382,6 +409,10 @@ function __wbg_get_imports() {
382
409
  const ret = Promise.resolve(arg0);
383
410
  return ret;
384
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); },
385
416
  __wbg_setTimeout_cfa2cf195c3738db: function() { return handleError(function (arg0, arg1, arg2) {
386
417
  const ret = arg0.setTimeout(arg1, arg2);
387
418
  return ret;
@@ -431,7 +462,7 @@ function __wbg_get_imports() {
431
462
  return ret;
432
463
  },
433
464
  __wbindgen_cast_0000000000000001: function(arg0, arg1) {
434
- // Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [Externref], shim_idx: 128, 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`.
435
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_);
436
467
  return ret;
437
468
  },
Binary file
@@ -5,6 +5,7 @@ export const __wbg_libfwclient_free: (a: number, b: number) => void;
5
5
  export const js_option_string: (a: any, b: number, c: number) => [number, number];
6
6
  export const libfwclient_cancel: (a: number) => void;
7
7
  export const libfwclient_done_bytes: (a: number) => number;
8
+ export const libfwclient_download_file: (a: number, b: number, c: number, d: number, e: number, f: number, g: number) => any;
8
9
  export const libfwclient_download_folder: (a: number, b: number, c: number, d: number, e: number, f: number, g: number) => any;
9
10
  export const libfwclient_has_callbacks: (a: number) => number;
10
11
  export const libfwclient_new: (a: any) => number;
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.1",
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
+ }