@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,330 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Browser implementation of IFilesApi using the File System Access API.
|
|
3
|
+
*
|
|
4
|
+
* Provides a filesystem-like interface over browser's FileSystemDirectoryHandle.
|
|
5
|
+
* Works with directories obtained via showDirectoryPicker() or OPFS.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type {
|
|
9
|
+
CopyOptions,
|
|
10
|
+
FileHandle,
|
|
11
|
+
FileInfo,
|
|
12
|
+
FileRef,
|
|
13
|
+
IFilesApi,
|
|
14
|
+
ListOptions,
|
|
15
|
+
} from "@statewalker/webrun-files";
|
|
16
|
+
import { basename, dirname, joinPath, resolveFileRef } from "@statewalker/webrun-files";
|
|
17
|
+
import { BrowserFileHandle } from "./browser-file-handle.js";
|
|
18
|
+
|
|
19
|
+
export interface BrowserFilesApiOptions {
|
|
20
|
+
/**
|
|
21
|
+
* Root FileSystemDirectoryHandle to use as the filesystem root.
|
|
22
|
+
* Can be obtained via showDirectoryPicker(), navigator.storage.getDirectory(), etc.
|
|
23
|
+
*/
|
|
24
|
+
rootHandle: FileSystemDirectoryHandle;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export class BrowserFilesApi implements IFilesApi {
|
|
28
|
+
private rootHandle: FileSystemDirectoryHandle;
|
|
29
|
+
|
|
30
|
+
constructor(options: BrowserFilesApiOptions) {
|
|
31
|
+
this.rootHandle = options.rootHandle;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Navigates to a directory handle from a path.
|
|
36
|
+
* Returns null if the directory doesn't exist and create is false.
|
|
37
|
+
*/
|
|
38
|
+
private async getDirectoryHandle(
|
|
39
|
+
path: string,
|
|
40
|
+
options: { create?: boolean } = {},
|
|
41
|
+
): Promise<FileSystemDirectoryHandle | null> {
|
|
42
|
+
const segments = path.split("/").filter((s) => s.length > 0);
|
|
43
|
+
let current = this.rootHandle;
|
|
44
|
+
|
|
45
|
+
for (const segment of segments) {
|
|
46
|
+
try {
|
|
47
|
+
current = await current.getDirectoryHandle(segment, options);
|
|
48
|
+
} catch {
|
|
49
|
+
return null;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
return current;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Gets a file handle from a path.
|
|
58
|
+
* Returns null if the file doesn't exist and create is false.
|
|
59
|
+
*/
|
|
60
|
+
private async getFileHandle(
|
|
61
|
+
path: string,
|
|
62
|
+
options: { create?: boolean } = {},
|
|
63
|
+
): Promise<FileSystemFileHandle | null> {
|
|
64
|
+
const normalized = resolveFileRef(path);
|
|
65
|
+
const parentPath = dirname(normalized);
|
|
66
|
+
const fileName = basename(normalized);
|
|
67
|
+
|
|
68
|
+
const parent = await this.getDirectoryHandle(parentPath, options);
|
|
69
|
+
if (!parent) return null;
|
|
70
|
+
|
|
71
|
+
try {
|
|
72
|
+
return await parent.getFileHandle(fileName, options);
|
|
73
|
+
} catch {
|
|
74
|
+
return null;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Lists entries in a directory.
|
|
80
|
+
*/
|
|
81
|
+
async *list(file: FileRef, options: ListOptions = {}): AsyncGenerator<FileInfo> {
|
|
82
|
+
const normalized = resolveFileRef(file);
|
|
83
|
+
const dirHandle = await this.getDirectoryHandle(normalized);
|
|
84
|
+
if (!dirHandle) return;
|
|
85
|
+
|
|
86
|
+
const { recursive = false } = options;
|
|
87
|
+
|
|
88
|
+
for await (const [name, handle] of dirHandle.entries()) {
|
|
89
|
+
const entryPath = joinPath(normalized, name);
|
|
90
|
+
const isDirectory = handle.kind === "directory";
|
|
91
|
+
|
|
92
|
+
const info: FileInfo = {
|
|
93
|
+
kind: isDirectory ? "directory" : "file",
|
|
94
|
+
name,
|
|
95
|
+
path: entryPath,
|
|
96
|
+
lastModified: 0,
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
if (!isDirectory) {
|
|
100
|
+
try {
|
|
101
|
+
const fileHandle = handle as FileSystemFileHandle;
|
|
102
|
+
const file = await fileHandle.getFile();
|
|
103
|
+
info.size = file.size;
|
|
104
|
+
info.lastModified = file.lastModified;
|
|
105
|
+
info.type = file.type;
|
|
106
|
+
} catch {
|
|
107
|
+
// Ignore errors getting file metadata
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
yield info;
|
|
112
|
+
|
|
113
|
+
if (recursive && isDirectory) {
|
|
114
|
+
yield* this.list(entryPath, options);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Gets file or directory stats.
|
|
121
|
+
*/
|
|
122
|
+
async stats(file: FileRef): Promise<FileInfo | undefined> {
|
|
123
|
+
const normalized = resolveFileRef(file);
|
|
124
|
+
const fileName = basename(normalized);
|
|
125
|
+
|
|
126
|
+
// Handle root directory
|
|
127
|
+
if (normalized === "/") {
|
|
128
|
+
return {
|
|
129
|
+
kind: "directory",
|
|
130
|
+
name: "",
|
|
131
|
+
path: "/",
|
|
132
|
+
lastModified: 0,
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// Try as file first
|
|
137
|
+
const fileHandle = await this.getFileHandle(normalized);
|
|
138
|
+
if (fileHandle) {
|
|
139
|
+
try {
|
|
140
|
+
const f = await fileHandle.getFile();
|
|
141
|
+
return {
|
|
142
|
+
kind: "file",
|
|
143
|
+
name: fileName,
|
|
144
|
+
path: normalized,
|
|
145
|
+
size: f.size,
|
|
146
|
+
lastModified: f.lastModified,
|
|
147
|
+
type: f.type,
|
|
148
|
+
};
|
|
149
|
+
} catch {
|
|
150
|
+
return undefined;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// Try as directory
|
|
155
|
+
const dirHandle = await this.getDirectoryHandle(normalized);
|
|
156
|
+
if (dirHandle) {
|
|
157
|
+
return {
|
|
158
|
+
kind: "directory",
|
|
159
|
+
name: fileName,
|
|
160
|
+
path: normalized,
|
|
161
|
+
lastModified: 0,
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
return undefined;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Removes a file or directory.
|
|
170
|
+
*/
|
|
171
|
+
async remove(file: FileRef): Promise<boolean> {
|
|
172
|
+
const normalized = resolveFileRef(file);
|
|
173
|
+
const parentPath = dirname(normalized);
|
|
174
|
+
const name = basename(normalized);
|
|
175
|
+
|
|
176
|
+
const parent = await this.getDirectoryHandle(parentPath);
|
|
177
|
+
if (!parent) return false;
|
|
178
|
+
|
|
179
|
+
try {
|
|
180
|
+
await parent.removeEntry(name, { recursive: true });
|
|
181
|
+
return true;
|
|
182
|
+
} catch {
|
|
183
|
+
return false;
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Opens a file for reading and writing.
|
|
189
|
+
* Creates the file and parent directories if they don't exist.
|
|
190
|
+
*/
|
|
191
|
+
async open(file: FileRef): Promise<FileHandle> {
|
|
192
|
+
const normalized = resolveFileRef(file);
|
|
193
|
+
const parentPath = dirname(normalized);
|
|
194
|
+
const fileName = basename(normalized);
|
|
195
|
+
|
|
196
|
+
// Ensure parent directory exists
|
|
197
|
+
const parent = await this.getDirectoryHandle(parentPath, { create: true });
|
|
198
|
+
if (!parent) {
|
|
199
|
+
throw new Error(`Cannot create directory: ${parentPath}`);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// Get or create file
|
|
203
|
+
const fileHandle = await parent.getFileHandle(fileName, { create: true });
|
|
204
|
+
const f = await fileHandle.getFile();
|
|
205
|
+
|
|
206
|
+
return new BrowserFileHandle({ fileHandle, initialFile: f });
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* Creates a directory (and parent directories if needed).
|
|
211
|
+
*/
|
|
212
|
+
async mkdir(file: FileRef): Promise<void> {
|
|
213
|
+
const normalized = resolveFileRef(file);
|
|
214
|
+
await this.getDirectoryHandle(normalized, { create: true });
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* Moves a file or directory using copy + delete.
|
|
219
|
+
* The File System Access API doesn't have a native move operation.
|
|
220
|
+
*/
|
|
221
|
+
async move(source: FileRef, target: FileRef): Promise<boolean> {
|
|
222
|
+
const copied = await this.copy(source, target);
|
|
223
|
+
if (!copied) return false;
|
|
224
|
+
return this.remove(source);
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* Copies a file or directory.
|
|
229
|
+
*/
|
|
230
|
+
async copy(source: FileRef, target: FileRef, options: CopyOptions = {}): Promise<boolean> {
|
|
231
|
+
const sourcePath = resolveFileRef(source);
|
|
232
|
+
const targetPath = resolveFileRef(target);
|
|
233
|
+
const { recursive = true } = options;
|
|
234
|
+
|
|
235
|
+
const sourceInfo = await this.stats(source);
|
|
236
|
+
if (!sourceInfo) return false;
|
|
237
|
+
|
|
238
|
+
if (sourceInfo.kind === "file") {
|
|
239
|
+
// Copy single file
|
|
240
|
+
const sourceHandle = await this.getFileHandle(sourcePath);
|
|
241
|
+
if (!sourceHandle) return false;
|
|
242
|
+
|
|
243
|
+
const sourceFile = await sourceHandle.getFile();
|
|
244
|
+
|
|
245
|
+
// Create target file
|
|
246
|
+
const targetParentPath = dirname(targetPath);
|
|
247
|
+
const targetFileName = basename(targetPath);
|
|
248
|
+
|
|
249
|
+
const targetParent = await this.getDirectoryHandle(targetParentPath, { create: true });
|
|
250
|
+
if (!targetParent) return false;
|
|
251
|
+
|
|
252
|
+
const targetHandle = await targetParent.getFileHandle(targetFileName, { create: true });
|
|
253
|
+
const writable = await targetHandle.createWritable();
|
|
254
|
+
|
|
255
|
+
try {
|
|
256
|
+
// Stream the file content
|
|
257
|
+
const reader = sourceFile.stream().getReader();
|
|
258
|
+
while (true) {
|
|
259
|
+
const { done, value } = await reader.read();
|
|
260
|
+
if (done) break;
|
|
261
|
+
await writable.write(value);
|
|
262
|
+
}
|
|
263
|
+
} finally {
|
|
264
|
+
await writable.close();
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
return true;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
// Copy directory
|
|
271
|
+
if (!recursive) return false;
|
|
272
|
+
|
|
273
|
+
// Create target directory
|
|
274
|
+
await this.getDirectoryHandle(targetPath, { create: true });
|
|
275
|
+
|
|
276
|
+
// Copy all entries recursively
|
|
277
|
+
for await (const entry of this.list(source, { recursive: true })) {
|
|
278
|
+
if (entry.kind === "directory") continue;
|
|
279
|
+
|
|
280
|
+
const relativePath = entry.path.substring(sourcePath.length);
|
|
281
|
+
const newTargetPath = targetPath + relativePath;
|
|
282
|
+
|
|
283
|
+
const entryHandle = await this.getFileHandle(entry.path);
|
|
284
|
+
if (!entryHandle) continue;
|
|
285
|
+
|
|
286
|
+
const entryFile = await entryHandle.getFile();
|
|
287
|
+
|
|
288
|
+
const newTargetParentPath = dirname(newTargetPath);
|
|
289
|
+
const newTargetFileName = basename(newTargetPath);
|
|
290
|
+
|
|
291
|
+
const newTargetParent = await this.getDirectoryHandle(newTargetParentPath, { create: true });
|
|
292
|
+
if (!newTargetParent) continue;
|
|
293
|
+
|
|
294
|
+
const newTargetHandle = await newTargetParent.getFileHandle(newTargetFileName, { create: true });
|
|
295
|
+
const writable = await newTargetHandle.createWritable();
|
|
296
|
+
|
|
297
|
+
try {
|
|
298
|
+
const reader = entryFile.stream().getReader();
|
|
299
|
+
while (true) {
|
|
300
|
+
const { done, value } = await reader.read();
|
|
301
|
+
if (done) break;
|
|
302
|
+
await writable.write(value);
|
|
303
|
+
}
|
|
304
|
+
} finally {
|
|
305
|
+
await writable.close();
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
return true;
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/**
|
|
314
|
+
* Opens a browser directory picker and returns a BrowserFilesApi instance.
|
|
315
|
+
* Only works in secure contexts (HTTPS) and requires user gesture.
|
|
316
|
+
*/
|
|
317
|
+
export async function openBrowserFilesApi(): Promise<BrowserFilesApi> {
|
|
318
|
+
// Cast to any since TypeScript's lib.dom.d.ts may not include showDirectoryPicker
|
|
319
|
+
const rootHandle = await (globalThis as unknown as { showDirectoryPicker: () => Promise<FileSystemDirectoryHandle> }).showDirectoryPicker();
|
|
320
|
+
return new BrowserFilesApi({ rootHandle });
|
|
321
|
+
}
|
|
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
|
+
export async function getOPFSFilesApi(): Promise<BrowserFilesApi> {
|
|
328
|
+
const rootHandle = await navigator.storage.getDirectory();
|
|
329
|
+
return new BrowserFilesApi({ rootHandle });
|
|
330
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* webrun-files-browser - Browser File System Access API implementation
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
export {
|
|
6
|
+
BrowserFilesApi,
|
|
7
|
+
getOPFSFilesApi,
|
|
8
|
+
openBrowserFilesApi,
|
|
9
|
+
type BrowserFilesApiOptions,
|
|
10
|
+
} from "./browser-files-api.js";
|
|
11
|
+
|
|
12
|
+
export { BrowserFileHandle, type BrowserFileHandleOptions } from "./browser-file-handle.js";
|