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