UNPKG

turbowatch

Version:

Extremely fast file change detector and task orchestrator for Node.js.

254 lines (210 loc) 5.92 kB
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, }); }); }); }); };