diff --git a/README.md b/README.md index 323cf67..a9f6c2d 100644 --- a/README.md +++ b/README.md @@ -58,6 +58,8 @@ Refer to recipes: 1 Undocumented
2 Nodemon only provides the ability to [send a custom signal](https://github.com/remy/nodemon#gracefully-reloading-down-your-script) to the worker.
+> **Warning** Turbowatch uses [`fs.watch`](https://nodejs.org/api/fs.html#fswatchfilename-options-listener), which is known to have platform-specific caveats. Unfortunately, Watchman cannot be used due to it not supporting symbolic links (issue [#105](https://github.com/facebook/watchman/issues/105#issuecomment-1469496330)) and Chokidar cannot be used due to it failing to detect file changes (issue [#1240](https://github.com/paulmillr/chokidar/issues/1240)). This is not an issue if you are using MacOS, though it may have undersirable side-effects on other platforms. Please raise an issue if you discover a platform-specific issue. + ## API Turbowatch [defaults](#recipes) are a good choice for most projects. However, Turbowatch has _many_ options that you should be familiar with for advance use cases. @@ -205,7 +207,7 @@ type Expression = | ['not', Expression]; ``` -> **Note** Turbowatch expressions are a subset of [Watchman expressions](https://facebook.github.io/watchman/docs/expr/allof.html). Originally, Turbowatch was developed to leverage Watchman as a superior backend for watching a large number of files. However, along the way, we discovered that Watchman does not support symbolic links (issue [#105](https://github.com/facebook/watchman/issues/105#issuecomment-1469496330)). Unfortunately, that makes Watchman unsuitable for projects that utilize linked dependencies (which is the direction in which the ecosystem is moving for dependency management in monorepos). As such, Watchman was replaced with chokidar. We are hoping to provide Watchman as a backend in the future. Therefore, we made Turbowatch expressions syntax compatible with a subset of Watchman expressions. +> **Note** Turbowatch expressions are a subset of [Watchman expressions](https://facebook.github.io/watchman/docs/expr/allof.html). Originally, Turbowatch was developed to leverage Watchman as a superior backend for watching a large number of files. However, along the way, we discovered that Watchman does not support symbolic links (issue [#105](https://github.com/facebook/watchman/issues/105#issuecomment-1469496330)). Unfortunately, that makes Watchman unsuitable for projects that utilize linked dependencies (which is the direction in which the ecosystem is moving for dependency management in monorepos). As such, Watchman was replaced with [`fs.watch`](https://nodejs.org/api/fs.html#fswatchfilename-options-listener). We are hoping to provide Watchman as a backend in the future. Therefore, we made Turbowatch expressions syntax compatible with a subset of Watchman expressions. ## Recipes diff --git a/src/bin/turbowatch.ts b/src/bin/turbowatch.ts index 6d7b203..7a0cb74 100644 --- a/src/bin/turbowatch.ts +++ b/src/bin/turbowatch.ts @@ -50,10 +50,10 @@ const main = async () => { return; } - const userScript = jiti(__filename)(resolvedPath) - .default as Promise; + const turbowatchController = jiti(__filename)(resolvedPath) + .default as TurbowatchController; - if (typeof userScript?.then !== 'function') { + if (typeof turbowatchController?.shutdown !== 'function') { console.error( 'Expected user script to export an instance of TurbowatchController', ); @@ -63,8 +63,6 @@ const main = async () => { return; } - const turbowatchController = await userScript; - process.once('SIGINT', () => { log.warn('received SIGINT; gracefully terminating'); diff --git a/src/subscribe.test.ts b/src/subscribe.test.ts index 056e346..cfc6190 100644 --- a/src/subscribe.test.ts +++ b/src/subscribe.test.ts @@ -76,16 +76,16 @@ it('removes duplicates', async () => { subscription.trigger([ { - event: 'add', - path: '/foo', + event: 'change', + filename: '/foo', }, { - event: 'add', - path: '/foo', + event: 'change', + filename: '/foo', }, { - event: 'add', - path: '/bar', + event: 'change', + filename: '/bar', }, ]); diff --git a/src/subscribe.ts b/src/subscribe.ts index 771434d..8d047e2 100644 --- a/src/subscribe.ts +++ b/src/subscribe.ts @@ -3,7 +3,7 @@ import { generateShortId } from './generateShortId'; import { Logger } from './Logger'; import { type ActiveTask, - type ChokidarEvent, + type FSWatcherEvent, type Subscription, type Trigger, } from './types'; @@ -18,7 +18,7 @@ export const subscribe = (trigger: Trigger): Subscription => { let first = true; - let eventQueue: ChokidarEvent[] = []; + let eventQueue: FSWatcherEvent[] = []; const handleSubscriptionEvent = async () => { if (trigger.abortSignal?.aborted) { @@ -93,17 +93,17 @@ export const subscribe = (trigger: Trigger): Subscription => { const event = { files: eventQueue - .filter(({ path }) => { - if (affectedPaths.includes(path)) { + .filter(({ filename }) => { + if (affectedPaths.includes(filename)) { return false; } - affectedPaths.push(path); + affectedPaths.push(filename); return true; }) - .map(({ path }) => { + .map(({ filename }) => { return { - name: path, + name: filename, }; }), }; @@ -205,7 +205,7 @@ export const subscribe = (trigger: Trigger): Subscription => { }); } }, - trigger: async (events: readonly ChokidarEvent[]) => { + trigger: async (events: readonly FSWatcherEvent[]) => { eventQueue.push(...events); await handleSubscriptionEvent(); diff --git a/src/types.ts b/src/types.ts index 29e3e33..43c918f 100644 --- a/src/types.ts +++ b/src/types.ts @@ -140,9 +140,9 @@ export type Configuration = { readonly triggers: readonly TriggerInput[]; }; -export type ChokidarEvent = { - event: 'add' | 'addDir' | 'change' | 'unlink' | 'unlinkDir'; - path: string; +export type FSWatcherEvent = { + event: 'rename' | 'change'; + filename: string; }; /** @@ -160,7 +160,7 @@ export type Subscription = { expression: Expression; initialRun: boolean; teardown: () => Promise; - trigger: (events: readonly ChokidarEvent[]) => Promise; + trigger: (events: readonly FSWatcherEvent[]) => Promise; }; export type TurbowatchController = { diff --git a/src/watch.ts b/src/watch.ts index 2ddf017..0e3cf86 100644 --- a/src/watch.ts +++ b/src/watch.ts @@ -3,14 +3,14 @@ import { Logger } from './Logger'; import { subscribe } from './subscribe'; import { testExpression } from './testExpression'; import { - type ChokidarEvent, type Configuration, type ConfigurationInput, + type FSWatcherEvent, type JsonObject, type Subscription, type TurbowatchController, } from './types'; -import * as chokidar from 'chokidar'; +import fs from 'node:fs'; import { serializeError } from 'serialize-error'; import { debounce } from 'throttle-debounce'; @@ -20,7 +20,7 @@ const log = Logger.child({ export const watch = ( configurationInput: ConfigurationInput, -): Promise => { +): TurbowatchController => { const { project, triggers, @@ -51,7 +51,25 @@ export const watch = ( const subscriptions: Subscription[] = []; - const watcher = chokidar.watch(project); + const watcher = fs.watch(project, {recursive: true}, (event, filename) => { + queuedEvents.push({ + event, + filename, + }); + + evaluateSubscribers(); + }); + + watcher.on('error', (error) => { + log.error( + { + error: serializeError(error) as unknown as JsonObject, + }, + 'could not watch project', + ); + + shutdown(); + }); const shutdown = async () => { clearInterval(indexingIntervalId); @@ -102,19 +120,19 @@ export const watch = ( ); } - let queuedChokidarEvents: ChokidarEvent[] = []; + let queuedEvents: FSWatcherEvent[] = []; const evaluateSubscribers = debounce( userDebounce.wait, () => { const currentChokidarEvents = - queuedChokidarEvents as readonly ChokidarEvent[]; + queuedEvents as readonly FSWatcherEvent[]; - queuedChokidarEvents = []; + queuedEvents = []; for (const subscription of subscriptions) { const relevantEvents = currentChokidarEvents.filter((chokidarEvent) => { - return testExpression(subscription.expression, chokidarEvent.path); + return testExpression(subscription.expression, chokidarEvent.filename); }); if (relevantEvents.length) { @@ -127,84 +145,13 @@ export const watch = ( }, ); - let ready = false; - - const discoveredFiles: string[] = []; - - watcher.on('all', (event, path) => { - if (ready) { - queuedChokidarEvents.push({ - event, - path, - }); - - evaluateSubscribers(); - } else { - if (discoveredFiles.length < 10) { - discoveredFiles.push(path); - } - - discoveredFileCount++; + for (const subscription of subscriptions) { + if (subscription.initialRun) { + void subscription.trigger([]); } - }); - - 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 { - log.trace( - { - files: discoveredFiles.map((file) => { - return file; - }), - }, - 'discovered %d %s in %s', - discoveredFileCount, - discoveredFiles.length === 1 ? 'file' : 'files', - project, - ); - } - - log.info('Initial scan complete. Ready for changes'); - - for (const subscription of subscriptions) { - if (subscription.initialRun) { - void subscription.trigger([]); - } - } + } - resolve({ - shutdown, - }); - }); - }); + return { + shutdown, + }; };