| /** | |
| * @fileoverview The main file for the hfs package. | |
| * @author Nicholas C. Zakas | |
| */ | |
| /* global Buffer:readonly, URL */ | |
| //----------------------------------------------------------------------------- | |
| // Types | |
| //----------------------------------------------------------------------------- | |
| /** @typedef {import("@humanfs/types").HfsImpl} HfsImpl */ | |
| /** @typedef {import("@humanfs/types").HfsDirectoryEntry} HfsDirectoryEntry */ | |
| /** @typedef {import("node:fs/promises")} Fsp */ | |
| /** @typedef {import("fs").Dirent} Dirent */ | |
| //----------------------------------------------------------------------------- | |
| // Imports | |
| //----------------------------------------------------------------------------- | |
| import { Hfs } from "@humanfs/core"; | |
| import path from "node:path"; | |
| import { Retrier } from "@humanwhocodes/retry"; | |
| import nativeFsp from "node:fs/promises"; | |
| import { fileURLToPath } from "node:url"; | |
| //----------------------------------------------------------------------------- | |
| // Constants | |
| //----------------------------------------------------------------------------- | |
| const RETRY_ERROR_CODES = new Set(["ENFILE", "EMFILE"]); | |
| //----------------------------------------------------------------------------- | |
| // Helpers | |
| //----------------------------------------------------------------------------- | |
| /** | |
| * A class representing a directory entry. | |
| * @implements {HfsDirectoryEntry} | |
| */ | |
| class NodeHfsDirectoryEntry { | |
| /** | |
| * The name of the directory entry. | |
| * @type {string} | |
| */ | |
| name; | |
| /** | |
| * True if the entry is a file. | |
| * @type {boolean} | |
| */ | |
| isFile; | |
| /** | |
| * True if the entry is a directory. | |
| * @type {boolean} | |
| */ | |
| isDirectory; | |
| /** | |
| * True if the entry is a symbolic link. | |
| * @type {boolean} | |
| */ | |
| isSymlink; | |
| /** | |
| * Creates a new instance. | |
| * @param {Dirent} dirent The directory entry to wrap. | |
| */ | |
| constructor(dirent) { | |
| this.name = dirent.name; | |
| this.isFile = dirent.isFile(); | |
| this.isDirectory = dirent.isDirectory(); | |
| this.isSymlink = dirent.isSymbolicLink(); | |
| } | |
| } | |
| //----------------------------------------------------------------------------- | |
| // Exports | |
| //----------------------------------------------------------------------------- | |
| /** | |
| * A class representing the Node.js implementation of Hfs. | |
| * @implements {HfsImpl} | |
| */ | |
| export class NodeHfsImpl { | |
| /** | |
| * The file system module to use. | |
| * @type {Fsp} | |
| */ | |
| #fsp; | |
| /** | |
| * The retryer object used for retrying operations. | |
| * @type {Retrier} | |
| */ | |
| #retrier; | |
| /** | |
| * Creates a new instance. | |
| * @param {object} [options] The options for the instance. | |
| * @param {Fsp} [options.fsp] The file system module to use. | |
| */ | |
| constructor({ fsp = nativeFsp } = {}) { | |
| this.#fsp = fsp; | |
| this.#retrier = new Retrier(error => RETRY_ERROR_CODES.has(error.code)); | |
| } | |
| /** | |
| * Reads a file and returns the contents as an Uint8Array. | |
| * @param {string|URL} filePath The path to the file to read. | |
| * @returns {Promise<Uint8Array|undefined>} A promise that resolves with the contents | |
| * of the file or undefined if the file doesn't exist. | |
| * @throws {Error} If the file cannot be read. | |
| * @throws {TypeError} If the file path is not a string. | |
| */ | |
| bytes(filePath) { | |
| return this.#retrier | |
| .retry(() => this.#fsp.readFile(filePath)) | |
| .then(buffer => new Uint8Array(buffer.buffer)) | |
| .catch(error => { | |
| if (error.code === "ENOENT") { | |
| return undefined; | |
| } | |
| throw error; | |
| }); | |
| } | |
| /** | |
| * Writes a value to a file. If the value is a string, UTF-8 encoding is used. | |
| * @param {string|URL} filePath The path to the file to write. | |
| * @param {Uint8Array} contents The contents to write to the | |
| * file. | |
| * @returns {Promise<void>} A promise that resolves when the file is | |
| * written. | |
| * @throws {TypeError} If the file path is not a string. | |
| * @throws {Error} If the file cannot be written. | |
| */ | |
| async write(filePath, contents) { | |
| const value = Buffer.from(contents); | |
| return this.#retrier | |
| .retry(() => this.#fsp.writeFile(filePath, value)) | |
| .catch(error => { | |
| // the directory may not exist, so create it | |
| if (error.code === "ENOENT") { | |
| const dirPath = path.dirname( | |
| filePath instanceof URL | |
| ? fileURLToPath(filePath) | |
| : filePath, | |
| ); | |
| return this.#fsp | |
| .mkdir(dirPath, { recursive: true }) | |
| .then(() => this.#fsp.writeFile(filePath, value)); | |
| } | |
| throw error; | |
| }); | |
| } | |
| /** | |
| * Appends a value to a file. If the value is a string, UTF-8 encoding is used. | |
| * @param {string|URL} filePath The path to the file to append to. | |
| * @param {Uint8Array} contents The contents to append to the | |
| * file. | |
| * @returns {Promise<void>} A promise that resolves when the file is | |
| * written. | |
| * @throws {TypeError} If the file path is not a string. | |
| * @throws {Error} If the file cannot be appended to. | |
| */ | |
| async append(filePath, contents) { | |
| const value = Buffer.from(contents); | |
| return this.#retrier | |
| .retry(() => this.#fsp.appendFile(filePath, value)) | |
| .catch(error => { | |
| // the directory may not exist, so create it | |
| if (error.code === "ENOENT") { | |
| const dirPath = path.dirname( | |
| filePath instanceof URL | |
| ? fileURLToPath(filePath) | |
| : filePath, | |
| ); | |
| return this.#fsp | |
| .mkdir(dirPath, { recursive: true }) | |
| .then(() => this.#fsp.appendFile(filePath, value)); | |
| } | |
| throw error; | |
| }); | |
| } | |
| /** | |
| * Checks if a file exists. | |
| * @param {string|URL} filePath The path to the file to check. | |
| * @returns {Promise<boolean>} A promise that resolves with true if the | |
| * file exists or false if it does not. | |
| * @throws {Error} If the operation fails with a code other than ENOENT. | |
| */ | |
| isFile(filePath) { | |
| return this.#fsp | |
| .stat(filePath) | |
| .then(stat => stat.isFile()) | |
| .catch(error => { | |
| if (error.code === "ENOENT") { | |
| return false; | |
| } | |
| throw error; | |
| }); | |
| } | |
| /** | |
| * Checks if a directory exists. | |
| * @param {string|URL} dirPath The path to the directory to check. | |
| * @returns {Promise<boolean>} A promise that resolves with true if the | |
| * directory exists or false if it does not. | |
| * @throws {Error} If the operation fails with a code other than ENOENT. | |
| */ | |
| isDirectory(dirPath) { | |
| return this.#fsp | |
| .stat(dirPath) | |
| .then(stat => stat.isDirectory()) | |
| .catch(error => { | |
| if (error.code === "ENOENT") { | |
| return false; | |
| } | |
| throw error; | |
| }); | |
| } | |
| /** | |
| * Creates a directory recursively. | |
| * @param {string|URL} dirPath The path to the directory to create. | |
| * @returns {Promise<void>} A promise that resolves when the directory is | |
| * created. | |
| */ | |
| async createDirectory(dirPath) { | |
| await this.#fsp.mkdir(dirPath, { recursive: true }); | |
| } | |
| /** | |
| * Deletes a file or empty directory. | |
| * @param {string|URL} fileOrDirPath The path to the file or directory to | |
| * delete. | |
| * @returns {Promise<boolean>} A promise that resolves when the file or | |
| * directory is deleted, true if the file or directory is deleted, false | |
| * if the file or directory does not exist. | |
| * @throws {TypeError} If the file or directory path is not a string. | |
| * @throws {Error} If the file or directory cannot be deleted. | |
| */ | |
| delete(fileOrDirPath) { | |
| return this.#fsp | |
| .rm(fileOrDirPath) | |
| .then(() => true) | |
| .catch(error => { | |
| if (error.code === "ERR_FS_EISDIR") { | |
| return this.#fsp.rmdir(fileOrDirPath).then(() => true); | |
| } | |
| if (error.code === "ENOENT") { | |
| return false; | |
| } | |
| throw error; | |
| }); | |
| } | |
| /** | |
| * Deletes a file or directory recursively. | |
| * @param {string|URL} fileOrDirPath The path to the file or directory to | |
| * delete. | |
| * @returns {Promise<boolean>} A promise that resolves when the file or | |
| * directory is deleted, true if the file or directory is deleted, false | |
| * if the file or directory does not exist. | |
| * @throws {TypeError} If the file or directory path is not a string. | |
| * @throws {Error} If the file or directory cannot be deleted. | |
| */ | |
| deleteAll(fileOrDirPath) { | |
| return this.#fsp | |
| .rm(fileOrDirPath, { recursive: true }) | |
| .then(() => true) | |
| .catch(error => { | |
| if (error.code === "ENOENT") { | |
| return false; | |
| } | |
| throw error; | |
| }); | |
| } | |
| /** | |
| * Returns a list of directory entries for the given path. | |
| * @param {string|URL} dirPath The path to the directory to read. | |
| * @returns {AsyncIterable<HfsDirectoryEntry>} A promise that resolves with the | |
| * directory entries. | |
| * @throws {TypeError} If the directory path is not a string. | |
| * @throws {Error} If the directory cannot be read. | |
| */ | |
| async *list(dirPath) { | |
| const entries = await this.#fsp.readdir(dirPath, { | |
| withFileTypes: true, | |
| }); | |
| for (const entry of entries) { | |
| yield new NodeHfsDirectoryEntry(entry); | |
| } | |
| } | |
| /** | |
| * Returns the size of a file. This method handles ENOENT errors | |
| * and returns undefined in that case. | |
| * @param {string|URL} filePath The path to the file to read. | |
| * @returns {Promise<number|undefined>} A promise that resolves with the size of the | |
| * file in bytes or undefined if the file doesn't exist. | |
| */ | |
| size(filePath) { | |
| return this.#fsp | |
| .stat(filePath) | |
| .then(stat => stat.size) | |
| .catch(error => { | |
| if (error.code === "ENOENT") { | |
| return undefined; | |
| } | |
| throw error; | |
| }); | |
| } | |
| /** | |
| * Returns the last modified date of a file or directory. This method handles ENOENT errors | |
| * and returns undefined in that case. | |
| * @param {string|URL} fileOrDirPath The path to the file to read. | |
| * @returns {Promise<Date|undefined>} A promise that resolves with the last modified | |
| * date of the file or directory, or undefined if the file doesn't exist. | |
| */ | |
| lastModified(fileOrDirPath) { | |
| return this.#fsp | |
| .stat(fileOrDirPath) | |
| .then(stat => stat.mtime) | |
| .catch(error => { | |
| if (error.code === "ENOENT") { | |
| return undefined; | |
| } | |
| throw error; | |
| }); | |
| } | |
| /** | |
| * Copies a file from one location to another. | |
| * @param {string|URL} source The path to the file to copy. | |
| * @param {string|URL} destination The path to copy the file to. | |
| * @returns {Promise<void>} A promise that resolves when the file is copied. | |
| * @throws {Error} If the source file does not exist. | |
| * @throws {Error} If the source file is a directory. | |
| * @throws {Error} If the destination file is a directory. | |
| */ | |
| copy(source, destination) { | |
| return this.#fsp.copyFile(source, destination); | |
| } | |
| /** | |
| * Copies a file or directory from one location to another. | |
| * @param {string|URL} source The path to the file or directory to copy. | |
| * @param {string|URL} destination The path to copy the file or directory to. | |
| * @returns {Promise<void>} A promise that resolves when the file or directory is | |
| * copied. | |
| * @throws {Error} If the source file or directory does not exist. | |
| * @throws {Error} If the destination file or directory is a directory. | |
| */ | |
| async copyAll(source, destination) { | |
| // for files use copy() and exit | |
| if (await this.isFile(source)) { | |
| return this.copy(source, destination); | |
| } | |
| const sourceStr = | |
| source instanceof URL ? fileURLToPath(source) : source; | |
| const destinationStr = | |
| destination instanceof URL | |
| ? fileURLToPath(destination) | |
| : destination; | |
| // for directories, create the destination directory and copy each entry | |
| await this.createDirectory(destination); | |
| for await (const entry of this.list(source)) { | |
| const fromEntryPath = path.join(sourceStr, entry.name); | |
| const toEntryPath = path.join(destinationStr, entry.name); | |
| if (entry.isDirectory) { | |
| await this.copyAll(fromEntryPath, toEntryPath); | |
| } else { | |
| await this.copy(fromEntryPath, toEntryPath); | |
| } | |
| } | |
| } | |
| /** | |
| * Moves a file from the source path to the destination path. | |
| * @param {string|URL} source The location of the file to move. | |
| * @param {string|URL} destination The destination of the file to move. | |
| * @returns {Promise<void>} A promise that resolves when the move is complete. | |
| * @throws {TypeError} If the file paths are not strings. | |
| * @throws {Error} If the file cannot be moved. | |
| */ | |
| move(source, destination) { | |
| return this.#fsp.stat(source).then(stat => { | |
| if (stat.isDirectory()) { | |
| throw new Error( | |
| `EISDIR: illegal operation on a directory, move '${source}' -> '${destination}'`, | |
| ); | |
| } | |
| return this.#fsp.rename(source, destination); | |
| }); | |
| } | |
| /** | |
| * Moves a file or directory from the source path to the destination path. | |
| * @param {string|URL} source The location of the file or directory to move. | |
| * @param {string|URL} destination The destination of the file or directory to move. | |
| * @returns {Promise<void>} A promise that resolves when the move is complete. | |
| * @throws {TypeError} If the file paths are not strings. | |
| * @throws {Error} If the file or directory cannot be moved. | |
| */ | |
| async moveAll(source, destination) { | |
| return this.#fsp.rename(source, destination); | |
| } | |
| } | |
| /** | |
| * A class representing a file system utility library. | |
| * @implements {HfsImpl} | |
| */ | |
| export class NodeHfs extends Hfs { | |
| /** | |
| * Creates a new instance. | |
| * @param {object} [options] The options for the instance. | |
| * @param {Fsp} [options.fsp] The file system module to use. | |
| */ | |
| constructor({ fsp } = {}) { | |
| super({ impl: new NodeHfsImpl({ fsp }) }); | |
| } | |
| } | |
| export const hfs = new NodeHfs(); | |