@statewalker/webrun-files-browser 0.5.0 → 0.7.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.
@@ -1,20 +1,18 @@
1
1
  /**
2
- * Browser implementation of IFilesApi using the File System Access API.
2
+ * Browser implementation of FilesApi using the File System Access API.
3
3
  *
4
4
  * Provides a filesystem-like interface over browser's FileSystemDirectoryHandle.
5
5
  * Works with directories obtained via showDirectoryPicker() or OPFS.
6
6
  */
7
7
 
8
8
  import type {
9
- CopyOptions,
10
- FileHandle,
11
9
  FileInfo,
12
- FileRef,
13
- IFilesApi,
10
+ FileStats,
11
+ FilesApi,
14
12
  ListOptions,
13
+ ReadOptions,
15
14
  } from "@statewalker/webrun-files";
16
- import { basename, dirname, joinPath, resolveFileRef } from "@statewalker/webrun-files";
17
- import { BrowserFileHandle } from "./browser-file-handle.js";
15
+ import { basename, dirname, joinPath, normalizePath } from "@statewalker/webrun-files";
18
16
 
19
17
  export interface BrowserFilesApiOptions {
20
18
  /**
@@ -24,17 +22,20 @@ export interface BrowserFilesApiOptions {
24
22
  rootHandle: FileSystemDirectoryHandle;
25
23
  }
26
24
 
27
- export class BrowserFilesApi implements IFilesApi {
25
+ /**
26
+ * Browser filesystem implementation using the File System Access API.
27
+ *
28
+ * Provides FilesApi interface over browser's FileSystemDirectoryHandle.
29
+ * This enables web applications to read/write files in user-selected directories
30
+ * or the Origin Private File System (OPFS).
31
+ */
32
+ export class BrowserFilesApi implements FilesApi {
28
33
  private rootHandle: FileSystemDirectoryHandle;
29
34
 
30
35
  constructor(options: BrowserFilesApiOptions) {
31
36
  this.rootHandle = options.rootHandle;
32
37
  }
33
38
 
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
39
  private async getDirectoryHandle(
39
40
  path: string,
40
41
  options: { create?: boolean } = {},
@@ -53,15 +54,11 @@ export class BrowserFilesApi implements IFilesApi {
53
54
  return current;
54
55
  }
55
56
 
56
- /**
57
- * Gets a file handle from a path.
58
- * Returns null if the file doesn't exist and create is false.
59
- */
60
57
  private async getFileHandle(
61
58
  path: string,
62
59
  options: { create?: boolean } = {},
63
60
  ): Promise<FileSystemFileHandle | null> {
64
- const normalized = resolveFileRef(path);
61
+ const normalized = normalizePath(path);
65
62
  const parentPath = dirname(normalized);
66
63
  const fileName = basename(normalized);
67
64
 
@@ -75,15 +72,71 @@ export class BrowserFilesApi implements IFilesApi {
75
72
  }
76
73
  }
77
74
 
78
- /**
79
- * Lists entries in a directory.
80
- */
81
- async *list(file: FileRef, options: ListOptions = {}): AsyncGenerator<FileInfo> {
82
- const normalized = resolveFileRef(file);
75
+ async *read(path: string, options?: ReadOptions): AsyncIterable<Uint8Array> {
76
+ const fileHandle = await this.getFileHandle(path);
77
+ if (!fileHandle) return;
78
+
79
+ try {
80
+ const file = await fileHandle.getFile();
81
+ const start = options?.start ?? 0;
82
+ const length = options?.length;
83
+ const end = length !== undefined ? start + length : file.size;
84
+
85
+ if (start >= file.size) return;
86
+
87
+ const bufferSize = 8192;
88
+ let position = start;
89
+ const actualEnd = Math.min(end, file.size);
90
+
91
+ while (position < actualEnd) {
92
+ const remaining = actualEnd - position;
93
+ const toRead = Math.min(bufferSize, remaining);
94
+ const slice = file.slice(position, position + toRead);
95
+ const buffer = await slice.arrayBuffer();
96
+ yield new Uint8Array(buffer);
97
+ position += buffer.byteLength;
98
+ }
99
+ } catch {
100
+ return;
101
+ }
102
+ }
103
+
104
+ async write(
105
+ path: string,
106
+ content: Iterable<Uint8Array> | AsyncIterable<Uint8Array>,
107
+ ): Promise<void> {
108
+ const normalized = normalizePath(path);
109
+ const parentPath = dirname(normalized);
110
+ const fileName = basename(normalized);
111
+
112
+ const parent = await this.getDirectoryHandle(parentPath, { create: true });
113
+ if (!parent) {
114
+ throw new Error(`Cannot create directory: ${parentPath}`);
115
+ }
116
+
117
+ const fileHandle = await parent.getFileHandle(fileName, { create: true });
118
+ const writable = await fileHandle.createWritable();
119
+
120
+ try {
121
+ for await (const chunk of content) {
122
+ await writable.write(chunk as Uint8Array<ArrayBuffer>);
123
+ }
124
+ } finally {
125
+ await writable.close();
126
+ }
127
+ }
128
+
129
+ async mkdir(path: string): Promise<void> {
130
+ const normalized = normalizePath(path);
131
+ await this.getDirectoryHandle(normalized, { create: true });
132
+ }
133
+
134
+ async *list(path: string, options?: ListOptions): AsyncIterable<FileInfo> {
135
+ const normalized = normalizePath(path);
83
136
  const dirHandle = await this.getDirectoryHandle(normalized);
84
137
  if (!dirHandle) return;
85
138
 
86
- const { recursive = false } = options;
139
+ const recursive = options?.recursive ?? false;
87
140
 
88
141
  for await (const [name, handle] of dirHandle.entries()) {
89
142
  const entryPath = joinPath(normalized, name);
@@ -102,7 +155,6 @@ export class BrowserFilesApi implements IFilesApi {
102
155
  const file = await fileHandle.getFile();
103
156
  info.size = file.size;
104
157
  info.lastModified = file.lastModified;
105
- info.type = file.type;
106
158
  } catch {
107
159
  // Ignore errors getting file metadata
108
160
  }
@@ -116,19 +168,12 @@ export class BrowserFilesApi implements IFilesApi {
116
168
  }
117
169
  }
118
170
 
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);
171
+ async stats(path: string): Promise<FileStats | undefined> {
172
+ const normalized = normalizePath(path);
125
173
 
126
- // Handle root directory
127
174
  if (normalized === "/") {
128
175
  return {
129
176
  kind: "directory",
130
- name: "",
131
- path: "/",
132
177
  lastModified: 0,
133
178
  };
134
179
  }
@@ -140,11 +185,8 @@ export class BrowserFilesApi implements IFilesApi {
140
185
  const f = await fileHandle.getFile();
141
186
  return {
142
187
  kind: "file",
143
- name: fileName,
144
- path: normalized,
145
188
  size: f.size,
146
189
  lastModified: f.lastModified,
147
- type: f.type,
148
190
  };
149
191
  } catch {
150
192
  return undefined;
@@ -156,8 +198,6 @@ export class BrowserFilesApi implements IFilesApi {
156
198
  if (dirHandle) {
157
199
  return {
158
200
  kind: "directory",
159
- name: fileName,
160
- path: normalized,
161
201
  lastModified: 0,
162
202
  };
163
203
  }
@@ -165,11 +205,13 @@ export class BrowserFilesApi implements IFilesApi {
165
205
  return undefined;
166
206
  }
167
207
 
168
- /**
169
- * Removes a file or directory.
170
- */
171
- async remove(file: FileRef): Promise<boolean> {
172
- const normalized = resolveFileRef(file);
208
+ async exists(path: string): Promise<boolean> {
209
+ const stats = await this.stats(path);
210
+ return stats !== undefined;
211
+ }
212
+
213
+ async remove(path: string): Promise<boolean> {
214
+ const normalized = normalizePath(path);
173
215
  const parentPath = dirname(normalized);
174
216
  const name = basename(normalized);
175
217
 
@@ -184,65 +226,26 @@ export class BrowserFilesApi implements IFilesApi {
184
226
  }
185
227
  }
186
228
 
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> {
229
+ async move(source: string, target: string): Promise<boolean> {
222
230
  const copied = await this.copy(source, target);
223
231
  if (!copied) return false;
224
232
  return this.remove(source);
225
233
  }
226
234
 
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;
235
+ async copy(source: string, target: string): Promise<boolean> {
236
+ const sourcePath = normalizePath(source);
237
+ const targetPath = normalizePath(target);
234
238
 
235
- const sourceInfo = await this.stats(source);
236
- if (!sourceInfo) return false;
239
+ const sourceStats = await this.stats(source);
240
+ if (!sourceStats) return false;
237
241
 
238
- if (sourceInfo.kind === "file") {
242
+ if (sourceStats.kind === "file") {
239
243
  // Copy single file
240
244
  const sourceHandle = await this.getFileHandle(sourcePath);
241
245
  if (!sourceHandle) return false;
242
246
 
243
247
  const sourceFile = await sourceHandle.getFile();
244
248
 
245
- // Create target file
246
249
  const targetParentPath = dirname(targetPath);
247
250
  const targetFileName = basename(targetPath);
248
251
 
@@ -253,7 +256,6 @@ export class BrowserFilesApi implements IFilesApi {
253
256
  const writable = await targetHandle.createWritable();
254
257
 
255
258
  try {
256
- // Stream the file content
257
259
  const reader = sourceFile.stream().getReader();
258
260
  while (true) {
259
261
  const { done, value } = await reader.read();
@@ -267,13 +269,9 @@ export class BrowserFilesApi implements IFilesApi {
267
269
  return true;
268
270
  }
269
271
 
270
- // Copy directory
271
- if (!recursive) return false;
272
-
273
- // Create target directory
272
+ // Copy directory recursively
274
273
  await this.getDirectoryHandle(targetPath, { create: true });
275
274
 
276
- // Copy all entries recursively
277
275
  for await (const entry of this.list(source, { recursive: true })) {
278
276
  if (entry.kind === "directory") continue;
279
277
 
@@ -291,7 +289,9 @@ export class BrowserFilesApi implements IFilesApi {
291
289
  const newTargetParent = await this.getDirectoryHandle(newTargetParentPath, { create: true });
292
290
  if (!newTargetParent) continue;
293
291
 
294
- const newTargetHandle = await newTargetParent.getFileHandle(newTargetFileName, { create: true });
292
+ const newTargetHandle = await newTargetParent.getFileHandle(newTargetFileName, {
293
+ create: true,
294
+ });
295
295
  const writable = await newTargetHandle.createWritable();
296
296
 
297
297
  try {
@@ -310,19 +310,8 @@ export class BrowserFilesApi implements IFilesApi {
310
310
  }
311
311
  }
312
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
313
  /**
324
314
  * Gets a BrowserFilesApi instance backed by the Origin Private File System (OPFS).
325
- * OPFS provides a sandboxed filesystem that persists between sessions.
326
315
  */
327
316
  export async function getOPFSFilesApi(): Promise<BrowserFilesApi> {
328
317
  const rootHandle = await navigator.storage.getDirectory();
package/src/index.ts CHANGED
@@ -4,9 +4,12 @@
4
4
 
5
5
  export {
6
6
  BrowserFilesApi,
7
- getOPFSFilesApi,
8
- openBrowserFilesApi,
9
7
  type BrowserFilesApiOptions,
8
+ getOPFSFilesApi,
10
9
  } from "./browser-files-api.js";
11
-
12
- export { BrowserFileHandle, type BrowserFileHandleOptions } from "./browser-file-handle.js";
10
+ export {
11
+ isHandlerAccessible,
12
+ type OpenBrowserFilesApiOptions,
13
+ openBrowserFilesApi,
14
+ verifyPermission,
15
+ } from "./open-browser-files-api.js";
@@ -0,0 +1,148 @@
1
+ /**
2
+ * Utilities for opening and managing browser file system access.
3
+ *
4
+ * Provides functions for permission verification, handle accessibility checking,
5
+ * and opening directory pickers with optional persistent storage support.
6
+ */
7
+
8
+ import { BrowserFilesApi } from "./browser-files-api.js";
9
+
10
+ /**
11
+ * Extended FileSystemHandle interface with permission methods.
12
+ * These are part of the File System Access API but not fully typed in TypeScript's lib.dom.d.ts.
13
+ */
14
+ interface FileSystemHandleWithPermissions {
15
+ queryPermission(descriptor?: { mode?: "read" | "readwrite" }): Promise<PermissionState>;
16
+ requestPermission(descriptor?: { mode?: "read" | "readwrite" }): Promise<PermissionState>;
17
+ }
18
+
19
+ /**
20
+ * Options for opening a browser files API with persistent storage support.
21
+ */
22
+ export interface OpenBrowserFilesApiOptions {
23
+ /**
24
+ * Key used to store the directory handler in persistent storage.
25
+ * @default "root-dir"
26
+ */
27
+ handlerKey?: string;
28
+ /**
29
+ * Whether to request read-write access (true) or read-only (false).
30
+ * @default true
31
+ */
32
+ readwrite?: boolean;
33
+ /**
34
+ * Gets a stored directory handler by key.
35
+ * @default Returns undefined (no persistence)
36
+ */
37
+ get?: (key: string) => Promise<FileSystemDirectoryHandle | undefined>;
38
+ /**
39
+ * Stores a directory handler with a key.
40
+ * @default No-op (no persistence)
41
+ */
42
+ set?: (key: string, handler: FileSystemDirectoryHandle) => Promise<void>;
43
+ /**
44
+ * Deletes a stored directory handler by key.
45
+ * @default No-op (no persistence)
46
+ */
47
+ del?: (key: string) => Promise<void>;
48
+ }
49
+
50
+ /**
51
+ * Checks if a file system handle is still accessible by performing a real read operation.
52
+ * Handles may become inaccessible if the underlying file/directory was moved or deleted.
53
+ *
54
+ * @param fileHandle - The file or directory handle to check
55
+ * @returns True if the handle is accessible, false otherwise
56
+ */
57
+ export async function isHandlerAccessible(
58
+ fileHandle: FileSystemFileHandle | FileSystemDirectoryHandle,
59
+ ): Promise<boolean> {
60
+ try {
61
+ if (fileHandle.kind === "file") {
62
+ await (fileHandle as FileSystemFileHandle).getFile();
63
+ } else {
64
+ // For directories, iterate to check accessibility
65
+ for await (const _item of (fileHandle as FileSystemDirectoryHandle).values()) {
66
+ break;
67
+ }
68
+ }
69
+ return true;
70
+ } catch {
71
+ return false;
72
+ }
73
+ }
74
+
75
+ /**
76
+ * Verifies and requests file system permissions for a handle.
77
+ * First checks if permission was already granted, then requests if needed.
78
+ *
79
+ * @param fileHandle - The file or directory handle to verify permissions for
80
+ * @param readWrite - Whether to request read-write access (true) or read-only (false)
81
+ * @returns True if permission is granted, false otherwise
82
+ */
83
+ export async function verifyPermission(
84
+ fileHandle: FileSystemFileHandle | FileSystemDirectoryHandle,
85
+ readWrite: boolean = false,
86
+ ): Promise<boolean> {
87
+ const handle = fileHandle as unknown as FileSystemHandleWithPermissions;
88
+ const options: { mode?: "read" | "readwrite" } = {};
89
+ if (readWrite) {
90
+ options.mode = "readwrite";
91
+ }
92
+
93
+ // Check if permission was already granted
94
+ if ((await handle.queryPermission(options)) === "granted") {
95
+ return true;
96
+ }
97
+
98
+ // Request permission if not already granted
99
+ if ((await handle.requestPermission(options)) === "granted") {
100
+ return true;
101
+ }
102
+
103
+ return false;
104
+ }
105
+
106
+ /**
107
+ * Opens a browser directory picker and returns a BrowserFilesApi instance.
108
+ * Only works in secure contexts (HTTPS) and requires user gesture.
109
+ *
110
+ * Supports persistent storage of directory handles via optional get/set/del functions.
111
+ * When persistence is configured, will attempt to reuse a previously selected directory.
112
+ *
113
+ * @param options - Configuration options for opening the files API
114
+ * @returns A BrowserFilesApi instance for the selected directory
115
+ * @throws Error if access is not granted or the directory is not accessible
116
+ */
117
+ export async function openBrowserFilesApi(
118
+ options: OpenBrowserFilesApiOptions = {},
119
+ ): Promise<BrowserFilesApi> {
120
+ const {
121
+ handlerKey = "root-dir",
122
+ readwrite = true,
123
+ get = async () => undefined,
124
+ set = async () => {},
125
+ del = async () => {},
126
+ } = options;
127
+
128
+ let rootHandle = await get(handlerKey);
129
+
130
+ if (!rootHandle) {
131
+ // Cast to any since TypeScript's lib.dom.d.ts may not include showDirectoryPicker
132
+ rootHandle = await (
133
+ globalThis as unknown as { showDirectoryPicker: () => Promise<FileSystemDirectoryHandle> }
134
+ ).showDirectoryPicker();
135
+ await set(handlerKey, rootHandle);
136
+ }
137
+
138
+ if (!(await verifyPermission(rootHandle, readwrite))) {
139
+ throw new Error("Access was not granted");
140
+ }
141
+
142
+ if (!(await isHandlerAccessible(rootHandle))) {
143
+ await del(handlerKey);
144
+ throw new Error("Cannot access the folder. Please try again.");
145
+ }
146
+
147
+ return new BrowserFilesApi({ rootHandle });
148
+ }
@@ -1,42 +0,0 @@
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
- import type { AppendOptions, BinaryStream, FileHandle, ReadStreamOptions, WriteStreamOptions } from "@statewalker/webrun-files";
9
- export interface BrowserFileHandleOptions {
10
- fileHandle: FileSystemFileHandle;
11
- initialFile: File;
12
- }
13
- export declare class BrowserFileHandle implements FileHandle {
14
- private fileHandle;
15
- private file;
16
- private _size;
17
- private closed;
18
- constructor(options: BrowserFileHandleOptions);
19
- get size(): number;
20
- close(): Promise<void>;
21
- /**
22
- * Appends data to the end of the file.
23
- */
24
- appendFile(data: BinaryStream, options?: AppendOptions): Promise<number>;
25
- /**
26
- * Creates a read stream for the file.
27
- * Uses File.slice() for efficient partial reads.
28
- */
29
- createReadStream(options?: ReadStreamOptions): AsyncGenerator<Uint8Array>;
30
- /**
31
- * Writes data to the file starting at the specified position.
32
- * If start > 0, preserves content before start position.
33
- * Truncates file at start position before writing.
34
- */
35
- createWriteStream(data: BinaryStream, options?: WriteStreamOptions): Promise<number>;
36
- /**
37
- * Random access read from file at a specific position.
38
- * Uses File.slice() for efficient partial reads.
39
- */
40
- read(buffer: Uint8Array, offset: number, length: number, position: number): Promise<number>;
41
- }
42
- //# sourceMappingURL=browser-file-handle.d.ts.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"browser-file-handle.d.ts","sourceRoot":"","sources":["../src/browser-file-handle.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AAEH,OAAO,KAAK,EACV,aAAa,EACb,YAAY,EACZ,UAAU,EACV,iBAAiB,EACjB,kBAAkB,EACnB,MAAM,2BAA2B,CAAC;AAGnC,MAAM,WAAW,wBAAwB;IACvC,UAAU,EAAE,oBAAoB,CAAC;IACjC,WAAW,EAAE,IAAI,CAAC;CACnB;AAED,qBAAa,iBAAkB,YAAW,UAAU;IAClD,OAAO,CAAC,UAAU,CAAuB;IACzC,OAAO,CAAC,IAAI,CAAc;IAC1B,OAAO,CAAC,KAAK,CAAS;IACtB,OAAO,CAAC,MAAM,CAAS;gBAEX,OAAO,EAAE,wBAAwB;IAM7C,IAAI,IAAI,IAAI,MAAM,CAEjB;IAEK,KAAK,IAAI,OAAO,CAAC,IAAI,CAAC;IAK5B;;OAEG;IACG,UAAU,CAAC,IAAI,EAAE,YAAY,EAAE,OAAO,GAAE,aAAkB,GAAG,OAAO,CAAC,MAAM,CAAC;IAgClF;;;OAGG;IACI,gBAAgB,CAAC,OAAO,GAAE,iBAAsB,GAAG,cAAc,CAAC,UAAU,CAAC;IAmCpF;;;;OAIG;IACG,iBAAiB,CAAC,IAAI,EAAE,YAAY,EAAE,OAAO,GAAE,kBAAuB,GAAG,OAAO,CAAC,MAAM,CAAC;IAyC9F;;;OAGG;IACG,IAAI,CAAC,MAAM,EAAE,UAAU,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC;CA0BlG"}