Turbowatch
Extremely fast file change detector and task orchestrator for Node.js.
README
Turbowatch 🏎
- ```bash
- npm install turbowatch
- cat > turbowatch.ts <<'EOD'
- import { defineConfig } from 'turbowatch';
- export default defineConfig({
- project: __dirname,
- triggers: [
- {
- expression: ['match', '*.ts', 'basename'],
- name: 'build',
- onChange: async ({ spawn }) => {
- await spawn`tsc`;
- },
- },
- ],
- });
- EOD
- npm exec turbowatch ./turbowatch.ts
- ```
Note See logging instructions to print logs that explain what Turbowatch is doing.
||Turbowatch|Nodemon| |
---|
|---|---|---| |
|[Node.js |
|[Graceful |
|[Scriptable |
|Retries|✅|❌| |
|Debounce|✅|❌| |
|Interruptible |
|Concurrent |
|[Log |
|[Bring-your-own |
|Works |
|Works |
|Watch |
|Ignoring |
|Open |
2 Nodemon only provides the ability to [send a custom signal](https://github.com/remy/nodemon#gracefully-reloading-down-your-script) to the worker.
API
Note defineConfig is used to export configuration for the consumption by turbowatch program. If you want to run Turbowatch programmatically, then use watch. The API of both methods is equivalent.
- ```ts
- import {
- watch,
- type ChangeEvent,
- } from 'turbowatch';
- void watch({
- // Debounces triggers by 1 second.
- // Most multi-file spanning changes are non-atomic. Therefore, it is typically desirable to
- // batch together information about multiple file changes that happened in short succession.
- // Provide { debounce: { wait: 0 } } to disable debounce.
- debounce: {
- wait: 1000,
- },
- // The base directory under which all files are matched.
- // Note: This is different from the "root project" (https://github.com/gajus/turbowatch#project-root).
- project: __dirname,
- triggers: [
- {
- // Expression match files based on name.
- // https://github.com/gajus/turbowatch#expressions
- expression: [
- 'allof',
- ['not', ['dirname', 'node_modules']],
- [
- 'anyof',
- ['match', '*.ts', 'basename'],
- ['match', '*.tsx', 'basename'],
- ]
- ],
- // Indicates whether the onChange routine should be triggered on script startup.
- // Defaults to false. Set it to false if you would like onChange routine to not run until the first changes are detected.
- initialRun: true,
- // Determines what to do if a new file change is detected while the trigger is executing.
- // If {interruptible: true}, then AbortSignal will abort the current onChange routine.
- // If {interruptible: false}, then Turbowatch will wait until the onChange routine completes.
- // Defaults to true.
- interruptible: false,
- // Name of the trigger. Used for debugging
- // Must match /^[a-z0-9-_]+$/ pattern and must be unique.
- name: 'build',
- // Routine that is executed when file changes are detected.
- onChange: async ({ spawn }: ChangeEvent) => {
- await spawn`tsc`;
- await spawn`tsc-alias`;
- },
- // Routine that is executed when shutdown signal is received.
- onTeardown: async ({ spawn }) => {
- await spawn`rm -fr ./dist`;
- },
- // Label a task as persistent if it is a long-running process, such as a dev server or --watch mode.
- persistent: false,
- // Retry a task if it fails. Otherwise, watch program will throw an error if trigger fails.
- // Defaults to { retries: 0 }
- retry: {
- retries: 5,
- },
- },
- ],
- });
- ```
Motivation
Note If you are working on a very simple project, i.e. just one build step or just one watch operation, then you don't need Turbowatch. Turbowatch is designed for monorepos or otherwise complex workspaces where you have dozens or hundreds of build steps that depend on each other (e.g. building and re-building dependencies, building/starting/stopping Docker containers, populating data, sending notifications, etc).
Use Cases
spawn
- ```ts
- async ({ spawn }: ChangeEvent) => {
- await spawn`tsc`;
- await spawn`tsc-alias`;
- },
- ```
Persistent tasks
||Persistent|Non-Persistent| |
---|
|---|---|---| |
|Ignore |
Expressions
- ```ts
- ['match', '*.ts', 'basename']
- ```
- ```ts
- [
- 'anyof',
- ['match', '*.ts', 'basename'],
- ['match', '*.tsx', 'basename']
- ]
- ```
- ```ts
- [
- 'allof',
- ['match', '*.ts', 'basename'],
- [
- 'not',
- ['match', 'index.ts', 'basename']
- ]
- ]
- ```
- ```ts
- type Expression =
- // Evaluates as true if all of the grouped expressions also evaluated as true.
- | ['allof', ...Expression[]]
- // Evaluates as true if any of the grouped expressions also evaluated as true.
- | ['anyof', ...Expression[]]
- // Evaluates as true if a given file has a matching parent directory.
- | ['dirname' | 'idirname', string]
- // Evaluates as true if a glob matches against the basename of the file.
- | ['match' | 'imatch', string, 'basename' | 'wholename']
- // Evaluates as true if the sub-expression evaluated as false, i.e. inverts the sub-expression.
- | ['not', Expression];
- ```
Note Turbowatch expressions are a subset of Watchman expressions. 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). 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 uses micromatch for glob matching. Please note that you should be using forward slash (/) to separate paths, even on Windows.
Recipes
Rebuilding assets when file changes are detected
- ```ts
- import { watch } from 'turbowatch';
- void watch({
- project: __dirname,
- triggers: [
- {
- expression: [
- 'allof',
- ['not', ['dirname', 'node_modules']],
- ['match', '*.ts', 'basename'],
- ],
- name: 'build',
- onChange: async ({ spawn }) => {
- await spawn`tsc`;
- await spawn`tsc-alias`;
- },
- },
- ],
- });
- ```
Restarting server when file changes are detected
- ```ts
- import { watch } from 'turbowatch';
- void watch({
- project: __dirname,
- triggers: [
- {
- expression: [
- 'allof',
- ['not', ['dirname', 'node_modules']],
- [
- 'anyof',
- ['match', '*.ts', 'basename'],
- ['match', '*.graphql', 'basename'],
- ]
- ],
- // Because of this setting, Turbowatch will kill the processes that spawn starts
- // when it detects changes when it detects a change.
- interruptible: true,
- name: 'start-server',
- onChange: async ({ spawn }) => {
- await spawn`tsx ./src/bin/wait.ts`;
- await spawn`tsx ./src/bin/server.ts`;
- },
- },
- ],
- });
- ```
Watching node_modules
- ```ts
- import { watch } from 'turbowatch';
- void watch({
- project: path.resolve(__dirname, '../..'),
- triggers: [
- {
- expression: [
- 'anyof',
- [
- 'allof',
- ['dirname', 'node_modules'],
- ['dirname', 'dist'],
- ['match', '*', 'basename'],
- ],
- [
- 'allof',
- ['not', ['dirname', 'node_modules']],
- ['dirname', 'src'],
- ['match', '*', 'basename'],
- ],
- ],
- name: 'build',
- onChange: async ({ spawn }) => {
- return spawn`pnpm run build`;
- },
- },
- ],
- });
- ```
Reusing expressions
- ```ts
- import { watch } from 'turbowatch';
- import {
- buildTrigger,
- } from '@/turbowatch';
- void watch({
- project: __dirname,
- triggers: [
- buildTrigger(),
- ],
- });
- ```
Reducing unnecessary reloads
- ```bash
- rm -fr dist && tsc && tsc-alias
- ```
- ```ts
- [
- 'allof',
- ['dirname', 'node_modules'],
- ['dirname', 'dist'],
- ['match', '*'],
- ],
- ```
- ```bash
- rm -fr .dist && tsc --project tsconfig.build.json && rsync -cr --delete .dist/ ./dist/ && rm -fr .dist
- ```
Retrying failing triggers
- ```ts
- /**
- * @property factor The exponential factor to use. Default is 2.
- * @property maxTimeout The maximum number of milliseconds between two retries. Default is Infinity.
- * @property minTimeout The number of milliseconds before starting the first retry. Default is 1000.
- * @property retries The maximum amount of times to retry the operation. Default is 0. Seting this to 1 means do it once, then retry it once.
- */
- type Retry = {
- factor?: number,
- maxTimeout?: number,
- minTimeout?: number,
- retries?: number,
- }
- ```
Gracefully terminating Turbowatch
Note SIGINT is automatically handled if you are using turbowatch executable to evaluate your Turbowatch script. This examples shows how to programmatically gracefully shutdown Turbowatch if you choose not to use turbowatch program to evaluate your watch scripts.
Warning Unfortunately, many tools do not allow processes to gracefully terminate. There are open support issues for this in npm (#4603), pnpm (#2653) and yarn (#4667), but they haven't been addressed. Therefore, do not wrap yourturbowatch script execution using these tools if you require processes to gracefully terminate.
- ```ts
- const abortController = new AbortController();
- const { shutdown } = watch({
- abortSignal: abortController.signal,
- project: __dirname,
- triggers: [
- {
- name: 'test',
- expression: ['match', '*', 'basename'],
- onChange: async ({ spawn }) => {
- // `sleep 60` will receive `SIGTERM` as soon as `abortController.abort()` is called.
- await spawn`sleep 60`;
- },
- }
- ],
- });
- // SIGINT is the signal sent when we press Ctrl+C
- process.once('SIGINT', () => {
- void shutdown();
- });
- ```
Handling the AbortSignal
Note Turbowatch already comes with [zx](https://npmjs.com/zx) bound to the AbortSignal. Just use spawn. Documentation demonstrates how to implement equivalent functionality.
- ```ts
- import { type ProcessPromise } from 'zx';
- const interrupt = async (
- processPromise: ProcessPromise,
- abortSignal: AbortSignal,
- ) => {
- let aborted = false;
- const kill = () => {
- aborted = true;
- processPromise.kill();
- };
- abortSignal.addEventListener('abort', kill, { once: true });
- try {
- await processPromise;
- } catch (error) {
- if (!aborted) {
- console.log(error);
- }
- }
- abortSignal.removeEventListener('abort', kill);
- };
- ```
- ```ts
- export default watch({
- project: __dirname,
- triggers: [
- {
- expression: ['match', '*.ts', 'basename'],
- interruptible: false,
- name: 'sleep',
- onChange: async ({ abortSignal }) => {
- await interrupt($`sleep 30`, abortSignal);
- },
- },
- ],
- });
- ```
Tearing down project
Warning There is no timeout for onTeardown.
- ```ts
- import { watch } from 'turbowatch';
- export default watch({
- abortSignal: abortController.signal,
- project: __dirname,
- triggers: [
- {
- expression: ['match', '*.ts', 'basename'],
- name: 'build',
- onChange: async ({ spawn }) => {
- await spawn`tsc`;
- },
- onTeardown: async () => {
- await spawn`rm -fr ./dist`;
- },
- },
- ],
- });
- ```
Throttling spawn output
- ```yaml
- redis:dev: 973191cf > #5 sha256:7f65636102fd1f499092cb075baa95784488c0bbc3e0abff2a6d853109e4a948 4.19MB / 9.60MB 22.3s
- api:dev: a1e4c6a7 > [18:48:37.171] 765ms debug @utilities #waitFor: Waiting for database to be ready...
- redis:dev: 973191cf > #5 sha256:d01ec855d06e16385fb33f299d9cc6eb303ea04378d0eea3a75d74e26c6e6bb9 0B / 1.39MB 22.7s
- api:dev: a1e4c6a7 > [18:48:37.225] 54ms debug @utilities #waitFor: Waiting for Redis to be ready...
- worker:dev: 2fb02d72 > [18:48:37.313] 88ms debug @utilities #waitFor: Waiting for database to be ready...
- redis:dev: 973191cf > #5 sha256:7f65636102fd1f499092cb075baa95784488c0bbc3e0abff2a6d853109e4a948 5.24MB / 9.60MB 22.9s
- worker:dev: 2fb02d72 > [18:48:37.408] 95ms debug @utilities #waitFor: Waiting for Redis to be ready...
- redis:dev: 973191cf > #5 sha256:7f65636102fd1f499092cb075baa95784488c0bbc3e0abff2a6d853109e4a948 6.29MB / 9.60MB 23.7s
- api:dev: a1e4c6a7 > [18:48:38.172] 764ms debug @utilities #waitFor: Waiting for database to be ready...
- api:dev: a1e4c6a7 > [18:48:38.227] 55ms debug @utilities #waitFor: Waiting for Redis to be ready...
- ```
- ```yaml
- redis:dev: 973191cf > #5 sha256:7f65636102fd1f499092cb075baa95784488c0bbc3e0abff2a6d853109e4a948 4.19MB / 9.60MB 22.3s
- redis:dev: 973191cf > #5 sha256:d01ec855d06e16385fb33f299d9cc6eb303ea04378d0eea3a75d74e26c6e6bb9 0B / 1.39MB 22.7s
- redis:dev: 973191cf > #5 sha256:7f65636102fd1f499092cb075baa95784488c0bbc3e0abff2a6d853109e4a948 5.24MB / 9.60MB 22.9s
- redis:dev: 973191cf > #5 sha256:7f65636102fd1f499092cb075baa95784488c0bbc3e0abff2a6d853109e4a948 6.29MB / 9.60MB 23.7s
- api:dev: a1e4c6a7 > [18:48:37.171] 765ms debug @utilities #waitFor: Waiting for database to be ready...
- api:dev: a1e4c6a7 > [18:48:37.225] 54ms debug @utilities #waitFor: Waiting for Redis to be ready...
- api:dev: a1e4c6a7 > [18:48:38.172] 764ms debug @utilities #waitFor: Waiting for database to be ready...
- api:dev: a1e4c6a7 > [18:48:38.227] 55ms debug @utilities #waitFor: Waiting for Redis to be ready...
- worker:dev: 2fb02d72 > [18:48:37.313] 88ms debug @utilities #waitFor: Waiting for database to be ready...
- worker:dev: 2fb02d72 > [18:48:37.408] 95ms debug @utilities #waitFor: Waiting for Redis to be ready...
- ```
Watching multiple scripts
- ```bash
- turbowatch ./foo.ts ./bar.ts
- ```
- ```bash
- turbowatch '**/turbowatch.ts'
- ```
Using custom file watching backend
- ```ts
- import {
- watch,
- // Smart Watcher that detects the best available file-watching backend.
- TurboWatcher,
- // fs.watch based file watcher.
- FSWatcher,
- // Chokidar based file watcher.
- ChokidarWatcher,
- // Interface that all file watchers must implement.
- FileWatchingBackend,
- } from 'turbowatch';
- export default watch({
- Watcher: TurboWatcher,
- project: __dirname,
- triggers: [],
- });
- ```
Logging
- ```bash
- ROARR_LOG=true turbowatch | roarr
- ```
Alternatives
Why not use X --watch?
Note Turbowatch is not a replacement for services that implement Hot Module Replacement (HMR), e.g. Next.js. However, you should still wrap those operations in Turbowatch for consistency, e.g.
ts
void watch({
project: __dirname,
triggers: [
{
expression: ['dirname', __dirname],
// Marking this routine as non-interruptible will ensure that
// next dev is not restarted when file changes are detected.
interruptible: false,
name: 'start-server',
onChange: async ({ spawn }) => {
await spawnnext dev;
},
// Enabling this option modifies what Turbowatch logs and warns
// you if your configuration is incompatible with persistent tasks.
persistent: true,
},
],
});
Why not concurrently?
- ```bash
- concurrently "tsc -w" "tsc-alias -w"
- ```
- ```ts
- async ({ spawn }: ChangeEvent) => {
- await spawn`tsc`;
- await spawn`tsc-alias`;
- },
- ```
Why not Turborepo?
- ```json
- "dev": {
- "cache": false,
- "persistent": true
- },
- ```
- ```bash
- turbo run dev --parallel
- ```
Note We found that using dependsOn with Turbowatch produces undesirable effects. Instead, simply use Turbowatch expressions to identify when dependencies update.
Note Turbowatch is not aware of the Turborepo dependency graph. Meaning, that your builds might fail at the first attempt. However, if you setup Turbowatch to [watch node_modules](#watching-node_modules), then Turbowatch will automatically retry failing builds as soon as the dependencies are built.