Skip to content

Commit

Permalink
fs: add fsPromises.watch()
Browse files Browse the repository at this point in the history
An alternative to `fs.watch()` that returns an `AsyncIterator`

```js
const { watch } = require('fs/promises');

(async () => {

  const ac = new AbortController();
  const { signal } = ac;
  setTimeout(() => ac.abort(), 10000);

  const watcher = watch('file.txt', { signal });

  for await (const { eventType, filename } of watcher) {
    console.log(eventType, filename);
  }

})()
```

Signed-off-by: James M Snell <jasnell@gmail.com>

PR-URL: #37179
Reviewed-By: Benjamin Gruenbaum <benjamingr@gmail.com>
  • Loading branch information
jasnell authored and targos committed Sep 1, 2021
1 parent f5b2fe1 commit fe12cc0
Show file tree
Hide file tree
Showing 5 changed files with 286 additions and 7 deletions.
53 changes: 51 additions & 2 deletions doc/api/fs.md
Expand Up @@ -1181,6 +1181,55 @@ The `atime` and `mtime` arguments follow these rules:
* If the value can not be converted to a number, or is `NaN`, `Infinity` or
`-Infinity`, an `Error` will be thrown.
### `fsPromises.watch(filename[, options])`
<!-- YAML
added: REPLACEME
-->
* `filename` {string|Buffer|URL}
* `options` {string|Object}
* `persistent` {boolean} Indicates whether the process should continue to run
as long as files are being watched. **Default:** `true`.
* `recursive` {boolean} Indicates whether all subdirectories should be
watched, or only the current directory. This applies when a directory is
specified, and only on supported platforms (See [caveats][]). **Default:**
`false`.
* `encoding` {string} Specifies the character encoding to be used for the
filename passed to the listener. **Default:** `'utf8'`.
* `signal` {AbortSignal} An {AbortSignal} used to signal when the watcher
should stop.
* Returns: {AsyncIterator} of objects with the properties:
* `eventType` {string} The type of change
* `filename` {string|Buffer} The name of the file changed.
Returns an async iterator that watches for changes on `filename`, where `filename`
is either a file or a directory.
```js
const { watch } = require('fs/promises');
const ac = new AbortController();
const { signal } = ac;
setTimeout(() => ac.abort(), 10000);
(async () => {
try {
const watcher = watch(__filename, { signal });
for await (const event of watcher)
console.log(event);
} catch (err) {
if (err.name === 'AbortError')
return;
throw err;
}
})();
```
On most platforms, `'rename'` is emitted whenever a filename appears or
disappears in the directory.
All the [caveats][] for `fs.watch()` also apply to `fsPromises.watch()`.
### `fsPromises.writeFile(file, data[, options])`
<!-- YAML
added: v10.0.0
Expand Down Expand Up @@ -3461,7 +3510,7 @@ changes:
as long as files are being watched. **Default:** `true`.
* `recursive` {boolean} Indicates whether all subdirectories should be
watched, or only the current directory. This applies when a directory is
specified, and only on supported platforms (See [Caveats][]). **Default:**
specified, and only on supported platforms (See [caveats][]). **Default:**
`false`.
* `encoding` {string} Specifies the character encoding to be used for the
filename passed to the listener. **Default:** `'utf8'`.
Expand Down Expand Up @@ -6534,7 +6583,6 @@ A call to `fs.ftruncate()` or `filehandle.truncate()` can be used to reset
the file contents.
[#25741]: https://github.com/nodejs/node/issues/25741
[Caveats]: #fs_caveats
[Common System Errors]: errors.md#errors_common_system_errors
[File access constants]: #fs_file_access_constants
[MDN-Date]: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Date
Expand All @@ -6544,6 +6592,7 @@ the file contents.
[Naming Files, Paths, and Namespaces]: https://docs.microsoft.com/en-us/windows/desktop/FileIO/naming-a-file
[Readable Stream]: stream.md#stream_class_stream_readable
[Writable Stream]: stream.md#stream_class_stream_writable
[caveats]: #fs_caveats
[`AHAFS`]: https://www.ibm.com/developerworks/aix/library/au-aix_event_infrastructure/
[`Buffer.byteLength`]: buffer.md#buffer_static_method_buffer_bytelength_string_encoding
[`FSEvents`]: https://developer.apple.com/documentation/coreservices/file_system_events
Expand Down
2 changes: 2 additions & 0 deletions lib/internal/fs/promises.js
Expand Up @@ -72,6 +72,7 @@ const {
} = require('internal/validators');
const pathModule = require('path');
const { promisify } = require('internal/util');
const { watch } = require('internal/fs/watchers');

const kHandle = Symbol('kHandle');
const kFd = Symbol('kFd');
Expand Down Expand Up @@ -713,6 +714,7 @@ module.exports = {
writeFile,
appendFile,
readFile,
watch,
},

FileHandle
Expand Down
119 changes: 114 additions & 5 deletions lib/internal/fs/watchers.js
Expand Up @@ -3,27 +3,52 @@
const {
ObjectDefineProperty,
ObjectSetPrototypeOf,
Promise,
Symbol,
} = primordials;

const errors = require('internal/errors');
const {
AbortError,
uvException,
codes: {
ERR_INVALID_ARG_VALUE,
},
} = require('internal/errors');

const {
kFsStatsFieldsNumber,
StatWatcher: _StatWatcher
} = internalBinding('fs');

const { FSEvent } = internalBinding('fs_event_wrap');
const { UV_ENOSPC } = internalBinding('uv');
const { EventEmitter } = require('events');

const {
getStatsFromBinding,
getValidatedPath
} = require('internal/fs/utils');

const {
defaultTriggerAsyncIdScope,
symbols: { owner_symbol }
} = require('internal/async_hooks');

const { toNamespacedPath } = require('path');
const { validateUint32 } = require('internal/validators');

const {
validateAbortSignal,
validateBoolean,
validateObject,
validateUint32,
} = require('internal/validators');

const {
Buffer: {
isEncoding,
},
} = require('buffer');

const assert = require('internal/assert');

const kOldStatus = Symbol('kOldStatus');
Expand Down Expand Up @@ -90,7 +115,7 @@ StatWatcher.prototype[kFSStatWatcherStart] = function(filename,
validateUint32(interval, 'interval');
const err = this._handle.start(toNamespacedPath(filename), interval);
if (err) {
const error = errors.uvException({
const error = uvException({
errno: err,
syscall: 'watch',
path: filename
Expand Down Expand Up @@ -175,7 +200,7 @@ function FSWatcher() {
this._handle.close();
this._handle = null; // Make the handle garbage collectable.
}
const error = errors.uvException({
const error = uvException({
errno: status,
syscall: 'watch',
path: filename
Expand Down Expand Up @@ -215,7 +240,7 @@ FSWatcher.prototype[kFSWatchStart] = function(filename,
recursive,
encoding);
if (err) {
const error = errors.uvException({
const error = uvException({
errno: err,
syscall: 'watch',
path: filename,
Expand Down Expand Up @@ -269,10 +294,94 @@ ObjectDefineProperty(FSEvent.prototype, 'owner', {
set(v) { return this[owner_symbol] = v; }
});

async function* watch(filename, options = {}) {
const path = toNamespacedPath(getValidatedPath(filename));
validateObject(options, 'options');

const {
persistent = true,
recursive = false,
encoding = 'utf8',
signal,
} = options;

validateBoolean(persistent, 'options.persistent');
validateBoolean(recursive, 'options.recursive');
validateAbortSignal(signal, 'options.signal');

if (encoding && !isEncoding(encoding)) {
const reason = 'is invalid encoding';
throw new ERR_INVALID_ARG_VALUE(encoding, 'encoding', reason);
}

if (signal?.aborted)
throw new AbortError();

const handle = new FSEvent();
let res;
let rej;
const oncancel = () => {
handle.close();
rej(new AbortError());
};

try {
signal?.addEventListener('abort', oncancel, { once: true });

let promise = new Promise((resolve, reject) => {
res = resolve;
rej = reject;
});

handle.onchange = (status, eventType, filename) => {
if (status < 0) {
const error = uvException({
errno: status,
syscall: 'watch',
path: filename
});
error.filename = filename;
handle.close();
rej(error);
return;
}

res({ eventType, filename });
};

const err = handle.start(path, persistent, recursive, encoding);
if (err) {
const error = uvException({
errno: err,
syscall: 'watch',
path: filename,
message: err === UV_ENOSPC ?
'System limit for number of file watchers reached' : ''
});
error.filename = filename;
handle.close();
throw error;
}

while (!signal?.aborted) {
yield await promise;
promise = new Promise((resolve, reject) => {
res = resolve;
rej = reject;
});
}
throw new AbortError();
} finally {
handle.close();
signal?.removeEventListener('abort', oncancel);
}
}

module.exports = {
FSWatcher,
StatWatcher,
kFSWatchStart,
kFSStatWatcherStart,
kFSStatWatcherAddOrCleanRef,
watch,
};
3 changes: 3 additions & 0 deletions test/parallel/test-bootstrap-modules.js
Expand Up @@ -17,6 +17,7 @@ const expectedModules = new Set([
'Internal Binding credentials',
'Internal Binding fs',
'Internal Binding fs_dir',
'Internal Binding fs_event_wrap',
'Internal Binding messaging',
'Internal Binding module_wrap',
'Internal Binding native_module',
Expand All @@ -31,6 +32,7 @@ const expectedModules = new Set([
'Internal Binding types',
'Internal Binding url',
'Internal Binding util',
'Internal Binding uv',
'NativeModule async_hooks',
'NativeModule buffer',
'NativeModule events',
Expand All @@ -49,6 +51,7 @@ const expectedModules = new Set([
'NativeModule internal/fs/utils',
'NativeModule internal/fs/promises',
'NativeModule internal/fs/rimraf',
'NativeModule internal/fs/watchers',
'NativeModule internal/idna',
'NativeModule internal/linkedlist',
'NativeModule internal/modules/run_main',
Expand Down

0 comments on commit fe12cc0

Please sign in to comment.