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 +77 -0
- package/index.d.ts +137 -0
- package/index.js +478 -0
- package/package.json +46 -0
- package/pkg/libfw_client.d.ts +123 -0
- package/pkg/libfw_client.js +784 -0
- package/pkg/libfw_client_bg.wasm +0 -0
- package/pkg/libfw_client_bg.wasm.d.ts +28 -0
- package/pkg/package.json +28 -0
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;
|