simple-watcher
Advanced tools
Comparing version 4.0.2 to 5.0.0
236
index.js
'use strict' | ||
const fs = require('fs') | ||
const path = require('path') | ||
import fs from 'fs' | ||
import path from 'path' | ||
const TOLERANCE = 200 // For ReadDirectoryChangesW() double reporting. | ||
const PLATFORMS = ['win32', 'darwin'] | ||
const PLATFORMS = ['win32', 'darwin'] // Native recursive support. | ||
// OS directory watcher. | ||
function watchDir (dirToWatch, options, callback) { | ||
// ----------------------------------------------------------------------------- | ||
// HELPERS AND POLYFILLS | ||
// TODO: Refactor after fsPromises.watch is available in the next LTS version. | ||
// ----------------------------------------------------------------------------- | ||
// AbortController falback. | ||
export class AbortController { | ||
constructor () { | ||
this.callbacks = [] | ||
this.aborted = false | ||
this.signal = { | ||
aborted: false, | ||
onabort: callback => this.callbacks.push(callback), | ||
abort: () => { | ||
this.callbacks.forEach(cb => cb()) | ||
this.signal.aborted = true | ||
} | ||
} | ||
} | ||
} | ||
// Graceful closing. | ||
function createWatchers (abortSignal) { | ||
const watchers = {} | ||
// Close all watchers on abort. | ||
abortSignal && abortSignal.onabort(() => { | ||
for (const entityPath of Object.keys(watchers)) { | ||
close(entityPath) | ||
} | ||
}) | ||
function has (entityPath) { | ||
return !!watchers[entityPath] | ||
} | ||
function add (entityPath, w) { | ||
if (!has(entityPath)) { | ||
watchers[entityPath] = w | ||
w.on('error', err => watchers.close(pathToWatch)) | ||
} | ||
} | ||
function close (entityPath) { | ||
if (has(entityPath)) { | ||
watchers[entityPath].close() | ||
delete watchers[entityPath] | ||
} | ||
} | ||
return { add, has, close } | ||
} | ||
// Chanel support for asyc generators. | ||
function createChannel (abortSignal) { | ||
const messageQueue = [] | ||
const promiseQueue = [] | ||
abortSignal && abortSignal.onabort(() => { | ||
const nextPromise = promiseQueue.shift() | ||
nextPromise && nextPromise.resolve() | ||
}) | ||
function put (msg) { | ||
// Anyone waiting for a message? | ||
if (promiseQueue.length) { | ||
// Deliver the message to the oldest one waiting (FIFO). | ||
const nextPromise = promiseQueue.shift() | ||
nextPromise.resolve(msg) | ||
} else { | ||
// No one is waiting - queue the event. | ||
messageQueue.push(msg) | ||
} | ||
} | ||
function take () { | ||
// Do we have queued messages? | ||
if (messageQueue.length) { | ||
// Deliver the oldest queued message. | ||
return Promise.resolve(messageQueue.shift()) | ||
} else { | ||
// No queued messages - queue the taker until a message arrives. | ||
return new Promise((resolve, reject) => promiseQueue.push({ resolve, reject })) | ||
} | ||
} | ||
return { put, take } | ||
} | ||
// ----------------------------------------------------------------------------- | ||
// WATCHERS | ||
// ----------------------------------------------------------------------------- | ||
// Native recursive watcher. | ||
function watchNative (pathToWatch, options, callback) { | ||
const last = { filePath: null, timestamp: 0 } | ||
const w = fs.watch(dirToWatch, { persistent: true, recursive: !options.shallow }, (event, fileName) => { | ||
const recursive = options.recursive ?? true | ||
// Do not create a watcher if already present. | ||
if (options.watchers.has(pathToWatch)) { | ||
return | ||
} | ||
const w = fs.watch(pathToWatch, { recursive }, async (event, fileName) => { | ||
// On Windows fileName may actually be empty. | ||
// In such case assume this is the working dir change. | ||
const filePath = fileName ? path.join(dirToWatch, fileName) : dirToWatch | ||
fs.stat(filePath, (err, stat) => { | ||
// If error, the file was likely deleted. | ||
const timestamp = err ? 0 : (new Date(stat.mtime)).getTime() | ||
const ready = err || timestamp - last.timestamp >= options.tolerance | ||
const filePath = fileName ? path.join(pathToWatch, fileName) : pathToWatch | ||
// callback(filePath) | ||
try { | ||
const stat = await fs.promises.stat(filePath) | ||
const timestamp = (new Date(stat.mtime)).getTime() | ||
const ready = timestamp - last.timestamp >= options.tolerance | ||
const fileMatches = filePath === last.filePath | ||
@@ -25,41 +125,40 @@ last.filePath = filePath | ||
if (fileMatches && !ready) { | ||
return | ||
// Avoid double reporting if change occurs withint the tolerance. | ||
if (!fileMatches || ready) { | ||
callback(filePath) | ||
} | ||
} catch (err) { | ||
// File is likely deleted. | ||
callback(filePath) | ||
}) | ||
} | ||
}) | ||
w.on('error', (e) => { | ||
w.close() | ||
}) | ||
options.watchers.add(pathToWatch, w) | ||
} | ||
// Fallback deep watcher. | ||
function watchDirFallback (dirToWatch, options, callback) { | ||
const dirs = [dirToWatch] | ||
options.ledger = options.ledger || new Set() | ||
// Fallback recursive watcher. | ||
function watchFallback (pathToWatch, options, callback) { | ||
const dirs = [pathToWatch] | ||
for (let ii = 0; ii < dirs.length; ++ii) { | ||
const dir = dirs[ii] | ||
for (const dir of dirs) { | ||
// Append dirs with descendants. | ||
if (!options.shallow) { | ||
for (const entityName of fs.readdirSync(dir)) { | ||
const entityPath = path.resolve(dir, entityName) | ||
fs.statSync(entityPath).isDirectory() && dirs.push(entityPath) | ||
} | ||
for (const entityName of fs.readdirSync(dir)) { | ||
const entityPath = path.join(dir, entityName) | ||
fs.statSync(entityPath).isDirectory() && dirs.push(entityPath) | ||
} | ||
options.ledger.add(dir) | ||
watchDir(dir, { shallow: true, tolerance: options.tolerance }, (entityPath) => { | ||
fs.stat(entityPath, (err, stat) => { | ||
if (err) { // Entity was deleted. | ||
options.ledger.delete(entityPath) | ||
} else if (stat.isDirectory() && !options.ledger.has(entityPath) && !options.shallow) { // New directory added. | ||
watchDirFallback(entityPath, options, callback) | ||
// Shallow watch using native watcher. | ||
watchNative(dir, { ...options, recursive: false }, async entityPath => { | ||
try { | ||
const stat = await fs.promises.stat(entityPath) | ||
// Watch newly created directory. | ||
if (stat.isDirectory()) { | ||
watchFallback(entityPath, options, callback) | ||
} | ||
} catch (err) { | ||
// Close watcher for deleted directory. | ||
options.watchers.close(entityPath) | ||
} | ||
callback(entityPath) | ||
}) | ||
callback(entityPath) | ||
}) | ||
@@ -69,42 +168,33 @@ } | ||
function watchFile (filePath, options, callback) { | ||
options = options.interval ? { interval: options.interval } : {} | ||
fs.watchFile(filePath, options, (curr, prev) => { | ||
curr.mtime === 0 && fs.unwatchFile(filePath) // Unwatch if deleted. | ||
callback(filePath) | ||
}) | ||
} | ||
export default async function* watch (pathsToWatch, options = {}) { | ||
// Normalize paths to array. | ||
pathsToWatch = pathsToWatch.constructor === Array ? pathsToWatch : [pathsToWatch] | ||
function watch (entitiesToWatch, arg1, arg2) { | ||
const callback = arg2 || arg1 | ||
const options = arg2 ? arg1 : { tolerance: TOLERANCE } | ||
options.tolerance = process.platform === 'win32' ? (options.tolerance || TOLERANCE) : 0 // Disable tolerance if not on Windows. | ||
options.fallback = options.fallback || !PLATFORMS.includes(process.platform) | ||
// Set default tolerance for Windows. | ||
options.tolerance = options.tolerance ?? (process.platform === 'win32' ? TOLERANCE : 0) | ||
entitiesToWatch = entitiesToWatch.constructor === Array ? entitiesToWatch : [entitiesToWatch] // Normalize to array. | ||
entitiesToWatch = entitiesToWatch.map(entityToWatch => path.resolve(entityToWatch)) // Resolve directory paths. | ||
// Choose the the watch. function. | ||
options.fallback = options.fallback ?? !PLATFORMS.includes(process.platform) | ||
const watchFunction = options.fallback ? watchFallback : watchNative | ||
for (const entityToWatch of entitiesToWatch) { | ||
if (!fs.statSync(entityToWatch).isDirectory()) { | ||
watchFile(entityToWatch, options, callback) | ||
} else { | ||
options.fallback ? watchDirFallback(entityToWatch, options, callback) : watchDir(entityToWatch, options, callback) | ||
} | ||
} | ||
} | ||
// Create watchers registry. | ||
options.watchers = createWatchers(options.signal) | ||
watch.main = function () { | ||
const args = process.argv.slice(2) | ||
const entitiesToWatch = args.filter(a => !a.startsWith('--')) | ||
const options = { shallow: args.includes('--shallow'), fallback: args.includes('--fallback') } | ||
// Put results to the channel. | ||
const channel = createChannel(options.signal) | ||
watch(entitiesToWatch, options, fileName => { | ||
console.log(`${fileName}`) | ||
}) | ||
} | ||
for (const pathToWatch of pathsToWatch) { | ||
watchFunction(path.normalize(pathToWatch), options, changedPath => { | ||
channel.put(changedPath) | ||
}) | ||
} | ||
if (require.main === module) { | ||
watch.main() | ||
// Yield changes until aborted. | ||
const signal = options.signal ?? { aborted: false } | ||
while (!signal.aborted) { | ||
const changedPath = await channel.take() | ||
if (changedPath) { // Path will be undefined when watch is aborted. | ||
yield changedPath | ||
} | ||
} | ||
} | ||
module.exports = watch |
{ | ||
"name": "simple-watcher", | ||
"version": "4.0.2", | ||
"description": "\"A simple directory watcher.\"", | ||
"version": "5.0.0", | ||
"description": "\"A simple file s watcher.\"", | ||
"type": "module", | ||
"main": "index.js", | ||
"bin": { | ||
"simple-watcher": "./bin/simple-watcher" | ||
"simple-watcher": "./bin/simple-watcher.js" | ||
}, | ||
"scripts": { | ||
"test": "node ./test/test.js -v", | ||
"bin": "node ./bin/simple-watcher.js" | ||
}, | ||
"engines": { | ||
"node": ">=14.15.1" | ||
}, | ||
"repository": { | ||
@@ -26,3 +34,7 @@ "type": "git", | ||
}, | ||
"homepage": "https://github.com/gavoja/simple-watcher#readme" | ||
"homepage": "https://github.com/gavoja/simple-watcher#readme", | ||
"devDependencies": { | ||
"fs-extra": "^9.1.0", | ||
"triala": "^0.4.0" | ||
} | ||
} |
@@ -7,9 +7,10 @@ # Simple Watcher | ||
I know there's plenty of them out there, but most don't seem to care about the `recursive` option of Node's [`fs.watch()`](https://nodejs.org/docs/latest/api/fs.html#fs_fs_watch_filename_options_listener), which **significantly** improves performance on the supported platforms, especially for large directories. | ||
Most watchers do not seem to care about the `recursive` option of Node's [`fs.watch()`](https://nodejs.org/docs/latest/api/fs.html#fs_fs_watch_filename_options_listener), which **significantly** improves performance on the supported platforms, especially for large directories. | ||
Features: | ||
* Dead simple and dead lightweight. | ||
* Simple, fast and lightweight. | ||
* No dependencies. | ||
* Leverages the `recursive` options on OS X and Windows; uses a fallback for other platforms. | ||
* Leverages the `recursive` option on OS X and Windows for improved performance; uses a fallback for other platforms. | ||
* Takes care of WinAPI's `ReadDirectoryChangesW` [double reporting](http://stackoverflow.com/questions/14036449/c-winapi-readdirectorychangesw-receiving-double-notifications). | ||
* Modern API without callbacks. | ||
@@ -22,3 +23,3 @@ ## Usage | ||
Usage: | ||
simple-watcher path1 [path2 path3 ...] [--shallow] | ||
simple-watcher path1 [path2 path3 ...] | ||
``` | ||
@@ -29,22 +30,17 @@ | ||
```JavaScript | ||
const watch = require('simple-watcher') | ||
import watch, { AbortController } from 'simple-watcher' | ||
// Watch over file or directory: | ||
watch('/path/to/foo', filePath => { | ||
console.log(`Changed: ${filePath}`) | ||
}) | ||
// The AbortController is available natively since 15.9.0. | ||
const ac = new AbortController() | ||
const { signal } = ac | ||
setTimeout(() => ac.abort(), 10000) | ||
// Watch over multiple paths: | ||
watch(['/path/to/foo', '/path/to/bar'], filePath => { | ||
// Watch over file or directory. | ||
for await (const changedPath of watch('/path/to/foo'), { signal }) { | ||
console.log(`Changed: ${filePath}`) | ||
}) | ||
} | ||
// Shallow watch: | ||
watch(['/path/to/foo', '/path/to/bar'], { shallow: true }, filePath => { | ||
// Watch over multiple paths. | ||
for await (const changedPath of watch(['/path/to/bar', '/path/to/baz']), { signal }) { | ||
console.log(`Changed: ${filePath}`) | ||
}) | ||
``` | ||
## Caveats | ||
When watching over files rather than directories, the [`fs.watchFile()`](https://nodejs.org/docs/latest/api/fs.html#fs_fs_watchfile_filename_options_listener) is used. This is to provide a polling fallback in cases where directory watching is problematic (e.g. Docker). | ||
} |
161
test/test.js
'use strict' | ||
const watch = require('../index') | ||
const path = require('path') | ||
import assert from 'assert' | ||
import fs from 'fs-extra' | ||
import path from 'path' | ||
import test from 'triala' | ||
import watch, { AbortController } from '../index.js' | ||
const toWatch = path.resolve(__dirname, 'root', 'level1-file') | ||
const options = { | ||
shallow: false, | ||
fallback: true, | ||
interval: 100 | ||
} | ||
// Existing paths. | ||
const PATH_TO_WATCH = path.normalize('./test/data') | ||
const LEVEL1_DIR = path.join(PATH_TO_WATCH, 'level1-dir') | ||
const LEVEL1_FILE = path.join(PATH_TO_WATCH, 'level1-file') | ||
const LEVEL2_DIR = path.join(LEVEL1_DIR, 'level2-dir') | ||
const LEVEL2_FILE = path.join(LEVEL1_DIR, 'level2-file') | ||
const LEVEL3_FILE = path.join(LEVEL2_DIR, 'level3-file') | ||
watch(toWatch, options, fileName => { | ||
console.log(`${fileName}`, options.ledger) | ||
// New paths. | ||
const LEVEL3_DIR = path.join(LEVEL2_DIR, 'level3-dir') | ||
const LEVEL4_FILE = path.join(LEVEL3_DIR, 'level4-file') | ||
// WARNING: Tests are Windows specific! | ||
test('Watcher', class { | ||
async _watch (pathToWatch, options, action) { | ||
const [result] = await Promise.all([ | ||
(async () => { | ||
const changes = [] | ||
for await (const changedPath of watch(pathToWatch, options)) { | ||
changes.push(changedPath) | ||
} | ||
return changes | ||
})(), | ||
action() | ||
]) | ||
return result | ||
} | ||
async _pause (ms) { | ||
return new Promise(resolve => setTimeout(resolve, ms)); | ||
} | ||
_touch (path) { | ||
const time = new Date() | ||
return fs.utimesSync(path, time, time) | ||
} | ||
async _timeout (ms) { | ||
return new Promise(resolve => setTimeout(resolve, ms)); | ||
} | ||
async 'Watch over directory' () { | ||
const signal = (new AbortController()).signal | ||
const changes = await this._watch(PATH_TO_WATCH, { signal }, async () => { | ||
this._touch(LEVEL1_FILE) | ||
this._touch(LEVEL1_FILE) | ||
await this._pause(201) | ||
this._touch(LEVEL1_FILE) | ||
this._touch(LEVEL1_DIR) | ||
this._touch(LEVEL2_FILE) | ||
this._touch(LEVEL2_DIR) | ||
this._touch(LEVEL3_FILE) | ||
await this._pause(10) | ||
signal.abort() | ||
}) | ||
assert.deepStrictEqual(changes, [ | ||
LEVEL1_FILE, | ||
LEVEL1_FILE, | ||
LEVEL1_DIR, | ||
LEVEL2_FILE, | ||
LEVEL2_DIR, | ||
LEVEL3_FILE | ||
]) | ||
} | ||
async 'Watch over directory (zero tolerance)' () { | ||
const signal = (new AbortController()).signal | ||
const changes = await this._watch(PATH_TO_WATCH, { signal, tolerance: 0 }, async () => { | ||
this._touch(LEVEL1_FILE) | ||
this._touch(LEVEL1_FILE) | ||
this._touch(LEVEL1_DIR) | ||
this._touch(LEVEL2_FILE) | ||
this._touch(LEVEL2_DIR) | ||
this._touch(LEVEL3_FILE) | ||
await this._pause(10) | ||
signal.abort() | ||
}) | ||
assert.deepStrictEqual(changes, [ | ||
LEVEL1_FILE, | ||
LEVEL1_FILE, | ||
LEVEL1_DIR, | ||
LEVEL2_FILE, | ||
LEVEL2_DIR, | ||
LEVEL3_FILE | ||
]) | ||
} | ||
async 'Watch over directory (fallback)' () { | ||
const signal = (new AbortController()).signal | ||
const changes = await this._watch(PATH_TO_WATCH, { signal, fallback: true }, async () => { | ||
await this._pause(10) // Allow for the watchers to apply recursively. | ||
this._touch(LEVEL1_FILE) | ||
this._touch(LEVEL1_FILE) | ||
this._touch(LEVEL1_DIR) | ||
await this._pause(10) | ||
this._touch(LEVEL2_FILE) | ||
this._touch(LEVEL2_DIR) | ||
await this._pause(10) | ||
this._touch(LEVEL3_FILE) | ||
fs.ensureDirSync(LEVEL3_DIR) | ||
await this._pause(10) | ||
fs.createFileSync(LEVEL4_FILE) | ||
await this._pause(10) | ||
signal.abort() | ||
}) | ||
assert.deepStrictEqual(changes, [ | ||
LEVEL1_FILE, | ||
LEVEL1_DIR, | ||
LEVEL2_FILE, | ||
LEVEL2_DIR, | ||
LEVEL3_FILE, | ||
LEVEL3_DIR, | ||
LEVEL4_FILE | ||
]) | ||
} | ||
async 'Watch over file' () { | ||
const signal = (new AbortController()).signal | ||
const changes = await this._watch(LEVEL1_FILE, { signal }, async () => { | ||
this._touch(LEVEL1_FILE) | ||
this._touch(LEVEL1_FILE) | ||
await this._pause(10) | ||
signal.abort() | ||
}) | ||
assert.notStrictEqual(changes, [LEVEL1_FILE]) | ||
} | ||
async _before () { | ||
// Clean up folders. | ||
fs.removeSync(LEVEL3_DIR) | ||
} | ||
async _after () { | ||
this._before() | ||
} | ||
}) |
License Policy Violation
LicenseThis package is not allowed per your license policy. Review the package's license to ensure compliance.
Found 1 instance in 1 package
Major refactor
Supply chain riskPackage has recently undergone a major refactor. It may be unstable or indicate significant internal changes. Use caution when updating to versions that include significant changes.
Found 1 instance in 1 package
Filesystem access
Supply chain riskAccesses the file system, and could potentially read sensitive data.
Found 1 instance in 1 package
License Policy Violation
LicenseThis package is not allowed per your license policy. Review the package's license to ensure compliance.
Found 1 instance in 1 package
13275
309
Yes
2
44
2
1