node-hfs.js 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452
  1. /**
  2. * @fileoverview The main file for the hfs package.
  3. * @author Nicholas C. Zakas
  4. */
  5. /* global Buffer:readonly, URL */
  6. //-----------------------------------------------------------------------------
  7. // Types
  8. //-----------------------------------------------------------------------------
  9. /** @typedef {import("@humanfs/types").HfsImpl} HfsImpl */
  10. /** @typedef {import("@humanfs/types").HfsDirectoryEntry} HfsDirectoryEntry */
  11. /** @typedef {import("node:fs/promises")} Fsp */
  12. /** @typedef {import("fs").Dirent} Dirent */
  13. //-----------------------------------------------------------------------------
  14. // Imports
  15. //-----------------------------------------------------------------------------
  16. import { Hfs } from "@humanfs/core";
  17. import path from "node:path";
  18. import { Retrier } from "@humanwhocodes/retry";
  19. import nativeFsp from "node:fs/promises";
  20. import { fileURLToPath } from "node:url";
  21. //-----------------------------------------------------------------------------
  22. // Constants
  23. //-----------------------------------------------------------------------------
  24. const RETRY_ERROR_CODES = new Set(["ENFILE", "EMFILE"]);
  25. //-----------------------------------------------------------------------------
  26. // Helpers
  27. //-----------------------------------------------------------------------------
  28. /**
  29. * A class representing a directory entry.
  30. * @implements {HfsDirectoryEntry}
  31. */
  32. class NodeHfsDirectoryEntry {
  33. /**
  34. * The name of the directory entry.
  35. * @type {string}
  36. */
  37. name;
  38. /**
  39. * True if the entry is a file.
  40. * @type {boolean}
  41. */
  42. isFile;
  43. /**
  44. * True if the entry is a directory.
  45. * @type {boolean}
  46. */
  47. isDirectory;
  48. /**
  49. * True if the entry is a symbolic link.
  50. * @type {boolean}
  51. */
  52. isSymlink;
  53. /**
  54. * Creates a new instance.
  55. * @param {Dirent} dirent The directory entry to wrap.
  56. */
  57. constructor(dirent) {
  58. this.name = dirent.name;
  59. this.isFile = dirent.isFile();
  60. this.isDirectory = dirent.isDirectory();
  61. this.isSymlink = dirent.isSymbolicLink();
  62. }
  63. }
  64. //-----------------------------------------------------------------------------
  65. // Exports
  66. //-----------------------------------------------------------------------------
  67. /**
  68. * A class representing the Node.js implementation of Hfs.
  69. * @implements {HfsImpl}
  70. */
  71. export class NodeHfsImpl {
  72. /**
  73. * The file system module to use.
  74. * @type {Fsp}
  75. */
  76. #fsp;
  77. /**
  78. * The retryer object used for retrying operations.
  79. * @type {Retrier}
  80. */
  81. #retrier;
  82. /**
  83. * Creates a new instance.
  84. * @param {object} [options] The options for the instance.
  85. * @param {Fsp} [options.fsp] The file system module to use.
  86. */
  87. constructor({ fsp = nativeFsp } = {}) {
  88. this.#fsp = fsp;
  89. this.#retrier = new Retrier(error => RETRY_ERROR_CODES.has(error.code));
  90. }
  91. /**
  92. * Reads a file and returns the contents as an Uint8Array.
  93. * @param {string|URL} filePath The path to the file to read.
  94. * @returns {Promise<Uint8Array|undefined>} A promise that resolves with the contents
  95. * of the file or undefined if the file doesn't exist.
  96. * @throws {Error} If the file cannot be read.
  97. * @throws {TypeError} If the file path is not a string.
  98. */
  99. bytes(filePath) {
  100. return this.#retrier
  101. .retry(() => this.#fsp.readFile(filePath))
  102. .then(buffer => new Uint8Array(buffer.buffer))
  103. .catch(error => {
  104. if (error.code === "ENOENT") {
  105. return undefined;
  106. }
  107. throw error;
  108. });
  109. }
  110. /**
  111. * Writes a value to a file. If the value is a string, UTF-8 encoding is used.
  112. * @param {string|URL} filePath The path to the file to write.
  113. * @param {Uint8Array} contents The contents to write to the
  114. * file.
  115. * @returns {Promise<void>} A promise that resolves when the file is
  116. * written.
  117. * @throws {TypeError} If the file path is not a string.
  118. * @throws {Error} If the file cannot be written.
  119. */
  120. async write(filePath, contents) {
  121. const value = Buffer.from(contents);
  122. return this.#retrier
  123. .retry(() => this.#fsp.writeFile(filePath, value))
  124. .catch(error => {
  125. // the directory may not exist, so create it
  126. if (error.code === "ENOENT") {
  127. const dirPath = path.dirname(
  128. filePath instanceof URL
  129. ? fileURLToPath(filePath)
  130. : filePath,
  131. );
  132. return this.#fsp
  133. .mkdir(dirPath, { recursive: true })
  134. .then(() => this.#fsp.writeFile(filePath, value));
  135. }
  136. throw error;
  137. });
  138. }
  139. /**
  140. * Appends a value to a file. If the value is a string, UTF-8 encoding is used.
  141. * @param {string|URL} filePath The path to the file to append to.
  142. * @param {Uint8Array} contents The contents to append to the
  143. * file.
  144. * @returns {Promise<void>} A promise that resolves when the file is
  145. * written.
  146. * @throws {TypeError} If the file path is not a string.
  147. * @throws {Error} If the file cannot be appended to.
  148. */
  149. async append(filePath, contents) {
  150. const value = Buffer.from(contents);
  151. return this.#retrier
  152. .retry(() => this.#fsp.appendFile(filePath, value))
  153. .catch(error => {
  154. // the directory may not exist, so create it
  155. if (error.code === "ENOENT") {
  156. const dirPath = path.dirname(
  157. filePath instanceof URL
  158. ? fileURLToPath(filePath)
  159. : filePath,
  160. );
  161. return this.#fsp
  162. .mkdir(dirPath, { recursive: true })
  163. .then(() => this.#fsp.appendFile(filePath, value));
  164. }
  165. throw error;
  166. });
  167. }
  168. /**
  169. * Checks if a file exists.
  170. * @param {string|URL} filePath The path to the file to check.
  171. * @returns {Promise<boolean>} A promise that resolves with true if the
  172. * file exists or false if it does not.
  173. * @throws {Error} If the operation fails with a code other than ENOENT.
  174. */
  175. isFile(filePath) {
  176. return this.#fsp
  177. .stat(filePath)
  178. .then(stat => stat.isFile())
  179. .catch(error => {
  180. if (error.code === "ENOENT") {
  181. return false;
  182. }
  183. throw error;
  184. });
  185. }
  186. /**
  187. * Checks if a directory exists.
  188. * @param {string|URL} dirPath The path to the directory to check.
  189. * @returns {Promise<boolean>} A promise that resolves with true if the
  190. * directory exists or false if it does not.
  191. * @throws {Error} If the operation fails with a code other than ENOENT.
  192. */
  193. isDirectory(dirPath) {
  194. return this.#fsp
  195. .stat(dirPath)
  196. .then(stat => stat.isDirectory())
  197. .catch(error => {
  198. if (error.code === "ENOENT") {
  199. return false;
  200. }
  201. throw error;
  202. });
  203. }
  204. /**
  205. * Creates a directory recursively.
  206. * @param {string|URL} dirPath The path to the directory to create.
  207. * @returns {Promise<void>} A promise that resolves when the directory is
  208. * created.
  209. */
  210. async createDirectory(dirPath) {
  211. await this.#fsp.mkdir(dirPath, { recursive: true });
  212. }
  213. /**
  214. * Deletes a file or empty directory.
  215. * @param {string|URL} fileOrDirPath The path to the file or directory to
  216. * delete.
  217. * @returns {Promise<boolean>} A promise that resolves when the file or
  218. * directory is deleted, true if the file or directory is deleted, false
  219. * if the file or directory does not exist.
  220. * @throws {TypeError} If the file or directory path is not a string.
  221. * @throws {Error} If the file or directory cannot be deleted.
  222. */
  223. delete(fileOrDirPath) {
  224. return this.#fsp
  225. .rm(fileOrDirPath)
  226. .then(() => true)
  227. .catch(error => {
  228. if (error.code === "ERR_FS_EISDIR") {
  229. return this.#fsp.rmdir(fileOrDirPath).then(() => true);
  230. }
  231. if (error.code === "ENOENT") {
  232. return false;
  233. }
  234. throw error;
  235. });
  236. }
  237. /**
  238. * Deletes a file or directory recursively.
  239. * @param {string|URL} fileOrDirPath The path to the file or directory to
  240. * delete.
  241. * @returns {Promise<boolean>} A promise that resolves when the file or
  242. * directory is deleted, true if the file or directory is deleted, false
  243. * if the file or directory does not exist.
  244. * @throws {TypeError} If the file or directory path is not a string.
  245. * @throws {Error} If the file or directory cannot be deleted.
  246. */
  247. deleteAll(fileOrDirPath) {
  248. return this.#fsp
  249. .rm(fileOrDirPath, { recursive: true })
  250. .then(() => true)
  251. .catch(error => {
  252. if (error.code === "ENOENT") {
  253. return false;
  254. }
  255. throw error;
  256. });
  257. }
  258. /**
  259. * Returns a list of directory entries for the given path.
  260. * @param {string|URL} dirPath The path to the directory to read.
  261. * @returns {AsyncIterable<HfsDirectoryEntry>} A promise that resolves with the
  262. * directory entries.
  263. * @throws {TypeError} If the directory path is not a string.
  264. * @throws {Error} If the directory cannot be read.
  265. */
  266. async *list(dirPath) {
  267. const entries = await this.#fsp.readdir(dirPath, {
  268. withFileTypes: true,
  269. });
  270. for (const entry of entries) {
  271. yield new NodeHfsDirectoryEntry(entry);
  272. }
  273. }
  274. /**
  275. * Returns the size of a file. This method handles ENOENT errors
  276. * and returns undefined in that case.
  277. * @param {string|URL} filePath The path to the file to read.
  278. * @returns {Promise<number|undefined>} A promise that resolves with the size of the
  279. * file in bytes or undefined if the file doesn't exist.
  280. */
  281. size(filePath) {
  282. return this.#fsp
  283. .stat(filePath)
  284. .then(stat => stat.size)
  285. .catch(error => {
  286. if (error.code === "ENOENT") {
  287. return undefined;
  288. }
  289. throw error;
  290. });
  291. }
  292. /**
  293. * Returns the last modified date of a file or directory. This method handles ENOENT errors
  294. * and returns undefined in that case.
  295. * @param {string|URL} fileOrDirPath The path to the file to read.
  296. * @returns {Promise<Date|undefined>} A promise that resolves with the last modified
  297. * date of the file or directory, or undefined if the file doesn't exist.
  298. */
  299. lastModified(fileOrDirPath) {
  300. return this.#fsp
  301. .stat(fileOrDirPath)
  302. .then(stat => stat.mtime)
  303. .catch(error => {
  304. if (error.code === "ENOENT") {
  305. return undefined;
  306. }
  307. throw error;
  308. });
  309. }
  310. /**
  311. * Copies a file from one location to another.
  312. * @param {string|URL} source The path to the file to copy.
  313. * @param {string|URL} destination The path to copy the file to.
  314. * @returns {Promise<void>} A promise that resolves when the file is copied.
  315. * @throws {Error} If the source file does not exist.
  316. * @throws {Error} If the source file is a directory.
  317. * @throws {Error} If the destination file is a directory.
  318. */
  319. copy(source, destination) {
  320. return this.#fsp.copyFile(source, destination);
  321. }
  322. /**
  323. * Copies a file or directory from one location to another.
  324. * @param {string|URL} source The path to the file or directory to copy.
  325. * @param {string|URL} destination The path to copy the file or directory to.
  326. * @returns {Promise<void>} A promise that resolves when the file or directory is
  327. * copied.
  328. * @throws {Error} If the source file or directory does not exist.
  329. * @throws {Error} If the destination file or directory is a directory.
  330. */
  331. async copyAll(source, destination) {
  332. // for files use copy() and exit
  333. if (await this.isFile(source)) {
  334. return this.copy(source, destination);
  335. }
  336. const sourceStr =
  337. source instanceof URL ? fileURLToPath(source) : source;
  338. const destinationStr =
  339. destination instanceof URL
  340. ? fileURLToPath(destination)
  341. : destination;
  342. // for directories, create the destination directory and copy each entry
  343. await this.createDirectory(destination);
  344. for await (const entry of this.list(source)) {
  345. const fromEntryPath = path.join(sourceStr, entry.name);
  346. const toEntryPath = path.join(destinationStr, entry.name);
  347. if (entry.isDirectory) {
  348. await this.copyAll(fromEntryPath, toEntryPath);
  349. } else {
  350. await this.copy(fromEntryPath, toEntryPath);
  351. }
  352. }
  353. }
  354. /**
  355. * Moves a file from the source path to the destination path.
  356. * @param {string|URL} source The location of the file to move.
  357. * @param {string|URL} destination The destination of the file to move.
  358. * @returns {Promise<void>} A promise that resolves when the move is complete.
  359. * @throws {TypeError} If the file paths are not strings.
  360. * @throws {Error} If the file cannot be moved.
  361. */
  362. move(source, destination) {
  363. return this.#fsp.stat(source).then(stat => {
  364. if (stat.isDirectory()) {
  365. throw new Error(
  366. `EISDIR: illegal operation on a directory, move '${source}' -> '${destination}'`,
  367. );
  368. }
  369. return this.#fsp.rename(source, destination);
  370. });
  371. }
  372. /**
  373. * Moves a file or directory from the source path to the destination path.
  374. * @param {string|URL} source The location of the file or directory to move.
  375. * @param {string|URL} destination The destination of the file or directory to move.
  376. * @returns {Promise<void>} A promise that resolves when the move is complete.
  377. * @throws {TypeError} If the file paths are not strings.
  378. * @throws {Error} If the file or directory cannot be moved.
  379. */
  380. async moveAll(source, destination) {
  381. return this.#fsp.rename(source, destination);
  382. }
  383. }
  384. /**
  385. * A class representing a file system utility library.
  386. * @implements {HfsImpl}
  387. */
  388. export class NodeHfs extends Hfs {
  389. /**
  390. * Creates a new instance.
  391. * @param {object} [options] The options for the instance.
  392. * @param {Fsp} [options.fsp] The file system module to use.
  393. */
  394. constructor({ fsp } = {}) {
  395. super({ impl: new NodeHfsImpl({ fsp }) });
  396. }
  397. }
  398. export const hfs = new NodeHfs();