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