@procedures/fs 0.2.3 → 0.4.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.
Files changed (59) hide show
  1. package/CHANGELOG.md +66 -0
  2. package/README.md +76 -2
  3. package/dist/.tsbuildinfo +1 -1
  4. package/dist/handlers.d.ts +40 -0
  5. package/dist/handlers.d.ts.map +1 -0
  6. package/dist/handlers.js +163 -0
  7. package/dist/handlers.js.map +1 -0
  8. package/dist/index.d.ts +1 -0
  9. package/dist/index.d.ts.map +1 -1
  10. package/dist/index.js +2 -0
  11. package/dist/index.js.map +1 -1
  12. package/dist/module.d.ts +23 -2
  13. package/dist/module.d.ts.map +1 -1
  14. package/dist/module.js +95 -4
  15. package/dist/module.js.map +1 -1
  16. package/dist/procedures/fs/copy.d.ts +6 -1
  17. package/dist/procedures/fs/copy.d.ts.map +1 -1
  18. package/dist/procedures/fs/copy.js +28 -5
  19. package/dist/procedures/fs/copy.js.map +1 -1
  20. package/dist/procedures/fs/hash.d.ts +12 -0
  21. package/dist/procedures/fs/hash.d.ts.map +1 -0
  22. package/dist/procedures/fs/hash.js +23 -0
  23. package/dist/procedures/fs/hash.js.map +1 -0
  24. package/dist/procedures/fs/index.d.ts +4 -0
  25. package/dist/procedures/fs/index.d.ts.map +1 -1
  26. package/dist/procedures/fs/index.js +4 -0
  27. package/dist/procedures/fs/index.js.map +1 -1
  28. package/dist/procedures/fs/temp.d.ts +14 -0
  29. package/dist/procedures/fs/temp.d.ts.map +1 -0
  30. package/dist/procedures/fs/temp.js +20 -0
  31. package/dist/procedures/fs/temp.js.map +1 -0
  32. package/dist/procedures/fs/watch.d.ts +14 -0
  33. package/dist/procedures/fs/watch.d.ts.map +1 -0
  34. package/dist/procedures/fs/watch.js +26 -0
  35. package/dist/procedures/fs/watch.js.map +1 -0
  36. package/dist/procedures/fs/write-json.d.ts +12 -0
  37. package/dist/procedures/fs/write-json.d.ts.map +1 -0
  38. package/dist/procedures/fs/write-json.js +33 -0
  39. package/dist/procedures/fs/write-json.js.map +1 -0
  40. package/dist/root.d.ts +52 -0
  41. package/dist/root.d.ts.map +1 -0
  42. package/dist/root.js +129 -0
  43. package/dist/root.js.map +1 -0
  44. package/dist/types.d.ts +55 -0
  45. package/dist/types.d.ts.map +1 -1
  46. package/dist/types.js +43 -2
  47. package/dist/types.js.map +1 -1
  48. package/package.json +2 -2
  49. package/src/handlers.ts +214 -0
  50. package/src/index.ts +2 -0
  51. package/src/module.ts +128 -5
  52. package/src/procedures/fs/copy.ts +28 -5
  53. package/src/procedures/fs/hash.ts +25 -0
  54. package/src/procedures/fs/index.ts +4 -0
  55. package/src/procedures/fs/temp.ts +22 -0
  56. package/src/procedures/fs/watch.ts +26 -0
  57. package/src/procedures/fs/write-json.ts +32 -0
  58. package/src/root.ts +139 -0
  59. package/src/types.ts +112 -2
@@ -0,0 +1,214 @@
1
+ /**
2
+ * The handlers of the file procedures with a root (roadmap R33).
3
+ *
4
+ * `fsModule` has the plain handlers: they operate on each path. `createFsModule({ root })`
5
+ * replaces each handler with the handler of `rootedHandlers(root)`. That handler sends the paths
6
+ * of its input through `inRoot()`, then starts the plain handler with the absolute path.
7
+ */
8
+
9
+ import { platform } from "node:process";
10
+ import { isAbsolute, resolve } from "node:path";
11
+ import type { ProcedurePath } from "@procedures/client";
12
+ import { read } from "./procedures/fs/read.js";
13
+ import { write } from "./procedures/fs/write.js";
14
+ import { exists } from "./procedures/fs/exists.js";
15
+ import { mkdir } from "./procedures/fs/mkdir.js";
16
+ import { rm } from "./procedures/fs/rm.js";
17
+ import { readdir } from "./procedures/fs/readdir.js";
18
+ import { stat } from "./procedures/fs/stat.js";
19
+ import { copy } from "./procedures/fs/copy.js";
20
+ import { move } from "./procedures/fs/move.js";
21
+ import { glob } from "./procedures/fs/glob.js";
22
+ import { readJson } from "./procedures/fs/json.js";
23
+ import { fsWriteJson } from "./procedures/fs/write-json.js";
24
+ import { fsHash } from "./procedures/fs/hash.js";
25
+ import { fsTemp } from "./procedures/fs/temp.js";
26
+ import { fsWatch } from "./procedures/fs/watch.js";
27
+ import { inRoot, isOutsideRoot, outsideRoot, realPathIsInside, resolveRoot, type ResolvedRoot } from "./root.js";
28
+ import type {
29
+ ReadInput,
30
+ ReadOutput,
31
+ WriteInput,
32
+ WriteOutput,
33
+ ExistsInput,
34
+ ExistsOutput,
35
+ MkdirInput,
36
+ MkdirOutput,
37
+ RmInput,
38
+ RmOutput,
39
+ ReaddirInput,
40
+ ReaddirOutput,
41
+ StatInput,
42
+ StatOutput,
43
+ CopyInput,
44
+ CopyOutput,
45
+ MoveInput,
46
+ MoveOutput,
47
+ GlobInput,
48
+ GlobOutput,
49
+ ReadJsonInput,
50
+ ReadJsonOutput,
51
+ FsWriteJsonInput,
52
+ FsWriteJsonOutput,
53
+ FsHashInput,
54
+ FsHashOutput,
55
+ FsTempInput,
56
+ FsTempOutput,
57
+ FsWatchInput,
58
+ FsWatchOutput,
59
+ } from "./types.js";
60
+
61
+ /** One handler for each file procedure. */
62
+ export interface FsHandlers {
63
+ read: (input: ReadInput) => Promise<ReadOutput>;
64
+ write: (input: WriteInput) => Promise<WriteOutput>;
65
+ exists: (input: ExistsInput) => Promise<ExistsOutput>;
66
+ mkdir: (input: MkdirInput) => Promise<MkdirOutput>;
67
+ rm: (input: RmInput) => Promise<RmOutput>;
68
+ readdir: (input: ReaddirInput) => Promise<ReaddirOutput>;
69
+ stat: (input: StatInput) => Promise<StatOutput>;
70
+ copy: (input: CopyInput) => Promise<CopyOutput>;
71
+ move: (input: MoveInput) => Promise<MoveOutput>;
72
+ glob: (input: GlobInput) => Promise<GlobOutput>;
73
+ readJson: (input: ReadJsonInput) => Promise<ReadJsonOutput>;
74
+ writeJson: (input: FsWriteJsonInput) => Promise<FsWriteJsonOutput>;
75
+ hash: (input: FsHashInput) => Promise<FsHashOutput>;
76
+ temp: (input: FsTempInput) => Promise<FsTempOutput>;
77
+ watch: (input: FsWatchInput, ctx?: { signal?: AbortSignal | undefined }) => AsyncGenerator<FsWatchOutput, void, undefined>;
78
+ }
79
+
80
+ /**
81
+ * The name of the handler of each procedure, by the key of the procedure. `createFsModule()`
82
+ * refuses a procedure that is not in this list, so a new procedure cannot skip the root.
83
+ */
84
+ export const HANDLER_NAMES: Readonly<Record<string, keyof FsHandlers>> = {
85
+ "fs.read": "read",
86
+ "fs.write": "write",
87
+ "fs.exists": "exists",
88
+ "fs.mkdir": "mkdir",
89
+ "fs.rm": "rm",
90
+ "fs.readdir": "readdir",
91
+ "fs.stat": "stat",
92
+ "fs.copy": "copy",
93
+ "fs.move": "move",
94
+ "fs.glob": "glob",
95
+ "fs.read.json": "readJson",
96
+ "fs.write.json": "writeJson",
97
+ "fs.hash": "hash",
98
+ "fs.temp": "temp",
99
+ "fs.watch": "watch",
100
+ };
101
+
102
+ /**
103
+ * True for a glob pattern that can match outside its folder: an absolute pattern, a pattern that
104
+ * goes up with `..` (also in a brace or a group), or a pattern that starts with a drive on Windows.
105
+ * The handler also removes each match outside the root, so this check only gives a clear error.
106
+ */
107
+ function patternLeavesFolder(pattern: string): boolean {
108
+ if (isAbsolute(pattern) || pattern.startsWith("/") || pattern.startsWith("\\")) return true;
109
+ if (platform === "win32" && /^[a-zA-Z]:/.test(pattern)) return true;
110
+ return pattern.split(/[\\/{},()|]/).includes("..");
111
+ }
112
+
113
+ /**
114
+ * This function gives the handlers that confine each path to `root`. The root is resolved
115
+ * against the working folder of the process when the function starts. It must exist when a
116
+ * procedure starts.
117
+ */
118
+ export function rootedHandlers(root: string): FsHandlers {
119
+ const folder = resolve(root);
120
+ /** The root of one call (its real path can change between two calls), and the check of its paths. */
121
+ const check = async (procedure: string): Promise<{ root: ResolvedRoot; inside: (path: string) => Promise<string> }> => {
122
+ const resolved = await resolveRoot(folder);
123
+ const at: ProcedurePath = ["fs", procedure];
124
+ return { root: resolved, inside: (path) => inRoot(resolved, path, at) };
125
+ };
126
+
127
+ return {
128
+ read: async (input) => {
129
+ const { inside } = await check("read");
130
+ return read({ ...input, path: await inside(input.path) });
131
+ },
132
+ write: async (input) => {
133
+ const { inside } = await check("write");
134
+ return write({ ...input, path: await inside(input.path) });
135
+ },
136
+ exists: async (input) => {
137
+ const { inside } = await check("exists");
138
+ return exists({ ...input, path: await inside(input.path) });
139
+ },
140
+ mkdir: async (input) => {
141
+ const { inside } = await check("mkdir");
142
+ return mkdir({ ...input, path: await inside(input.path) });
143
+ },
144
+ rm: async (input) => {
145
+ const { inside } = await check("rm");
146
+ return rm({ ...input, path: await inside(input.path) });
147
+ },
148
+ stat: async (input) => {
149
+ const { inside } = await check("stat");
150
+ return stat({ ...input, path: await inside(input.path) });
151
+ },
152
+ readJson: async (input) => {
153
+ const { inside } = await check("read.json");
154
+ return readJson({ ...input, path: await inside(input.path) });
155
+ },
156
+ writeJson: async (input) => {
157
+ const { inside } = await check("write.json");
158
+ return fsWriteJson({ ...input, path: await inside(input.path) });
159
+ },
160
+ hash: async (input) => {
161
+ const { inside } = await check("hash");
162
+ return fsHash({ ...input, path: await inside(input.path) });
163
+ },
164
+ // The temporary folders of a root are in its folder .tmp, so they stay inside the root
165
+ temp: async (input) => {
166
+ const { inside } = await check("temp");
167
+ return fsTemp(input, await inside(".tmp"));
168
+ },
169
+ watch: async function* (input, ctx) {
170
+ const { inside } = await check("watch");
171
+ yield* fsWatch({ ...input, path: await inside(input.path) }, ctx?.signal);
172
+ },
173
+ copy: async (input) => {
174
+ const { inside } = await check("copy");
175
+ return copy({ ...input, src: await inside(input.src), dest: await inside(input.dest) });
176
+ },
177
+ move: async (input) => {
178
+ const { inside } = await check("move");
179
+ return move({ ...input, src: await inside(input.src), dest: await inside(input.dest) });
180
+ },
181
+ readdir: async (input) => {
182
+ const { inside } = await check("readdir");
183
+ // The stats of an entry follow a symlink: an entry whose target is outside the root gets no stats
184
+ const result = await readdir({ ...input, path: await inside(input.path), includeStats: false });
185
+ if (input.includeStats) {
186
+ for (const entry of result.entries) {
187
+ try {
188
+ await inside(entry.path);
189
+ } catch (error) {
190
+ if (isOutsideRoot(error)) continue;
191
+ throw error;
192
+ }
193
+ entry.stats = await stat({ path: entry.path });
194
+ }
195
+ }
196
+ return result;
197
+ },
198
+ glob: async (input) => {
199
+ const { root: resolved, inside } = await check("glob");
200
+ const cwd = await inside(input.cwd ?? ".");
201
+ if (patternLeavesFolder(input.pattern)) {
202
+ throw outsideRoot(["fs", "glob"], `the pattern ${JSON.stringify(input.pattern)} can match outside the folder ${cwd}`);
203
+ }
204
+ // A match through a symlink whose target is outside the root is left out
205
+ const found = await glob({ ...input, cwd, absolute: false });
206
+ const matches: string[] = [];
207
+ for (const match of found.matches) {
208
+ const absolute = resolve(cwd, match);
209
+ if (await realPathIsInside(resolved, absolute)) matches.push(input.absolute ? absolute : match);
210
+ }
211
+ return { pattern: input.pattern, matches };
212
+ },
213
+ };
214
+ }
package/src/index.ts CHANGED
@@ -15,3 +15,5 @@
15
15
 
16
16
  export * from "./procedures/fs.js";
17
17
  export * from "./module.js";
18
+ // The root that confines each path (roadmap R33)
19
+ export { OUTSIDE_ROOT, isInside, isOutsideRoot } from "./root.js";
package/src/module.ts CHANGED
@@ -5,9 +5,13 @@
5
5
  *
6
6
  * Registers fs.* procedures with the client system.
7
7
  * This file is referenced by package.json's client.procedures field.
8
+ *
9
+ * `fsModule` operates on each path. `createFsModule({ root })` gives the same procedures, and
10
+ * each path of their input must be in the root (roadmap R33, `root.ts`).
8
11
  */
9
12
 
10
- import { createProcedure, zodAdapter, defineModule, type Effects, type ProcedureModule, PROCEDURE_REGISTRY, registerModules, type ProcedureRegistry } from "@procedures/client";
13
+ import { createProcedure, zodAdapter, defineModule, type AnyProcedure, type Effects, type ProcedureModule, PROCEDURE_REGISTRY, registerModules, type ProcedureRegistry } from "@procedures/client";
14
+ import { HANDLER_NAMES, rootedHandlers } from "./handlers.js";
11
15
  import { read } from "./procedures/fs/read.js";
12
16
  import { write } from "./procedures/fs/write.js";
13
17
  import { exists } from "./procedures/fs/exists.js";
@@ -65,6 +69,28 @@ import {
65
69
  GlobOutputSchema,
66
70
  ReadJsonOutputSchema,
67
71
  } from "./types.js";
72
+ import { fsWriteJson } from "./procedures/fs/write-json.js";
73
+ import { fsHash } from "./procedures/fs/hash.js";
74
+ import { fsTemp } from "./procedures/fs/temp.js";
75
+ import { fsWatch } from "./procedures/fs/watch.js";
76
+ import {
77
+ FsWriteJsonInputSchema,
78
+ FsWriteJsonOutputSchema,
79
+ FsHashInputSchema,
80
+ FsHashOutputSchema,
81
+ FsTempInputSchema,
82
+ FsTempOutputSchema,
83
+ FsWatchInputSchema,
84
+ FsWatchOutputSchema,
85
+ type FsWriteJsonInput,
86
+ type FsWriteJsonOutput,
87
+ type FsHashInput,
88
+ type FsHashOutput,
89
+ type FsTempInput,
90
+ type FsTempOutput,
91
+ type FsWatchInput,
92
+ type FsWatchOutput,
93
+ } from "./types.js";
68
94
 
69
95
  // =============================================================================
70
96
  // Procedure Definitions
@@ -178,7 +204,7 @@ const fsCopyProcedure = createProcedure()
178
204
  .input(zodAdapter<CopyInput>(CopyInputSchema))
179
205
  .output(zodAdapter<CopyOutput>(CopyOutputSchema))
180
206
  .meta({
181
- description: "Copy file or directory",
207
+ description: "Copy file or directory. Without overwrite, it refuses an existing destination (EEXIST).",
182
208
  args: ["src", "dest"],
183
209
  shorts: { recursive: "r", overwrite: "o" },
184
210
  output: "json",
@@ -231,6 +257,68 @@ const fsReadJsonProcedure = createProcedure()
231
257
  .handler(readJson)
232
258
  .build();
233
259
 
260
+ // fs.write.json, fs.hash, fs.temp and fs.watch (roadmap R44)
261
+
262
+ const fsWriteJsonProcedure = createProcedure()
263
+ .path(["fs", "write.json"])
264
+ .input(zodAdapter<FsWriteJsonInput>(FsWriteJsonInputSchema))
265
+ .output(zodAdapter<FsWriteJsonOutput>(FsWriteJsonOutputSchema))
266
+ .meta({
267
+ description: "Write a value as a JSON file, with a rename at the end",
268
+ args: ["path"],
269
+ shorts: {},
270
+ output: "json",
271
+ // It replaces the file: destructive, and the same input gives the same file
272
+ effects: { reads: [], writes: ["fs"], destructive: true, idempotent: true },
273
+ })
274
+ .handler(fsWriteJson)
275
+ .build();
276
+
277
+ const fsHashProcedure = createProcedure()
278
+ .path(["fs", "hash"])
279
+ .input(zodAdapter<FsHashInput>(FsHashInputSchema))
280
+ .output(zodAdapter<FsHashOutput>(FsHashOutputSchema))
281
+ .meta({
282
+ description: "The hash of a file",
283
+ args: ["path"],
284
+ shorts: { algorithm: "a" },
285
+ output: "json",
286
+ effects: READS_FILES,
287
+ })
288
+ .handler((input: FsHashInput) => fsHash(input))
289
+ .build();
290
+
291
+ const fsTempProcedure = createProcedure()
292
+ .path(["fs", "temp"])
293
+ .input(zodAdapter<FsTempInput>(FsTempInputSchema))
294
+ .output(zodAdapter<FsTempOutput>(FsTempOutputSchema))
295
+ .meta({
296
+ description: "Make a temporary folder",
297
+ args: [],
298
+ shorts: {},
299
+ output: "json",
300
+ // It makes a new folder each time, and changes nothing that exists
301
+ effects: { reads: [], writes: ["fs"], destructive: false, idempotent: false },
302
+ })
303
+ .handler((input: FsTempInput) => fsTemp(input))
304
+ .build();
305
+
306
+ const fsWatchProcedure = createProcedure()
307
+ .path(["fs", "watch"])
308
+ .input(zodAdapter<FsWatchInput>(FsWatchInputSchema))
309
+ .output(zodAdapter<FsWatchOutput>(FsWatchOutputSchema))
310
+ .meta({
311
+ description: "The changes of a file or folder, as a stream",
312
+ args: ["path"],
313
+ shorts: { recursive: "r" },
314
+ output: "streaming",
315
+ effects: READS_FILES,
316
+ // The stream has no end: it stops when the reader stops (roadmap R15)
317
+ stream: { unbounded: true },
318
+ })
319
+ .handler(((input: FsWatchInput, ctx?: { signal?: AbortSignal }) => fsWatch(input, ctx?.signal)) as never)
320
+ .build();
321
+
234
322
  // =============================================================================
235
323
  // Registration
236
324
  // =============================================================================
@@ -251,15 +339,50 @@ export const fsModule: ProcedureModule = defineModule("@procedures/fs", [
251
339
  fsMoveProcedure,
252
340
  fsGlobProcedure,
253
341
  fsReadJsonProcedure,
342
+ fsWriteJsonProcedure,
343
+ fsHashProcedure,
344
+ fsTempProcedure,
345
+ fsWatchProcedure,
254
346
  ]);
255
347
 
256
348
  export default fsModule;
257
349
 
350
+ /** The options of `createFsModule()`. */
351
+ export interface FsModuleOptions {
352
+ /**
353
+ * The folder that confines each path of the procedures (roadmap R33). A relative path of an
354
+ * input starts at the root. A path outside the root gives `FORBIDDEN` with the reason
355
+ * `outside-root`. A relative root starts at the working folder of the process. Without a root,
356
+ * the procedures operate on each path, as `fsModule` does.
357
+ */
358
+ root?: string | undefined;
359
+ }
360
+
361
+ /**
362
+ * This function gives the module of this package. Without `root`, it gives `fsModule`. With
363
+ * `root`, it gives a module with the same name and procedures, whose handlers confine each path
364
+ * of the input to the root. A host gives it to `createRegistry()` before its bundles, so it
365
+ * replaces the `fsModule` that the bundles require (the first module of a name counts).
366
+ */
367
+ export function createFsModule(options: FsModuleOptions = {}): ProcedureModule {
368
+ if (options.root === undefined) return fsModule;
369
+ const handlers = rootedHandlers(options.root);
370
+ const procedures = fsModule.procedures.map((procedure): AnyProcedure => {
371
+ const key = procedure.path.join(".");
372
+ const name = HANDLER_NAMES[key];
373
+ // A procedure without a rooted handler would operate outside the root
374
+ if (name === undefined) throw new Error(`The root of @procedures/fs has no handler for ${key}: add it to rootedHandlers()`);
375
+ return { ...procedure, handler: handlers[name] as AnyProcedure["handler"] };
376
+ });
377
+ return defineModule("@procedures/fs", procedures);
378
+ }
379
+
258
380
  /**
259
381
  * This function registers the procedures of @procedures/fs, and of the modules that it requires, in
260
382
  * a registry. The default is the global registry. Importing this file registers nothing: the
261
- * `./register` entry starts this function (ARCHITECTURE-PROPOSALS-2026-10, P9).
383
+ * `./register` entry starts this function (ARCHITECTURE-PROPOSALS-2026-10, P9). With
384
+ * `options.root`, each path of an input must be in the root.
262
385
  */
263
- export function registerFsProcedures(registry: ProcedureRegistry = PROCEDURE_REGISTRY): void {
264
- registerModules(registry, [fsModule]);
386
+ export function registerFsProcedures(registry: ProcedureRegistry = PROCEDURE_REGISTRY, options: FsModuleOptions = {}): void {
387
+ registerModules(registry, [createFsModule(options)]);
265
388
  }
@@ -4,20 +4,43 @@
4
4
  * Copy file or directory
5
5
  */
6
6
 
7
- import { copyFile, cp, constants } from "node:fs/promises";
7
+ import { copyFile, cp, constants, lstat } from "node:fs/promises";
8
8
  import type { CopyInput, CopyOutput } from "../../types.js";
9
9
 
10
+ /** This function gives true when something exists at the path: a file, a folder or a link. */
11
+ async function exists(path: string): Promise<boolean> {
12
+ try {
13
+ await lstat(path);
14
+ return true;
15
+ } catch (error) {
16
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return false;
17
+ throw error;
18
+ }
19
+ }
20
+
10
21
  /**
11
- * Copy file or directory
22
+ * Copy file or directory.
23
+ *
24
+ * Without `overwrite`, an existing destination is an error with the code `EEXIST`, and the copy
25
+ * changes nothing. Before, the default copy (`recursive: true`) kept an existing file and
26
+ * reported success (roadmap R33). With `overwrite`, the copy replaces the files at the
27
+ * destination.
12
28
  */
13
29
  export async function copy(input: CopyInput): Promise<CopyOutput> {
14
30
  const { src, dest, recursive, overwrite } = input;
15
- const flags = overwrite ? 0 : constants.COPYFILE_EXCL;
31
+
32
+ if (!overwrite && (await exists(dest))) {
33
+ throw Object.assign(new Error(`Destination already exists: ${dest}. Set overwrite to replace it.`), {
34
+ code: "EEXIST",
35
+ path: dest,
36
+ });
37
+ }
16
38
 
17
39
  if (recursive) {
18
- await cp(src, dest, { recursive, force: overwrite });
40
+ // errorOnExist: a file that appears at the destination after the check is an error too
41
+ await cp(src, dest, { recursive, force: overwrite, errorOnExist: !overwrite });
19
42
  } else {
20
- await copyFile(src, dest, flags);
43
+ await copyFile(src, dest, overwrite ? 0 : constants.COPYFILE_EXCL);
21
44
  }
22
45
 
23
46
  return { src, dest };
@@ -0,0 +1,25 @@
1
+ /**
2
+ * fs.hash procedure (roadmap R44)
3
+ *
4
+ * The hash of a file. The procedure reads the file as a stream, so a large file does not fill
5
+ * the memory.
6
+ */
7
+
8
+ import { createHash } from "node:crypto";
9
+ import { createReadStream } from "node:fs";
10
+ import type { FsHashInput, FsHashOutput } from "../../types.js";
11
+
12
+ /**
13
+ * The hash of a file
14
+ */
15
+ export async function fsHash(input: FsHashInput): Promise<FsHashOutput> {
16
+ const algorithm = input.algorithm ?? "sha256";
17
+ const hash = createHash(algorithm);
18
+ let size = 0;
19
+ for await (const chunk of createReadStream(input.path)) {
20
+ const bytes = chunk as Buffer;
21
+ size += bytes.length;
22
+ hash.update(bytes);
23
+ }
24
+ return { path: input.path, algorithm, hash: hash.digest("hex"), size };
25
+ }
@@ -9,3 +9,7 @@ export { copy } from "./copy.js";
9
9
  export { move } from "./move.js";
10
10
  export { glob } from "./glob.js";
11
11
  export { readJson } from "./json.js";
12
+ export { fsWriteJson } from "./write-json.js";
13
+ export { fsHash } from "./hash.js";
14
+ export { fsTemp } from "./temp.js";
15
+ export { fsWatch } from "./watch.js";
@@ -0,0 +1,22 @@
1
+ /**
2
+ * fs.temp procedure (roadmap R44)
3
+ *
4
+ * Make a temporary folder with a name that no other folder has. Without a root, the folder is in
5
+ * the temporary folder of the system. With the root of `createFsModule({ root })`, it is in the
6
+ * folder `.tmp` of the root, so it stays inside the root.
7
+ */
8
+
9
+ import { mkdir, mkdtemp } from "node:fs/promises";
10
+ import { tmpdir } from "node:os";
11
+ import { join } from "node:path";
12
+ import type { FsTempInput, FsTempOutput } from "../../types.js";
13
+
14
+ /**
15
+ * Make a temporary folder. `base` is the folder that holds it (default: the temporary folder of
16
+ * the system).
17
+ */
18
+ export async function fsTemp(input: FsTempInput, base: string = tmpdir()): Promise<FsTempOutput> {
19
+ await mkdir(base, { recursive: true });
20
+ const path = await mkdtemp(join(base, input.prefix ?? "procedures-"));
21
+ return { path };
22
+ }
@@ -0,0 +1,26 @@
1
+ /**
2
+ * fs.watch procedure (roadmap R44)
3
+ *
4
+ * The changes of a file or folder, as a stream: one item for each change that the platform
5
+ * reports. The stream has no end: it stops when the reader stops or the signal of the call
6
+ * aborts. The platform decides the details: a change can give two items, and some platforms
7
+ * give no file name.
8
+ */
9
+
10
+ import { watch } from "node:fs/promises";
11
+ import type { FsWatchInput, FsWatchOutput } from "../../types.js";
12
+
13
+ /**
14
+ * The changes of a file or folder, as a stream
15
+ */
16
+ export async function* fsWatch(input: FsWatchInput, signal?: AbortSignal): AsyncGenerator<FsWatchOutput, void, undefined> {
17
+ try {
18
+ for await (const change of watch(input.path, { recursive: input.recursive ?? false, ...(signal ? { signal } : {}) })) {
19
+ yield { event: change.eventType === "change" ? "change" : "rename", filename: change.filename ?? null };
20
+ }
21
+ } catch (error) {
22
+ // The abort of the call ends the stream without an error
23
+ if ((error as { name?: string }).name === "AbortError") return;
24
+ throw error;
25
+ }
26
+ }
@@ -0,0 +1,32 @@
1
+ /**
2
+ * fs.write.json procedure (roadmap R44)
3
+ *
4
+ * Write a value as a JSON file. The procedure writes a file next to the target, then renames it,
5
+ * so a reader sees the old file or the whole new file, never a half-written one.
6
+ */
7
+
8
+ import { randomBytes } from "node:crypto";
9
+ import { rename, rm, writeFile } from "node:fs/promises";
10
+ import type { FsWriteJsonInput, FsWriteJsonOutput } from "../../types.js";
11
+
12
+ /**
13
+ * Write a value as a JSON file, with a rename at the end
14
+ */
15
+ export async function fsWriteJson(input: FsWriteJsonInput): Promise<FsWriteJsonOutput> {
16
+ const text = JSON.stringify(input.value ?? null, null, input.indent ?? 2);
17
+ if (text === undefined) throw new Error("fs.write.json: the value has no JSON form");
18
+ const content = `${text}\n`;
19
+ const temporary = `${input.path}.${randomBytes(6).toString("hex")}.tmp`;
20
+ try {
21
+ await writeFile(temporary, content, "utf8");
22
+ await rename(temporary, input.path);
23
+ } catch (error) {
24
+ try {
25
+ await rm(temporary, { force: true });
26
+ } catch {
27
+ // The error of the write counts, not the error of the cleanup
28
+ }
29
+ throw error;
30
+ }
31
+ return { path: input.path, bytesWritten: Buffer.byteLength(content, "utf8") };
32
+ }