libfw-client 0.1.2 → 0.1.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
@@ -11,11 +11,18 @@ import { LibfwClient } from 'libfw-client';
11
11
  const client = new LibfwClient({
12
12
  baseUrl: '/api', // where libfw-server routes are mounted
13
13
  concurrency: 4, // max parallel file transfers
14
+ uploadWindow: 8, // in-flight chunks per single file upload (raise to
15
+ // reduce upload stutter on high-latency links)
16
+ downloadWindow: 4, // in-flight byte-range GETs per single file download
17
+ // (tus-style parallel download: one file's throughput
18
+ // isn't bounded by a single connection's RTT)
14
19
  compress: true, // zrip streaming compression
15
20
  onEvent: (e) => console.log(e), // { type: 'progress', done, total }
16
21
  });
17
22
 
18
- // Download a whole folder (showDirectoryPicker, preserves structure)
23
+ // Download a whole folder. Uses showDirectoryPicker when the File System
24
+ // Access API is available; otherwise the folder is zipped and saved via a
25
+ // traditional browser download — no feature detection needed by the caller.
19
26
  await client.downloadFolder('your_token_here');
20
27
 
21
28
  // Upload a FileList
@@ -33,14 +40,28 @@ client.cancel();
33
40
 
34
41
  ## How it works
35
42
 
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()`.
43
+ - `downloadFolder(token, dirPath?)` / `downloadFile(token, filePath)` the
44
+ engine lists (for folders) and downloads each file with `Range`/`If-Range`
45
+ resume, decompresses the zrip stream, and pushes `Uint8Array` chunks to the
46
+ SDK. Large files use the **tus-style parallel path**: `downloadWindow`
47
+ concurrent byte-range GETs, reordered in memory so the SDK still receives
48
+ bytes strictly in order (append-mode `createWritable()`, no `.crswap`
49
+ churn), with per-chunk independent retries. With the File System Access API
50
+ the SDK streams them to disk via `fileHandle.createWritable()`; without it
51
+ (or with `downloadMode: 'browser'`) the SDK buffers the chunks and saves the
52
+ result through a traditional browser download — a single file as-is, a
53
+ folder packed into a `.zip`.
40
54
  - `upload(token, files?)` — the engine slices each file into fixed-size
41
55
  chunks, reads them via `readFile`, compresses each chunk into one zstd
42
- frame, and POSTs them with `x-libfw-offset` for server-side resume
43
- validation.
56
+ frame, and POSTs them with an absolute `x-libfw-offset` into a shared
57
+ per-session temp file. Up to `uploadWindow` chunks of one file are kept in
58
+ flight concurrently (independent of the cross-file `concurrency`), so a
59
+ high-latency link stays saturated. Uploads are **tus-style
60
+ verify-then-complete**: the server is the source of truth — the client
61
+ probes the byte ranges the server actually persisted and re-sends only the
62
+ still-missing blocks, filling gaps a lost response may have left (and
63
+ re-probing + refilling if a commit fails) before the final `x-libfw-final`
64
+ request merges the temp into place.
44
65
  - Resume state (`etag`, `offset`, `size`) is persisted per path in
45
66
  IndexedDB and re-validated on every retry.
46
67
  - Pause/resume/cancel drive the WASM state machine
@@ -61,6 +82,7 @@ The resulting package contains:
61
82
  ```
62
83
  pkg/ wasm-pack output (wasm + wasm-bindgen web glue)
63
84
  index.js ESM SDK
85
+ zip.js dependency-free ZIP writer (browser-download fallback)
64
86
  index.d.ts TypeScript types
65
87
  dist/libfw-client.umd.js UMD bundle (after build:umd)
66
88
  ```
@@ -68,8 +90,24 @@ dist/libfw-client.umd.js UMD bundle (after build:umd)
68
90
  ## API
69
91
 
70
92
  - `new LibfwClient(options?)`
93
+ - `downloadWindow: number` (default `4`) — in-flight byte-range GETs per
94
+ single file download; `1` disables parallelism.
95
+ - `downloadChunkSize: number` (default `262144`, 256 KiB) — byte range size
96
+ for parallel downloads; the engine reorders in-flight chunks in memory
97
+ (worst case ≈ `downloadWindow * downloadChunkSize` bytes) so the SDK
98
+ still receives data in order.
99
+ - `downloadMode: 'auto' | 'fs' | 'browser'` (default `'auto'`) — `'fs'`
100
+ streams downloads through the File System Access API; `'browser'` buffers
101
+ and triggers a traditional browser download (folders become `.zip`);
102
+ `'auto'` uses `'fs'` when the API exists and falls back to `'browser'`.
103
+ - `maxFallbackBytes: number` (default `536870912`, 512 MiB) — memory cap
104
+ for the in-memory `'browser'` fallback. File sizes are pre-checked
105
+ against it before buffering; a download that would exceed it rejects
106
+ with a `too-large` `LibfwError` instead of risking an OOM. `0` disables.
71
107
  - `downloadFolder(token, dirPath?) → Promise<number>`
108
+ - `downloadFile(token, filePath) → Promise<number>`
72
109
  - `upload(token, files?) → Promise<number>`
110
+ - `clearResumeStore(direction?) → Promise<number>`
73
111
  - `pause()`, `resume()`, `cancel()`
74
112
  - `state()`, `progress()`, `doneBytes()`, `totalBytes()`
75
113
  - 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 {
@@ -51,8 +52,30 @@ export interface LibfwEvent {
51
52
  export interface LibfwClientOptions {
52
53
  /** Base URL the libfw server routes are mounted under. Default `''`. */
53
54
  baseUrl?: string;
54
- /** Max concurrent file transfers. Default `4`. */
55
+ /** Max concurrently-transferring files. Default `4`. */
55
56
  concurrency?: number;
57
+ /**
58
+ * In-flight chunk window for a single file's upload, independent of
59
+ * `concurrency`. A higher value keeps high-latency links saturated and
60
+ * reduces upload stutter; keep it within your server's connection limit
61
+ * (~6 for HTTP/1.1). Default `8`.
62
+ */
63
+ uploadWindow?: number;
64
+ /**
65
+ * In-flight byte-range window for a single file's download. Large files
66
+ * are fetched as `downloadWindow` concurrent `Range` GETs (tus-style
67
+ * parallel transfer), so a single file's throughput is bounded by
68
+ * bandwidth instead of one connection's `chunkSize / RTT` on high-latency
69
+ * links. `1` disables parallelism. Default `4`.
70
+ */
71
+ downloadWindow?: number;
72
+ /**
73
+ * Byte range size for parallel downloads. Smaller than the upload chunk
74
+ * on purpose: the engine reorders in-flight chunks in memory (worst case
75
+ * ≈ `downloadWindow * downloadChunkSize` bytes) so the SDK still receives
76
+ * data strictly in order. Default `262144` (256 KiB).
77
+ */
78
+ downloadChunkSize?: number;
56
79
  /** Negotiate zrip compression. Default `true`. */
57
80
  compress?: boolean;
58
81
  /** Upload chunk size in bytes. Default 2 MiB. */
@@ -65,6 +88,27 @@ export interface LibfwClientOptions {
65
88
  maxRetryDelayMs?: number;
66
89
  /** Per-request timeout (ms). Default `60000`. */
67
90
  timeoutMs?: number;
91
+ /**
92
+ * Explicit URL of `libfw_client_bg.wasm`. When omitted it is resolved
93
+ * automatically for both ESM and classic-`<script>`/UMD consumers.
94
+ */
95
+ wasmUrl?: string;
96
+ /**
97
+ * How downloads reach the user's disk.
98
+ *
99
+ * - `'fs'` — File System Access API (`showDirectoryPicker`), streaming to disk.
100
+ * - `'browser'` — buffer each file, then trigger a traditional browser
101
+ * download; folders are packed into a `.zip` archive.
102
+ * - `'auto'` (default) — `'fs'` when the API exists, else `'browser'`.
103
+ */
104
+ downloadMode?: 'auto' | 'fs' | 'browser';
105
+ /**
106
+ * Memory cap (bytes) for the in-memory `'browser'` download fallback.
107
+ * File sizes are pre-checked against it before buffering; a download that
108
+ * would exceed it is rejected with a `too-large` error. `0` disables the
109
+ * limit. Default `536870912` (512 MiB).
110
+ */
111
+ maxFallbackBytes?: number;
68
112
  /** Optional progress/state listener. */
69
113
  onEvent?: (event: LibfwEvent) => void;
70
114
  }
@@ -142,6 +186,17 @@ export declare class LibfwClient {
142
186
 
143
187
  /** Total bytes to transfer. */
144
188
  totalBytes(): number;
189
+
190
+ /**
191
+ * Delete persisted resume state (IndexedDB).
192
+ *
193
+ * Pass a direction to wipe only that transfer's state (`'download'` or
194
+ * `'upload'`); omit it to clear the whole store.
195
+ *
196
+ * @param direction restrict the wipe to one transfer direction
197
+ * @returns number of records removed
198
+ */
199
+ clearResumeStore(direction?: 'upload' | 'download'): Promise<number>;
145
200
  }
146
201
 
147
202
  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,17 +168,62 @@ 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]
129
190
  * @param {string} [options.baseUrl=''] base URL the server routes are mounted under
130
- * @param {number} [options.concurrency=4] max concurrent file transfers
191
+ * @param {number} [options.concurrency=4] max concurrently-transferring files
192
+ * @param {number} [options.uploadWindow=8] in-flight chunk window for a
193
+ * single file's upload; independent of `concurrency`, keeps a
194
+ * high-latency link saturated (raise it to reduce upload stutter;
195
+ * keep within your server's connection limit, ~6 for HTTP/1.1)
196
+ * @param {number} [options.downloadWindow=4] in-flight byte-range window
197
+ * for a single file's download. Large files are fetched as
198
+ * `downloadWindow` concurrent `Range` GETs (tus-style parallel
199
+ * transfer), so a single file's throughput is bounded by bandwidth
200
+ * instead of one connection's `chunkSize / RTT` on high-latency
201
+ * links. `1` disables parallelism (sequential downloads).
202
+ * @param {number} [options.downloadChunkSize=262144] byte range size for
203
+ * parallel downloads (256 KiB default). Smaller than the upload
204
+ * chunk on purpose: the engine reorders in-flight chunks in memory
205
+ * (worst case ≈ `downloadWindow * downloadChunkSize` bytes) so the
206
+ * SDK still receives data strictly in order.
131
207
  * @param {boolean} [options.compress=true] negotiate zrip compression
132
208
  * @param {number} [options.chunkSize=2097152] upload chunk size in bytes
133
209
  * @param {number} [options.maxRetries=3] retries per chunk/file before failing
134
210
  * @param {number} [options.baseRetryDelayMs=500] initial backoff (ms)
135
211
  * @param {number} [options.maxRetryDelayMs=30000] backoff ceiling (ms)
136
212
  * @param {number} [options.timeoutMs=60000] per-request timeout (ms)
213
+ * @param {string} [options.wasmUrl] explicit URL of `libfw_client_bg.wasm`;
214
+ * when omitted it is resolved automatically for both ESM and
215
+ * classic-`<script>`/UMD consumers (see {@link LibfwClient#_wasmUrl})
216
+ * @param {'auto'|'fs'|'browser'} [options.downloadMode='auto'] how downloads
217
+ * reach the user's disk: `'fs'` uses the File System Access API
218
+ * (`showDirectoryPicker`); `'browser'` buffers each file and triggers
219
+ * a traditional browser download (folders are packed into a `.zip`);
220
+ * `'auto'` picks `'fs'` when the API exists, otherwise `'browser'`.
221
+ * @param {number} [options.maxFallbackBytes=536870912] memory cap (bytes)
222
+ * for the in-memory `'browser'` download fallback. Each file's size
223
+ * (and the cumulative buffered total) is pre-checked against it
224
+ * before any bytes are buffered; a download that would exceed it is
225
+ * rejected with a `too-large` error instead of risking an OOM.
226
+ * `0` disables the limit.
137
227
  * @param {(event: {type: string, done: number, total: number, path?: string, error?: string}) => void} [options.onEvent]
138
228
  * optional progress/state listener
139
229
  */
@@ -141,12 +231,18 @@ export class LibfwClient {
141
231
  this._options = {
142
232
  baseUrl: '',
143
233
  concurrency: 4,
234
+ uploadWindow: 8,
235
+ downloadWindow: 4,
236
+ downloadChunkSize: 256 * 1024,
144
237
  compress: true,
145
238
  chunkSize: 2 * 1024 * 1024,
146
239
  maxRetries: 3,
147
240
  baseRetryDelayMs: 500,
148
241
  maxRetryDelayMs: 30000,
149
242
  timeoutMs: 60000,
243
+ wasmUrl: null,
244
+ downloadMode: 'auto',
245
+ maxFallbackBytes: 512 * 1024 * 1024,
150
246
  onEvent: null,
151
247
  ...options,
152
248
  };
@@ -164,6 +260,11 @@ export class LibfwClient {
164
260
  this._uploadFiles = new Map();
165
261
  /** @type {Array<{path:string,size:number,mtime:number}>} upload plan */
166
262
  this._uploadPlan = [];
263
+ /**
264
+ * Active browser-download fallback state, or `null`.
265
+ * @type {{isFolder:boolean, buffers:Map<string,Uint8Array[]>, order:string[], sizes:Map<string,number>}|null}
266
+ */
267
+ this._fallback = null;
167
268
  }
168
269
 
169
270
  // ------------------------------------------------------------------ setup
@@ -176,7 +277,12 @@ export class LibfwClient {
176
277
  async _ready() {
177
278
  if (this._engine) return this._engine;
178
279
  if (!this._initPromise) {
179
- this._initPromise = init().catch((err) => {
280
+ // Always pass the .wasm location explicitly so the generated glue's
281
+ // ESM-only `import.meta.url` fallback is never exercised — that
282
+ // fallback throws when the SDK is bundled for a classic <script>/UMD
283
+ // context. See _wasmUrl() for how the URL is resolved. The
284
+ // `{ module_or_path }` object form is the glue's non-deprecated API.
285
+ this._initPromise = init({ module_or_path: this._wasmUrl() }).catch((err) => {
180
286
  this._initPromise = null;
181
287
  throw toLibfwError(err);
182
288
  });
@@ -184,6 +290,9 @@ export class LibfwClient {
184
290
  await this._initPromise;
185
291
  const engine = new WasmEngine({
186
292
  concurrency: this._options.concurrency,
293
+ uploadWindow: this._options.uploadWindow,
294
+ downloadWindow: this._options.downloadWindow,
295
+ downloadChunkSize: this._options.downloadChunkSize,
187
296
  compress: this._options.compress,
188
297
  chunkSize: this._options.chunkSize,
189
298
  maxRetries: this._options.maxRetries,
@@ -196,6 +305,31 @@ export class LibfwClient {
196
305
  return engine;
197
306
  }
198
307
 
308
+ /**
309
+ * Resolve the `.wasm` file URL without relying on `import.meta` (which is
310
+ * ESM-only and a parse error in a classic `<script>`).
311
+ *
312
+ * Order: explicit `wasmUrl` option → classic-script `document.currentScript`
313
+ * → ESM `import.meta.url`. The `wasmUrl` option is the escape hatch for
314
+ * deployments where neither auto-detection applies.
315
+ * @returns {string|URL}
316
+ * @private
317
+ */
318
+ _wasmUrl() {
319
+ if (this._options.wasmUrl) return this._options.wasmUrl;
320
+ // Classic <script> / UMD: the bundle's own <script src> (captured at load
321
+ // time in BUNDLE_SCRIPT_SRC) tells us where the sibling `.wasm` lives.
322
+ if (BUNDLE_SCRIPT_SRC) {
323
+ return new URL('libfw_client_bg.wasm', BUNDLE_SCRIPT_SRC);
324
+ }
325
+ // ESM: relative to this module.
326
+ if (typeof import.meta !== 'undefined' && import.meta.url) {
327
+ return new URL('./pkg/libfw_client_bg.wasm', import.meta.url);
328
+ }
329
+ // Last resort: same-origin relative path.
330
+ return 'libfw_client_bg.wasm';
331
+ }
332
+
199
333
  /**
200
334
  * Build the callbacks object handed to the WASM engine.
201
335
  * @returns {object}
@@ -203,12 +337,42 @@ export class LibfwClient {
203
337
  */
204
338
  _makeCallbacks() {
205
339
  return {
206
- onFileStart: (path, size) => this._emit({ type: 'fileStart', path, done: 0, total: size }),
340
+ onFileStart: (path, size) => {
341
+ if (this._fallback) {
342
+ this._fallback.sizes.set(path, size);
343
+ // Pre-check the in-memory cap BEFORE any bytes are buffered so an
344
+ // oversized download is rejected instead of exhausting memory.
345
+ const max = this._maxFallbackBytes();
346
+ if (max > 0) {
347
+ if (size > max) {
348
+ throw new LibfwError(
349
+ `file too large for browser download (${size} > ${max} bytes): ${path}`,
350
+ 'too-large'
351
+ );
352
+ }
353
+ this._fallback.total += size;
354
+ if (this._fallback.total > max) {
355
+ throw new LibfwError(
356
+ `browser download would buffer more than the ${max}-byte in-memory limit`,
357
+ 'too-large'
358
+ );
359
+ }
360
+ }
361
+ }
362
+ this._emit({ type: 'fileStart', path, done: 0, total: size });
363
+ },
207
364
  onWriteChunk: (path, offset, data) => this._onWriteChunk(path, offset, data),
208
365
  onFileCompleted: (path) => this._onFileCompleted(path),
209
366
  onProgress: (done, total) => this._emit({ type: 'progress', done, total }),
210
367
  loadState: (direction, path) => Idb.loadState(`${direction}:${path}`),
211
- saveState: (direction, path, state) => Idb.saveState(`${direction}:${path}`, state),
368
+ saveState: (direction, path, state) => {
369
+ // The in-memory browser-download fallback never commits bytes to
370
+ // disk, so a persisted download offset would be a phantom that
371
+ // poisons a later FS-API resume. Skip persisting download state
372
+ // while a fallback transfer is active.
373
+ if (direction === 'download' && this._fallback) return Promise.resolve();
374
+ return Idb.saveState(`${direction}:${path}`, state);
375
+ },
212
376
  getFileList: () => this._getFileList(),
213
377
  readFile: (path, offset, length) => this._readFile(path, offset, length),
214
378
  log: (msg) => {
@@ -228,24 +392,54 @@ export class LibfwClient {
228
392
  }
229
393
  }
230
394
 
395
+ /**
396
+ * Whether the File System Access API is available in this browser.
397
+ * @returns {boolean}
398
+ * @private
399
+ */
400
+ _supportsFsAccess() {
401
+ return (
402
+ typeof window !== 'undefined' &&
403
+ typeof window.showDirectoryPicker === 'function' &&
404
+ typeof FileSystemFileHandle !== 'undefined' &&
405
+ typeof FileSystemDirectoryHandle !== 'undefined'
406
+ );
407
+ }
408
+
409
+ /**
410
+ * Resolve the effective download mode from the `downloadMode` option:
411
+ * an explicit `'fs'`/`'browser'` wins; `'auto'` falls back to the browser
412
+ * download when the File System Access API is missing.
413
+ * @returns {'fs'|'browser'}
414
+ * @private
415
+ */
416
+ _effectiveMode() {
417
+ const mode = this._options.downloadMode || 'auto';
418
+ if (mode === 'fs' || mode === 'browser') return mode;
419
+ return this._supportsFsAccess() ? 'fs' : 'browser';
420
+ }
421
+
231
422
  // ------------------------------------------------------------ downloads
232
423
 
233
424
  /**
234
- * Download a whole folder from the server into a local directory chosen
235
- * by the user via `showDirectoryPicker()`.
425
+ * Download a whole folder from the server.
236
426
  *
237
- * Folder structure (including nested directories) is preserved; bytes
238
- * are streamed to disk through `createWritable({ type: 'write' })`.
427
+ * With the File System Access API available the folder is streamed into a
428
+ * user-selected local directory (`showDirectoryPicker`), preserving the
429
+ * structure through one `createWritable()` per file. Without FS API (or
430
+ * with `downloadMode: 'browser'`) the folder is buffered in memory, packed
431
+ * into a `.zip` and saved via a traditional browser download — no manual
432
+ * feature detection needed by the caller.
239
433
  *
240
434
  * @param {string} token bearer token
241
435
  * @param {string} [dirPath=''] virtual server path to download (root by default)
242
- * @returns {Promise<number>} total bytes written
436
+ * @returns {Promise<number>} total bytes transferred
243
437
  * @throws {LibfwError}
244
438
  */
245
439
  async downloadFolder(token, dirPath = '') {
246
440
  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');
441
+ if (this._effectiveMode() === 'browser') {
442
+ return this._downloadViaBrowser(engine, token, dirPath, true);
249
443
  }
250
444
  this._dirHandle = await window.showDirectoryPicker();
251
445
  this._fileHandles.clear();
@@ -255,24 +449,29 @@ export class LibfwClient {
255
449
  throw toLibfwError(err);
256
450
  } finally {
257
451
  await this._flushWritables();
452
+ await this._syncResumeOffsets();
258
453
  }
259
454
  }
260
455
 
261
456
  /**
262
- * Download a single file from the server at `filePath` into a local
263
- * directory chosen via `showDirectoryPicker()`.
457
+ * Download a single file from the server at `filePath`.
458
+ *
459
+ * With the File System Access API available the file is streamed into the
460
+ * directory chosen via `showDirectoryPicker()`. Without FS API (or with
461
+ * `downloadMode: 'browser'`) the file is buffered and saved through a
462
+ * traditional browser download.
264
463
  *
265
464
  * @param {string} token bearer token
266
465
  * @param {string} filePath virtual server path of the file to download
267
- * @returns {Promise<number>} total bytes written
466
+ * @returns {Promise<number>} total bytes transferred
268
467
  * @throws {LibfwError}
269
468
  */
270
469
  async downloadFile(token, filePath) {
271
470
  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
471
  if (!filePath) throw new LibfwError('downloadFile requires a file path', 'path');
472
+ if (this._effectiveMode() === 'browser') {
473
+ return this._downloadViaBrowser(engine, token, filePath, false);
474
+ }
276
475
  this._dirHandle = await window.showDirectoryPicker();
277
476
  this._fileHandles.clear();
278
477
  try {
@@ -281,36 +480,210 @@ export class LibfwClient {
281
480
  throw toLibfwError(err);
282
481
  } finally {
283
482
  await this._flushWritables();
483
+ await this._syncResumeOffsets();
484
+ }
485
+ }
486
+
487
+ /**
488
+ * Buffer-chunk fallback download used when the File System Access API is
489
+ * unavailable (or `downloadMode: 'browser'`).
490
+ *
491
+ * `onWriteChunk` chunks are collected per path in memory (the engine keeps
492
+ * calling them in order). When the transfer finishes: a single file is
493
+ * emitted as a `Blob` and saved via a normal browser download; a folder is
494
+ * packed into a `.zip` (STORE method) and downloaded. Progress/state events
495
+ * keep flowing as usual. Note this buffers the whole transfer in memory —
496
+ * the cost of not having FS API to stream to disk.
497
+ *
498
+ * @param {WasmEngine} engine
499
+ * @param {string} token
500
+ * @param {string} path virtual path
501
+ * @param {boolean} isFolder
502
+ * @returns {Promise<number>} total bytes transferred
503
+ * @private
504
+ */
505
+ async _downloadViaBrowser(engine, token, path, isFolder) {
506
+ this._fallback = { isFolder, buffers: new Map(), order: [], sizes: new Map(), total: 0 };
507
+ try {
508
+ const total = isFolder
509
+ ? await engine.download_folder(this._options.baseUrl, token, path)
510
+ : await engine.download_file(this._options.baseUrl, token, path);
511
+ const { buffers, order, sizes } = this._fallback;
512
+ if (isFolder) {
513
+ const entries = [];
514
+ for (const p of order) {
515
+ entries.push({
516
+ name: this._safeEntryName(p),
517
+ data: this._concatBuffers(buffers.get(p) || []),
518
+ });
519
+ }
520
+ // Include files that were announced but produced no bytes (empty).
521
+ for (const p of sizes.keys()) {
522
+ if (!buffers.has(p)) {
523
+ entries.push({ name: this._safeEntryName(p), data: new Uint8Array(0) });
524
+ }
525
+ }
526
+ this._triggerBrowserDownload(createZip(entries), this._archiveName(path));
527
+ } else {
528
+ const data = this._concatBuffers(buffers.get(path) || []);
529
+ this._triggerBrowserDownload(new Blob([data], { type: 'application/octet-stream' }), this._downloadName(path));
530
+ }
531
+ return total;
532
+ } catch (err) {
533
+ throw toLibfwError(err);
534
+ } finally {
535
+ this._fallback = null;
536
+ }
537
+ }
538
+
539
+ /**
540
+ * Concatenate buffered chunks into one `Uint8Array`.
541
+ * @param {Uint8Array[]} bufs
542
+ * @returns {Uint8Array}
543
+ * @private
544
+ */
545
+ _concatBuffers(bufs) {
546
+ if (bufs.length === 0) return new Uint8Array(0);
547
+ if (bufs.length === 1) return bufs[0];
548
+ const len = bufs.reduce((n, b) => n + b.length, 0);
549
+ const out = new Uint8Array(len);
550
+ let off = 0;
551
+ for (const b of bufs) {
552
+ out.set(b, off);
553
+ off += b.length;
554
+ }
555
+ return out;
556
+ }
557
+
558
+ /**
559
+ * Strip a leading `/` so an entry path is archive/OS friendly.
560
+ * @param {string} path
561
+ * @returns {string}
562
+ * @private
563
+ */
564
+ _cleanPath(path) {
565
+ return String(path).replace(/^\/+/, '');
566
+ }
567
+
568
+ /**
569
+ * Validate a virtual path for use as a ZIP entry name, rejecting any
570
+ * traversal (`..`), absolute/drive-letter prefixes or Windows-style
571
+ * separators that could escape the archive on extraction (zip-slip).
572
+ * @param {string} path
573
+ * @returns {string}
574
+ * @private
575
+ */
576
+ _safeEntryName(path) {
577
+ const cleaned = this._cleanPath(path);
578
+ const segs = String(cleaned).split('/');
579
+ if (segs.some((seg) => seg === '..' || seg.includes('\\') || /^[a-zA-Z]:/.test(seg))) {
580
+ throw new LibfwError(`unsafe path in download: ${path}`, 'path');
284
581
  }
582
+ return cleaned;
583
+ }
584
+
585
+ /**
586
+ * The configured in-memory cap for the browser-download fallback.
587
+ * @returns {number} 0 disables the limit.
588
+ * @private
589
+ */
590
+ _maxFallbackBytes() {
591
+ const max = Number(this._options.maxFallbackBytes);
592
+ return Number.isFinite(max) && max > 0 ? max : 0;
593
+ }
594
+
595
+ /**
596
+ * Derive a safe file name from a virtual path.
597
+ * @param {string} path
598
+ * @returns {string}
599
+ * @private
600
+ */
601
+ _downloadName(path) {
602
+ const name = this._cleanPath(path).split('/').pop();
603
+ return name || 'download';
285
604
  }
286
605
 
287
606
  /**
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.
607
+ * Derive the `.zip` archive name for a folder download.
608
+ * @param {string} path
609
+ * @returns {string}
610
+ * @private
611
+ */
612
+ _archiveName(path) {
613
+ const base = this._cleanPath(path).split('/').pop() || 'download';
614
+ return `${base.replace(/[^\w.\- ]+/g, '_') || 'download'}.zip`;
615
+ }
616
+
617
+ /**
618
+ * Trigger a traditional browser download via a temporary `<a download>`.
619
+ * @param {Blob} blob
620
+ * @param {string} filename
621
+ * @returns {void}
622
+ * @private
623
+ */
624
+ _triggerBrowserDownload(blob, filename) {
625
+ const url = URL.createObjectURL(blob);
626
+ const a = document.createElement('a');
627
+ a.href = url;
628
+ a.download = filename;
629
+ document.body.appendChild(a);
630
+ a.click();
631
+ a.remove();
632
+ // Revoke once the download has had a chance to start.
633
+ setTimeout(() => URL.revokeObjectURL(url), 60000);
634
+ }
635
+
636
+ /**
637
+ * Stream a decompressed chunk to disk, keeping memory bounded regardless
638
+ * of file size (no whole-file buffering).
639
+ *
640
+ * The destination writable is opened exactly once per file and written in
641
+ * **append mode** (`writable.write(data)` without an explicit `position`).
642
+ * The engine awaits this callback, so chunks for a file arrive strictly in
643
+ * order, making append writes correct for both fresh and resumed
644
+ * downloads. Crucially, this avoids per-write
645
+ * `{ type: 'write', position }` calls, which in Chromium can spawn a fresh
646
+ * `.crswap` swap file per write and leave the target file empty on close —
647
+ * the single `createWritable()` + sequential writes + one `close()` below
648
+ * commits the swap file atomically.
292
649
  * @param {string} path virtual path
293
- * @param {number} offset byte offset
650
+ * @param {number} offset byte offset (informational; writes append)
294
651
  * @param {Uint8Array} data decompressed chunk
295
652
  * @returns {Promise<void>}
296
653
  * @private
297
654
  */
298
655
  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);
656
+ if (this._fallback) {
657
+ // Browser-download fallback: collect chunks in memory instead of
658
+ // streaming to disk. Chunks arrive in order, so a plain append works.
659
+ let bufs = this._fallback.buffers.get(path);
660
+ if (!bufs) {
661
+ bufs = [];
662
+ this._fallback.buffers.set(path, bufs);
663
+ this._fallback.order.push(path);
664
+ }
665
+ bufs.push(data);
666
+ return;
667
+ }
668
+ let entry = this._writables.get(path);
669
+ if (!entry) {
670
+ const { dir, name, handle } = await this._ensureFileHandle(path);
307
671
  this._fileHandles.set(path, handle);
308
- writable = await handle.createWritable(
672
+ // A true resume (first chunk at offset > 0) keeps the existing prefix
673
+ // on disk; a fresh download opens a truncating writable.
674
+ const isResume = offset > 0;
675
+ if (!isResume) {
676
+ // Remove any orphaned `.crswap` left behind by a crashed/aborted run
677
+ // so a stale swap file can never shadow the new write.
678
+ await this._removeSwapFile(dir, name);
679
+ }
680
+ const writable = await handle.createWritable(
309
681
  isResume ? { keepExistingData: true } : undefined
310
682
  );
311
- this._writables.set(path, writable);
683
+ entry = { writable, dir, name };
684
+ this._writables.set(path, entry);
312
685
  }
313
- await writable.write({ type: 'write', position: offset, data });
686
+ await entry.writable.write(data);
314
687
  }
315
688
 
316
689
  /**
@@ -320,22 +693,28 @@ export class LibfwClient {
320
693
  * @private
321
694
  */
322
695
  async _onFileCompleted(path) {
696
+ if (this._fallback) {
697
+ // Nothing is open to flush in browser-download mode.
698
+ this._emit({ type: 'fileCompleted', path });
699
+ return;
700
+ }
323
701
  await this._closeWritable(path);
324
702
  this._emit({ type: 'fileCompleted', path });
325
703
  }
326
704
 
327
705
  /**
328
- * Close (and forget) a file's writable, flushing buffered bytes to disk.
706
+ * Close (and forget) a file's writable, atomically committing the swap
707
+ * file to its final name. Best-effort so failure/abort never throws.
329
708
  * @param {string} path virtual path
330
709
  * @returns {Promise<void>}
331
710
  * @private
332
711
  */
333
712
  async _closeWritable(path) {
334
- const writable = this._writables.get(path);
335
- if (writable) {
713
+ const entry = this._writables.get(path);
714
+ if (entry) {
336
715
  this._writables.delete(path);
337
716
  try {
338
- await writable.close();
717
+ await entry.writable.close();
339
718
  } catch {
340
719
  /* best-effort flush on failure/abort */
341
720
  }
@@ -346,7 +725,7 @@ export class LibfwClient {
346
725
  * Resolve (and create, if needed) the file handle for a virtual path,
347
726
  * creating any parent directories along the way.
348
727
  * @param {string} path
349
- * @returns {Promise<FileSystemFileHandle>}
728
+ * @returns {Promise<{dir: FileSystemDirectoryHandle, name: string, handle: FileSystemFileHandle}>}
350
729
  * @private
351
730
  */
352
731
  async _ensureFileHandle(path) {
@@ -358,7 +737,25 @@ export class LibfwClient {
358
737
  for (let i = 0; i < segments.length - 1; i += 1) {
359
738
  dir = await dir.getDirectoryHandle(segments[i], { create: true });
360
739
  }
361
- return dir.getFileHandle(segments[segments.length - 1], { create: true });
740
+ const name = segments[segments.length - 1];
741
+ const handle = await dir.getFileHandle(name, { create: true });
742
+ return { dir, name, handle };
743
+ }
744
+
745
+ /**
746
+ * Delete a leftover Chromium swap file (`.<name>.crswap`) next to a file,
747
+ * ignoring any error (no swap file, or permission denied).
748
+ * @param {FileSystemDirectoryHandle} dir parent directory
749
+ * @param {string} name target file name
750
+ * @returns {Promise<void>}
751
+ * @private
752
+ */
753
+ async _removeSwapFile(dir, name) {
754
+ try {
755
+ await dir.removeEntry(`.${name}.crswap`, { recursive: false });
756
+ } catch {
757
+ /* nothing to clean up */
758
+ }
362
759
  }
363
760
 
364
761
  /**
@@ -368,10 +765,10 @@ export class LibfwClient {
368
765
  * @private
369
766
  */
370
767
  async _flushWritables() {
371
- const pending = [...this._writables.entries()].map(async ([path, writable]) => {
768
+ const pending = [...this._writables.entries()].map(async ([path, entry]) => {
372
769
  this._writables.delete(path);
373
770
  try {
374
- await writable.close();
771
+ await entry.writable.close();
375
772
  } catch {
376
773
  /* best-effort flush */
377
774
  }
@@ -379,6 +776,34 @@ export class LibfwClient {
379
776
  await Promise.allSettled(pending);
380
777
  }
381
778
 
779
+ /**
780
+ * Reconcile persisted download resume offsets with the bytes actually
781
+ * committed to disk.
782
+ *
783
+ * `createWritable()` only commits to the real file on `close()`, so an
784
+ * interrupted download's on-disk length can be ahead of (or behind) the
785
+ * engine's periodically-saved offset. Overwriting each file's stored
786
+ * offset with its real size keeps the append-based resume consistent:
787
+ * the next transfer resumes exactly where the file on disk ends.
788
+ * @returns {Promise<void>}
789
+ * @private
790
+ */
791
+ async _syncResumeOffsets() {
792
+ for (const [path, handle] of this._fileHandles) {
793
+ try {
794
+ const file = await handle.getFile();
795
+ const size = file.size;
796
+ const state = await Idb.loadState(`download:${path}`);
797
+ if (state && typeof state.etag === 'string') {
798
+ await Idb.saveState(`download:${path}`, { ...state, offset: size, size });
799
+ }
800
+ } catch {
801
+ /* best-effort */
802
+ }
803
+ }
804
+ this._fileHandles.clear();
805
+ }
806
+
382
807
  // -------------------------------------------------------------- uploads
383
808
 
384
809
  /**
@@ -544,6 +969,36 @@ export class LibfwClient {
544
969
  totalBytes() {
545
970
  return this._engine ? this._engine.total_bytes() : 0;
546
971
  }
972
+
973
+ // ------------------------------------------------------------- resume store
974
+
975
+ /**
976
+ * Delete persisted resume state (IndexedDB).
977
+ *
978
+ * Pass a direction to wipe only that transfer's state, leaving the other
979
+ * direction intact — the targeted replacement for clearing the whole store
980
+ * before every transfer:
981
+ *
982
+ * - `await client.clearResumeStore('download')` — drop all download state.
983
+ * - `await client.clearResumeStore('upload')` — drop all upload state.
984
+ * - `await client.clearResumeStore()` — wipe everything (whole-store clear).
985
+ *
986
+ * @param {'upload'|'download'} [direction] restrict to one direction
987
+ * @returns {Promise<number>} number of records removed
988
+ */
989
+ async clearResumeStore(direction) {
990
+ if (direction !== undefined && direction !== 'upload' && direction !== 'download') {
991
+ throw new LibfwError(
992
+ `clearResumeStore: expected 'upload' | 'download' | undefined, got ${JSON.stringify(direction)}`,
993
+ 'path'
994
+ );
995
+ }
996
+ if (direction === undefined) {
997
+ await Idb.clear();
998
+ return 0;
999
+ }
1000
+ return Idb.clearDirection(direction);
1001
+ }
547
1002
  }
548
1003
 
549
1004
  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.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",
@@ -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
  },
@@ -33,8 +33,9 @@ export class LibfwClient {
33
33
  has_callbacks(): boolean;
34
34
  /**
35
35
  * Create an engine. `options` may include:
36
- * `{ concurrency, compress, chunkSize, maxRetries, baseRetryDelayMs,
37
- * maxRetryDelayMs, timeoutMs }`.
36
+ * `{ concurrency, uploadWindow, downloadWindow, downloadChunkSize,
37
+ * compress, chunkSize, maxRetries, baseRetryDelayMs, maxRetryDelayMs,
38
+ * timeoutMs }`.
38
39
  */
39
40
  constructor(opts: any);
40
41
  /**
@@ -76,8 +76,9 @@ export class LibfwClient {
76
76
  }
77
77
  /**
78
78
  * Create an engine. `options` may include:
79
- * `{ concurrency, compress, chunkSize, maxRetries, baseRetryDelayMs,
80
- * maxRetryDelayMs, timeoutMs }`.
79
+ * `{ concurrency, uploadWindow, downloadWindow, downloadChunkSize,
80
+ * compress, chunkSize, maxRetries, baseRetryDelayMs, maxRetryDelayMs,
81
+ * timeoutMs }`.
81
82
  * @param {any} opts
82
83
  */
83
84
  constructor(opts) {
@@ -379,6 +380,10 @@ function __wbg_get_imports() {
379
380
  const ret = new Request(getStringFromWasm0(arg0, arg1), arg2);
380
381
  return ret;
381
382
  }, arguments); },
383
+ __wbg_of_5f1b88183ddb5d94: function(arg0, arg1) {
384
+ const ret = Array.of(arg0, arg1);
385
+ return ret;
386
+ },
382
387
  __wbg_prototypesetcall_4770620bbe4688a0: function(arg0, arg1, arg2) {
383
388
  Uint8Array.prototype.set.call(getArrayU8FromWasm0(arg0, arg1), arg2);
384
389
  },
@@ -393,6 +398,10 @@ function __wbg_get_imports() {
393
398
  __wbg_queueMicrotask_6a09b7bc46549209: function(arg0) {
394
399
  queueMicrotask(arg0);
395
400
  },
401
+ __wbg_race_ac5c7b465abcfa15: function(arg0) {
402
+ const ret = Promise.race(arg0);
403
+ return ret;
404
+ },
396
405
  __wbg_read_8afa15f12a160ef8: function(arg0) {
397
406
  const ret = arg0.read();
398
407
  return ret;
@@ -401,6 +410,10 @@ function __wbg_get_imports() {
401
410
  const ret = Promise.resolve(arg0);
402
411
  return ret;
403
412
  },
413
+ __wbg_setTimeout_725a27c387d005c7: function() { return handleError(function (arg0, arg1, arg2, arg3) {
414
+ const ret = arg0.setTimeout(arg1, arg2, arg3);
415
+ return ret;
416
+ }, arguments); },
404
417
  __wbg_setTimeout_cfa2cf195c3738db: function() { return handleError(function (arg0, arg1, arg2) {
405
418
  const ret = arg0.setTimeout(arg1, arg2);
406
419
  return ret;
@@ -450,7 +463,7 @@ function __wbg_get_imports() {
450
463
  return ret;
451
464
  },
452
465
  __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`.
466
+ // Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [Externref], shim_idx: 145, ret: Result(Unit), inner_ret: Some(Result(Unit)) }, mutable: true }) -> Externref`.
454
467
  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
468
  return ret;
456
469
  },
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.4",
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
+ }