From a4a0040fcd791f7a1120c30cfd923513407ca912 Mon Sep 17 00:00:00 2001 From: Eli <88557639+lishaduck@users.noreply.github.com> Date: Fri, 15 Nov 2024 21:56:03 -0600 Subject: [PATCH 1/3] feat: (mostly) deno-native file system --- .vscode/settings.json | 1 - biome.json | 9 +- deno.json | 3 + deno.lock | 19 + packages/platform-deno/package.json | 2 + packages/platform-deno/src/DenoContext.ts | 4 +- packages/platform-deno/src/DenoFileSystem.ts | 919 ++++++++++++++++++ .../src/internal/effectify-promise.ts | 37 + packages/platform-deno/src/internal/error.ts | 67 ++ 9 files changed, 1056 insertions(+), 5 deletions(-) create mode 100644 packages/platform-deno/src/DenoFileSystem.ts create mode 100644 packages/platform-deno/src/internal/effectify-promise.ts create mode 100644 packages/platform-deno/src/internal/error.ts diff --git a/.vscode/settings.json b/.vscode/settings.json index c75e93e..84f5cda 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,6 +1,5 @@ { "deno.enable": true, - "deno.lint": false, "[typescriptreact][typescript]": { "editor.defaultFormatter": "biomejs.biome" }, diff --git a/biome.json b/biome.json index 7fa0efb..5ef176b 100644 --- a/biome.json +++ b/biome.json @@ -15,13 +15,15 @@ "nursery": { "all": true, "noNestedTernary": "off", + "noSecrets": "off", "useImportRestrictions": "off" }, "style": { + "noNamespaceImport": "off", + "useBlockStatements": "off", "useDefaultSwitchClause": "off", "useFilenamingConvention": "off", - "useNamingConvention": "off", - "noNamespaceImport": "off" + "useNamingConvention": "off" }, "complexity": { "useLiteralKeys": "off" @@ -63,6 +65,9 @@ "clientKind": "git", "defaultBranch": "main" }, + "files": { + "ignore": ["deno.lock"] + }, "javascript": { "globals": ["Deno"] } diff --git a/deno.json b/deno.json index 7c6f336..12c6431 100644 --- a/deno.json +++ b/deno.json @@ -28,5 +28,8 @@ "noUncheckedIndexedAccess": true, "noPropertyAccessFromIndexSignature": true, "useUnknownInCatchVariables": true + }, + "lint": { + "exclude": ["no-explicit-any"] } } diff --git a/deno.lock b/deno.lock index 25e178c..d6a5415 100644 --- a/deno.lock +++ b/deno.lock @@ -9,7 +9,9 @@ "npm:@effect/platform-node@~0.64.26": "0.64.26_@effect+platform@0.69.24__effect@3.10.15_effect@3.10.15", "npm:@effect/platform@~0.69.24": "0.69.24_effect@3.10.15", "npm:@effect/vitest@~0.13.15": "0.13.15_effect@3.10.15_vitest@2.1.5__vite@5.4.11", + "npm:@jsr/std__fs@^1.0.5": "1.0.5", "npm:@jsr/std__path@^1.0.8": "1.0.8", + "npm:@types/node@^22.9.0": "22.9.0", "npm:@vitest/coverage-v8@^2.1.5": "2.1.5_vitest@2.1.5__vite@5.4.11", "npm:@vitest/ui@^2.1.5": "2.1.5_vitest@2.1.5__vite@5.4.11", "npm:effect@^3.10.15": "3.10.15", @@ -243,6 +245,12 @@ "@jridgewell/sourcemap-codec" ] }, + "@jsr/std__fs@1.0.5": { + "integrity": "sha512-2ihx5BjO2IxpJ1aHy+joER4l1tJSktyaNaoDb9HOVK5IRToUY5OwstLe3+yhZnSn2KXlCo5DBS1mfAgrtu10aw==", + "dependencies": [ + "@jsr/std__path" + ] + }, "@jsr/std__path@1.0.8": { "integrity": "sha512-eNBGlh/8ZVkMxtFH4bwIzlAeKoHYk5in4wrBZhi20zMdOiuX4QozP4+19mIXBT2lzHDjhuVLyECbhFeR304iDg==" }, @@ -370,6 +378,12 @@ "@types/estree@1.0.6": { "integrity": "sha512-AYnb1nQyY49te+VRAVgmzfcgjYS91mY5P0TKUDCLEM+gNnA+3T6rWITXRLYCpahpqSQbN5cE+gHpnPyXjHWxcw==" }, + "@types/node@22.9.0": { + "integrity": "sha512-vuyHg81vvWA1Z1ELfvLko2c8f34gyA0zaic0+Rllc5lbCnbSyuvb2Oxpm6TAUAC/2xZN3QGqxBNggD1nNR2AfQ==", + "dependencies": [ + "undici-types" + ] + }, "@vitest/coverage-v8@2.1.5_vitest@2.1.5__vite@5.4.11": { "integrity": "sha512-/RoopB7XGW7UEkUndRXF87A9CwkoZAJW01pj8/3pgmDVsjMH2IKy6H1A38po9tmUlwhSyYs0az82rbKd9Yaynw==", "dependencies": [ @@ -924,6 +938,9 @@ "totalist@3.0.1": { "integrity": "sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==" }, + "undici-types@6.19.8": { + "integrity": "sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw==" + }, "undici@6.21.0": { "integrity": "sha512-BUgJXc752Kou3oOIuU1i+yZZypyZRqNPW0vqoMPl8VaoalSfeR0D8/t4iAS3yirs79SSMTxTag+ZC86uswv+Cw==" }, @@ -1048,7 +1065,9 @@ "npm:@effect/platform-node-shared@~0.19.25", "npm:@effect/platform-node@~0.64.26", "npm:@effect/platform@~0.69.24", + "npm:@jsr/std__fs@^1.0.5", "npm:@jsr/std__path@^1.0.8", + "npm:@types/node@^22.9.0", "npm:effect@^3.10.15" ] } diff --git a/packages/platform-deno/package.json b/packages/platform-deno/package.json index 74f4ccd..5a7fe8e 100644 --- a/packages/platform-deno/package.json +++ b/packages/platform-deno/package.json @@ -2,9 +2,11 @@ "name": "@lishaduck/effect-platform-deno", "type": "module", "dependencies": { + "@std/fs": "npm:@jsr/std__fs@^1.0.5", "@std/path": "npm:@jsr/std__path@^1.0.8" }, "devDependencies": { + "@types/node": "npm:@types/node@^22.9.0", "effect": "npm:effect@^3.10.15", "@effect/platform": "npm:@effect/platform@^0.69.24", "@effect/platform-node": "npm:@effect/platform-node@^0.64.26", diff --git a/packages/platform-deno/src/DenoContext.ts b/packages/platform-deno/src/DenoContext.ts index 65094ef..87ac3de 100644 --- a/packages/platform-deno/src/DenoContext.ts +++ b/packages/platform-deno/src/DenoContext.ts @@ -11,9 +11,9 @@ import type { Worker, } from "@effect/platform"; import * as NodeCommandExecutor from "@effect/platform-node-shared/NodeCommandExecutor"; -import * as NodeFileSystem from "@effect/platform-node-shared/NodeFileSystem"; import * as NodeTerminal from "@effect/platform-node-shared/NodeTerminal"; import { Layer } from "effect"; +import * as DenoFileSystem from "./DenoFileSystem.ts"; import * as DenoPath from "./DenoPath.ts"; import * as DenoWorker from "./DenoWorker.ts"; @@ -37,4 +37,4 @@ export const layer: Layer.Layer = Layer.mergeAll( NodeCommandExecutor.layer, NodeTerminal.layer, DenoWorker.layerManager, -).pipe(Layer.provideMerge(NodeFileSystem.layer)); +).pipe(Layer.provideMerge(DenoFileSystem.layer)); diff --git a/packages/platform-deno/src/DenoFileSystem.ts b/packages/platform-deno/src/DenoFileSystem.ts new file mode 100644 index 0000000..109e850 --- /dev/null +++ b/packages/platform-deno/src/DenoFileSystem.ts @@ -0,0 +1,919 @@ +/** + * @file + * @since 0.1.1 + */ + +// biome-ignore lint/correctness/noNodejsModules: Using Node.js compat for fid operations. +import * as NFS from "node:fs"; +import { FileSystem } from "@effect/platform"; +import { effectify } from "@effect/platform/Effectify"; +import { + BadArgument, + type PlatformError, + SystemError, +} from "@effect/platform/Error"; +import * as SFS from "@std/fs"; +import * as Path from "@std/path"; +import { + Chunk, + type Context, + Effect, + Layer, + Option, + type Scope, + Stream, + pipe, +} from "effect"; +import { + effectifyAbortablePromise, + effectifyPromise, +} from "./internal/effectify-promise.ts"; +import { handleErrnoException } from "./internal/error.ts"; + +const handleBadArgument = + (method: string) => + (err: unknown): BadArgument => + BadArgument({ + module: moduleName, + method, + message: (err as Error).message ?? String(err), + }); + +const moduleName = "FileSystem"; + +// == access + +const access = (() => { + const nodeAccess = effectify( + // TODO: Use Deno.open & immediately close, use catch to figure out access. + // See also, denoland/deno#10021 + // NVM, polyfill with `SFS.exists`. + NFS.access, + handleErrnoException(moduleName, "access"), + handleBadArgument("access"), + ); + return ( + path: string, + options?: FileSystem.AccessFileOptions, + ): Effect.Effect => { + let mode = NFS.constants.F_OK; + if (options?.readable) { + mode |= NFS.constants.R_OK; + } + if (options?.writable) { + mode |= NFS.constants.W_OK; + } + return nodeAccess(path, mode); + }; +})(); + +// == copy + +const copy = (() => { + const stdCopy = effectifyPromise( + SFS.copy, + handleErrnoException(moduleName, "copy"), + ); + return ( + fromPath: string, + toPath: string, + options?: FileSystem.CopyOptions, + ): Effect.Effect => + stdCopy(fromPath, toPath, { + overwrite: options?.overwrite ?? false, + preserveTimestamps: options?.preserveTimestamps ?? false, + }); +})(); + +// == copyFile + +const copyFile = (() => { + const denoCopyFile = effectifyPromise( + Deno.copyFile, + handleErrnoException(moduleName, "copyFile"), + ); + return ( + fromPath: string, + toPath: string, + ): Effect.Effect => denoCopyFile(fromPath, toPath); +})(); + +// == chmod + +const chmod = (() => { + const denoChmod = effectifyPromise( + Deno.chmod, + handleErrnoException(moduleName, "chmod"), + ); + return (path: string, mode: number): Effect.Effect => + denoChmod(path, mode); +})(); + +// == chown + +const chown = (() => { + const denoChown = effectifyPromise( + Deno.chown, + handleErrnoException(moduleName, "chown"), + ); + return ( + path: string, + uid: number, + gid: number, + ): Effect.Effect => denoChown(path, uid, gid); +})(); + +// == link + +const link = (() => { + const denoLink = effectifyPromise( + Deno.link, + handleErrnoException(moduleName, "link"), + ); + return ( + existingPath: string, + newPath: string, + ): Effect.Effect => denoLink(existingPath, newPath); +})(); + +// == makeDirectory + +const makeDirectory = (() => { + const denoMkdir = effectifyPromise( + Deno.mkdir, + handleErrnoException(moduleName, "makeDirectory"), + ); + return ( + path: string, + options?: FileSystem.MakeDirectoryOptions, + ): Effect.Effect => + denoMkdir(path, { + recursive: options?.recursive ?? false, + + // TODO: PR Deno to fix this type. + ...(options?.mode !== undefined ? { mode: options?.mode } : {}), + }); +})(); + +// == makeTempDirectory + +const makeTempDirectoryFactory = ( + method: string, +): (( + options?: FileSystem.MakeTempDirectoryOptions, +) => Effect.Effect) => { + const denoMakeTempDir = effectifyPromise( + async (prefix, options) => await Deno.makeTempDir({ ...options, prefix }), + handleErrnoException(moduleName, method), + ); + return ( + options: FileSystem.MakeTempDirectoryOptions = {}, + ): Effect.Effect => + Effect.suspend(() => { + const { prefix, ...restOptions } = options; + + return denoMakeTempDir(prefix, restOptions); + }); +}; +const makeTempDirectory = makeTempDirectoryFactory("makeTempDirectory"); + +// == remove + +const removeFactory = ( + method: string, +): (( + path: string, + options?: FileSystem.RemoveOptions, +) => Effect.Effect) => { + const denoRemove = effectifyPromise( + Deno.remove, + handleErrnoException(moduleName, method), + ); + + return ( + path: string, + options?: FileSystem.RemoveOptions, + ): Effect.Effect => + denoRemove(path, { + recursive: (options?.recursive || options?.force) ?? false, + }); +}; + +const remove = removeFactory("remove"); + +// == makeTempDirectoryScoped + +const makeTempDirectoryScoped = (() => { + const makeDirectory = makeTempDirectoryFactory("makeTempDirectoryScoped"); + const removeDirectory = removeFactory("makeTempDirectoryScoped"); + return ( + options?: FileSystem.MakeTempDirectoryOptions, + ): Effect.Effect => + Effect.acquireRelease(makeDirectory(options), (directory) => + Effect.orDie(removeDirectory(directory, { recursive: true })), + ); +})(); + +// == open + +const nodeOpenFactory = ( + method: string, +): (( + path: NFS.PathLike, + flags: NFS.OpenMode | undefined, + mode: NFS.Mode | null | undefined, +) => Effect.Effect) => + effectify( + NFS.open, + handleErrnoException("FileSystem", method), + handleBadArgument(method), + ); +const nodeCloseFactory = ( + method: string, +): ((fd: number) => Effect.Effect) => + effectify( + NFS.close, + handleErrnoException("FileSystem", method), + handleBadArgument(method), + ); + +const openFactory = ( + method: string, +): (( + path: string, + options?: FileSystem.OpenFileOptions, +) => Effect.Effect) => { + const nodeOpen = nodeOpenFactory(method); + const nodeClose = nodeCloseFactory(method); + + return ( + path: string, + options?: FileSystem.OpenFileOptions, + ): Effect.Effect => + pipe( + Effect.acquireRelease( + nodeOpen(path, options?.flag ?? "r", options?.mode), + (fd) => Effect.orDie(nodeClose(fd)), + ), + Effect.map((fd) => + makeFile( + FileSystem.FileDescriptor(fd), + options?.flag?.startsWith("a") ?? false, + ), + ), + ); +}; +const open = openFactory("open"); + +const nodeReadFactory = ( + method: string, +): (( + fd: number, + options: NFS.ReadAsyncOptions, +) => Effect.Effect) => + effectify( + NFS.read, + handleErrnoException(moduleName, method), + handleBadArgument(method), + ); +const nodeRead = nodeReadFactory("read"); +const nodeReadAlloc = nodeReadFactory("readAlloc"); + +const nodeFstatFactory = ( + method: string, +): ((fd: number) => Effect.Effect) => + effectify( + NFS.fstat, + handleErrnoException(moduleName, method), + handleBadArgument(method), + ); +const fstat = nodeFstatFactory("fstat"); + +const nodeWriteFactory = ( + method: string, +): (( + fd: number, + buffer: Uint8Array, + offset: number | null | undefined, + length: number | null | undefined, + position: number | null | undefined, +) => Effect.Effect) => + effectify( + NFS.write, + handleErrnoException(moduleName, method), + handleBadArgument(method), + ); +const nodeWrite = nodeWriteFactory("write"); +const nodeWriteAll = nodeWriteFactory("writeAll"); + +const makeFile = (() => { + class FileImpl implements FileSystem.File { + readonly [FileSystem.FileTypeId]: FileSystem.FileTypeId; + + private readonly semaphore = Effect.unsafeMakeSemaphore(1); + private position = 0n; + + readonly fd: FileSystem.File.Descriptor; + private readonly append: boolean; + + constructor(fd: FileSystem.File.Descriptor, append: boolean) { + this[FileSystem.FileTypeId] = FileSystem.FileTypeId; + this.fd = fd; + this.append = append; + } + + get stat(): Effect.Effect { + return Effect.map(fstat(this.fd), makeNodeFileInfo); + } + + seek( + offset: FileSystem.SizeInput, + from: FileSystem.SeekMode, + ): Effect.Effect { + const offsetSize = FileSystem.Size(offset); + return this.semaphore.withPermits(1)( + Effect.sync(() => { + if (from === "start") { + this.position = offsetSize; + } else if (from === "current") { + this.position += offsetSize; + } + + // Used in tests. + return this.position; + }), + ); + } + + read(buffer: Uint8Array): Effect.Effect { + return this.semaphore.withPermits(1)( + Effect.map( + Effect.suspend(() => + nodeRead(this.fd, { + buffer, + position: this.position, + }), + ), + (bytesRead) => { + const sizeRead = FileSystem.Size(bytesRead); + this.position += sizeRead; + return sizeRead; + }, + ), + ); + } + + readAlloc( + size: FileSystem.SizeInput, + ): Effect.Effect, PlatformError> { + const sizeNumber = Number(size); + return this.semaphore.withPermits(1)( + Effect.flatMap( + Effect.sync(() => new Uint8Array(sizeNumber)), + (buffer) => + Effect.map( + nodeReadAlloc(this.fd, { + buffer, + position: this.position, + }), + (bytesRead): Option.Option => { + if (bytesRead === 0) { + return Option.none(); + } + + this.position += BigInt(bytesRead); + if (bytesRead === sizeNumber) { + return Option.some(buffer); + } + + const dst = buffer.slice(0, bytesRead); + + return Option.some(dst); + }, + ), + ), + ); + } + + truncate( + length?: FileSystem.SizeInput, + ): Effect.Effect { + return this.semaphore.withPermits(1)( + Effect.map( + // FIXME: `truncate` takes a path, not a FileDescriptor. + // TODO: PR `@effect/platform`, b/c passing a `FileDescriptor` to truncate is also deprecated in Node.js. + ftruncateFactory("truncate")( + this.fd, + length ? Number(length) : undefined, + ), + () => { + if (!this.append) { + const len = BigInt(length ?? 0); + if (this.position > len) { + this.position = len; + } + } + }, + ), + ); + } + + write(buffer: Uint8Array): Effect.Effect { + return this.semaphore.withPermits(1)( + Effect.map( + Effect.suspend(() => + nodeWrite( + this.fd, + buffer, + undefined, + undefined, + this.append ? undefined : Number(this.position), + ), + ), + (bytesWritten) => { + const sizeWritten = FileSystem.Size(bytesWritten); + if (!this.append) { + this.position += sizeWritten; + } + + return sizeWritten; + }, + ), + ); + } + + private writeAllChunk( + buffer: Uint8Array, + ): Effect.Effect { + return Effect.flatMap< + number, + PlatformError, + never, + void, + PlatformError, + never + >( + Effect.suspend(() => + nodeWriteAll( + this.fd, + buffer, + undefined, + undefined, + this.append ? undefined : Number(this.position), + ), + ), + (bytesWritten) => { + if (bytesWritten === 0) { + return Effect.fail( + SystemError({ + module: moduleName, + method: "writeAll", + reason: "WriteZero", + pathOrDescriptor: this.fd, + message: "write returned 0 bytes written", + }), + ); + } + + if (!this.append) { + this.position += BigInt(bytesWritten); + } + + return bytesWritten < buffer.length + ? this.writeAllChunk(buffer.subarray(bytesWritten)) + : Effect.void; + }, + ); + } + + writeAll(buffer: Uint8Array): Effect.Effect { + return this.semaphore.withPermits(1)(this.writeAllChunk(buffer)); + } + } + + return (fd: FileSystem.File.Descriptor, append: boolean): FileSystem.File => + new FileImpl(fd, append); +})(); + +// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: still waiting for proposal-pattern-matching. +const getNodeFileType = (stat: NFS.Stats): FileSystem.File.Type => + stat.isFile() + ? "File" + : stat.isDirectory() + ? "Directory" + : stat.isSymbolicLink() + ? "SymbolicLink" + : stat.isBlockDevice() + ? "BlockDevice" + : stat.isCharacterDevice() + ? "CharacterDevice" + : stat.isFIFO() + ? "FIFO" + : stat.isSocket() + ? "Socket" + : "Unknown"; + +const makeNodeFileInfo = (stat: NFS.Stats): FileSystem.File.Info => ({ + type: getNodeFileType(stat), + mtime: Option.fromNullable(stat.mtime), + atime: Option.fromNullable(stat.atime), + birthtime: Option.fromNullable(stat.birthtime), + dev: stat.dev, + rdev: Option.fromNullable(stat.rdev), + ino: Option.fromNullable(stat.ino), + mode: stat.mode, + nlink: Option.fromNullable(stat.nlink), + uid: Option.fromNullable(stat.uid), + gid: Option.fromNullable(stat.gid), + size: FileSystem.Size(stat.size), + blksize: Option.fromNullable(FileSystem.Size(stat.blksize)), + blocks: Option.fromNullable(stat.blocks), +}); + +const ftruncateFactory = ( + method: string, +): (( + path: number, + length?: FileSystem.SizeInput, +) => Effect.Effect) => { + const nodeTruncate = effectify( + NFS.ftruncate, + handleErrnoException("FileSystem", method), + handleBadArgument(method), + ); + return ( + path: number, + length?: FileSystem.SizeInput, + ): Effect.Effect => + nodeTruncate(path, length !== undefined ? Number(length) : undefined); +}; + +// == makeTempFile + +/** + * From + */ +const toHexString = (arr: Iterable): string => + Array.from(arr, (i) => i.toString(16).padStart(2, "0")).join(""); + +const makeTempFileFactory = ( + method: string, +): (( + options?: FileSystem.MakeTempFileOptions, +) => Effect.Effect) => { + const makeDirectory = makeTempDirectoryFactory(method); + const open = openFactory(method); + const randomHexString = (bytes: number): Effect.Effect => + Effect.sync(() => + toHexString(crypto.getRandomValues(new Uint8Array(bytes))), + ); + + return ( + options?: FileSystem.MakeTempFileOptions, + ): Effect.Effect => + pipe( + Effect.zip(makeDirectory(options), randomHexString(6)), + Effect.map(([directory, random]) => Path.join(directory, random)), + Effect.tap((path) => Effect.scoped(open(path, { flag: "w+" }))), + ); +}; +const makeTempFile = makeTempFileFactory("makeTempFile"); + +// == makeTempFileScoped + +const makeTempFileScoped = (() => { + const makeFile = makeTempFileFactory("makeTempFileScoped"); + const removeDirectory = removeFactory("makeTempFileScoped"); + return ( + options?: FileSystem.MakeTempFileOptions, + ): Effect.Effect => + Effect.acquireRelease(makeFile(options), (file) => + Effect.orDie(removeDirectory(Path.dirname(file), { recursive: true })), + ); +})(); + +// == readDirectory + +const readDirectory = ( + path: string, + options?: FileSystem.ReadDirectoryOptions, +): Effect.Effect => + Effect.gen(function* () { + const entriesStream = + options?.recursive === undefined + ? Stream.fromAsyncIterable(Deno.readDir(path), (err) => + handleErrnoException(moduleName, "readDirectory")(err as Error, [ + path, + ]), + ).pipe(Stream.map((n) => n.name)) + : walkDir(path, (err) => + handleErrnoException(moduleName, "readDirectory")(err as Error, [ + path, + ]), + ).pipe(Stream.map((n) => n.path)); + + return yield* streamToArray(entriesStream); + }); + +const streamToArray = ( + stream: Stream.Stream, +): Effect.Effect => + Effect.gen(function* () { + const chunk = yield* stream.pipe(Stream.runCollect); + + return chunk.pipe(Chunk.toArray); + }); + +const walkDir = ( + root: string | URL, + onError: (e: unknown) => E, + walkOptions?: SFS.WalkOptions, +): Stream.Stream => + Stream.fromAsyncIterable(SFS.walk(root, walkOptions), onError); + +// == readFile + +const readFile = (path: string): Effect.Effect => + (() => { + const denoReadFile = effectifyAbortablePromise( + (signal) => + (path: string): Promise => + Deno.readFile(path, { signal }), + handleErrnoException(moduleName, "readFile"), + ); + + return denoReadFile(path); + })(); + +// == readLink + +const readLink = (() => { + const denoReadLink = effectifyPromise( + Deno.readLink, + handleErrnoException(moduleName, "readLink"), + ); + return (path: string): Effect.Effect => + denoReadLink(path); +})(); + +// == realPath + +const realPath = (() => { + const denoRealPath = effectifyPromise( + Deno.realPath, + handleErrnoException(moduleName, "realPath"), + ); + return (path: string): Effect.Effect => + denoRealPath(path); +})(); + +// == rename + +const rename = (() => { + const denoRename = effectifyPromise( + Deno.rename, + handleErrnoException(moduleName, "rename"), + ); + return ( + oldPath: string, + newPath: string, + ): Effect.Effect => denoRename(oldPath, newPath); +})(); + +// == stat + +// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: still waiting for proposal-pattern-matching. +const getFileType = (stat: Deno.FileInfo): FileSystem.File.Type => + stat.isFile + ? "File" + : stat.isDirectory + ? "Directory" + : stat.isSymlink + ? "SymbolicLink" + : stat.isBlockDevice + ? "BlockDevice" + : stat.isCharDevice + ? "CharacterDevice" + : stat.isFifo + ? "FIFO" + : stat.isSocket + ? "Socket" + : "Unknown"; + +const makeFileInfo = (stat: Deno.FileInfo): FileSystem.File.Info => ({ + type: getFileType(stat), + mtime: Option.fromNullable(stat.mtime), + atime: Option.fromNullable(stat.atime), + birthtime: Option.fromNullable(stat.birthtime), + dev: stat.dev, + rdev: Option.fromNullable(stat.rdev), + ino: Option.fromNullable(stat.ino), + mode: stat.mode ?? 0, // FIXME: Deno doesn't support `mode` on Windows. + nlink: Option.fromNullable(stat.nlink), + uid: Option.fromNullable(stat.uid), + gid: Option.fromNullable(stat.gid), + size: FileSystem.Size(stat.size), + blksize: Option.fromNullable( + FileSystem.Size( + stat.blksize ?? 0, // FIXME: Deno doesn't support `mode` on Windows. + ), + ), + blocks: Option.fromNullable(stat.blocks), +}); +const stat = (() => { + const denoStat = effectifyPromise( + Deno.stat, + handleErrnoException(moduleName, "stat"), + ); + return ( + path: string, + ): Effect.Effect => + Effect.map(denoStat(path), makeFileInfo); +})(); + +// == symlink + +const symlink = (() => { + const denoSymlink = effectifyPromise( + Deno.symlink, + handleErrnoException(moduleName, "symlink"), + ); + return (target: string, path: string): Effect.Effect => + denoSymlink(target, path); +})(); + +// == truncate + +const truncate = (() => { + const denoTruncate = effectifyPromise( + Deno.truncate, + handleErrnoException(moduleName, "truncate"), + ); + return ( + path: string, + length?: FileSystem.SizeInput, + ): Effect.Effect => + denoTruncate(path, length !== undefined ? Number(length) : undefined); +})(); + +// == utimes + +const utimes = (() => { + const denoUtime = effectifyPromise( + Deno.utime, + handleErrnoException(moduleName, "utime"), + ); + return ( + path: string, + atime: number | Date, + mtime: number | Date, + ): Effect.Effect => denoUtime(path, atime, mtime); +})(); + +// == watch + +const watchNode = ( + path: string, +): Stream.Stream => + Stream.asyncScoped((emit) => + Effect.acquireRelease( + Effect.tryPromise({ + try: async (): Promise => { + const watcher = Deno.watchFs(path); + + for await (const event of watcher) { + for (const eventPath of event.paths) { + switch (event.kind) { + case "create": { + await emit.single( + FileSystem.WatchEventCreate({ path: eventPath }), + ); + break; + } + case "modify": { + await emit.single( + FileSystem.WatchEventUpdate({ path: eventPath }), + ); + break; + } + case "remove": { + await emit.single( + FileSystem.WatchEventRemove({ path: eventPath }), + ); + break; + } + case "rename": { + await emit.fromEffect( + Effect.match(stat(path), { + onSuccess: (_): FileSystem.WatchEvent.Create => + FileSystem.WatchEventCreate({ path }), + onFailure: (_): FileSystem.WatchEvent.Remove => + FileSystem.WatchEventRemove({ path }), + }), + ); + + break; + } + } + } + } + + return watcher; + }, + catch: (error): PlatformError => { + return SystemError({ + module: moduleName, + reason: "Unknown", + method: "watch", + pathOrDescriptor: path, + message: (error as Error).message ?? String(error), + }); + }, + }), + (watcher) => Effect.sync(() => watcher.close()), + ), + ); + +const watch = ( + backend: Option.Option>, + path: string, +): Stream.Stream => + stat(path).pipe( + Effect.map((stat) => + backend.pipe( + Option.flatMap((_) => _.register(path, stat)), + Option.getOrElse(() => watchNode(path)), + ), + ), + Stream.unwrap, + ); + +// == writeFile + +const writeFile = ( + path: string, + data: Uint8Array, + options?: FileSystem.WriteFileOptions, +): Effect.Effect => + (() => { + const denoWriteFile = effectifyAbortablePromise( + ( + signal, + ): (( + path: string, + data: Uint8Array, + options?: FileSystem.WriteFileOptions, + ) => Promise) => + async ( + path: string, + data: Uint8Array, + options?: FileSystem.WriteFileOptions, + ): Promise => + await Deno.writeFile(path, data, { signal, ...options }), + handleErrnoException(moduleName, "writeFile"), + ); + + return denoWriteFile(path, data, options); + })(); + +const makeFileSystem = Effect.map( + Effect.serviceOption(FileSystem.WatchBackend), + (backend) => + FileSystem.make({ + access, + chmod, + chown, + copy, + copyFile, + link, + makeDirectory, + makeTempDirectory, + makeTempDirectoryScoped, + makeTempFile, + makeTempFileScoped, + open, + readDirectory, + readFile, + readLink, + realPath, + remove, + rename, + stat, + symlink, + truncate, + utimes, + watch: (path): Stream.Stream => { + return watch(backend, path); + }, + writeFile, + }), +); + +/** + * @since 0.0.1 + * @category layer + */ +export const layer: Layer.Layer = Layer.effect( + FileSystem.FileSystem, + makeFileSystem, +); diff --git a/packages/platform-deno/src/internal/effectify-promise.ts b/packages/platform-deno/src/internal/effectify-promise.ts new file mode 100644 index 0000000..668a245 --- /dev/null +++ b/packages/platform-deno/src/internal/effectify-promise.ts @@ -0,0 +1,37 @@ +import { Effect } from "effect"; +import type { FunctionN } from "effect/Function"; + +export const effectifyPromise = + < + // biome-ignore lint/suspicious/noExplicitAny: Otherwise, there's no way to accept anything. Neither `never` nor `unknown` works. + DenoParams extends any[], + DenoReturn extends Promise, + MappedError = never, + >( + method: FunctionN, + onError: (error: Error, args: DenoParams) => MappedError, + ) => + (...args: DenoParams): Effect.Effect, MappedError> => { + return Effect.tryPromise, MappedError>({ + try: async (): Promise> => await method(...args), + catch: (err): MappedError => onError(err as Error, args), + }); + }; + +export const effectifyAbortablePromise = + < + // biome-ignore lint/suspicious/noExplicitAny: Otherwise, there's no way to accept anything. Neither `never` nor `unknown` works. + DenoParams extends any[] = never, + DenoReturn extends Promise = Promise, + MappedError = never, + >( + method: (signal: AbortSignal) => (...args: DenoParams) => DenoReturn, + onError: (error: Error, args: DenoParams) => MappedError, + ) => + (...args: DenoParams): Effect.Effect, MappedError> => { + return Effect.tryPromise, MappedError>({ + try: async (signal): Promise> => + await method(signal)(...args), + catch: (err): MappedError => onError(err as Error, args), + }); + }; diff --git a/packages/platform-deno/src/internal/error.ts b/packages/platform-deno/src/internal/error.ts new file mode 100644 index 0000000..3a51e6e --- /dev/null +++ b/packages/platform-deno/src/internal/error.ts @@ -0,0 +1,67 @@ +import type { Buffer } from "node:buffer"; +import { + type PlatformError, + SystemError, + type SystemErrorReason, +} from "@effect/platform/Error"; + +/** @internal */ +export const handleErrnoException = + (module: SystemError["module"], method: string) => + ( + err: Error, + [path]: readonly [ + path?: string | URL | Buffer | number, + ...args: unknown[], + ] = [], + ): PlatformError => { + let reason: SystemErrorReason = "Unknown"; + + switch (err.constructor) { + case Deno.errors.NotFound: + reason = "NotFound"; + break; + case Deno.errors.InvalidData: + reason = "InvalidData"; + break; + + case Deno.errors.TimedOut: + reason = "TimedOut"; + break; + + case Deno.errors.UnexpectedEof: + reason = "UnexpectedEof"; + break; + + case Deno.errors.PermissionDenied: + reason = "PermissionDenied"; + break; + + case Deno.errors.AlreadyExists: + reason = "AlreadyExists"; + break; + + case Deno.errors.BadResource: + case Deno.errors.IsADirectory: + case Deno.errors.NotADirectory: + case Deno.errors.FilesystemLoop: + reason = "BadResource"; + break; + + case Deno.errors.Busy: + reason = "Busy"; + break; + } + + return SystemError({ + reason, + module, + method, + pathOrDescriptor: + typeof path === "number" || typeof path === "string" + ? path + : (path?.toString() ?? ""), + syscall: undefined, // TODO: Figure out how to syscall. + message: err.message, + }); + }; From 9d6a80f5367932b64153ddd3422ffe79e0ff2d6c Mon Sep 17 00:00:00 2001 From: Eli <88557639+lishaduck@users.noreply.github.com> Date: Mon, 18 Nov 2024 12:36:12 -0600 Subject: [PATCH 2/3] chore: add fs tests --- .../tests/DenoFileSystem.test.ts | 227 ++++++++++++++++++ .../platform-deno/tests/fixtures/text.txt | 1 + 2 files changed, 228 insertions(+) create mode 100644 packages/platform-deno/tests/DenoFileSystem.test.ts create mode 100644 packages/platform-deno/tests/fixtures/text.txt diff --git a/packages/platform-deno/tests/DenoFileSystem.test.ts b/packages/platform-deno/tests/DenoFileSystem.test.ts new file mode 100644 index 0000000..970287e --- /dev/null +++ b/packages/platform-deno/tests/DenoFileSystem.test.ts @@ -0,0 +1,227 @@ +import * as Fs from "@effect/platform/FileSystem"; +import { it } from "@effect/vitest"; +import { Chunk, Effect, Stream, pipe } from "effect"; +import * as DenoFileSystem from "../src/DenoFileSystem.ts"; + +it.layer(DenoFileSystem.layer)("FileSystem", (it) => { + it.effect("readFile", ({ expect }) => + Effect.gen(function* () { + const fs = yield* Fs.FileSystem; + const data = yield* fs.readFile( + `${import.meta.dirname}/fixtures/text.txt`, + ); + const text = new TextDecoder().decode(data); + expect(text.trim()).toEqual("lorem ipsum dolar sit amet"); + }), + ); + + it.scoped("makeTempDirectory", ({ expect }) => + Effect.gen(function* () { + const fs = yield* Fs.FileSystem; + let dir = ""; + yield* Effect.gen(function* () { + dir = yield* fs.makeTempDirectory(); + const stat = yield* fs.stat(dir); + expect(stat.type).toEqual("Directory"); + }); + const stat = yield* fs.stat(dir); + expect(stat.type).toEqual("Directory"); + }), + ); + + it.scoped("makeTempDirectoryScoped", ({ expect }) => + Effect.gen(function* () { + const fs = yield* Fs.FileSystem; + let dir = ""; + yield* Effect.gen(function* () { + dir = yield* fs.makeTempDirectoryScoped(); + const stat = yield* fs.stat(dir); + expect(stat.type).toEqual("Directory"); + }) + // TODO: Figure out why removing this causes this error: + // TypeError: Do not know how to serialize a BigInt\n at undefined + .pipe(Effect.scoped); + const error = yield* Effect.flip(fs.stat(dir)); + expect(error._tag === "SystemError" && error.reason === "NotFound").toBe( + true, + ); + }), + ); + + it.effect("truncate", ({ expect }) => + Effect.gen(function* () { + const fs = yield* Fs.FileSystem; + const file = yield* fs.makeTempFile(); + + const text = "hello world"; + yield* fs.writeFile(file, new TextEncoder().encode(text)); + + const before = yield* pipe( + fs.readFile(file), + Effect.map((_) => new TextDecoder().decode(_)), + ); + expect(before).toEqual(text); + + yield* fs.truncate(file); + + const after = yield* pipe( + fs.readFile(file), + Effect.map((_) => new TextDecoder().decode(_)), + ); + expect(after).toEqual(""); + }), + ); + + it.scoped( + "should track the cursor position when reading", + ({ expect }) => + Effect.gen(function* () { + const fs = yield* Fs.FileSystem; + + let text: string; + const file = yield* pipe( + fs.open(`${import.meta.dirname}/fixtures/text.txt`), + ); + + text = yield* pipe( + Effect.flatten(file.readAlloc(Fs.Size(5))), + Effect.map((_) => new TextDecoder().decode(_)), + ); + expect(text).toBe("lorem"); + + yield* file.seek(Fs.Size(7), "current"); + text = yield* pipe( + Effect.flatten(file.readAlloc(Fs.Size(5))), + Effect.map((_) => new TextDecoder().decode(_)), + ); + expect(text).toBe("dolar"); + + yield* file.seek(Fs.Size(1), "current"); + text = yield* pipe( + Effect.flatten(file.readAlloc(Fs.Size(8))), + Effect.map((_) => new TextDecoder().decode(_)), + ); + expect(text).toBe("sit amet"); + + yield* file.seek(Fs.Size(0), "start"); + text = yield* pipe( + Effect.flatten(file.readAlloc(Fs.Size(11))), + Effect.map((_) => new TextDecoder().decode(_)), + ); + expect(text).toBe("lorem ipsum"); + + text = yield* pipe( + fs.stream(`${import.meta.dirname}/fixtures/text.txt`, { + offset: Fs.Size(6), + bytesToRead: Fs.Size(5), + }), + Stream.map((_) => new TextDecoder().decode(_)), + Stream.runCollect, + Effect.map(Chunk.join("")), + ); + expect(text).toBe("ipsum"); + }), + { fails: true }, // The Node-compat layer for `NFS.read` is buggy. + ); + + it.scoped("should track the cursor position when writing", ({ expect }) => + Effect.gen(function* () { + const fs = yield* Fs.FileSystem; + + let text: string; + const path = yield* fs.makeTempFileScoped(); + const file = yield* fs.open(path, { flag: "w+" }); + + yield* file.write(new TextEncoder().encode("lorem ipsum")); + yield* file.write(new TextEncoder().encode(" ")); + yield* file.write(new TextEncoder().encode("dolor sit amet")); + text = yield* fs.readFileString(path); + expect(text).toBe("lorem ipsum dolor sit amet"); + + yield* file.seek(Fs.Size(-4), "current"); + yield* file.write(new TextEncoder().encode("hello world")); + text = yield* fs.readFileString(path); + expect(text).toBe("lorem ipsum dolor sit hello world"); + + yield* file.seek(Fs.Size(6), "start"); + yield* file.write(new TextEncoder().encode("blabl")); + text = yield* fs.readFileString(path); + expect(text).toBe("lorem blabl dolor sit hello world"); + }), + ); + + it.scoped( + "should maintain a read cursor in append mode", + ({ expect }) => + Effect.gen(function* () { + const fs = yield* Fs.FileSystem; + + let text: string; + const path = yield* fs.makeTempFileScoped(); + const file = yield* fs.open(path, { flag: "a+" }); + + yield* file.write(new TextEncoder().encode("foo")); + yield* file.seek(Fs.Size(0), "start"); + + yield* file.write(new TextEncoder().encode("bar")); + text = yield* fs.readFileString(path); + expect(text).toBe("foobar"); + + text = yield* pipe( + Effect.flatten(file.readAlloc(Fs.Size(3))), + Effect.map((_) => new TextDecoder().decode(_)), + ); + expect(text).toBe("foo"); + + yield* file.write(new TextEncoder().encode("baz")); + text = yield* fs.readFileString(path); + expect(text).toBe("foobarbaz"); + + text = yield* pipe( + Effect.flatten(file.readAlloc(Fs.Size(6))), + Effect.map((_) => new TextDecoder().decode(_)), + ); + expect(text).toBe("barbaz"); + }), + { fails: true }, // The Node-compat layer for `NFS.read` is buggy. + ); + + it.scoped( + "should keep the current cursor if truncating doesn't affect it", + ({ expect }) => + Effect.gen(function* () { + const fs = yield* Fs.FileSystem; + + const path = yield* fs.makeTempFileScoped(); + const file = yield* fs.open(path, { flag: "w+" }); + + yield* pipe( + file.write(new TextEncoder().encode("lorem ipsum dolor sit amet")), + ); + yield* file.seek(Fs.Size(6), "start"); + yield* file.truncate(Fs.Size(11)); + + const cursor = yield* file.seek(Fs.Size(0), "current"); + expect(cursor).toBe(Fs.Size(6)); + }), + ); + + it.scoped( + "should update the current cursor if truncating affects it", + ({ expect }) => + Effect.gen(function* () { + const fs = yield* Fs.FileSystem; + + const path = yield* fs.makeTempFileScoped(); + const file = yield* fs.open(path, { flag: "w+" }); + + yield* pipe( + file.write(new TextEncoder().encode("lorem ipsum dolor sit amet")), + ); + yield* file.truncate(Fs.Size(11)); + + const cursor = yield* file.seek(Fs.Size(0), "current"); + expect(cursor).toBe(Fs.Size(11)); + }), + ); +}); diff --git a/packages/platform-deno/tests/fixtures/text.txt b/packages/platform-deno/tests/fixtures/text.txt new file mode 100644 index 0000000..72b190e --- /dev/null +++ b/packages/platform-deno/tests/fixtures/text.txt @@ -0,0 +1 @@ +lorem ipsum dolar sit amet From 1274a25aa7e18534326f905fb58bc4096708d98c Mon Sep 17 00:00:00 2001 From: Eli <88557639+lishaduck@users.noreply.github.com> Date: Sun, 24 Nov 2024 16:36:01 -0600 Subject: [PATCH 3/3] fix: pin @types/node to ts5.6 5.7 includes some breaking fixes that Deno isn't pulling in until 2.2. --- deno.lock | 8 ++++---- packages/platform-deno/package.json | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/deno.lock b/deno.lock index d6a5415..e878113 100644 --- a/deno.lock +++ b/deno.lock @@ -11,7 +11,7 @@ "npm:@effect/vitest@~0.13.15": "0.13.15_effect@3.10.15_vitest@2.1.5__vite@5.4.11", "npm:@jsr/std__fs@^1.0.5": "1.0.5", "npm:@jsr/std__path@^1.0.8": "1.0.8", - "npm:@types/node@^22.9.0": "22.9.0", + "npm:@types/node@<22.7.4": "22.7.3", "npm:@vitest/coverage-v8@^2.1.5": "2.1.5_vitest@2.1.5__vite@5.4.11", "npm:@vitest/ui@^2.1.5": "2.1.5_vitest@2.1.5__vite@5.4.11", "npm:effect@^3.10.15": "3.10.15", @@ -378,8 +378,8 @@ "@types/estree@1.0.6": { "integrity": "sha512-AYnb1nQyY49te+VRAVgmzfcgjYS91mY5P0TKUDCLEM+gNnA+3T6rWITXRLYCpahpqSQbN5cE+gHpnPyXjHWxcw==" }, - "@types/node@22.9.0": { - "integrity": "sha512-vuyHg81vvWA1Z1ELfvLko2c8f34gyA0zaic0+Rllc5lbCnbSyuvb2Oxpm6TAUAC/2xZN3QGqxBNggD1nNR2AfQ==", + "@types/node@22.7.3": { + "integrity": "sha512-qXKfhXXqGTyBskvWEzJZPUxSslAiLaB6JGP1ic/XTH9ctGgzdgYguuLP1C601aRTSDNlLb0jbKqXjZ48GNraSA==", "dependencies": [ "undici-types" ] @@ -1067,7 +1067,7 @@ "npm:@effect/platform@~0.69.24", "npm:@jsr/std__fs@^1.0.5", "npm:@jsr/std__path@^1.0.8", - "npm:@types/node@^22.9.0", + "npm:@types/node@<22.7.4", "npm:effect@^3.10.15" ] } diff --git a/packages/platform-deno/package.json b/packages/platform-deno/package.json index 5a7fe8e..ae55367 100644 --- a/packages/platform-deno/package.json +++ b/packages/platform-deno/package.json @@ -6,7 +6,7 @@ "@std/path": "npm:@jsr/std__path@^1.0.8" }, "devDependencies": { - "@types/node": "npm:@types/node@^22.9.0", + "@types/node": "npm:@types/node@<22.7.4", "effect": "npm:effect@^3.10.15", "@effect/platform": "npm:@effect/platform@^0.69.24", "@effect/platform-node": "npm:@effect/platform-node@^0.64.26",