diff --git a/doc/api/fs.md b/doc/api/fs.md index fa3368da281da4..c7f7b9b16a5eae 100644 --- a/doc/api/fs.md +++ b/doc/api/fs.md @@ -4377,10 +4377,6 @@ the returned {fs.FSWatcher}. The `fs.watch` API is not 100% consistent across platforms, and is unavailable in some situations. -The recursive option is only supported on macOS and Windows. -An `ERR_FEATURE_UNAVAILABLE_ON_PLATFORM` exception will be thrown -when the option is used on a platform that does not support it. - On Windows, no events will be emitted if the watched directory is moved or renamed. An `EPERM` error is reported when the watched directory is deleted. diff --git a/lib/fs.js b/lib/fs.js index 5e110eef17dd8b..480c0a980837dd 100644 --- a/lib/fs.js +++ b/lib/fs.js @@ -57,6 +57,7 @@ const { const pathModule = require('path'); const { isArrayBufferView } = require('internal/util/types'); +const linuxWatcher = require('internal/fs/linux_recursive_watcher'); // We need to get the statValues from the binding at the callsite since // it's re-initialized after deserialization. @@ -68,7 +69,6 @@ const { codes: { ERR_FS_FILE_TOO_LARGE, ERR_INVALID_ARG_VALUE, - ERR_FEATURE_UNAVAILABLE_ON_PLATFORM, }, AbortError, uvErrmapGet, @@ -161,7 +161,7 @@ let FileReadStream; let FileWriteStream; const isWindows = process.platform === 'win32'; -const isOSX = process.platform === 'darwin'; +const isLinux = process.platform === 'linux'; function showTruncateDeprecation() { @@ -2297,13 +2297,22 @@ function watch(filename, options, listener) { if (options.persistent === undefined) options.persistent = true; if (options.recursive === undefined) options.recursive = false; - if (options.recursive && !(isOSX || isWindows)) - throw new ERR_FEATURE_UNAVAILABLE_ON_PLATFORM('watch recursively'); - const watcher = new watchers.FSWatcher(); - watcher[watchers.kFSWatchStart](filename, - options.persistent, - options.recursive, - options.encoding); + + let watcher; + + // TODO(anonrig): Remove this when/if libuv supports it. + // libuv does not support recursive file watch on Linux due to + // the limitations of inotify. + if (options.recursive && isLinux) { + watcher = new linuxWatcher.FSWatcher(options); + watcher[linuxWatcher.kFSWatchStart](filename); + } else { + watcher = new watchers.FSWatcher(); + watcher[watchers.kFSWatchStart](filename, + options.persistent, + options.recursive, + options.encoding); + } if (listener) { watcher.addListener('change', listener); diff --git a/lib/internal/fs/linux_recursive_watcher.js b/lib/internal/fs/linux_recursive_watcher.js new file mode 100644 index 00000000000000..a80ef84259a435 --- /dev/null +++ b/lib/internal/fs/linux_recursive_watcher.js @@ -0,0 +1,154 @@ +'use strict'; + +const { EventEmitter } = require('events'); +const path = require('path'); +const { Symbol, ObjectKeys } = primordials; + +const kFSWatchStart = Symbol('kFSWatchStart'); + +let internalSync; +let internalPromises; + +function lazyLoadFsPromises() { + internalPromises ??= require('fs/promises'); + return internalPromises; +} + +function lazyLoadFsSync() { + internalSync ??= require('fs'); + return internalSync; +} + +async function traverse(dir, files = {}) { + const { stat, readdir } = lazyLoadFsPromises(); + + files[dir] = await stat(dir); + + try { + const directoryFiles = await readdir(dir); + + for (const file of directoryFiles) { + const f = path.join(dir, file); + + try { + const stats = await stat(f); + + files[f] = stats; + + if (stats.isDirectory()) { + await traverse(f, files); + } + } catch (error) { + if (error.code !== 'ENOENT' || error.code !== 'EPERM') { + throw error; + } + } + + } + } catch (error) { + if (error.code !== 'EACCES') { + throw error; + } + } + + return files; +} + +class FSWatcher extends EventEmitter { + #options = null; + #closed = false; + #files = {}; + + /** + * @param {{ + * persistent?: boolean; + * recursive?: boolean; + * encoding?: string; + * signal?: AbortSignal; + * }} [options] + */ + constructor(options) { + super(); + + this.#options = options || {}; + } + + async close() { + const { unwatchFile } = lazyLoadFsPromises(); + this.#closed = true; + + for (const file of ObjectKeys(this.#files)) { + await unwatchFile(file); + } + + this.emit('close'); + } + + /** + * @param {string} file + */ + #watchFile(file) { + const { readdir } = lazyLoadFsPromises(); + const { stat, watchFile } = lazyLoadFsSync(); + + watchFile(file, this.#options, (event, payload) => { + const existingStat = this.#files[file]; + + if (existingStat && !existingStat.isDirectory() && + event.nlink !== 0 && existingStat.mtime.getTime() === event.mtime.getTime()) { + return; + } + + this.#files[file] = event; + + if (!event.isDirectory()) { + this.emit(event, payload); + } else { + readdir(file) + .then((files) => { + for (const subfile of files) { + const f = path.join(file, subfile); + + if (!this.#files[f]) { + stat(f, (error, stat) => { + if (error) { + return; + } + + this.#files[f] = stat; + this.#watchFile(f); + }); + } + } + }); + } + }); + } + + /** + * @param {string | Buffer | URL} filename + */ + async [kFSWatchStart](filename) { + this.#closed = false; + this.#files = await traverse(filename); + + this.#watchFile(filename); + + for (const f in this.#files) { + this.#watchFile(f); + } + } + + /** + * @param {string} name + * @param {Function=} callback + */ + addEventListener(name, callback) { + this.on(name, (...args) => callback(...args)); + } +} + +module.exports = { + FSWatcher, + kFSWatchStart, +}; diff --git a/test/parallel/test-fs-watch-recursive.js b/test/parallel/test-fs-watch-recursive.js index 70b413814e78f4..b9f9ed34c004d0 100644 --- a/test/parallel/test-fs-watch-recursive.js +++ b/test/parallel/test-fs-watch-recursive.js @@ -17,12 +17,6 @@ tmpdir.refresh(); const testsubdir = fs.mkdtempSync(testDir + path.sep); const relativePathOne = path.join(path.basename(testsubdir), filenameOne); const filepathOne = path.join(testsubdir, filenameOne); - -if (!common.isOSX && !common.isWindows) { - assert.throws(() => { fs.watch(testDir, { recursive: true }); }, - { code: 'ERR_FEATURE_UNAVAILABLE_ON_PLATFORM' }); - return; -} const watcher = fs.watch(testDir, { recursive: true }); let watcherClosed = false;