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