jg-fs 1.1.0 → 1.1.1
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 +1 -1
- package/CHANGELOG.md +6 -0
- package/README.md +2 -0
- package/RECIPES.md +125 -0
- package/package.json +5 -4
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.
|
|
3
|
+
Every export of `jg-fs@1.1.1`, 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
|
|
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 1.1.1
|
|
4
|
+
|
|
5
|
+
### Added
|
|
6
|
+
|
|
7
|
+
- `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.
|
|
8
|
+
|
|
3
9
|
## 1.1.0
|
|
4
10
|
|
|
5
11
|
A fresh start: breaking changes in a minor version, once, because nothing depended on 1.0.x.
|
package/README.md
CHANGED
|
@@ -54,6 +54,8 @@ Failures throw `Exception`s from `jg-exception`, with the codes in `ERRORS`.
|
|
|
54
54
|
|
|
55
55
|
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
56
|
|
|
57
|
+
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/.
|
|
58
|
+
|
|
57
59
|
## License
|
|
58
60
|
|
|
59
61
|
[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. `writeFileAtomic` 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, writeFileAtomic } 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 writeFileAtomic(CONFIG_PATH, `${JSON.stringify(config, undefined, 2)}\n`);
|
|
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
|
+
`writeJsonFile` is a plain write, so a file that must survive a crash goes through `writeFileAtomic` with `JSON.stringify` instead. 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.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "jg-fs",
|
|
3
|
-
"version": "1.1.
|
|
3
|
+
"version": "1.1.1",
|
|
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.
|
|
52
|
-
"jg-utilities": "^1.1.
|
|
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",
|