@statewalker/webrun-files-browser 0.10.3 → 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 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` uses the native `FileSystemHandle.move()` where it exists (Chromium 110+), which renames
110
- in place without copying. Without it, or when it throws, `move` copies then removes, which is
111
- not atomic.
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 still commits.** If the content iterable throws midway, `write` closes the
128
- writable stream in a `finally`, which saves the bytes written so far instead of aborting.
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;AAcnC,MAAM,WAAW,sBAAsB;IACrC;;;OAGG;IACH,UAAU,EAAE,yBAAyB,CAAC;CACvC;AAED;;;;;;GAMG;AACH,qBAAa,eAAgB,YAAW,QAAQ;IAC9C,OAAO,CAAC,UAAU,CAA4B;IAE9C,YAAY,OAAO,EAAE,sBAAsB,EAE1C;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,CAoBf;IAEK,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;IAEK,IAAI,CAAC,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC,CA8B3D;IAEK,IAAI,CAAC,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC,CA2E3D;CACF;AAED;;GAEG;AACH,wBAAsB,eAAe,IAAI,OAAO,CAAC,eAAe,CAAC,CAGhE"}
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"}
@@ -1,6 +1,6 @@
1
1
  Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
2
2
  let _statewalker_webrun_files = require("@statewalker/webrun-files");
3
- //#region src/browser-files-api.ts
3
+ // src/browser-files-api.ts
4
4
  /**
5
5
  * Browser filesystem implementation using the File System Access API.
6
6
  *
@@ -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
- const writable = await (await parent.getFileHandle(fileName, { create: true })).createWritable();
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
- } finally {
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
- const targetParent = await this.getDirectoryHandle(targetParentPath, { create: true });
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
- if (!await this.copy(source, target)) return false;
161
- return this.remove(source);
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
- const writable = await (await targetParent.getFileHandle(targetFileName, { create: true })).createWritable();
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
- const writable = await (await newTargetParent.getFileHandle(newTargetFileName, { create: true })).createWritable();
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,8 +314,81 @@ var BrowserFilesApi = class {
218
314
  async function getOPFSFilesApi() {
219
315
  return new BrowserFilesApi({ rootHandle: await navigator.storage.getDirectory() });
220
316
  }
221
- //#endregion
222
- //#region src/open-browser-files-api.ts
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
+ }
391
+ // src/open-browser-files-api.ts
223
392
  /**
224
393
  * Utilities for opening and managing browser file system access.
225
394
  *
@@ -283,7 +452,6 @@ async function openBrowserFilesApi(options = {}) {
283
452
  }
284
453
  return new BrowserFilesApi({ rootHandle });
285
454
  }
286
- //#endregion
287
455
  exports.BrowserFilesApi = BrowserFilesApi;
288
456
  exports.getOPFSFilesApi = getOPFSFilesApi;
289
457
  exports.isHandlerAccessible = isHandlerAccessible;
package/dist/esm/index.js CHANGED
@@ -1,5 +1,5 @@
1
- import { basename, dirname, joinPath, listInPathOrder, normalizePath } from "@statewalker/webrun-files";
2
- //#region src/browser-files-api.ts
1
+ import { basename, dirname, joinPath, listInPathOrder, normalizePath, removeMovedSource } from "@statewalker/webrun-files";
2
+ // src/browser-files-api.ts
3
3
  /**
4
4
  * Browser filesystem implementation using the File System Access API.
5
5
  *
@@ -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
- const writable = await (await parent.getFileHandle(fileName, { create: true })).createWritable();
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
- } finally {
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
- const targetParent = await this.getDirectoryHandle(targetParentPath, { create: true });
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
- if (!await this.copy(source, target)) return false;
160
- return this.remove(source);
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
- const writable = await (await targetParent.getFileHandle(targetFileName, { create: true })).createWritable();
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
- const writable = await (await newTargetParent.getFileHandle(newTargetFileName, { create: true })).createWritable();
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,8 +313,81 @@ var BrowserFilesApi = class {
217
313
  async function getOPFSFilesApi() {
218
314
  return new BrowserFilesApi({ rootHandle: await navigator.storage.getDirectory() });
219
315
  }
220
- //#endregion
221
- //#region src/open-browser-files-api.ts
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
+ }
390
+ // src/open-browser-files-api.ts
222
391
  /**
223
392
  * Utilities for opening and managing browser file system access.
224
393
  *
@@ -282,5 +451,4 @@ async function openBrowserFilesApi(options = {}) {
282
451
  }
283
452
  return new BrowserFilesApi({ rootHandle });
284
453
  }
285
- //#endregion
286
454
  export { BrowserFilesApi, getOPFSFilesApi, isHandlerAccessible, openBrowserFilesApi, verifyPermission };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@statewalker/webrun-files-browser",
3
- "version": "0.10.3",
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.10.4"
37
+ "@statewalker/webrun-files": "^0.11.0"
38
38
  },
39
39
  "devDependencies": {
40
40
  "@types/node": "^26.6.4",
@@ -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
- const fileHandle = await parent.getFileHandle(fileName, { create: true });
129
- const writable = await fileHandle.createWritable();
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
- } finally {
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
- const targetParent = await this.getDirectoryHandle(targetParentPath, { create: true });
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
- // Fall through to copy-then-delete
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
- const copied = await this.copy(source, target);
264
- if (!copied) return false;
265
- return this.remove(source);
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
- const targetHandle = await targetParent.getFileHandle(targetFileName, { create: true });
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
- const newTargetHandle = await newTargetParent.getFileHandle(newTargetFileName, {
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
+ }