1/**
2 * Shared Node-compatible implementation of Effect's `FileSystem` service.
3 *
4 * This module adapts Node's `node:fs`, `node:os`, and `node:path` APIs into a
5 * `FileSystem` layer for Effect programs running on Node-compatible runtimes.
6 * Platform packages use it to provide file and directory I/O, permissions,
7 * links, metadata, temporary files and directories, and file watching through
8 * the shared `FileSystem` service.
9 *
10 * @since 4.0.0
11 */
12import * as BI from "effect/BigInt"
13import * as ByteSize from "effect/ByteSize"
14import * as Cause from "effect/Cause"
15import * as Effect from "effect/Effect"
16import { effectify } from "effect/Effect"
17import * as FileSystem from "effect/FileSystem"
18import { pipe } from "effect/Function"
19import * as Layer from "effect/Layer"
20import * as Option from "effect/Option"
21import * as Error from "effect/PlatformError"
22import * as Queue from "effect/Queue"
23import * as Stream from "effect/Stream"
24import * as Crypto from "node:crypto"
25import * as NFS from "node:fs"
26import * as OS from "node:os"
27import * as Path from "node:path"
28import { handleErrnoException } from "./internal/utils.ts"
29
30const handleBadArgument = (method: string) => (err: unknown) =>
31 Error.badArgument({
32 module: "FileSystem",
33 method,
34 description: (err as Error).message ?? String(err)
35 })
36
37const bigintToNumber = (value: bigint, field: string): number => {
38 const number = Number(value)
39 if (!Number.isSafeInteger(number)) {
40 throw new RangeError(`${field} exceeds the safe integer range: ${value}`)
41 }
42 return number
43}
44
45const bigintToNumberOption = (value: bigint | undefined): Option.Option<number> =>
46 Option.flatMap(Option.fromNullishOr(value), BI.toNumber)
47
48// fs.write ignores bigint positions.
49const positionToNumber = (position: bigint, method: string) =>
50 Effect.try({
51 try: () => bigintToNumber(position, "position"),
52 catch: handleBadArgument(method)
53 })
54
55// == access
56
57const access = ((): FileSystem.FileSystem["access"] => {
58 const nodeAccess = effectify(
59 NFS.access,
60 handleErrnoException("FileSystem", "access"),
61 handleBadArgument("access")
62 )
63 return (path, options) => {
64 let mode = NFS.constants.F_OK
65 if (options?.readable) {
66 mode |= NFS.constants.R_OK
67 }
68 if (options?.writable) {
69 mode |= NFS.constants.W_OK
70 }
71 return nodeAccess(path, mode)
72 }
73})()
74
75// == copy
76
77const copy = ((): FileSystem.FileSystem["copy"] => {
78 const nodeCp = effectify(
79 NFS.cp,
80 handleErrnoException("FileSystem", "copy"),
81 handleBadArgument("copy")
82 )
83 return (fromPath, toPath, options) =>
84 nodeCp(fromPath, toPath, {
85 force: options?.overwrite ?? false,
86 preserveTimestamps: options?.preserveTimestamps ?? false,
87 recursive: true
88 })
89})()
90
91// == copyFile
92
93const copyFile = (() => {
94 const nodeCopyFile = effectify(
95 NFS.copyFile,
96 handleErrnoException("FileSystem", "copyFile"),
97 handleBadArgument("copyFile")
98 )
99 return (fromPath: string, toPath: string) => nodeCopyFile(fromPath, toPath)
100})()
101
102// == chmod
103
104const chmod = (() => {
105 const nodeChmod = effectify(
106 NFS.chmod,
107 handleErrnoException("FileSystem", "chmod"),
108 handleBadArgument("chmod")
109 )
110 return (path: string, mode: number) => nodeChmod(path, mode)
111})()
112
113// == chown
114
115const chown = (() => {
116 const nodeChown = effectify(
117 NFS.chown,
118 handleErrnoException("FileSystem", "chown"),
119 handleBadArgument("chown")
120 )
121 return (path: string, uid: number, gid: number) => nodeChown(path, uid, gid)
122})()
123
124// == glob
125
126const glob = ((): FileSystem.FileSystem["glob"] => {
127 const nodeGlob = effectify(
128 NFS.glob,
129 handleErrnoException("FileSystem", "glob"),
130 handleBadArgument("glob")
131 )
132 return (pattern: string, options) =>
133 nodeGlob(pattern, {
134 cwd: options?.root,
135 exclude: options?.exclude
136 })
137})()
138
139// == link
140
141const link = (() => {
142 const nodeLink = effectify(
143 NFS.link,
144 handleErrnoException("FileSystem", "link"),
145 handleBadArgument("link")
146 )
147 return (existingPath: string, newPath: string) => nodeLink(existingPath, newPath)
148})()
149
150// == makeDirectory
151
152const makeDirectory = ((): FileSystem.FileSystem["makeDirectory"] => {
153 const nodeMkdir = effectify(
154 NFS.mkdir,
155 handleErrnoException("FileSystem", "makeDirectory"),
156 handleBadArgument("makeDirectory")
157 )
158 return (path, options) =>
159 nodeMkdir(path, {
160 recursive: options?.recursive ?? false,
161 mode: options?.mode
162 })
163})()
164
165// == makeTempDirectory
166
167const makeTempDirectoryFactory = (method: string): FileSystem.FileSystem["makeTempDirectory"] => {
168 const nodeMkdtemp = effectify(
169 NFS.mkdtemp,
170 handleErrnoException("FileSystem", method),
171 handleBadArgument(method)
172 )
173 return (options) =>
174 Effect.suspend(() => {
175 const prefix = options?.prefix ?? ""
176 const directory = typeof options?.directory === "string"
177 ? Path.join(options.directory, ".")
178 : OS.tmpdir()
179
180 return nodeMkdtemp(prefix ? Path.join(directory, prefix) : directory + "/")
181 })
182}
183const makeTempDirectory = makeTempDirectoryFactory("makeTempDirectory")
184
185// == remove
186
187const removeFactory = (method: string): FileSystem.FileSystem["remove"] => {
188 const nodeRm = effectify(
189 NFS.rm,
190 handleErrnoException("FileSystem", method),
191 handleBadArgument(method)
192 )
193 return (path, options) =>
194 nodeRm(
195 path,
196 { recursive: options?.recursive ?? false, force: options?.force ?? false }
197 )
198}
199const remove = removeFactory("remove")
200
201// == makeTempDirectoryScoped
202
203const makeTempDirectoryScoped = ((): FileSystem.FileSystem["makeTempDirectoryScoped"] => {
204 const makeDirectory = makeTempDirectoryFactory("makeTempDirectoryScoped")
205 const removeDirectory = removeFactory("makeTempDirectoryScoped")
206 return (options) =>
207 Effect.acquireRelease(
208 makeDirectory(options),
209 (directory) => Effect.orDie(removeDirectory(directory, { recursive: true }))
210 )
211})()
212
213// == open
214
215const openFactory = (method: string): FileSystem.FileSystem["open"] => {
216 const nodeOpen = effectify(
217 NFS.open,
218 handleErrnoException("FileSystem", method),
219 handleBadArgument(method)
220 )
221 const nodeClose = effectify(
222 NFS.close,
223 handleErrnoException("FileSystem", method),
224 handleBadArgument(method)
225 )
226
227 return (path, options) =>
228 pipe(
229 Effect.acquireRelease(
230 nodeOpen(path, options?.flag ?? "r", options?.mode),
231 (fd) => Effect.orDie(nodeClose(fd))
232 ),
233 Effect.map((fd) => makeFile(fd, options?.flag?.startsWith("a") ?? false))
234 )
235}
236const open = openFactory("open")
237
238const makeFile = (() => {
239 const nodeReadFactory = (method: string) =>
240 effectify(
241 NFS.read,
242 handleErrnoException("FileSystem", method),
243 handleBadArgument(method)
244 )
245 const nodeRead = nodeReadFactory("read")
246 const nodeReadAlloc = nodeReadFactory("readAlloc")
247 const nodeStat = effectify(
248 NFS.fstat,
249 handleErrnoException("FileSystem", "stat"),
250 handleBadArgument("stat")
251 )
252 const nodeTruncate = effectify(
253 NFS.ftruncate,
254 handleErrnoException("FileSystem", "truncate"),
255 handleBadArgument("truncate")
256 )
257
258 const nodeSync = effectify(
259 NFS.fsync,
260 handleErrnoException("FileSystem", "sync"),
261 handleBadArgument("sync")
262 )
263
264 const nodeWriteFactory = (method: string) =>
265 effectify(
266 NFS.write,
267 handleErrnoException("FileSystem", method),
268 handleBadArgument(method)
269 )
270 const nodeWrite = nodeWriteFactory("write")
271 const nodeWriteAll = nodeWriteFactory("writeAll")
272
273 class FileImpl implements FileSystem.File {
274 readonly [FileSystem.FileTypeId]: typeof FileSystem.FileTypeId
275 readonly fd: number
276 private readonly append: boolean
277
278 private position: bigint = BigInt(0)
279
280 constructor(
281 fd: number,
282 append: boolean
283 ) {
284 this[FileSystem.FileTypeId] = FileSystem.FileTypeId
285 this.fd = fd
286 this.append = append
287 }
288
289 get stat() {
290 return Effect.flatMap(nodeStat(this.fd, { bigint: true }), makeFileInfo)
291 }
292
293 get sync() {
294 return nodeSync(this.fd)
295 }
296
297 seek(offset: bigint, from: FileSystem.SeekMode) {
298 return Effect.suspend(() => {
299 const position = from === "start" ? offset : this.position + offset
300 if (position < BigInt(0)) {
301 return Effect.fail(Error.badArgument({
302 module: "FileSystem",
303 method: "seek",
304 description: "Cannot seek before the start of the file"
305 }))
306 }
307 this.position = position
308 return Effect.succeed(position)
309 })
310 }
311
312 read(buffer: Uint8Array) {
313 return Effect.suspend(() => {
314 const position = this.position
315 return Effect.map(
316 nodeRead(this.fd, { buffer, position }),
317 (bytesRead) => {
318 this.position = position + BigInt(bytesRead)
319 return bytesRead
320 }
321 )
322 })
323 }
324
325 readAlloc(size: number) {
326 return Effect.suspend(() => {
327 try {
328 if (!Number.isInteger(size) || size < 0) {
329 throw new RangeError("size must be a non-negative integer")
330 }
331 const buffer = Buffer.allocUnsafeSlow(size)
332 const position = this.position
333 return Effect.map(
334 nodeReadAlloc(this.fd, { buffer, position }),
335 (bytesRead): Option.Option<Buffer> => {
336 if (bytesRead === 0) {
337 return Option.none()
338 }
339
340 this.position = position + BigInt(bytesRead)
341 if (bytesRead === size) {
342 return Option.some(buffer)
343 }
344
345 const dst = Buffer.allocUnsafeSlow(bytesRead)
346 buffer.copy(dst, 0, 0, bytesRead)
347 return Option.some(dst)
348 }
349 )
350 } catch (cause) {
351 return Effect.fail(handleBadArgument("readAlloc")(cause))
352 }
353 })
354 }
355
356 truncate(length?: number) {
357 return Effect.map(nodeTruncate(this.fd, length || undefined), () => {
358 if (!this.append) {
359 const len = BigInt(length ?? 0)
360 if (this.position > len) {
361 this.position = len
362 }
363 }
364 })
365 }
366
367 write(buffer: Uint8Array) {
368 return Effect.suspend(() => {
369 const position = this.position
370 return Effect.flatMap(
371 this.append ? Effect.succeed(undefined) : positionToNumber(position, "write"),
372 (nodePosition) =>
373 Effect.map(
374 nodeWrite(this.fd, buffer, undefined, undefined, nodePosition),
375 (bytesWritten) => {
376 if (!this.append) {
377 this.position = position + BigInt(bytesWritten)
378 }
379 return bytesWritten
380 }
381 )
382 )
383 })
384 }
385
386 private writeAllChunk(buffer: Uint8Array): Effect.Effect<void, Error.PlatformError> {
387 return Effect.suspend(() => {
388 const position = this.position
389 return Effect.flatMap(
390 this.append ? Effect.succeed(undefined) : positionToNumber(position, "writeAll"),
391 (nodePosition) =>
392 Effect.flatMap(
393 nodeWriteAll(this.fd, buffer, undefined, undefined, nodePosition),
394 (bytesWritten) => {
395 if (bytesWritten === 0) {
396 return Effect.fail(
397 Error.systemError({
398 module: "FileSystem",
399 method: "writeAll",
400 _tag: "WriteZero",
401 pathOrDescriptor: this.fd,
402 description: "write returned 0 bytes written"
403 })
404 )
405 }
406
407 if (!this.append) {
408 this.position = position + BigInt(bytesWritten)
409 }
410
411 return bytesWritten < buffer.length ? this.writeAllChunk(buffer.subarray(bytesWritten)) : Effect.void
412 }
413 )
414 )
415 })
416 }
417
418 writeAll(buffer: Uint8Array) {
419 return buffer.length === 0 ? Effect.void : this.writeAllChunk(buffer)
420 }
421 }
422
423 return (fd: number, append: boolean): FileSystem.File => new FileImpl(fd, append)
424})()
425
426// == makeTempFile
427
428const makeTempFileFactory = (method: string): FileSystem.FileSystem["makeTempFile"] => {
429 const makeDirectory = makeTempDirectoryFactory(method)
430 return Effect.fnUntraced(function*(options) {
431 const directory = yield* makeDirectory(options)
432 const random = Crypto.randomBytes(6).toString("hex")
433 const name = Path.join(directory, options?.suffix ? `${random}${options.suffix}` : random)
434 yield* writeFile(name, new Uint8Array(0))
435 return name
436 })
437}
438const makeTempFile = makeTempFileFactory("makeTempFile")
439
440// == makeTempFileScoped
441
442const makeTempFileScoped = ((): FileSystem.FileSystem["makeTempFileScoped"] => {
443 const makeFile = makeTempFileFactory("makeTempFileScoped")
444 const removeDirectory = removeFactory("makeTempFileScoped")
445 return (options) =>
446 Effect.acquireRelease(
447 makeFile(options),
448 (file) => Effect.orDie(removeDirectory(Path.dirname(file), { recursive: true }))
449 )
450})()
451
452// == readDirectory
453
454const readDirectory: FileSystem.FileSystem["readDirectory"] = (path, options) =>
455 Effect.tryPromise({
456 try: () => NFS.promises.readdir(path, options),
457 catch: (err) => handleErrnoException("FileSystem", "readDirectory")(err as any, [path])
458 })
459
460// == readFile
461
462const readFile = (path: string) =>
463 Effect.callback<Uint8Array, Error.PlatformError>((resume, signal) => {
464 try {
465 NFS.readFile(path, { signal }, (err, data) => {
466 if (err) {
467 resume(Effect.fail(handleErrnoException("FileSystem", "readFile")(err, [path])))
468 } else {
469 resume(Effect.succeed(data))
470 }
471 })
472 } catch (err) {
473 resume(Effect.fail(handleBadArgument("readFile")(err)))
474 }
475 })
476
477// == readLink
478
479const readLink = (() => {
480 const nodeReadLink = effectify(
481 NFS.readlink,
482 handleErrnoException("FileSystem", "readLink"),
483 handleBadArgument("readLink")
484 )
485 return (path: string) => nodeReadLink(path)
486})()
487
488// == realPath
489
490const realPath = (() => {
491 const nodeRealPath = effectify(
492 NFS.realpath,
493 handleErrnoException("FileSystem", "realPath"),
494 handleBadArgument("realPath")
495 )
496 return (path: string) => nodeRealPath(path)
497})()
498
499// == rename
500
501const rename = (() => {
502 const nodeRename = effectify(
503 NFS.rename,
504 handleErrnoException("FileSystem", "rename"),
505 handleBadArgument("rename")
506 )
507 return (oldPath: string, newPath: string) => nodeRename(oldPath, newPath)
508})()
509
510// == stat
511
512const makeFileInfo = (stat: NFS.BigIntStats): Effect.Effect<FileSystem.File.Info, Error.PlatformError> =>
513 Effect.try({
514 try: (): FileSystem.File.Info => ({
515 type: stat.isFile() ?
516 "File" :
517 stat.isDirectory() ?
518 "Directory" :
519 stat.isSymbolicLink() ?
520 "SymbolicLink" :
521 stat.isBlockDevice() ?
522 "BlockDevice" :
523 stat.isCharacterDevice() ?
524 "CharacterDevice" :
525 stat.isFIFO() ?
526 "FIFO" :
527 stat.isSocket() ?
528 "Socket" :
529 "Unknown",
530 mtime: Option.fromNullishOr(stat.mtime),
531 atime: Option.fromNullishOr(stat.atime),
532 birthtime: Option.fromNullishOr(stat.birthtime),
533 dev: bigintToNumber(stat.dev, "dev"),
534 rdev: bigintToNumberOption(stat.rdev),
535 ino: bigintToNumberOption(stat.ino),
536 mode: bigintToNumber(stat.mode, "mode"),
537 nlink: bigintToNumberOption(stat.nlink),
538 uid: bigintToNumberOption(stat.uid),
539 gid: bigintToNumberOption(stat.gid),
540 size: ByteSize.bytes(stat.size),
541 blksize: stat.blksize !== undefined ? Option.some(ByteSize.bytes(stat.blksize)) : Option.none(),
542 blocks: bigintToNumberOption(stat.blocks)
543 }),
544 catch: handleBadArgument("stat")
545 })
546const stat = (() => {
547 const nodeStat = effectify(
548 NFS.stat,
549 handleErrnoException("FileSystem", "stat"),
550 handleBadArgument("stat")
551 )
552 return (path: string) => Effect.flatMap(nodeStat(path, { bigint: true }), makeFileInfo)
553})()
554
555// == symlink
556
557const symlink = (() => {
558 const nodeSymlink = effectify(
559 NFS.symlink,
560 handleErrnoException("FileSystem", "symlink"),
561 handleBadArgument("symlink")
562 )
563 return (target: string, path: string) => nodeSymlink(target, path)
564})()
565
566// == truncate
567
568const truncate = (() => {
569 const nodeTruncate = effectify(
570 NFS.truncate,
571 handleErrnoException("FileSystem", "truncate"),
572 handleBadArgument("truncate")
573 )
574 return (path: string, length?: number) => nodeTruncate(path, length)
575})()
576
577// == utimes
578
579const utimes = (() => {
580 const nodeUtimes = effectify(
581 NFS.utimes,
582 handleErrnoException("FileSystem", "utime"),
583 handleBadArgument("utime")
584 )
585 return (path: string, atime: number | Date, mtime: number | Date) => nodeUtimes(path, atime, mtime)
586})()
587
588// == watch
589
590const watchNode = (path: string, info: FileSystem.File.Info, options?: FileSystem.WatchOptions) =>
591 Stream.callback<FileSystem.WatchEvent, Error.PlatformError>((queue) =>
592 Effect.acquireRelease(
593 Effect.sync(() => {
594 const directory = info.type === "Directory" ? path : Path.dirname(path)
595 const watcher = NFS.watch(path, {
596 recursive: options?.recursive ?? false
597 }, (event, path) => {
598 if (!path) return
599 switch (event) {
600 case "rename": {
601 Effect.runFork(Effect.matchEffect(stat(Path.resolve(directory, path)), {
602 onSuccess: (_) => Queue.offer(queue, { _tag: "Create", path }),
603 onFailure: (_) => Queue.offer(queue, { _tag: "Remove", path })
604 }))
605 return
606 }
607 case "change": {
608 Queue.offerUnsafe(queue, { _tag: "Update", path })
609 return
610 }
611 }
612 })
613 watcher.on("error", (error) => {
614 Queue.failCauseUnsafe(
615 queue,
616 Cause.fail(
617 Error.systemError({
618 module: "FileSystem",
619 _tag: "Unknown",
620 method: "watch",
621 pathOrDescriptor: path,
622 cause: error
623 })
624 )
625 )
626 })
627 watcher.on("close", () => {
628 Queue.endUnsafe(queue)
629 })
630 return watcher
631 }),
632 (watcher) => Effect.sync(() => watcher.close())
633 )
634 )
635
636const watch = (
637 backend: Option.Option<FileSystem.WatchBackend["Service"]>,
638 path: string,
639 options?: FileSystem.WatchOptions
640) =>
641 stat(path).pipe(
642 Effect.map((stat) =>
643 backend.pipe(
644 Option.flatMap((_) => _.register(path, stat, options)),
645 Option.getOrElse(() => watchNode(path, stat, options))
646 )
647 ),
648 Stream.unwrap
649 )
650
651// == writeFile
652
653const writeFile: FileSystem.FileSystem["writeFile"] = (path, data, options) =>
654 Effect.callback<void, Error.PlatformError>((resume, signal) => {
655 try {
656 NFS.writeFile(path, data, {
657 signal,
658 flag: options?.flag,
659 mode: options?.mode
660 }, (err) => {
661 if (err) {
662 resume(Effect.fail(handleErrnoException("FileSystem", "writeFile")(err, [path])))
663 } else {
664 resume(Effect.void)
665 }
666 })
667 } catch (err) {
668 resume(Effect.fail(handleBadArgument("writeFile")(err)))
669 }
670 })
671
672const makeFileSystem = Effect.map(Effect.serviceOption(FileSystem.WatchBackend), (backend) =>
673 FileSystem.make({
674 access,
675 chmod,
676 chown,
677 copy,
678 copyFile,
679 glob,
680 link,
681 makeDirectory,
682 makeTempDirectory,
683 makeTempDirectoryScoped,
684 makeTempFile,
685 makeTempFileScoped,
686 open,
687 readDirectory,
688 readFile,
689 readLink,
690 realPath,
691 remove,
692 rename,
693 stat,
694 symlink,
695 truncate,
696 utimes,
697 watch(path, options) {
698 return watch(backend, path, options)
699 },
700 writeFile
701 }))
702
703/**
704 * Provides the `FileSystem` service backed by Node filesystem APIs, including
705 * file operations, directory operations, links, metadata, and file watching.
706 *
707 * @category layers
708 * @since 4.0.0
709 */
710export const layer: Layer.Layer<FileSystem.FileSystem> = Layer.effect(FileSystem.FileSystem)(makeFileSystem)