wireit
Version:
Upgrade your npm scripts to make them smarter and more efficient
396 lines • 13.9 kB
JavaScript
/**
* @license
* Copyright 2022 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import chokidar from 'chokidar';
import { Analyzer } from './analyzer.js';
import { scriptReferenceToString, } from './config.js';
import { Executor } from './executor.js';
import { Deferred } from './util/deferred.js';
import './util/dispose.js';
function unknownState(state) {
return new Error(`Unknown watcher state ${String(state)}`);
}
function unexpectedState(state) {
return new Error(`Unexpected watcher state ${state}`);
}
/**
* The minimum time that must elapse after the last file change was detected
* before we begin a new run. Also the minimum time between successive runs.
*
* Note even 0 is a useful value here, because that defers new runs to the next
* JS task. This is important because if multiple scripts are watching the same
* file that changed, we get a file watcher event for each of them. Without
* debouncing, a second run will be immediately queued after the first event
* starts the run.
*/
const DEBOUNCE_MS = 0;
/**
* Watches a script for changes in its input files, and in the input files of
* its transitive dependencies, and executes all affected scripts when they
* change.
*
* Also watches all related package.json files and reloads script configuration
* when they change.
*/
export class Watcher {
/** See {@link WatcherState} */
#state = 'initial';
#rootScript;
#extraArgs;
#logger;
#workerPool;
#cache;
#failureMode;
#agent;
#executor;
#debounceTimeoutId = undefined;
#previousIterationServices = undefined;
#previousIterationFailures = new Map();
/**
* The most recent analysis of the root script. As soon as we detect it might
* be stale because a package.json file was modified, this becomes undefined
* again.
*/
#latestRootScriptConfig;
/**
* The file watcher for all package.json files relevant to this build graph.
*/
#configFilesWatcher;
/**
* File watchers for the input files of all scripts in this build graph.
*/
#inputFileWatchers = new Map();
/**
* Resolves when this watcher has been aborted and the last run finished.
*/
#finished = new Deferred();
#watchOptions;
constructor(rootScript, extraArgs, logger, workerPool, cache, failureMode, agent, watchOptions) {
this.#rootScript = rootScript;
this.#extraArgs = extraArgs;
this.#logger = logger.getWatchLogger?.() ?? logger;
this.#workerPool = workerPool;
this.#failureMode = failureMode;
this.#cache = cache;
this.#agent = agent;
this.#watchOptions = watchOptions;
}
watch() {
this.#startRun();
return this.#finished.promise;
}
#startDebounce() {
if (this.#debounceTimeoutId !== undefined) {
throw new Error('Expected #debounceTimeoutId to be undefined');
}
this.#debounceTimeoutId = setTimeout(() => {
this.#onDebounced();
}, DEBOUNCE_MS);
}
#cancelDebounce() {
clearTimeout(this.#debounceTimeoutId);
this.#debounceTimeoutId = undefined;
}
#onDebounced() {
switch (this.#state) {
case 'debouncing': {
this.#debounceTimeoutId = undefined;
this.#startRun();
return;
}
case 'initial':
case 'watching':
case 'queued':
case 'running':
case 'aborted': {
throw unexpectedState(this.#state);
}
default: {
throw unknownState(this.#state);
}
}
}
#startRun() {
switch (this.#state) {
case 'initial':
case 'debouncing': {
this.#state = 'running';
this.#logger.log({
script: this.#rootScript,
type: 'info',
detail: 'watch-run-start',
});
if (this.#latestRootScriptConfig === undefined) {
void this.#analyze();
}
else {
// We already have a valid config, so we can skip the analysis step.
// but if the logger needs to know about the analysis, this lets
// it know.
this.#logger.log({
type: 'info',
detail: 'analysis-completed',
script: this.#rootScript,
rootScriptConfig: this.#latestRootScriptConfig,
});
void this.#execute(this.#latestRootScriptConfig);
}
return;
}
case 'watching':
case 'queued':
case 'running':
case 'aborted': {
throw unexpectedState(this.#state);
}
default: {
throw unknownState(this.#state);
}
}
}
async #analyze() {
if (this.#state !== 'running') {
throw unexpectedState(this.#state);
}
const analyzer = new Analyzer(this.#agent, this.#logger);
const result = await analyzer.analyze(this.#rootScript, this.#extraArgs);
if (this.#state === 'aborted') {
return;
}
// Set up watchers for all relevant config files even if there were errors
// so that we'll try again when the user modifies a config file.
const configFiles = [...result.relevantConfigFilePaths];
// Order doesn't matter because we know we don't have any !negated patterns,
// but we're going to compare arrays exactly so the order should be
// deterministic.
configFiles.sort();
const oldWatcher = this.#configFilesWatcher;
if (!watchPathsEqual(configFiles, oldWatcher?.patterns)) {
this.#configFilesWatcher = makeWatcher(configFiles, '/', this.#onConfigFileChanged, true, this.#watchOptions);
if (oldWatcher !== undefined) {
void oldWatcher[Symbol.asyncDispose]();
}
}
if (!result.config.ok) {
for (const error of result.config.error) {
this.#logger.log(error);
}
this.#onRunDone();
return;
}
this.#latestRootScriptConfig = result.config.value;
this.#synchronizeInputFileWatchers(this.#latestRootScriptConfig);
void this.#execute(this.#latestRootScriptConfig);
}
async #execute(script) {
if (this.#state !== 'running') {
throw unexpectedState(this.#state);
}
this.#executor = new Executor(script, this.#logger, this.#workerPool, this.#cache, this.#failureMode, this.#previousIterationServices, true, this.#previousIterationFailures);
const result = await this.#executor.execute();
this.#previousIterationServices = result.persistentServices;
if (result.errors.length > 0) {
for (const error of result.errors) {
this.#logger.log(error);
}
}
else {
this.#previousIterationFailures = new Map();
}
this.#onRunDone();
}
#onRunDone() {
this.#logger.log({
script: this.#rootScript,
type: 'info',
detail: 'watch-run-end',
});
switch (this.#state) {
case 'queued': {
// Note that the debounce time could actually have already elapsed since
// the last file change while we were running, but we don't start the
// debounce timer until the run finishes. This means that the debounce
// interval is also the minimum time between successive runs. This seems
// fine and probably good, and is simpler than maintaining a separate
// "queued-debouncing" state.
this.#state = 'debouncing';
this.#startDebounce();
return;
}
case 'running': {
this.#state = 'watching';
return;
}
case 'aborted': {
this.#finished.resolve();
return;
}
case 'initial':
case 'watching':
case 'debouncing': {
throw unexpectedState(this.#state);
}
default: {
throw unknownState(this.#state);
}
}
}
#onConfigFileChanged = () => {
this.#latestRootScriptConfig = undefined;
this.#fileChanged();
};
#fileChanged = () => {
switch (this.#state) {
case 'watching': {
this.#state = 'debouncing';
this.#startDebounce();
return;
}
case 'debouncing': {
this.#cancelDebounce();
this.#startDebounce();
return;
}
case 'running': {
this.#state = 'queued';
return;
}
case 'queued':
case 'aborted': {
return;
}
case 'initial': {
throw unexpectedState(this.#state);
}
default: {
throw unknownState(this.#state);
}
}
};
#synchronizeInputFileWatchers(root) {
const visited = new Set();
const visit = (script) => {
const key = scriptReferenceToString(script);
if (visited.has(key)) {
return;
}
visited.add(key);
const newInputFiles = script.files?.values;
const oldWatcher = this.#inputFileWatchers.get(key);
if (!watchPathsEqual(newInputFiles, oldWatcher?.patterns)) {
if (newInputFiles === undefined || newInputFiles.length === 0) {
this.#inputFileWatchers.delete(key);
}
else {
const newWatcher = makeWatcher(newInputFiles, script.packageDir, this.#fileChanged, true, this.#watchOptions);
this.#inputFileWatchers.set(key, newWatcher);
}
if (oldWatcher !== undefined) {
void oldWatcher[Symbol.asyncDispose]();
}
}
for (const dep of script.dependencies) {
visit(dep.config);
}
};
visit(root);
// There also could be some scripts that have been removed entirely.
for (const [oldKey, oldWatcher] of this.#inputFileWatchers) {
if (!visited.has(oldKey)) {
void oldWatcher[Symbol.asyncDispose]();
this.#inputFileWatchers.delete(oldKey);
}
}
}
abort() {
if (this.#executor !== undefined) {
this.#executor.abort();
this.#executor = undefined;
}
switch (this.#state) {
case 'debouncing':
case 'watching': {
if (this.#state === 'debouncing') {
this.#cancelDebounce();
}
this.#state = 'aborted';
this.#closeAllFileWatchers();
this.#finished.resolve();
return;
}
case 'running':
case 'queued': {
this.#state = 'aborted';
this.#closeAllFileWatchers();
// Don't resolve #finished immediately so that we will wait for #analyze
// or #execute to finish.
return;
}
case 'aborted': {
return;
}
case 'initial': {
throw unexpectedState(this.#state);
}
default: {
throw unknownState(this.#state);
}
}
}
#closeAllFileWatchers() {
void this.#configFilesWatcher?.[Symbol.asyncDispose]();
for (const value of this.#inputFileWatchers.values()) {
void value[Symbol.asyncDispose]();
}
}
}
const watchPathsEqual = (a, b) => {
if (a === undefined && b === undefined) {
return true;
}
if (a === undefined || b === undefined) {
return false;
}
if (a.length !== b.length) {
return false;
}
for (let i = 0; i < a.length; i++) {
if (a[i] !== b[i]) {
return false;
}
}
return true;
};
/**
* Exported for testing.
*
* @param ignoreInitial Ignore the initial "add" events emitted when chokidar
* first discovers each file. We already do an initial run, so these events are
* just noise that may trigger an unnecessary second run.
* https://github.com/paulmillr/chokidar#path-filtering
*/
export const makeWatcher = (patterns, cwd, callback, ignoreInitial, watchOptions) => {
// TODO(aomarks) chokidar doesn't work exactly like fast-glob, so there are
// currently various differences in what gets watched vs what actually affects
// the build. See https://github.com/google/wireit/issues/550.
const watcher = chokidar.watch(
// Trim leading slashes from patterns, to "re-root" all paths to the package
// directory, just as we do when globbing for script execution.
patterns.map((pattern) => pattern.replace(/^\/+/, '')), {
cwd,
ignoreInitial,
usePolling: watchOptions.strategy === 'poll',
interval: watchOptions.strategy === 'poll' ? watchOptions.interval : undefined,
});
watcher.on('all', callback);
return {
patterns,
watcher,
async [Symbol.asyncDispose]() {
await watcher.close();
},
};
};
//# sourceMappingURL=watcher.js.map