@statewalker/webrun-files-browser 0.10.4 → 0.11.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 +69 -6
- package/dist/browser-files-api.d.ts +76 -0
- package/dist/browser-files-api.d.ts.map +1 -1
- package/dist/cjs/index.cjs +202 -32
- package/dist/esm/index.js +203 -33
- package/package.json +2 -2
- package/src/browser-files-api.ts +255 -43
package/README.md
CHANGED
|
@@ -26,7 +26,7 @@ System Access API; requires a secure context (HTTPS or localhost).
|
|
|
26
26
|
|
|
27
27
|
| Export | What it gives |
|
|
28
28
|
| --- | --- |
|
|
29
|
-
| `new BrowserFilesApi({ rootHandle })` | A `FilesApi` over any `FileSystemDirectoryHandle` |
|
|
29
|
+
| `new BrowserFilesApi({ rootHandle, lockScope? })` | A `FilesApi` over any `FileSystemDirectoryHandle`; `lockScope` names a directory outside OPFS in `createExclusive`'s locks |
|
|
30
30
|
| `openBrowserFilesApi(options?)` | Reuses a stored handle or shows `showDirectoryPicker()`, checks permission and accessibility |
|
|
31
31
|
| `getOPFSFilesApi()` | A `BrowserFilesApi` over `navigator.storage.getDirectory()` |
|
|
32
32
|
| `verifyPermission(handle, readWrite = false)` | Queries, then requests, `read` or `readwrite` permission |
|
|
@@ -86,6 +86,38 @@ if ((await verifyPermission(handle, true)) && (await isHandlerAccessible(handle)
|
|
|
86
86
|
}
|
|
87
87
|
```
|
|
88
88
|
|
|
89
|
+
### A lock file shared by every tab
|
|
90
|
+
|
|
91
|
+
```typescript
|
|
92
|
+
const files = await getOPFSFilesApi();
|
|
93
|
+
|
|
94
|
+
if (await files.createExclusive('/repo/.git/index.lock', [new TextEncoder().encode('locked')])) {
|
|
95
|
+
try {
|
|
96
|
+
// ... no other tab or worker of this origin gets here meanwhile
|
|
97
|
+
} finally {
|
|
98
|
+
await files.remove('/repo/.git/index.lock');
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
The File System Access API cannot create a file only if it is absent, so `createExclusive` runs
|
|
104
|
+
the check and the create under a Web Lock (`navigator.locks`) named after the file. Every tab and
|
|
105
|
+
worker of the origin that calls `createExclusive` for that file asks for the same lock, with
|
|
106
|
+
`ifAvailable`: the one that gets it creates the file, and any call that finds it held returns
|
|
107
|
+
`false` at once, without waiting for the holder to finish writing.
|
|
108
|
+
|
|
109
|
+
The lock has to name the file the same way in every tab and every instance. Under an OPFS root it
|
|
110
|
+
is named by the file's full path in OPFS, `webrun-files:opfs:/repo/.git/index.lock`, so an
|
|
111
|
+
instance rooted at OPFS `/repo` locking `/.git/index.lock` and one rooted at OPFS `/` locking
|
|
112
|
+
`/repo/.git/index.lock` take the same lock. A directory from `showDirectoryPicker()` or a drop
|
|
113
|
+
has no such name, so the lock falls back to the handle object, which only instances built on that
|
|
114
|
+
very object share; two tabs that each obtained the handle exclude each other only by the
|
|
115
|
+
existence check, which can race. Give both the same `lockScope` (it is ignored under OPFS):
|
|
116
|
+
|
|
117
|
+
```typescript
|
|
118
|
+
const files = new BrowserFilesApi({ rootHandle: handle, lockScope: 'project:my-repo' });
|
|
119
|
+
```
|
|
120
|
+
|
|
89
121
|
### Testing in Node.js
|
|
90
122
|
|
|
91
123
|
```typescript
|
|
@@ -105,10 +137,30 @@ This is how the package's own tests run.
|
|
|
105
137
|
|
|
106
138
|
- `read` slices the `File` from `getFile()` into 8 KiB `arrayBuffer()` reads over the requested range.
|
|
107
139
|
- `write` creates parents, then streams every chunk into `createWritable()` and closes it.
|
|
140
|
+
- `createExclusive` asks for the Web Lock `webrun-files:opfs:<full OPFS path>` (under an OPFS
|
|
141
|
+
root) or `webrun-files:<lockScope or handle id>:<path>` (elsewhere) in `exclusive` mode with
|
|
142
|
+
`ifAvailable: true`, and returns `false` if it is held. Holding it, it looks for a file or
|
|
143
|
+
directory of that name with `getFileHandle` / `getDirectoryHandle` (no `create`) and, if there
|
|
144
|
+
is none, creates the parents and writes the file as `write` does.
|
|
108
145
|
- `remove` is `removeEntry(name, { recursive: true })` on the parent.
|
|
109
|
-
- `move`
|
|
110
|
-
|
|
111
|
-
|
|
146
|
+
- `move` first checks an existing target as `rename(2)` would: a file replaces a file and a
|
|
147
|
+
directory an empty directory; a file onto a directory, a directory onto a file or a non-empty
|
|
148
|
+
directory, or a directory into its own subtree returns `false` and changes source and target
|
|
149
|
+
not at all (the target's parent directories, created first, may be left even then). It then uses
|
|
150
|
+
the native `FileSystemHandle.move()` where it exists (Chromium 110+), which renames in place
|
|
151
|
+
without copying and is atomic in OPFS; current Chromium replaces an existing file. The target
|
|
152
|
+
check and the native move are separate steps, so another writer can change the target in
|
|
153
|
+
between; the native move then decides on its own. Without native move, or when it reports the
|
|
154
|
+
move unsupported (`TypeError`, `NotSupportedError`) or refuses an existing target
|
|
155
|
+
(`InvalidModificationError` on older versions), `move` copies then removes, which
|
|
156
|
+
is not atomic: a file is copied through a writable stream, so the target's content is swapped
|
|
157
|
+
in one step on close; a directory is copied file by file. If the source cannot be removed after
|
|
158
|
+
the copy, the removal is retried once, then `move` throws a `MoveIncompleteError`
|
|
159
|
+
(`move /a -> /b: the target was replaced, but the source could not be removed`): the target
|
|
160
|
+
already holds the new content and the source still exists. It never returns `false` for a
|
|
161
|
+
half-done move. Any other native error (`NoModificationAllowedError` for a locked entry,
|
|
162
|
+
`NotAllowedError`, ...) is rethrown, except that a native move which throws after the source
|
|
163
|
+
has already moved (the source is gone, the target exists) counts as done and returns `true`.
|
|
112
164
|
|
|
113
165
|
### Why listings read every file
|
|
114
166
|
|
|
@@ -124,8 +176,19 @@ subtrees that sort at or before `after`. A large directory costs one `getFile()`
|
|
|
124
176
|
deleted directory (it calls `del(handlerKey)` first, so the next call shows the picker).
|
|
125
177
|
- **No user gesture, no picker.** Calling `openBrowserFilesApi()` without a stored handle outside a
|
|
126
178
|
click or key handler rejects with the browser's `SecurityError`.
|
|
127
|
-
- **A failed write
|
|
128
|
-
writable stream
|
|
179
|
+
- **A failed write keeps the old file.** If the content iterable throws midway, `write` aborts
|
|
180
|
+
the writable stream (its swap file is dropped), so the previous content stays, and a file the
|
|
181
|
+
write created is removed again.
|
|
182
|
+
- **Without Web Locks, exclusion stops at the realm.** Where `navigator.locks` is missing (Node.js
|
|
183
|
+
before 24 with a polyfilled handle, some test adapters), an in-memory lock per lock name
|
|
184
|
+
stands in: calls in the same JavaScript realm still exclude each other (a call that finds the
|
|
185
|
+
lock held returns `false` at once), other tabs or workers only through the existence check.
|
|
186
|
+
- **A held lock means `false`.** A call that finds another call holding the lock returns `false`
|
|
187
|
+
without waiting. If that other call's content then fails, the path ends up free even though
|
|
188
|
+
this call reported it taken.
|
|
189
|
+
- **`createExclusive` excludes only itself.** A plain `write` to the path, from this or another
|
|
190
|
+
tab, does not take the lock. While the content is written the file exists and reads as empty;
|
|
191
|
+
a source that fails removes it again.
|
|
129
192
|
- **Read errors look like missing files.** `read` yields nothing when the handle or file cannot be
|
|
130
193
|
read.
|
|
131
194
|
|
|
@@ -11,6 +11,15 @@ export interface BrowserFilesApiOptions {
|
|
|
11
11
|
* Can be obtained via showDirectoryPicker(), navigator.storage.getDirectory(), etc.
|
|
12
12
|
*/
|
|
13
13
|
rootHandle: FileSystemDirectoryHandle;
|
|
14
|
+
/**
|
|
15
|
+
* Names a directory outside the Origin Private File System in the locks
|
|
16
|
+
* `createExclusive` takes, so that separately created `BrowserFilesApi` instances over
|
|
17
|
+
* it exclude each other. Give every instance over the same directory the same value,
|
|
18
|
+
* and different directories different values. Without it, such a directory is named by
|
|
19
|
+
* its handle object (see `createExclusive`). Ignored for a root inside the Origin
|
|
20
|
+
* Private File System: there the lock is named by the file's full path in it.
|
|
21
|
+
*/
|
|
22
|
+
lockScope?: string;
|
|
14
23
|
}
|
|
15
24
|
/**
|
|
16
25
|
* Browser filesystem implementation using the File System Access API.
|
|
@@ -21,11 +30,50 @@ export interface BrowserFilesApiOptions {
|
|
|
21
30
|
*/
|
|
22
31
|
export declare class BrowserFilesApi implements FilesApi {
|
|
23
32
|
private rootHandle;
|
|
33
|
+
private readonly lockScope;
|
|
34
|
+
private lockNamer;
|
|
24
35
|
constructor(options: BrowserFilesApiOptions);
|
|
25
36
|
private getDirectoryHandle;
|
|
26
37
|
private getFileHandle;
|
|
27
38
|
read(path: string, options?: ReadOptions): AsyncIterable<Uint8Array>;
|
|
28
39
|
write(path: string, content: Iterable<Uint8Array> | AsyncIterable<Uint8Array>): Promise<void>;
|
|
40
|
+
/**
|
|
41
|
+
* Write `content` to `fileName` in `parent`. A writable stream writes to a swap file:
|
|
42
|
+
* close() commits it, abort() drops it and leaves the old file as it was. So a source
|
|
43
|
+
* that fails part way keeps the old file (and a file this write created is removed
|
|
44
|
+
* again) instead of committing a cut-off one.
|
|
45
|
+
*/
|
|
46
|
+
private writeFileIn;
|
|
47
|
+
/**
|
|
48
|
+
* The File System Access API has no exclusive create, so the check and the create run
|
|
49
|
+
* under a lock named after this directory and the path:
|
|
50
|
+
*
|
|
51
|
+
* - With the Web Locks API (`navigator.locks`, in every current browser), the lock is
|
|
52
|
+
* shared by all tabs and workers of the origin, so calls from any of them exclude
|
|
53
|
+
* each other.
|
|
54
|
+
* - Without it (some test environments and polyfills), an in-memory mutex stands in,
|
|
55
|
+
* which excludes calls within this JavaScript realm only; another tab or worker is
|
|
56
|
+
* then excluded only by the existence check, which can race.
|
|
57
|
+
*
|
|
58
|
+
* The lock is asked for `ifAvailable`: a call that finds it held returns false at once
|
|
59
|
+
* instead of waiting, since the path is being taken. (Should the holder's content then
|
|
60
|
+
* fail, the path stays free, though this call has returned false.)
|
|
61
|
+
*
|
|
62
|
+
* When the root lies in the Origin Private File System, the lock is named by the file's
|
|
63
|
+
* full path there, `webrun-files:opfs:/repo/HEAD.lock`, so instances rooted at
|
|
64
|
+
* different OPFS directories take the same lock for the same file. Outside it, the
|
|
65
|
+
* lock names the directory by `lockScope` when one was given, else by the handle
|
|
66
|
+
* object, which only instances built on that same object share. Two instances over
|
|
67
|
+
* one picked directory, from separately obtained handles, then exclude each other only
|
|
68
|
+
* through the existence check: pass them the same `lockScope`.
|
|
69
|
+
*
|
|
70
|
+
* Writers that do not take the lock — `write`, or other programs — are not excluded.
|
|
71
|
+
* The file exists, empty, while its content is written, and is removed again if the
|
|
72
|
+
* content fails.
|
|
73
|
+
*/
|
|
74
|
+
createExclusive(path: string, content: Iterable<Uint8Array> | AsyncIterable<Uint8Array>): Promise<boolean>;
|
|
75
|
+
/** Names the lock for a normalized path; see {@link createExclusive}. */
|
|
76
|
+
private getLockNamer;
|
|
29
77
|
mkdir(path: string): Promise<void>;
|
|
30
78
|
/** Directory handles enumerate in no defined order, so the tree is walked in `comparePaths` order. */
|
|
31
79
|
list(path: string, options?: ListOptions): AsyncIterable<FileInfo>;
|
|
@@ -34,6 +82,34 @@ export declare class BrowserFilesApi implements FilesApi {
|
|
|
34
82
|
stats(path: string): Promise<FileStats | undefined>;
|
|
35
83
|
exists(path: string): Promise<boolean>;
|
|
36
84
|
remove(path: string): Promise<boolean>;
|
|
85
|
+
/**
|
|
86
|
+
* Uses the native `FileSystemHandle.move()` where it exists (Chromium; atomic in the
|
|
87
|
+
* Origin Private File System), else copies, then deletes the source.
|
|
88
|
+
*
|
|
89
|
+
* - Native move over an existing file: current Chromium replaces the file; versions
|
|
90
|
+
* that throw `InvalidModificationError` instead (the target exists) fall back to the
|
|
91
|
+
* copy below, which still replaces the target atomically.
|
|
92
|
+
* - Other native errors: only an unsupported move (`TypeError`, `NotSupportedError`)
|
|
93
|
+
* falls back; anything else (a locked entry, no permission, ...) is rethrown. If the
|
|
94
|
+
* native move threw after the source had already moved (the source is gone and the
|
|
95
|
+
* target exists), the move counts as done and returns true.
|
|
96
|
+
* - Time of check: the target is checked (below) before the native move runs, with
|
|
97
|
+
* `await`s between them, so another writer can create or change the target in that
|
|
98
|
+
* gap; the native move then decides on its own (replacing a file, or throwing).
|
|
99
|
+
* - Fallback, NOT atomic as a whole: a file is copied through a writable stream,
|
|
100
|
+
* whose swap file replaces the target's content in one step on close; a directory
|
|
101
|
+
* is copied file by file. If the copy fails, the error is thrown and the source
|
|
102
|
+
* kept. If the copy succeeds but the source cannot be deleted, the delete is retried
|
|
103
|
+
* once, then a `MoveIncompleteError` is thrown: the target already holds the new
|
|
104
|
+
* content and the source still exists. False is never returned for a half-done move.
|
|
105
|
+
*
|
|
106
|
+
* An existing target is checked first, as POSIX `rename(2)` treats it: a file
|
|
107
|
+
* replaces a file and a directory an empty directory; anything else (a file onto a
|
|
108
|
+
* directory, a directory onto a file or a non-empty directory, a directory into its
|
|
109
|
+
* own subtree) returns false and changes nothing. A move onto itself is a no-op.
|
|
110
|
+
* The target's parent directories are created before the native move or the copy,
|
|
111
|
+
* so they may be left behind even when `move` returns false.
|
|
112
|
+
*/
|
|
37
113
|
move(source: string, target: string): Promise<boolean>;
|
|
38
114
|
copy(source: string, target: string): Promise<boolean>;
|
|
39
115
|
}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"browser-files-api.d.ts","sourceRoot":"","sources":["../src/browser-files-api.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAEH,OAAO,KAAK,EACV,QAAQ,EACR,SAAS,EACT,QAAQ,EACR,WAAW,EACX,WAAW,EACZ,MAAM,2BAA2B,CAAC;
|
|
1
|
+
{"version":3,"file":"browser-files-api.d.ts","sourceRoot":"","sources":["../src/browser-files-api.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAEH,OAAO,KAAK,EACV,QAAQ,EACR,SAAS,EACT,QAAQ,EACR,WAAW,EACX,WAAW,EACZ,MAAM,2BAA2B,CAAC;AAenC,MAAM,WAAW,sBAAsB;IACrC;;;OAGG;IACH,UAAU,EAAE,yBAAyB,CAAC;IACtC;;;;;;;OAOG;IACH,SAAS,CAAC,EAAE,MAAM,CAAC;CACpB;AAED;;;;;;GAMG;AACH,qBAAa,eAAgB,YAAW,QAAQ;IAC9C,OAAO,CAAC,UAAU,CAA4B;IAC9C,OAAO,CAAC,QAAQ,CAAC,SAAS,CAAqB;IAC/C,OAAO,CAAC,SAAS,CAAgD;IAEjE,YAAY,OAAO,EAAE,sBAAsB,EAG1C;YAEa,kBAAkB;YAkBlB,aAAa;IAkBpB,IAAI,CAAC,IAAI,EAAE,MAAM,EAAE,OAAO,CAAC,EAAE,WAAW,GAAG,aAAa,CAAC,UAAU,CAAC,CA2B1E;IAEK,KAAK,CACT,IAAI,EAAE,MAAM,EACZ,OAAO,EAAE,QAAQ,CAAC,UAAU,CAAC,GAAG,aAAa,CAAC,UAAU,CAAC,GACxD,OAAO,CAAC,IAAI,CAAC,CAWf;IAED;;;;;OAKG;YACW,WAAW;IAkCzB;;;;;;;;;;;;;;;;;;;;;;;;;;OA0BG;IACG,eAAe,CACnB,IAAI,EAAE,MAAM,EACZ,OAAO,EAAE,QAAQ,CAAC,UAAU,CAAC,GAAG,aAAa,CAAC,UAAU,CAAC,GACxD,OAAO,CAAC,OAAO,CAAC,CAelB;IAED,yEAAyE;IACzE,OAAO,CAAC,YAAY;IAkBd,KAAK,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAGvC;IAED,sGAAsG;IAC/F,IAAI,CAAC,IAAI,EAAE,MAAM,EAAE,OAAO,CAAC,EAAE,WAAW,GAAG,aAAa,CAAC,QAAQ,CAAC,CAExE;IAED,gFAAgF;YAClE,YAAY;IAiCpB,KAAK,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,SAAS,GAAG,SAAS,CAAC,CA6BxD;IAEK,MAAM,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC,CAG3C;IAEK,MAAM,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC,CAc3C;IAED;;;;;;;;;;;;;;;;;;;;;;;;;;;OA2BG;IACG,IAAI,CAAC,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC,CAwC3D;IAEK,IAAI,CAAC,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC,CAgD3D;CACF;AAED;;GAEG;AACH,wBAAsB,eAAe,IAAI,OAAO,CAAC,eAAe,CAAC,CAGhE"}
|
package/dist/cjs/index.cjs
CHANGED
|
@@ -10,8 +10,11 @@ let _statewalker_webrun_files = require("@statewalker/webrun-files");
|
|
|
10
10
|
*/
|
|
11
11
|
var BrowserFilesApi = class {
|
|
12
12
|
rootHandle;
|
|
13
|
+
lockScope;
|
|
14
|
+
lockNamer;
|
|
13
15
|
constructor(options) {
|
|
14
16
|
this.rootHandle = options.rootHandle;
|
|
17
|
+
this.lockScope = options.lockScope;
|
|
15
18
|
}
|
|
16
19
|
async getDirectoryHandle(path, options = {}) {
|
|
17
20
|
const segments = path.split("/").filter((s) => s.length > 0);
|
|
@@ -64,13 +67,89 @@ var BrowserFilesApi = class {
|
|
|
64
67
|
const fileName = (0, _statewalker_webrun_files.basename)(normalized);
|
|
65
68
|
const parent = await this.getDirectoryHandle(parentPath, { create: true });
|
|
66
69
|
if (!parent) throw new Error(`Cannot create directory: ${parentPath}`);
|
|
67
|
-
|
|
70
|
+
await this.writeFileIn(parent, fileName, content);
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* Write `content` to `fileName` in `parent`. A writable stream writes to a swap file:
|
|
74
|
+
* close() commits it, abort() drops it and leaves the old file as it was. So a source
|
|
75
|
+
* that fails part way keeps the old file (and a file this write created is removed
|
|
76
|
+
* again) instead of committing a cut-off one.
|
|
77
|
+
*/
|
|
78
|
+
async writeFileIn(parent, fileName, content) {
|
|
79
|
+
const existed = await parent.getFileHandle(fileName).then(() => true).catch(() => false);
|
|
80
|
+
const fileHandle = await parent.getFileHandle(fileName, { create: true });
|
|
81
|
+
let writable;
|
|
82
|
+
let closing = false;
|
|
68
83
|
try {
|
|
84
|
+
writable = await fileHandle.createWritable();
|
|
69
85
|
for await (const chunk of content) await writable.write(chunk);
|
|
70
|
-
|
|
86
|
+
closing = true;
|
|
71
87
|
await writable.close();
|
|
88
|
+
} catch (error) {
|
|
89
|
+
if (writable && !closing) await writable.abort().catch(() => {});
|
|
90
|
+
if (!existed) {
|
|
91
|
+
if (await fileHandle.getFile().then((f) => f.size).catch(() => -1) === 0) await parent.removeEntry(fileName).catch(() => {});
|
|
92
|
+
}
|
|
93
|
+
throw error;
|
|
72
94
|
}
|
|
73
95
|
}
|
|
96
|
+
/**
|
|
97
|
+
* The File System Access API has no exclusive create, so the check and the create run
|
|
98
|
+
* under a lock named after this directory and the path:
|
|
99
|
+
*
|
|
100
|
+
* - With the Web Locks API (`navigator.locks`, in every current browser), the lock is
|
|
101
|
+
* shared by all tabs and workers of the origin, so calls from any of them exclude
|
|
102
|
+
* each other.
|
|
103
|
+
* - Without it (some test environments and polyfills), an in-memory mutex stands in,
|
|
104
|
+
* which excludes calls within this JavaScript realm only; another tab or worker is
|
|
105
|
+
* then excluded only by the existence check, which can race.
|
|
106
|
+
*
|
|
107
|
+
* The lock is asked for `ifAvailable`: a call that finds it held returns false at once
|
|
108
|
+
* instead of waiting, since the path is being taken. (Should the holder's content then
|
|
109
|
+
* fail, the path stays free, though this call has returned false.)
|
|
110
|
+
*
|
|
111
|
+
* When the root lies in the Origin Private File System, the lock is named by the file's
|
|
112
|
+
* full path there, `webrun-files:opfs:/repo/HEAD.lock`, so instances rooted at
|
|
113
|
+
* different OPFS directories take the same lock for the same file. Outside it, the
|
|
114
|
+
* lock names the directory by `lockScope` when one was given, else by the handle
|
|
115
|
+
* object, which only instances built on that same object share. Two instances over
|
|
116
|
+
* one picked directory, from separately obtained handles, then exclude each other only
|
|
117
|
+
* through the existence check: pass them the same `lockScope`.
|
|
118
|
+
*
|
|
119
|
+
* Writers that do not take the lock — `write`, or other programs — are not excluded.
|
|
120
|
+
* The file exists, empty, while its content is written, and is removed again if the
|
|
121
|
+
* content fails.
|
|
122
|
+
*/
|
|
123
|
+
async createExclusive(path, content) {
|
|
124
|
+
const normalized = (0, _statewalker_webrun_files.normalizePath)(path);
|
|
125
|
+
if (normalized === "/") return false;
|
|
126
|
+
const parentPath = (0, _statewalker_webrun_files.dirname)(normalized);
|
|
127
|
+
const fileName = (0, _statewalker_webrun_files.basename)(normalized);
|
|
128
|
+
return withLockIfAvailable((await this.getLockNamer())(normalized), false, async () => {
|
|
129
|
+
const existing = await this.getDirectoryHandle(parentPath);
|
|
130
|
+
if (existing && await hasEntry(existing, fileName)) return false;
|
|
131
|
+
const parent = existing ?? await this.getDirectoryHandle(parentPath, { create: true });
|
|
132
|
+
if (!parent) throw new Error(`Cannot create directory: ${parentPath}`);
|
|
133
|
+
await this.writeFileIn(parent, fileName, content);
|
|
134
|
+
return true;
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
/** Names the lock for a normalized path; see {@link createExclusive}. */
|
|
138
|
+
getLockNamer() {
|
|
139
|
+
this.lockNamer ??= (async () => {
|
|
140
|
+
try {
|
|
141
|
+
const opfsRoot = await globalThis.navigator?.storage?.getDirectory?.();
|
|
142
|
+
const names = opfsRoot && await opfsRoot.resolve(this.rootHandle);
|
|
143
|
+
if (names) {
|
|
144
|
+
const rootPath = names.map((name) => `/${name}`).join("");
|
|
145
|
+
return (path) => `webrun-files:opfs:${rootPath}${path}`;
|
|
146
|
+
}
|
|
147
|
+
} catch {}
|
|
148
|
+
const scope = this.lockScope ?? `handle:${handleId(this.rootHandle)}`;
|
|
149
|
+
return (path) => `webrun-files:${scope}:${path}`;
|
|
150
|
+
})();
|
|
151
|
+
return this.lockNamer;
|
|
152
|
+
}
|
|
74
153
|
async mkdir(path) {
|
|
75
154
|
const normalized = (0, _statewalker_webrun_files.normalizePath)(path);
|
|
76
155
|
await this.getDirectoryHandle(normalized, { create: true });
|
|
@@ -142,23 +221,60 @@ var BrowserFilesApi = class {
|
|
|
142
221
|
return false;
|
|
143
222
|
}
|
|
144
223
|
}
|
|
224
|
+
/**
|
|
225
|
+
* Uses the native `FileSystemHandle.move()` where it exists (Chromium; atomic in the
|
|
226
|
+
* Origin Private File System), else copies, then deletes the source.
|
|
227
|
+
*
|
|
228
|
+
* - Native move over an existing file: current Chromium replaces the file; versions
|
|
229
|
+
* that throw `InvalidModificationError` instead (the target exists) fall back to the
|
|
230
|
+
* copy below, which still replaces the target atomically.
|
|
231
|
+
* - Other native errors: only an unsupported move (`TypeError`, `NotSupportedError`)
|
|
232
|
+
* falls back; anything else (a locked entry, no permission, ...) is rethrown. If the
|
|
233
|
+
* native move threw after the source had already moved (the source is gone and the
|
|
234
|
+
* target exists), the move counts as done and returns true.
|
|
235
|
+
* - Time of check: the target is checked (below) before the native move runs, with
|
|
236
|
+
* `await`s between them, so another writer can create or change the target in that
|
|
237
|
+
* gap; the native move then decides on its own (replacing a file, or throwing).
|
|
238
|
+
* - Fallback, NOT atomic as a whole: a file is copied through a writable stream,
|
|
239
|
+
* whose swap file replaces the target's content in one step on close; a directory
|
|
240
|
+
* is copied file by file. If the copy fails, the error is thrown and the source
|
|
241
|
+
* kept. If the copy succeeds but the source cannot be deleted, the delete is retried
|
|
242
|
+
* once, then a `MoveIncompleteError` is thrown: the target already holds the new
|
|
243
|
+
* content and the source still exists. False is never returned for a half-done move.
|
|
244
|
+
*
|
|
245
|
+
* An existing target is checked first, as POSIX `rename(2)` treats it: a file
|
|
246
|
+
* replaces a file and a directory an empty directory; anything else (a file onto a
|
|
247
|
+
* directory, a directory onto a file or a non-empty directory, a directory into its
|
|
248
|
+
* own subtree) returns false and changes nothing. A move onto itself is a no-op.
|
|
249
|
+
* The target's parent directories are created before the native move or the copy,
|
|
250
|
+
* so they may be left behind even when `move` returns false.
|
|
251
|
+
*/
|
|
145
252
|
async move(source, target) {
|
|
146
253
|
const sourcePath = (0, _statewalker_webrun_files.normalizePath)(source);
|
|
147
254
|
const targetPath = (0, _statewalker_webrun_files.normalizePath)(target);
|
|
148
|
-
const targetParentPath = (0, _statewalker_webrun_files.dirname)(targetPath);
|
|
149
|
-
const targetName = (0, _statewalker_webrun_files.basename)(targetPath);
|
|
150
255
|
const sourceStats = await this.stats(sourcePath);
|
|
151
256
|
if (!sourceStats) return false;
|
|
257
|
+
if (sourcePath === targetPath) return true;
|
|
258
|
+
if (sourcePath === "/" || targetPath.startsWith(`${sourcePath}/`)) return false;
|
|
259
|
+
const targetStats = await this.stats(targetPath);
|
|
260
|
+
if (targetStats) {
|
|
261
|
+
if (targetStats.kind !== sourceStats.kind) return false;
|
|
262
|
+
if (targetStats.kind === "directory" && !await isEmpty(this.list(targetPath))) return false;
|
|
263
|
+
}
|
|
152
264
|
const sourceHandle = sourceStats.kind === "file" ? await this.getFileHandle(sourcePath) : await this.getDirectoryHandle(sourcePath);
|
|
153
265
|
if (!sourceHandle) return false;
|
|
266
|
+
const targetParent = await this.getDirectoryHandle((0, _statewalker_webrun_files.dirname)(targetPath), { create: true });
|
|
267
|
+
if (!targetParent) return false;
|
|
154
268
|
if (typeof sourceHandle.move === "function") try {
|
|
155
|
-
|
|
156
|
-
if (!targetParent) return false;
|
|
157
|
-
await sourceHandle.move(targetParent, targetName);
|
|
269
|
+
await sourceHandle.move(targetParent, (0, _statewalker_webrun_files.basename)(targetPath));
|
|
158
270
|
return true;
|
|
159
|
-
} catch {
|
|
160
|
-
|
|
161
|
-
|
|
271
|
+
} catch (error) {
|
|
272
|
+
if (!await this.exists(sourcePath) && await this.exists(targetPath)) return true;
|
|
273
|
+
if (!canFallBack(error, targetStats !== void 0)) throw error;
|
|
274
|
+
}
|
|
275
|
+
if (!await this.copy(sourcePath, targetPath)) return false;
|
|
276
|
+
await (0, _statewalker_webrun_files.removeMovedSource)(this, sourcePath, targetPath);
|
|
277
|
+
return true;
|
|
162
278
|
}
|
|
163
279
|
async copy(source, target) {
|
|
164
280
|
const sourcePath = (0, _statewalker_webrun_files.normalizePath)(source);
|
|
@@ -173,17 +289,7 @@ var BrowserFilesApi = class {
|
|
|
173
289
|
const targetFileName = (0, _statewalker_webrun_files.basename)(targetPath);
|
|
174
290
|
const targetParent = await this.getDirectoryHandle(targetParentPath, { create: true });
|
|
175
291
|
if (!targetParent) return false;
|
|
176
|
-
|
|
177
|
-
try {
|
|
178
|
-
const reader = sourceFile.stream().getReader();
|
|
179
|
-
while (true) {
|
|
180
|
-
const { done, value } = await reader.read();
|
|
181
|
-
if (done) break;
|
|
182
|
-
await writable.write(value);
|
|
183
|
-
}
|
|
184
|
-
} finally {
|
|
185
|
-
await writable.close();
|
|
186
|
-
}
|
|
292
|
+
await this.writeFileIn(targetParent, targetFileName, fileChunks(sourceFile));
|
|
187
293
|
return true;
|
|
188
294
|
}
|
|
189
295
|
await this.getDirectoryHandle(targetPath, { create: true });
|
|
@@ -197,17 +303,7 @@ var BrowserFilesApi = class {
|
|
|
197
303
|
const newTargetFileName = (0, _statewalker_webrun_files.basename)(newTargetPath);
|
|
198
304
|
const newTargetParent = await this.getDirectoryHandle(newTargetParentPath, { create: true });
|
|
199
305
|
if (!newTargetParent) continue;
|
|
200
|
-
|
|
201
|
-
try {
|
|
202
|
-
const reader = entryFile.stream().getReader();
|
|
203
|
-
while (true) {
|
|
204
|
-
const { done, value } = await reader.read();
|
|
205
|
-
if (done) break;
|
|
206
|
-
await writable.write(value);
|
|
207
|
-
}
|
|
208
|
-
} finally {
|
|
209
|
-
await writable.close();
|
|
210
|
-
}
|
|
306
|
+
await this.writeFileIn(newTargetParent, newTargetFileName, fileChunks(entryFile));
|
|
211
307
|
}
|
|
212
308
|
return true;
|
|
213
309
|
}
|
|
@@ -218,6 +314,80 @@ var BrowserFilesApi = class {
|
|
|
218
314
|
async function getOPFSFilesApi() {
|
|
219
315
|
return new BrowserFilesApi({ rootHandle: await navigator.storage.getDirectory() });
|
|
220
316
|
}
|
|
317
|
+
/** A file's content as chunks, read through its stream (released when stopped early). */
|
|
318
|
+
async function* fileChunks(file) {
|
|
319
|
+
const reader = file.stream().getReader();
|
|
320
|
+
try {
|
|
321
|
+
while (true) {
|
|
322
|
+
const { done, value } = await reader.read();
|
|
323
|
+
if (done) return;
|
|
324
|
+
yield value;
|
|
325
|
+
}
|
|
326
|
+
} finally {
|
|
327
|
+
await reader.cancel().catch(() => {});
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
/**
|
|
331
|
+
* Whether a native `move()` error means "not done this way", so copy-then-delete may
|
|
332
|
+
* try instead: the move is unsupported for this handle (`TypeError`, as for a handle
|
|
333
|
+
* whose `move` is not callable, or `NotSupportedError`), or the target exists and this
|
|
334
|
+
* Chromium refuses to overwrite it (`InvalidModificationError`; versions before the
|
|
335
|
+
* overwrite change raise it for an existing file). Everything else, such as
|
|
336
|
+
* `NoModificationAllowedError` (a locked entry), `NotAllowedError` (no permission) or
|
|
337
|
+
* `NotFoundError`, is a real failure and is rethrown.
|
|
338
|
+
*/
|
|
339
|
+
function canFallBack(error, targetExisted) {
|
|
340
|
+
if (error instanceof TypeError) return true;
|
|
341
|
+
const name = error?.name;
|
|
342
|
+
if (name === "NotSupportedError") return true;
|
|
343
|
+
return name === "InvalidModificationError" && targetExisted;
|
|
344
|
+
}
|
|
345
|
+
/** Whether a listing yields nothing. */
|
|
346
|
+
async function isEmpty(entries) {
|
|
347
|
+
for await (const _ of entries) return false;
|
|
348
|
+
return true;
|
|
349
|
+
}
|
|
350
|
+
/** Whether `parent` holds a file or a directory called `name`. */
|
|
351
|
+
async function hasEntry(parent, name) {
|
|
352
|
+
for (const get of [() => parent.getFileHandle(name), () => parent.getDirectoryHandle(name)]) try {
|
|
353
|
+
await get();
|
|
354
|
+
return true;
|
|
355
|
+
} catch (error) {
|
|
356
|
+
if (error?.name === "TypeMismatchError") return true;
|
|
357
|
+
}
|
|
358
|
+
return false;
|
|
359
|
+
}
|
|
360
|
+
/** Ids of root handles, for lock names; instances built on one handle object share it. */
|
|
361
|
+
const handleIds = /* @__PURE__ */ new WeakMap();
|
|
362
|
+
function handleId(handle) {
|
|
363
|
+
let id = handleIds.get(handle);
|
|
364
|
+
if (id === void 0) {
|
|
365
|
+
id = crypto.randomUUID();
|
|
366
|
+
handleIds.set(handle, id);
|
|
367
|
+
}
|
|
368
|
+
return id;
|
|
369
|
+
}
|
|
370
|
+
/** Names of the in-memory locks standing in for Web Locks that are held now. */
|
|
371
|
+
const localLocks = /* @__PURE__ */ new Set();
|
|
372
|
+
/**
|
|
373
|
+
* Run `task` holding the exclusive lock `name`, or return `busy` at once when someone
|
|
374
|
+
* else holds it: a Web Lock asked for `ifAvailable` when `navigator.locks` exists
|
|
375
|
+
* (origin-wide), else an in-memory lock (this realm only).
|
|
376
|
+
*/
|
|
377
|
+
async function withLockIfAvailable(name, busy, task) {
|
|
378
|
+
const locks = globalThis.navigator?.locks;
|
|
379
|
+
if (locks) return locks.request(name, {
|
|
380
|
+
mode: "exclusive",
|
|
381
|
+
ifAvailable: true
|
|
382
|
+
}, (lock) => lock ? task() : busy);
|
|
383
|
+
if (localLocks.has(name)) return busy;
|
|
384
|
+
localLocks.add(name);
|
|
385
|
+
try {
|
|
386
|
+
return await task();
|
|
387
|
+
} finally {
|
|
388
|
+
localLocks.delete(name);
|
|
389
|
+
}
|
|
390
|
+
}
|
|
221
391
|
// src/open-browser-files-api.ts
|
|
222
392
|
/**
|
|
223
393
|
* Utilities for opening and managing browser file system access.
|
package/dist/esm/index.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { basename, dirname, joinPath, listInPathOrder, normalizePath } from "@statewalker/webrun-files";
|
|
1
|
+
import { basename, dirname, joinPath, listInPathOrder, normalizePath, removeMovedSource } from "@statewalker/webrun-files";
|
|
2
2
|
// src/browser-files-api.ts
|
|
3
3
|
/**
|
|
4
4
|
* Browser filesystem implementation using the File System Access API.
|
|
@@ -9,8 +9,11 @@ import { basename, dirname, joinPath, listInPathOrder, normalizePath } from "@st
|
|
|
9
9
|
*/
|
|
10
10
|
var BrowserFilesApi = class {
|
|
11
11
|
rootHandle;
|
|
12
|
+
lockScope;
|
|
13
|
+
lockNamer;
|
|
12
14
|
constructor(options) {
|
|
13
15
|
this.rootHandle = options.rootHandle;
|
|
16
|
+
this.lockScope = options.lockScope;
|
|
14
17
|
}
|
|
15
18
|
async getDirectoryHandle(path, options = {}) {
|
|
16
19
|
const segments = path.split("/").filter((s) => s.length > 0);
|
|
@@ -63,13 +66,89 @@ var BrowserFilesApi = class {
|
|
|
63
66
|
const fileName = basename(normalized);
|
|
64
67
|
const parent = await this.getDirectoryHandle(parentPath, { create: true });
|
|
65
68
|
if (!parent) throw new Error(`Cannot create directory: ${parentPath}`);
|
|
66
|
-
|
|
69
|
+
await this.writeFileIn(parent, fileName, content);
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* Write `content` to `fileName` in `parent`. A writable stream writes to a swap file:
|
|
73
|
+
* close() commits it, abort() drops it and leaves the old file as it was. So a source
|
|
74
|
+
* that fails part way keeps the old file (and a file this write created is removed
|
|
75
|
+
* again) instead of committing a cut-off one.
|
|
76
|
+
*/
|
|
77
|
+
async writeFileIn(parent, fileName, content) {
|
|
78
|
+
const existed = await parent.getFileHandle(fileName).then(() => true).catch(() => false);
|
|
79
|
+
const fileHandle = await parent.getFileHandle(fileName, { create: true });
|
|
80
|
+
let writable;
|
|
81
|
+
let closing = false;
|
|
67
82
|
try {
|
|
83
|
+
writable = await fileHandle.createWritable();
|
|
68
84
|
for await (const chunk of content) await writable.write(chunk);
|
|
69
|
-
|
|
85
|
+
closing = true;
|
|
70
86
|
await writable.close();
|
|
87
|
+
} catch (error) {
|
|
88
|
+
if (writable && !closing) await writable.abort().catch(() => {});
|
|
89
|
+
if (!existed) {
|
|
90
|
+
if (await fileHandle.getFile().then((f) => f.size).catch(() => -1) === 0) await parent.removeEntry(fileName).catch(() => {});
|
|
91
|
+
}
|
|
92
|
+
throw error;
|
|
71
93
|
}
|
|
72
94
|
}
|
|
95
|
+
/**
|
|
96
|
+
* The File System Access API has no exclusive create, so the check and the create run
|
|
97
|
+
* under a lock named after this directory and the path:
|
|
98
|
+
*
|
|
99
|
+
* - With the Web Locks API (`navigator.locks`, in every current browser), the lock is
|
|
100
|
+
* shared by all tabs and workers of the origin, so calls from any of them exclude
|
|
101
|
+
* each other.
|
|
102
|
+
* - Without it (some test environments and polyfills), an in-memory mutex stands in,
|
|
103
|
+
* which excludes calls within this JavaScript realm only; another tab or worker is
|
|
104
|
+
* then excluded only by the existence check, which can race.
|
|
105
|
+
*
|
|
106
|
+
* The lock is asked for `ifAvailable`: a call that finds it held returns false at once
|
|
107
|
+
* instead of waiting, since the path is being taken. (Should the holder's content then
|
|
108
|
+
* fail, the path stays free, though this call has returned false.)
|
|
109
|
+
*
|
|
110
|
+
* When the root lies in the Origin Private File System, the lock is named by the file's
|
|
111
|
+
* full path there, `webrun-files:opfs:/repo/HEAD.lock`, so instances rooted at
|
|
112
|
+
* different OPFS directories take the same lock for the same file. Outside it, the
|
|
113
|
+
* lock names the directory by `lockScope` when one was given, else by the handle
|
|
114
|
+
* object, which only instances built on that same object share. Two instances over
|
|
115
|
+
* one picked directory, from separately obtained handles, then exclude each other only
|
|
116
|
+
* through the existence check: pass them the same `lockScope`.
|
|
117
|
+
*
|
|
118
|
+
* Writers that do not take the lock — `write`, or other programs — are not excluded.
|
|
119
|
+
* The file exists, empty, while its content is written, and is removed again if the
|
|
120
|
+
* content fails.
|
|
121
|
+
*/
|
|
122
|
+
async createExclusive(path, content) {
|
|
123
|
+
const normalized = normalizePath(path);
|
|
124
|
+
if (normalized === "/") return false;
|
|
125
|
+
const parentPath = dirname(normalized);
|
|
126
|
+
const fileName = basename(normalized);
|
|
127
|
+
return withLockIfAvailable((await this.getLockNamer())(normalized), false, async () => {
|
|
128
|
+
const existing = await this.getDirectoryHandle(parentPath);
|
|
129
|
+
if (existing && await hasEntry(existing, fileName)) return false;
|
|
130
|
+
const parent = existing ?? await this.getDirectoryHandle(parentPath, { create: true });
|
|
131
|
+
if (!parent) throw new Error(`Cannot create directory: ${parentPath}`);
|
|
132
|
+
await this.writeFileIn(parent, fileName, content);
|
|
133
|
+
return true;
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
/** Names the lock for a normalized path; see {@link createExclusive}. */
|
|
137
|
+
getLockNamer() {
|
|
138
|
+
this.lockNamer ??= (async () => {
|
|
139
|
+
try {
|
|
140
|
+
const opfsRoot = await globalThis.navigator?.storage?.getDirectory?.();
|
|
141
|
+
const names = opfsRoot && await opfsRoot.resolve(this.rootHandle);
|
|
142
|
+
if (names) {
|
|
143
|
+
const rootPath = names.map((name) => `/${name}`).join("");
|
|
144
|
+
return (path) => `webrun-files:opfs:${rootPath}${path}`;
|
|
145
|
+
}
|
|
146
|
+
} catch {}
|
|
147
|
+
const scope = this.lockScope ?? `handle:${handleId(this.rootHandle)}`;
|
|
148
|
+
return (path) => `webrun-files:${scope}:${path}`;
|
|
149
|
+
})();
|
|
150
|
+
return this.lockNamer;
|
|
151
|
+
}
|
|
73
152
|
async mkdir(path) {
|
|
74
153
|
const normalized = normalizePath(path);
|
|
75
154
|
await this.getDirectoryHandle(normalized, { create: true });
|
|
@@ -141,23 +220,60 @@ var BrowserFilesApi = class {
|
|
|
141
220
|
return false;
|
|
142
221
|
}
|
|
143
222
|
}
|
|
223
|
+
/**
|
|
224
|
+
* Uses the native `FileSystemHandle.move()` where it exists (Chromium; atomic in the
|
|
225
|
+
* Origin Private File System), else copies, then deletes the source.
|
|
226
|
+
*
|
|
227
|
+
* - Native move over an existing file: current Chromium replaces the file; versions
|
|
228
|
+
* that throw `InvalidModificationError` instead (the target exists) fall back to the
|
|
229
|
+
* copy below, which still replaces the target atomically.
|
|
230
|
+
* - Other native errors: only an unsupported move (`TypeError`, `NotSupportedError`)
|
|
231
|
+
* falls back; anything else (a locked entry, no permission, ...) is rethrown. If the
|
|
232
|
+
* native move threw after the source had already moved (the source is gone and the
|
|
233
|
+
* target exists), the move counts as done and returns true.
|
|
234
|
+
* - Time of check: the target is checked (below) before the native move runs, with
|
|
235
|
+
* `await`s between them, so another writer can create or change the target in that
|
|
236
|
+
* gap; the native move then decides on its own (replacing a file, or throwing).
|
|
237
|
+
* - Fallback, NOT atomic as a whole: a file is copied through a writable stream,
|
|
238
|
+
* whose swap file replaces the target's content in one step on close; a directory
|
|
239
|
+
* is copied file by file. If the copy fails, the error is thrown and the source
|
|
240
|
+
* kept. If the copy succeeds but the source cannot be deleted, the delete is retried
|
|
241
|
+
* once, then a `MoveIncompleteError` is thrown: the target already holds the new
|
|
242
|
+
* content and the source still exists. False is never returned for a half-done move.
|
|
243
|
+
*
|
|
244
|
+
* An existing target is checked first, as POSIX `rename(2)` treats it: a file
|
|
245
|
+
* replaces a file and a directory an empty directory; anything else (a file onto a
|
|
246
|
+
* directory, a directory onto a file or a non-empty directory, a directory into its
|
|
247
|
+
* own subtree) returns false and changes nothing. A move onto itself is a no-op.
|
|
248
|
+
* The target's parent directories are created before the native move or the copy,
|
|
249
|
+
* so they may be left behind even when `move` returns false.
|
|
250
|
+
*/
|
|
144
251
|
async move(source, target) {
|
|
145
252
|
const sourcePath = normalizePath(source);
|
|
146
253
|
const targetPath = normalizePath(target);
|
|
147
|
-
const targetParentPath = dirname(targetPath);
|
|
148
|
-
const targetName = basename(targetPath);
|
|
149
254
|
const sourceStats = await this.stats(sourcePath);
|
|
150
255
|
if (!sourceStats) return false;
|
|
256
|
+
if (sourcePath === targetPath) return true;
|
|
257
|
+
if (sourcePath === "/" || targetPath.startsWith(`${sourcePath}/`)) return false;
|
|
258
|
+
const targetStats = await this.stats(targetPath);
|
|
259
|
+
if (targetStats) {
|
|
260
|
+
if (targetStats.kind !== sourceStats.kind) return false;
|
|
261
|
+
if (targetStats.kind === "directory" && !await isEmpty(this.list(targetPath))) return false;
|
|
262
|
+
}
|
|
151
263
|
const sourceHandle = sourceStats.kind === "file" ? await this.getFileHandle(sourcePath) : await this.getDirectoryHandle(sourcePath);
|
|
152
264
|
if (!sourceHandle) return false;
|
|
265
|
+
const targetParent = await this.getDirectoryHandle(dirname(targetPath), { create: true });
|
|
266
|
+
if (!targetParent) return false;
|
|
153
267
|
if (typeof sourceHandle.move === "function") try {
|
|
154
|
-
|
|
155
|
-
if (!targetParent) return false;
|
|
156
|
-
await sourceHandle.move(targetParent, targetName);
|
|
268
|
+
await sourceHandle.move(targetParent, basename(targetPath));
|
|
157
269
|
return true;
|
|
158
|
-
} catch {
|
|
159
|
-
|
|
160
|
-
|
|
270
|
+
} catch (error) {
|
|
271
|
+
if (!await this.exists(sourcePath) && await this.exists(targetPath)) return true;
|
|
272
|
+
if (!canFallBack(error, targetStats !== void 0)) throw error;
|
|
273
|
+
}
|
|
274
|
+
if (!await this.copy(sourcePath, targetPath)) return false;
|
|
275
|
+
await removeMovedSource(this, sourcePath, targetPath);
|
|
276
|
+
return true;
|
|
161
277
|
}
|
|
162
278
|
async copy(source, target) {
|
|
163
279
|
const sourcePath = normalizePath(source);
|
|
@@ -172,17 +288,7 @@ var BrowserFilesApi = class {
|
|
|
172
288
|
const targetFileName = basename(targetPath);
|
|
173
289
|
const targetParent = await this.getDirectoryHandle(targetParentPath, { create: true });
|
|
174
290
|
if (!targetParent) return false;
|
|
175
|
-
|
|
176
|
-
try {
|
|
177
|
-
const reader = sourceFile.stream().getReader();
|
|
178
|
-
while (true) {
|
|
179
|
-
const { done, value } = await reader.read();
|
|
180
|
-
if (done) break;
|
|
181
|
-
await writable.write(value);
|
|
182
|
-
}
|
|
183
|
-
} finally {
|
|
184
|
-
await writable.close();
|
|
185
|
-
}
|
|
291
|
+
await this.writeFileIn(targetParent, targetFileName, fileChunks(sourceFile));
|
|
186
292
|
return true;
|
|
187
293
|
}
|
|
188
294
|
await this.getDirectoryHandle(targetPath, { create: true });
|
|
@@ -196,17 +302,7 @@ var BrowserFilesApi = class {
|
|
|
196
302
|
const newTargetFileName = basename(newTargetPath);
|
|
197
303
|
const newTargetParent = await this.getDirectoryHandle(newTargetParentPath, { create: true });
|
|
198
304
|
if (!newTargetParent) continue;
|
|
199
|
-
|
|
200
|
-
try {
|
|
201
|
-
const reader = entryFile.stream().getReader();
|
|
202
|
-
while (true) {
|
|
203
|
-
const { done, value } = await reader.read();
|
|
204
|
-
if (done) break;
|
|
205
|
-
await writable.write(value);
|
|
206
|
-
}
|
|
207
|
-
} finally {
|
|
208
|
-
await writable.close();
|
|
209
|
-
}
|
|
305
|
+
await this.writeFileIn(newTargetParent, newTargetFileName, fileChunks(entryFile));
|
|
210
306
|
}
|
|
211
307
|
return true;
|
|
212
308
|
}
|
|
@@ -217,6 +313,80 @@ var BrowserFilesApi = class {
|
|
|
217
313
|
async function getOPFSFilesApi() {
|
|
218
314
|
return new BrowserFilesApi({ rootHandle: await navigator.storage.getDirectory() });
|
|
219
315
|
}
|
|
316
|
+
/** A file's content as chunks, read through its stream (released when stopped early). */
|
|
317
|
+
async function* fileChunks(file) {
|
|
318
|
+
const reader = file.stream().getReader();
|
|
319
|
+
try {
|
|
320
|
+
while (true) {
|
|
321
|
+
const { done, value } = await reader.read();
|
|
322
|
+
if (done) return;
|
|
323
|
+
yield value;
|
|
324
|
+
}
|
|
325
|
+
} finally {
|
|
326
|
+
await reader.cancel().catch(() => {});
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
/**
|
|
330
|
+
* Whether a native `move()` error means "not done this way", so copy-then-delete may
|
|
331
|
+
* try instead: the move is unsupported for this handle (`TypeError`, as for a handle
|
|
332
|
+
* whose `move` is not callable, or `NotSupportedError`), or the target exists and this
|
|
333
|
+
* Chromium refuses to overwrite it (`InvalidModificationError`; versions before the
|
|
334
|
+
* overwrite change raise it for an existing file). Everything else, such as
|
|
335
|
+
* `NoModificationAllowedError` (a locked entry), `NotAllowedError` (no permission) or
|
|
336
|
+
* `NotFoundError`, is a real failure and is rethrown.
|
|
337
|
+
*/
|
|
338
|
+
function canFallBack(error, targetExisted) {
|
|
339
|
+
if (error instanceof TypeError) return true;
|
|
340
|
+
const name = error?.name;
|
|
341
|
+
if (name === "NotSupportedError") return true;
|
|
342
|
+
return name === "InvalidModificationError" && targetExisted;
|
|
343
|
+
}
|
|
344
|
+
/** Whether a listing yields nothing. */
|
|
345
|
+
async function isEmpty(entries) {
|
|
346
|
+
for await (const _ of entries) return false;
|
|
347
|
+
return true;
|
|
348
|
+
}
|
|
349
|
+
/** Whether `parent` holds a file or a directory called `name`. */
|
|
350
|
+
async function hasEntry(parent, name) {
|
|
351
|
+
for (const get of [() => parent.getFileHandle(name), () => parent.getDirectoryHandle(name)]) try {
|
|
352
|
+
await get();
|
|
353
|
+
return true;
|
|
354
|
+
} catch (error) {
|
|
355
|
+
if (error?.name === "TypeMismatchError") return true;
|
|
356
|
+
}
|
|
357
|
+
return false;
|
|
358
|
+
}
|
|
359
|
+
/** Ids of root handles, for lock names; instances built on one handle object share it. */
|
|
360
|
+
const handleIds = /* @__PURE__ */ new WeakMap();
|
|
361
|
+
function handleId(handle) {
|
|
362
|
+
let id = handleIds.get(handle);
|
|
363
|
+
if (id === void 0) {
|
|
364
|
+
id = crypto.randomUUID();
|
|
365
|
+
handleIds.set(handle, id);
|
|
366
|
+
}
|
|
367
|
+
return id;
|
|
368
|
+
}
|
|
369
|
+
/** Names of the in-memory locks standing in for Web Locks that are held now. */
|
|
370
|
+
const localLocks = /* @__PURE__ */ new Set();
|
|
371
|
+
/**
|
|
372
|
+
* Run `task` holding the exclusive lock `name`, or return `busy` at once when someone
|
|
373
|
+
* else holds it: a Web Lock asked for `ifAvailable` when `navigator.locks` exists
|
|
374
|
+
* (origin-wide), else an in-memory lock (this realm only).
|
|
375
|
+
*/
|
|
376
|
+
async function withLockIfAvailable(name, busy, task) {
|
|
377
|
+
const locks = globalThis.navigator?.locks;
|
|
378
|
+
if (locks) return locks.request(name, {
|
|
379
|
+
mode: "exclusive",
|
|
380
|
+
ifAvailable: true
|
|
381
|
+
}, (lock) => lock ? task() : busy);
|
|
382
|
+
if (localLocks.has(name)) return busy;
|
|
383
|
+
localLocks.add(name);
|
|
384
|
+
try {
|
|
385
|
+
return await task();
|
|
386
|
+
} finally {
|
|
387
|
+
localLocks.delete(name);
|
|
388
|
+
}
|
|
389
|
+
}
|
|
220
390
|
// src/open-browser-files-api.ts
|
|
221
391
|
/**
|
|
222
392
|
* Utilities for opening and managing browser file system access.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@statewalker/webrun-files-browser",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.11.0",
|
|
4
4
|
"private": false,
|
|
5
5
|
"type": "module",
|
|
6
6
|
"description": "Browser File System Access API implementation for webrun-files",
|
|
@@ -34,7 +34,7 @@
|
|
|
34
34
|
"src"
|
|
35
35
|
],
|
|
36
36
|
"dependencies": {
|
|
37
|
-
"@statewalker/webrun-files": "^0.
|
|
37
|
+
"@statewalker/webrun-files": "^0.11.0"
|
|
38
38
|
},
|
|
39
39
|
"devDependencies": {
|
|
40
40
|
"@types/node": "^26.6.4",
|
package/src/browser-files-api.ts
CHANGED
|
@@ -18,6 +18,7 @@ import {
|
|
|
18
18
|
joinPath,
|
|
19
19
|
listInPathOrder,
|
|
20
20
|
normalizePath,
|
|
21
|
+
removeMovedSource,
|
|
21
22
|
} from "@statewalker/webrun-files";
|
|
22
23
|
|
|
23
24
|
/** Chrome 110+ exposes move() on FileSystemHandle; not yet in TS DOM types. */
|
|
@@ -31,6 +32,15 @@ export interface BrowserFilesApiOptions {
|
|
|
31
32
|
* Can be obtained via showDirectoryPicker(), navigator.storage.getDirectory(), etc.
|
|
32
33
|
*/
|
|
33
34
|
rootHandle: FileSystemDirectoryHandle;
|
|
35
|
+
/**
|
|
36
|
+
* Names a directory outside the Origin Private File System in the locks
|
|
37
|
+
* `createExclusive` takes, so that separately created `BrowserFilesApi` instances over
|
|
38
|
+
* it exclude each other. Give every instance over the same directory the same value,
|
|
39
|
+
* and different directories different values. Without it, such a directory is named by
|
|
40
|
+
* its handle object (see `createExclusive`). Ignored for a root inside the Origin
|
|
41
|
+
* Private File System: there the lock is named by the file's full path in it.
|
|
42
|
+
*/
|
|
43
|
+
lockScope?: string;
|
|
34
44
|
}
|
|
35
45
|
|
|
36
46
|
/**
|
|
@@ -42,9 +52,12 @@ export interface BrowserFilesApiOptions {
|
|
|
42
52
|
*/
|
|
43
53
|
export class BrowserFilesApi implements FilesApi {
|
|
44
54
|
private rootHandle: FileSystemDirectoryHandle;
|
|
55
|
+
private readonly lockScope: string | undefined;
|
|
56
|
+
private lockNamer: Promise<(path: string) => string> | undefined;
|
|
45
57
|
|
|
46
58
|
constructor(options: BrowserFilesApiOptions) {
|
|
47
59
|
this.rootHandle = options.rootHandle;
|
|
60
|
+
this.lockScope = options.lockScope;
|
|
48
61
|
}
|
|
49
62
|
|
|
50
63
|
private async getDirectoryHandle(
|
|
@@ -125,18 +138,115 @@ export class BrowserFilesApi implements FilesApi {
|
|
|
125
138
|
throw new Error(`Cannot create directory: ${parentPath}`);
|
|
126
139
|
}
|
|
127
140
|
|
|
128
|
-
|
|
129
|
-
|
|
141
|
+
await this.writeFileIn(parent, fileName, content);
|
|
142
|
+
}
|
|
130
143
|
|
|
144
|
+
/**
|
|
145
|
+
* Write `content` to `fileName` in `parent`. A writable stream writes to a swap file:
|
|
146
|
+
* close() commits it, abort() drops it and leaves the old file as it was. So a source
|
|
147
|
+
* that fails part way keeps the old file (and a file this write created is removed
|
|
148
|
+
* again) instead of committing a cut-off one.
|
|
149
|
+
*/
|
|
150
|
+
private async writeFileIn(
|
|
151
|
+
parent: FileSystemDirectoryHandle,
|
|
152
|
+
fileName: string,
|
|
153
|
+
content: Iterable<Uint8Array> | AsyncIterable<Uint8Array>,
|
|
154
|
+
): Promise<void> {
|
|
155
|
+
const existed = await parent
|
|
156
|
+
.getFileHandle(fileName)
|
|
157
|
+
.then(() => true)
|
|
158
|
+
.catch(() => false);
|
|
159
|
+
const fileHandle = await parent.getFileHandle(fileName, { create: true });
|
|
160
|
+
let writable: FileSystemWritableFileStream | undefined;
|
|
161
|
+
let closing = false;
|
|
131
162
|
try {
|
|
163
|
+
writable = await fileHandle.createWritable();
|
|
132
164
|
for await (const chunk of content) {
|
|
133
165
|
await writable.write(chunk as Uint8Array<ArrayBuffer>);
|
|
134
166
|
}
|
|
135
|
-
|
|
167
|
+
closing = true;
|
|
136
168
|
await writable.close();
|
|
169
|
+
} catch (error) {
|
|
170
|
+
if (writable && !closing) await writable.abort().catch(() => {});
|
|
171
|
+
// Remove the empty file this write created, unless someone else has written it since
|
|
172
|
+
// (concurrent writers to one path are otherwise last-close-wins).
|
|
173
|
+
if (!existed) {
|
|
174
|
+
const size = await fileHandle
|
|
175
|
+
.getFile()
|
|
176
|
+
.then((f) => f.size)
|
|
177
|
+
.catch(() => -1);
|
|
178
|
+
if (size === 0) await parent.removeEntry(fileName).catch(() => {});
|
|
179
|
+
}
|
|
180
|
+
throw error;
|
|
137
181
|
}
|
|
138
182
|
}
|
|
139
183
|
|
|
184
|
+
/**
|
|
185
|
+
* The File System Access API has no exclusive create, so the check and the create run
|
|
186
|
+
* under a lock named after this directory and the path:
|
|
187
|
+
*
|
|
188
|
+
* - With the Web Locks API (`navigator.locks`, in every current browser), the lock is
|
|
189
|
+
* shared by all tabs and workers of the origin, so calls from any of them exclude
|
|
190
|
+
* each other.
|
|
191
|
+
* - Without it (some test environments and polyfills), an in-memory mutex stands in,
|
|
192
|
+
* which excludes calls within this JavaScript realm only; another tab or worker is
|
|
193
|
+
* then excluded only by the existence check, which can race.
|
|
194
|
+
*
|
|
195
|
+
* The lock is asked for `ifAvailable`: a call that finds it held returns false at once
|
|
196
|
+
* instead of waiting, since the path is being taken. (Should the holder's content then
|
|
197
|
+
* fail, the path stays free, though this call has returned false.)
|
|
198
|
+
*
|
|
199
|
+
* When the root lies in the Origin Private File System, the lock is named by the file's
|
|
200
|
+
* full path there, `webrun-files:opfs:/repo/HEAD.lock`, so instances rooted at
|
|
201
|
+
* different OPFS directories take the same lock for the same file. Outside it, the
|
|
202
|
+
* lock names the directory by `lockScope` when one was given, else by the handle
|
|
203
|
+
* object, which only instances built on that same object share. Two instances over
|
|
204
|
+
* one picked directory, from separately obtained handles, then exclude each other only
|
|
205
|
+
* through the existence check: pass them the same `lockScope`.
|
|
206
|
+
*
|
|
207
|
+
* Writers that do not take the lock — `write`, or other programs — are not excluded.
|
|
208
|
+
* The file exists, empty, while its content is written, and is removed again if the
|
|
209
|
+
* content fails.
|
|
210
|
+
*/
|
|
211
|
+
async createExclusive(
|
|
212
|
+
path: string,
|
|
213
|
+
content: Iterable<Uint8Array> | AsyncIterable<Uint8Array>,
|
|
214
|
+
): Promise<boolean> {
|
|
215
|
+
const normalized = normalizePath(path);
|
|
216
|
+
if (normalized === "/") return false;
|
|
217
|
+
const parentPath = dirname(normalized);
|
|
218
|
+
const fileName = basename(normalized);
|
|
219
|
+
const lockName = (await this.getLockNamer())(normalized);
|
|
220
|
+
|
|
221
|
+
return withLockIfAvailable(lockName, false, async () => {
|
|
222
|
+
const existing = await this.getDirectoryHandle(parentPath);
|
|
223
|
+
if (existing && (await hasEntry(existing, fileName))) return false;
|
|
224
|
+
const parent = existing ?? (await this.getDirectoryHandle(parentPath, { create: true }));
|
|
225
|
+
if (!parent) throw new Error(`Cannot create directory: ${parentPath}`);
|
|
226
|
+
await this.writeFileIn(parent, fileName, content);
|
|
227
|
+
return true;
|
|
228
|
+
});
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/** Names the lock for a normalized path; see {@link createExclusive}. */
|
|
232
|
+
private getLockNamer(): Promise<(path: string) => string> {
|
|
233
|
+
this.lockNamer ??= (async () => {
|
|
234
|
+
try {
|
|
235
|
+
const opfsRoot = await globalThis.navigator?.storage?.getDirectory?.();
|
|
236
|
+
const names = opfsRoot && (await opfsRoot.resolve(this.rootHandle));
|
|
237
|
+
if (names) {
|
|
238
|
+
const rootPath = names.map((name) => `/${name}`).join("");
|
|
239
|
+
return (path: string) => `webrun-files:opfs:${rootPath}${path}`;
|
|
240
|
+
}
|
|
241
|
+
} catch {
|
|
242
|
+
// not available here, or not permitted: fall back to the scope or the handle
|
|
243
|
+
}
|
|
244
|
+
const scope = this.lockScope ?? `handle:${handleId(this.rootHandle)}`;
|
|
245
|
+
return (path: string) => `webrun-files:${scope}:${path}`;
|
|
246
|
+
})();
|
|
247
|
+
return this.lockNamer;
|
|
248
|
+
}
|
|
249
|
+
|
|
140
250
|
async mkdir(path: string): Promise<void> {
|
|
141
251
|
const normalized = normalizePath(path);
|
|
142
252
|
await this.getDirectoryHandle(normalized, { create: true });
|
|
@@ -233,36 +343,74 @@ export class BrowserFilesApi implements FilesApi {
|
|
|
233
343
|
}
|
|
234
344
|
}
|
|
235
345
|
|
|
346
|
+
/**
|
|
347
|
+
* Uses the native `FileSystemHandle.move()` where it exists (Chromium; atomic in the
|
|
348
|
+
* Origin Private File System), else copies, then deletes the source.
|
|
349
|
+
*
|
|
350
|
+
* - Native move over an existing file: current Chromium replaces the file; versions
|
|
351
|
+
* that throw `InvalidModificationError` instead (the target exists) fall back to the
|
|
352
|
+
* copy below, which still replaces the target atomically.
|
|
353
|
+
* - Other native errors: only an unsupported move (`TypeError`, `NotSupportedError`)
|
|
354
|
+
* falls back; anything else (a locked entry, no permission, ...) is rethrown. If the
|
|
355
|
+
* native move threw after the source had already moved (the source is gone and the
|
|
356
|
+
* target exists), the move counts as done and returns true.
|
|
357
|
+
* - Time of check: the target is checked (below) before the native move runs, with
|
|
358
|
+
* `await`s between them, so another writer can create or change the target in that
|
|
359
|
+
* gap; the native move then decides on its own (replacing a file, or throwing).
|
|
360
|
+
* - Fallback, NOT atomic as a whole: a file is copied through a writable stream,
|
|
361
|
+
* whose swap file replaces the target's content in one step on close; a directory
|
|
362
|
+
* is copied file by file. If the copy fails, the error is thrown and the source
|
|
363
|
+
* kept. If the copy succeeds but the source cannot be deleted, the delete is retried
|
|
364
|
+
* once, then a `MoveIncompleteError` is thrown: the target already holds the new
|
|
365
|
+
* content and the source still exists. False is never returned for a half-done move.
|
|
366
|
+
*
|
|
367
|
+
* An existing target is checked first, as POSIX `rename(2)` treats it: a file
|
|
368
|
+
* replaces a file and a directory an empty directory; anything else (a file onto a
|
|
369
|
+
* directory, a directory onto a file or a non-empty directory, a directory into its
|
|
370
|
+
* own subtree) returns false and changes nothing. A move onto itself is a no-op.
|
|
371
|
+
* The target's parent directories are created before the native move or the copy,
|
|
372
|
+
* so they may be left behind even when `move` returns false.
|
|
373
|
+
*/
|
|
236
374
|
async move(source: string, target: string): Promise<boolean> {
|
|
237
375
|
const sourcePath = normalizePath(source);
|
|
238
376
|
const targetPath = normalizePath(target);
|
|
239
|
-
const targetParentPath = dirname(targetPath);
|
|
240
|
-
const targetName = basename(targetPath);
|
|
241
377
|
|
|
242
|
-
// Try native move() (available in Chrome 110+, Edge 110+)
|
|
243
378
|
const sourceStats = await this.stats(sourcePath);
|
|
244
379
|
if (!sourceStats) return false;
|
|
380
|
+
if (sourcePath === targetPath) return true;
|
|
381
|
+
if (sourcePath === "/" || targetPath.startsWith(`${sourcePath}/`)) return false;
|
|
382
|
+
const targetStats = await this.stats(targetPath);
|
|
383
|
+
if (targetStats) {
|
|
384
|
+
if (targetStats.kind !== sourceStats.kind) return false;
|
|
385
|
+
if (targetStats.kind === "directory" && !(await isEmpty(this.list(targetPath)))) {
|
|
386
|
+
return false;
|
|
387
|
+
}
|
|
388
|
+
}
|
|
245
389
|
|
|
246
390
|
const sourceHandle: FileSystemHandle | null =
|
|
247
391
|
sourceStats.kind === "file"
|
|
248
392
|
? await this.getFileHandle(sourcePath)
|
|
249
393
|
: await this.getDirectoryHandle(sourcePath);
|
|
250
394
|
if (!sourceHandle) return false;
|
|
395
|
+
// Fails, creating nothing, when a file stands where a parent directory should be.
|
|
396
|
+
const targetParent = await this.getDirectoryHandle(dirname(targetPath), { create: true });
|
|
397
|
+
if (!targetParent) return false;
|
|
251
398
|
|
|
399
|
+
// Native move() (Chrome 110+, Edge 110+): one atomic step in OPFS.
|
|
252
400
|
if (typeof (sourceHandle as MovableHandle).move === "function") {
|
|
253
401
|
try {
|
|
254
|
-
|
|
255
|
-
if (!targetParent) return false;
|
|
256
|
-
await (sourceHandle as MovableHandle).move(targetParent, targetName);
|
|
402
|
+
await (sourceHandle as MovableHandle).move(targetParent, basename(targetPath));
|
|
257
403
|
return true;
|
|
258
|
-
} catch {
|
|
259
|
-
//
|
|
404
|
+
} catch (error) {
|
|
405
|
+
// The move may have happened before the error was raised.
|
|
406
|
+
if (!(await this.exists(sourcePath)) && (await this.exists(targetPath))) return true;
|
|
407
|
+
if (!canFallBack(error, targetStats !== undefined)) throw error;
|
|
260
408
|
}
|
|
261
409
|
}
|
|
262
410
|
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
return
|
|
411
|
+
if (!(await this.copy(sourcePath, targetPath))) return false;
|
|
412
|
+
await removeMovedSource(this, sourcePath, targetPath);
|
|
413
|
+
return true;
|
|
266
414
|
}
|
|
267
415
|
|
|
268
416
|
async copy(source: string, target: string): Promise<boolean> {
|
|
@@ -285,20 +433,7 @@ export class BrowserFilesApi implements FilesApi {
|
|
|
285
433
|
const targetParent = await this.getDirectoryHandle(targetParentPath, { create: true });
|
|
286
434
|
if (!targetParent) return false;
|
|
287
435
|
|
|
288
|
-
|
|
289
|
-
const writable = await targetHandle.createWritable();
|
|
290
|
-
|
|
291
|
-
try {
|
|
292
|
-
const reader = sourceFile.stream().getReader();
|
|
293
|
-
while (true) {
|
|
294
|
-
const { done, value } = await reader.read();
|
|
295
|
-
if (done) break;
|
|
296
|
-
await writable.write(value);
|
|
297
|
-
}
|
|
298
|
-
} finally {
|
|
299
|
-
await writable.close();
|
|
300
|
-
}
|
|
301
|
-
|
|
436
|
+
await this.writeFileIn(targetParent, targetFileName, fileChunks(sourceFile));
|
|
302
437
|
return true;
|
|
303
438
|
}
|
|
304
439
|
|
|
@@ -322,21 +457,7 @@ export class BrowserFilesApi implements FilesApi {
|
|
|
322
457
|
const newTargetParent = await this.getDirectoryHandle(newTargetParentPath, { create: true });
|
|
323
458
|
if (!newTargetParent) continue;
|
|
324
459
|
|
|
325
|
-
|
|
326
|
-
create: true,
|
|
327
|
-
});
|
|
328
|
-
const writable = await newTargetHandle.createWritable();
|
|
329
|
-
|
|
330
|
-
try {
|
|
331
|
-
const reader = entryFile.stream().getReader();
|
|
332
|
-
while (true) {
|
|
333
|
-
const { done, value } = await reader.read();
|
|
334
|
-
if (done) break;
|
|
335
|
-
await writable.write(value);
|
|
336
|
-
}
|
|
337
|
-
} finally {
|
|
338
|
-
await writable.close();
|
|
339
|
-
}
|
|
460
|
+
await this.writeFileIn(newTargetParent, newTargetFileName, fileChunks(entryFile));
|
|
340
461
|
}
|
|
341
462
|
|
|
342
463
|
return true;
|
|
@@ -350,3 +471,94 @@ export async function getOPFSFilesApi(): Promise<BrowserFilesApi> {
|
|
|
350
471
|
const rootHandle = await navigator.storage.getDirectory();
|
|
351
472
|
return new BrowserFilesApi({ rootHandle });
|
|
352
473
|
}
|
|
474
|
+
|
|
475
|
+
/** A file's content as chunks, read through its stream (released when stopped early). */
|
|
476
|
+
async function* fileChunks(file: File): AsyncGenerator<Uint8Array> {
|
|
477
|
+
const reader = file.stream().getReader();
|
|
478
|
+
try {
|
|
479
|
+
while (true) {
|
|
480
|
+
const { done, value } = await reader.read();
|
|
481
|
+
if (done) return;
|
|
482
|
+
yield value;
|
|
483
|
+
}
|
|
484
|
+
} finally {
|
|
485
|
+
// stopped early or done: cancel, so the file's stream does not keep buffering
|
|
486
|
+
await reader.cancel().catch(() => {});
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
/**
|
|
491
|
+
* Whether a native `move()` error means "not done this way", so copy-then-delete may
|
|
492
|
+
* try instead: the move is unsupported for this handle (`TypeError`, as for a handle
|
|
493
|
+
* whose `move` is not callable, or `NotSupportedError`), or the target exists and this
|
|
494
|
+
* Chromium refuses to overwrite it (`InvalidModificationError`; versions before the
|
|
495
|
+
* overwrite change raise it for an existing file). Everything else, such as
|
|
496
|
+
* `NoModificationAllowedError` (a locked entry), `NotAllowedError` (no permission) or
|
|
497
|
+
* `NotFoundError`, is a real failure and is rethrown.
|
|
498
|
+
*/
|
|
499
|
+
function canFallBack(error: unknown, targetExisted: boolean): boolean {
|
|
500
|
+
if (error instanceof TypeError) return true;
|
|
501
|
+
const name = (error as { name?: unknown } | null)?.name;
|
|
502
|
+
if (name === "NotSupportedError") return true;
|
|
503
|
+
return name === "InvalidModificationError" && targetExisted;
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
/** Whether a listing yields nothing. */
|
|
507
|
+
async function isEmpty(entries: AsyncIterable<FileInfo>): Promise<boolean> {
|
|
508
|
+
for await (const _ of entries) return false;
|
|
509
|
+
return true;
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
/** Whether `parent` holds a file or a directory called `name`. */
|
|
513
|
+
async function hasEntry(parent: FileSystemDirectoryHandle, name: string): Promise<boolean> {
|
|
514
|
+
for (const get of [
|
|
515
|
+
() => parent.getFileHandle(name),
|
|
516
|
+
() => parent.getDirectoryHandle(name),
|
|
517
|
+
] as const) {
|
|
518
|
+
try {
|
|
519
|
+
await get();
|
|
520
|
+
return true;
|
|
521
|
+
} catch (error) {
|
|
522
|
+
// A file asked for as a directory, or a directory as a file, is still an entry.
|
|
523
|
+
if ((error as { name?: string })?.name === "TypeMismatchError") return true;
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
return false;
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
/** Ids of root handles, for lock names; instances built on one handle object share it. */
|
|
530
|
+
const handleIds = new WeakMap<FileSystemDirectoryHandle, string>();
|
|
531
|
+
|
|
532
|
+
function handleId(handle: FileSystemDirectoryHandle): string {
|
|
533
|
+
let id = handleIds.get(handle);
|
|
534
|
+
if (id === undefined) {
|
|
535
|
+
id = crypto.randomUUID();
|
|
536
|
+
handleIds.set(handle, id);
|
|
537
|
+
}
|
|
538
|
+
return id;
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
/** Names of the in-memory locks standing in for Web Locks that are held now. */
|
|
542
|
+
const localLocks = new Set<string>();
|
|
543
|
+
|
|
544
|
+
/**
|
|
545
|
+
* Run `task` holding the exclusive lock `name`, or return `busy` at once when someone
|
|
546
|
+
* else holds it: a Web Lock asked for `ifAvailable` when `navigator.locks` exists
|
|
547
|
+
* (origin-wide), else an in-memory lock (this realm only).
|
|
548
|
+
*/
|
|
549
|
+
async function withLockIfAvailable<T>(name: string, busy: T, task: () => Promise<T>): Promise<T> {
|
|
550
|
+
const locks = globalThis.navigator?.locks;
|
|
551
|
+
if (locks) {
|
|
552
|
+
return locks.request(name, { mode: "exclusive", ifAvailable: true }, (lock) =>
|
|
553
|
+
lock ? task() : busy,
|
|
554
|
+
) as Promise<T>;
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
if (localLocks.has(name)) return busy;
|
|
558
|
+
localLocks.add(name);
|
|
559
|
+
try {
|
|
560
|
+
return await task();
|
|
561
|
+
} finally {
|
|
562
|
+
localLocks.delete(name);
|
|
563
|
+
}
|
|
564
|
+
}
|