watching.js 8.11 KB
// @ts-check
"use strict";
Object.defineProperty(exports, "__esModule", {
    value: true
});
Object.defineProperty(exports, "createWatcher", {
    enumerable: true,
    get: ()=>createWatcher
});
const _chokidar = /*#__PURE__*/ _interopRequireDefault(require("chokidar"));
const _fs = /*#__PURE__*/ _interopRequireDefault(require("fs"));
const _micromatch = /*#__PURE__*/ _interopRequireDefault(require("micromatch"));
const _normalizePath = /*#__PURE__*/ _interopRequireDefault(require("normalize-path"));
const _path = /*#__PURE__*/ _interopRequireDefault(require("path"));
const _utilsJs = require("./utils.js");
function _interopRequireDefault(obj) {
    return obj && obj.__esModule ? obj : {
        default: obj
    };
}
function createWatcher(args, { state , rebuild  }) {
    let shouldPoll = args["--poll"];
    let shouldCoalesceWriteEvents = shouldPoll || process.platform === "win32";
    // Polling interval in milliseconds
    // Used only when polling or coalescing add/change events on Windows
    let pollInterval = 10;
    let watcher = _chokidar.default.watch([], {
        // Force checking for atomic writes in all situations
        // This causes chokidar to wait up to 100ms for a file to re-added after it's been unlinked
        // This only works when watching directories though
        atomic: true,
        usePolling: shouldPoll,
        interval: shouldPoll ? pollInterval : undefined,
        ignoreInitial: true,
        awaitWriteFinish: shouldCoalesceWriteEvents ? {
            stabilityThreshold: 50,
            pollInterval: pollInterval
        } : false
    });
    // A queue of rebuilds, file reads, etc… to run
    let chain = Promise.resolve();
    /**
   * A list of files that have been changed since the last rebuild
   *
   * @type {{file: string, content: () => Promise<string>, extension: string}[]}
   */ let changedContent = [];
    /**
   * A list of files for which a rebuild has already been queued.
   * This is used to prevent duplicate rebuilds when multiple events are fired for the same file.
   * The rebuilt file is cleared from this list when it's associated rebuild has _started_
   * This is because if the file is changed during a rebuild it won't trigger a new rebuild which it should
   **/ let pendingRebuilds = new Set();
    let _timer;
    let _reject;
    /**
   * Rebuilds the changed files and resolves when the rebuild is
   * complete regardless of whether it was successful or not
   */ async function rebuildAndContinue() {
        let changes = changedContent.splice(0);
        // There are no changes to rebuild so we can just do nothing
        if (changes.length === 0) {
            return Promise.resolve();
        }
        // Clear all pending rebuilds for the about-to-be-built files
        changes.forEach((change)=>pendingRebuilds.delete(change.file));
        // Resolve the promise even when the rebuild fails
        return rebuild(changes).then(()=>{}, ()=>{});
    }
    /**
   *
   * @param {*} file
   * @param {(() => Promise<string>) | null} content
   * @param {boolean} skipPendingCheck
   * @returns {Promise<void>}
   */ function recordChangedFile(file, content = null, skipPendingCheck = false) {
        file = _path.default.resolve(file);
        // Applications like Vim/Neovim fire both rename and change events in succession for atomic writes
        // In that case rebuild has already been queued by rename, so can be skipped in change
        if (pendingRebuilds.has(file) && !skipPendingCheck) {
            return Promise.resolve();
        }
        // Mark that a rebuild of this file is going to happen
        // It MUST happen synchronously before the rebuild is queued for this to be effective
        pendingRebuilds.add(file);
        changedContent.push({
            file,
            content: content !== null && content !== void 0 ? content : ()=>_fs.default.promises.readFile(file, "utf8"),
            extension: _path.default.extname(file).slice(1)
        });
        if (_timer) {
            clearTimeout(_timer);
            _reject();
        }
        // If a rebuild is already in progress we don't want to start another one until the 10ms timer has expired
        chain = chain.then(()=>new Promise((resolve, reject)=>{
                _timer = setTimeout(resolve, 10);
                _reject = reject;
            }));
        // Resolves once this file has been rebuilt (or the rebuild for this file has failed)
        // This queues as many rebuilds as there are changed files
        // But those rebuilds happen after some delay
        // And will immediately resolve if there are no changes
        chain = chain.then(rebuildAndContinue, rebuildAndContinue);
        return chain;
    }
    watcher.on("change", (file)=>recordChangedFile(file));
    watcher.on("add", (file)=>recordChangedFile(file));
    // Restore watching any files that are "removed"
    // This can happen when a file is pseudo-atomically replaced (a copy is created, overwritten, the old one is unlinked, and the new one is renamed)
    // TODO: An an optimization we should allow removal when the config changes
    watcher.on("unlink", (file)=>{
        file = (0, _normalizePath.default)(file);
        // Only re-add the file if it's not covered by a dynamic pattern
        if (!_micromatch.default.some([
            file
        ], state.contentPatterns.dynamic)) {
            watcher.add(file);
        }
    });
    // Some applications such as Visual Studio (but not VS Code)
    // will only fire a rename event for atomic writes and not a change event
    // This is very likely a chokidar bug but it's one we need to work around
    // We treat this as a change event and rebuild the CSS
    watcher.on("raw", (evt, filePath, meta)=>{
        if (evt !== "rename") {
            return;
        }
        let watchedPath = meta.watchedPath;
        // Watched path might be the file itself
        // Or the directory it is in
        filePath = watchedPath.endsWith(filePath) ? watchedPath : _path.default.join(watchedPath, filePath);
        // Skip this event since the files it is for does not match any of the registered content globs
        if (!_micromatch.default.some([
            filePath
        ], state.contentPatterns.all)) {
            return;
        }
        // Skip since we've already queued a rebuild for this file that hasn't happened yet
        if (pendingRebuilds.has(filePath)) {
            return;
        }
        // We'll go ahead and add the file to the pending rebuilds list here
        // It'll be removed when the rebuild starts unless the read fails
        // which will be taken care of as well
        pendingRebuilds.add(filePath);
        async function enqueue() {
            try {
                // We need to read the file as early as possible outside of the chain
                // because it may be gone by the time we get to it. doing the read
                // immediately increases the chance that the file is still there
                let content = await (0, _utilsJs.readFileWithRetries)(_path.default.resolve(filePath));
                if (content === undefined) {
                    return;
                }
                // This will push the rebuild onto the chain
                // We MUST skip the rebuild check here otherwise the rebuild will never happen on Linux
                // This is because the order of events and timing is different on Linux
                // @ts-ignore: TypeScript isn't picking up that content is a string here
                await recordChangedFile(filePath, ()=>content, true);
            } catch  {
            // If reading the file fails, it's was probably a deleted temporary file
            // So we can ignore it and no rebuild is needed
            }
        }
        enqueue().then(()=>{
            // If the file read fails we still need to make sure the file isn't stuck in the pending rebuilds list
            pendingRebuilds.delete(filePath);
        });
    });
    return {
        fswatcher: watcher,
        refreshWatchedFiles () {
            watcher.add(Array.from(state.contextDependencies));
            watcher.add(Array.from(state.configDependencies));
            watcher.add(state.contentPatterns.all);
        }
    };
}