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 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.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.0",
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.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",