volumeWatchers.mjs

/* eslint-disable new-cap */
/**
 * @description Watches files and folders for changes.
 * @copyright Nov 2021
 * @author Mike Price <dev.grumptech@gmail.com>
 * @module VolumeWatcherModule
 * @requires debug
 * @see {@link https://github.com/debug-js/debug#readme}
 * @requires events
 * @see {@link https://nodejs.org/dist/latest-v16.x/docs/api/events.html#events}
 * @requires fs
 * @see {@link https://nodejs.org/dist/latest-v16.x/docs/api/fs.html#file-system}
 */

// External dependencies and imports.
import {access as _fsPromiseAccess} from 'fs/promises';
import {constants as _fsConstants, watch as _fsWatch} from 'fs';
import EventEmitter from 'events';
import _debugModule from 'debug';

// External dependencies and imports.
/**
 * @description Debugging function pointer for runtime related diagnostics.
 * @private
 */
// eslint-disable-next-line camelcase
const _debug_process = new _debugModule('vi_process');

// Helpful constants and conversion factors.
/**
 * @description Flag indicating the identification of an invalid timeout.
 * @private
 */
const INVALID_TIMEOUT_ID = -1;
/**
 * @description Time, in milliseconds, for rescanning for changes.
 * @private
 * @todo Remove this if not being used.
 */
// eslint-disable-next-line no-unused-vars
const RESCAN_PERIOD_MS   = 60000/* milliseconds */;

/**
 * @description Enumeration of the types of volume change detection.
 * @private
 * @readonly
 * @enum {number}
 * @property {number} Add- Volume added
 * @property {number} Delete - Volume deleted
 * @property {number} Modify - Volume modified
 */
export const VOLUME_CHANGE_DETECTION_BITMASK_DEF = {
    /* eslint-disable key-spacing */
    Add    : 0x1,
    Delete : 0x2,
    Modify : 0x4,
    /* eslint-enable key-spacing */
};

/**
 * @description Enumeration of published events.
 * @readonly
 * @private
 * @enum {string}
 * @property {string} EVENT_CHANGE_DETECTED - Identification for the event published when a change is detected.
 * @property {string} EVENT_WATCH_ADD_RESULT - Identification for the event published when a watch item is added to the watcher.
 */
export const VOLUME_WATCHER_EVENTS = {
    /* eslint-disable key-spacing */
    EVENT_CHANGE_DETECTED   : 'change_detected',
    EVENT_WATCH_ADD_RESULT  : 'watch_add_result',
    /* eslint-enable key-spacing */
};

/**
 * @description Change detected notification
 * @event module:VolumeWatcherModule#event:change_detected
 * @type {string}
 * @type {string}
 */
/**
 * @description Watch added result notification
 * @event module:VolumeWatcherModule#event:watch_add_result
 * @type {object}
 * @param {string} e.target - Name of the watch item that was addded.
 * @param {boolean} e.success - Flag indicating if the watch item was added successfully.
 */
/**
 * @description Monitors file system objects for changes.
 * @augments EventEmitter
 */
export class VolumeWatcher extends EventEmitter {
    /**
     * @description Constructor
     * @class
     */
    constructor() {
        // Initialize the base class.
        super();

        // Initialize data members.
        this._timeoutID = INVALID_TIMEOUT_ID;

        // Map of watched folders.
        this._watchers = new Map();

        // Callbacks bound to this object.
        this._CB__VolumeWatcherChange = this._handleVolumeWatcherChangeDetected.bind(this);
    }

    /**
     * @description Destructor
     * @returns {void}
     */
    Terminate() {
        // Cleanup the volume watcher list.
        this._watchers.forEach((value, key) => {
            _debug_process(`Volume Watcher closing target '${key}`);
            value.close();
        });
        this._watchers.clear();

        // Clean up event registrations.
        this.removeAllListeners(VOLUME_WATCHER_EVENTS.EVENT_CHANGE_DETECTED);
        this.removeAllListeners(VOLUME_WATCHER_EVENTS.EVENT_WATCH_ADD_RESULT);
    }

    /**
     * @description Add or replace file system objects to be monitored
     * @param {object[]} watchList - Array of objects containing watch information.
     * @param {string} watchList[].target - Path of the target to be watched.
     * @param {boolean} [watchList[].recursive] - Flag indicating that the target should be recursed for change monitoring (if a directory)
     * @param {boolean} [watchList[].ignoreAccess] - Flag indicating that the access to the target should be ignored.
     * @returns {Promise} - A promise that when resolved will indicate if the watch targets were added.
     * @throws {TypeError} - Thrown if 'watchList' does is not an array of objects.
     */
    async AddWatches(watchList) {
        // Validate the arguments
        if ((watchList === undefined)  ||
            (!Array.isArray(watchList) ||
            (watchList.length <= 0))) {
            throw new TypeError('\'watchList\' is not a non-zero length array.');
        }
        else {
            // Ensure that the array contents are all objects containing a 'target' field.
            for (const watchItem of watchList) {
                if ((!Object.prototype.hasOwnProperty.call(watchItem, 'target')) ||
                    (typeof(watchItem.target) !== 'string') ||
                    (watchItem.target.length <= 0)) {
                    throw new TypeError('\'watchList\' item does not contain a field \'target\' or \'target\' is not a non-null string.');
                }
            }
        }
        // Body -----------------

        // construct a promise for this operation.
        const thePromise = new Promise((resolve) => {
            (async (theList) => {
                // Assume success. Set false on any issue.
                let success = true;

                // Iterate over the list of watch items and determine the file access.
                const accessPromises = [];
                for (const watchItem of theList) {
                    accessPromises.push(this.ValidateAccess(watchItem.target, _fsConstants.F_OK));
                }
                // Wait until all of the promises have been servided.
                const accessResults = await Promise.all(accessPromises);

                // iterate over the list of watch items to set up watches for the ones which have access.
                for (const watchItem of theList) {
                    // No need to validate the 'target' field, as this was done initially.
                    let recurse         = false;
                    let ignoreAccess    = false;
                    // Use the recursive flag, if present & valid
                    if ((Object.prototype.hasOwnProperty.call(watchItem, 'recursive')) &&
                        (typeof(watchItem.recursive) === 'boolean')) {
                        recurse = watchItem.recursive;
                    }
                    // Use the ignore access flag, if present & valid
                    if ((Object.prototype.hasOwnProperty.call(watchItem, 'ignoreAccess')) &&
                        (typeof(watchItem.ignoreAccess) === 'boolean')) {
                        ignoreAccess = watchItem.ignoreAccess;
                    }

                    // Determine if the process has access to the target of watchItem
                    let accessOk = false;
                    let found = false;
                    for (const accessResult of accessResults) {
                        if (accessResult.target === watchItem.target) {
                            accessOk = accessResult.success;
                            found = true;
                            // No need to continue the search.
                            break;
                        }
                    }
                    // Sanity
                    if (!found) {
                        // This should never happen.
                        throw new Error(`target:${watchItem} not found in 'accessResults'`);
                    }

                    const watchOk = (accessOk || ignoreAccess);
                    if (watchOk) {
                        // Determine if this target is already being watched.
                        const exists = this._watchers.has(watchItem.target);

                        _debug_process(`Watch target: '${watchItem.target}' accessOk:${accessOk} exists:${exists}`);

                        // If a watch already exists, clean up first.
                        if (exists) {
                            this.DeleteWatch(watchItem.target);
                        }

                        // Initiate the watch.
                        const watcher = _fsWatch(watchItem.target, {persistent: true, recursive: recurse, encoding: 'utf8'}, this._CB__VolumeWatcherChange);
                        // Update the map of watchers.
                        this._watchers.set(watchItem.target, watcher);
                    }
                    else {
                        _debug_process(`Unable to watch target: '${watchItem.target}`);
                        success = false;
                    }

                    // Notify clients
                    this.emit(VOLUME_WATCHER_EVENTS.EVENT_WATCH_ADD_RESULT, {target: watchItem.target, success: watchOk});
                }

                resolve(success);
            })(watchList);
        });

        return thePromise;
    }

    /**
     * @description Delete a watch
     * @param {string} target - Oatg ti tge watch to be removed.
     * @returns {boolean} - trye if the watch item was deleted.
     */
    DeleteWatch(target) {
        let success = false;

        // Attempt to get the watch matching the target.
        const watchItem = this._watchers.get(target);
        if (watchItem !== undefined) {
            // Stop watching.
            watchItem.close();
            // Remove the entry.
            success = this._watchers.delete(target);
        }

        return success;
    }

    /**
     * @description Provide a list of the watch targets
     * @returns {string[]} - Array of the watch targets
     */
    ListWatches() {
        const targets = [];

        // Attempt to get the watch matching the target.
        const iter = this._watchers.keys();
        for (const watchTarget of iter) {
            targets.push(watchTarget);
        }

        return targets;
    }

    /**
     * @async
     * @description Check to see if the current process has access to the specified target.
     * @param {string} target - File system target.
     * @param {number} accessMode - Mode for the access being sought.
     * @returns {Promise<object>} Promise to validate the status of the item specified.
     */
    async ValidateAccess(target, accessMode) {
        // Validate the arguments.
        if ((target === undefined) || (target === null) ||
            (typeof(target) !== 'string') || (target.length < 1)) {
            throw new TypeError('\'target\' is not a non-zero string.');
        }
        if ((accessMode === undefined) || (accessMode === null) ||
            (typeof(accessMode) !== 'number')) {
            throw new TypeError('\'accessMode\' is not a number');
        }
        // Body ------------
        const thePromise = new Promise((resolve) => {
            (async (loc, mode) => {
                try {
                    await _fsPromiseAccess(loc, mode);
                    resolve({target: loc, success: true});
                }
                catch {
                    resolve({target: loc, success: false});
                }
            })(target, accessMode);
        });

        return thePromise;
    }

    /**
     * @private
     * @description Event handler for file system change detections.
     * @param {string} eventType - Type of change detected ('rename' or 'change')
     * @param {string | Buffer} fileName - Name of the file or directory with the change.
     * @returns {void}
     */
    _handleVolumeWatcherChangeDetected(eventType, fileName) {
        // Decouple the automatic refresh.
        setImmediate((eType, fName) => {
            _debug_process(`Volume Watcher Change Detected: type:${eType} name:${fName}`);

            // For simplicity, forward the event onto out clients.
            this.emit(VOLUME_WATCHER_EVENTS.EVENT_CHANGE_DETECTED, eType, fName);
        }, eventType, fileName);
    }
}
export default VolumeWatcher;