@statewalker/webrun-files-node 0.10.4 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md 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 collects every chunk and calls `fs.writeFile` once |
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` holds the whole file in memory** before writing it, so a very large upload costs its
107
- full size in RAM. Reads stream.
108
- - **`move` across devices returns `false`.** It is a single `fs.rename`; when that fails (`EXDEV`,
109
- permissions) the method returns `false` instead of falling back to copy and delete.
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
 
@@ -1,5 +1,5 @@
1
1
  Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
2
- //#region \0rolldown/runtime.js
2
+ // \0rolldown/runtime.js
3
3
  var __create = Object.create;
4
4
  var __defProp = Object.defineProperty;
5
5
  var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
@@ -20,12 +20,11 @@ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__ge
20
20
  value: mod,
21
21
  enumerable: true
22
22
  }) : target, mod));
23
- //#endregion
24
23
  let node_fs_promises = require("node:fs/promises");
25
24
  node_fs_promises = __toESM(node_fs_promises, 1);
26
25
  let node_path = require("node:path");
27
26
  node_path = __toESM(node_path, 1);
28
- //#region ../webrun-files/dist/esm/index.js
27
+ // ../webrun-files/dist/esm/index.js
29
28
  /**
30
29
  * The order every `FilesApi.list()` yields: paths compared by Unicode code
31
30
  * point, which is the same as comparing their UTF-8 bytes — the order of
@@ -140,8 +139,86 @@ function dirname(path) {
140
139
  if (lastSlash <= 0) return "/";
141
140
  return normalized.substring(0, lastSlash);
142
141
  }
143
- //#endregion
144
- //#region src/node-files-api.ts
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
+ }
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("");
145
222
  /**
146
223
  * Node.js FilesApi implementation using fs/promises.
147
224
  * Maps virtual paths (starting with /) to the filesystem rooted at rootDir.
@@ -154,7 +231,8 @@ var NodeFilesApi = class {
154
231
  /** The real path of a virtual one; throws when `..` segments would take it outside rootDir. */
155
232
  resolvePath(virtualPath) {
156
233
  const real = node_path.resolve(this.rootDir, `.${normalizePath(virtualPath)}`);
157
- if (real !== this.rootDir && !real.startsWith(this.rootDir + node_path.sep)) throw new Error(`NodeFilesApi: path is outside rootDir: ${virtualPath}`);
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}`);
158
236
  return real;
159
237
  }
160
238
  async *read(path, options) {
@@ -188,23 +266,70 @@ var NodeFilesApi = class {
188
266
  }
189
267
  }
190
268
  async write(path, content) {
191
- const realPath = this.resolvePath(path);
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" });
192
271
  const normalized = normalizePath(path);
193
272
  const dir = this.resolvePath(dirname(normalized));
194
273
  await node_fs_promises.mkdir(dir, { recursive: true });
195
- const chunks = [];
196
- let totalLength = 0;
197
- for await (const chunk of content) {
198
- chunks.push(chunk);
199
- totalLength += chunk.length;
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;
200
285
  }
201
- const result = new Uint8Array(totalLength);
202
- let offset = 0;
203
- for (const chunk of chunks) {
204
- result.set(chunk, offset);
205
- offset += chunk.length;
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
+ }
206
332
  }
207
- await node_fs_promises.writeFile(realPath, result);
208
333
  }
209
334
  async mkdir(path) {
210
335
  const realPath = this.resolvePath(path);
@@ -224,6 +349,7 @@ var NodeFilesApi = class {
224
349
  }
225
350
  const infos = [];
226
351
  for (const entry of entries) {
352
+ if (entry.isFile() && TMP_NAME.test(entry.name)) continue;
227
353
  const entryPath = joinPath(dir, entry.name);
228
354
  let stat;
229
355
  try {
@@ -281,6 +407,16 @@ var NodeFilesApi = class {
281
407
  return false;
282
408
  }
283
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
+ */
284
420
  async move(source, target) {
285
421
  const sourcePath = this.resolvePath(source);
286
422
  const targetPath = this.resolvePath(target);
@@ -318,5 +454,27 @@ var NodeFilesApi = class {
318
454
  }
319
455
  }
320
456
  };
321
- //#endregion
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
+ }
322
480
  exports.NodeFilesApi = NodeFilesApi;
package/dist/esm/index.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import * as fs from "node:fs/promises";
2
2
  import * as nodePath from "node:path";
3
- //#region ../webrun-files/dist/esm/index.js
3
+ // ../webrun-files/dist/esm/index.js
4
4
  /**
5
5
  * The order every `FilesApi.list()` yields: paths compared by Unicode code
6
6
  * point, which is the same as comparing their UTF-8 bytes — the order of
@@ -115,8 +115,86 @@ function dirname(path) {
115
115
  if (lastSlash <= 0) return "/";
116
116
  return normalized.substring(0, lastSlash);
117
117
  }
118
- //#endregion
119
- //#region src/node-files-api.ts
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
+ }
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("");
120
198
  /**
121
199
  * Node.js FilesApi implementation using fs/promises.
122
200
  * Maps virtual paths (starting with /) to the filesystem rooted at rootDir.
@@ -129,7 +207,8 @@ var NodeFilesApi = class {
129
207
  /** The real path of a virtual one; throws when `..` segments would take it outside rootDir. */
130
208
  resolvePath(virtualPath) {
131
209
  const real = nodePath.resolve(this.rootDir, `.${normalizePath(virtualPath)}`);
132
- if (real !== this.rootDir && !real.startsWith(this.rootDir + nodePath.sep)) throw new Error(`NodeFilesApi: path is outside rootDir: ${virtualPath}`);
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}`);
133
212
  return real;
134
213
  }
135
214
  async *read(path, options) {
@@ -163,23 +242,70 @@ var NodeFilesApi = class {
163
242
  }
164
243
  }
165
244
  async write(path, content) {
166
- const realPath = this.resolvePath(path);
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" });
167
247
  const normalized = normalizePath(path);
168
248
  const dir = this.resolvePath(dirname(normalized));
169
249
  await fs.mkdir(dir, { recursive: true });
170
- const chunks = [];
171
- let totalLength = 0;
172
- for await (const chunk of content) {
173
- chunks.push(chunk);
174
- totalLength += chunk.length;
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;
175
261
  }
176
- const result = new Uint8Array(totalLength);
177
- let offset = 0;
178
- for (const chunk of chunks) {
179
- result.set(chunk, offset);
180
- offset += chunk.length;
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
+ }
181
308
  }
182
- await fs.writeFile(realPath, result);
183
309
  }
184
310
  async mkdir(path) {
185
311
  const realPath = this.resolvePath(path);
@@ -199,6 +325,7 @@ var NodeFilesApi = class {
199
325
  }
200
326
  const infos = [];
201
327
  for (const entry of entries) {
328
+ if (entry.isFile() && TMP_NAME.test(entry.name)) continue;
202
329
  const entryPath = joinPath(dir, entry.name);
203
330
  let stat;
204
331
  try {
@@ -256,6 +383,16 @@ var NodeFilesApi = class {
256
383
  return false;
257
384
  }
258
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
+ */
259
396
  async move(source, target) {
260
397
  const sourcePath = this.resolvePath(source);
261
398
  const targetPath = this.resolvePath(target);
@@ -293,5 +430,27 @@ var NodeFilesApi = class {
293
430
  }
294
431
  }
295
432
  };
296
- //#endregion
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
+ }
297
456
  export { NodeFilesApi };
@@ -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;AAGnC,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;IAQZ,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,CAuBf;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;IAgCpB,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;IAEK,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"}
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.10.4",
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.10.4"
37
+ "@statewalker/webrun-files": "^0.11.0"
38
38
  },
39
39
  "devDependencies": {
40
40
  "@types/node": "^26.6.4",
@@ -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
- if (real !== this.rootDir && !real.startsWith(this.rootDir + nodePath.sep)) {
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 realPath = this.resolvePath(path);
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
- const chunks: Uint8Array[] = [];
95
- let totalLength = 0;
96
-
97
- for await (const chunk of content) {
98
- chunks.push(chunk);
99
- totalLength += chunk.length;
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
- const result = new Uint8Array(totalLength);
103
- let offset = 0;
104
- for (const chunk of chunks) {
105
- result.set(chunk, offset);
106
- offset += chunk.length;
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
- await fs.writeFile(realPath, result);
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
+ }