@statewalker/webrun-files-node 0.10.5 → 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 +55 -6
- package/dist/cjs/index.cjs +174 -13
- package/dist/esm/index.js +174 -13
- package/dist/node-files-api.d.ts +19 -0
- package/dist/node-files-api.d.ts.map +1 -1
- package/dist/replace-file.d.ts +19 -0
- package/dist/replace-file.d.ts.map +1 -0
- package/package.json +2 -2
- package/src/node-files-api.ts +139 -14
- package/src/replace-file.ts +94 -0
package/README.md
CHANGED
|
@@ -59,6 +59,26 @@ for await (const chunk of files.read('/large-file.bin', { start: 1000, length: 5
|
|
|
59
59
|
}
|
|
60
60
|
```
|
|
61
61
|
|
|
62
|
+
### A lock file, safe across processes
|
|
63
|
+
|
|
64
|
+
```typescript
|
|
65
|
+
const content = [new TextEncoder().encode(`${process.pid}\n`)];
|
|
66
|
+
if (await files.createExclusive('/repo/.git/index.lock', content)) {
|
|
67
|
+
try {
|
|
68
|
+
// ... only this process gets here until the lock is removed
|
|
69
|
+
} finally {
|
|
70
|
+
await files.remove('/repo/.git/index.lock');
|
|
71
|
+
}
|
|
72
|
+
} else {
|
|
73
|
+
// another process holds the lock
|
|
74
|
+
}
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
`createExclusive` opens the file with `O_CREAT | O_EXCL`, so the operating system decides who
|
|
78
|
+
wins: every process on the machine using that filesystem is excluded, and on network file systems
|
|
79
|
+
that honour `O_EXCL` (NFSv3 and later) other machines are too. Anything already at the path,
|
|
80
|
+
including a symbolic link, dangling or not, makes it return `false`.
|
|
81
|
+
|
|
62
82
|
### A temporary directory per test
|
|
63
83
|
|
|
64
84
|
```typescript
|
|
@@ -83,9 +103,10 @@ try {
|
|
|
83
103
|
| Call | Implementation |
|
|
84
104
|
| --- | --- |
|
|
85
105
|
| `read` | `fs.open` + positioned `handle.read` in 8 KiB buffers, only the requested range |
|
|
86
|
-
| `write` | `fs.mkdir(parent, { recursive: true })`, then
|
|
106
|
+
| `write` | `fs.mkdir(parent, { recursive: true })`, then streams the chunks (batched to 64 KiB) into a temporary `.webrun-tmp-<random>` beside the file and renames it over the file |
|
|
107
|
+
| `createExclusive` | `fs.mkdir(parent, { recursive: true })`, then `fs.open(path, "wx")`; `EEXIST` returns `false`, otherwise the chunks are streamed (batched to 64 KiB) straight into the new file, which is unlinked again if the source fails |
|
|
87
108
|
| `list` | `fs.readdir` + `fs.stat` per entry, walked through `listInPathOrder` |
|
|
88
|
-
| `move` | `fs.rename` after creating the target's parent |
|
|
109
|
+
| `move` | `fs.rename` after creating the target's parent: atomic, replaces an existing file or (for a directory) an empty directory |
|
|
89
110
|
| `copy` | `fs.cp(..., { recursive: true })` |
|
|
90
111
|
| `remove` | `fs.rm(..., { recursive: true, force: true })` for a directory, `fs.unlink` for a file |
|
|
91
112
|
|
|
@@ -103,10 +124,38 @@ directory whose whole subtree sorts at or before `after`.
|
|
|
103
124
|
`NodeFilesApi: path is outside rootDir: /../etc/passwd` (reads throw when iterated). `..` that
|
|
104
125
|
stays inside (`/a/../b.txt`) is fine. Symbolic links inside `rootDir` are followed as the
|
|
105
126
|
operating system follows them, so a link pointing out of `rootDir` still leads out.
|
|
106
|
-
- **`write`
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
127
|
+
- **`write` streams, and replaces the file at the end.** Memory stays at about two batches of
|
|
128
|
+
64 KiB plus one chunk. Until the rename the old file stays readable, and a source that fails leaves it
|
|
129
|
+
unchanged. A read-only file is refused, as an in-place write was. Replacing keeps the old
|
|
130
|
+
file's permission bits (not setuid/setgid, which a write would clear) and writes through a
|
|
131
|
+
symbolic link to its target (a dangling link is replaced by a regular file, and a link out of
|
|
132
|
+
`rootDir` is written there, with its temporary beside the target); a hard link is replaced,
|
|
133
|
+
not written through, and the owner, ACLs and extended
|
|
134
|
+
attributes are those of a new file. The temporary needs write permission on the directory.
|
|
135
|
+
`list` hides the temporaries (files named exactly `.webrun-tmp-` plus 16 hex digits); a crash
|
|
136
|
+
can leave one behind, still visible to `exists`, `stats` and `read`. A caller that reuses
|
|
137
|
+
a chunk's buffer after yielding it may change what is written (up to 64 KiB is held before
|
|
138
|
+
each write). Reads stream.
|
|
139
|
+
- **Replacing has the costs of a rename.** The disk must hold the old file and the new one
|
|
140
|
+
until the rename. The new content is not flushed (`fsync`) before the rename, so after a
|
|
141
|
+
power loss the file can be empty or partial on some file systems. On Windows a file that
|
|
142
|
+
another process holds open cannot be replaced, so the rename is retried for about a second
|
|
143
|
+
before the write fails (the temporary is removed and the old file kept). A file the rename cannot replace because it is a mount point (a
|
|
144
|
+
bind-mounted file in a container, `EBUSY`) or on another device (`EXDEV`) is written in
|
|
145
|
+
place instead: the temporary is copied into it, keeping its owner and mode, so readers may
|
|
146
|
+
see it half-written, and a failure part way (a full disk) leaves it partial.
|
|
147
|
+
- **A lock file is visible while it is written.** Unlike `write`, `createExclusive` writes in
|
|
148
|
+
place, as git does with its lock files: other readers see the file grow until the content
|
|
149
|
+
ends. A crash part way leaves the partial file, and with it the lock, behind.
|
|
150
|
+
- **Cleanup unlinks by path.** When the source fails, the file is unlinked by its path. If
|
|
151
|
+
another process meanwhile removed it as a stale lock and created its own, that file is the one
|
|
152
|
+
removed. Git's lock files behave the same.
|
|
153
|
+
- **`move` is atomic, and returns `false` when the rename fails.** It is a single `fs.rename`, so
|
|
154
|
+
a reader sees the old target or the new one, never a missing or partial file, and a failed
|
|
155
|
+
rename changes nothing. When it fails (a file onto a directory, a directory onto a file or a
|
|
156
|
+
non-empty directory, a directory into itself, `EXDEV` across devices, permissions) the method
|
|
157
|
+
returns `false` instead of falling back to copy and delete; the target's parent directories may
|
|
158
|
+
stay created.
|
|
110
159
|
- **Unreadable reads look like missing files.** `read` swallows every error (missing file,
|
|
111
160
|
permission denied, a directory) and yields nothing; `copy` and `move` return `false`.
|
|
112
161
|
|
package/dist/cjs/index.cjs
CHANGED
|
@@ -139,7 +139,86 @@ function dirname(path) {
|
|
|
139
139
|
if (lastSlash <= 0) return "/";
|
|
140
140
|
return normalized.substring(0, lastSlash);
|
|
141
141
|
}
|
|
142
|
+
// src/replace-file.ts
|
|
143
|
+
/** Windows refuses to replace a file another process holds open; such holds are brief. */
|
|
144
|
+
const WIN32_RETRY_CODES = /* @__PURE__ */ new Set([
|
|
145
|
+
"EPERM",
|
|
146
|
+
"EACCES",
|
|
147
|
+
"EBUSY"
|
|
148
|
+
]);
|
|
149
|
+
const WIN32_RETRIES = 7;
|
|
150
|
+
/**
|
|
151
|
+
* A rename cannot replace a mount point (a bind-mounted file: EBUSY on POSIX) or cross
|
|
152
|
+
* devices (EXDEV). On Windows EBUSY is a sharing violation, retried above, not a mount.
|
|
153
|
+
*/
|
|
154
|
+
const COPY_CODES = /* @__PURE__ */ new Set(["EBUSY", "EXDEV"]);
|
|
155
|
+
const WIN32_COPY_CODES = /* @__PURE__ */ new Set(["EXDEV"]);
|
|
156
|
+
const COPY_CHUNK = 65536;
|
|
157
|
+
const codeOf = (error) => error?.code;
|
|
158
|
+
/**
|
|
159
|
+
* Move `tmp` over `target`. Normally a rename, so readers see the old file or the new one.
|
|
160
|
+
* On Windows a rename refused because the file is open elsewhere is retried for about a
|
|
161
|
+
* second. A rename refused because `target` is a mount point or on another device (EBUSY,
|
|
162
|
+
* EXDEV) falls back to copying `tmp` into `target` in place, as an in-place write would:
|
|
163
|
+
* the target keeps its inode, owner and mode (no chmod, no unlink), and a failure part way
|
|
164
|
+
* leaves it partial. Then `tmp` is removed (best effort). Any other failure is thrown and
|
|
165
|
+
* `tmp` is left to the caller.
|
|
166
|
+
*/
|
|
167
|
+
async function replaceFile(tmp, target, options = {}) {
|
|
168
|
+
const rename = options.rename ?? node_fs_promises.rename;
|
|
169
|
+
const win32 = (options.platform ?? process.platform) === "win32";
|
|
170
|
+
let delay = options.retryDelayMs ?? 10;
|
|
171
|
+
for (let attempt = 0;; attempt++) try {
|
|
172
|
+
await rename(tmp, target);
|
|
173
|
+
return;
|
|
174
|
+
} catch (error) {
|
|
175
|
+
const code = codeOf(error);
|
|
176
|
+
if (win32 && code && WIN32_RETRY_CODES.has(code) && attempt < WIN32_RETRIES) {
|
|
177
|
+
await new Promise((resolve) => setTimeout(resolve, delay));
|
|
178
|
+
delay *= 2;
|
|
179
|
+
continue;
|
|
180
|
+
}
|
|
181
|
+
if (code && (win32 ? WIN32_COPY_CODES : COPY_CODES).has(code)) {
|
|
182
|
+
await copyInto(tmp, target);
|
|
183
|
+
await node_fs_promises.rm(tmp, { force: true }).catch(() => {});
|
|
184
|
+
return;
|
|
185
|
+
}
|
|
186
|
+
throw error;
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
/** Overwrite `target` in place with `source` (not `fs.copyFile`, which chmods and unlinks). */
|
|
190
|
+
async function copyInto(source, target) {
|
|
191
|
+
const from = await node_fs_promises.open(source, "r");
|
|
192
|
+
try {
|
|
193
|
+
const to = await node_fs_promises.open(target, "r+");
|
|
194
|
+
try {
|
|
195
|
+
const buffer = new Uint8Array(COPY_CHUNK);
|
|
196
|
+
let position = 0;
|
|
197
|
+
for (;;) {
|
|
198
|
+
const { bytesRead } = await from.read(buffer, 0, buffer.length, position);
|
|
199
|
+
if (bytesRead === 0) break;
|
|
200
|
+
let offset = 0;
|
|
201
|
+
while (offset < bytesRead) {
|
|
202
|
+
const { bytesWritten } = await to.write(buffer, offset, bytesRead - offset, position + offset);
|
|
203
|
+
offset += bytesWritten;
|
|
204
|
+
}
|
|
205
|
+
position += bytesRead;
|
|
206
|
+
}
|
|
207
|
+
await to.truncate(position);
|
|
208
|
+
} finally {
|
|
209
|
+
await to.close();
|
|
210
|
+
}
|
|
211
|
+
} finally {
|
|
212
|
+
await from.close();
|
|
213
|
+
}
|
|
214
|
+
}
|
|
142
215
|
// src/node-files-api.ts
|
|
216
|
+
/** Small chunks are gathered up to this size before each write call. */
|
|
217
|
+
const WRITE_BATCH = 65536;
|
|
218
|
+
/** Temporaries of writes in flight: hidden from `list`, left behind only by a crash. */
|
|
219
|
+
const TMP_PREFIX = ".webrun-tmp-";
|
|
220
|
+
const TMP_NAME = /^\.webrun-tmp-[0-9a-f]{16}$/;
|
|
221
|
+
const tmpName = () => TMP_PREFIX + Array.from(crypto.getRandomValues(/* @__PURE__ */ new Uint8Array(8)), (b) => b.toString(16).padStart(2, "0")).join("");
|
|
143
222
|
/**
|
|
144
223
|
* Node.js FilesApi implementation using fs/promises.
|
|
145
224
|
* Maps virtual paths (starting with /) to the filesystem rooted at rootDir.
|
|
@@ -152,7 +231,8 @@ var NodeFilesApi = class {
|
|
|
152
231
|
/** The real path of a virtual one; throws when `..` segments would take it outside rootDir. */
|
|
153
232
|
resolvePath(virtualPath) {
|
|
154
233
|
const real = node_path.resolve(this.rootDir, `.${normalizePath(virtualPath)}`);
|
|
155
|
-
|
|
234
|
+
const prefix = this.rootDir.endsWith(node_path.sep) ? this.rootDir : this.rootDir + node_path.sep;
|
|
235
|
+
if (real !== this.rootDir && !real.startsWith(prefix)) throw new Error(`NodeFilesApi: path is outside rootDir: ${virtualPath}`);
|
|
156
236
|
return real;
|
|
157
237
|
}
|
|
158
238
|
async *read(path, options) {
|
|
@@ -186,23 +266,70 @@ var NodeFilesApi = class {
|
|
|
186
266
|
}
|
|
187
267
|
}
|
|
188
268
|
async write(path, content) {
|
|
189
|
-
const
|
|
269
|
+
const resolved = this.resolvePath(path);
|
|
270
|
+
if (resolved === this.rootDir) throw Object.assign(/* @__PURE__ */ new Error(`EISDIR: cannot write the root directory: ${path}`), { code: "EISDIR" });
|
|
190
271
|
const normalized = normalizePath(path);
|
|
191
272
|
const dir = this.resolvePath(dirname(normalized));
|
|
192
273
|
await node_fs_promises.mkdir(dir, { recursive: true });
|
|
193
|
-
|
|
194
|
-
let
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
274
|
+
let realPath = resolved;
|
|
275
|
+
let mode;
|
|
276
|
+
let existing;
|
|
277
|
+
try {
|
|
278
|
+
if ((await node_fs_promises.lstat(resolved)).isSymbolicLink()) realPath = await node_fs_promises.realpath(resolved);
|
|
279
|
+
existing = await node_fs_promises.stat(realPath);
|
|
280
|
+
} catch {}
|
|
281
|
+
if (existing) {
|
|
282
|
+
if (existing.isDirectory()) throw Object.assign(/* @__PURE__ */ new Error(`EISDIR: illegal operation on a directory: ${path}`), { code: "EISDIR" });
|
|
283
|
+
await node_fs_promises.access(realPath, node_fs_promises.constants.W_OK);
|
|
284
|
+
mode = existing.mode & 511;
|
|
198
285
|
}
|
|
199
|
-
const
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
286
|
+
const tmp = node_path.join(node_path.dirname(realPath), tmpName());
|
|
287
|
+
const handle = await node_fs_promises.open(tmp, "wx");
|
|
288
|
+
let done = false;
|
|
289
|
+
try {
|
|
290
|
+
await writeBatched(handle, content);
|
|
291
|
+
if (mode !== void 0) await handle.chmod(mode);
|
|
292
|
+
await handle.close();
|
|
293
|
+
await replaceFile(tmp, realPath);
|
|
294
|
+
done = true;
|
|
295
|
+
} finally {
|
|
296
|
+
if (!done) {
|
|
297
|
+
await handle.close().catch(() => {});
|
|
298
|
+
await node_fs_promises.rm(tmp, { force: true }).catch(() => {});
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
/**
|
|
303
|
+
* `open(path, "wx")` — `O_CREAT | O_EXCL` — so the operating system decides: exclusive
|
|
304
|
+
* against every process on the machine using the same filesystem, and on most network
|
|
305
|
+
* filesystems (NFSv3 and later) across machines too. Anything at the path counts as
|
|
306
|
+
* existing, a symbolic link included, even a dangling one. The file is visible, growing,
|
|
307
|
+
* while its content is streamed in (64 KiB batches), as git's lock files are; a source
|
|
308
|
+
* that fails removes it again.
|
|
309
|
+
*/
|
|
310
|
+
async createExclusive(path, content) {
|
|
311
|
+
const resolved = this.resolvePath(path);
|
|
312
|
+
if (resolved === this.rootDir) return false;
|
|
313
|
+
await node_fs_promises.mkdir(this.resolvePath(dirname(normalizePath(path))), { recursive: true });
|
|
314
|
+
let handle;
|
|
315
|
+
try {
|
|
316
|
+
handle = await node_fs_promises.open(resolved, "wx");
|
|
317
|
+
} catch (error) {
|
|
318
|
+
if (error.code === "EEXIST") return false;
|
|
319
|
+
throw error;
|
|
320
|
+
}
|
|
321
|
+
let done = false;
|
|
322
|
+
try {
|
|
323
|
+
await writeBatched(handle, content);
|
|
324
|
+
await handle.close();
|
|
325
|
+
done = true;
|
|
326
|
+
return true;
|
|
327
|
+
} finally {
|
|
328
|
+
if (!done) {
|
|
329
|
+
await handle.close().catch(() => {});
|
|
330
|
+
await node_fs_promises.unlink(resolved).catch(() => {});
|
|
331
|
+
}
|
|
204
332
|
}
|
|
205
|
-
await node_fs_promises.writeFile(realPath, result);
|
|
206
333
|
}
|
|
207
334
|
async mkdir(path) {
|
|
208
335
|
const realPath = this.resolvePath(path);
|
|
@@ -222,6 +349,7 @@ var NodeFilesApi = class {
|
|
|
222
349
|
}
|
|
223
350
|
const infos = [];
|
|
224
351
|
for (const entry of entries) {
|
|
352
|
+
if (entry.isFile() && TMP_NAME.test(entry.name)) continue;
|
|
225
353
|
const entryPath = joinPath(dir, entry.name);
|
|
226
354
|
let stat;
|
|
227
355
|
try {
|
|
@@ -279,6 +407,16 @@ var NodeFilesApi = class {
|
|
|
279
407
|
return false;
|
|
280
408
|
}
|
|
281
409
|
}
|
|
410
|
+
/**
|
|
411
|
+
* `fs.rename` (POSIX `rename(2)`), after creating the target's parent directories:
|
|
412
|
+
* atomic within one filesystem, so a reader sees the old target or the new one, never
|
|
413
|
+
* a missing or partial file, and a failed rename changes nothing. An existing file is
|
|
414
|
+
* replaced, and so is an empty directory when the source is a directory. A file onto
|
|
415
|
+
* a directory (EISDIR), a directory onto a file (ENOTDIR) or a non-empty directory
|
|
416
|
+
* (ENOTEMPTY), a directory into its own subtree (EINVAL), and any other failed rename
|
|
417
|
+
* return false, the target untouched. The target's parent directories are created
|
|
418
|
+
* before the rename, so they may be left behind even when `move` returns false.
|
|
419
|
+
*/
|
|
282
420
|
async move(source, target) {
|
|
283
421
|
const sourcePath = this.resolvePath(source);
|
|
284
422
|
const targetPath = this.resolvePath(target);
|
|
@@ -316,4 +454,27 @@ var NodeFilesApi = class {
|
|
|
316
454
|
}
|
|
317
455
|
}
|
|
318
456
|
};
|
|
457
|
+
/** Write the content to the handle, small chunks gathered into {@link WRITE_BATCH} writes. */
|
|
458
|
+
async function writeBatched(handle, content) {
|
|
459
|
+
const batch = [];
|
|
460
|
+
let batched = 0;
|
|
461
|
+
const flush = async () => {
|
|
462
|
+
if (batched === 0) return;
|
|
463
|
+
const bytes = batch.length === 1 ? batch[0] : Buffer.concat(batch);
|
|
464
|
+
let offset = 0;
|
|
465
|
+
while (offset < bytes.length) {
|
|
466
|
+
const { bytesWritten } = await handle.write(bytes, offset, bytes.length - offset);
|
|
467
|
+
offset += bytesWritten;
|
|
468
|
+
}
|
|
469
|
+
batch.length = 0;
|
|
470
|
+
batched = 0;
|
|
471
|
+
};
|
|
472
|
+
for await (const chunk of content) {
|
|
473
|
+
if (chunk.length === 0) continue;
|
|
474
|
+
batch.push(chunk);
|
|
475
|
+
batched += chunk.length;
|
|
476
|
+
if (batched >= WRITE_BATCH) await flush();
|
|
477
|
+
}
|
|
478
|
+
await flush();
|
|
479
|
+
}
|
|
319
480
|
exports.NodeFilesApi = NodeFilesApi;
|
package/dist/esm/index.js
CHANGED
|
@@ -115,7 +115,86 @@ function dirname(path) {
|
|
|
115
115
|
if (lastSlash <= 0) return "/";
|
|
116
116
|
return normalized.substring(0, lastSlash);
|
|
117
117
|
}
|
|
118
|
+
// src/replace-file.ts
|
|
119
|
+
/** Windows refuses to replace a file another process holds open; such holds are brief. */
|
|
120
|
+
const WIN32_RETRY_CODES = /* @__PURE__ */ new Set([
|
|
121
|
+
"EPERM",
|
|
122
|
+
"EACCES",
|
|
123
|
+
"EBUSY"
|
|
124
|
+
]);
|
|
125
|
+
const WIN32_RETRIES = 7;
|
|
126
|
+
/**
|
|
127
|
+
* A rename cannot replace a mount point (a bind-mounted file: EBUSY on POSIX) or cross
|
|
128
|
+
* devices (EXDEV). On Windows EBUSY is a sharing violation, retried above, not a mount.
|
|
129
|
+
*/
|
|
130
|
+
const COPY_CODES = /* @__PURE__ */ new Set(["EBUSY", "EXDEV"]);
|
|
131
|
+
const WIN32_COPY_CODES = /* @__PURE__ */ new Set(["EXDEV"]);
|
|
132
|
+
const COPY_CHUNK = 65536;
|
|
133
|
+
const codeOf = (error) => error?.code;
|
|
134
|
+
/**
|
|
135
|
+
* Move `tmp` over `target`. Normally a rename, so readers see the old file or the new one.
|
|
136
|
+
* On Windows a rename refused because the file is open elsewhere is retried for about a
|
|
137
|
+
* second. A rename refused because `target` is a mount point or on another device (EBUSY,
|
|
138
|
+
* EXDEV) falls back to copying `tmp` into `target` in place, as an in-place write would:
|
|
139
|
+
* the target keeps its inode, owner and mode (no chmod, no unlink), and a failure part way
|
|
140
|
+
* leaves it partial. Then `tmp` is removed (best effort). Any other failure is thrown and
|
|
141
|
+
* `tmp` is left to the caller.
|
|
142
|
+
*/
|
|
143
|
+
async function replaceFile(tmp, target, options = {}) {
|
|
144
|
+
const rename = options.rename ?? fs.rename;
|
|
145
|
+
const win32 = (options.platform ?? process.platform) === "win32";
|
|
146
|
+
let delay = options.retryDelayMs ?? 10;
|
|
147
|
+
for (let attempt = 0;; attempt++) try {
|
|
148
|
+
await rename(tmp, target);
|
|
149
|
+
return;
|
|
150
|
+
} catch (error) {
|
|
151
|
+
const code = codeOf(error);
|
|
152
|
+
if (win32 && code && WIN32_RETRY_CODES.has(code) && attempt < WIN32_RETRIES) {
|
|
153
|
+
await new Promise((resolve) => setTimeout(resolve, delay));
|
|
154
|
+
delay *= 2;
|
|
155
|
+
continue;
|
|
156
|
+
}
|
|
157
|
+
if (code && (win32 ? WIN32_COPY_CODES : COPY_CODES).has(code)) {
|
|
158
|
+
await copyInto(tmp, target);
|
|
159
|
+
await fs.rm(tmp, { force: true }).catch(() => {});
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
162
|
+
throw error;
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
/** Overwrite `target` in place with `source` (not `fs.copyFile`, which chmods and unlinks). */
|
|
166
|
+
async function copyInto(source, target) {
|
|
167
|
+
const from = await fs.open(source, "r");
|
|
168
|
+
try {
|
|
169
|
+
const to = await fs.open(target, "r+");
|
|
170
|
+
try {
|
|
171
|
+
const buffer = new Uint8Array(COPY_CHUNK);
|
|
172
|
+
let position = 0;
|
|
173
|
+
for (;;) {
|
|
174
|
+
const { bytesRead } = await from.read(buffer, 0, buffer.length, position);
|
|
175
|
+
if (bytesRead === 0) break;
|
|
176
|
+
let offset = 0;
|
|
177
|
+
while (offset < bytesRead) {
|
|
178
|
+
const { bytesWritten } = await to.write(buffer, offset, bytesRead - offset, position + offset);
|
|
179
|
+
offset += bytesWritten;
|
|
180
|
+
}
|
|
181
|
+
position += bytesRead;
|
|
182
|
+
}
|
|
183
|
+
await to.truncate(position);
|
|
184
|
+
} finally {
|
|
185
|
+
await to.close();
|
|
186
|
+
}
|
|
187
|
+
} finally {
|
|
188
|
+
await from.close();
|
|
189
|
+
}
|
|
190
|
+
}
|
|
118
191
|
// src/node-files-api.ts
|
|
192
|
+
/** Small chunks are gathered up to this size before each write call. */
|
|
193
|
+
const WRITE_BATCH = 65536;
|
|
194
|
+
/** Temporaries of writes in flight: hidden from `list`, left behind only by a crash. */
|
|
195
|
+
const TMP_PREFIX = ".webrun-tmp-";
|
|
196
|
+
const TMP_NAME = /^\.webrun-tmp-[0-9a-f]{16}$/;
|
|
197
|
+
const tmpName = () => TMP_PREFIX + Array.from(crypto.getRandomValues(/* @__PURE__ */ new Uint8Array(8)), (b) => b.toString(16).padStart(2, "0")).join("");
|
|
119
198
|
/**
|
|
120
199
|
* Node.js FilesApi implementation using fs/promises.
|
|
121
200
|
* Maps virtual paths (starting with /) to the filesystem rooted at rootDir.
|
|
@@ -128,7 +207,8 @@ var NodeFilesApi = class {
|
|
|
128
207
|
/** The real path of a virtual one; throws when `..` segments would take it outside rootDir. */
|
|
129
208
|
resolvePath(virtualPath) {
|
|
130
209
|
const real = nodePath.resolve(this.rootDir, `.${normalizePath(virtualPath)}`);
|
|
131
|
-
|
|
210
|
+
const prefix = this.rootDir.endsWith(nodePath.sep) ? this.rootDir : this.rootDir + nodePath.sep;
|
|
211
|
+
if (real !== this.rootDir && !real.startsWith(prefix)) throw new Error(`NodeFilesApi: path is outside rootDir: ${virtualPath}`);
|
|
132
212
|
return real;
|
|
133
213
|
}
|
|
134
214
|
async *read(path, options) {
|
|
@@ -162,23 +242,70 @@ var NodeFilesApi = class {
|
|
|
162
242
|
}
|
|
163
243
|
}
|
|
164
244
|
async write(path, content) {
|
|
165
|
-
const
|
|
245
|
+
const resolved = this.resolvePath(path);
|
|
246
|
+
if (resolved === this.rootDir) throw Object.assign(/* @__PURE__ */ new Error(`EISDIR: cannot write the root directory: ${path}`), { code: "EISDIR" });
|
|
166
247
|
const normalized = normalizePath(path);
|
|
167
248
|
const dir = this.resolvePath(dirname(normalized));
|
|
168
249
|
await fs.mkdir(dir, { recursive: true });
|
|
169
|
-
|
|
170
|
-
let
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
250
|
+
let realPath = resolved;
|
|
251
|
+
let mode;
|
|
252
|
+
let existing;
|
|
253
|
+
try {
|
|
254
|
+
if ((await fs.lstat(resolved)).isSymbolicLink()) realPath = await fs.realpath(resolved);
|
|
255
|
+
existing = await fs.stat(realPath);
|
|
256
|
+
} catch {}
|
|
257
|
+
if (existing) {
|
|
258
|
+
if (existing.isDirectory()) throw Object.assign(/* @__PURE__ */ new Error(`EISDIR: illegal operation on a directory: ${path}`), { code: "EISDIR" });
|
|
259
|
+
await fs.access(realPath, fs.constants.W_OK);
|
|
260
|
+
mode = existing.mode & 511;
|
|
174
261
|
}
|
|
175
|
-
const
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
262
|
+
const tmp = nodePath.join(nodePath.dirname(realPath), tmpName());
|
|
263
|
+
const handle = await fs.open(tmp, "wx");
|
|
264
|
+
let done = false;
|
|
265
|
+
try {
|
|
266
|
+
await writeBatched(handle, content);
|
|
267
|
+
if (mode !== void 0) await handle.chmod(mode);
|
|
268
|
+
await handle.close();
|
|
269
|
+
await replaceFile(tmp, realPath);
|
|
270
|
+
done = true;
|
|
271
|
+
} finally {
|
|
272
|
+
if (!done) {
|
|
273
|
+
await handle.close().catch(() => {});
|
|
274
|
+
await fs.rm(tmp, { force: true }).catch(() => {});
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
/**
|
|
279
|
+
* `open(path, "wx")` — `O_CREAT | O_EXCL` — so the operating system decides: exclusive
|
|
280
|
+
* against every process on the machine using the same filesystem, and on most network
|
|
281
|
+
* filesystems (NFSv3 and later) across machines too. Anything at the path counts as
|
|
282
|
+
* existing, a symbolic link included, even a dangling one. The file is visible, growing,
|
|
283
|
+
* while its content is streamed in (64 KiB batches), as git's lock files are; a source
|
|
284
|
+
* that fails removes it again.
|
|
285
|
+
*/
|
|
286
|
+
async createExclusive(path, content) {
|
|
287
|
+
const resolved = this.resolvePath(path);
|
|
288
|
+
if (resolved === this.rootDir) return false;
|
|
289
|
+
await fs.mkdir(this.resolvePath(dirname(normalizePath(path))), { recursive: true });
|
|
290
|
+
let handle;
|
|
291
|
+
try {
|
|
292
|
+
handle = await fs.open(resolved, "wx");
|
|
293
|
+
} catch (error) {
|
|
294
|
+
if (error.code === "EEXIST") return false;
|
|
295
|
+
throw error;
|
|
296
|
+
}
|
|
297
|
+
let done = false;
|
|
298
|
+
try {
|
|
299
|
+
await writeBatched(handle, content);
|
|
300
|
+
await handle.close();
|
|
301
|
+
done = true;
|
|
302
|
+
return true;
|
|
303
|
+
} finally {
|
|
304
|
+
if (!done) {
|
|
305
|
+
await handle.close().catch(() => {});
|
|
306
|
+
await fs.unlink(resolved).catch(() => {});
|
|
307
|
+
}
|
|
180
308
|
}
|
|
181
|
-
await fs.writeFile(realPath, result);
|
|
182
309
|
}
|
|
183
310
|
async mkdir(path) {
|
|
184
311
|
const realPath = this.resolvePath(path);
|
|
@@ -198,6 +325,7 @@ var NodeFilesApi = class {
|
|
|
198
325
|
}
|
|
199
326
|
const infos = [];
|
|
200
327
|
for (const entry of entries) {
|
|
328
|
+
if (entry.isFile() && TMP_NAME.test(entry.name)) continue;
|
|
201
329
|
const entryPath = joinPath(dir, entry.name);
|
|
202
330
|
let stat;
|
|
203
331
|
try {
|
|
@@ -255,6 +383,16 @@ var NodeFilesApi = class {
|
|
|
255
383
|
return false;
|
|
256
384
|
}
|
|
257
385
|
}
|
|
386
|
+
/**
|
|
387
|
+
* `fs.rename` (POSIX `rename(2)`), after creating the target's parent directories:
|
|
388
|
+
* atomic within one filesystem, so a reader sees the old target or the new one, never
|
|
389
|
+
* a missing or partial file, and a failed rename changes nothing. An existing file is
|
|
390
|
+
* replaced, and so is an empty directory when the source is a directory. A file onto
|
|
391
|
+
* a directory (EISDIR), a directory onto a file (ENOTDIR) or a non-empty directory
|
|
392
|
+
* (ENOTEMPTY), a directory into its own subtree (EINVAL), and any other failed rename
|
|
393
|
+
* return false, the target untouched. The target's parent directories are created
|
|
394
|
+
* before the rename, so they may be left behind even when `move` returns false.
|
|
395
|
+
*/
|
|
258
396
|
async move(source, target) {
|
|
259
397
|
const sourcePath = this.resolvePath(source);
|
|
260
398
|
const targetPath = this.resolvePath(target);
|
|
@@ -292,4 +430,27 @@ var NodeFilesApi = class {
|
|
|
292
430
|
}
|
|
293
431
|
}
|
|
294
432
|
};
|
|
433
|
+
/** Write the content to the handle, small chunks gathered into {@link WRITE_BATCH} writes. */
|
|
434
|
+
async function writeBatched(handle, content) {
|
|
435
|
+
const batch = [];
|
|
436
|
+
let batched = 0;
|
|
437
|
+
const flush = async () => {
|
|
438
|
+
if (batched === 0) return;
|
|
439
|
+
const bytes = batch.length === 1 ? batch[0] : Buffer.concat(batch);
|
|
440
|
+
let offset = 0;
|
|
441
|
+
while (offset < bytes.length) {
|
|
442
|
+
const { bytesWritten } = await handle.write(bytes, offset, bytes.length - offset);
|
|
443
|
+
offset += bytesWritten;
|
|
444
|
+
}
|
|
445
|
+
batch.length = 0;
|
|
446
|
+
batched = 0;
|
|
447
|
+
};
|
|
448
|
+
for await (const chunk of content) {
|
|
449
|
+
if (chunk.length === 0) continue;
|
|
450
|
+
batch.push(chunk);
|
|
451
|
+
batched += chunk.length;
|
|
452
|
+
if (batched >= WRITE_BATCH) await flush();
|
|
453
|
+
}
|
|
454
|
+
await flush();
|
|
455
|
+
}
|
|
295
456
|
export { NodeFilesApi };
|
package/dist/node-files-api.d.ts
CHANGED
|
@@ -17,6 +17,15 @@ export declare class NodeFilesApi implements FilesApi {
|
|
|
17
17
|
private resolvePath;
|
|
18
18
|
read(path: string, options?: ReadOptions): AsyncIterable<Uint8Array>;
|
|
19
19
|
write(path: string, content: Iterable<Uint8Array> | AsyncIterable<Uint8Array>): Promise<void>;
|
|
20
|
+
/**
|
|
21
|
+
* `open(path, "wx")` — `O_CREAT | O_EXCL` — so the operating system decides: exclusive
|
|
22
|
+
* against every process on the machine using the same filesystem, and on most network
|
|
23
|
+
* filesystems (NFSv3 and later) across machines too. Anything at the path counts as
|
|
24
|
+
* existing, a symbolic link included, even a dangling one. The file is visible, growing,
|
|
25
|
+
* while its content is streamed in (64 KiB batches), as git's lock files are; a source
|
|
26
|
+
* that fails removes it again.
|
|
27
|
+
*/
|
|
28
|
+
createExclusive(path: string, content: Iterable<Uint8Array> | AsyncIterable<Uint8Array>): Promise<boolean>;
|
|
20
29
|
mkdir(path: string): Promise<void>;
|
|
21
30
|
/** `readdir` order is unspecified, so the tree is walked in `comparePaths` order. */
|
|
22
31
|
list(path: string, options?: ListOptions): AsyncIterable<FileInfo>;
|
|
@@ -25,6 +34,16 @@ export declare class NodeFilesApi implements FilesApi {
|
|
|
25
34
|
stats(path: string): Promise<FileStats | undefined>;
|
|
26
35
|
exists(path: string): Promise<boolean>;
|
|
27
36
|
remove(path: string): Promise<boolean>;
|
|
37
|
+
/**
|
|
38
|
+
* `fs.rename` (POSIX `rename(2)`), after creating the target's parent directories:
|
|
39
|
+
* atomic within one filesystem, so a reader sees the old target or the new one, never
|
|
40
|
+
* a missing or partial file, and a failed rename changes nothing. An existing file is
|
|
41
|
+
* replaced, and so is an empty directory when the source is a directory. A file onto
|
|
42
|
+
* a directory (EISDIR), a directory onto a file (ENOTDIR) or a non-empty directory
|
|
43
|
+
* (ENOTEMPTY), a directory into its own subtree (EINVAL), and any other failed rename
|
|
44
|
+
* return false, the target untouched. The target's parent directories are created
|
|
45
|
+
* before the rename, so they may be left behind even when `move` returns false.
|
|
46
|
+
*/
|
|
28
47
|
move(source: string, target: string): Promise<boolean>;
|
|
29
48
|
copy(source: string, target: string): Promise<boolean>;
|
|
30
49
|
}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"node-files-api.d.ts","sourceRoot":"","sources":["../src/node-files-api.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EACV,QAAQ,EACR,SAAS,EACT,QAAQ,EACR,WAAW,EACX,WAAW,EACZ,MAAM,2BAA2B,CAAC;
|
|
1
|
+
{"version":3,"file":"node-files-api.d.ts","sourceRoot":"","sources":["../src/node-files-api.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EACV,QAAQ,EACR,SAAS,EACT,QAAQ,EACR,WAAW,EACX,WAAW,EACZ,MAAM,2BAA2B,CAAC;AAgBnC,MAAM,WAAW,mBAAmB;IAClC;;;OAGG;IACH,OAAO,CAAC,EAAE,MAAM,CAAC;CAClB;AAED;;;GAGG;AACH,qBAAa,YAAa,YAAW,QAAQ;IAC3C,OAAO,CAAC,OAAO,CAAS;IAExB,YAAY,OAAO,CAAC,EAAE,mBAAmB,EAExC;IAED,+FAA+F;IAC/F,OAAO,CAAC,WAAW;IASZ,IAAI,CAAC,IAAI,EAAE,MAAM,EAAE,OAAO,CAAC,EAAE,WAAW,GAAG,aAAa,CAAC,UAAU,CAAC,CAyC1E;IAEK,KAAK,CACT,IAAI,EAAE,MAAM,EACZ,OAAO,EAAE,QAAQ,CAAC,UAAU,CAAC,GAAG,aAAa,CAAC,UAAU,CAAC,GACxD,OAAO,CAAC,IAAI,CAAC,CAwDf;IAED;;;;;;;OAOG;IACG,eAAe,CACnB,IAAI,EAAE,MAAM,EACZ,OAAO,EAAE,QAAQ,CAAC,UAAU,CAAC,GAAG,aAAa,CAAC,UAAU,CAAC,GACxD,OAAO,CAAC,OAAO,CAAC,CA2BlB;IAEK,KAAK,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAGvC;IAED,qFAAqF;IAC9E,IAAI,CAAC,IAAI,EAAE,MAAM,EAAE,OAAO,CAAC,EAAE,WAAW,GAAG,aAAa,CAAC,QAAQ,CAAC,CAExE;IAED,mFAAmF;YACrE,YAAY;IAiCpB,KAAK,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,SAAS,GAAG,SAAS,CAAC,CAkBxD;IAEK,MAAM,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC,CAS3C;IAEK,MAAM,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC,CAc3C;IAED;;;;;;;;;OASG;IACG,IAAI,CAAC,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC,CAmB3D;IAEK,IAAI,CAAC,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC,CAmB3D;CACF"}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
export interface ReplaceFileOptions {
|
|
2
|
+
/** The rename to use (tests inject failures). */
|
|
3
|
+
rename?: (from: string, to: string) => Promise<void>;
|
|
4
|
+
/** The platform whose rename rules apply (default: `process.platform`). */
|
|
5
|
+
platform?: string;
|
|
6
|
+
/** Delay before the first Windows retry; it doubles each time (default 10 ms). */
|
|
7
|
+
retryDelayMs?: number;
|
|
8
|
+
}
|
|
9
|
+
/**
|
|
10
|
+
* Move `tmp` over `target`. Normally a rename, so readers see the old file or the new one.
|
|
11
|
+
* On Windows a rename refused because the file is open elsewhere is retried for about a
|
|
12
|
+
* second. A rename refused because `target` is a mount point or on another device (EBUSY,
|
|
13
|
+
* EXDEV) falls back to copying `tmp` into `target` in place, as an in-place write would:
|
|
14
|
+
* the target keeps its inode, owner and mode (no chmod, no unlink), and a failure part way
|
|
15
|
+
* leaves it partial. Then `tmp` is removed (best effort). Any other failure is thrown and
|
|
16
|
+
* `tmp` is left to the caller.
|
|
17
|
+
*/
|
|
18
|
+
export declare function replaceFile(tmp: string, target: string, options?: ReplaceFileOptions): Promise<void>;
|
|
19
|
+
//# sourceMappingURL=replace-file.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"replace-file.d.ts","sourceRoot":"","sources":["../src/replace-file.ts"],"names":[],"mappings":"AAEA,MAAM,WAAW,kBAAkB;IACjC,iDAAiD;IACjD,MAAM,CAAC,EAAE,CAAC,IAAI,EAAE,MAAM,EAAE,EAAE,EAAE,MAAM,KAAK,OAAO,CAAC,IAAI,CAAC,CAAC;IACrD,2EAA2E;IAC3E,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,kFAAkF;IAClF,YAAY,CAAC,EAAE,MAAM,CAAC;CACvB;AAgBD;;;;;;;;GAQG;AACH,wBAAsB,WAAW,CAC/B,GAAG,EAAE,MAAM,EACX,MAAM,EAAE,MAAM,EACd,OAAO,GAAE,kBAAuB,GAC/B,OAAO,CAAC,IAAI,CAAC,CAuBf"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@statewalker/webrun-files-node",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.11.0",
|
|
4
4
|
"private": false,
|
|
5
5
|
"type": "module",
|
|
6
6
|
"description": "Node.js FilesApi implementation",
|
|
@@ -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/node-files-api.ts
CHANGED
|
@@ -9,6 +9,19 @@ import type {
|
|
|
9
9
|
ReadOptions,
|
|
10
10
|
} from "@statewalker/webrun-files";
|
|
11
11
|
import { dirname, joinPath, listInPathOrder, normalizePath } from "@statewalker/webrun-files";
|
|
12
|
+
import { replaceFile } from "./replace-file.js";
|
|
13
|
+
|
|
14
|
+
/** Small chunks are gathered up to this size before each write call. */
|
|
15
|
+
const WRITE_BATCH = 64 * 1024;
|
|
16
|
+
|
|
17
|
+
/** Temporaries of writes in flight: hidden from `list`, left behind only by a crash. */
|
|
18
|
+
const TMP_PREFIX = ".webrun-tmp-";
|
|
19
|
+
const TMP_NAME = /^\.webrun-tmp-[0-9a-f]{16}$/;
|
|
20
|
+
const tmpName = () =>
|
|
21
|
+
TMP_PREFIX +
|
|
22
|
+
Array.from(crypto.getRandomValues(new Uint8Array(8)), (b) =>
|
|
23
|
+
b.toString(16).padStart(2, "0"),
|
|
24
|
+
).join("");
|
|
12
25
|
|
|
13
26
|
export interface NodeFilesApiOptions {
|
|
14
27
|
/**
|
|
@@ -32,7 +45,8 @@ export class NodeFilesApi implements FilesApi {
|
|
|
32
45
|
/** The real path of a virtual one; throws when `..` segments would take it outside rootDir. */
|
|
33
46
|
private resolvePath(virtualPath: string): string {
|
|
34
47
|
const real = nodePath.resolve(this.rootDir, `.${normalizePath(virtualPath)}`);
|
|
35
|
-
|
|
48
|
+
const prefix = this.rootDir.endsWith(nodePath.sep) ? this.rootDir : this.rootDir + nodePath.sep;
|
|
49
|
+
if (real !== this.rootDir && !real.startsWith(prefix)) {
|
|
36
50
|
throw new Error(`NodeFilesApi: path is outside rootDir: ${virtualPath}`);
|
|
37
51
|
}
|
|
38
52
|
return real;
|
|
@@ -85,28 +99,101 @@ export class NodeFilesApi implements FilesApi {
|
|
|
85
99
|
path: string,
|
|
86
100
|
content: Iterable<Uint8Array> | AsyncIterable<Uint8Array>,
|
|
87
101
|
): Promise<void> {
|
|
88
|
-
const
|
|
102
|
+
const resolved = this.resolvePath(path);
|
|
103
|
+
if (resolved === this.rootDir) {
|
|
104
|
+
throw Object.assign(new Error(`EISDIR: cannot write the root directory: ${path}`), {
|
|
105
|
+
code: "EISDIR",
|
|
106
|
+
});
|
|
107
|
+
}
|
|
89
108
|
const normalized = normalizePath(path);
|
|
90
109
|
const dir = this.resolvePath(dirname(normalized));
|
|
91
110
|
|
|
92
111
|
await fs.mkdir(dir, { recursive: true });
|
|
93
112
|
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
113
|
+
// An existing file keeps what an in-place write kept: a symbolic link is written
|
|
114
|
+
// through to its target, and the new file gets the old file's mode.
|
|
115
|
+
// (A dangling link is replaced by a regular file rather than creating its target.)
|
|
116
|
+
let realPath = resolved;
|
|
117
|
+
let mode: number | undefined;
|
|
118
|
+
let existing: Stats | undefined;
|
|
119
|
+
try {
|
|
120
|
+
const st = await fs.lstat(resolved);
|
|
121
|
+
if (st.isSymbolicLink()) realPath = await fs.realpath(resolved);
|
|
122
|
+
existing = await fs.stat(realPath);
|
|
123
|
+
} catch {
|
|
124
|
+
// a new file (or a dangling link): written as a new file
|
|
125
|
+
}
|
|
126
|
+
if (existing) {
|
|
127
|
+
if (existing.isDirectory()) {
|
|
128
|
+
throw Object.assign(new Error(`EISDIR: illegal operation on a directory: ${path}`), {
|
|
129
|
+
code: "EISDIR",
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
// A rename would replace a file the caller may not write: refuse, as writeFile did.
|
|
133
|
+
await fs.access(realPath, fs.constants.W_OK);
|
|
134
|
+
// Permission bits only: setuid/setgid are dropped, as the kernel drops them on a write.
|
|
135
|
+
mode = existing.mode & 0o777;
|
|
100
136
|
}
|
|
101
137
|
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
for
|
|
105
|
-
|
|
106
|
-
|
|
138
|
+
// Stream into a temporary beside the file, then rename it over the file: memory stays at
|
|
139
|
+
// about one batch, a source that fails leaves the old file, and a file can be rewritten
|
|
140
|
+
// from its own read. The short dot-name works for names near the length limit, and
|
|
141
|
+
// `list` does not show it.
|
|
142
|
+
const tmp = nodePath.join(nodePath.dirname(realPath), tmpName());
|
|
143
|
+
const handle = await fs.open(tmp, "wx");
|
|
144
|
+
let done = false;
|
|
145
|
+
try {
|
|
146
|
+
await writeBatched(handle, content);
|
|
147
|
+
if (mode !== undefined) await handle.chmod(mode);
|
|
148
|
+
await handle.close();
|
|
149
|
+
await replaceFile(tmp, realPath);
|
|
150
|
+
done = true;
|
|
151
|
+
} finally {
|
|
152
|
+
if (!done) {
|
|
153
|
+
await handle.close().catch(() => {});
|
|
154
|
+
await fs.rm(tmp, { force: true }).catch(() => {});
|
|
155
|
+
}
|
|
107
156
|
}
|
|
157
|
+
}
|
|
108
158
|
|
|
109
|
-
|
|
159
|
+
/**
|
|
160
|
+
* `open(path, "wx")` — `O_CREAT | O_EXCL` — so the operating system decides: exclusive
|
|
161
|
+
* against every process on the machine using the same filesystem, and on most network
|
|
162
|
+
* filesystems (NFSv3 and later) across machines too. Anything at the path counts as
|
|
163
|
+
* existing, a symbolic link included, even a dangling one. The file is visible, growing,
|
|
164
|
+
* while its content is streamed in (64 KiB batches), as git's lock files are; a source
|
|
165
|
+
* that fails removes it again.
|
|
166
|
+
*/
|
|
167
|
+
async createExclusive(
|
|
168
|
+
path: string,
|
|
169
|
+
content: Iterable<Uint8Array> | AsyncIterable<Uint8Array>,
|
|
170
|
+
): Promise<boolean> {
|
|
171
|
+
const resolved = this.resolvePath(path);
|
|
172
|
+
if (resolved === this.rootDir) return false; // the root always exists
|
|
173
|
+
await fs.mkdir(this.resolvePath(dirname(normalizePath(path))), { recursive: true });
|
|
174
|
+
|
|
175
|
+
let handle: fs.FileHandle;
|
|
176
|
+
try {
|
|
177
|
+
handle = await fs.open(resolved, "wx");
|
|
178
|
+
} catch (error) {
|
|
179
|
+
if ((error as NodeJS.ErrnoException).code === "EEXIST") return false;
|
|
180
|
+
throw error;
|
|
181
|
+
}
|
|
182
|
+
let done = false;
|
|
183
|
+
try {
|
|
184
|
+
await writeBatched(handle, content);
|
|
185
|
+
await handle.close();
|
|
186
|
+
done = true;
|
|
187
|
+
return true;
|
|
188
|
+
} finally {
|
|
189
|
+
if (!done) {
|
|
190
|
+
await handle.close().catch(() => {});
|
|
191
|
+
// Unlinks by path, not by handle: if another process removed our file meanwhile
|
|
192
|
+
// (taking it for a stale lock) and created its own, this removes theirs. Git's
|
|
193
|
+
// lock files behave the same.
|
|
194
|
+
await fs.unlink(resolved).catch(() => {});
|
|
195
|
+
}
|
|
196
|
+
}
|
|
110
197
|
}
|
|
111
198
|
|
|
112
199
|
async mkdir(path: string): Promise<void> {
|
|
@@ -130,6 +217,7 @@ export class NodeFilesApi implements FilesApi {
|
|
|
130
217
|
}
|
|
131
218
|
const infos: FileInfo[] = [];
|
|
132
219
|
for (const entry of entries) {
|
|
220
|
+
if (entry.isFile() && TMP_NAME.test(entry.name)) continue; // a write in flight
|
|
133
221
|
const entryPath = joinPath(dir, entry.name);
|
|
134
222
|
let stat: Stats;
|
|
135
223
|
try {
|
|
@@ -199,6 +287,16 @@ export class NodeFilesApi implements FilesApi {
|
|
|
199
287
|
}
|
|
200
288
|
}
|
|
201
289
|
|
|
290
|
+
/**
|
|
291
|
+
* `fs.rename` (POSIX `rename(2)`), after creating the target's parent directories:
|
|
292
|
+
* atomic within one filesystem, so a reader sees the old target or the new one, never
|
|
293
|
+
* a missing or partial file, and a failed rename changes nothing. An existing file is
|
|
294
|
+
* replaced, and so is an empty directory when the source is a directory. A file onto
|
|
295
|
+
* a directory (EISDIR), a directory onto a file (ENOTDIR) or a non-empty directory
|
|
296
|
+
* (ENOTEMPTY), a directory into its own subtree (EINVAL), and any other failed rename
|
|
297
|
+
* return false, the target untouched. The target's parent directories are created
|
|
298
|
+
* before the rename, so they may be left behind even when `move` returns false.
|
|
299
|
+
*/
|
|
202
300
|
async move(source: string, target: string): Promise<boolean> {
|
|
203
301
|
const sourcePath = this.resolvePath(source);
|
|
204
302
|
const targetPath = this.resolvePath(target);
|
|
@@ -241,3 +339,30 @@ export class NodeFilesApi implements FilesApi {
|
|
|
241
339
|
}
|
|
242
340
|
}
|
|
243
341
|
}
|
|
342
|
+
|
|
343
|
+
/** Write the content to the handle, small chunks gathered into {@link WRITE_BATCH} writes. */
|
|
344
|
+
async function writeBatched(
|
|
345
|
+
handle: fs.FileHandle,
|
|
346
|
+
content: Iterable<Uint8Array> | AsyncIterable<Uint8Array>,
|
|
347
|
+
): Promise<void> {
|
|
348
|
+
const batch: Uint8Array[] = [];
|
|
349
|
+
let batched = 0;
|
|
350
|
+
const flush = async () => {
|
|
351
|
+
if (batched === 0) return;
|
|
352
|
+
const bytes = batch.length === 1 ? (batch[0] as Uint8Array) : Buffer.concat(batch);
|
|
353
|
+
let offset = 0;
|
|
354
|
+
while (offset < bytes.length) {
|
|
355
|
+
const { bytesWritten } = await handle.write(bytes, offset, bytes.length - offset);
|
|
356
|
+
offset += bytesWritten;
|
|
357
|
+
}
|
|
358
|
+
batch.length = 0;
|
|
359
|
+
batched = 0;
|
|
360
|
+
};
|
|
361
|
+
for await (const chunk of content) {
|
|
362
|
+
if (chunk.length === 0) continue;
|
|
363
|
+
batch.push(chunk);
|
|
364
|
+
batched += chunk.length;
|
|
365
|
+
if (batched >= WRITE_BATCH) await flush();
|
|
366
|
+
}
|
|
367
|
+
await flush();
|
|
368
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import * as fs from "node:fs/promises";
|
|
2
|
+
|
|
3
|
+
export interface ReplaceFileOptions {
|
|
4
|
+
/** The rename to use (tests inject failures). */
|
|
5
|
+
rename?: (from: string, to: string) => Promise<void>;
|
|
6
|
+
/** The platform whose rename rules apply (default: `process.platform`). */
|
|
7
|
+
platform?: string;
|
|
8
|
+
/** Delay before the first Windows retry; it doubles each time (default 10 ms). */
|
|
9
|
+
retryDelayMs?: number;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/** Windows refuses to replace a file another process holds open; such holds are brief. */
|
|
13
|
+
const WIN32_RETRY_CODES = new Set(["EPERM", "EACCES", "EBUSY"]);
|
|
14
|
+
const WIN32_RETRIES = 7; // 10 ms doubling: about 1.3 s in all
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* A rename cannot replace a mount point (a bind-mounted file: EBUSY on POSIX) or cross
|
|
18
|
+
* devices (EXDEV). On Windows EBUSY is a sharing violation, retried above, not a mount.
|
|
19
|
+
*/
|
|
20
|
+
const COPY_CODES = new Set(["EBUSY", "EXDEV"]);
|
|
21
|
+
const WIN32_COPY_CODES = new Set(["EXDEV"]);
|
|
22
|
+
const COPY_CHUNK = 64 * 1024;
|
|
23
|
+
|
|
24
|
+
const codeOf = (error: unknown) => (error as { code?: string } | null)?.code;
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Move `tmp` over `target`. Normally a rename, so readers see the old file or the new one.
|
|
28
|
+
* On Windows a rename refused because the file is open elsewhere is retried for about a
|
|
29
|
+
* second. A rename refused because `target` is a mount point or on another device (EBUSY,
|
|
30
|
+
* EXDEV) falls back to copying `tmp` into `target` in place, as an in-place write would:
|
|
31
|
+
* the target keeps its inode, owner and mode (no chmod, no unlink), and a failure part way
|
|
32
|
+
* leaves it partial. Then `tmp` is removed (best effort). Any other failure is thrown and
|
|
33
|
+
* `tmp` is left to the caller.
|
|
34
|
+
*/
|
|
35
|
+
export async function replaceFile(
|
|
36
|
+
tmp: string,
|
|
37
|
+
target: string,
|
|
38
|
+
options: ReplaceFileOptions = {},
|
|
39
|
+
): Promise<void> {
|
|
40
|
+
const rename = options.rename ?? fs.rename;
|
|
41
|
+
const win32 = (options.platform ?? process.platform) === "win32";
|
|
42
|
+
let delay = options.retryDelayMs ?? 10;
|
|
43
|
+
for (let attempt = 0; ; attempt++) {
|
|
44
|
+
try {
|
|
45
|
+
await rename(tmp, target);
|
|
46
|
+
return;
|
|
47
|
+
} catch (error) {
|
|
48
|
+
const code = codeOf(error);
|
|
49
|
+
if (win32 && code && WIN32_RETRY_CODES.has(code) && attempt < WIN32_RETRIES) {
|
|
50
|
+
await new Promise((resolve) => setTimeout(resolve, delay));
|
|
51
|
+
delay *= 2;
|
|
52
|
+
continue;
|
|
53
|
+
}
|
|
54
|
+
if (code && (win32 ? WIN32_COPY_CODES : COPY_CODES).has(code)) {
|
|
55
|
+
await copyInto(tmp, target);
|
|
56
|
+
await fs.rm(tmp, { force: true }).catch(() => {}); // the content has landed
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
throw error;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Overwrite `target` in place with `source` (not `fs.copyFile`, which chmods and unlinks). */
|
|
65
|
+
async function copyInto(source: string, target: string): Promise<void> {
|
|
66
|
+
const from = await fs.open(source, "r");
|
|
67
|
+
try {
|
|
68
|
+
const to = await fs.open(target, "r+");
|
|
69
|
+
try {
|
|
70
|
+
const buffer = new Uint8Array(COPY_CHUNK);
|
|
71
|
+
let position = 0;
|
|
72
|
+
for (;;) {
|
|
73
|
+
const { bytesRead } = await from.read(buffer, 0, buffer.length, position);
|
|
74
|
+
if (bytesRead === 0) break;
|
|
75
|
+
let offset = 0;
|
|
76
|
+
while (offset < bytesRead) {
|
|
77
|
+
const { bytesWritten } = await to.write(
|
|
78
|
+
buffer,
|
|
79
|
+
offset,
|
|
80
|
+
bytesRead - offset,
|
|
81
|
+
position + offset,
|
|
82
|
+
);
|
|
83
|
+
offset += bytesWritten;
|
|
84
|
+
}
|
|
85
|
+
position += bytesRead;
|
|
86
|
+
}
|
|
87
|
+
await to.truncate(position);
|
|
88
|
+
} finally {
|
|
89
|
+
await to.close();
|
|
90
|
+
}
|
|
91
|
+
} finally {
|
|
92
|
+
await from.close();
|
|
93
|
+
}
|
|
94
|
+
}
|