@statewalker/webrun-files-browser 0.5.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/LICENSE +21 -0
- package/README.md +203 -0
- package/dist/browser-file-handle.d.ts +42 -0
- package/dist/browser-file-handle.d.ts.map +1 -0
- package/dist/browser-files-api.d.ts +69 -0
- package/dist/browser-files-api.d.ts.map +1 -0
- package/dist/cjs/index.cjs +335 -0
- package/dist/esm/index.js +332 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.d.ts.map +1 -0
- package/package.json +52 -0
- package/src/browser-file-handle.ts +194 -0
- package/src/browser-files-api.ts +330 -0
- package/src/index.ts +12 -0
|
@@ -0,0 +1,332 @@
|
|
|
1
|
+
import { basename, dirname, joinPath, resolveFileRef, toBinaryAsyncIterable } from "@statewalker/webrun-files";
|
|
2
|
+
|
|
3
|
+
//#region src/browser-file-handle.ts
|
|
4
|
+
var BrowserFileHandle = class {
|
|
5
|
+
fileHandle;
|
|
6
|
+
file;
|
|
7
|
+
_size;
|
|
8
|
+
closed = false;
|
|
9
|
+
constructor(options) {
|
|
10
|
+
this.fileHandle = options.fileHandle;
|
|
11
|
+
this.file = options.initialFile;
|
|
12
|
+
this._size = options.initialFile.size;
|
|
13
|
+
}
|
|
14
|
+
get size() {
|
|
15
|
+
return this._size;
|
|
16
|
+
}
|
|
17
|
+
async close() {
|
|
18
|
+
this.closed = true;
|
|
19
|
+
this.file = null;
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Appends data to the end of the file.
|
|
23
|
+
*/
|
|
24
|
+
async appendFile(data, options = {}) {
|
|
25
|
+
if (this.closed) throw new Error("FileHandle is closed");
|
|
26
|
+
const { signal } = options;
|
|
27
|
+
const writable = await this.fileHandle.createWritable({ keepExistingData: true });
|
|
28
|
+
let bytesWritten = 0;
|
|
29
|
+
try {
|
|
30
|
+
await writable.seek(this._size);
|
|
31
|
+
for await (const chunk of toBinaryAsyncIterable(data)) {
|
|
32
|
+
if (signal?.aborted) throw new Error("Operation aborted");
|
|
33
|
+
await writable.write(chunk);
|
|
34
|
+
bytesWritten += chunk.length;
|
|
35
|
+
}
|
|
36
|
+
} finally {
|
|
37
|
+
await writable.close();
|
|
38
|
+
}
|
|
39
|
+
this.file = await this.fileHandle.getFile();
|
|
40
|
+
this._size = this.file.size;
|
|
41
|
+
return bytesWritten;
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Creates a read stream for the file.
|
|
45
|
+
* Uses File.slice() for efficient partial reads.
|
|
46
|
+
*/
|
|
47
|
+
async *createReadStream(options = {}) {
|
|
48
|
+
if (this.closed) throw new Error("FileHandle is closed");
|
|
49
|
+
if (!this.file) {
|
|
50
|
+
this.file = await this.fileHandle.getFile();
|
|
51
|
+
this._size = this.file.size;
|
|
52
|
+
}
|
|
53
|
+
const { start = 0, end = Infinity, signal } = options;
|
|
54
|
+
const bufferSize = 8192;
|
|
55
|
+
const actualEnd = Math.min(end, this._size);
|
|
56
|
+
if (start >= actualEnd || this._size === 0) return;
|
|
57
|
+
let position = start;
|
|
58
|
+
while (position < actualEnd) {
|
|
59
|
+
if (signal?.aborted) throw new Error("Operation aborted");
|
|
60
|
+
const remaining = actualEnd - position;
|
|
61
|
+
const toRead = Math.min(bufferSize, remaining);
|
|
62
|
+
const buffer = await this.file.slice(position, position + toRead).arrayBuffer();
|
|
63
|
+
yield new Uint8Array(buffer);
|
|
64
|
+
position += buffer.byteLength;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* Writes data to the file starting at the specified position.
|
|
69
|
+
* If start > 0, preserves content before start position.
|
|
70
|
+
* Truncates file at start position before writing.
|
|
71
|
+
*/
|
|
72
|
+
async createWriteStream(data, options = {}) {
|
|
73
|
+
if (this.closed) throw new Error("FileHandle is closed");
|
|
74
|
+
const { start = 0, signal } = options;
|
|
75
|
+
const writable = await this.fileHandle.createWritable({ keepExistingData: start > 0 });
|
|
76
|
+
let bytesWritten = 0;
|
|
77
|
+
try {
|
|
78
|
+
if (start > 0) {
|
|
79
|
+
await writable.seek(start);
|
|
80
|
+
await writable.truncate(start);
|
|
81
|
+
}
|
|
82
|
+
for await (const chunk of toBinaryAsyncIterable(data)) {
|
|
83
|
+
if (signal?.aborted) throw new Error("Operation aborted");
|
|
84
|
+
await writable.write(chunk);
|
|
85
|
+
bytesWritten += chunk.length;
|
|
86
|
+
}
|
|
87
|
+
} finally {
|
|
88
|
+
await writable.close();
|
|
89
|
+
}
|
|
90
|
+
this.file = await this.fileHandle.getFile();
|
|
91
|
+
this._size = this.file.size;
|
|
92
|
+
return bytesWritten;
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* Random access read from file at a specific position.
|
|
96
|
+
* Uses File.slice() for efficient partial reads.
|
|
97
|
+
*/
|
|
98
|
+
async read(buffer, offset, length, position) {
|
|
99
|
+
if (this.closed) throw new Error("FileHandle is closed");
|
|
100
|
+
if (!this.file) {
|
|
101
|
+
this.file = await this.fileHandle.getFile();
|
|
102
|
+
this._size = this.file.size;
|
|
103
|
+
}
|
|
104
|
+
let len = Math.min(length, buffer.length - offset);
|
|
105
|
+
len = Math.min(len, this._size - position);
|
|
106
|
+
len = Math.max(0, len);
|
|
107
|
+
if (len === 0) return 0;
|
|
108
|
+
const arrayBuffer = await this.file.slice(position, position + len).arrayBuffer();
|
|
109
|
+
const data = new Uint8Array(arrayBuffer);
|
|
110
|
+
buffer.set(data, offset);
|
|
111
|
+
return len;
|
|
112
|
+
}
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
//#endregion
|
|
116
|
+
//#region src/browser-files-api.ts
|
|
117
|
+
var BrowserFilesApi = class {
|
|
118
|
+
rootHandle;
|
|
119
|
+
constructor(options) {
|
|
120
|
+
this.rootHandle = options.rootHandle;
|
|
121
|
+
}
|
|
122
|
+
/**
|
|
123
|
+
* Navigates to a directory handle from a path.
|
|
124
|
+
* Returns null if the directory doesn't exist and create is false.
|
|
125
|
+
*/
|
|
126
|
+
async getDirectoryHandle(path, options = {}) {
|
|
127
|
+
const segments = path.split("/").filter((s) => s.length > 0);
|
|
128
|
+
let current = this.rootHandle;
|
|
129
|
+
for (const segment of segments) try {
|
|
130
|
+
current = await current.getDirectoryHandle(segment, options);
|
|
131
|
+
} catch {
|
|
132
|
+
return null;
|
|
133
|
+
}
|
|
134
|
+
return current;
|
|
135
|
+
}
|
|
136
|
+
/**
|
|
137
|
+
* Gets a file handle from a path.
|
|
138
|
+
* Returns null if the file doesn't exist and create is false.
|
|
139
|
+
*/
|
|
140
|
+
async getFileHandle(path, options = {}) {
|
|
141
|
+
const normalized = resolveFileRef(path);
|
|
142
|
+
const parentPath = dirname(normalized);
|
|
143
|
+
const fileName = basename(normalized);
|
|
144
|
+
const parent = await this.getDirectoryHandle(parentPath, options);
|
|
145
|
+
if (!parent) return null;
|
|
146
|
+
try {
|
|
147
|
+
return await parent.getFileHandle(fileName, options);
|
|
148
|
+
} catch {
|
|
149
|
+
return null;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
/**
|
|
153
|
+
* Lists entries in a directory.
|
|
154
|
+
*/
|
|
155
|
+
async *list(file, options = {}) {
|
|
156
|
+
const normalized = resolveFileRef(file);
|
|
157
|
+
const dirHandle = await this.getDirectoryHandle(normalized);
|
|
158
|
+
if (!dirHandle) return;
|
|
159
|
+
const { recursive = false } = options;
|
|
160
|
+
for await (const [name, handle] of dirHandle.entries()) {
|
|
161
|
+
const entryPath = joinPath(normalized, name);
|
|
162
|
+
const isDirectory = handle.kind === "directory";
|
|
163
|
+
const info = {
|
|
164
|
+
kind: isDirectory ? "directory" : "file",
|
|
165
|
+
name,
|
|
166
|
+
path: entryPath,
|
|
167
|
+
lastModified: 0
|
|
168
|
+
};
|
|
169
|
+
if (!isDirectory) try {
|
|
170
|
+
const file$1 = await handle.getFile();
|
|
171
|
+
info.size = file$1.size;
|
|
172
|
+
info.lastModified = file$1.lastModified;
|
|
173
|
+
info.type = file$1.type;
|
|
174
|
+
} catch {}
|
|
175
|
+
yield info;
|
|
176
|
+
if (recursive && isDirectory) yield* this.list(entryPath, options);
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
/**
|
|
180
|
+
* Gets file or directory stats.
|
|
181
|
+
*/
|
|
182
|
+
async stats(file) {
|
|
183
|
+
const normalized = resolveFileRef(file);
|
|
184
|
+
const fileName = basename(normalized);
|
|
185
|
+
if (normalized === "/") return {
|
|
186
|
+
kind: "directory",
|
|
187
|
+
name: "",
|
|
188
|
+
path: "/",
|
|
189
|
+
lastModified: 0
|
|
190
|
+
};
|
|
191
|
+
const fileHandle = await this.getFileHandle(normalized);
|
|
192
|
+
if (fileHandle) try {
|
|
193
|
+
const f = await fileHandle.getFile();
|
|
194
|
+
return {
|
|
195
|
+
kind: "file",
|
|
196
|
+
name: fileName,
|
|
197
|
+
path: normalized,
|
|
198
|
+
size: f.size,
|
|
199
|
+
lastModified: f.lastModified,
|
|
200
|
+
type: f.type
|
|
201
|
+
};
|
|
202
|
+
} catch {
|
|
203
|
+
return;
|
|
204
|
+
}
|
|
205
|
+
if (await this.getDirectoryHandle(normalized)) return {
|
|
206
|
+
kind: "directory",
|
|
207
|
+
name: fileName,
|
|
208
|
+
path: normalized,
|
|
209
|
+
lastModified: 0
|
|
210
|
+
};
|
|
211
|
+
}
|
|
212
|
+
/**
|
|
213
|
+
* Removes a file or directory.
|
|
214
|
+
*/
|
|
215
|
+
async remove(file) {
|
|
216
|
+
const normalized = resolveFileRef(file);
|
|
217
|
+
const parentPath = dirname(normalized);
|
|
218
|
+
const name = basename(normalized);
|
|
219
|
+
const parent = await this.getDirectoryHandle(parentPath);
|
|
220
|
+
if (!parent) return false;
|
|
221
|
+
try {
|
|
222
|
+
await parent.removeEntry(name, { recursive: true });
|
|
223
|
+
return true;
|
|
224
|
+
} catch {
|
|
225
|
+
return false;
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
/**
|
|
229
|
+
* Opens a file for reading and writing.
|
|
230
|
+
* Creates the file and parent directories if they don't exist.
|
|
231
|
+
*/
|
|
232
|
+
async open(file) {
|
|
233
|
+
const normalized = resolveFileRef(file);
|
|
234
|
+
const parentPath = dirname(normalized);
|
|
235
|
+
const fileName = basename(normalized);
|
|
236
|
+
const parent = await this.getDirectoryHandle(parentPath, { create: true });
|
|
237
|
+
if (!parent) throw new Error(`Cannot create directory: ${parentPath}`);
|
|
238
|
+
const fileHandle = await parent.getFileHandle(fileName, { create: true });
|
|
239
|
+
return new BrowserFileHandle({
|
|
240
|
+
fileHandle,
|
|
241
|
+
initialFile: await fileHandle.getFile()
|
|
242
|
+
});
|
|
243
|
+
}
|
|
244
|
+
/**
|
|
245
|
+
* Creates a directory (and parent directories if needed).
|
|
246
|
+
*/
|
|
247
|
+
async mkdir(file) {
|
|
248
|
+
const normalized = resolveFileRef(file);
|
|
249
|
+
await this.getDirectoryHandle(normalized, { create: true });
|
|
250
|
+
}
|
|
251
|
+
/**
|
|
252
|
+
* Moves a file or directory using copy + delete.
|
|
253
|
+
* The File System Access API doesn't have a native move operation.
|
|
254
|
+
*/
|
|
255
|
+
async move(source, target) {
|
|
256
|
+
if (!await this.copy(source, target)) return false;
|
|
257
|
+
return this.remove(source);
|
|
258
|
+
}
|
|
259
|
+
/**
|
|
260
|
+
* Copies a file or directory.
|
|
261
|
+
*/
|
|
262
|
+
async copy(source, target, options = {}) {
|
|
263
|
+
const sourcePath = resolveFileRef(source);
|
|
264
|
+
const targetPath = resolveFileRef(target);
|
|
265
|
+
const { recursive = true } = options;
|
|
266
|
+
const sourceInfo = await this.stats(source);
|
|
267
|
+
if (!sourceInfo) return false;
|
|
268
|
+
if (sourceInfo.kind === "file") {
|
|
269
|
+
const sourceHandle = await this.getFileHandle(sourcePath);
|
|
270
|
+
if (!sourceHandle) return false;
|
|
271
|
+
const sourceFile = await sourceHandle.getFile();
|
|
272
|
+
const targetParentPath = dirname(targetPath);
|
|
273
|
+
const targetFileName = basename(targetPath);
|
|
274
|
+
const targetParent = await this.getDirectoryHandle(targetParentPath, { create: true });
|
|
275
|
+
if (!targetParent) return false;
|
|
276
|
+
const writable = await (await targetParent.getFileHandle(targetFileName, { create: true })).createWritable();
|
|
277
|
+
try {
|
|
278
|
+
const reader = sourceFile.stream().getReader();
|
|
279
|
+
while (true) {
|
|
280
|
+
const { done, value } = await reader.read();
|
|
281
|
+
if (done) break;
|
|
282
|
+
await writable.write(value);
|
|
283
|
+
}
|
|
284
|
+
} finally {
|
|
285
|
+
await writable.close();
|
|
286
|
+
}
|
|
287
|
+
return true;
|
|
288
|
+
}
|
|
289
|
+
if (!recursive) return false;
|
|
290
|
+
await this.getDirectoryHandle(targetPath, { create: true });
|
|
291
|
+
for await (const entry of this.list(source, { recursive: true })) {
|
|
292
|
+
if (entry.kind === "directory") continue;
|
|
293
|
+
const newTargetPath = targetPath + entry.path.substring(sourcePath.length);
|
|
294
|
+
const entryHandle = await this.getFileHandle(entry.path);
|
|
295
|
+
if (!entryHandle) continue;
|
|
296
|
+
const entryFile = await entryHandle.getFile();
|
|
297
|
+
const newTargetParentPath = dirname(newTargetPath);
|
|
298
|
+
const newTargetFileName = basename(newTargetPath);
|
|
299
|
+
const newTargetParent = await this.getDirectoryHandle(newTargetParentPath, { create: true });
|
|
300
|
+
if (!newTargetParent) continue;
|
|
301
|
+
const writable = await (await newTargetParent.getFileHandle(newTargetFileName, { create: true })).createWritable();
|
|
302
|
+
try {
|
|
303
|
+
const reader = entryFile.stream().getReader();
|
|
304
|
+
while (true) {
|
|
305
|
+
const { done, value } = await reader.read();
|
|
306
|
+
if (done) break;
|
|
307
|
+
await writable.write(value);
|
|
308
|
+
}
|
|
309
|
+
} finally {
|
|
310
|
+
await writable.close();
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
return true;
|
|
314
|
+
}
|
|
315
|
+
};
|
|
316
|
+
/**
|
|
317
|
+
* Opens a browser directory picker and returns a BrowserFilesApi instance.
|
|
318
|
+
* Only works in secure contexts (HTTPS) and requires user gesture.
|
|
319
|
+
*/
|
|
320
|
+
async function openBrowserFilesApi() {
|
|
321
|
+
return new BrowserFilesApi({ rootHandle: await globalThis.showDirectoryPicker() });
|
|
322
|
+
}
|
|
323
|
+
/**
|
|
324
|
+
* Gets a BrowserFilesApi instance backed by the Origin Private File System (OPFS).
|
|
325
|
+
* OPFS provides a sandboxed filesystem that persists between sessions.
|
|
326
|
+
*/
|
|
327
|
+
async function getOPFSFilesApi() {
|
|
328
|
+
return new BrowserFilesApi({ rootHandle: await navigator.storage.getDirectory() });
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
//#endregion
|
|
332
|
+
export { BrowserFileHandle, BrowserFilesApi, getOPFSFilesApi, openBrowserFilesApi };
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* webrun-files-browser - Browser File System Access API implementation
|
|
3
|
+
*/
|
|
4
|
+
export { BrowserFilesApi, getOPFSFilesApi, openBrowserFilesApi, type BrowserFilesApiOptions, } from "./browser-files-api.js";
|
|
5
|
+
export { BrowserFileHandle, type BrowserFileHandleOptions } from "./browser-file-handle.js";
|
|
6
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;GAEG;AAEH,OAAO,EACL,eAAe,EACf,eAAe,EACf,mBAAmB,EACnB,KAAK,sBAAsB,GAC5B,MAAM,wBAAwB,CAAC;AAEhC,OAAO,EAAE,iBAAiB,EAAE,KAAK,wBAAwB,EAAE,MAAM,0BAA0B,CAAC"}
|
package/package.json
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@statewalker/webrun-files-browser",
|
|
3
|
+
"version": "0.5.0",
|
|
4
|
+
"private": false,
|
|
5
|
+
"type": "module",
|
|
6
|
+
"description": "Browser File System Access API implementation for webrun-files",
|
|
7
|
+
"homepage": "https://github.com/statewalker/webrun-files",
|
|
8
|
+
"author": {
|
|
9
|
+
"name": "Mikhail Kotelnikov",
|
|
10
|
+
"email": "mikhail.kotelnikov@gmail.com"
|
|
11
|
+
},
|
|
12
|
+
"license": "MIT",
|
|
13
|
+
"repository": {
|
|
14
|
+
"type": "git",
|
|
15
|
+
"url": "git@github.com:statewalker/webrun-files.git"
|
|
16
|
+
},
|
|
17
|
+
"main": "./dist/cjs/index.cjs",
|
|
18
|
+
"module": "./dist/esm/index.js",
|
|
19
|
+
"types": "./dist/index.d.ts",
|
|
20
|
+
"exports": {
|
|
21
|
+
".": {
|
|
22
|
+
"types": "./dist/index.d.ts",
|
|
23
|
+
"import": "./dist/esm/index.js",
|
|
24
|
+
"require": "./dist/cjs/index.cjs"
|
|
25
|
+
}
|
|
26
|
+
},
|
|
27
|
+
"files": [
|
|
28
|
+
"dist",
|
|
29
|
+
"src"
|
|
30
|
+
],
|
|
31
|
+
"dependencies": {
|
|
32
|
+
"@statewalker/webrun-files": "0.5.0"
|
|
33
|
+
},
|
|
34
|
+
"devDependencies": {
|
|
35
|
+
"@types/node": "^24.10.1",
|
|
36
|
+
"native-file-system-adapter": "^3.0.1",
|
|
37
|
+
"rimraf": "^6.1.2",
|
|
38
|
+
"rolldown": "^1.0.0-beta.53",
|
|
39
|
+
"typescript": "^5.6.0",
|
|
40
|
+
"vitest": "^4.0.12",
|
|
41
|
+
"@statewalker/webrun-files-tests": "1.0.0"
|
|
42
|
+
},
|
|
43
|
+
"sideEffects": false,
|
|
44
|
+
"publishConfig": {
|
|
45
|
+
"access": "public"
|
|
46
|
+
},
|
|
47
|
+
"scripts": {
|
|
48
|
+
"build": "rimraf dist && rolldown -c && tsc --emitDeclarationOnly --declaration",
|
|
49
|
+
"test": "vitest run",
|
|
50
|
+
"lint": "biome lint src tests"
|
|
51
|
+
}
|
|
52
|
+
}
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Browser FileHandle implementation using File System Access API.
|
|
3
|
+
*
|
|
4
|
+
* Provides random access read/write operations for browser files.
|
|
5
|
+
* Uses File.slice() for efficient partial reads and FileSystemWritableFileStream
|
|
6
|
+
* for writes.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import type {
|
|
10
|
+
AppendOptions,
|
|
11
|
+
BinaryStream,
|
|
12
|
+
FileHandle,
|
|
13
|
+
ReadStreamOptions,
|
|
14
|
+
WriteStreamOptions,
|
|
15
|
+
} from "@statewalker/webrun-files";
|
|
16
|
+
import { toBinaryAsyncIterable } from "@statewalker/webrun-files";
|
|
17
|
+
|
|
18
|
+
export interface BrowserFileHandleOptions {
|
|
19
|
+
fileHandle: FileSystemFileHandle;
|
|
20
|
+
initialFile: File;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export class BrowserFileHandle implements FileHandle {
|
|
24
|
+
private fileHandle: FileSystemFileHandle;
|
|
25
|
+
private file: File | null;
|
|
26
|
+
private _size: number;
|
|
27
|
+
private closed = false;
|
|
28
|
+
|
|
29
|
+
constructor(options: BrowserFileHandleOptions) {
|
|
30
|
+
this.fileHandle = options.fileHandle;
|
|
31
|
+
this.file = options.initialFile;
|
|
32
|
+
this._size = options.initialFile.size;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
get size(): number {
|
|
36
|
+
return this._size;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
async close(): Promise<void> {
|
|
40
|
+
this.closed = true;
|
|
41
|
+
this.file = null;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Appends data to the end of the file.
|
|
46
|
+
*/
|
|
47
|
+
async appendFile(data: BinaryStream, options: AppendOptions = {}): Promise<number> {
|
|
48
|
+
if (this.closed) {
|
|
49
|
+
throw new Error("FileHandle is closed");
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const { signal } = options;
|
|
53
|
+
const writable = await this.fileHandle.createWritable({ keepExistingData: true });
|
|
54
|
+
let bytesWritten = 0;
|
|
55
|
+
|
|
56
|
+
try {
|
|
57
|
+
// Seek to end of file
|
|
58
|
+
await writable.seek(this._size);
|
|
59
|
+
|
|
60
|
+
for await (const chunk of toBinaryAsyncIterable(data)) {
|
|
61
|
+
if (signal?.aborted) {
|
|
62
|
+
throw new Error("Operation aborted");
|
|
63
|
+
}
|
|
64
|
+
// Cast to Uint8Array<ArrayBuffer> to satisfy FileSystemWritableFileStream.write()
|
|
65
|
+
await writable.write(chunk as Uint8Array<ArrayBuffer>);
|
|
66
|
+
bytesWritten += chunk.length;
|
|
67
|
+
}
|
|
68
|
+
} finally {
|
|
69
|
+
await writable.close();
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Refresh file reference and size
|
|
73
|
+
this.file = await this.fileHandle.getFile();
|
|
74
|
+
this._size = this.file.size;
|
|
75
|
+
|
|
76
|
+
return bytesWritten;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Creates a read stream for the file.
|
|
81
|
+
* Uses File.slice() for efficient partial reads.
|
|
82
|
+
*/
|
|
83
|
+
async *createReadStream(options: ReadStreamOptions = {}): AsyncGenerator<Uint8Array> {
|
|
84
|
+
if (this.closed) {
|
|
85
|
+
throw new Error("FileHandle is closed");
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Ensure we have a fresh file reference
|
|
89
|
+
if (!this.file) {
|
|
90
|
+
this.file = await this.fileHandle.getFile();
|
|
91
|
+
this._size = this.file.size;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const { start = 0, end = Infinity, signal } = options;
|
|
95
|
+
const bufferSize = 8192;
|
|
96
|
+
const actualEnd = Math.min(end, this._size);
|
|
97
|
+
|
|
98
|
+
if (start >= actualEnd || this._size === 0) return;
|
|
99
|
+
|
|
100
|
+
let position = start;
|
|
101
|
+
|
|
102
|
+
while (position < actualEnd) {
|
|
103
|
+
if (signal?.aborted) {
|
|
104
|
+
throw new Error("Operation aborted");
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const remaining = actualEnd - position;
|
|
108
|
+
const toRead = Math.min(bufferSize, remaining);
|
|
109
|
+
|
|
110
|
+
const slice = this.file.slice(position, position + toRead);
|
|
111
|
+
const buffer = await slice.arrayBuffer();
|
|
112
|
+
|
|
113
|
+
yield new Uint8Array(buffer);
|
|
114
|
+
position += buffer.byteLength;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Writes data to the file starting at the specified position.
|
|
120
|
+
* If start > 0, preserves content before start position.
|
|
121
|
+
* Truncates file at start position before writing.
|
|
122
|
+
*/
|
|
123
|
+
async createWriteStream(data: BinaryStream, options: WriteStreamOptions = {}): Promise<number> {
|
|
124
|
+
if (this.closed) {
|
|
125
|
+
throw new Error("FileHandle is closed");
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const { start = 0, signal } = options;
|
|
129
|
+
|
|
130
|
+
// Create writable stream, keeping existing data if we need to preserve content before start
|
|
131
|
+
const writable = await this.fileHandle.createWritable({
|
|
132
|
+
keepExistingData: start > 0,
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
let bytesWritten = 0;
|
|
136
|
+
|
|
137
|
+
try {
|
|
138
|
+
if (start > 0) {
|
|
139
|
+
// Seek to start position
|
|
140
|
+
await writable.seek(start);
|
|
141
|
+
// Truncate at start position (removes content after start)
|
|
142
|
+
await writable.truncate(start);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
for await (const chunk of toBinaryAsyncIterable(data)) {
|
|
146
|
+
if (signal?.aborted) {
|
|
147
|
+
throw new Error("Operation aborted");
|
|
148
|
+
}
|
|
149
|
+
// Cast to Uint8Array<ArrayBuffer> to satisfy FileSystemWritableFileStream.write()
|
|
150
|
+
await writable.write(chunk as Uint8Array<ArrayBuffer>);
|
|
151
|
+
bytesWritten += chunk.length;
|
|
152
|
+
}
|
|
153
|
+
} finally {
|
|
154
|
+
await writable.close();
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// Refresh file reference and size
|
|
158
|
+
this.file = await this.fileHandle.getFile();
|
|
159
|
+
this._size = this.file.size;
|
|
160
|
+
|
|
161
|
+
return bytesWritten;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Random access read from file at a specific position.
|
|
166
|
+
* Uses File.slice() for efficient partial reads.
|
|
167
|
+
*/
|
|
168
|
+
async read(buffer: Uint8Array, offset: number, length: number, position: number): Promise<number> {
|
|
169
|
+
if (this.closed) {
|
|
170
|
+
throw new Error("FileHandle is closed");
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// Ensure we have a fresh file reference
|
|
174
|
+
if (!this.file) {
|
|
175
|
+
this.file = await this.fileHandle.getFile();
|
|
176
|
+
this._size = this.file.size;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// Calculate actual bytes to read
|
|
180
|
+
let len = Math.min(length, buffer.length - offset);
|
|
181
|
+
len = Math.min(len, this._size - position);
|
|
182
|
+
len = Math.max(0, len);
|
|
183
|
+
|
|
184
|
+
if (len === 0) {
|
|
185
|
+
return 0;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
const slice = this.file.slice(position, position + len);
|
|
189
|
+
const arrayBuffer = await slice.arrayBuffer();
|
|
190
|
+
const data = new Uint8Array(arrayBuffer);
|
|
191
|
+
buffer.set(data, offset);
|
|
192
|
+
return len;
|
|
193
|
+
}
|
|
194
|
+
}
|