1/**
2 * Deno implementation of Effect's `FileSystem` service.
3 *
4 * @since 4.0.0
5 */
6import { copy as denoCopy, expandGlob, walk } from "@std/fs"
7import { relative } from "@std/path"
8import * as ByteSize from "effect/ByteSize"
9import * as Effect from "effect/Effect"
10import * as FileSystem from "effect/FileSystem"
11import * as Layer from "effect/Layer"
12import * as Option from "effect/Option"
13import * as PlatformError from "effect/PlatformError"
14import * as Stream from "effect/Stream"
15import { handleError } from "./internal/error.ts"
16
17const tryPromise = <A>(
18 method: string,
19 pathOrDescriptor: string | number | undefined,
20 evaluate: (signal: AbortSignal) => PromiseLike<A>
21) =>
22 Effect.tryPromise({
23 try: evaluate,
24 catch: handleError("FileSystem", method, pathOrDescriptor)
25 })
26
27const close = (file: Deno.FsFile, method: string, pathOrDescriptor: string | number) =>
28 Effect.orDie(Effect.try({
29 try: () => file.close(),
30 catch: handleError("FileSystem", method, pathOrDescriptor)
31 }))
32
33const collectAsyncIterable = <A>(
34 method: string,
35 pathOrDescriptor: string | number | undefined,
36 evaluate: () => AsyncIterable<A>
37) =>
38 Effect.tryPromise({
39 try: async () => {
40 const values = new Array<A>()
41 for await (const value of evaluate()) {
42 values.push(value)
43 }
44 return values
45 },
46 catch: handleError("FileSystem", method, pathOrDescriptor)
47 })
48
49const access: FileSystem.FileSystem["access"] = (path, options) => {
50 if (!options?.readable && !options?.writable) {
51 return Effect.asVoid(tryPromise("access", path, () => Deno.stat(path)))
52 }
53 return Effect.acquireUseRelease(
54 tryPromise("access", path, () =>
55 Deno.open(path, {
56 ...(options.readable ? { read: true } : {}),
57 ...(options.writable ? { write: true } : {})
58 })),
59 () => Effect.void,
60 (file) => close(file, "access", path)
61 )
62}
63
64const copy: FileSystem.FileSystem["copy"] = (fromPath, toPath, options) =>
65 tryPromise("copy", fromPath, () =>
66 denoCopy(fromPath, toPath, {
67 overwrite: options?.overwrite ?? false,
68 preserveTimestamps: options?.preserveTimestamps ?? false
69 }))
70
71const copyFile: FileSystem.FileSystem["copyFile"] = (fromPath, toPath) =>
72 tryPromise("copyFile", fromPath, () => Deno.copyFile(fromPath, toPath))
73
74const chmod: FileSystem.FileSystem["chmod"] = (path, mode) => tryPromise("chmod", path, () => Deno.chmod(path, mode))
75
76const chown: FileSystem.FileSystem["chown"] = (path, uid, gid) =>
77 tryPromise("chown", path, () => Deno.chown(path, uid, gid))
78
79const glob: FileSystem.FileSystem["glob"] = (pattern, options) =>
80 Effect.map(
81 collectAsyncIterable("glob", pattern, () =>
82 expandGlob(pattern, {
83 root: options?.root ?? Deno.cwd(),
84 exclude: options?.exclude ? [...options.exclude] : []
85 })),
86 (entries) => entries.map((entry) => entry.path)
87 )
88
89const link: FileSystem.FileSystem["link"] = (existingPath, newPath) =>
90 tryPromise("link", existingPath, () => Deno.link(existingPath, newPath))
91
92const makeDirectory: FileSystem.FileSystem["makeDirectory"] = (path, options) =>
93 tryPromise("makeDirectory", path, () =>
94 Deno.mkdir(path, {
95 recursive: options?.recursive ?? false,
96 ...(options?.mode === undefined ? {} : { mode: options.mode })
97 }))
98
99const makeTempDirectoryFactory = (method: string): FileSystem.FileSystem["makeTempDirectory"] => (options) =>
100 tryPromise(method, options?.directory, () =>
101 Deno.makeTempDir({
102 ...(options?.directory === undefined ? {} : { dir: options.directory }),
103 ...(options?.prefix === undefined ? {} : { prefix: options.prefix })
104 }))
105
106const makeTempDirectory = makeTempDirectoryFactory("makeTempDirectory")
107
108const removeFactory = (method: string): FileSystem.FileSystem["remove"] => (path, options) => {
109 const effect = tryPromise(method, path, () => Deno.remove(path, { recursive: options?.recursive ?? false }))
110 return options?.force
111 ? Effect.catchTag(
112 effect,
113 "PlatformError",
114 (error) => error.reason._tag === "NotFound" ? Effect.void : Effect.fail(error)
115 )
116 : effect
117}
118
119const remove = removeFactory("remove")
120
121const makeTempDirectoryScoped: FileSystem.FileSystem["makeTempDirectoryScoped"] = (options) =>
122 Effect.acquireRelease(
123 makeTempDirectoryFactory("makeTempDirectoryScoped")(options),
124 (directory) => Effect.orDie(removeFactory("makeTempDirectoryScoped")(directory, { recursive: true }))
125 )
126
127const openOptions = (flag: FileSystem.OpenFlag = "r", mode?: number): Deno.OpenOptions => {
128 const modeOption = mode === undefined ? {} : { mode }
129 switch (flag) {
130 case "r":
131 return { read: true, ...modeOption }
132 case "r+":
133 return { read: true, write: true, ...modeOption }
134 case "w":
135 return { write: true, create: true, truncate: true, ...modeOption }
136 case "wx":
137 return { write: true, createNew: true, ...modeOption }
138 case "w+":
139 return { read: true, write: true, create: true, truncate: true, ...modeOption }
140 case "wx+":
141 return { read: true, write: true, createNew: true, ...modeOption }
142 case "a":
143 return { append: true, create: true, ...modeOption }
144 case "ax":
145 return { append: true, createNew: true, ...modeOption }
146 case "a+":
147 return { read: true, append: true, create: true, ...modeOption }
148 case "ax+":
149 return { read: true, append: true, createNew: true, ...modeOption }
150 }
151}
152
153const makeFileInfo = (info: Deno.FileInfo): FileSystem.File.Info => ({
154 type: info.isFile ?
155 "File" :
156 info.isDirectory ?
157 "Directory" :
158 info.isSymlink ?
159 "SymbolicLink" :
160 info.isBlockDevice ?
161 "BlockDevice" :
162 info.isCharDevice ?
163 "CharacterDevice" :
164 info.isFifo ?
165 "FIFO" :
166 info.isSocket ?
167 "Socket" :
168 "Unknown",
169 mtime: Option.fromNullishOr(info.mtime),
170 atime: Option.fromNullishOr(info.atime),
171 birthtime: Option.fromNullishOr(info.birthtime),
172 dev: info.dev,
173 rdev: Option.fromNullishOr(info.rdev),
174 ino: Option.fromNullishOr(info.ino),
175 mode: info.mode ?? 0,
176 nlink: Option.fromNullishOr(info.nlink),
177 uid: Option.fromNullishOr(info.uid),
178 gid: Option.fromNullishOr(info.gid),
179 size: ByteSize.bytes(BigInt(info.size)),
180 blksize: Option.map(Option.fromNullishOr(info.blksize), (size) => ByteSize.bytes(BigInt(size))),
181 blocks: Option.fromNullishOr(info.blocks)
182})
183
184/** A file handle is stateful and must not be used concurrently. */
185class FileImpl implements FileSystem.File {
186 readonly [FileSystem.FileTypeId]: typeof FileSystem.FileTypeId = FileSystem.FileTypeId
187 private readonly file: Deno.FsFile
188 private readonly append: boolean
189 private position = BigInt(0)
190 private nativePosition: bigint | undefined = undefined
191
192 constructor(
193 file: Deno.FsFile,
194 append: boolean
195 ) {
196 this.file = file
197 this.append = append
198 }
199
200 get stat() {
201 return Effect.map(
202 tryPromise("stat", undefined, () => this.file.stat()),
203 makeFileInfo
204 )
205 }
206
207 get sync() {
208 return tryPromise("sync", undefined, () => this.file.sync())
209 }
210
211 seek(offset: bigint, from: FileSystem.SeekMode) {
212 return Effect.suspend(() => {
213 const position = from === "start" ? offset : this.position + offset
214 if (position < BigInt(0)) {
215 return Effect.fail(PlatformError.badArgument({
216 module: "FileSystem",
217 method: "seek",
218 description: "Cannot seek before the start of the file"
219 }))
220 }
221 this.position = position
222 return Effect.succeed(position)
223 })
224 }
225
226 private readChunk(method: string, buffer: Uint8Array) {
227 return Effect.suspend(() => {
228 const position = this.position
229 return Effect.map(
230 tryPromise(
231 method,
232 undefined,
233 async () => {
234 if (this.nativePosition !== position) {
235 this.file.seekSync(position, Deno.SeekMode.Start)
236 }
237 this.nativePosition = undefined
238 return await this.file.read(buffer)
239 }
240 ),
241 (bytesRead) => {
242 this.position = this.nativePosition = position + BigInt(bytesRead ?? 0)
243 return bytesRead ?? 0
244 }
245 )
246 })
247 }
248
249 read(buffer: Uint8Array) {
250 return this.readChunk("read", buffer)
251 }
252
253 readAlloc(size: number) {
254 return Effect.suspend(() => {
255 try {
256 if (!Number.isInteger(size) || size < 0) {
257 throw new RangeError("size must be a non-negative integer")
258 }
259 const buffer = new Uint8Array(size)
260 return Effect.map(this.readChunk("readAlloc", buffer), (bytesRead) => {
261 if (bytesRead === 0) {
262 return Option.none()
263 }
264 return Option.some(bytesRead === size ? buffer : buffer.subarray(0, bytesRead))
265 })
266 } catch (cause) {
267 return Effect.fail(PlatformError.badArgument({
268 module: "FileSystem",
269 method: "readAlloc",
270 description: `Could not allocate a buffer of size ${size}`,
271 cause
272 }))
273 }
274 })
275 }
276
277 truncate(length?: number) {
278 const size = BigInt(length ?? 0)
279 return Effect.map(
280 tryPromise("truncate", undefined, () => this.file.truncate(Number(size))),
281 () => {
282 if (!this.append && this.position > size) {
283 this.position = size
284 }
285 }
286 )
287 }
288
289 private writeChunk(method: string, buffer: Uint8Array) {
290 return Effect.suspend(() => {
291 const position = this.position
292 return Effect.map(
293 tryPromise(
294 method,
295 undefined,
296 async () => {
297 if (!this.append && this.nativePosition !== position) {
298 this.file.seekSync(position, Deno.SeekMode.Start)
299 }
300 this.nativePosition = undefined
301 return await this.file.write(buffer)
302 }
303 ),
304 (bytesWritten) => {
305 if (this.append) {
306 this.nativePosition = undefined
307 } else {
308 this.position = this.nativePosition = position + BigInt(bytesWritten)
309 }
310 return bytesWritten
311 }
312 )
313 })
314 }
315
316 write(buffer: Uint8Array) {
317 return this.writeChunk("write", buffer)
318 }
319
320 private writeAllChunk(buffer: Uint8Array): Effect.Effect<void, PlatformError.PlatformError> {
321 return Effect.flatMap(this.writeChunk("writeAll", buffer), (bytesWritten) => {
322 if (bytesWritten === 0) {
323 return Effect.fail(PlatformError.systemError({
324 module: "FileSystem",
325 method: "writeAll",
326 _tag: "WriteZero",
327 description: "write returned 0 bytes written"
328 }))
329 }
330 return bytesWritten < buffer.length
331 ? this.writeAllChunk(buffer.subarray(bytesWritten))
332 : Effect.void
333 })
334 }
335
336 writeAll(buffer: Uint8Array) {
337 return buffer.length === 0 ? Effect.void : this.writeAllChunk(buffer)
338 }
339}
340
341const open: FileSystem.FileSystem["open"] = (path, options) => {
342 const append = options?.flag?.startsWith("a") ?? false
343 return Effect.map(
344 Effect.acquireRelease(
345 tryPromise("open", path, () => Deno.open(path, openOptions(options?.flag, options?.mode))),
346 (file) => close(file, "open", path)
347 ),
348 (file) => new FileImpl(file, append)
349 )
350}
351
352const makeTempFileFactory = (method: string): FileSystem.FileSystem["makeTempFile"] => (options) =>
353 tryPromise(method, options?.directory, () =>
354 Deno.makeTempFile({
355 ...(options?.directory === undefined ? {} : { dir: options.directory }),
356 ...(options?.prefix === undefined ? {} : { prefix: options.prefix }),
357 ...(options?.suffix === undefined ? {} : { suffix: options.suffix })
358 }))
359
360const makeTempFile = makeTempFileFactory("makeTempFile")
361
362const makeTempFileScoped: FileSystem.FileSystem["makeTempFileScoped"] = (options) =>
363 Effect.acquireRelease(
364 makeTempFileFactory("makeTempFileScoped")(options),
365 (file) => Effect.orDie(removeFactory("makeTempFileScoped")(file, { force: true }))
366 )
367
368const readDirectory: FileSystem.FileSystem["readDirectory"] = (path, options) => {
369 if (options?.recursive) {
370 return Effect.map(
371 collectAsyncIterable("readDirectory", path, () => walk(path)),
372 (entries) => entries.map((entry) => relative(path, entry.path)).filter(Boolean)
373 )
374 }
375 return Effect.map(
376 collectAsyncIterable("readDirectory", path, () => Deno.readDir(path)),
377 (entries) => entries.map((entry) => entry.name)
378 )
379}
380
381const readFile: FileSystem.FileSystem["readFile"] = (path) =>
382 tryPromise("readFile", path, (signal) => Deno.readFile(path, { signal }))
383
384const readLink: FileSystem.FileSystem["readLink"] = (path) => tryPromise("readLink", path, () => Deno.readLink(path))
385
386const realPath: FileSystem.FileSystem["realPath"] = (path) => tryPromise("realPath", path, () => Deno.realPath(path))
387
388const rename: FileSystem.FileSystem["rename"] = (oldPath, newPath) =>
389 tryPromise("rename", oldPath, () => Deno.rename(oldPath, newPath))
390
391const stat: FileSystem.FileSystem["stat"] = (path) =>
392 Effect.map(
393 tryPromise("stat", path, () => Deno.stat(path)),
394 makeFileInfo
395 )
396
397const symlink: FileSystem.FileSystem["symlink"] = (target, path) =>
398 tryPromise("symlink", target, () => Deno.symlink(target, path))
399
400const truncate: FileSystem.FileSystem["truncate"] = (path, length) =>
401 tryPromise("truncate", path, () => Deno.truncate(path, length))
402
403const utimes: FileSystem.FileSystem["utimes"] = (path, atime, mtime) =>
404 tryPromise("utimes", path, () => Deno.utime(path, atime, mtime))
405
406const watchNative = (
407 path: string,
408 options?: FileSystem.WatchOptions
409): Stream.Stream<FileSystem.WatchEvent, PlatformError.PlatformError> =>
410 Stream.unwrap(
411 Effect.map(
412 Effect.try({
413 try: () => Deno.watchFs(path, { recursive: options?.recursive ?? false }),
414 catch: handleError("FileSystem", "watch", path)
415 }),
416 (watcher) =>
417 Stream.fromAsyncIterable(watcher, handleError("FileSystem", "watch", path)).pipe(
418 Stream.flatMap((event): Stream.Stream<FileSystem.WatchEvent, PlatformError.PlatformError> => {
419 switch (event.kind) {
420 case "create":
421 return Stream.map(Stream.fromIterable(event.paths), (path) => ({ _tag: "Create" as const, path }))
422 case "modify":
423 return Stream.map(Stream.fromIterable(event.paths), (path) => ({ _tag: "Update" as const, path }))
424 case "remove":
425 return Stream.map(Stream.fromIterable(event.paths), (path) => ({ _tag: "Remove" as const, path }))
426 case "rename":
427 return Stream.mapEffect(Stream.fromIterable(event.paths), (path) =>
428 Effect.match(stat(path), {
429 onFailure: () => ({ _tag: "Remove" as const, path }),
430 onSuccess: () => ({ _tag: "Create" as const, path })
431 }))
432 default:
433 return Stream.empty
434 }
435 })
436 )
437 )
438 )
439
440const watch = (
441 backend: Option.Option<FileSystem.WatchBackend["Service"]>,
442 path: string,
443 options?: FileSystem.WatchOptions
444) =>
445 stat(path).pipe(
446 Effect.map((info) =>
447 backend.pipe(
448 Option.flatMap((backend) => backend.register(path, info, options)),
449 Option.getOrElse(() => watchNative(path, options))
450 )
451 ),
452 Stream.unwrap
453 )
454
455const writeFile: FileSystem.FileSystem["writeFile"] = (path, data, options) => {
456 const flag = options?.flag ?? "w"
457 if (options?.mode === undefined && (flag === "w" || flag === "wx" || flag === "a" || flag === "ax")) {
458 return tryPromise("writeFile", path, (signal) =>
459 Deno.writeFile(path, data, {
460 append: flag.startsWith("a"),
461 createNew: flag.includes("x"),
462 signal
463 }))
464 }
465 return Effect.acquireUseRelease(
466 tryPromise("writeFile", path, () => Deno.open(path, openOptions(flag, options?.mode))),
467 (file) =>
468 new FileImpl(file, flag.startsWith("a")).writeAll(data).pipe(
469 Effect.mapError((error) =>
470 error.reason._tag !== "BadArgument"
471 ? PlatformError.systemError({ ...error.reason, method: "writeFile", pathOrDescriptor: path })
472 : error
473 )
474 ),
475 (file) => close(file, "writeFile", path)
476 )
477}
478
479const makeFileSystem = Effect.map(Effect.serviceOption(FileSystem.WatchBackend), (backend) =>
480 FileSystem.make({
481 access,
482 chmod,
483 chown,
484 copy,
485 copyFile,
486 glob,
487 link,
488 makeDirectory,
489 makeTempDirectory,
490 makeTempDirectoryScoped,
491 makeTempFile,
492 makeTempFileScoped,
493 open,
494 readDirectory,
495 readFile,
496 readLink,
497 realPath,
498 remove,
499 rename,
500 stat,
501 symlink,
502 truncate,
503 utimes,
504 watch(path, options) {
505 return watch(backend, path, options)
506 },
507 writeFile
508 }))
509
510/**
511 * Provides the `FileSystem` service backed by Deno filesystem APIs.
512 *
513 * @category layers
514 * @since 4.0.0
515 */
516export const layer: Layer.Layer<FileSystem.FileSystem> = Layer.effect(FileSystem.FileSystem)(makeFileSystem)