jg-fs 1.1.0 → 1.2.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/API.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # <a id="api-reference"></a>jg-fs API reference
2
2
 
3
- Every export of `jg-fs@1.1.0`, 62 in all, with its signature, its errors, and an example. It's generated from the package's TSDoc.
3
+ Every export of `jg-fs@1.2.0`, 62 in all, with its signature, its errors, and an example. It's generated from the package's TSDoc.
4
4
 
5
5
  ## <a id="api-entry-main"></a>`jg-fs`
6
6
 
@@ -528,7 +528,7 @@ await readFile('avatar.png'); // a Buffer
528
528
 
529
529
  *Function*
530
530
 
531
- Reads and parses a JSON file, and validates it with the schema when there is one. Without a schema, the value is `unknown`, or the type you pass.
531
+ Reads and parses a JSON file, and validates it with the schema when there is one. Without a schema, the value is `unknown`, or the type you pass. With `maxBytes`, it reads the file as readBoundedFile does, for a file whose size isn't yours to decide.
532
532
 
533
533
  ```ts
534
534
  readJsonFile<T = unknown>(
@@ -540,13 +540,14 @@ readJsonFile<T = unknown>(
540
540
  **Parameters**
541
541
 
542
542
  - `path` `string`: The file's path.
543
- - `options?` `IReadJsonFileOptions<T>`: The schema, such as a Zod schema, whose errors propagate unchanged. Defaults to `{}`.
543
+ - `options?` `IReadJsonFileOptions<T>`: The schema, such as a Zod schema, whose errors propagate unchanged, and the largest file accepted, in bytes. Defaults to `{}`.
544
544
 
545
545
  **Returns** `Promise<T>`: A promise that resolves to the parsed value.
546
546
 
547
547
  **Throws**
548
548
 
549
549
  - `NOT_A_FILE`: if the path doesn't exist or is not a file
550
+ - `FILE_TOO_LARGE`: if the file holds more than maxBytes bytes
550
551
  - `FILE_CONTENT_IS_EMPTY_OR_INVALID`: if the file's JSON content cannot be parsed
551
552
 
552
553
  **Example 1**
@@ -658,7 +659,7 @@ await writeFile('out/report.csv', csv, { encoding: 'utf-8' });
658
659
 
659
660
  *Function*
660
661
 
661
- Writes a file atomically: readers see either the previous content or the complete new content, never a partial write. The data goes to a temporary file in the same directory, which is flushed to disk and renamed over the path; on POSIX, the directory is flushed too. An explicit mode wins; without one, a replaced file keeps its permission bits and a new file gets the platform default. The temporary file is never more widely readable than the final mode. When writing fails before the rename, the temporary file is removed and the destination is untouched.
662
+ Writes a file atomically: readers see either the previous content or the complete new content, never a partial write. The data goes to a temporary file in the same directory, which is flushed to disk and renamed over the path; on POSIX, the directory is flushed too. An explicit mode wins; without one, a replaced file keeps its permission bits and a new file gets the platform default. The temporary file is never more widely readable than the final mode. When writing fails before the rename, the temporary file is removed and the destination is untouched. While Windows refuses the rename because another process, such as an antivirus scanner, holds the file, it tries again for about 4 seconds. A symbolic link at the path is replaced by the file.
662
663
 
663
664
  ```ts
664
665
  writeFileAtomic(
@@ -690,7 +691,7 @@ await writeFileAtomic('secrets.env', 'TOKEN=abc\n', 0o600); // only the owner ca
690
691
 
691
692
  *Function*
692
693
 
693
- Writes a JSON file. A string is written as given; anything else is stringified.
694
+ Writes a JSON file atomically, as writeFileAtomic does, so a crash or a full disk never leaves a truncated file that won't parse. A symbolic link at the path keeps pointing at the file, whose target is replaced. A string is written as given; anything else is stringified.
694
695
 
695
696
  ```ts
696
697
  writeJsonFile<T = Record<string, unknown>>(
@@ -852,12 +853,14 @@ what readJsonFile accepts
852
853
  ```ts
853
854
  type IReadJsonFileOptions<T> = {
854
855
  schema?: ISchema<T>;
856
+ maxBytes?: number;
855
857
  };
856
858
  ```
857
859
 
858
860
  **Members**
859
861
 
860
862
  - `schema: ISchema<T>` (property): validates the parsed content, such as a Zod schema. Its errors propagate unchanged
863
+ - `maxBytes: number` (property): the largest file accepted, in bytes, for a file whose size isn't yours to decide, such as one a user uploaded. With it, the file is read as readBoundedFile reads it
861
864
 
862
865
  #### <a id="api-ISchema"></a>`ISchema`
863
866
 
@@ -1381,7 +1384,7 @@ readFile('avatar.png'); // a Buffer
1381
1384
 
1382
1385
  *Function*
1383
1386
 
1384
- Reads and parses a JSON file, and validates it with the schema when there is one. Without a schema, the value is `unknown`, or the type you pass.
1387
+ Reads and parses a JSON file, and validates it with the schema when there is one. Without a schema, the value is `unknown`, or the type you pass. With `maxBytes`, it reads the file as readBoundedFile does, for a file whose size isn't yours to decide.
1385
1388
 
1386
1389
  ```ts
1387
1390
  readJsonFile<T = unknown>(path: string, options?: IReadJsonFileOptions<T>): T
@@ -1390,13 +1393,14 @@ readJsonFile<T = unknown>(path: string, options?: IReadJsonFileOptions<T>): T
1390
1393
  **Parameters**
1391
1394
 
1392
1395
  - `path` `string`: The file's path.
1393
- - `options?` `IReadJsonFileOptions<T>`: The schema, such as a Zod schema, whose errors propagate unchanged. Defaults to `{}`.
1396
+ - `options?` `IReadJsonFileOptions<T>`: The schema, such as a Zod schema, whose errors propagate unchanged, and the largest file accepted, in bytes. Defaults to `{}`.
1394
1397
 
1395
1398
  **Returns** `T`: The parsed value.
1396
1399
 
1397
1400
  **Throws**
1398
1401
 
1399
1402
  - `NOT_A_FILE`: if the path doesn't exist or is not a file
1403
+ - `FILE_TOO_LARGE`: if the file holds more than maxBytes bytes
1400
1404
  - `FILE_CONTENT_IS_EMPTY_OR_INVALID`: if the file's JSON content cannot be parsed
1401
1405
 
1402
1406
  **Example 1**
@@ -1504,7 +1508,7 @@ writeFile('out/report.csv', csv, { encoding: 'utf-8' });
1504
1508
 
1505
1509
  *Function*
1506
1510
 
1507
- Writes a file atomically: readers see either the previous content or the complete new content, never a partial write. The data goes to a temporary file in the same directory, which is flushed to disk and renamed over the path; on POSIX, the directory is flushed too. An explicit mode wins; without one, a replaced file keeps its permission bits and a new file gets the platform default. The temporary file is never more widely readable than the final mode. When writing fails before the rename, the temporary file is removed and the destination is untouched.
1511
+ Writes a file atomically: readers see either the previous content or the complete new content, never a partial write. The data goes to a temporary file in the same directory, which is flushed to disk and renamed over the path; on POSIX, the directory is flushed too. An explicit mode wins; without one, a replaced file keeps its permission bits and a new file gets the platform default. The temporary file is never more widely readable than the final mode. When writing fails before the rename, the temporary file is removed and the destination is untouched. While Windows refuses the rename because another process, such as an antivirus scanner, holds the file, it tries again for about 4 seconds. A symbolic link at the path is replaced by the file.
1508
1512
 
1509
1513
  ```ts
1510
1514
  writeFileAtomic(
@@ -1534,7 +1538,7 @@ writeFileAtomic('secrets.env', 'TOKEN=abc\n', 0o600); // only the owner can read
1534
1538
 
1535
1539
  *Function*
1536
1540
 
1537
- Writes a JSON file. A string is written as given; anything else is stringified.
1541
+ Writes a JSON file atomically, as writeFileAtomic does, so a crash or a full disk never leaves a truncated file that won't parse. A symbolic link at the path keeps pointing at the file, whose target is replaced. A string is written as given; anything else is stringified.
1538
1542
 
1539
1543
  ```ts
1540
1544
  writeJsonFile<T = Record<string, unknown>>(
@@ -1692,12 +1696,14 @@ what readJsonFile accepts
1692
1696
  ```ts
1693
1697
  type IReadJsonFileOptions<T> = {
1694
1698
  schema?: ISchema<T>;
1699
+ maxBytes?: number;
1695
1700
  };
1696
1701
  ```
1697
1702
 
1698
1703
  **Members**
1699
1704
 
1700
1705
  - `schema: ISchema<T>` (property): validates the parsed content, such as a Zod schema. Its errors propagate unchanged
1706
+ - `maxBytes: number` (property): the largest file accepted, in bytes, for a file whose size isn't yours to decide, such as one a user uploaded. With it, the file is read as readBoundedFile reads it
1701
1707
 
1702
1708
  #### <a id="api-sync-ISchema"></a>`ISchema`
1703
1709
 
package/CHANGELOG.md CHANGED
@@ -1,5 +1,25 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.2.0
4
+
5
+ ### Added
6
+
7
+ - `readJsonFile` takes `maxBytes`, and then reads the file as `readBoundedFile` does, throwing `FILE_TOO_LARGE` for a larger one, for JSON whose size isn't yours to decide.
8
+
9
+ ### Changed
10
+
11
+ - `writeJsonFile` writes atomically, as `writeFileAtomic` does, so a crash or a full disk never leaves a truncated file. A symbolic link at the path keeps pointing at the file, whose target is replaced.
12
+
13
+ ### Fixed
14
+
15
+ - `writeFileAtomic`, and so `writeJsonFile`, tries the rename again for about 4 seconds while Windows refuses it with `EACCES`, `EBUSY`, or `EPERM` because another process, such as an antivirus scanner, holds the file.
16
+
17
+ ## 1.1.1
18
+
19
+ ### Added
20
+
21
+ - `RECIPES.md`, patterns for apps that use the package: a config file that's never half-written, and reading uploads with a size limit. Each block is typechecked against the release.
22
+
3
23
  ## 1.1.0
4
24
 
5
25
  A fresh start: breaking changes in a minor version, once, because nothing depended on 1.0.x.
package/README.md CHANGED
@@ -13,7 +13,7 @@ npm install jg-fs
13
13
  The root entry is async, and `jg-fs/sync` has the same functions, synchronous:
14
14
 
15
15
  ```ts
16
- import { readJsonFile, writeFileAtomic, writeTextFile } from 'jg-fs';
16
+ import { readJsonFile, writeFileAtomic, writeJsonFile, writeTextFile } from 'jg-fs';
17
17
  import { readTextFile } from 'jg-fs/sync';
18
18
  import { z } from 'zod';
19
19
 
@@ -21,7 +21,9 @@ const Config = z.object({ port: z.number() });
21
21
 
22
22
  await writeTextFile('notes/today.md', '# Today'); // creates notes/ when it's missing
23
23
  const config = await readJsonFile('config.json', { schema: Config }); // validated, and typed by the schema
24
- await writeFileAtomic('state.json', JSON.stringify(state)); // readers never see a partial file
24
+ await writeJsonFile('config.json', { ...config, port: 3000 }); // atomic: readers never see a partial file
25
+ await writeFileAtomic('state.txt', 'step 2'); // any content, atomically
26
+ const upload = await readJsonFile('uploads/import.json', { maxBytes: 1_048_576 }); // FILE_TOO_LARGE past 1 MB
25
27
 
26
28
  const readme = readTextFile('README.md');
27
29
  ```
@@ -54,6 +56,8 @@ Failures throw `Exception`s from `jg-exception`, with the codes in `ERRORS`.
54
56
 
55
57
  The full API reference ships in the package as `API.md`, generated from its TSDoc for the installed version, and is at https://jg-packages.jesusgraterol.dev/packages/jg-fs/.
56
58
 
59
+ Recipes for common jobs ship as `RECIPES.md`, with every block typechecked against the release, and are at https://jg-packages.jesusgraterol.dev/packages/jg-fs/recipes/.
60
+
57
61
  ## License
58
62
 
59
63
  [MIT](https://github.com/jesusgraterol/jg-packages/blob/main/packages/jg-fs/LICENSE)
package/RECIPES.md ADDED
@@ -0,0 +1,125 @@
1
+ # jg-fs recipes
2
+
3
+ Patterns for Node.js apps that keep their files whole through crashes, and read files from users within a size limit.
4
+
5
+ ## A config file that's never half-written
6
+
7
+ Use this for a file the app reads at startup and rewrites while it runs, such as settings changed from an admin page. A plain write that's cut short, by a crash or a full disk, leaves a truncated file that won't parse at the next start. `writeJsonFile` writes a temporary file next to it, flushes it to disk, and renames it over the old one, so the file holds either the old config or the new one:
8
+
9
+ ```ts
10
+ import { hasErrorCode } from 'jg-exception';
11
+ import { ERRORS, readJsonFile, writeJsonFile } from 'jg-fs';
12
+
13
+ // the config's type, from your schema
14
+ type IConfig = ReturnType<typeof Config.parse>;
15
+
16
+ const CONFIG_PATH = 'data/config.json';
17
+
18
+ // what the app runs with until someone saves a change
19
+ const DEFAULT_CONFIG: IConfig = { port: 8080 };
20
+
21
+ export const loadConfig = async (): Promise<IConfig> => {
22
+ try {
23
+ // Config is your schema, such as a Zod object, so a file edited by hand into a wrong shape
24
+ // fails here, at startup, instead of somewhere later
25
+ return await readJsonFile(CONFIG_PATH, { schema: Config });
26
+ } catch (error) {
27
+ // nothing was saved yet
28
+ if (hasErrorCode(error, ERRORS.NOT_A_FILE)) {
29
+ return DEFAULT_CONFIG;
30
+ }
31
+ throw error;
32
+ }
33
+ };
34
+
35
+ // the update in progress, which the next one waits for
36
+ let pendingUpdate: Promise<unknown> = Promise.resolve();
37
+
38
+ // reads, changes, and saves the config one update at a time, so two updates never start from
39
+ // the same version and lose one of the changes
40
+ export const updateConfig = (change: (config: IConfig) => IConfig): Promise<IConfig> => {
41
+ const update = pendingUpdate.then(async () => {
42
+ const config = change(await loadConfig());
43
+ await writeJsonFile(CONFIG_PATH, config);
44
+ return config;
45
+ });
46
+
47
+ // a failed update rejects for its caller, and the next one still runs
48
+ pendingUpdate = update.catch(() => undefined);
49
+ return update;
50
+ };
51
+
52
+ await updateConfig((config) => ({ ...config, port: 3000 }));
53
+ ```
54
+
55
+ `writeFileAtomic` writes any other content the same way, and both try again for a few seconds while Windows refuses the rename because a scanner holds the file. A symbolic link at the path keeps pointing at the config. Any error other than a missing file propagates, such as `FILE_CONTENT_IS_EMPTY_OR_INVALID` for a file that isn't JSON, or the schema's own error, so the app never starts from a config it can't trust. The atomic write keeps each save whole, but doesn't order saves: the queue does that inside one process, and when several processes write the file, give it one owner. A crash during a save can leave a temporary `.<random>.tmp` file next to the config, which is safe to delete.
56
+
57
+ `jg-fs/sync` has the same functions without promises, for code that can't await, such as an `exit` handler or a short CLI command. An explicit mode keeps a secret private from the first byte written:
58
+
59
+ ```ts
60
+ import { homedir } from 'node:os';
61
+ import { join } from 'node:path';
62
+ import { writeFileAtomic } from 'jg-fs/sync';
63
+
64
+ // the token your CLI's login command received
65
+ declare const token: string;
66
+
67
+ // kept outside any project, so it's never committed with one
68
+ const credentialsPath = join(homedir(), '.my-cli', 'credentials.json');
69
+
70
+ // on POSIX, only the owner can read it, and the temporary file is never more widely readable
71
+ writeFileAtomic(credentialsPath, JSON.stringify({ token }), 0o600);
72
+ ```
73
+
74
+ Without a mode, a replaced file keeps its permission bits, so a config someone restricted with `chmod` stays restricted after the app saves it.
75
+
76
+ ## Reading uploads with a size limit
77
+
78
+ Use this when a request names a file that someone uploaded earlier, such as an import or an attachment, and the app reads it into memory. The name comes from a client, and the file can be larger than you expect, so the read checks three things: the path stays inside the uploads directory, the file is a regular file and not a symbolic link, and its size is within a limit:
79
+
80
+ ```ts
81
+ import { join, resolve } from 'node:path';
82
+ import { Exception, hasErrorCode } from 'jg-exception';
83
+ import { ERRORS, isPathWithin, readBoundedFile } from 'jg-fs';
84
+
85
+ // where your upload handler saves files, a directory that only the app writes to
86
+ const UPLOADS_DIRECTORY = resolve('uploads');
87
+
88
+ // the largest upload the app loads into memory
89
+ const MAX_UPLOAD_BYTES = 5 * 1024 * 1024;
90
+
91
+ // reads an upload by the name a request gave, such as 'report.csv'. The messages name the upload
92
+ // and not the server's path, and the cause keeps jg-fs's error for the logs
93
+ export const readUpload = async (name: string): Promise<Buffer> => {
94
+ // a name such as '../.env' leads outside the directory, which counts as not found
95
+ const path = join(UPLOADS_DIRECTORY, name);
96
+ if (!isPathWithin(UPLOADS_DIRECTORY, path)) {
97
+ throw new Exception('The upload was not found.', {
98
+ code: 'UPLOAD_NOT_FOUND',
99
+ data: { name },
100
+ });
101
+ }
102
+ try {
103
+ return await readBoundedFile(path, MAX_UPLOAD_BYTES);
104
+ } catch (error) {
105
+ // a missing file, a directory, or a symbolic link someone planted
106
+ if (hasErrorCode(error, ERRORS.NOT_A_FILE)) {
107
+ throw new Exception('The upload was not found.', {
108
+ code: 'UPLOAD_NOT_FOUND',
109
+ data: { name },
110
+ cause: error,
111
+ });
112
+ }
113
+ if (hasErrorCode(error, ERRORS.FILE_TOO_LARGE)) {
114
+ throw new Exception('The upload is too large to read.', {
115
+ code: 'UPLOAD_TOO_LARGE',
116
+ data: { name, maxBytes: MAX_UPLOAD_BYTES },
117
+ cause: error,
118
+ });
119
+ }
120
+ throw error;
121
+ }
122
+ };
123
+ ```
124
+
125
+ `readBoundedFile` checks the size before it reads, and reads at most one byte past the limit, so a file that grows during the read can't make it load more. It refuses a symbolic link as the last part of the path, but follows links in the directories above it, which is why the uploads go in a directory that only the app writes to. A request handler can answer `UPLOAD_NOT_FOUND` with a 404, and `jg-fs/sync` has the same `readBoundedFile` for code that can't await.
@@ -38,7 +38,9 @@ export declare const writeFile: (path: string, data: string | NodeJS.ArrayBuffer
38
38
  */
39
39
  export declare const writeTextFile: (path: string, data: string) => Promise<void>;
40
40
  /**
41
- * Writes a JSON file. A string is written as given; anything else is stringified.
41
+ * Writes a JSON file atomically, as writeFileAtomic does, so a crash or a full disk never leaves a
42
+ * truncated file that won't parse. A symbolic link at the path keeps pointing at the file, whose
43
+ * target is replaced. A string is written as given; anything else is stringified.
42
44
  * @param path The file's path.
43
45
  * @param data The value, or its JSON text.
44
46
  * @param space The indentation for stringified values.
@@ -95,9 +97,11 @@ export declare function readFile(path: string, options?: IReadFileOptions): Prom
95
97
  export declare const readTextFile: (path: string) => Promise<string>;
96
98
  /**
97
99
  * Reads and parses a JSON file, and validates it with the schema when there is one. Without a
98
- * schema, the value is `unknown`, or the type you pass.
100
+ * schema, the value is `unknown`, or the type you pass. With `maxBytes`, it reads the file as
101
+ * readBoundedFile does, for a file whose size isn't yours to decide.
99
102
  * @param path The file's path.
100
- * @param options The schema, such as a Zod schema, whose errors propagate unchanged.
103
+ * @param options The schema, such as a Zod schema, whose errors propagate unchanged, and the
104
+ * largest file accepted, in bytes.
101
105
  * @returns A promise that resolves to the parsed value.
102
106
  * @example
103
107
  * import { readJsonFile } from 'jg-fs';
@@ -109,6 +113,7 @@ export declare const readTextFile: (path: string) => Promise<string>;
109
113
  * const value = await readJsonFile('data.json'); // unknown
110
114
  * @throws
111
115
  * - NOT_A_FILE: if the path doesn't exist or is not a file
116
+ * - FILE_TOO_LARGE: if the file holds more than maxBytes bytes
112
117
  * - FILE_CONTENT_IS_EMPTY_OR_INVALID: if the file's JSON content cannot be parsed
113
118
  */
114
119
  export declare const readJsonFile: <T = unknown>(path: string, options?: IReadJsonFileOptions<T>) => Promise<T>;
@@ -169,7 +174,9 @@ export declare const createFileSymLink: (target: string, path: string) => Promis
169
174
  * to disk and renamed over the path; on POSIX, the directory is flushed too. An explicit mode wins;
170
175
  * without one, a replaced file keeps its permission bits and a new file gets the platform default.
171
176
  * The temporary file is never more widely readable than the final mode. When writing fails before
172
- * the rename, the temporary file is removed and the destination is untouched.
177
+ * the rename, the temporary file is removed and the destination is untouched. While Windows refuses
178
+ * the rename because another process, such as an antivirus scanner, holds the file, it tries again
179
+ * for about 4 seconds. A symbolic link at the path is replaced by the file.
173
180
  * @param path The file's path.
174
181
  * @param data The content.
175
182
  * @param mode The permission bits of the written file, such as `0o600`.
@@ -1,11 +1,11 @@
1
1
  import { Buffer } from 'node:buffer';
2
2
  import { randomUUID } from 'node:crypto';
3
- import { copyFile as copyFileNode, mkdir, open, readFile as readFileNode, rename, rm, symlink, unlink, writeFile as writeFileNode, } from 'node:fs/promises';
3
+ import { copyFile as copyFileNode, mkdir, open, readFile as readFileNode, rm, symlink, unlink, writeFile as writeFileNode, } from 'node:fs/promises';
4
4
  import { dirname, join } from 'node:path';
5
5
  import { Exception } from 'jg-exception';
6
6
  import { assertBufferData, assertTextData, BOUNDED_READ_CHUNK_SIZE, ERRORS, fileTooLarge, notAFile, parseJsonContent, serializeJsonData, } from '../../shared/index.js';
7
7
  import { getPathElement } from '../general/index.js';
8
- import { openWithoutFollowing, readPermissionBits, syncDirectory } from './utilities.js';
8
+ import { openWithoutFollowing, readPermissionBits, renameReplacing, resolveWriteTarget, syncDirectory, } from './utilities.js';
9
9
  /**
10
10
  * Checks whether a path exists and is a regular file.
11
11
  * @param path The path.
@@ -50,7 +50,9 @@ export const writeTextFile = async (path, data) => {
50
50
  await writeFile(path, data, { encoding: 'utf-8' });
51
51
  };
52
52
  /**
53
- * Writes a JSON file. A string is written as given; anything else is stringified.
53
+ * Writes a JSON file atomically, as writeFileAtomic does, so a crash or a full disk never leaves a
54
+ * truncated file that won't parse. A symbolic link at the path keeps pointing at the file, whose
55
+ * target is replaced. A string is written as given; anything else is stringified.
54
56
  * @param path The file's path.
55
57
  * @param data The value, or its JSON text.
56
58
  * @param space The indentation for stringified values.
@@ -63,7 +65,8 @@ export const writeTextFile = async (path, data) => {
63
65
  * - FILE_CONTENT_IS_EMPTY_OR_INVALID: if the JSON content cannot be stringified or is empty
64
66
  */
65
67
  export const writeJsonFile = async (path, data, space = 2) => {
66
- await writeFile(path, serializeJsonData(path, data, space), { encoding: 'utf-8' });
68
+ const content = serializeJsonData(path, data, space);
69
+ await writeFileAtomic(await resolveWriteTarget(path), content);
67
70
  };
68
71
  /**
69
72
  * Writes a binary file. An empty Buffer writes an empty file.
@@ -102,9 +105,11 @@ export async function readFile(path, options = null) {
102
105
  export const readTextFile = (path) => readFile(path, { encoding: 'utf-8' });
103
106
  /**
104
107
  * Reads and parses a JSON file, and validates it with the schema when there is one. Without a
105
- * schema, the value is `unknown`, or the type you pass.
108
+ * schema, the value is `unknown`, or the type you pass. With `maxBytes`, it reads the file as
109
+ * readBoundedFile does, for a file whose size isn't yours to decide.
106
110
  * @param path The file's path.
107
- * @param options The schema, such as a Zod schema, whose errors propagate unchanged.
111
+ * @param options The schema, such as a Zod schema, whose errors propagate unchanged, and the
112
+ * largest file accepted, in bytes.
108
113
  * @returns A promise that resolves to the parsed value.
109
114
  * @example
110
115
  * import { readJsonFile } from 'jg-fs';
@@ -116,9 +121,12 @@ export const readTextFile = (path) => readFile(path, { encoding: 'utf-8' });
116
121
  * const value = await readJsonFile('data.json'); // unknown
117
122
  * @throws
118
123
  * - NOT_A_FILE: if the path doesn't exist or is not a file
124
+ * - FILE_TOO_LARGE: if the file holds more than maxBytes bytes
119
125
  * - FILE_CONTENT_IS_EMPTY_OR_INVALID: if the file's JSON content cannot be parsed
120
126
  */
121
- export const readJsonFile = async (path, options = {}) => parseJsonContent(path, await readTextFile(path), options.schema);
127
+ export const readJsonFile = async (path, options = {}) => parseJsonContent(path, options.maxBytes === undefined
128
+ ? await readTextFile(path)
129
+ : (await readBoundedFile(path, options.maxBytes)).toString('utf-8'), options.schema);
122
130
  /**
123
131
  * Reads a binary file. An empty file reads as an empty Buffer.
124
132
  * @param path The file's path.
@@ -197,7 +205,9 @@ export const createFileSymLink = async (target, path) => {
197
205
  * to disk and renamed over the path; on POSIX, the directory is flushed too. An explicit mode wins;
198
206
  * without one, a replaced file keeps its permission bits and a new file gets the platform default.
199
207
  * The temporary file is never more widely readable than the final mode. When writing fails before
200
- * the rename, the temporary file is removed and the destination is untouched.
208
+ * the rename, the temporary file is removed and the destination is untouched. While Windows refuses
209
+ * the rename because another process, such as an antivirus scanner, holds the file, it tries again
210
+ * for about 4 seconds. A symbolic link at the path is replaced by the file.
201
211
  * @param path The file's path.
202
212
  * @param data The content.
203
213
  * @param mode The permission bits of the written file, such as `0o600`.
@@ -228,7 +238,7 @@ export const writeFileAtomic = async (path, data, mode) => {
228
238
  finally {
229
239
  await handle.close();
230
240
  }
231
- await rename(tempPath, path);
241
+ await renameReplacing(tempPath, path);
232
242
  isRenamed = true;
233
243
  }
234
244
  finally {
@@ -1 +1 @@
1
- {"version":3,"file":"files.js","sourceRoot":"","sources":["../../../src/async/files/files.ts"],"names":[],"mappings":"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","sourcesContent":["import { Buffer } from 'node:buffer';\nimport { randomUUID } from 'node:crypto';\nimport type { WriteFileOptions } from 'node:fs';\nimport {\n copyFile as copyFileNode,\n mkdir,\n open,\n readFile as readFileNode,\n rename,\n rm,\n symlink,\n unlink,\n writeFile as writeFileNode,\n} from 'node:fs/promises';\nimport { dirname, join } from 'node:path';\nimport { Exception } from 'jg-exception';\n\nimport {\n assertBufferData,\n assertTextData,\n BOUNDED_READ_CHUNK_SIZE,\n ERRORS,\n fileTooLarge,\n notAFile,\n parseJsonContent,\n serializeJsonData,\n type IReadBufferFileOptions,\n type IReadFileOptions,\n type IReadJsonFileOptions,\n type IReadStringFileOptions,\n} from '../../shared/index.js';\nimport { getPathElement } from '../general/index.js';\n\nimport { openWithoutFollowing, readPermissionBits, syncDirectory } from './utilities.js';\n\n/**\n * Checks whether a path exists and is a regular file.\n * @param path The path.\n * @returns A promise that resolves to whether the path is a file.\n * @example\n * import { isFile } from 'jg-fs';\n *\n * await isFile('package.json'); // true\n */\nexport const isFile = async (path: string): Promise<boolean> =>\n (await getPathElement(path))?.isFile ?? false;\n\n/**\n * Writes a file, creating its directory first when it doesn't exist.\n * @param path The file's path.\n * @param data The content.\n * @param options Node.js's writeFile options, such as the encoding or mode.\n * @returns A promise that resolves once the file is written.\n * @example\n * import { writeFile } from 'jg-fs';\n *\n * const csv = 'id,name\\n1,Ada\\n';\n * await writeFile('out/report.csv', csv, { encoding: 'utf-8' });\n */\nexport const writeFile = async (\n path: string,\n data: string | NodeJS.ArrayBufferView,\n options?: WriteFileOptions,\n): Promise<void> => {\n // a recursive mkdir succeeds when a concurrent call already created the directory\n await mkdir(dirname(path), { recursive: true });\n await writeFileNode(path, data, options);\n};\n\n/**\n * Writes a UTF-8 text file. An empty string writes an empty file.\n * @param path The file's path.\n * @param data The text.\n * @returns A promise that resolves once the file is written.\n * @example\n * import { writeTextFile } from 'jg-fs';\n *\n * await writeTextFile('notes/today.md', '# Today');\n * @throws\n * - FILE_CONTENT_IS_EMPTY_OR_INVALID: if the data isn't a string\n */\nexport const writeTextFile = async (path: string, data: string): Promise<void> => {\n assertTextData(path, data);\n await writeFile(path, data, { encoding: 'utf-8' });\n};\n\n/**\n * Writes a JSON file. A string is written as given; anything else is stringified.\n * @param path The file's path.\n * @param data The value, or its JSON text.\n * @param space The indentation for stringified values.\n * @returns A promise that resolves once the file is written.\n * @example\n * import { writeJsonFile } from 'jg-fs';\n *\n * await writeJsonFile('config.json', { port: 8080 });\n * @throws\n * - FILE_CONTENT_IS_EMPTY_OR_INVALID: if the JSON content cannot be stringified or is empty\n */\nexport const writeJsonFile = async <T = Record<string, unknown>>(\n path: string,\n data: T | string,\n space: number = 2,\n): Promise<void> => {\n await writeFile(path, serializeJsonData(path, data, space), { encoding: 'utf-8' });\n};\n\n/**\n * Writes a binary file. An empty Buffer writes an empty file.\n * @param path The file's path.\n * @param data The content.\n * @returns A promise that resolves once the file is written.\n * @example\n * import { writeBufferFile } from 'jg-fs';\n *\n * const imageBuffer = Buffer.from('iVBORw0KGgo=', 'base64');\n * await writeBufferFile('avatar.png', imageBuffer);\n * @throws\n * - FILE_CONTENT_IS_EMPTY_OR_INVALID: if the data isn't a Buffer\n */\nexport const writeBufferFile = async (path: string, data: Buffer): Promise<void> => {\n assertBufferData(path, data);\n await writeFile(path, data);\n};\n\n/**\n * Reads a file as a string when an encoding is given, or as a Buffer otherwise.\n * @param path The file's path.\n * @param options Node.js's readFile options, such as the encoding.\n * @returns A promise that resolves to the content.\n * @example\n * import { readFile } from 'jg-fs';\n *\n * await readFile('notes.md', 'utf-8'); // a string\n * await readFile('avatar.png'); // a Buffer\n * @throws\n * - NOT_A_FILE: if the path doesn't exist or is not a file\n */\nexport async function readFile(path: string, options: IReadStringFileOptions): Promise<string>;\nexport async function readFile(path: string, options?: IReadBufferFileOptions): Promise<Buffer>;\nexport async function readFile(path: string, options?: IReadFileOptions): Promise<string | Buffer>;\nexport async function readFile(\n path: string,\n options: IReadFileOptions = null,\n): Promise<string | Buffer> {\n if (!(await isFile(path))) {\n throw notAFile(path);\n }\n return readFileNode(path, options);\n}\n\n/**\n * Reads a UTF-8 text file. An empty file reads as an empty string.\n * @param path The file's path.\n * @returns A promise that resolves to the text.\n * @example\n * import { readTextFile } from 'jg-fs';\n *\n * const markdown = await readTextFile('README.md');\n * @throws\n * - NOT_A_FILE: if the path doesn't exist or is not a file\n */\nexport const readTextFile = (path: string): Promise<string> =>\n readFile(path, { encoding: 'utf-8' });\n\n/**\n * Reads and parses a JSON file, and validates it with the schema when there is one. Without a\n * schema, the value is `unknown`, or the type you pass.\n * @param path The file's path.\n * @param options The schema, such as a Zod schema, whose errors propagate unchanged.\n * @returns A promise that resolves to the parsed value.\n * @example\n * import { readJsonFile } from 'jg-fs';\n *\n * const config = await readJsonFile('config.json', { schema: Config }); // Config's type\n * @example\n * import { readJsonFile } from 'jg-fs';\n *\n * const value = await readJsonFile('data.json'); // unknown\n * @throws\n * - NOT_A_FILE: if the path doesn't exist or is not a file\n * - FILE_CONTENT_IS_EMPTY_OR_INVALID: if the file's JSON content cannot be parsed\n */\nexport const readJsonFile = async <T = unknown>(\n path: string,\n options: IReadJsonFileOptions<T> = {},\n): Promise<T> => parseJsonContent(path, await readTextFile(path), options.schema);\n\n/**\n * Reads a binary file. An empty file reads as an empty Buffer.\n * @param path The file's path.\n * @returns A promise that resolves to the content.\n * @example\n * import { readBufferFile } from 'jg-fs';\n *\n * const image = await readBufferFile('avatar.png');\n * @throws\n * - NOT_A_FILE: if the path doesn't exist or is not a file\n */\nexport const readBufferFile = (path: string): Promise<Buffer> => readFile(path, null);\n\n/**\n * Copies a file, replacing the destination when it exists.\n * @param srcPath The file to copy.\n * @param destPath Where to copy it.\n * @returns A promise that resolves once the copy exists.\n * @example\n * import { copyFile } from 'jg-fs';\n *\n * await copyFile('config.example.json', 'config.json');\n * @throws\n * - NOT_A_FILE: if the srcPath doesn't exist or is not a file\n */\nexport const copyFile = async (srcPath: string, destPath: string): Promise<void> => {\n if (!(await isFile(srcPath))) {\n throw notAFile(srcPath);\n }\n await copyFileNode(srcPath, destPath);\n};\n\n/**\n * Deletes a file.\n * @param path The file's path.\n * @returns A promise that resolves once the file is gone.\n * @example\n * import { deleteFile } from 'jg-fs';\n *\n * await deleteFile('cache.json');\n * @throws\n * - NOT_A_FILE: if the path doesn't exist or is not a file\n */\nexport const deleteFile = async (path: string): Promise<void> => {\n if (!(await isFile(path))) {\n throw notAFile(path);\n }\n await unlink(path);\n};\n\n/**\n * Creates a symbolic link to a file.\n * @param target The file the link points to.\n * @param path Where to create the link.\n * @returns A promise that resolves once the link exists.\n * @example\n * import { createFileSymLink } from 'jg-fs';\n *\n * await createFileSymLink('config.production.json', 'config.json');\n * @throws\n * - NON_EXISTENT_FILE: if the target file does not exist\n * - NOT_A_FILE: if the target is not a file\n */\nexport const createFileSymLink = async (target: string, path: string): Promise<void> => {\n const element = await getPathElement(target);\n if (element === null) {\n throw new Exception(`The target file '${target}' does not exist.`, {\n code: ERRORS.NON_EXISTENT_FILE,\n });\n }\n if (!element.isFile) {\n throw notAFile(target);\n }\n await symlink(target, path, 'file');\n};\n\n/**\n * Writes a file atomically: readers see either the previous content or the complete new content,\n * never a partial write. The data goes to a temporary file in the same directory, which is flushed\n * to disk and renamed over the path; on POSIX, the directory is flushed too. An explicit mode wins;\n * without one, a replaced file keeps its permission bits and a new file gets the platform default.\n * The temporary file is never more widely readable than the final mode. When writing fails before\n * the rename, the temporary file is removed and the destination is untouched.\n * @param path The file's path.\n * @param data The content.\n * @param mode The permission bits of the written file, such as `0o600`.\n * @returns A promise that resolves once the file is written and flushed.\n * @example\n * import { writeFileAtomic } from 'jg-fs';\n *\n * const state = { step: 2 };\n * await writeFileAtomic('state.json', JSON.stringify(state)); // readers never see a partial file\n * await writeFileAtomic('secrets.env', 'TOKEN=abc\\n', 0o600); // only the owner can read it\n */\nexport const writeFileAtomic = async (\n path: string,\n data: string | NodeJS.ArrayBufferView,\n mode?: number,\n): Promise<void> => {\n const directory = dirname(path);\n await mkdir(directory, { recursive: true });\n const finalMode = mode ?? (await readPermissionBits(path));\n const tempPath = join(directory, `.${randomUUID()}.tmp`);\n let isRenamed = false;\n try {\n // a known final mode is applied with chmod, so the umask can't change it\n const handle = await open(tempPath, 'wx', finalMode === undefined ? 0o666 : 0o600);\n try {\n if (finalMode !== undefined) {\n await handle.chmod(finalMode);\n }\n await handle.writeFile(data);\n await handle.sync();\n } finally {\n await handle.close();\n }\n await rename(tempPath, path);\n isRenamed = true;\n } finally {\n if (!isRenamed) {\n await rm(tempPath, { force: true });\n }\n }\n if (process.platform !== 'win32') {\n await syncDirectory(directory);\n }\n};\n\n/**\n * Reads a regular file of at most `maxBytes` bytes. It never follows a symbolic link in the\n * path's last component, though links in parent directories are followed, so callers that need\n * containment combine it with `isPathWithin`. It reads at most `maxBytes + 1` bytes, even when the\n * file grows while it's read.\n * @param path The file's path.\n * @param maxBytes The largest size accepted, in bytes.\n * @returns A promise that resolves to the content.\n * @example\n * import { join } from 'node:path';\n * import { readBoundedFile } from 'jg-fs';\n *\n * const upload = await readBoundedFile(join('uploads', 'avatar.png'), 5 * 1024 * 1024); // at most 5 MB\n * @throws\n * - NOT_A_FILE: if the path doesn't exist, its final component is a symbolic link, or it isn't a regular file\n * - FILE_TOO_LARGE: if the file holds more than maxBytes bytes\n */\nexport const readBoundedFile = async (path: string, maxBytes: number): Promise<Buffer> => {\n const handle = await openWithoutFollowing(path);\n try {\n const stats = await handle.stat();\n if (!stats.isFile()) {\n throw notAFile(path);\n }\n if (stats.size > maxBytes) {\n throw fileTooLarge(path, maxBytes);\n }\n const chunks: Buffer[] = [];\n let totalBytes = 0;\n while (totalBytes <= maxBytes) {\n const chunk = Buffer.allocUnsafe(\n Math.min(BOUNDED_READ_CHUNK_SIZE, maxBytes + 1 - totalBytes),\n );\n const { bytesRead } = await handle.read(chunk, 0, chunk.length, totalBytes);\n if (bytesRead === 0) {\n break;\n }\n chunks.push(chunk.subarray(0, bytesRead));\n totalBytes += bytesRead;\n }\n if (totalBytes > maxBytes) {\n throw fileTooLarge(path, maxBytes);\n }\n return Buffer.concat(chunks, totalBytes);\n } finally {\n await handle.close();\n }\n};\n"]}
1
+ {"version":3,"file":"files.js","sourceRoot":"","sources":["../../../src/async/files/files.ts"],"names":[],"mappings":"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","sourcesContent":["import { Buffer } from 'node:buffer';\nimport { randomUUID } from 'node:crypto';\nimport type { WriteFileOptions } from 'node:fs';\nimport {\n copyFile as copyFileNode,\n mkdir,\n open,\n readFile as readFileNode,\n rm,\n symlink,\n unlink,\n writeFile as writeFileNode,\n} from 'node:fs/promises';\nimport { dirname, join } from 'node:path';\nimport { Exception } from 'jg-exception';\n\nimport {\n assertBufferData,\n assertTextData,\n BOUNDED_READ_CHUNK_SIZE,\n ERRORS,\n fileTooLarge,\n notAFile,\n parseJsonContent,\n serializeJsonData,\n type IReadBufferFileOptions,\n type IReadFileOptions,\n type IReadJsonFileOptions,\n type IReadStringFileOptions,\n} from '../../shared/index.js';\nimport { getPathElement } from '../general/index.js';\n\nimport {\n openWithoutFollowing,\n readPermissionBits,\n renameReplacing,\n resolveWriteTarget,\n syncDirectory,\n} from './utilities.js';\n\n/**\n * Checks whether a path exists and is a regular file.\n * @param path The path.\n * @returns A promise that resolves to whether the path is a file.\n * @example\n * import { isFile } from 'jg-fs';\n *\n * await isFile('package.json'); // true\n */\nexport const isFile = async (path: string): Promise<boolean> =>\n (await getPathElement(path))?.isFile ?? false;\n\n/**\n * Writes a file, creating its directory first when it doesn't exist.\n * @param path The file's path.\n * @param data The content.\n * @param options Node.js's writeFile options, such as the encoding or mode.\n * @returns A promise that resolves once the file is written.\n * @example\n * import { writeFile } from 'jg-fs';\n *\n * const csv = 'id,name\\n1,Ada\\n';\n * await writeFile('out/report.csv', csv, { encoding: 'utf-8' });\n */\nexport const writeFile = async (\n path: string,\n data: string | NodeJS.ArrayBufferView,\n options?: WriteFileOptions,\n): Promise<void> => {\n // a recursive mkdir succeeds when a concurrent call already created the directory\n await mkdir(dirname(path), { recursive: true });\n await writeFileNode(path, data, options);\n};\n\n/**\n * Writes a UTF-8 text file. An empty string writes an empty file.\n * @param path The file's path.\n * @param data The text.\n * @returns A promise that resolves once the file is written.\n * @example\n * import { writeTextFile } from 'jg-fs';\n *\n * await writeTextFile('notes/today.md', '# Today');\n * @throws\n * - FILE_CONTENT_IS_EMPTY_OR_INVALID: if the data isn't a string\n */\nexport const writeTextFile = async (path: string, data: string): Promise<void> => {\n assertTextData(path, data);\n await writeFile(path, data, { encoding: 'utf-8' });\n};\n\n/**\n * Writes a JSON file atomically, as writeFileAtomic does, so a crash or a full disk never leaves a\n * truncated file that won't parse. A symbolic link at the path keeps pointing at the file, whose\n * target is replaced. A string is written as given; anything else is stringified.\n * @param path The file's path.\n * @param data The value, or its JSON text.\n * @param space The indentation for stringified values.\n * @returns A promise that resolves once the file is written.\n * @example\n * import { writeJsonFile } from 'jg-fs';\n *\n * await writeJsonFile('config.json', { port: 8080 });\n * @throws\n * - FILE_CONTENT_IS_EMPTY_OR_INVALID: if the JSON content cannot be stringified or is empty\n */\nexport const writeJsonFile = async <T = Record<string, unknown>>(\n path: string,\n data: T | string,\n space: number = 2,\n): Promise<void> => {\n const content = serializeJsonData(path, data, space);\n await writeFileAtomic(await resolveWriteTarget(path), content);\n};\n\n/**\n * Writes a binary file. An empty Buffer writes an empty file.\n * @param path The file's path.\n * @param data The content.\n * @returns A promise that resolves once the file is written.\n * @example\n * import { writeBufferFile } from 'jg-fs';\n *\n * const imageBuffer = Buffer.from('iVBORw0KGgo=', 'base64');\n * await writeBufferFile('avatar.png', imageBuffer);\n * @throws\n * - FILE_CONTENT_IS_EMPTY_OR_INVALID: if the data isn't a Buffer\n */\nexport const writeBufferFile = async (path: string, data: Buffer): Promise<void> => {\n assertBufferData(path, data);\n await writeFile(path, data);\n};\n\n/**\n * Reads a file as a string when an encoding is given, or as a Buffer otherwise.\n * @param path The file's path.\n * @param options Node.js's readFile options, such as the encoding.\n * @returns A promise that resolves to the content.\n * @example\n * import { readFile } from 'jg-fs';\n *\n * await readFile('notes.md', 'utf-8'); // a string\n * await readFile('avatar.png'); // a Buffer\n * @throws\n * - NOT_A_FILE: if the path doesn't exist or is not a file\n */\nexport async function readFile(path: string, options: IReadStringFileOptions): Promise<string>;\nexport async function readFile(path: string, options?: IReadBufferFileOptions): Promise<Buffer>;\nexport async function readFile(path: string, options?: IReadFileOptions): Promise<string | Buffer>;\nexport async function readFile(\n path: string,\n options: IReadFileOptions = null,\n): Promise<string | Buffer> {\n if (!(await isFile(path))) {\n throw notAFile(path);\n }\n return readFileNode(path, options);\n}\n\n/**\n * Reads a UTF-8 text file. An empty file reads as an empty string.\n * @param path The file's path.\n * @returns A promise that resolves to the text.\n * @example\n * import { readTextFile } from 'jg-fs';\n *\n * const markdown = await readTextFile('README.md');\n * @throws\n * - NOT_A_FILE: if the path doesn't exist or is not a file\n */\nexport const readTextFile = (path: string): Promise<string> =>\n readFile(path, { encoding: 'utf-8' });\n\n/**\n * Reads and parses a JSON file, and validates it with the schema when there is one. Without a\n * schema, the value is `unknown`, or the type you pass. With `maxBytes`, it reads the file as\n * readBoundedFile does, for a file whose size isn't yours to decide.\n * @param path The file's path.\n * @param options The schema, such as a Zod schema, whose errors propagate unchanged, and the\n * largest file accepted, in bytes.\n * @returns A promise that resolves to the parsed value.\n * @example\n * import { readJsonFile } from 'jg-fs';\n *\n * const config = await readJsonFile('config.json', { schema: Config }); // Config's type\n * @example\n * import { readJsonFile } from 'jg-fs';\n *\n * const value = await readJsonFile('data.json'); // unknown\n * @throws\n * - NOT_A_FILE: if the path doesn't exist or is not a file\n * - FILE_TOO_LARGE: if the file holds more than maxBytes bytes\n * - FILE_CONTENT_IS_EMPTY_OR_INVALID: if the file's JSON content cannot be parsed\n */\nexport const readJsonFile = async <T = unknown>(\n path: string,\n options: IReadJsonFileOptions<T> = {},\n): Promise<T> =>\n parseJsonContent(\n path,\n options.maxBytes === undefined\n ? await readTextFile(path)\n : (await readBoundedFile(path, options.maxBytes)).toString('utf-8'),\n options.schema,\n );\n\n/**\n * Reads a binary file. An empty file reads as an empty Buffer.\n * @param path The file's path.\n * @returns A promise that resolves to the content.\n * @example\n * import { readBufferFile } from 'jg-fs';\n *\n * const image = await readBufferFile('avatar.png');\n * @throws\n * - NOT_A_FILE: if the path doesn't exist or is not a file\n */\nexport const readBufferFile = (path: string): Promise<Buffer> => readFile(path, null);\n\n/**\n * Copies a file, replacing the destination when it exists.\n * @param srcPath The file to copy.\n * @param destPath Where to copy it.\n * @returns A promise that resolves once the copy exists.\n * @example\n * import { copyFile } from 'jg-fs';\n *\n * await copyFile('config.example.json', 'config.json');\n * @throws\n * - NOT_A_FILE: if the srcPath doesn't exist or is not a file\n */\nexport const copyFile = async (srcPath: string, destPath: string): Promise<void> => {\n if (!(await isFile(srcPath))) {\n throw notAFile(srcPath);\n }\n await copyFileNode(srcPath, destPath);\n};\n\n/**\n * Deletes a file.\n * @param path The file's path.\n * @returns A promise that resolves once the file is gone.\n * @example\n * import { deleteFile } from 'jg-fs';\n *\n * await deleteFile('cache.json');\n * @throws\n * - NOT_A_FILE: if the path doesn't exist or is not a file\n */\nexport const deleteFile = async (path: string): Promise<void> => {\n if (!(await isFile(path))) {\n throw notAFile(path);\n }\n await unlink(path);\n};\n\n/**\n * Creates a symbolic link to a file.\n * @param target The file the link points to.\n * @param path Where to create the link.\n * @returns A promise that resolves once the link exists.\n * @example\n * import { createFileSymLink } from 'jg-fs';\n *\n * await createFileSymLink('config.production.json', 'config.json');\n * @throws\n * - NON_EXISTENT_FILE: if the target file does not exist\n * - NOT_A_FILE: if the target is not a file\n */\nexport const createFileSymLink = async (target: string, path: string): Promise<void> => {\n const element = await getPathElement(target);\n if (element === null) {\n throw new Exception(`The target file '${target}' does not exist.`, {\n code: ERRORS.NON_EXISTENT_FILE,\n });\n }\n if (!element.isFile) {\n throw notAFile(target);\n }\n await symlink(target, path, 'file');\n};\n\n/**\n * Writes a file atomically: readers see either the previous content or the complete new content,\n * never a partial write. The data goes to a temporary file in the same directory, which is flushed\n * to disk and renamed over the path; on POSIX, the directory is flushed too. An explicit mode wins;\n * without one, a replaced file keeps its permission bits and a new file gets the platform default.\n * The temporary file is never more widely readable than the final mode. When writing fails before\n * the rename, the temporary file is removed and the destination is untouched. While Windows refuses\n * the rename because another process, such as an antivirus scanner, holds the file, it tries again\n * for about 4 seconds. A symbolic link at the path is replaced by the file.\n * @param path The file's path.\n * @param data The content.\n * @param mode The permission bits of the written file, such as `0o600`.\n * @returns A promise that resolves once the file is written and flushed.\n * @example\n * import { writeFileAtomic } from 'jg-fs';\n *\n * const state = { step: 2 };\n * await writeFileAtomic('state.json', JSON.stringify(state)); // readers never see a partial file\n * await writeFileAtomic('secrets.env', 'TOKEN=abc\\n', 0o600); // only the owner can read it\n */\nexport const writeFileAtomic = async (\n path: string,\n data: string | NodeJS.ArrayBufferView,\n mode?: number,\n): Promise<void> => {\n const directory = dirname(path);\n await mkdir(directory, { recursive: true });\n const finalMode = mode ?? (await readPermissionBits(path));\n const tempPath = join(directory, `.${randomUUID()}.tmp`);\n let isRenamed = false;\n try {\n // a known final mode is applied with chmod, so the umask can't change it\n const handle = await open(tempPath, 'wx', finalMode === undefined ? 0o666 : 0o600);\n try {\n if (finalMode !== undefined) {\n await handle.chmod(finalMode);\n }\n await handle.writeFile(data);\n await handle.sync();\n } finally {\n await handle.close();\n }\n await renameReplacing(tempPath, path);\n isRenamed = true;\n } finally {\n if (!isRenamed) {\n await rm(tempPath, { force: true });\n }\n }\n if (process.platform !== 'win32') {\n await syncDirectory(directory);\n }\n};\n\n/**\n * Reads a regular file of at most `maxBytes` bytes. It never follows a symbolic link in the\n * path's last component, though links in parent directories are followed, so callers that need\n * containment combine it with `isPathWithin`. It reads at most `maxBytes + 1` bytes, even when the\n * file grows while it's read.\n * @param path The file's path.\n * @param maxBytes The largest size accepted, in bytes.\n * @returns A promise that resolves to the content.\n * @example\n * import { join } from 'node:path';\n * import { readBoundedFile } from 'jg-fs';\n *\n * const upload = await readBoundedFile(join('uploads', 'avatar.png'), 5 * 1024 * 1024); // at most 5 MB\n * @throws\n * - NOT_A_FILE: if the path doesn't exist, its final component is a symbolic link, or it isn't a regular file\n * - FILE_TOO_LARGE: if the file holds more than maxBytes bytes\n */\nexport const readBoundedFile = async (path: string, maxBytes: number): Promise<Buffer> => {\n const handle = await openWithoutFollowing(path);\n try {\n const stats = await handle.stat();\n if (!stats.isFile()) {\n throw notAFile(path);\n }\n if (stats.size > maxBytes) {\n throw fileTooLarge(path, maxBytes);\n }\n const chunks: Buffer[] = [];\n let totalBytes = 0;\n while (totalBytes <= maxBytes) {\n const chunk = Buffer.allocUnsafe(\n Math.min(BOUNDED_READ_CHUNK_SIZE, maxBytes + 1 - totalBytes),\n );\n const { bytesRead } = await handle.read(chunk, 0, chunk.length, totalBytes);\n if (bytesRead === 0) {\n break;\n }\n chunks.push(chunk.subarray(0, bytesRead));\n totalBytes += bytesRead;\n }\n if (totalBytes > maxBytes) {\n throw fileTooLarge(path, maxBytes);\n }\n return Buffer.concat(chunks, totalBytes);\n } finally {\n await handle.close();\n }\n};\n"]}
@@ -5,6 +5,21 @@ import { type FileHandle } from 'node:fs/promises';
5
5
  * @returns A promise that resolves to the bits, or `undefined` when the file doesn't exist.
6
6
  */
7
7
  export declare const readPermissionBits: (path: string) => Promise<number | undefined>;
8
+ /**
9
+ * Renames a file over another, trying again for about 4 seconds while Windows refuses because
10
+ * another process, such as an antivirus scanner, holds the destination.
11
+ * @param from The file to rename.
12
+ * @param to Its new path.
13
+ * @returns A promise that resolves once it's renamed.
14
+ */
15
+ export declare const renameReplacing: (from: string, to: string) => Promise<void>;
16
+ /**
17
+ * Resolves the file a write replaces: the target of a symbolic link at the path, so the link keeps
18
+ * pointing at the new content, or the path itself when nothing is there yet.
19
+ * @param path The path.
20
+ * @returns A promise that resolves to the path to write.
21
+ */
22
+ export declare const resolveWriteTarget: (path: string) => Promise<string>;
8
23
  /**
9
24
  * Flushes a directory's entries to disk, so a rename inside it survives a crash.
10
25
  * @param path The directory's path.
@@ -1,5 +1,5 @@
1
- import { constants, lstat, open, stat } from 'node:fs/promises';
2
- import { isNoFollowError, isNotFoundError, notARegularFile } from '../../shared/index.js';
1
+ import { constants, lstat, open, realpath, rename, stat } from 'node:fs/promises';
2
+ import { isBusyRenameError, isNoFollowError, isNotFoundError, notARegularFile, RENAME_RETRY_DELAYS_MS, } from '../../shared/index.js';
3
3
  /**
4
4
  * Reads the permission bits of an existing file.
5
5
  * @param path The file's path.
@@ -16,6 +16,47 @@ export const readPermissionBits = async (path) => {
16
16
  throw error;
17
17
  }
18
18
  };
19
+ /**
20
+ * Renames a file over another, trying again for about 4 seconds while Windows refuses because
21
+ * another process, such as an antivirus scanner, holds the destination.
22
+ * @param from The file to rename.
23
+ * @param to Its new path.
24
+ * @returns A promise that resolves once it's renamed.
25
+ */
26
+ export const renameReplacing = async (from, to) => {
27
+ for (let attempt = 0;; attempt += 1) {
28
+ try {
29
+ await rename(from, to);
30
+ return;
31
+ }
32
+ catch (error) {
33
+ const delay = RENAME_RETRY_DELAYS_MS[attempt];
34
+ if (delay === undefined || !isBusyRenameError(error)) {
35
+ throw error;
36
+ }
37
+ await new Promise((resolve) => {
38
+ setTimeout(resolve, delay);
39
+ });
40
+ }
41
+ }
42
+ };
43
+ /**
44
+ * Resolves the file a write replaces: the target of a symbolic link at the path, so the link keeps
45
+ * pointing at the new content, or the path itself when nothing is there yet.
46
+ * @param path The path.
47
+ * @returns A promise that resolves to the path to write.
48
+ */
49
+ export const resolveWriteTarget = async (path) => {
50
+ try {
51
+ return await realpath(path);
52
+ }
53
+ catch (error) {
54
+ if (isNotFoundError(error)) {
55
+ return path;
56
+ }
57
+ throw error;
58
+ }
59
+ };
19
60
  /**
20
61
  * Flushes a directory's entries to disk, so a rename inside it survives a crash.
21
62
  * @param path The directory's path.
@@ -1 +1 @@
1
- {"version":3,"file":"utilities.js","sourceRoot":"","sources":["../../../src/async/files/utilities.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,SAAS,EAAE,KAAK,EAAE,IAAI,EAAE,IAAI,EAAmB,MAAM,kBAAkB,CAAC;AAEjF,OAAO,EAAE,eAAe,EAAE,eAAe,EAAE,eAAe,EAAE,MAAM,uBAAuB,CAAC;AAE1F;;;;GAIG;AACH,MAAM,CAAC,MAAM,kBAAkB,GAAG,KAAK,EAAE,IAAY,EAA+B,EAAE;IACpF,IAAI,CAAC;QACH,OAAO,CAAC,MAAM,IAAI,CAAC,IAAI,CAAC,CAAC,CAAC,IAAI,GAAG,KAAK,CAAC;IACzC,CAAC;IAAC,OAAO,KAAK,EAAE,CAAC;QACf,IAAI,eAAe,CAAC,KAAK,CAAC,EAAE,CAAC;YAC3B,OAAO,SAAS,CAAC;QACnB,CAAC;QACD,MAAM,KAAK,CAAC;IACd,CAAC;AACH,CAAC,CAAC;AAEF;;;;GAIG;AACH,MAAM,CAAC,MAAM,aAAa,GAAG,KAAK,EAAE,IAAY,EAAiB,EAAE;IACjE,MAAM,MAAM,GAAG,MAAM,IAAI,CAAC,IAAI,EAAE,GAAG,CAAC,CAAC;IACrC,IAAI,CAAC;QACH,MAAM,MAAM,CAAC,IAAI,EAAE,CAAC;IACtB,CAAC;YAAS,CAAC;QACT,MAAM,MAAM,CAAC,KAAK,EAAE,CAAC;IACvB,CAAC;AACH,CAAC,CAAC;AAEF;;;;;;GAMG;AACH,MAAM,CAAC,MAAM,oBAAoB,GAAG,KAAK,EAAE,IAAY,EAAuB,EAAE;IAC9E,IAAI,CAAC;QACH,IAAI,OAAO,CAAC,QAAQ,KAAK,OAAO,EAAE,CAAC;YACjC,kFAAkF;YAClF,IAAI,CAAC,MAAM,KAAK,CAAC,IAAI,CAAC,CAAC,CAAC,cAAc,EAAE,EAAE,CAAC;gBACzC,MAAM,MAAM,CAAC,MAAM,CAAC,IAAI,KAAK,CAAC,8BAA8B,CAAC,EAAE,EAAE,IAAI,EAAE,OAAO,EAAE,CAAC,CAAC;YACpF,CAAC;YACD,OAAO,MAAM,IAAI,CAAC,IAAI,EAAE,SAAS,CAAC,QAAQ,CAAC,CAAC;QAC9C,CAAC;QACD,mEAAmE;QACnE,OAAO,MAAM,IAAI,CAAC,IAAI,EAAE,SAAS,CAAC,QAAQ,GAAG,SAAS,CAAC,UAAU,GAAG,SAAS,CAAC,UAAU,CAAC,CAAC;IAC5F,CAAC;IAAC,OAAO,KAAK,EAAE,CAAC;QACf,IAAI,eAAe,CAAC,KAAK,CAAC,EAAE,CAAC;YAC3B,MAAM,eAAe,CAAC,IAAI,EAAE,KAAK,CAAC,CAAC;QACrC,CAAC;QACD,MAAM,KAAK,CAAC;IACd,CAAC;AACH,CAAC,CAAC","sourcesContent":["import { constants, lstat, open, stat, type FileHandle } from 'node:fs/promises';\n\nimport { isNoFollowError, isNotFoundError, notARegularFile } from '../../shared/index.js';\n\n/**\n * Reads the permission bits of an existing file.\n * @param path The file's path.\n * @returns A promise that resolves to the bits, or `undefined` when the file doesn't exist.\n */\nexport const readPermissionBits = async (path: string): Promise<number | undefined> => {\n try {\n return (await stat(path)).mode & 0o777;\n } catch (error) {\n if (isNotFoundError(error)) {\n return undefined;\n }\n throw error;\n }\n};\n\n/**\n * Flushes a directory's entries to disk, so a rename inside it survives a crash.\n * @param path The directory's path.\n * @returns A promise that resolves once the directory is flushed.\n */\nexport const syncDirectory = async (path: string): Promise<void> => {\n const handle = await open(path, 'r');\n try {\n await handle.sync();\n } finally {\n await handle.close();\n }\n};\n\n/**\n * Opens a file for reading without following a symbolic link in its last component.\n * @param path The file's path.\n * @returns A promise that resolves to the open file.\n * @throws\n * - NOT_A_FILE: if the path doesn't exist or its final component is a symbolic link\n */\nexport const openWithoutFollowing = async (path: string): Promise<FileHandle> => {\n try {\n if (process.platform === 'win32') {\n // Windows has no O_NOFOLLOW, so a link swapped in after this check isn't detected\n if ((await lstat(path)).isSymbolicLink()) {\n throw Object.assign(new Error('The path is a symbolic link.'), { code: 'ELOOP' });\n }\n return await open(path, constants.O_RDONLY);\n }\n // O_NONBLOCK keeps a named pipe at the path from blocking the open\n return await open(path, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);\n } catch (error) {\n if (isNoFollowError(error)) {\n throw notARegularFile(path, error);\n }\n throw error;\n }\n};\n"]}
1
+ {"version":3,"file":"utilities.js","sourceRoot":"","sources":["../../../src/async/files/utilities.ts"],"names":[],"mappings":"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","sourcesContent":["import { constants, lstat, open, realpath, rename, stat, type FileHandle } from 'node:fs/promises';\n\nimport {\n isBusyRenameError,\n isNoFollowError,\n isNotFoundError,\n notARegularFile,\n RENAME_RETRY_DELAYS_MS,\n} from '../../shared/index.js';\n\n/**\n * Reads the permission bits of an existing file.\n * @param path The file's path.\n * @returns A promise that resolves to the bits, or `undefined` when the file doesn't exist.\n */\nexport const readPermissionBits = async (path: string): Promise<number | undefined> => {\n try {\n return (await stat(path)).mode & 0o777;\n } catch (error) {\n if (isNotFoundError(error)) {\n return undefined;\n }\n throw error;\n }\n};\n\n/**\n * Renames a file over another, trying again for about 4 seconds while Windows refuses because\n * another process, such as an antivirus scanner, holds the destination.\n * @param from The file to rename.\n * @param to Its new path.\n * @returns A promise that resolves once it's renamed.\n */\nexport const renameReplacing = async (from: string, to: string): Promise<void> => {\n for (let attempt = 0; ; attempt += 1) {\n try {\n await rename(from, to);\n return;\n } catch (error) {\n const delay = RENAME_RETRY_DELAYS_MS[attempt];\n if (delay === undefined || !isBusyRenameError(error)) {\n throw error;\n }\n await new Promise((resolve) => {\n setTimeout(resolve, delay);\n });\n }\n }\n};\n\n/**\n * Resolves the file a write replaces: the target of a symbolic link at the path, so the link keeps\n * pointing at the new content, or the path itself when nothing is there yet.\n * @param path The path.\n * @returns A promise that resolves to the path to write.\n */\nexport const resolveWriteTarget = async (path: string): Promise<string> => {\n try {\n return await realpath(path);\n } catch (error) {\n if (isNotFoundError(error)) {\n return path;\n }\n throw error;\n }\n};\n\n/**\n * Flushes a directory's entries to disk, so a rename inside it survives a crash.\n * @param path The directory's path.\n * @returns A promise that resolves once the directory is flushed.\n */\nexport const syncDirectory = async (path: string): Promise<void> => {\n const handle = await open(path, 'r');\n try {\n await handle.sync();\n } finally {\n await handle.close();\n }\n};\n\n/**\n * Opens a file for reading without following a symbolic link in its last component.\n * @param path The file's path.\n * @returns A promise that resolves to the open file.\n * @throws\n * - NOT_A_FILE: if the path doesn't exist or its final component is a symbolic link\n */\nexport const openWithoutFollowing = async (path: string): Promise<FileHandle> => {\n try {\n if (process.platform === 'win32') {\n // Windows has no O_NOFOLLOW, so a link swapped in after this check isn't detected\n if ((await lstat(path)).isSymbolicLink()) {\n throw Object.assign(new Error('The path is a symbolic link.'), { code: 'ELOOP' });\n }\n return await open(path, constants.O_RDONLY);\n }\n // O_NONBLOCK keeps a named pipe at the path from blocking the open\n return await open(path, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);\n } catch (error) {\n if (isNoFollowError(error)) {\n throw notARegularFile(path, error);\n }\n throw error;\n }\n};\n"]}
@@ -2,3 +2,4 @@ import type { IDirectoryElementsOptions } from './types.js';
2
2
  export declare const DIRECTORY_ELEMENTS_DEFAULT_OPTIONS: IDirectoryElementsOptions;
3
3
  export declare const DIRECTORY_ELEMENTS_CONCURRENCY = 32;
4
4
  export declare const BOUNDED_READ_CHUNK_SIZE = 65536;
5
+ export declare const RENAME_RETRY_DELAYS_MS: readonly number[];
@@ -8,4 +8,9 @@ export const DIRECTORY_ELEMENTS_DEFAULT_OPTIONS = {
8
8
  export const DIRECTORY_ELEMENTS_CONCURRENCY = 32;
9
9
  // the number of bytes readBoundedFile reads per call
10
10
  export const BOUNDED_READ_CHUNK_SIZE = 65536;
11
+ // the waits, in milliseconds, between the attempts of a rename Windows refuses while another
12
+ // process, such as an antivirus scanner or a search indexer, holds the file: about 4 seconds in all
13
+ export const RENAME_RETRY_DELAYS_MS = [
14
+ 10, 20, 40, 80, 160, 320, 640, 1000, 1000, 1000,
15
+ ];
11
16
  //# sourceMappingURL=constants.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"constants.js","sourceRoot":"","sources":["../../src/shared/constants.ts"],"names":[],"mappings":"AAEA,2EAA2E;AAC3E,MAAM,CAAC,MAAM,kCAAkC,GAA8B;IAC3E,SAAS,EAAE,UAAU;IACrB,SAAS,EAAE,KAAK;IAChB,WAAW,EAAE,EAAE;CAChB,CAAC;AAEF,mIAAmI;AACnI,MAAM,CAAC,MAAM,8BAA8B,GAAG,EAAE,CAAC;AAEjD,qDAAqD;AACrD,MAAM,CAAC,MAAM,uBAAuB,GAAG,KAAK,CAAC","sourcesContent":["import type { IDirectoryElementsOptions } from './types.js';\n\n// the options getDirectoryElements uses for anything the caller leaves out\nexport const DIRECTORY_ELEMENTS_DEFAULT_OPTIONS: IDirectoryElementsOptions = {\n sortByKey: 'baseName',\n sortOrder: 'asc',\n includeExts: [],\n};\n\n// how many entries the async getDirectoryElements reads at once, so large directories don't open a stat per entry at the same time\nexport const DIRECTORY_ELEMENTS_CONCURRENCY = 32;\n\n// the number of bytes readBoundedFile reads per call\nexport const BOUNDED_READ_CHUNK_SIZE = 65536;\n"]}
1
+ {"version":3,"file":"constants.js","sourceRoot":"","sources":["../../src/shared/constants.ts"],"names":[],"mappings":"AAEA,2EAA2E;AAC3E,MAAM,CAAC,MAAM,kCAAkC,GAA8B;IAC3E,SAAS,EAAE,UAAU;IACrB,SAAS,EAAE,KAAK;IAChB,WAAW,EAAE,EAAE;CAChB,CAAC;AAEF,mIAAmI;AACnI,MAAM,CAAC,MAAM,8BAA8B,GAAG,EAAE,CAAC;AAEjD,qDAAqD;AACrD,MAAM,CAAC,MAAM,uBAAuB,GAAG,KAAK,CAAC;AAE7C,6FAA6F;AAC7F,oGAAoG;AACpG,MAAM,CAAC,MAAM,sBAAsB,GAAsB;IACvD,EAAE,EAAE,EAAE,EAAE,EAAE,EAAE,EAAE,EAAE,GAAG,EAAE,GAAG,EAAE,GAAG,EAAE,IAAI,EAAE,IAAI,EAAE,IAAI;CAChD,CAAC","sourcesContent":["import type { IDirectoryElementsOptions } from './types.js';\n\n// the options getDirectoryElements uses for anything the caller leaves out\nexport const DIRECTORY_ELEMENTS_DEFAULT_OPTIONS: IDirectoryElementsOptions = {\n sortByKey: 'baseName',\n sortOrder: 'asc',\n includeExts: [],\n};\n\n// how many entries the async getDirectoryElements reads at once, so large directories don't open a stat per entry at the same time\nexport const DIRECTORY_ELEMENTS_CONCURRENCY = 32;\n\n// the number of bytes readBoundedFile reads per call\nexport const BOUNDED_READ_CHUNK_SIZE = 65536;\n\n// the waits, in milliseconds, between the attempts of a rename Windows refuses while another\n// process, such as an antivirus scanner or a search indexer, holds the file: about 4 seconds in all\nexport const RENAME_RETRY_DELAYS_MS: readonly number[] = [\n 10, 20, 40, 80, 160, 320, 640, 1000, 1000, 1000,\n];\n"]}
@@ -1,6 +1,6 @@
1
1
  export { ERRORS } from './errors.js';
2
2
  export type { IDirectoryElementsKeySort, IDirectoryElementsOptions, IDirectoryPathElements, IPathElement, IReadBufferFileOptions, IReadFileOptions, IReadJsonFileOptions, IReadStringFileOptions, ISchema, } from './types.js';
3
- export { BOUNDED_READ_CHUNK_SIZE, DIRECTORY_ELEMENTS_CONCURRENCY, DIRECTORY_ELEMENTS_DEFAULT_OPTIONS, } from './constants.js';
3
+ export { BOUNDED_READ_CHUNK_SIZE, DIRECTORY_ELEMENTS_CONCURRENCY, DIRECTORY_ELEMENTS_DEFAULT_OPTIONS, RENAME_RETRY_DELAYS_MS, } from './constants.js';
4
4
  export { isPathWithin, toPathElement } from './paths.js';
5
5
  export { buildDirectoryElementsOptions, groupDirectoryElements } from './directory-elements.js';
6
- export { assertBufferData, assertTextData, fileTooLarge, isNoFollowError, isNotFoundError, notADirectory, notAFile, notARegularFile, parseJsonContent, serializeJsonData, } from './validations.js';
6
+ export { assertBufferData, assertTextData, fileTooLarge, isBusyRenameError, isNoFollowError, isNotFoundError, notADirectory, notAFile, notARegularFile, parseJsonContent, serializeJsonData, } from './validations.js';
@@ -1,11 +1,11 @@
1
1
  // errors
2
2
  export { ERRORS } from './errors.js';
3
3
  // constants
4
- export { BOUNDED_READ_CHUNK_SIZE, DIRECTORY_ELEMENTS_CONCURRENCY, DIRECTORY_ELEMENTS_DEFAULT_OPTIONS, } from './constants.js';
4
+ export { BOUNDED_READ_CHUNK_SIZE, DIRECTORY_ELEMENTS_CONCURRENCY, DIRECTORY_ELEMENTS_DEFAULT_OPTIONS, RENAME_RETRY_DELAYS_MS, } from './constants.js';
5
5
  // paths
6
6
  export { isPathWithin, toPathElement } from './paths.js';
7
7
  // directory elements
8
8
  export { buildDirectoryElementsOptions, groupDirectoryElements } from './directory-elements.js';
9
9
  // validations and exceptions
10
- export { assertBufferData, assertTextData, fileTooLarge, isNoFollowError, isNotFoundError, notADirectory, notAFile, notARegularFile, parseJsonContent, serializeJsonData, } from './validations.js';
10
+ export { assertBufferData, assertTextData, fileTooLarge, isBusyRenameError, isNoFollowError, isNotFoundError, notADirectory, notAFile, notARegularFile, parseJsonContent, serializeJsonData, } from './validations.js';
11
11
  //# sourceMappingURL=index.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.js","sourceRoot":"","sources":["../../src/shared/index.ts"],"names":[],"mappings":"AAAA,SAAS;AACT,OAAO,EAAE,MAAM,EAAE,MAAM,aAAa,CAAC;AAerC,YAAY;AACZ,OAAO,EACL,uBAAuB,EACvB,8BAA8B,EAC9B,kCAAkC,GACnC,MAAM,gBAAgB,CAAC;AAExB,QAAQ;AACR,OAAO,EAAE,YAAY,EAAE,aAAa,EAAE,MAAM,YAAY,CAAC;AAEzD,qBAAqB;AACrB,OAAO,EAAE,6BAA6B,EAAE,sBAAsB,EAAE,MAAM,yBAAyB,CAAC;AAEhG,6BAA6B;AAC7B,OAAO,EACL,gBAAgB,EAChB,cAAc,EACd,YAAY,EACZ,eAAe,EACf,eAAe,EACf,aAAa,EACb,QAAQ,EACR,eAAe,EACf,gBAAgB,EAChB,iBAAiB,GAClB,MAAM,kBAAkB,CAAC","sourcesContent":["// errors\nexport { ERRORS } from './errors.js';\n\n// types\nexport type {\n IDirectoryElementsKeySort,\n IDirectoryElementsOptions,\n IDirectoryPathElements,\n IPathElement,\n IReadBufferFileOptions,\n IReadFileOptions,\n IReadJsonFileOptions,\n IReadStringFileOptions,\n ISchema,\n} from './types.js';\n\n// constants\nexport {\n BOUNDED_READ_CHUNK_SIZE,\n DIRECTORY_ELEMENTS_CONCURRENCY,\n DIRECTORY_ELEMENTS_DEFAULT_OPTIONS,\n} from './constants.js';\n\n// paths\nexport { isPathWithin, toPathElement } from './paths.js';\n\n// directory elements\nexport { buildDirectoryElementsOptions, groupDirectoryElements } from './directory-elements.js';\n\n// validations and exceptions\nexport {\n assertBufferData,\n assertTextData,\n fileTooLarge,\n isNoFollowError,\n isNotFoundError,\n notADirectory,\n notAFile,\n notARegularFile,\n parseJsonContent,\n serializeJsonData,\n} from './validations.js';\n"]}
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../../src/shared/index.ts"],"names":[],"mappings":"AAAA,SAAS;AACT,OAAO,EAAE,MAAM,EAAE,MAAM,aAAa,CAAC;AAerC,YAAY;AACZ,OAAO,EACL,uBAAuB,EACvB,8BAA8B,EAC9B,kCAAkC,EAClC,sBAAsB,GACvB,MAAM,gBAAgB,CAAC;AAExB,QAAQ;AACR,OAAO,EAAE,YAAY,EAAE,aAAa,EAAE,MAAM,YAAY,CAAC;AAEzD,qBAAqB;AACrB,OAAO,EAAE,6BAA6B,EAAE,sBAAsB,EAAE,MAAM,yBAAyB,CAAC;AAEhG,6BAA6B;AAC7B,OAAO,EACL,gBAAgB,EAChB,cAAc,EACd,YAAY,EACZ,iBAAiB,EACjB,eAAe,EACf,eAAe,EACf,aAAa,EACb,QAAQ,EACR,eAAe,EACf,gBAAgB,EAChB,iBAAiB,GAClB,MAAM,kBAAkB,CAAC","sourcesContent":["// errors\nexport { ERRORS } from './errors.js';\n\n// types\nexport type {\n IDirectoryElementsKeySort,\n IDirectoryElementsOptions,\n IDirectoryPathElements,\n IPathElement,\n IReadBufferFileOptions,\n IReadFileOptions,\n IReadJsonFileOptions,\n IReadStringFileOptions,\n ISchema,\n} from './types.js';\n\n// constants\nexport {\n BOUNDED_READ_CHUNK_SIZE,\n DIRECTORY_ELEMENTS_CONCURRENCY,\n DIRECTORY_ELEMENTS_DEFAULT_OPTIONS,\n RENAME_RETRY_DELAYS_MS,\n} from './constants.js';\n\n// paths\nexport { isPathWithin, toPathElement } from './paths.js';\n\n// directory elements\nexport { buildDirectoryElementsOptions, groupDirectoryElements } from './directory-elements.js';\n\n// validations and exceptions\nexport {\n assertBufferData,\n assertTextData,\n fileTooLarge,\n isBusyRenameError,\n isNoFollowError,\n isNotFoundError,\n notADirectory,\n notAFile,\n notARegularFile,\n parseJsonContent,\n serializeJsonData,\n} from './validations.js';\n"]}
@@ -34,4 +34,5 @@ export type ISchema<T> = {
34
34
  };
35
35
  export type IReadJsonFileOptions<T> = {
36
36
  schema?: ISchema<T>;
37
+ maxBytes?: number;
37
38
  };
@@ -1 +1 @@
1
- {"version":3,"file":"types.js","sourceRoot":"","sources":["../../src/shared/types.ts"],"names":[],"mappings":"","sourcesContent":["import type { ISortDirection } from 'jg-utilities';\n\n// the details of a path, read with lstat, so a symbolic link is described rather than followed\nexport type IPathElement = {\n // the path, as given\n path: string;\n\n // the last segment of the path\n baseName: string;\n\n // the extension, such as '.json', or an empty string when there is none\n extName: string;\n\n // whether it's a regular file\n isFile: boolean;\n\n // whether it's a directory\n isDirectory: boolean;\n\n // whether it's a symbolic link, in which case isFile and isDirectory are false\n isSymbolicLink: boolean;\n\n // the size in bytes\n size: number;\n\n // when it was created, in milliseconds since the epoch\n creation: number;\n};\n\n// the keys a directory's elements can be sorted by\nexport type IDirectoryElementsKeySort = 'baseName' | 'size' | 'creation';\n\n// how getDirectoryElements filters and sorts a directory's elements\nexport type IDirectoryElementsOptions = {\n // the key the elements are sorted by. Defaults to 'baseName'\n sortByKey: IDirectoryElementsKeySort;\n\n // the sort direction. Defaults to 'asc'\n sortOrder: ISortDirection;\n\n // the extensions of the files to include, such as ['.json'], matched case-insensitively. Defaults to all\n includeExts: string[];\n};\n\n// a directory's elements, grouped by kind\nexport type IDirectoryPathElements = {\n directories: IPathElement[];\n files: IPathElement[];\n symbolicLinks: IPathElement[];\n};\n\n// the readFile options that make it return a string\nexport type IReadStringFileOptions =\n | {\n encoding: BufferEncoding;\n flag?: string | undefined;\n }\n | BufferEncoding;\n\n// the readFile options that make it return a Buffer\nexport type IReadBufferFileOptions = {\n encoding?: null | undefined;\n flag?: string | undefined;\n} | null;\n\n// the options readFile accepts\nexport type IReadFileOptions = IReadBufferFileOptions | IReadStringFileOptions;\n\n// validates parsed data and returns it typed, which a Zod schema does\nexport type ISchema<T> = {\n parse(value: unknown): T;\n};\n\n// what readJsonFile accepts\nexport type IReadJsonFileOptions<T> = {\n // validates the parsed content, such as a Zod schema. Its errors propagate unchanged\n schema?: ISchema<T>;\n};\n"]}
1
+ {"version":3,"file":"types.js","sourceRoot":"","sources":["../../src/shared/types.ts"],"names":[],"mappings":"","sourcesContent":["import type { ISortDirection } from 'jg-utilities';\n\n// the details of a path, read with lstat, so a symbolic link is described rather than followed\nexport type IPathElement = {\n // the path, as given\n path: string;\n\n // the last segment of the path\n baseName: string;\n\n // the extension, such as '.json', or an empty string when there is none\n extName: string;\n\n // whether it's a regular file\n isFile: boolean;\n\n // whether it's a directory\n isDirectory: boolean;\n\n // whether it's a symbolic link, in which case isFile and isDirectory are false\n isSymbolicLink: boolean;\n\n // the size in bytes\n size: number;\n\n // when it was created, in milliseconds since the epoch\n creation: number;\n};\n\n// the keys a directory's elements can be sorted by\nexport type IDirectoryElementsKeySort = 'baseName' | 'size' | 'creation';\n\n// how getDirectoryElements filters and sorts a directory's elements\nexport type IDirectoryElementsOptions = {\n // the key the elements are sorted by. Defaults to 'baseName'\n sortByKey: IDirectoryElementsKeySort;\n\n // the sort direction. Defaults to 'asc'\n sortOrder: ISortDirection;\n\n // the extensions of the files to include, such as ['.json'], matched case-insensitively. Defaults to all\n includeExts: string[];\n};\n\n// a directory's elements, grouped by kind\nexport type IDirectoryPathElements = {\n directories: IPathElement[];\n files: IPathElement[];\n symbolicLinks: IPathElement[];\n};\n\n// the readFile options that make it return a string\nexport type IReadStringFileOptions =\n | {\n encoding: BufferEncoding;\n flag?: string | undefined;\n }\n | BufferEncoding;\n\n// the readFile options that make it return a Buffer\nexport type IReadBufferFileOptions = {\n encoding?: null | undefined;\n flag?: string | undefined;\n} | null;\n\n// the options readFile accepts\nexport type IReadFileOptions = IReadBufferFileOptions | IReadStringFileOptions;\n\n// validates parsed data and returns it typed, which a Zod schema does\nexport type ISchema<T> = {\n parse(value: unknown): T;\n};\n\n// what readJsonFile accepts\nexport type IReadJsonFileOptions<T> = {\n // validates the parsed content, such as a Zod schema. Its errors propagate unchanged\n schema?: ISchema<T>;\n\n // the largest file accepted, in bytes, for a file whose size isn't yours to decide, such as one\n // a user uploaded. With it, the file is read as readBoundedFile reads it\n maxBytes?: number;\n};\n"]}
@@ -62,6 +62,14 @@ export declare const parseJsonContent: <T>(path: string, content: string, schema
62
62
  * @returns Whether its code is ENOENT.
63
63
  */
64
64
  export declare const isNotFoundError: (error: unknown) => boolean;
65
+ /**
66
+ * Checks whether a rename failed only because another process holds the file for now, which
67
+ * Windows reports as EACCES, EBUSY, or EPERM, so the rename is worth another attempt.
68
+ * @param error The rename's error.
69
+ * @param platform The platform. Defaults to the current one.
70
+ * @returns Whether to try the rename again.
71
+ */
72
+ export declare const isBusyRenameError: (error: unknown, platform?: NodeJS.Platform) => boolean;
65
73
  /**
66
74
  * Builds the exception for a bounded read whose path doesn't exist or ends in a symbolic link.
67
75
  * @param path The path.
@@ -106,6 +106,17 @@ export const parseJsonContent = (path, content, schema) => {
106
106
  * @returns Whether its code is ENOENT.
107
107
  */
108
108
  export const isNotFoundError = (error) => error instanceof Error && 'code' in error && error.code === 'ENOENT';
109
+ /**
110
+ * Checks whether a rename failed only because another process holds the file for now, which
111
+ * Windows reports as EACCES, EBUSY, or EPERM, so the rename is worth another attempt.
112
+ * @param error The rename's error.
113
+ * @param platform The platform. Defaults to the current one.
114
+ * @returns Whether to try the rename again.
115
+ */
116
+ export const isBusyRenameError = (error, platform = process.platform) => platform === 'win32' &&
117
+ error instanceof Error &&
118
+ 'code' in error &&
119
+ (error.code === 'EACCES' || error.code === 'EBUSY' || error.code === 'EPERM');
109
120
  /**
110
121
  * Builds the exception for a bounded read whose path doesn't exist or ends in a symbolic link.
111
122
  * @param path The path.
@@ -1 +1 @@
1
- {"version":3,"file":"validations.js","sourceRoot":"","sources":["../../src/shared/validations.ts"],"names":[],"mappings":"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","sourcesContent":["import { Buffer } from 'node:buffer';\nimport { Exception } from 'jg-exception';\n\nimport { ERRORS } from './errors.js';\nimport type { ISchema } from './types.js';\n\n/*\n * The checks and exceptions both entries share, so the async and sync functions validate data and\n * report errors identically.\n */\n\n/**\n * Builds the exception for a path that isn't a file.\n * @param path The path.\n * @returns The NOT_A_FILE exception.\n */\nexport const notAFile = (path: string): Exception =>\n new Exception(`The path '${path}' is not a file.`, { code: ERRORS.NOT_A_FILE });\n\n/**\n * Builds the exception for a path that isn't a directory.\n * @param path The path.\n * @returns The NOT_A_DIRECTORY exception.\n */\nexport const notADirectory = (path: string): Exception =>\n new Exception(`The path '${path}' is not a directory.`, { code: ERRORS.NOT_A_DIRECTORY });\n\n/**\n * Builds the exception for a file larger than a bounded read allows.\n * @param path The path.\n * @param maxBytes The limit.\n * @returns The FILE_TOO_LARGE exception.\n */\nexport const fileTooLarge = (path: string, maxBytes: number): Exception =>\n new Exception(`The file '${path}' is larger than ${maxBytes} bytes.`, {\n code: ERRORS.FILE_TOO_LARGE,\n });\n\n/**\n * Ensures text written to a file is a string. An empty string is valid and writes an empty file.\n * @param path The file's path.\n * @param data The text.\n * @throws\n * - FILE_CONTENT_IS_EMPTY_OR_INVALID: if the data isn't a string\n */\nexport const assertTextData = (path: string, data: unknown): void => {\n if (typeof data !== 'string') {\n throw new Exception(\n `The data for the file '${path}' must be a string. Received: ${typeof data}`,\n { code: ERRORS.FILE_CONTENT_IS_EMPTY_OR_INVALID },\n );\n }\n};\n\n/**\n * Ensures data written to a binary file is a Buffer. An empty Buffer is valid.\n * @param path The file's path.\n * @param data The data.\n * @throws\n * - FILE_CONTENT_IS_EMPTY_OR_INVALID: if the data isn't a Buffer\n */\nexport const assertBufferData = (path: string, data: unknown): void => {\n if (!Buffer.isBuffer(data)) {\n throw new Exception(\n `The data for the file '${path}' must be a Buffer. Received: ${typeof data}`,\n { code: ERRORS.FILE_CONTENT_IS_EMPTY_OR_INVALID },\n );\n }\n};\n\n/**\n * Turns data into the JSON text of a file: a string is written as given, and anything else is\n * stringified. JSON can't be empty, so an empty result is rejected.\n * @param path The file's path.\n * @param data The data, or its JSON text.\n * @param space The indentation for stringified data.\n * @returns The JSON text.\n * @throws\n * - FILE_CONTENT_IS_EMPTY_OR_INVALID: if the JSON content cannot be stringified or is empty\n */\nexport const serializeJsonData = (path: string, data: unknown, space: number): string => {\n let text: unknown;\n try {\n text = typeof data === 'string' ? data : JSON.stringify(data, undefined, space);\n } catch (error) {\n throw new Exception(`The JSON data for the file '${path}' could not be stringified.`, {\n code: ERRORS.FILE_CONTENT_IS_EMPTY_OR_INVALID,\n cause: error,\n });\n }\n if (typeof text !== 'string' || text === '') {\n throw new Exception(`The JSON data for the file '${path}' is empty.`, {\n code: ERRORS.FILE_CONTENT_IS_EMPTY_OR_INVALID,\n });\n }\n return text;\n};\n\n/**\n * Parses a JSON file's text, and validates it with the schema when there is one.\n * @param path The file's path.\n * @param content The file's text.\n * @param schema The schema, whose errors propagate unchanged.\n * @returns The parsed value.\n * @throws\n * - FILE_CONTENT_IS_EMPTY_OR_INVALID: if the file's JSON content cannot be parsed\n */\nexport const parseJsonContent = <T>(path: string, content: string, schema?: ISchema<T>): T => {\n let value: unknown;\n try {\n value = JSON.parse(content);\n } catch (error) {\n throw new Exception(`The JSON file '${path}' could not be parsed.`, {\n code: ERRORS.FILE_CONTENT_IS_EMPTY_OR_INVALID,\n cause: error,\n });\n }\n return schema === undefined ? (value as T) : schema.parse(value);\n};\n\n/**\n * Checks whether an error is a missing-path error from Node.js.\n * @param error The error.\n * @returns Whether its code is ENOENT.\n */\nexport const isNotFoundError = (error: unknown): boolean =>\n error instanceof Error && 'code' in error && error.code === 'ENOENT';\n\n/**\n * Builds the exception for a bounded read whose path doesn't exist or ends in a symbolic link.\n * @param path The path.\n * @param error The error opening it.\n * @returns The NOT_A_FILE exception.\n */\nexport const notARegularFile = (path: string, error: unknown): Exception =>\n new Exception(`The file '${path}' doesn't exist or is a symbolic link.`, {\n code: ERRORS.NOT_A_FILE,\n cause: error,\n });\n\n/**\n * Checks whether opening a path without following links failed because it doesn't exist or its\n * last component is a link (ELOOP, or EMLINK on some BSDs).\n * @param error The error.\n * @returns Whether it means the path isn't a regular file to read.\n */\nexport const isNoFollowError = (error: unknown): boolean =>\n error instanceof Error &&\n 'code' in error &&\n (error.code === 'ENOENT' || error.code === 'ELOOP' || error.code === 'EMLINK');\n"]}
1
+ {"version":3,"file":"validations.js","sourceRoot":"","sources":["../../src/shared/validations.ts"],"names":[],"mappings":"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","sourcesContent":["import { Buffer } from 'node:buffer';\nimport { Exception } from 'jg-exception';\n\nimport { ERRORS } from './errors.js';\nimport type { ISchema } from './types.js';\n\n/*\n * The checks and exceptions both entries share, so the async and sync functions validate data and\n * report errors identically.\n */\n\n/**\n * Builds the exception for a path that isn't a file.\n * @param path The path.\n * @returns The NOT_A_FILE exception.\n */\nexport const notAFile = (path: string): Exception =>\n new Exception(`The path '${path}' is not a file.`, { code: ERRORS.NOT_A_FILE });\n\n/**\n * Builds the exception for a path that isn't a directory.\n * @param path The path.\n * @returns The NOT_A_DIRECTORY exception.\n */\nexport const notADirectory = (path: string): Exception =>\n new Exception(`The path '${path}' is not a directory.`, { code: ERRORS.NOT_A_DIRECTORY });\n\n/**\n * Builds the exception for a file larger than a bounded read allows.\n * @param path The path.\n * @param maxBytes The limit.\n * @returns The FILE_TOO_LARGE exception.\n */\nexport const fileTooLarge = (path: string, maxBytes: number): Exception =>\n new Exception(`The file '${path}' is larger than ${maxBytes} bytes.`, {\n code: ERRORS.FILE_TOO_LARGE,\n });\n\n/**\n * Ensures text written to a file is a string. An empty string is valid and writes an empty file.\n * @param path The file's path.\n * @param data The text.\n * @throws\n * - FILE_CONTENT_IS_EMPTY_OR_INVALID: if the data isn't a string\n */\nexport const assertTextData = (path: string, data: unknown): void => {\n if (typeof data !== 'string') {\n throw new Exception(\n `The data for the file '${path}' must be a string. Received: ${typeof data}`,\n { code: ERRORS.FILE_CONTENT_IS_EMPTY_OR_INVALID },\n );\n }\n};\n\n/**\n * Ensures data written to a binary file is a Buffer. An empty Buffer is valid.\n * @param path The file's path.\n * @param data The data.\n * @throws\n * - FILE_CONTENT_IS_EMPTY_OR_INVALID: if the data isn't a Buffer\n */\nexport const assertBufferData = (path: string, data: unknown): void => {\n if (!Buffer.isBuffer(data)) {\n throw new Exception(\n `The data for the file '${path}' must be a Buffer. Received: ${typeof data}`,\n { code: ERRORS.FILE_CONTENT_IS_EMPTY_OR_INVALID },\n );\n }\n};\n\n/**\n * Turns data into the JSON text of a file: a string is written as given, and anything else is\n * stringified. JSON can't be empty, so an empty result is rejected.\n * @param path The file's path.\n * @param data The data, or its JSON text.\n * @param space The indentation for stringified data.\n * @returns The JSON text.\n * @throws\n * - FILE_CONTENT_IS_EMPTY_OR_INVALID: if the JSON content cannot be stringified or is empty\n */\nexport const serializeJsonData = (path: string, data: unknown, space: number): string => {\n let text: unknown;\n try {\n text = typeof data === 'string' ? data : JSON.stringify(data, undefined, space);\n } catch (error) {\n throw new Exception(`The JSON data for the file '${path}' could not be stringified.`, {\n code: ERRORS.FILE_CONTENT_IS_EMPTY_OR_INVALID,\n cause: error,\n });\n }\n if (typeof text !== 'string' || text === '') {\n throw new Exception(`The JSON data for the file '${path}' is empty.`, {\n code: ERRORS.FILE_CONTENT_IS_EMPTY_OR_INVALID,\n });\n }\n return text;\n};\n\n/**\n * Parses a JSON file's text, and validates it with the schema when there is one.\n * @param path The file's path.\n * @param content The file's text.\n * @param schema The schema, whose errors propagate unchanged.\n * @returns The parsed value.\n * @throws\n * - FILE_CONTENT_IS_EMPTY_OR_INVALID: if the file's JSON content cannot be parsed\n */\nexport const parseJsonContent = <T>(path: string, content: string, schema?: ISchema<T>): T => {\n let value: unknown;\n try {\n value = JSON.parse(content);\n } catch (error) {\n throw new Exception(`The JSON file '${path}' could not be parsed.`, {\n code: ERRORS.FILE_CONTENT_IS_EMPTY_OR_INVALID,\n cause: error,\n });\n }\n return schema === undefined ? (value as T) : schema.parse(value);\n};\n\n/**\n * Checks whether an error is a missing-path error from Node.js.\n * @param error The error.\n * @returns Whether its code is ENOENT.\n */\nexport const isNotFoundError = (error: unknown): boolean =>\n error instanceof Error && 'code' in error && error.code === 'ENOENT';\n\n/**\n * Checks whether a rename failed only because another process holds the file for now, which\n * Windows reports as EACCES, EBUSY, or EPERM, so the rename is worth another attempt.\n * @param error The rename's error.\n * @param platform The platform. Defaults to the current one.\n * @returns Whether to try the rename again.\n */\nexport const isBusyRenameError = (\n error: unknown,\n platform: NodeJS.Platform = process.platform,\n): boolean =>\n platform === 'win32' &&\n error instanceof Error &&\n 'code' in error &&\n (error.code === 'EACCES' || error.code === 'EBUSY' || error.code === 'EPERM');\n\n/**\n * Builds the exception for a bounded read whose path doesn't exist or ends in a symbolic link.\n * @param path The path.\n * @param error The error opening it.\n * @returns The NOT_A_FILE exception.\n */\nexport const notARegularFile = (path: string, error: unknown): Exception =>\n new Exception(`The file '${path}' doesn't exist or is a symbolic link.`, {\n code: ERRORS.NOT_A_FILE,\n cause: error,\n });\n\n/**\n * Checks whether opening a path without following links failed because it doesn't exist or its\n * last component is a link (ELOOP, or EMLINK on some BSDs).\n * @param error The error.\n * @returns Whether it means the path isn't a regular file to read.\n */\nexport const isNoFollowError = (error: unknown): boolean =>\n error instanceof Error &&\n 'code' in error &&\n (error.code === 'ENOENT' || error.code === 'ELOOP' || error.code === 'EMLINK');\n"]}
@@ -36,7 +36,9 @@ export declare const writeFile: (path: string, data: string | NodeJS.ArrayBuffer
36
36
  */
37
37
  export declare const writeTextFile: (path: string, data: string) => void;
38
38
  /**
39
- * Writes a JSON file. A string is written as given; anything else is stringified.
39
+ * Writes a JSON file atomically, as writeFileAtomic does, so a crash or a full disk never leaves a
40
+ * truncated file that won't parse. A symbolic link at the path keeps pointing at the file, whose
41
+ * target is replaced. A string is written as given; anything else is stringified.
40
42
  * @param path The file's path.
41
43
  * @param data The value, or its JSON text.
42
44
  * @param space The indentation for stringified values.
@@ -91,9 +93,11 @@ export declare function readFile(path: string, options?: IReadFileOptions): stri
91
93
  export declare const readTextFile: (path: string) => string;
92
94
  /**
93
95
  * Reads and parses a JSON file, and validates it with the schema when there is one. Without a
94
- * schema, the value is `unknown`, or the type you pass.
96
+ * schema, the value is `unknown`, or the type you pass. With `maxBytes`, it reads the file as
97
+ * readBoundedFile does, for a file whose size isn't yours to decide.
95
98
  * @param path The file's path.
96
- * @param options The schema, such as a Zod schema, whose errors propagate unchanged.
99
+ * @param options The schema, such as a Zod schema, whose errors propagate unchanged, and the
100
+ * largest file accepted, in bytes.
97
101
  * @returns The parsed value.
98
102
  * @example
99
103
  * import { readJsonFile } from 'jg-fs/sync';
@@ -105,6 +109,7 @@ export declare const readTextFile: (path: string) => string;
105
109
  * const value = readJsonFile('data.json'); // unknown
106
110
  * @throws
107
111
  * - NOT_A_FILE: if the path doesn't exist or is not a file
112
+ * - FILE_TOO_LARGE: if the file holds more than maxBytes bytes
108
113
  * - FILE_CONTENT_IS_EMPTY_OR_INVALID: if the file's JSON content cannot be parsed
109
114
  */
110
115
  export declare const readJsonFile: <T = unknown>(path: string, options?: IReadJsonFileOptions<T>) => T;
@@ -162,7 +167,9 @@ export declare const createFileSymLink: (target: string, path: string) => void;
162
167
  * to disk and renamed over the path; on POSIX, the directory is flushed too. An explicit mode wins;
163
168
  * without one, a replaced file keeps its permission bits and a new file gets the platform default.
164
169
  * The temporary file is never more widely readable than the final mode. When writing fails before
165
- * the rename, the temporary file is removed and the destination is untouched.
170
+ * the rename, the temporary file is removed and the destination is untouched. While Windows refuses
171
+ * the rename because another process, such as an antivirus scanner, holds the file, it tries again
172
+ * for about 4 seconds. A symbolic link at the path is replaced by the file.
166
173
  * @param path The file's path.
167
174
  * @param data The content.
168
175
  * @param mode The permission bits of the written file, such as `0o600`.
@@ -1,11 +1,11 @@
1
1
  import { Buffer } from 'node:buffer';
2
2
  import { randomUUID } from 'node:crypto';
3
- import { closeSync, copyFileSync, fchmodSync, fstatSync, fsyncSync, mkdirSync, openSync, readFileSync, readSync, renameSync, rmSync, symlinkSync, unlinkSync, writeFileSync, } from 'node:fs';
3
+ import { closeSync, copyFileSync, fchmodSync, fstatSync, fsyncSync, mkdirSync, openSync, readFileSync, readSync, rmSync, symlinkSync, unlinkSync, writeFileSync, } from 'node:fs';
4
4
  import { dirname, join } from 'node:path';
5
5
  import { Exception } from 'jg-exception';
6
6
  import { assertBufferData, assertTextData, BOUNDED_READ_CHUNK_SIZE, ERRORS, fileTooLarge, notAFile, parseJsonContent, serializeJsonData, } from '../../shared/index.js';
7
7
  import { getPathElement } from '../general/index.js';
8
- import { openWithoutFollowing, readPermissionBits, syncDirectory } from './utilities.js';
8
+ import { openWithoutFollowing, readPermissionBits, renameReplacing, resolveWriteTarget, syncDirectory, } from './utilities.js';
9
9
  /**
10
10
  * Checks whether a path exists and is a regular file.
11
11
  * @param path The path.
@@ -47,7 +47,9 @@ export const writeTextFile = (path, data) => {
47
47
  writeFile(path, data, { encoding: 'utf-8' });
48
48
  };
49
49
  /**
50
- * Writes a JSON file. A string is written as given; anything else is stringified.
50
+ * Writes a JSON file atomically, as writeFileAtomic does, so a crash or a full disk never leaves a
51
+ * truncated file that won't parse. A symbolic link at the path keeps pointing at the file, whose
52
+ * target is replaced. A string is written as given; anything else is stringified.
51
53
  * @param path The file's path.
52
54
  * @param data The value, or its JSON text.
53
55
  * @param space The indentation for stringified values.
@@ -59,7 +61,8 @@ export const writeTextFile = (path, data) => {
59
61
  * - FILE_CONTENT_IS_EMPTY_OR_INVALID: if the JSON content cannot be stringified or is empty
60
62
  */
61
63
  export const writeJsonFile = (path, data, space = 2) => {
62
- writeFile(path, serializeJsonData(path, data, space), { encoding: 'utf-8' });
64
+ const content = serializeJsonData(path, data, space);
65
+ writeFileAtomic(resolveWriteTarget(path), content);
63
66
  };
64
67
  /**
65
68
  * Writes a binary file. An empty Buffer writes an empty file.
@@ -97,9 +100,11 @@ export function readFile(path, options = null) {
97
100
  export const readTextFile = (path) => readFile(path, { encoding: 'utf-8' });
98
101
  /**
99
102
  * Reads and parses a JSON file, and validates it with the schema when there is one. Without a
100
- * schema, the value is `unknown`, or the type you pass.
103
+ * schema, the value is `unknown`, or the type you pass. With `maxBytes`, it reads the file as
104
+ * readBoundedFile does, for a file whose size isn't yours to decide.
101
105
  * @param path The file's path.
102
- * @param options The schema, such as a Zod schema, whose errors propagate unchanged.
106
+ * @param options The schema, such as a Zod schema, whose errors propagate unchanged, and the
107
+ * largest file accepted, in bytes.
103
108
  * @returns The parsed value.
104
109
  * @example
105
110
  * import { readJsonFile } from 'jg-fs/sync';
@@ -111,9 +116,12 @@ export const readTextFile = (path) => readFile(path, { encoding: 'utf-8' });
111
116
  * const value = readJsonFile('data.json'); // unknown
112
117
  * @throws
113
118
  * - NOT_A_FILE: if the path doesn't exist or is not a file
119
+ * - FILE_TOO_LARGE: if the file holds more than maxBytes bytes
114
120
  * - FILE_CONTENT_IS_EMPTY_OR_INVALID: if the file's JSON content cannot be parsed
115
121
  */
116
- export const readJsonFile = (path, options = {}) => parseJsonContent(path, readTextFile(path), options.schema);
122
+ export const readJsonFile = (path, options = {}) => parseJsonContent(path, options.maxBytes === undefined
123
+ ? readTextFile(path)
124
+ : readBoundedFile(path, options.maxBytes).toString('utf-8'), options.schema);
117
125
  /**
118
126
  * Reads a binary file. An empty file reads as an empty Buffer.
119
127
  * @param path The file's path.
@@ -189,7 +197,9 @@ export const createFileSymLink = (target, path) => {
189
197
  * to disk and renamed over the path; on POSIX, the directory is flushed too. An explicit mode wins;
190
198
  * without one, a replaced file keeps its permission bits and a new file gets the platform default.
191
199
  * The temporary file is never more widely readable than the final mode. When writing fails before
192
- * the rename, the temporary file is removed and the destination is untouched.
200
+ * the rename, the temporary file is removed and the destination is untouched. While Windows refuses
201
+ * the rename because another process, such as an antivirus scanner, holds the file, it tries again
202
+ * for about 4 seconds. A symbolic link at the path is replaced by the file.
193
203
  * @param path The file's path.
194
204
  * @param data The content.
195
205
  * @param mode The permission bits of the written file, such as `0o600`.
@@ -219,7 +229,7 @@ export const writeFileAtomic = (path, data, mode) => {
219
229
  finally {
220
230
  closeSync(descriptor);
221
231
  }
222
- renameSync(tempPath, path);
232
+ renameReplacing(tempPath, path);
223
233
  isRenamed = true;
224
234
  }
225
235
  finally {
@@ -1 +1 @@
1
- {"version":3,"file":"files.js","sourceRoot":"","sources":["../../../src/sync/files/files.ts"],"names":[],"mappings":"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","sourcesContent":["import { Buffer } from 'node:buffer';\nimport { randomUUID } from 'node:crypto';\nimport {\n closeSync,\n copyFileSync,\n fchmodSync,\n fstatSync,\n fsyncSync,\n mkdirSync,\n openSync,\n readFileSync,\n readSync,\n renameSync,\n rmSync,\n symlinkSync,\n unlinkSync,\n writeFileSync,\n type WriteFileOptions,\n} from 'node:fs';\nimport { dirname, join } from 'node:path';\nimport { Exception } from 'jg-exception';\n\nimport {\n assertBufferData,\n assertTextData,\n BOUNDED_READ_CHUNK_SIZE,\n ERRORS,\n fileTooLarge,\n notAFile,\n parseJsonContent,\n serializeJsonData,\n type IReadBufferFileOptions,\n type IReadFileOptions,\n type IReadJsonFileOptions,\n type IReadStringFileOptions,\n} from '../../shared/index.js';\nimport { getPathElement } from '../general/index.js';\n\nimport { openWithoutFollowing, readPermissionBits, syncDirectory } from './utilities.js';\n\n/**\n * Checks whether a path exists and is a regular file.\n * @param path The path.\n * @returns Whether the path is a file.\n * @example\n * import { isFile } from 'jg-fs/sync';\n *\n * isFile('package.json'); // true\n */\nexport const isFile = (path: string): boolean => getPathElement(path)?.isFile ?? false;\n\n/**\n * Writes a file, creating its directory first when it doesn't exist.\n * @param path The file's path.\n * @param data The content.\n * @param options Node.js's writeFile options, such as the encoding or mode.\n * @example\n * import { writeFile } from 'jg-fs/sync';\n *\n * const csv = 'id,name\\n1,Ada\\n';\n * writeFile('out/report.csv', csv, { encoding: 'utf-8' });\n */\nexport const writeFile = (\n path: string,\n data: string | NodeJS.ArrayBufferView,\n options?: WriteFileOptions,\n): void => {\n mkdirSync(dirname(path), { recursive: true });\n writeFileSync(path, data, options);\n};\n\n/**\n * Writes a UTF-8 text file. An empty string writes an empty file.\n * @param path The file's path.\n * @param data The text.\n * @example\n * import { writeTextFile } from 'jg-fs/sync';\n *\n * writeTextFile('notes/today.md', '# Today');\n * @throws\n * - FILE_CONTENT_IS_EMPTY_OR_INVALID: if the data isn't a string\n */\nexport const writeTextFile = (path: string, data: string): void => {\n assertTextData(path, data);\n writeFile(path, data, { encoding: 'utf-8' });\n};\n\n/**\n * Writes a JSON file. A string is written as given; anything else is stringified.\n * @param path The file's path.\n * @param data The value, or its JSON text.\n * @param space The indentation for stringified values.\n * @example\n * import { writeJsonFile } from 'jg-fs/sync';\n *\n * writeJsonFile('config.json', { port: 8080 });\n * @throws\n * - FILE_CONTENT_IS_EMPTY_OR_INVALID: if the JSON content cannot be stringified or is empty\n */\nexport const writeJsonFile = <T = Record<string, unknown>>(\n path: string,\n data: T | string,\n space: number = 2,\n): void => {\n writeFile(path, serializeJsonData(path, data, space), { encoding: 'utf-8' });\n};\n\n/**\n * Writes a binary file. An empty Buffer writes an empty file.\n * @param path The file's path.\n * @param data The content.\n * @example\n * import { writeBufferFile } from 'jg-fs/sync';\n *\n * const imageBuffer = Buffer.from('iVBORw0KGgo=', 'base64');\n * writeBufferFile('avatar.png', imageBuffer);\n * @throws\n * - FILE_CONTENT_IS_EMPTY_OR_INVALID: if the data isn't a Buffer\n */\nexport const writeBufferFile = (path: string, data: Buffer): void => {\n assertBufferData(path, data);\n writeFile(path, data);\n};\n\n/**\n * Reads a file as a string when an encoding is given, or as a Buffer otherwise.\n * @param path The file's path.\n * @param options Node.js's readFile options, such as the encoding.\n * @returns The content.\n * @example\n * import { readFile } from 'jg-fs/sync';\n *\n * readFile('notes.md', 'utf-8'); // a string\n * readFile('avatar.png'); // a Buffer\n * @throws\n * - NOT_A_FILE: if the path doesn't exist or is not a file\n */\nexport function readFile(path: string, options: IReadStringFileOptions): string;\nexport function readFile(path: string, options?: IReadBufferFileOptions): Buffer;\nexport function readFile(path: string, options?: IReadFileOptions): string | Buffer;\nexport function readFile(path: string, options: IReadFileOptions = null): string | Buffer {\n if (!isFile(path)) {\n throw notAFile(path);\n }\n return readFileSync(path, options as Parameters<typeof readFileSync>[1]);\n}\n\n/**\n * Reads a UTF-8 text file. An empty file reads as an empty string.\n * @param path The file's path.\n * @returns The text.\n * @example\n * import { readTextFile } from 'jg-fs/sync';\n *\n * const markdown = readTextFile('README.md');\n * @throws\n * - NOT_A_FILE: if the path doesn't exist or is not a file\n */\nexport const readTextFile = (path: string): string => readFile(path, { encoding: 'utf-8' });\n\n/**\n * Reads and parses a JSON file, and validates it with the schema when there is one. Without a\n * schema, the value is `unknown`, or the type you pass.\n * @param path The file's path.\n * @param options The schema, such as a Zod schema, whose errors propagate unchanged.\n * @returns The parsed value.\n * @example\n * import { readJsonFile } from 'jg-fs/sync';\n *\n * const config = readJsonFile('config.json', { schema: Config }); // Config's type\n * @example\n * import { readJsonFile } from 'jg-fs/sync';\n *\n * const value = readJsonFile('data.json'); // unknown\n * @throws\n * - NOT_A_FILE: if the path doesn't exist or is not a file\n * - FILE_CONTENT_IS_EMPTY_OR_INVALID: if the file's JSON content cannot be parsed\n */\nexport const readJsonFile = <T = unknown>(path: string, options: IReadJsonFileOptions<T> = {}): T =>\n parseJsonContent(path, readTextFile(path), options.schema);\n\n/**\n * Reads a binary file. An empty file reads as an empty Buffer.\n * @param path The file's path.\n * @returns The content.\n * @example\n * import { readBufferFile } from 'jg-fs/sync';\n *\n * const image = readBufferFile('avatar.png');\n * @throws\n * - NOT_A_FILE: if the path doesn't exist or is not a file\n */\nexport const readBufferFile = (path: string): Buffer => readFile(path, null);\n\n/**\n * Copies a file, replacing the destination when it exists.\n * @param srcPath The file to copy.\n * @param destPath Where to copy it.\n * @example\n * import { copyFile } from 'jg-fs/sync';\n *\n * copyFile('config.example.json', 'config.json');\n * @throws\n * - NOT_A_FILE: if the srcPath doesn't exist or is not a file\n */\nexport const copyFile = (srcPath: string, destPath: string): void => {\n if (!isFile(srcPath)) {\n throw notAFile(srcPath);\n }\n copyFileSync(srcPath, destPath);\n};\n\n/**\n * Deletes a file.\n * @param path The file's path.\n * @example\n * import { deleteFile } from 'jg-fs/sync';\n *\n * deleteFile('cache.json');\n * @throws\n * - NOT_A_FILE: if the path doesn't exist or is not a file\n */\nexport const deleteFile = (path: string): void => {\n if (!isFile(path)) {\n throw notAFile(path);\n }\n unlinkSync(path);\n};\n\n/**\n * Creates a symbolic link to a file.\n * @param target The file the link points to.\n * @param path Where to create the link.\n * @example\n * import { createFileSymLink } from 'jg-fs/sync';\n *\n * createFileSymLink('config.production.json', 'config.json');\n * @throws\n * - NON_EXISTENT_FILE: if the target file does not exist\n * - NOT_A_FILE: if the target is not a file\n */\nexport const createFileSymLink = (target: string, path: string): void => {\n const element = getPathElement(target);\n if (element === null) {\n throw new Exception(`The target file '${target}' does not exist.`, {\n code: ERRORS.NON_EXISTENT_FILE,\n });\n }\n if (!element.isFile) {\n throw notAFile(target);\n }\n symlinkSync(target, path, 'file');\n};\n\n/**\n * Writes a file atomically: readers see either the previous content or the complete new content,\n * never a partial write. The data goes to a temporary file in the same directory, which is flushed\n * to disk and renamed over the path; on POSIX, the directory is flushed too. An explicit mode wins;\n * without one, a replaced file keeps its permission bits and a new file gets the platform default.\n * The temporary file is never more widely readable than the final mode. When writing fails before\n * the rename, the temporary file is removed and the destination is untouched.\n * @param path The file's path.\n * @param data The content.\n * @param mode The permission bits of the written file, such as `0o600`.\n * @example\n * import { writeFileAtomic } from 'jg-fs/sync';\n *\n * const state = { step: 2 };\n * writeFileAtomic('state.json', JSON.stringify(state)); // readers never see a partial file\n * writeFileAtomic('secrets.env', 'TOKEN=abc\\n', 0o600); // only the owner can read it\n */\nexport const writeFileAtomic = (\n path: string,\n data: string | NodeJS.ArrayBufferView,\n mode?: number,\n): void => {\n const directory = dirname(path);\n mkdirSync(directory, { recursive: true });\n const finalMode = mode ?? readPermissionBits(path);\n const tempPath = join(directory, `.${randomUUID()}.tmp`);\n let isRenamed = false;\n try {\n // a known final mode is applied with chmod, so the umask can't change it\n const descriptor = openSync(tempPath, 'wx', finalMode === undefined ? 0o666 : 0o600);\n try {\n if (finalMode !== undefined) {\n fchmodSync(descriptor, finalMode);\n }\n writeFileSync(descriptor, data);\n fsyncSync(descriptor);\n } finally {\n closeSync(descriptor);\n }\n renameSync(tempPath, path);\n isRenamed = true;\n } finally {\n if (!isRenamed) {\n rmSync(tempPath, { force: true });\n }\n }\n if (process.platform !== 'win32') {\n syncDirectory(directory);\n }\n};\n\n/**\n * Reads a regular file of at most `maxBytes` bytes. It never follows a symbolic link in the\n * path's last component, though links in parent directories are followed, so callers that need\n * containment combine it with `isPathWithin`. It reads at most `maxBytes + 1` bytes, even when the\n * file grows while it's read.\n * @param path The file's path.\n * @param maxBytes The largest size accepted, in bytes.\n * @returns The content.\n * @example\n * import { join } from 'node:path';\n * import { readBoundedFile } from 'jg-fs/sync';\n *\n * const upload = readBoundedFile(join('uploads', 'avatar.png'), 5 * 1024 * 1024); // at most 5 MB\n * @throws\n * - NOT_A_FILE: if the path doesn't exist, its final component is a symbolic link, or it isn't a regular file\n * - FILE_TOO_LARGE: if the file holds more than maxBytes bytes\n */\nexport const readBoundedFile = (path: string, maxBytes: number): Buffer => {\n const descriptor = openWithoutFollowing(path);\n try {\n const stats = fstatSync(descriptor);\n if (!stats.isFile()) {\n throw notAFile(path);\n }\n if (stats.size > maxBytes) {\n throw fileTooLarge(path, maxBytes);\n }\n const chunks: Buffer[] = [];\n let totalBytes = 0;\n while (totalBytes <= maxBytes) {\n const chunk = Buffer.allocUnsafe(\n Math.min(BOUNDED_READ_CHUNK_SIZE, maxBytes + 1 - totalBytes),\n );\n const bytesRead = readSync(descriptor, chunk, 0, chunk.length, totalBytes);\n if (bytesRead === 0) {\n break;\n }\n chunks.push(chunk.subarray(0, bytesRead));\n totalBytes += bytesRead;\n }\n if (totalBytes > maxBytes) {\n throw fileTooLarge(path, maxBytes);\n }\n return Buffer.concat(chunks, totalBytes);\n } finally {\n closeSync(descriptor);\n }\n};\n"]}
1
+ {"version":3,"file":"files.js","sourceRoot":"","sources":["../../../src/sync/files/files.ts"],"names":[],"mappings":"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","sourcesContent":["import { Buffer } from 'node:buffer';\nimport { randomUUID } from 'node:crypto';\nimport {\n closeSync,\n copyFileSync,\n fchmodSync,\n fstatSync,\n fsyncSync,\n mkdirSync,\n openSync,\n readFileSync,\n readSync,\n rmSync,\n symlinkSync,\n unlinkSync,\n writeFileSync,\n type WriteFileOptions,\n} from 'node:fs';\nimport { dirname, join } from 'node:path';\nimport { Exception } from 'jg-exception';\n\nimport {\n assertBufferData,\n assertTextData,\n BOUNDED_READ_CHUNK_SIZE,\n ERRORS,\n fileTooLarge,\n notAFile,\n parseJsonContent,\n serializeJsonData,\n type IReadBufferFileOptions,\n type IReadFileOptions,\n type IReadJsonFileOptions,\n type IReadStringFileOptions,\n} from '../../shared/index.js';\nimport { getPathElement } from '../general/index.js';\n\nimport {\n openWithoutFollowing,\n readPermissionBits,\n renameReplacing,\n resolveWriteTarget,\n syncDirectory,\n} from './utilities.js';\n\n/**\n * Checks whether a path exists and is a regular file.\n * @param path The path.\n * @returns Whether the path is a file.\n * @example\n * import { isFile } from 'jg-fs/sync';\n *\n * isFile('package.json'); // true\n */\nexport const isFile = (path: string): boolean => getPathElement(path)?.isFile ?? false;\n\n/**\n * Writes a file, creating its directory first when it doesn't exist.\n * @param path The file's path.\n * @param data The content.\n * @param options Node.js's writeFile options, such as the encoding or mode.\n * @example\n * import { writeFile } from 'jg-fs/sync';\n *\n * const csv = 'id,name\\n1,Ada\\n';\n * writeFile('out/report.csv', csv, { encoding: 'utf-8' });\n */\nexport const writeFile = (\n path: string,\n data: string | NodeJS.ArrayBufferView,\n options?: WriteFileOptions,\n): void => {\n mkdirSync(dirname(path), { recursive: true });\n writeFileSync(path, data, options);\n};\n\n/**\n * Writes a UTF-8 text file. An empty string writes an empty file.\n * @param path The file's path.\n * @param data The text.\n * @example\n * import { writeTextFile } from 'jg-fs/sync';\n *\n * writeTextFile('notes/today.md', '# Today');\n * @throws\n * - FILE_CONTENT_IS_EMPTY_OR_INVALID: if the data isn't a string\n */\nexport const writeTextFile = (path: string, data: string): void => {\n assertTextData(path, data);\n writeFile(path, data, { encoding: 'utf-8' });\n};\n\n/**\n * Writes a JSON file atomically, as writeFileAtomic does, so a crash or a full disk never leaves a\n * truncated file that won't parse. A symbolic link at the path keeps pointing at the file, whose\n * target is replaced. A string is written as given; anything else is stringified.\n * @param path The file's path.\n * @param data The value, or its JSON text.\n * @param space The indentation for stringified values.\n * @example\n * import { writeJsonFile } from 'jg-fs/sync';\n *\n * writeJsonFile('config.json', { port: 8080 });\n * @throws\n * - FILE_CONTENT_IS_EMPTY_OR_INVALID: if the JSON content cannot be stringified or is empty\n */\nexport const writeJsonFile = <T = Record<string, unknown>>(\n path: string,\n data: T | string,\n space: number = 2,\n): void => {\n const content = serializeJsonData(path, data, space);\n writeFileAtomic(resolveWriteTarget(path), content);\n};\n\n/**\n * Writes a binary file. An empty Buffer writes an empty file.\n * @param path The file's path.\n * @param data The content.\n * @example\n * import { writeBufferFile } from 'jg-fs/sync';\n *\n * const imageBuffer = Buffer.from('iVBORw0KGgo=', 'base64');\n * writeBufferFile('avatar.png', imageBuffer);\n * @throws\n * - FILE_CONTENT_IS_EMPTY_OR_INVALID: if the data isn't a Buffer\n */\nexport const writeBufferFile = (path: string, data: Buffer): void => {\n assertBufferData(path, data);\n writeFile(path, data);\n};\n\n/**\n * Reads a file as a string when an encoding is given, or as a Buffer otherwise.\n * @param path The file's path.\n * @param options Node.js's readFile options, such as the encoding.\n * @returns The content.\n * @example\n * import { readFile } from 'jg-fs/sync';\n *\n * readFile('notes.md', 'utf-8'); // a string\n * readFile('avatar.png'); // a Buffer\n * @throws\n * - NOT_A_FILE: if the path doesn't exist or is not a file\n */\nexport function readFile(path: string, options: IReadStringFileOptions): string;\nexport function readFile(path: string, options?: IReadBufferFileOptions): Buffer;\nexport function readFile(path: string, options?: IReadFileOptions): string | Buffer;\nexport function readFile(path: string, options: IReadFileOptions = null): string | Buffer {\n if (!isFile(path)) {\n throw notAFile(path);\n }\n return readFileSync(path, options as Parameters<typeof readFileSync>[1]);\n}\n\n/**\n * Reads a UTF-8 text file. An empty file reads as an empty string.\n * @param path The file's path.\n * @returns The text.\n * @example\n * import { readTextFile } from 'jg-fs/sync';\n *\n * const markdown = readTextFile('README.md');\n * @throws\n * - NOT_A_FILE: if the path doesn't exist or is not a file\n */\nexport const readTextFile = (path: string): string => readFile(path, { encoding: 'utf-8' });\n\n/**\n * Reads and parses a JSON file, and validates it with the schema when there is one. Without a\n * schema, the value is `unknown`, or the type you pass. With `maxBytes`, it reads the file as\n * readBoundedFile does, for a file whose size isn't yours to decide.\n * @param path The file's path.\n * @param options The schema, such as a Zod schema, whose errors propagate unchanged, and the\n * largest file accepted, in bytes.\n * @returns The parsed value.\n * @example\n * import { readJsonFile } from 'jg-fs/sync';\n *\n * const config = readJsonFile('config.json', { schema: Config }); // Config's type\n * @example\n * import { readJsonFile } from 'jg-fs/sync';\n *\n * const value = readJsonFile('data.json'); // unknown\n * @throws\n * - NOT_A_FILE: if the path doesn't exist or is not a file\n * - FILE_TOO_LARGE: if the file holds more than maxBytes bytes\n * - FILE_CONTENT_IS_EMPTY_OR_INVALID: if the file's JSON content cannot be parsed\n */\nexport const readJsonFile = <T = unknown>(path: string, options: IReadJsonFileOptions<T> = {}): T =>\n parseJsonContent(\n path,\n options.maxBytes === undefined\n ? readTextFile(path)\n : readBoundedFile(path, options.maxBytes).toString('utf-8'),\n options.schema,\n );\n\n/**\n * Reads a binary file. An empty file reads as an empty Buffer.\n * @param path The file's path.\n * @returns The content.\n * @example\n * import { readBufferFile } from 'jg-fs/sync';\n *\n * const image = readBufferFile('avatar.png');\n * @throws\n * - NOT_A_FILE: if the path doesn't exist or is not a file\n */\nexport const readBufferFile = (path: string): Buffer => readFile(path, null);\n\n/**\n * Copies a file, replacing the destination when it exists.\n * @param srcPath The file to copy.\n * @param destPath Where to copy it.\n * @example\n * import { copyFile } from 'jg-fs/sync';\n *\n * copyFile('config.example.json', 'config.json');\n * @throws\n * - NOT_A_FILE: if the srcPath doesn't exist or is not a file\n */\nexport const copyFile = (srcPath: string, destPath: string): void => {\n if (!isFile(srcPath)) {\n throw notAFile(srcPath);\n }\n copyFileSync(srcPath, destPath);\n};\n\n/**\n * Deletes a file.\n * @param path The file's path.\n * @example\n * import { deleteFile } from 'jg-fs/sync';\n *\n * deleteFile('cache.json');\n * @throws\n * - NOT_A_FILE: if the path doesn't exist or is not a file\n */\nexport const deleteFile = (path: string): void => {\n if (!isFile(path)) {\n throw notAFile(path);\n }\n unlinkSync(path);\n};\n\n/**\n * Creates a symbolic link to a file.\n * @param target The file the link points to.\n * @param path Where to create the link.\n * @example\n * import { createFileSymLink } from 'jg-fs/sync';\n *\n * createFileSymLink('config.production.json', 'config.json');\n * @throws\n * - NON_EXISTENT_FILE: if the target file does not exist\n * - NOT_A_FILE: if the target is not a file\n */\nexport const createFileSymLink = (target: string, path: string): void => {\n const element = getPathElement(target);\n if (element === null) {\n throw new Exception(`The target file '${target}' does not exist.`, {\n code: ERRORS.NON_EXISTENT_FILE,\n });\n }\n if (!element.isFile) {\n throw notAFile(target);\n }\n symlinkSync(target, path, 'file');\n};\n\n/**\n * Writes a file atomically: readers see either the previous content or the complete new content,\n * never a partial write. The data goes to a temporary file in the same directory, which is flushed\n * to disk and renamed over the path; on POSIX, the directory is flushed too. An explicit mode wins;\n * without one, a replaced file keeps its permission bits and a new file gets the platform default.\n * The temporary file is never more widely readable than the final mode. When writing fails before\n * the rename, the temporary file is removed and the destination is untouched. While Windows refuses\n * the rename because another process, such as an antivirus scanner, holds the file, it tries again\n * for about 4 seconds. A symbolic link at the path is replaced by the file.\n * @param path The file's path.\n * @param data The content.\n * @param mode The permission bits of the written file, such as `0o600`.\n * @example\n * import { writeFileAtomic } from 'jg-fs/sync';\n *\n * const state = { step: 2 };\n * writeFileAtomic('state.json', JSON.stringify(state)); // readers never see a partial file\n * writeFileAtomic('secrets.env', 'TOKEN=abc\\n', 0o600); // only the owner can read it\n */\nexport const writeFileAtomic = (\n path: string,\n data: string | NodeJS.ArrayBufferView,\n mode?: number,\n): void => {\n const directory = dirname(path);\n mkdirSync(directory, { recursive: true });\n const finalMode = mode ?? readPermissionBits(path);\n const tempPath = join(directory, `.${randomUUID()}.tmp`);\n let isRenamed = false;\n try {\n // a known final mode is applied with chmod, so the umask can't change it\n const descriptor = openSync(tempPath, 'wx', finalMode === undefined ? 0o666 : 0o600);\n try {\n if (finalMode !== undefined) {\n fchmodSync(descriptor, finalMode);\n }\n writeFileSync(descriptor, data);\n fsyncSync(descriptor);\n } finally {\n closeSync(descriptor);\n }\n renameReplacing(tempPath, path);\n isRenamed = true;\n } finally {\n if (!isRenamed) {\n rmSync(tempPath, { force: true });\n }\n }\n if (process.platform !== 'win32') {\n syncDirectory(directory);\n }\n};\n\n/**\n * Reads a regular file of at most `maxBytes` bytes. It never follows a symbolic link in the\n * path's last component, though links in parent directories are followed, so callers that need\n * containment combine it with `isPathWithin`. It reads at most `maxBytes + 1` bytes, even when the\n * file grows while it's read.\n * @param path The file's path.\n * @param maxBytes The largest size accepted, in bytes.\n * @returns The content.\n * @example\n * import { join } from 'node:path';\n * import { readBoundedFile } from 'jg-fs/sync';\n *\n * const upload = readBoundedFile(join('uploads', 'avatar.png'), 5 * 1024 * 1024); // at most 5 MB\n * @throws\n * - NOT_A_FILE: if the path doesn't exist, its final component is a symbolic link, or it isn't a regular file\n * - FILE_TOO_LARGE: if the file holds more than maxBytes bytes\n */\nexport const readBoundedFile = (path: string, maxBytes: number): Buffer => {\n const descriptor = openWithoutFollowing(path);\n try {\n const stats = fstatSync(descriptor);\n if (!stats.isFile()) {\n throw notAFile(path);\n }\n if (stats.size > maxBytes) {\n throw fileTooLarge(path, maxBytes);\n }\n const chunks: Buffer[] = [];\n let totalBytes = 0;\n while (totalBytes <= maxBytes) {\n const chunk = Buffer.allocUnsafe(\n Math.min(BOUNDED_READ_CHUNK_SIZE, maxBytes + 1 - totalBytes),\n );\n const bytesRead = readSync(descriptor, chunk, 0, chunk.length, totalBytes);\n if (bytesRead === 0) {\n break;\n }\n chunks.push(chunk.subarray(0, bytesRead));\n totalBytes += bytesRead;\n }\n if (totalBytes > maxBytes) {\n throw fileTooLarge(path, maxBytes);\n }\n return Buffer.concat(chunks, totalBytes);\n } finally {\n closeSync(descriptor);\n }\n};\n"]}
@@ -4,6 +4,21 @@
4
4
  * @returns The bits, or `undefined` when the file doesn't exist.
5
5
  */
6
6
  export declare const readPermissionBits: (path: string) => number | undefined;
7
+ /**
8
+ * Renames a file over another, trying again for about 4 seconds while Windows refuses because
9
+ * another process, such as an antivirus scanner, holds the destination. It blocks between
10
+ * attempts, as every sync function does.
11
+ * @param from The file to rename.
12
+ * @param to Its new path.
13
+ */
14
+ export declare const renameReplacing: (from: string, to: string) => void;
15
+ /**
16
+ * Resolves the file a write replaces: the target of a symbolic link at the path, so the link keeps
17
+ * pointing at the new content, or the path itself when nothing is there yet.
18
+ * @param path The path.
19
+ * @returns The path to write.
20
+ */
21
+ export declare const resolveWriteTarget: (path: string) => string;
7
22
  /**
8
23
  * Flushes a directory's entries to disk, so a rename inside it survives a crash.
9
24
  * @param path The directory's path.
@@ -1,5 +1,5 @@
1
- import { closeSync, constants, fsyncSync, lstatSync, openSync, statSync } from 'node:fs';
2
- import { isNoFollowError, isNotFoundError, notARegularFile } from '../../shared/index.js';
1
+ import { closeSync, constants, fsyncSync, lstatSync, openSync, realpathSync, renameSync, statSync, } from 'node:fs';
2
+ import { isBusyRenameError, isNoFollowError, isNotFoundError, notARegularFile, RENAME_RETRY_DELAYS_MS, } from '../../shared/index.js';
3
3
  /**
4
4
  * Reads the permission bits of an existing file.
5
5
  * @param path The file's path.
@@ -16,6 +16,45 @@ export const readPermissionBits = (path) => {
16
16
  throw error;
17
17
  }
18
18
  };
19
+ /**
20
+ * Renames a file over another, trying again for about 4 seconds while Windows refuses because
21
+ * another process, such as an antivirus scanner, holds the destination. It blocks between
22
+ * attempts, as every sync function does.
23
+ * @param from The file to rename.
24
+ * @param to Its new path.
25
+ */
26
+ export const renameReplacing = (from, to) => {
27
+ for (let attempt = 0;; attempt += 1) {
28
+ try {
29
+ renameSync(from, to);
30
+ return;
31
+ }
32
+ catch (error) {
33
+ const delay = RENAME_RETRY_DELAYS_MS[attempt];
34
+ if (delay === undefined || !isBusyRenameError(error)) {
35
+ throw error;
36
+ }
37
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, delay);
38
+ }
39
+ }
40
+ };
41
+ /**
42
+ * Resolves the file a write replaces: the target of a symbolic link at the path, so the link keeps
43
+ * pointing at the new content, or the path itself when nothing is there yet.
44
+ * @param path The path.
45
+ * @returns The path to write.
46
+ */
47
+ export const resolveWriteTarget = (path) => {
48
+ try {
49
+ return realpathSync(path);
50
+ }
51
+ catch (error) {
52
+ if (isNotFoundError(error)) {
53
+ return path;
54
+ }
55
+ throw error;
56
+ }
57
+ };
19
58
  /**
20
59
  * Flushes a directory's entries to disk, so a rename inside it survives a crash.
21
60
  * @param path The directory's path.
@@ -1 +1 @@
1
- {"version":3,"file":"utilities.js","sourceRoot":"","sources":["../../../src/sync/files/utilities.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,SAAS,EAAE,SAAS,EAAE,SAAS,EAAE,SAAS,EAAE,QAAQ,EAAE,QAAQ,EAAE,MAAM,SAAS,CAAC;AAEzF,OAAO,EAAE,eAAe,EAAE,eAAe,EAAE,eAAe,EAAE,MAAM,uBAAuB,CAAC;AAE1F;;;;GAIG;AACH,MAAM,CAAC,MAAM,kBAAkB,GAAG,CAAC,IAAY,EAAsB,EAAE;IACrE,IAAI,CAAC;QACH,OAAO,QAAQ,CAAC,IAAI,CAAC,CAAC,IAAI,GAAG,KAAK,CAAC;IACrC,CAAC;IAAC,OAAO,KAAK,EAAE,CAAC;QACf,IAAI,eAAe,CAAC,KAAK,CAAC,EAAE,CAAC;YAC3B,OAAO,SAAS,CAAC;QACnB,CAAC;QACD,MAAM,KAAK,CAAC;IACd,CAAC;AACH,CAAC,CAAC;AAEF;;;GAGG;AACH,MAAM,CAAC,MAAM,aAAa,GAAG,CAAC,IAAY,EAAQ,EAAE;IAClD,MAAM,UAAU,GAAG,QAAQ,CAAC,IAAI,EAAE,GAAG,CAAC,CAAC;IACvC,IAAI,CAAC;QACH,SAAS,CAAC,UAAU,CAAC,CAAC;IACxB,CAAC;YAAS,CAAC;QACT,SAAS,CAAC,UAAU,CAAC,CAAC;IACxB,CAAC;AACH,CAAC,CAAC;AAEF;;;;;;GAMG;AACH,MAAM,CAAC,MAAM,oBAAoB,GAAG,CAAC,IAAY,EAAU,EAAE;IAC3D,IAAI,CAAC;QACH,IAAI,OAAO,CAAC,QAAQ,KAAK,OAAO,EAAE,CAAC;YACjC,kFAAkF;YAClF,IAAI,SAAS,CAAC,IAAI,CAAC,CAAC,cAAc,EAAE,EAAE,CAAC;gBACrC,MAAM,MAAM,CAAC,MAAM,CAAC,IAAI,KAAK,CAAC,8BAA8B,CAAC,EAAE,EAAE,IAAI,EAAE,OAAO,EAAE,CAAC,CAAC;YACpF,CAAC;YACD,OAAO,QAAQ,CAAC,IAAI,EAAE,SAAS,CAAC,QAAQ,CAAC,CAAC;QAC5C,CAAC;QACD,mEAAmE;QACnE,OAAO,QAAQ,CAAC,IAAI,EAAE,SAAS,CAAC,QAAQ,GAAG,SAAS,CAAC,UAAU,GAAG,SAAS,CAAC,UAAU,CAAC,CAAC;IAC1F,CAAC;IAAC,OAAO,KAAK,EAAE,CAAC;QACf,IAAI,eAAe,CAAC,KAAK,CAAC,EAAE,CAAC;YAC3B,MAAM,eAAe,CAAC,IAAI,EAAE,KAAK,CAAC,CAAC;QACrC,CAAC;QACD,MAAM,KAAK,CAAC;IACd,CAAC;AACH,CAAC,CAAC","sourcesContent":["import { closeSync, constants, fsyncSync, lstatSync, openSync, statSync } from 'node:fs';\n\nimport { isNoFollowError, isNotFoundError, notARegularFile } from '../../shared/index.js';\n\n/**\n * Reads the permission bits of an existing file.\n * @param path The file's path.\n * @returns The bits, or `undefined` when the file doesn't exist.\n */\nexport const readPermissionBits = (path: string): number | undefined => {\n try {\n return statSync(path).mode & 0o777;\n } catch (error) {\n if (isNotFoundError(error)) {\n return undefined;\n }\n throw error;\n }\n};\n\n/**\n * Flushes a directory's entries to disk, so a rename inside it survives a crash.\n * @param path The directory's path.\n */\nexport const syncDirectory = (path: string): void => {\n const descriptor = openSync(path, 'r');\n try {\n fsyncSync(descriptor);\n } finally {\n closeSync(descriptor);\n }\n};\n\n/**\n * Opens a file for reading without following a symbolic link in its last component.\n * @param path The file's path.\n * @returns The file descriptor.\n * @throws\n * - NOT_A_FILE: if the path doesn't exist or its final component is a symbolic link\n */\nexport const openWithoutFollowing = (path: string): number => {\n try {\n if (process.platform === 'win32') {\n // Windows has no O_NOFOLLOW, so a link swapped in after this check isn't detected\n if (lstatSync(path).isSymbolicLink()) {\n throw Object.assign(new Error('The path is a symbolic link.'), { code: 'ELOOP' });\n }\n return openSync(path, constants.O_RDONLY);\n }\n // O_NONBLOCK keeps a named pipe at the path from blocking the open\n return openSync(path, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);\n } catch (error) {\n if (isNoFollowError(error)) {\n throw notARegularFile(path, error);\n }\n throw error;\n }\n};\n"]}
1
+ {"version":3,"file":"utilities.js","sourceRoot":"","sources":["../../../src/sync/files/utilities.ts"],"names":[],"mappings":"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","sourcesContent":["import {\n closeSync,\n constants,\n fsyncSync,\n lstatSync,\n openSync,\n realpathSync,\n renameSync,\n statSync,\n} from 'node:fs';\n\nimport {\n isBusyRenameError,\n isNoFollowError,\n isNotFoundError,\n notARegularFile,\n RENAME_RETRY_DELAYS_MS,\n} from '../../shared/index.js';\n\n/**\n * Reads the permission bits of an existing file.\n * @param path The file's path.\n * @returns The bits, or `undefined` when the file doesn't exist.\n */\nexport const readPermissionBits = (path: string): number | undefined => {\n try {\n return statSync(path).mode & 0o777;\n } catch (error) {\n if (isNotFoundError(error)) {\n return undefined;\n }\n throw error;\n }\n};\n\n/**\n * Renames a file over another, trying again for about 4 seconds while Windows refuses because\n * another process, such as an antivirus scanner, holds the destination. It blocks between\n * attempts, as every sync function does.\n * @param from The file to rename.\n * @param to Its new path.\n */\nexport const renameReplacing = (from: string, to: string): void => {\n for (let attempt = 0; ; attempt += 1) {\n try {\n renameSync(from, to);\n return;\n } catch (error) {\n const delay = RENAME_RETRY_DELAYS_MS[attempt];\n if (delay === undefined || !isBusyRenameError(error)) {\n throw error;\n }\n Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, delay);\n }\n }\n};\n\n/**\n * Resolves the file a write replaces: the target of a symbolic link at the path, so the link keeps\n * pointing at the new content, or the path itself when nothing is there yet.\n * @param path The path.\n * @returns The path to write.\n */\nexport const resolveWriteTarget = (path: string): string => {\n try {\n return realpathSync(path);\n } catch (error) {\n if (isNotFoundError(error)) {\n return path;\n }\n throw error;\n }\n};\n\n/**\n * Flushes a directory's entries to disk, so a rename inside it survives a crash.\n * @param path The directory's path.\n */\nexport const syncDirectory = (path: string): void => {\n const descriptor = openSync(path, 'r');\n try {\n fsyncSync(descriptor);\n } finally {\n closeSync(descriptor);\n }\n};\n\n/**\n * Opens a file for reading without following a symbolic link in its last component.\n * @param path The file's path.\n * @returns The file descriptor.\n * @throws\n * - NOT_A_FILE: if the path doesn't exist or its final component is a symbolic link\n */\nexport const openWithoutFollowing = (path: string): number => {\n try {\n if (process.platform === 'win32') {\n // Windows has no O_NOFOLLOW, so a link swapped in after this check isn't detected\n if (lstatSync(path).isSymbolicLink()) {\n throw Object.assign(new Error('The path is a symbolic link.'), { code: 'ELOOP' });\n }\n return openSync(path, constants.O_RDONLY);\n }\n // O_NONBLOCK keeps a named pipe at the path from blocking the open\n return openSync(path, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);\n } catch (error) {\n if (isNoFollowError(error)) {\n throw notARegularFile(path, error);\n }\n throw error;\n }\n};\n"]}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "jg-fs",
3
- "version": "1.1.0",
3
+ "version": "1.2.0",
4
4
  "description": "Filesystem helpers for Node.js, async at the root and sync at jg-fs/sync: paths, directories, text, JSON, atomic and bounded files.",
5
5
  "author": "Jesus Graterol",
6
6
  "license": "MIT",
@@ -20,7 +20,8 @@
20
20
  "files": [
21
21
  "dist",
22
22
  "CHANGELOG.md",
23
- "API.md"
23
+ "API.md",
24
+ "RECIPES.md"
24
25
  ],
25
26
  "sideEffects": false,
26
27
  "engines": {
@@ -48,8 +49,8 @@
48
49
  "node"
49
50
  ],
50
51
  "dependencies": {
51
- "jg-exception": "^1.1.0",
52
- "jg-utilities": "^1.1.0"
52
+ "jg-exception": "^1.2.0",
53
+ "jg-utilities": "^1.1.1"
53
54
  },
54
55
  "devDependencies": {
55
56
  "@jg-packages/eslint-config": "0.0.0",