libfw-client 0.1.0

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 ADDED
@@ -0,0 +1,77 @@
1
+ # libfw-client SDK
2
+
3
+ The browser SDK for [libfw](../README.md): a zero-config wrapper around the
4
+ WASM engine, the File System Access API and IndexedDB.
5
+
6
+ ## Usage
7
+
8
+ ```js
9
+ import { LibfwClient } from 'libfw-client';
10
+
11
+ const client = new LibfwClient({
12
+ baseUrl: '/api', // where libfw-server routes are mounted
13
+ concurrency: 4, // max parallel file transfers
14
+ compress: true, // zrip streaming compression
15
+ onEvent: (e) => console.log(e), // { type: 'progress', done, total }
16
+ });
17
+
18
+ // Download a whole folder (showDirectoryPicker, preserves structure)
19
+ await client.downloadFolder('your_token_here');
20
+
21
+ // Upload a FileList
22
+ const input = document.querySelector('input[type=file]');
23
+ await client.upload('your_token_here', input.files);
24
+
25
+ // Or upload a whole folder
26
+ await client.upload('your_token_here');
27
+
28
+ // Controls
29
+ client.pause();
30
+ client.resume();
31
+ client.cancel();
32
+ ```
33
+
34
+ ## How it works
35
+
36
+ - `downloadFolder(token, dirPath?)` — the engine lists the folder on the
37
+ server (recursively), then downloads each file with `Range`/`If-Range`
38
+ resume, decompresses the zrip stream, and pushes `Uint8Array` chunks to
39
+ the SDK, which writes them with `fileHandle.createWritable()`.
40
+ - `upload(token, files?)` — the engine slices each file into fixed-size
41
+ chunks, reads them via `readFile`, compresses each chunk into one zstd
42
+ frame, and POSTs them with `x-libfw-offset` for server-side resume
43
+ validation.
44
+ - Resume state (`etag`, `offset`, `size`) is persisted per path in
45
+ IndexedDB and re-validated on every retry.
46
+ - Pause/resume/cancel drive the WASM state machine
47
+ (`idle → downloading/uploading → paused → resumed → completed/failed`).
48
+
49
+ ## Build
50
+
51
+ ```bash
52
+ # 1. Compile the WASM engine + generate the web glue (requires wasm-pack)
53
+ npm run build:wasm
54
+
55
+ # 2. (optional) bundle a UMD build
56
+ npm run build:umd
57
+ ```
58
+
59
+ The resulting package contains:
60
+
61
+ ```
62
+ pkg/ wasm-pack output (wasm + wasm-bindgen web glue)
63
+ index.js ESM SDK
64
+ index.d.ts TypeScript types
65
+ dist/libfw-client.umd.js UMD bundle (after build:umd)
66
+ ```
67
+
68
+ ## API
69
+
70
+ - `new LibfwClient(options?)`
71
+ - `downloadFolder(token, dirPath?) → Promise<number>`
72
+ - `upload(token, files?) → Promise<number>`
73
+ - `pause()`, `resume()`, `cancel()`
74
+ - `state()`, `progress()`, `doneBytes()`, `totalBytes()`
75
+ - Errors: every rejection is a `LibfwError` with a stable `code`.
76
+
77
+ See `index.d.ts` for the full type surface.
package/index.d.ts ADDED
@@ -0,0 +1,137 @@
1
+ /**
2
+ * Type definitions for `libfw-client` — the browser SDK.
3
+ */
4
+
5
+ /** Machine-readable error categories. */
6
+ export type LibfwErrorCode =
7
+ | 'unknown'
8
+ | 'wasm'
9
+ | 'abort'
10
+ | 'unsupported'
11
+ | 'path'
12
+ | 'storage'
13
+ | 'idb'
14
+ | 'http'
15
+ | 'network'
16
+ | 'decompress'
17
+ | 'compress'
18
+ | 'protocol'
19
+ | 'cancelled';
20
+
21
+ /** Uniform error type thrown by every SDK operation. */
22
+ export declare class LibfwError extends Error {
23
+ readonly name: 'LibfwError';
24
+ readonly code: LibfwErrorCode;
25
+ constructor(message: string, code?: LibfwErrorCode);
26
+ }
27
+
28
+ /** A file scheduled for upload. */
29
+ export interface UploadEntry {
30
+ /** Virtual path (POSIX separators), e.g. `dir/sub/file.txt`. */
31
+ path: string;
32
+ /** Size in bytes. */
33
+ size: number;
34
+ /** Last-modified unix seconds. */
35
+ mtime: number;
36
+ }
37
+
38
+ /** Progress / lifecycle event delivered via `options.onEvent`. */
39
+ export interface LibfwEvent {
40
+ /** `fileStart`, `fileCompleted`, `progress`. */
41
+ type: 'fileStart' | 'fileCompleted' | 'progress';
42
+ /** Virtual path of the file involved (file events only). */
43
+ path?: string;
44
+ /** Bytes done (progress events). */
45
+ done?: number;
46
+ /** Total bytes (progress events). */
47
+ total?: number;
48
+ }
49
+
50
+ /** Options accepted by the {@link LibfwClient} constructor. */
51
+ export interface LibfwClientOptions {
52
+ /** Base URL the libfw server routes are mounted under. Default `''`. */
53
+ baseUrl?: string;
54
+ /** Max concurrent file transfers. Default `4`. */
55
+ concurrency?: number;
56
+ /** Negotiate zrip compression. Default `true`. */
57
+ compress?: boolean;
58
+ /** Upload chunk size in bytes. Default 2 MiB. */
59
+ chunkSize?: number;
60
+ /** Retries per chunk/file before failing. Default `3`. */
61
+ maxRetries?: number;
62
+ /** Initial exponential-backoff delay (ms). Default `500`. */
63
+ baseRetryDelayMs?: number;
64
+ /** Backoff ceiling (ms). Default `30000`. */
65
+ maxRetryDelayMs?: number;
66
+ /** Per-request timeout (ms). Default `60000`. */
67
+ timeoutMs?: number;
68
+ /** Optional progress/state listener. */
69
+ onEvent?: (event: LibfwEvent) => void;
70
+ }
71
+
72
+ /**
73
+ * The high-level libfw client.
74
+ *
75
+ * @example
76
+ * import { LibfwClient } from 'libfw-client';
77
+ *
78
+ * const client = new LibfwClient({ baseUrl: '/api', concurrency: 4, compress: true });
79
+ * await client.downloadFolder('your_token_here');
80
+ * await client.upload('your_token_here', fileInput.files);
81
+ */
82
+ export declare class LibfwClient {
83
+ constructor(options?: LibfwClientOptions);
84
+
85
+ /**
86
+ * Download a folder from the server into a user-selected local directory.
87
+ *
88
+ * Uses `window.showDirectoryPicker()`; nested directories are recreated
89
+ * and bytes are streamed through `createWritable`.
90
+ *
91
+ * @param token bearer token
92
+ * @param dirPath virtual server path to download (empty = root)
93
+ * @returns total bytes written
94
+ */
95
+ downloadFolder(token: string, dirPath?: string): Promise<number>;
96
+
97
+ /**
98
+ * Upload files to the server.
99
+ *
100
+ * `files` may be a `FileList`, `File[]`, or `UploadEntry[]`. When omitted,
101
+ * a local directory is selected with `showDirectoryPicker()`.
102
+ *
103
+ * @param token bearer token
104
+ * @param files files (or plan entries) to upload
105
+ * @returns total bytes uploaded
106
+ */
107
+ upload(
108
+ token: string,
109
+ files?: FileList | File[] | UploadEntry[],
110
+ ): Promise<number>;
111
+
112
+ /** Pause the active transfer. */
113
+ pause(): void;
114
+
115
+ /** Resume a paused transfer. */
116
+ resume(): void;
117
+
118
+ /** Cancel the active transfer. */
119
+ cancel(): void;
120
+
121
+ /**
122
+ * Current engine state.
123
+ * @returns `idle | downloading | uploading | paused | completed | failed`
124
+ */
125
+ state(): string;
126
+
127
+ /** Progress in `[0, 1]`. */
128
+ progress(): number;
129
+
130
+ /** Bytes transferred so far. */
131
+ doneBytes(): number;
132
+
133
+ /** Total bytes to transfer. */
134
+ totalBytes(): number;
135
+ }
136
+
137
+ export default LibfwClient;
package/index.js ADDED
@@ -0,0 +1,478 @@
1
+ /**
2
+ * libfw-client — browser SDK.
3
+ *
4
+ * A thin, dependency-free wrapper around the libfw WASM engine that owns:
5
+ * - WASM instantiation (via the wasm-bindgen `web` glue),
6
+ * - the File System Access API (`showDirectoryPicker`, `getFileHandle`,
7
+ * `createWritable`),
8
+ * - IndexedDB resume-state persistence,
9
+ * - converting engine callbacks (`onWriteChunk`, `getFileList`, …) into
10
+ * real file I/O.
11
+ *
12
+ * Every method returns a `Promise`; no `WebAssembly` or raw memory APIs are
13
+ * ever exposed to the caller.
14
+ *
15
+ * @module libfw-client
16
+ */
17
+
18
+ import init, { LibfwClient as WasmEngine } from './pkg/libfw_client.js';
19
+
20
+ /** Database / store names used by the IndexedDB resume-state layer. */
21
+ const IDB_NAME = 'libfw';
22
+ const IDB_STORE = 'resume';
23
+
24
+ /**
25
+ * Uniform error type thrown by every SDK operation.
26
+ *
27
+ * @example
28
+ * try {
29
+ * await client.downloadFolder('token');
30
+ * } catch (err) {
31
+ * console.error(err.code, err.message); // e.g. "http", "http 404 for `/file/x`"
32
+ * }
33
+ */
34
+ export class LibfwError extends Error {
35
+ /**
36
+ * @param {string} message human-readable description
37
+ * @param {string} [code] machine-readable category
38
+ */
39
+ constructor(message, code = 'unknown') {
40
+ super(message);
41
+ this.name = 'LibfwError';
42
+ this.code = code;
43
+ }
44
+ }
45
+
46
+ /** Map an arbitrary rejection to a {@link LibfwError}. */
47
+ function toLibfwError(err) {
48
+ if (err instanceof LibfwError) return err;
49
+ if (err && typeof err === 'object' && err.isLibfwError) {
50
+ return new LibfwError(String(err.message || err), 'wasm');
51
+ }
52
+ if (err && typeof err === 'object' && err.message) {
53
+ return new LibfwError(String(err.message), err.name === 'AbortError' ? 'abort' : 'unknown');
54
+ }
55
+ return new LibfwError(String(err));
56
+ }
57
+
58
+ /**
59
+ * IndexedDB-backed resume state (per virtual path).
60
+ */
61
+ const Idb = {
62
+ /** @returns {Promise<IDBDatabase>} */
63
+ open() {
64
+ return new Promise((resolve, reject) => {
65
+ const req = indexedDB.open(IDB_NAME, 1);
66
+ req.onupgradeneeded = () => {
67
+ if (!req.result.objectStoreNames.contains(IDB_STORE)) {
68
+ req.result.createObjectStore(IDB_STORE);
69
+ }
70
+ };
71
+ req.onsuccess = () => resolve(req.result);
72
+ req.onerror = () => reject(new LibfwError(`indexeddb open: ${req.error}`, 'idb'));
73
+ });
74
+ },
75
+
76
+ /**
77
+ * @param {string} path virtual file path
78
+ * @returns {Promise<object|null>} `{ etag, offset, size }` or `null`
79
+ */
80
+ async loadState(path) {
81
+ try {
82
+ const db = await Idb.open();
83
+ return await new Promise((resolve, reject) => {
84
+ const tx = db.transaction(IDB_STORE, 'readonly');
85
+ const req = tx.objectStore(IDB_STORE).get(path);
86
+ req.onsuccess = () => resolve(req.result ?? null);
87
+ req.onerror = () => reject(new LibfwError(`idb get: ${req.error}`, 'idb'));
88
+ });
89
+ } catch (err) {
90
+ if (err instanceof LibfwError) return null; // resume is best-effort
91
+ throw err;
92
+ }
93
+ },
94
+
95
+ /**
96
+ * @param {string} path virtual file path
97
+ * @param {object} state `{ etag, offset, size }`
98
+ * @returns {Promise<void>}
99
+ */
100
+ async saveState(path, state) {
101
+ const db = await Idb.open();
102
+ await new Promise((resolve, reject) => {
103
+ const tx = db.transaction(IDB_STORE, 'readwrite');
104
+ tx.objectStore(IDB_STORE).put(state, path);
105
+ tx.oncomplete = () => resolve();
106
+ tx.onerror = () => reject(new LibfwError(`idb put: ${tx.error}`, 'idb'));
107
+ });
108
+ },
109
+ };
110
+
111
+ /** Split a POSIX virtual path into segments. */
112
+ function splitPath(path) {
113
+ return String(path).split('/').filter((s) => s.length > 0);
114
+ }
115
+
116
+ /**
117
+ * The high-level libfw client.
118
+ *
119
+ * @example
120
+ * import { LibfwClient } from 'libfw-client';
121
+ *
122
+ * const client = new LibfwClient({ baseUrl: '/api', concurrency: 4, compress: true });
123
+ * await client.downloadFolder('your_token_here');
124
+ * await client.upload('your_token_here', fileInput.files);
125
+ */
126
+ export class LibfwClient {
127
+ /**
128
+ * @param {object} [options]
129
+ * @param {string} [options.baseUrl=''] base URL the server routes are mounted under
130
+ * @param {number} [options.concurrency=4] max concurrent file transfers
131
+ * @param {boolean} [options.compress=true] negotiate zrip compression
132
+ * @param {number} [options.chunkSize=2097152] upload chunk size in bytes
133
+ * @param {number} [options.maxRetries=3] retries per chunk/file before failing
134
+ * @param {number} [options.baseRetryDelayMs=500] initial backoff (ms)
135
+ * @param {number} [options.maxRetryDelayMs=30000] backoff ceiling (ms)
136
+ * @param {number} [options.timeoutMs=60000] per-request timeout (ms)
137
+ * @param {(event: {type: string, done: number, total: number, path?: string, error?: string}) => void} [options.onEvent]
138
+ * optional progress/state listener
139
+ */
140
+ constructor(options = {}) {
141
+ this._options = {
142
+ baseUrl: '',
143
+ concurrency: 4,
144
+ compress: true,
145
+ chunkSize: 2 * 1024 * 1024,
146
+ maxRetries: 3,
147
+ baseRetryDelayMs: 500,
148
+ maxRetryDelayMs: 30000,
149
+ timeoutMs: 60000,
150
+ onEvent: null,
151
+ ...options,
152
+ };
153
+ /** @type {WasmEngine|null} */
154
+ this._engine = null;
155
+ /** @type {Promise<void>|null} */
156
+ this._initPromise = null;
157
+ /** @type {FileSystemDirectoryHandle|null} selected download directory */
158
+ this._dirHandle = null;
159
+ /** @type {Map<string, FileSystemFileHandle>} path → file handle */
160
+ this._fileHandles = new Map();
161
+ /** @type {Map<string, FileSystemWritableFileStream>} path → writable stream */
162
+ this._writables = new Map();
163
+ /** @type {Map<string, File>} path → File (upload) */
164
+ this._uploadFiles = new Map();
165
+ /** @type {Array<{path:string,size:number,mtime:number}>} upload plan */
166
+ this._uploadPlan = [];
167
+ }
168
+
169
+ // ------------------------------------------------------------------ setup
170
+
171
+ /**
172
+ * Lazily initialise the WASM engine (idempotent).
173
+ * @returns {Promise<WasmEngine>}
174
+ * @private
175
+ */
176
+ async _ready() {
177
+ if (this._engine) return this._engine;
178
+ if (!this._initPromise) {
179
+ this._initPromise = init().catch((err) => {
180
+ this._initPromise = null;
181
+ throw toLibfwError(err);
182
+ });
183
+ }
184
+ await this._initPromise;
185
+ const engine = new WasmEngine({
186
+ concurrency: this._options.concurrency,
187
+ compress: this._options.compress,
188
+ chunkSize: this._options.chunkSize,
189
+ maxRetries: this._options.maxRetries,
190
+ baseRetryDelayMs: this._options.baseRetryDelayMs,
191
+ maxRetryDelayMs: this._options.maxRetryDelayMs,
192
+ timeoutMs: this._options.timeoutMs,
193
+ });
194
+ engine.set_callbacks(this._makeCallbacks());
195
+ this._engine = engine;
196
+ return engine;
197
+ }
198
+
199
+ /**
200
+ * Build the callbacks object handed to the WASM engine.
201
+ * @returns {object}
202
+ * @private
203
+ */
204
+ _makeCallbacks() {
205
+ return {
206
+ onFileStart: (path, size) => this._emit({ type: 'fileStart', path, done: 0, total: size }),
207
+ onWriteChunk: (path, offset, data) => this._onWriteChunk(path, offset, data),
208
+ onFileCompleted: (path) => this._emit({ type: 'fileCompleted', path }),
209
+ onProgress: (done, total) => this._emit({ type: 'progress', done, total }),
210
+ loadState: (path) => Idb.loadState(path),
211
+ saveState: (path, state) => Idb.saveState(path, state),
212
+ getFileList: () => this._getFileList(),
213
+ readFile: (path, offset, length) => this._readFile(path, offset, length),
214
+ log: (msg) => {
215
+ if (typeof console !== 'undefined') console.debug(`[libfw] ${msg}`);
216
+ },
217
+ };
218
+ }
219
+
220
+ /** @private */
221
+ _emit(event) {
222
+ if (typeof this._options.onEvent === 'function') {
223
+ try {
224
+ this._options.onEvent(event);
225
+ } catch {
226
+ /* listener errors must not break transfers */
227
+ }
228
+ }
229
+ }
230
+
231
+ // ------------------------------------------------------------ downloads
232
+
233
+ /**
234
+ * Download a whole folder from the server into a local directory chosen
235
+ * by the user via `showDirectoryPicker()`.
236
+ *
237
+ * Folder structure (including nested directories) is preserved; bytes
238
+ * are streamed to disk through `createWritable({ type: 'write' })`.
239
+ *
240
+ * @param {string} token bearer token
241
+ * @param {string} [dirPath=''] virtual server path to download (root by default)
242
+ * @returns {Promise<number>} total bytes written
243
+ * @throws {LibfwError}
244
+ */
245
+ async downloadFolder(token, dirPath = '') {
246
+ const engine = await this._ready();
247
+ if (typeof window === 'undefined' || typeof window.showDirectoryPicker !== 'function') {
248
+ throw new LibfwError('File System Access API is not available in this browser', 'unsupported');
249
+ }
250
+ this._dirHandle = await window.showDirectoryPicker();
251
+ this._fileHandles.clear();
252
+ try {
253
+ return await engine.download_folder(this._options.baseUrl, token, dirPath);
254
+ } catch (err) {
255
+ throw toLibfwError(err);
256
+ } finally {
257
+ await this._flushWritables();
258
+ }
259
+ }
260
+
261
+ /**
262
+ * @param {string} path virtual path
263
+ * @param {number} offset byte offset
264
+ * @param {Uint8Array} data decompressed chunk
265
+ * @returns {Promise<void>}
266
+ * @private
267
+ */
268
+ async _onWriteChunk(path, offset, data) {
269
+ let writable = this._writables.get(path);
270
+ if (!writable) {
271
+ const handle = await this._ensureFileHandle(path);
272
+ this._fileHandles.set(path, handle);
273
+ // keepExistingData: true lets resumed downloads overwrite only the
274
+ // tail without truncating the already-written prefix.
275
+ writable = await handle.createWritable({ keepExistingData: true });
276
+ this._writables.set(path, writable);
277
+ }
278
+ await writable.write({ type: 'write', position: offset, data });
279
+ }
280
+
281
+ /**
282
+ * Resolve (and create, if needed) the file handle for a virtual path,
283
+ * creating any parent directories along the way.
284
+ * @param {string} path
285
+ * @returns {Promise<FileSystemFileHandle>}
286
+ * @private
287
+ */
288
+ async _ensureFileHandle(path) {
289
+ const segments = splitPath(path);
290
+ if (segments.length === 0) {
291
+ throw new LibfwError(`invalid download path: ${path}`, 'path');
292
+ }
293
+ let dir = this._dirHandle;
294
+ for (let i = 0; i < segments.length - 1; i += 1) {
295
+ dir = await dir.getDirectoryHandle(segments[i], { create: true });
296
+ }
297
+ return dir.getFileHandle(segments[segments.length - 1], { create: true });
298
+ }
299
+
300
+ /**
301
+ * Close all open writable streams (flush to disk).
302
+ * @returns {Promise<void>}
303
+ * @private
304
+ */
305
+ async _flushWritables() {
306
+ const pending = [...this._writables.values()].map((w) => w.close().catch(() => {}));
307
+ this._writables.clear();
308
+ await Promise.allSettled(pending);
309
+ }
310
+
311
+ // -------------------------------------------------------------- uploads
312
+
313
+ /**
314
+ * Upload files to the server.
315
+ *
316
+ * If `files` is omitted, `showDirectoryPicker()` is used to select a
317
+ * local folder whose structure is mirrored on the server. Otherwise
318
+ * `files` may be a `FileList`, an array of `File`s, or an array of
319
+ * `{ path, size, mtime }` plan entries (when you want to drive reading
320
+ * yourself).
321
+ *
322
+ * @param {string} token bearer token
323
+ * @param {FileList|File[]|Array<{path:string,size:number,mtime:number}>} [files]
324
+ * @returns {Promise<number>} total bytes uploaded
325
+ * @throws {LibfwError}
326
+ */
327
+ async upload(token, files) {
328
+ const engine = await this._ready();
329
+ this._uploadFiles.clear();
330
+ this._uploadPlan = [];
331
+
332
+ if (files === undefined || files === null) {
333
+ if (typeof window === 'undefined' || typeof window.showDirectoryPicker !== 'function') {
334
+ throw new LibfwError('File System Access API is not available in this browser', 'unsupported');
335
+ }
336
+ const dir = await window.showDirectoryPicker();
337
+ this._dirHandle = dir;
338
+ this._uploadPlan = await this._collectDirectoryFiles(dir, '');
339
+ } else {
340
+ this._uploadPlan = await this._collectProvidedFiles(files);
341
+ }
342
+
343
+ try {
344
+ return await engine.upload(this._options.baseUrl, token);
345
+ } catch (err) {
346
+ throw toLibfwError(err);
347
+ }
348
+ }
349
+
350
+ /**
351
+ * Walk a directory handle and build the upload plan.
352
+ * @param {FileSystemDirectoryHandle} dir
353
+ * @param {string} prefix virtual path prefix
354
+ * @returns {Promise<Array<{path:string,size:number,mtime:number}>>}
355
+ * @private
356
+ */
357
+ async _collectDirectoryFiles(dir, prefix) {
358
+ const plan = [];
359
+ for await (const [name, handle] of dir.entries()) {
360
+ const path = prefix ? `${prefix}/${name}` : name;
361
+ if (handle.kind === 'directory') {
362
+ plan.push(...(await this._collectDirectoryFiles(handle, path)));
363
+ } else {
364
+ const file = await handle.getFile();
365
+ this._uploadFiles.set(path, file);
366
+ plan.push({ path, size: file.size, mtime: Math.floor(file.lastModified / 1000) });
367
+ }
368
+ }
369
+ plan.sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0));
370
+ return plan;
371
+ }
372
+
373
+ /**
374
+ * Build the upload plan from a FileList / File[] / plan array.
375
+ * @param {FileList|File[]|Array} files
376
+ * @returns {Promise<Array<{path:string,size:number,mtime:number}>>}
377
+ * @private
378
+ */
379
+ async _collectProvidedFiles(files) {
380
+ if (Array.isArray(files) && files.length > 0 && typeof files[0] === 'object' && files[0] !== null && 'path' in files[0] && !(files[0] instanceof File)) {
381
+ // Caller-supplied plan (no File objects → they must provide readFile).
382
+ return files.map((f) => ({
383
+ path: String(f.path),
384
+ size: Number(f.size) || 0,
385
+ mtime: Number(f.mtime) || 0,
386
+ }));
387
+ }
388
+ const list = Array.from(files || []);
389
+ const plan = [];
390
+ for (const file of list) {
391
+ if (!(file instanceof File)) continue;
392
+ const path = file.webkitRelativePath || file.name;
393
+ this._uploadFiles.set(path, file);
394
+ plan.push({ path, size: file.size, mtime: Math.floor(file.lastModified / 1000) });
395
+ }
396
+ plan.sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0));
397
+ return plan;
398
+ }
399
+
400
+ /**
401
+ * Engine callback: current upload plan.
402
+ * @returns {Promise<Array<{path:string,size:number,mtime:number}>>}
403
+ * @private
404
+ */
405
+ async _getFileList() {
406
+ return this._uploadPlan;
407
+ }
408
+
409
+ /**
410
+ * Engine callback: read `length` bytes of an upload file at `offset`.
411
+ * @param {string} path
412
+ * @param {number} offset
413
+ * @param {number} length
414
+ * @returns {Promise<Uint8Array>}
415
+ * @private
416
+ */
417
+ async _readFile(path, offset, length) {
418
+ const file = this._uploadFiles.get(path);
419
+ if (!file) {
420
+ throw new LibfwError(`upload source not found: ${path}`, 'storage');
421
+ }
422
+ const blob = file.slice(offset, offset + length);
423
+ const buffer = await blob.arrayBuffer();
424
+ return new Uint8Array(buffer);
425
+ }
426
+
427
+ // ------------------------------------------------------------- controls
428
+
429
+ /** Pause the active transfer (state → `paused`). */
430
+ pause() {
431
+ if (this._engine) this._engine.pause();
432
+ }
433
+
434
+ /** Resume a paused transfer. */
435
+ resume() {
436
+ if (this._engine) this._engine.resume();
437
+ }
438
+
439
+ /** Cancel the active transfer (state → `failed`). */
440
+ cancel() {
441
+ if (this._engine) this._engine.cancel();
442
+ }
443
+
444
+ /**
445
+ * Current engine state: `idle | downloading | uploading | paused |
446
+ * completed | failed`.
447
+ * @returns {string}
448
+ */
449
+ state() {
450
+ return this._engine ? this._engine.state() : 'idle';
451
+ }
452
+
453
+ /**
454
+ * Progress in `[0, 1]`.
455
+ * @returns {number}
456
+ */
457
+ progress() {
458
+ return this._engine ? this._engine.progress() : 0;
459
+ }
460
+
461
+ /**
462
+ * Bytes transferred so far.
463
+ * @returns {number}
464
+ */
465
+ doneBytes() {
466
+ return this._engine ? this._engine.done_bytes() : 0;
467
+ }
468
+
469
+ /**
470
+ * Total bytes to transfer.
471
+ * @returns {number}
472
+ */
473
+ totalBytes() {
474
+ return this._engine ? this._engine.total_bytes() : 0;
475
+ }
476
+ }
477
+
478
+ export default LibfwClient;