turbowatch
Version:
Extremely fast file change detector and task orchestrator for Node.js.
254 lines (210 loc) • 5.92 kB
text/typescript
import { TurboWatcher } from './backends/TurboWatcher';
import { generateShortId } from './generateShortId';
import { Logger } from './Logger';
import { subscribe } from './subscribe';
import { testExpression } from './testExpression';
import {
type FileChangeEvent,
type JsonObject,
type Subscription,
type TurbowatchConfiguration,
type TurbowatchConfigurationInput,
type TurbowatchController,
} from './types';
import { serializeError } from 'serialize-error';
import { debounce } from 'throttle-debounce';
const log = Logger.child({
namespace: 'watch',
});
export const watch = (
configurationInput: TurbowatchConfigurationInput,
): Promise<TurbowatchController> => {
const {
cwd,
project,
triggers,
debounce: userDebounce,
Watcher,
}: TurbowatchConfiguration = {
// as far as I can tell, this is a bug in unicorn/no-unused-properties
// https://github.com/sindresorhus/eslint-plugin-unicorn/issues/2051
// eslint-disable-next-line unicorn/no-unused-properties
debounce: {
wait: 1_000,
},
// eslint-disable-next-line unicorn/no-unused-properties
Watcher: TurboWatcher,
...configurationInput,
};
const abortController = new AbortController();
const abortSignal = abortController.signal;
let discoveredFileCount = 0;
const indexingIntervalId = setInterval(() => {
log.trace(
'indexed %d %s...',
discoveredFileCount,
discoveredFileCount === 1 ? 'file' : 'files',
);
}, 5_000);
const subscriptions: Subscription[] = [];
const watcher = new Watcher(project);
let shuttingDown = false;
const shutdown = async () => {
if (shuttingDown) {
return;
}
shuttingDown = true;
// eslint-disable-next-line promise/prefer-await-to-then
await watcher.close();
clearInterval(indexingIntervalId);
abortController.abort();
for (const subscription of subscriptions) {
const { activeTask } = subscription;
if (activeTask?.promise) {
await activeTask?.promise;
}
}
for (const subscription of subscriptions) {
const { teardown } = subscription;
if (teardown) {
await teardown();
}
}
};
if (abortSignal) {
abortSignal.addEventListener(
'abort',
() => {
shutdown();
},
{
once: true,
},
);
}
for (const trigger of triggers) {
subscriptions.push(
subscribe({
abortSignal,
cwd,
expression: trigger.expression,
id: generateShortId(),
initialRun: trigger.initialRun ?? true,
interruptible: trigger.interruptible ?? true,
name: trigger.name,
onChange: trigger.onChange,
onTeardown: trigger.onTeardown,
persistent: trigger.persistent ?? false,
retry: trigger.retry ?? {
retries: 0,
},
throttleOutput: trigger.throttleOutput ?? { delay: 1_000 },
}),
);
}
let queuedFileChangeEvents: FileChangeEvent[] = [];
const evaluateSubscribers = debounce(
userDebounce.wait,
() => {
const currentFileChangeEvents =
queuedFileChangeEvents as readonly FileChangeEvent[];
queuedFileChangeEvents = [];
for (const subscription of subscriptions) {
const relevantEvents = currentFileChangeEvents.filter(
(fileChangeEvent) => {
return testExpression(
subscription.expression,
fileChangeEvent.filename,
);
},
);
if (relevantEvents.length) {
if (abortSignal?.aborted) {
return;
}
void subscription.trigger(relevantEvents);
}
}
},
{
noLeading: true,
},
);
let ready = false;
const discoveredFiles: string[] = [];
watcher.on('change', ({ filename }) => {
if (ready) {
queuedFileChangeEvents.push({
filename,
});
evaluateSubscribers();
} else {
if (discoveredFiles.length < 10) {
discoveredFiles.push(filename);
}
discoveredFileCount++;
}
});
return new Promise((resolve, reject) => {
watcher.on('error', (error) => {
log.error(
{
error: serializeError(error) as unknown as JsonObject,
},
'could not watch project',
);
if (ready) {
shutdown();
} else {
reject(error);
}
});
watcher.on('ready', () => {
ready = true;
clearInterval(indexingIntervalId);
if (discoveredFiles.length > 10) {
log.trace(
{
files: discoveredFiles.slice(0, 10).map((file) => {
return file;
}),
},
'discovered %d files in %s; showing first 10',
discoveredFileCount,
project,
);
} else if (discoveredFiles.length > 0) {
log.trace(
{
files: discoveredFiles.map((file) => {
return file;
}),
},
'discovered %d %s in %s',
discoveredFileCount,
discoveredFiles.length === 1 ? 'file' : 'files',
project,
);
}
log.info('triggering initial runs');
const initialRuns: Array<Promise<void>> = [];
for (const subscription of subscriptions) {
if (subscription.initialRun && !subscription.persistent) {
initialRuns.push(subscription.trigger([]));
}
}
// eslint-disable-next-line promise/prefer-await-to-then
void Promise.allSettled(initialRuns).then(() => {
for (const subscription of subscriptions) {
if (subscription.initialRun && subscription.persistent) {
void subscription.trigger([]);
}
}
log.info('ready for file changes');
resolve({
shutdown,
});
});
});
});
};