From 6750d6648109d30a14fe65dd6aa27b0e3caee4bc Mon Sep 17 00:00:00 2001 From: Gajus Kuizinas Date: Thu, 16 Mar 2023 00:06:01 +0800 Subject: [PATCH] feat: switch to using chokidar (#18) Turbowatch was developed to leverage [Watchman](https://facebook.github.io/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](https://www.npmjs.com/package/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. Breaking changes: * various miscellaneous expressions have been dropped * debounce became part of the `watch` configuration * change event no longer describes `exists`, `mtime` or `size` attributes of the file that changed --- README.md | 91 ++-------- cspell.yaml | 4 +- package-lock.json | 222 +++++++++++++++++------ package.json | 4 +- pnpm-lock.yaml | 105 +++++++---- src/index.ts | 2 +- src/subscribe.test.ts | 281 ++--------------------------- src/subscribe.ts | 355 +++++++++++++++---------------------- src/testExpression.test.ts | 96 ++++++++++ src/testExpression.ts | 87 +++++++++ src/types.ts | 108 ++++------- src/watch.ts | 126 +++++++------ 12 files changed, 703 insertions(+), 778 deletions(-) create mode 100644 src/testExpression.test.ts create mode 100644 src/testExpression.ts diff --git a/README.md b/README.md index aae6775..b4d61ed 100644 --- a/README.md +++ b/README.md @@ -73,25 +73,26 @@ void watch({ // If none is provided, then Turbowatch will gracefully terminate // the service when it receives SIGINT. abortSignal: new AbortController().signal, + // Debounces triggers by 100 milliseconds. + // 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: { + leading: false, + wait: 100, + }, // 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, file size, modification date, and other criteria. - // https://github.com/gajus/turbowatch#expressions-cheat-sheet + // Expression match files based on name. + // https://github.com/gajus/turbowatch#expressions expression: [ 'anyof', ['match', '*.ts', 'basename'], ['match', '*.tsx', 'basename'], ], - // Debounces trigger by 100 milliseconds. - // This is the default as it is often desirable to wait for several changes before re-running the trigger. - // Provide { debounce: { wait: 0 } } to disable debounce. - debounce: { - leading: false, - wait: 100, - }, // 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. @@ -120,20 +121,6 @@ void watch({ }); ``` -## Project root - -A project is the logical root of a set of related files in a filesystem tree. [Watchman](#why-not-use-watchman) uses it to consolidate watches. - -By default, this will be the first path that has a `.git` directory. However, it can be overridden using [`.watchmanconfig`](https://facebook.github.io/watchman/docs/config.html). - -> With a proliferation of tools that wish to take advantage of filesystem watching at different locations in a filesystem tree, it is possible and likely for those tools to establish multiple overlapping watches. -> -> Most systems have a finite limit on the number of directories that can be watched effectively; when that limit is exceeded the performance and reliability of filesystem watching is degraded, sometimes to the point that it ceases to function. -> -> It is therefore desirable to avoid this situation and consolidate the filesystem watches. Watchman offers the `watch-project` command to allow clients to opt-in to the watch consolidation behavior described below. - -– https://facebook.github.io/watchman/docs/cmd/watch-project.html - ## Motivation To abstract the complexity of orchestrating file watch operations. @@ -166,9 +153,9 @@ Turbowatch can be used to automate any sort of operations that need to happen in The `spawn` function that is exposed by `ChangeEvent` is used to evaluate shell commands. Behind the scenes it uses [zx](https://github.com/google/zx). The reason Turbowatch abstracts `zx` is to enable auto-termination of child-processes when triggers are configured to be `interruptible`. -## Expressions Cheat Sheet +## Expressions -Expressions are used to match files. The most basic expression is [`match`](https://facebook.github.io/watchman/docs/expr/match.html) – it evaluates as true if a glob pattern matches the file, e.g. +Expressions are used to match files. The most basic expression is `match` – it evaluates as true if a glob pattern matches the file, e.g. Match all files with `*.ts` extension: @@ -176,7 +163,7 @@ Match all files with `*.ts` extension: ['match', '*.ts', 'basename'] ``` -Expressions can be combined using [`allof`](https://facebook.github.io/watchman/docs/expr/allof.html) and [`anyof`](https://facebook.github.io/watchman/docs/expr/anyof.html) expressions, e.g. +Expressions can be combined using `allof` and `anyof`, e.g., Match all files with `*.ts` or `*.tsx` extensions: @@ -188,7 +175,7 @@ Match all files with `*.ts` or `*.tsx` extensions: ] ``` -Finally, [`not`](https://facebook.github.io/watchman/docs/expr/not.html) evaluates as true if the sub-expression evaluated as false, i.e. inverts the sub-expression. +Finally, `not` evaluates as true if the sub-expression evaluated as false, i.e. inverts the sub-expression. Match all files with `*.ts` extension, but exclude `index.ts`: @@ -203,52 +190,24 @@ Match all files with `*.ts` extension, but exclude `index.ts`: ] ``` -This is the gist behind Watchman expressions. However, there are many more expressions. Inspect `Expression` type for further guidance. +This is the gist behind Turbowatch expressions. However, there are many more expressions. Inspect `Expression` type for further guidance. ```ts type Expression = // Evaluates as true if all of the grouped expressions also evaluated as true. - // https://facebook.github.io/watchman/docs/expr/allof.html | ['allof', ...Expression[]] // Evaluates as true if any of the grouped expressions also evaluated as true. - // https://facebook.github.io/watchman/docs/expr/anyof.html | ['anyof', ...Expression[]] // Evaluates as true if a given file has a matching parent directory. - // https://facebook.github.io/watchman/docs/expr/dirname.html | ['dirname' | 'idirname', string] - | ['dirname' | 'idirname', string, ['depth', RelationalOperator, number]] - // Evaluates as true if the file exists, has size 0 and is a regular file or directory. - // https://facebook.github.io/watchman/docs/expr/empty.html - | ['empty'] - // Evaluates as true if the file exists. - // https://facebook.github.io/watchman/docs/expr/exists.html - | ['exists'] // Evaluates as true if a glob matches against the basename of the file. - // https://facebook.github.io/watchman/docs/expr/match.html - | ['match' | 'imatch', string | string[], 'basename' | 'wholename'] - // Evaluates as true if file matches the exact string. - // https://facebook.github.io/watchman/docs/expr/name.html - | ['name', string, 'basename' | 'wholename'] + | ['match' | 'imatch', string, 'basename' | 'wholename'] // Evaluates as true if the sub-expression evaluated as false, i.e. inverts the sub-expression. - // https://facebook.github.io/watchman/docs/expr/not.html - | ['not', Expression] - // Evaluates as true if file matches a Perl Compatible Regular Expression. - // https://facebook.github.io/watchman/docs/expr/pcre.html - | ['pcre' | 'ipcre', string, 'basename' | 'wholename'] - // Evaluates as true if the specified time property of the file is greater than the since value. - // https://facebook.github.io/watchman/docs/expr/since.html - | ['since', string | number, 'mtime' | 'ctime', 'oclock'] - // Evaluates as true if the size of a (not deleted) file satisfies the condition. - // https://facebook.github.io/watchman/docs/expr/size.html - | ['size', RelationalOperator, number] - // Evaluates as true if the file suffix matches the second argument. - // https://facebook.github.io/watchman/docs/expr/suffix.html - | ['suffix', string | string[]] - // Evaluates as true if the type of the file matches that specified by the second argument. - // https://facebook.github.io/watchman/docs/expr/type.html - | ['type', FileType]; + | ['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. + ## Recipes ### Rebuilding assets when file changes are detected @@ -506,18 +465,6 @@ ROARR_LOG=true turbowatch | roarr The biggest benefit of using Turbowatch is that it provides a single abstraction for all file watching operations. That is, you might get away with Nodemon, concurrently, `--watch`, etc. running in parallel, but using Turbowatch will introduce consistency to how you perform watch operations. -### Why not use Watchman? - -Turbowatch is based on [Watchman](https://facebook.github.io/watchman/), and while Watchman is great at watching files, Turbowatch adds a layer of abstraction for orchestrating task execution in response to file changes (shell interface, graceful shutdown, output grouping, etc). - -### Why not use Nodemon? - -[Nodemon](https://nodemon.io/) is a popular software to monitor files for changes. However, Turbowatch is more performant and more flexible. - -Turbowatch is based on [Watchman](https://facebook.github.io/watchman/), which has been built to monitor tens of thousands of files with little overhead. - -In terms of the API, Turbowatch leverages powerful Watchman [expression language](#expressions-cheat-sheet) and [zx](https://github.com/google/zx) `child_process` abstractions to give you granular control over event handling and script execution. - ### Why not use X --watch? Many tools provide built-in watch functionality, e.g. `tsc --watch`. However, there are couple of problems with relying on them: diff --git a/cspell.yaml b/cspell.yaml index 778bb4c..1b0c4d7 100644 --- a/cspell.yaml +++ b/cspell.yaml @@ -14,10 +14,12 @@ language: en version: '0.2' words: - gajus + - idirname + - imatch - jiti - SIGINT - SIGTERM - turborepo - turbowatch - vitest - - watchmanconfig \ No newline at end of file + - wholename \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 86e8bcf..b3a8597 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,8 +9,9 @@ "version": "1.0.0", "license": "BSD-3-Clause", "dependencies": { - "fb-watchman": "^2.0.2", + "chokidar": "^3.5.3", "jiti": "^1.17.2", + "micromatch": "^4.0.5", "p-retry": "^4.6.2", "roarr": "^7.14.3", "serialize-error": "^11.0.0", @@ -25,7 +26,6 @@ "@semantic-release/commit-analyzer": "^9.0.2", "@semantic-release/github": "^8.0.7", "@semantic-release/npm": "^9.0.2", - "@types/fb-watchman": "^2.0.1", "@types/node": "^18.14.1", "@types/sinon": "^10.0.13", "@types/yargs": "^17.0.22", @@ -2205,12 +2205,6 @@ "@types/chai": "*" } }, - "node_modules/@types/fb-watchman": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/@types/fb-watchman/-/fb-watchman-2.0.1.tgz", - "integrity": "sha512-iJ7/e6drSmuCzAp96/dpksm8YjxbhhyXWV6m1HPbRHvZwUOUZ5vZvZIAUJxKDtI0UpdNfDvLPiai0MTJmmS+HA==", - "dev": true - }, "node_modules/@types/fs-extra": { "version": "9.0.13", "resolved": "https://registry.npmjs.org/@types/fs-extra/-/fs-extra-9.0.13.tgz", @@ -2796,6 +2790,26 @@ "integrity": "sha512-QXu7BPrP29VllRxH8GwB7x5iX5qWKAAMLqKQGWTeLWVlNHNOpVMJ91dsxQAIWXpjuW5wqvxu3Jd/nRjrJ+0pqg==", "dev": true }, + "node_modules/anymatch": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/anymatch/node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/argparse": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", @@ -2986,6 +3000,14 @@ "integrity": "sha512-NzUnlZexiaH/46WDhANlyR2bXRopNg4F/zuSA3OpZnllCUgRaOF2znDioDWrmbNVsuZk6l9pMquQB38cfBZwkQ==", "dev": true }, + "node_modules/binary-extensions": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.2.0.tgz", + "integrity": "sha512-jDctJ/IVQbZoJykoeHbhXpOlNBqGNcwXJKJog42E5HDPUwQTSdjCHdihjj0DlnheQ7blbT6dHOafNAiS8ooQKA==", + "engines": { + "node": ">=8" + } + }, "node_modules/boolean": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/boolean/-/boolean-3.2.0.tgz", @@ -3046,14 +3068,6 @@ "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" } }, - "node_modules/bser": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/bser/-/bser-2.1.1.tgz", - "integrity": "sha512-gQxTNE/GAfIIrmHLUE3oJyp5FO6HRBfhjnw4/wMmA63ZGDJnWBmgY/lyQBpnDUkGmAhbSe39tx2d/iTOAfglwQ==", - "dependencies": { - "node-int64": "^0.4.0" - } - }, "node_modules/buffer-from": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", @@ -3213,6 +3227,51 @@ "node": "*" } }, + "node_modules/chokidar": { + "version": "3.5.3", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.5.3.tgz", + "integrity": "sha512-Dr3sfKRP6oTcjf2JmUmFJfeVMvXBdegxB0iVQ5eb2V10uFJUCAS8OByZdVAyVb8xXNz3GjjTgj9kLWsZTqE6kw==", + "funding": [ + { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + ], + "dependencies": { + "anymatch": "~3.1.2", + "braces": "~3.0.2", + "glob-parent": "~5.1.2", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.6.0" + }, + "engines": { + "node": ">= 8.10.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, + "node_modules/chokidar/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/chokidar/node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/ci-info": { "version": "3.8.0", "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-3.8.0.tgz", @@ -5692,14 +5751,6 @@ "reusify": "^1.0.4" } }, - "node_modules/fb-watchman": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/fb-watchman/-/fb-watchman-2.0.2.tgz", - "integrity": "sha512-p5161BqbuCaSnB8jIbzQHOlpgsPmK5rJVDfDKO91Axs5NC1uu3HRQm6wt9cd9/+GtQQIO53JdGXXoyDpTAsgYA==", - "dependencies": { - "bser": "2.1.1" - } - }, "node_modules/fetch-blob": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/fetch-blob/-/fetch-blob-3.2.0.tgz", @@ -5881,7 +5932,6 @@ "version": "2.3.2", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", - "dev": true, "hasInstallScript": true, "optional": true, "os": [ @@ -6639,6 +6689,17 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-binary-path": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", + "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", + "dependencies": { + "binary-extensions": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/is-boolean-object": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/is-boolean-object/-/is-boolean-object-1.1.2.tgz", @@ -7873,11 +7934,6 @@ "url": "https://opencollective.com/node-fetch" } }, - "node_modules/node-int64": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/node-int64/-/node-int64-0.4.0.tgz", - "integrity": "sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw==" - }, "node_modules/node-releases": { "version": "2.0.10", "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.10.tgz", @@ -11433,6 +11489,17 @@ "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", "dev": true }, + "node_modules/readdirp": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", + "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "dependencies": { + "picomatch": "^2.2.1" + }, + "engines": { + "node": ">=8.10.0" + } + }, "node_modules/redent": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz", @@ -15557,12 +15624,6 @@ "@types/chai": "*" } }, - "@types/fb-watchman": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/@types/fb-watchman/-/fb-watchman-2.0.1.tgz", - "integrity": "sha512-iJ7/e6drSmuCzAp96/dpksm8YjxbhhyXWV6m1HPbRHvZwUOUZ5vZvZIAUJxKDtI0UpdNfDvLPiai0MTJmmS+HA==", - "dev": true - }, "@types/fs-extra": { "version": "9.0.13", "resolved": "https://registry.npmjs.org/@types/fs-extra/-/fs-extra-9.0.13.tgz", @@ -15985,6 +16046,22 @@ "integrity": "sha512-QXu7BPrP29VllRxH8GwB7x5iX5qWKAAMLqKQGWTeLWVlNHNOpVMJ91dsxQAIWXpjuW5wqvxu3Jd/nRjrJ+0pqg==", "dev": true }, + "anymatch": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "requires": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "dependencies": { + "normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==" + } + } + }, "argparse": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", @@ -16136,6 +16213,11 @@ "integrity": "sha512-NzUnlZexiaH/46WDhANlyR2bXRopNg4F/zuSA3OpZnllCUgRaOF2znDioDWrmbNVsuZk6l9pMquQB38cfBZwkQ==", "dev": true }, + "binary-extensions": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.2.0.tgz", + "integrity": "sha512-jDctJ/IVQbZoJykoeHbhXpOlNBqGNcwXJKJog42E5HDPUwQTSdjCHdihjj0DlnheQ7blbT6dHOafNAiS8ooQKA==" + }, "boolean": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/boolean/-/boolean-3.2.0.tgz", @@ -16177,14 +16259,6 @@ "update-browserslist-db": "^1.0.10" } }, - "bser": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/bser/-/bser-2.1.1.tgz", - "integrity": "sha512-gQxTNE/GAfIIrmHLUE3oJyp5FO6HRBfhjnw4/wMmA63ZGDJnWBmgY/lyQBpnDUkGmAhbSe39tx2d/iTOAfglwQ==", - "requires": { - "node-int64": "^0.4.0" - } - }, "buffer-from": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", @@ -16292,6 +16366,36 @@ "integrity": "sha512-BrgHpW9NURQgzoNyjfq0Wu6VFO6D7IZEmJNdtgNqpzGG8RuNFHt2jQxWlAs4HMe119chBnv+34syEZtc6IhLtA==", "dev": true }, + "chokidar": { + "version": "3.5.3", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.5.3.tgz", + "integrity": "sha512-Dr3sfKRP6oTcjf2JmUmFJfeVMvXBdegxB0iVQ5eb2V10uFJUCAS8OByZdVAyVb8xXNz3GjjTgj9kLWsZTqE6kw==", + "requires": { + "anymatch": "~3.1.2", + "braces": "~3.0.2", + "fsevents": "~2.3.2", + "glob-parent": "~5.1.2", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.6.0" + }, + "dependencies": { + "glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "requires": { + "is-glob": "^4.0.1" + } + }, + "normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==" + } + } + }, "ci-info": { "version": "3.8.0", "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-3.8.0.tgz", @@ -18139,14 +18243,6 @@ "reusify": "^1.0.4" } }, - "fb-watchman": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/fb-watchman/-/fb-watchman-2.0.2.tgz", - "integrity": "sha512-p5161BqbuCaSnB8jIbzQHOlpgsPmK5rJVDfDKO91Axs5NC1uu3HRQm6wt9cd9/+GtQQIO53JdGXXoyDpTAsgYA==", - "requires": { - "bser": "2.1.1" - } - }, "fetch-blob": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/fetch-blob/-/fetch-blob-3.2.0.tgz", @@ -18278,7 +18374,6 @@ "version": "2.3.2", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", - "dev": true, "optional": true }, "function-bind": { @@ -18802,6 +18897,14 @@ "has-bigints": "^1.0.1" } }, + "is-binary-path": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", + "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", + "requires": { + "binary-extensions": "^2.0.0" + } + }, "is-boolean-object": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/is-boolean-object/-/is-boolean-object-1.1.2.tgz", @@ -19700,11 +19803,6 @@ "formdata-polyfill": "^4.0.10" } }, - "node-int64": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/node-int64/-/node-int64-0.4.0.tgz", - "integrity": "sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw==" - }, "node-releases": { "version": "2.0.10", "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.10.tgz", @@ -22210,6 +22308,14 @@ } } }, + "readdirp": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", + "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "requires": { + "picomatch": "^2.2.1" + } + }, "redent": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz", diff --git a/package.json b/package.json index b36b695..5a33842 100644 --- a/package.json +++ b/package.json @@ -8,8 +8,9 @@ "turbowatch": "./dist/bin/turbowatch.js" }, "dependencies": { - "fb-watchman": "^2.0.2", + "chokidar": "^3.5.3", "jiti": "^1.17.2", + "micromatch": "^4.0.5", "p-retry": "^4.6.2", "roarr": "^7.14.3", "serialize-error": "^11.0.0", @@ -21,7 +22,6 @@ "@semantic-release/commit-analyzer": "^9.0.2", "@semantic-release/github": "^8.0.7", "@semantic-release/npm": "^9.0.2", - "@types/fb-watchman": "^2.0.1", "@types/node": "^18.14.1", "@types/sinon": "^10.0.13", "@types/yargs": "^17.0.22", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 555fe8c..594c4c2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -7,10 +7,12 @@ specifiers: '@types/fb-watchman': ^2.0.1 '@types/node': ^18.14.1 '@types/sinon': ^10.0.13 + '@types/yargs': ^17.0.22 + chokidar: ^3.5.3 cspell: ^6.26.3 eslint: ^8.34.0 eslint-config-canonical: ^40.0.7 - fb-watchman: ^2.0.2 + jiti: ^1.17.2 p-retry: ^4.6.2 roarr: ^7.14.3 semantic-release: ^20.1.0 @@ -19,14 +21,17 @@ specifiers: throttle-debounce: ^5.0.0 typescript: ^4.9.4 vitest: ^0.28.5 + yargs: ^17.7.1 zx: ^7.1.1 dependencies: - fb-watchman: 2.0.2 + chokidar: 3.5.3 + jiti: 1.17.2 p-retry: 4.6.2 roarr: 7.14.3 serialize-error: 11.0.0 throttle-debounce: 5.0.0 + yargs: 17.7.1 zx: 7.2.0 devDependencies: @@ -36,6 +41,7 @@ devDependencies: '@types/fb-watchman': 2.0.1 '@types/node': 18.14.2 '@types/sinon': 10.0.13 + '@types/yargs': 17.0.22 cspell: 6.27.0 eslint: 8.35.0 eslint-config-canonical: 40.0.8_zro3vrnnfyw5fi3rgmsvqzdpce @@ -1547,6 +1553,16 @@ packages: '@types/node': 18.14.2 dev: true + /@types/yargs-parser/21.0.0: + resolution: {integrity: sha512-iO9ZQHkZxHn4mSakYV0vFHAVDyEOIJQrV2uZ06HxEPcx+mt8swXoZHIbaaJ2crJYFfErySgktuTZ3BeLz+XmFA==} + dev: true + + /@types/yargs/17.0.22: + resolution: {integrity: sha512-pet5WJ9U8yPVRhkwuEIp5ktAeAqRZOq4UdAyWLWzxbtpyXnzbtLdKiXAjJzi/KLmPGS9wk86lUFWZFN6sISo4g==} + dependencies: + '@types/yargs-parser': 21.0.0 + dev: true + /@typescript-eslint/eslint-plugin/5.54.0_6mj2wypvdnknez7kws2nfdgupi: resolution: {integrity: sha512-+hSN9BdSr629RF02d7mMtXhAJvDTyCbprNYJKrXETlul/Aml6YZwd90XioVbjejQeHbb3R8Dg0CkRgoJDxo8aw==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} @@ -1821,7 +1837,6 @@ packages: /ansi-regex/5.0.1: resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} engines: {node: '>=8'} - dev: true /ansi-regex/6.0.1: resolution: {integrity: sha512-n5M855fKb2SsfMIiFFoVrABHJC8QtHwVx+mHWP3QcEqBHYienj5dHSgjbxtC0WEZXYt4wcD6zrQElDPhFuZgfA==} @@ -1840,7 +1855,6 @@ packages: engines: {node: '>=8'} dependencies: color-convert: 2.0.1 - dev: true /ansi-styles/5.2.0: resolution: {integrity: sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==} @@ -1856,6 +1870,14 @@ packages: resolution: {integrity: sha512-QXu7BPrP29VllRxH8GwB7x5iX5qWKAAMLqKQGWTeLWVlNHNOpVMJ91dsxQAIWXpjuW5wqvxu3Jd/nRjrJ+0pqg==} dev: true + /anymatch/3.1.3: + resolution: {integrity: sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==} + engines: {node: '>= 8'} + dependencies: + normalize-path: 3.0.0 + picomatch: 2.3.1 + dev: false + /argparse/2.0.1: resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} dev: true @@ -1970,6 +1992,11 @@ packages: resolution: {integrity: sha512-NzUnlZexiaH/46WDhANlyR2bXRopNg4F/zuSA3OpZnllCUgRaOF2znDioDWrmbNVsuZk6l9pMquQB38cfBZwkQ==} dev: true + /binary-extensions/2.2.0: + resolution: {integrity: sha512-jDctJ/IVQbZoJykoeHbhXpOlNBqGNcwXJKJog42E5HDPUwQTSdjCHdihjj0DlnheQ7blbT6dHOafNAiS8ooQKA==} + engines: {node: '>=8'} + dev: false + /boolean/3.2.0: resolution: {integrity: sha512-d0II/GO9uf9lfUHH2BQsjxzRJZBdsjgsBiW4BvhWk/3qoKwQFjIDVN19PfX8F2D/r9PCMTtLWjYVCFrpeYUzsw==} @@ -2001,12 +2028,6 @@ packages: update-browserslist-db: 1.0.10_browserslist@4.21.5 dev: true - /bser/2.1.1: - resolution: {integrity: sha512-gQxTNE/GAfIIrmHLUE3oJyp5FO6HRBfhjnw4/wMmA63ZGDJnWBmgY/lyQBpnDUkGmAhbSe39tx2d/iTOAfglwQ==} - dependencies: - node-int64: 0.4.0 - dev: false - /buffer-from/1.1.2: resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==} dev: true @@ -2104,6 +2125,21 @@ packages: resolution: {integrity: sha512-BrgHpW9NURQgzoNyjfq0Wu6VFO6D7IZEmJNdtgNqpzGG8RuNFHt2jQxWlAs4HMe119chBnv+34syEZtc6IhLtA==} dev: true + /chokidar/3.5.3: + resolution: {integrity: sha512-Dr3sfKRP6oTcjf2JmUmFJfeVMvXBdegxB0iVQ5eb2V10uFJUCAS8OByZdVAyVb8xXNz3GjjTgj9kLWsZTqE6kw==} + engines: {node: '>= 8.10.0'} + dependencies: + anymatch: 3.1.3 + braces: 3.0.2 + glob-parent: 5.1.2 + is-binary-path: 2.1.0 + is-glob: 4.0.3 + normalize-path: 3.0.0 + readdirp: 3.6.0 + optionalDependencies: + fsevents: 2.3.2 + dev: false + /ci-info/3.8.0: resolution: {integrity: sha512-eXTggHWSooYhq49F2opQhuHWgzucfF2YgODK4e1566GQs5BIfP30B0oenwBJHfWxAs2fyPB1s7Mg949zLf61Yw==} engines: {node: '>=8'} @@ -2160,7 +2196,6 @@ packages: string-width: 4.2.3 strip-ansi: 6.0.1 wrap-ansi: 7.0.0 - dev: true /color-convert/1.9.3: resolution: {integrity: sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==} @@ -2173,7 +2208,6 @@ packages: engines: {node: '>=7.0.0'} dependencies: color-name: 1.1.4 - dev: true /color-name/1.1.3: resolution: {integrity: sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==} @@ -2181,7 +2215,6 @@ packages: /color-name/1.1.4: resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} - dev: true /commander/10.0.0: resolution: {integrity: sha512-zS5PnTI22FIRM6ylNW8G4Ap0IEOyk62fhLSD0+uHRT9McRCLGpkVNvao4bjimpK/GShynyQkFFxHhwMcETmduA==} @@ -2624,7 +2657,6 @@ packages: /emoji-regex/8.0.0: resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} - dev: true /emoji-regex/9.2.2: resolution: {integrity: sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==} @@ -2769,7 +2801,6 @@ packages: /escalade/3.1.1: resolution: {integrity: sha512-k0er2gUkLf8O0zKJiAhmkTnJlTvINGv7ygDNPbeIsX/TJjGJZHuh9B2UxbsaEkmlEo9MfhrSzmhIlhRlI2GXnw==} engines: {node: '>=6'} - dev: true /escape-string-regexp/1.0.5: resolution: {integrity: sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==} @@ -3646,12 +3677,6 @@ packages: dependencies: reusify: 1.0.4 - /fb-watchman/2.0.2: - resolution: {integrity: sha512-p5161BqbuCaSnB8jIbzQHOlpgsPmK5rJVDfDKO91Axs5NC1uu3HRQm6wt9cd9/+GtQQIO53JdGXXoyDpTAsgYA==} - dependencies: - bser: 2.1.1 - dev: false - /fetch-blob/3.2.0: resolution: {integrity: sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==} engines: {node: ^12.20 || >= 14.13} @@ -3779,7 +3804,6 @@ packages: engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} os: [darwin] requiresBuild: true - dev: true optional: true /function-bind/1.1.1: @@ -3812,7 +3836,6 @@ packages: /get-caller-file/2.0.5: resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==} engines: {node: 6.* || 8.* || >= 10.*} - dev: true /get-func-name/2.0.0: resolution: {integrity: sha512-Hm0ixYtaSZ/V7C8FJrtZIuBBI+iSgL+1Aq82zSu8VQNB4S3Gk8e7Qs3VwBDJAhmRZcFqkl3tQu36g/Foh5I5ig==} @@ -4234,6 +4257,13 @@ packages: has-bigints: 1.0.2 dev: true + /is-binary-path/2.1.0: + resolution: {integrity: sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==} + engines: {node: '>=8'} + dependencies: + binary-extensions: 2.2.0 + dev: false + /is-boolean-object/1.1.2: resolution: {integrity: sha512-gDYaKHJmnj4aWxyj6YHyXVpdQawtVLHU5cb+eztPGczf6cjuTdwve5ZIEfgXqH4e57An1D1AKf8CZ3kYrQRqYA==} engines: {node: '>= 0.4'} @@ -4280,7 +4310,6 @@ packages: /is-fullwidth-code-point/3.0.0: resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==} engines: {node: '>=8'} - dev: true /is-fullwidth-code-point/4.0.0: resolution: {integrity: sha512-O4L094N2/dZ7xqVdrXhh9r1KODPJpFms8B5sGdJLPy664AgvXsreZUyCQQNItZRDlYug4xStLjNp/sz3HvBowQ==} @@ -4497,6 +4526,11 @@ packages: engines: {node: '>= 0.6.0'} dev: true + /jiti/1.17.2: + resolution: {integrity: sha512-Xf0nU8+8wuiQpLcqdb2HRyHqYwGk2Pd+F7kstyp20ZuqTyCmB9dqpX2NxaxFc1kovraa2bG6c1RL3W7XfapiZg==} + hasBin: true + dev: false + /js-sdsl/4.3.0: resolution: {integrity: sha512-mifzlm2+5nZ+lEcLJMoBK0/IH/bDg8XnJfd/Wq6IP+xoCjLZsTOnV2QpxlVbX9bMnkl5PdEjNtBJ9Cj1NjifhQ==} dev: true @@ -4996,10 +5030,6 @@ packages: formdata-polyfill: 4.0.10 dev: false - /node-int64/0.4.0: - resolution: {integrity: sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw==} - dev: false - /node-releases/2.0.10: resolution: {integrity: sha512-5GFldHPXVG/YZmFzJvKK2zDSzPKhEp0+ZR5SVaoSag9fsL5YgHbUHDfnG5494ISANDcK4KwPXAx2xqVEydmd7w==} dev: true @@ -5030,6 +5060,11 @@ packages: remove-trailing-separator: 1.1.0 dev: true + /normalize-path/3.0.0: + resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==} + engines: {node: '>=0.10.0'} + dev: false + /normalize-url/6.1.0: resolution: {integrity: sha512-DlL+XwOy3NxAQ8xuC0okPgK46iuVNAK01YN7RueYBqqFeGsBjV9XmCAzAdgt+667bCl5kPh9EqKKDwnaPG1I7A==} engines: {node: '>=10'} @@ -5660,6 +5695,13 @@ packages: util-deprecate: 1.0.2 dev: true + /readdirp/3.6.0: + resolution: {integrity: sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==} + engines: {node: '>=8.10.0'} + dependencies: + picomatch: 2.3.1 + dev: false + /redent/3.0.0: resolution: {integrity: sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==} engines: {node: '>=8'} @@ -5748,7 +5790,6 @@ packages: /require-directory/2.1.1: resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==} engines: {node: '>=0.10.0'} - dev: true /requireindex/1.1.0: resolution: {integrity: sha512-LBnkqsDE7BZKvqylbmn7lTIVdpx4K/QCduRATpO5R+wtPmky/a8pN1bO2D6wXppn1497AJF9mNjqAXr6bdl9jg==} @@ -6127,7 +6168,6 @@ packages: emoji-regex: 8.0.0 is-fullwidth-code-point: 3.0.0 strip-ansi: 6.0.1 - dev: true /string-width/5.1.2: resolution: {integrity: sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==} @@ -6184,7 +6224,6 @@ packages: engines: {node: '>=8'} dependencies: ansi-regex: 5.0.1 - dev: true /strip-ansi/7.0.1: resolution: {integrity: sha512-cXNxvT8dFNRVfhVME3JAe98mkXDYN2O1l7jmcwMnOslDeESg1rF/OZMtK0nRAhiari1unG5cD4jG3rapUAkLbw==} @@ -6773,7 +6812,6 @@ packages: ansi-styles: 4.3.0 string-width: 4.2.3 strip-ansi: 6.0.1 - dev: true /wrappy/1.0.2: resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} @@ -6820,7 +6858,6 @@ packages: /y18n/5.0.8: resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==} engines: {node: '>=10'} - dev: true /yallist/3.1.1: resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} @@ -6851,7 +6888,6 @@ packages: /yargs-parser/21.1.1: resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==} engines: {node: '>=12'} - dev: true /yargs/17.7.1: resolution: {integrity: sha512-cwiTb08Xuv5fqF4AovYacTFNxk62th7LKJ6BL9IGUpTJrWoU7/7WdQGTP2SjKf1dUNBGzDd28p/Yfs/GI6JrLw==} @@ -6864,7 +6900,6 @@ packages: string-width: 4.2.3 y18n: 5.0.8 yargs-parser: 21.1.1 - dev: true /yocto-queue/0.1.0: resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} diff --git a/src/index.ts b/src/index.ts index 21adb7f..de504c0 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,2 +1,2 @@ -export { type ChangeEvent } from './types'; +export { type ChangeEvent, type Expression } from './types'; export { watch } from './watch'; diff --git a/src/subscribe.test.ts b/src/subscribe.test.ts index f599605..437dc02 100644 --- a/src/subscribe.test.ts +++ b/src/subscribe.test.ts @@ -1,24 +1,9 @@ import { subscribe } from './subscribe'; import { type Trigger } from './types'; -import { EventEmitter } from 'events'; import { setTimeout } from 'node:timers'; import * as sinon from 'sinon'; import { expect, it } from 'vitest'; -class Client extends EventEmitter { - public cancelCommands() {} - - public capabilityCheck() {} - - public command() {} - - public connect() {} - - public end() {} - - public sendNextCommand() {} -} - const defaultTrigger = { expression: ['match', 'foo', 'basename'], id: 'foo', @@ -42,30 +27,9 @@ const wait = (time: number) => { }); }; -it('rejects promise if Watchman "subscribe" command produces an error', async () => { - const client = new Client(); - const trigger = { - ...defaultTrigger, - } as Trigger; - - const clientMock = sinon.mock(client); - - clientMock - .expects('command') - .once() - .callsFake((args, callback) => { - callback(new Error('foo')); - }); - - await expect(subscribe(client, trigger)).rejects.toThrowError('foo'); - - expect(clientMock.verify()); -}); - it('evaluates onChange', async () => { const abortController = new AbortController(); - const client = new Client(); const trigger = { ...defaultTrigger, abortSignal: abortController.signal, @@ -82,129 +46,18 @@ it('evaluates onChange', async () => { return Promise.resolve(null); }); - const clientMock = sinon.mock(client); + const subscription = subscribe(trigger); - clientMock - .expects('on') - .once() - .callsFake((event, callback) => { - setImmediate(() => { - callback({ - files: [], - subscription: 'foo', - }); - }); - }); + subscription.trigger([]); - await subscribe(client, trigger); - - expect(clientMock.verify()); expect(subscriptionMock.verify()); expect(onChange.args[0][0].taskId).toMatch(/^[a-z\d]{8}$/u); }); -it('evaluates multiple onChange', async () => { - const abortController = new AbortController(); - - const client = new Client(); - const trigger = { - ...defaultTrigger, - abortSignal: abortController.signal, - } as Trigger; - - const subscriptionMock = sinon.mock(trigger); - - const onChange = subscriptionMock.expects('onChange').thrice(); - - onChange.onFirstCall().resolves(null); - - onChange.onSecondCall().resolves(null); - - onChange.onThirdCall().callsFake(() => { - abortController.abort(); - - return Promise.resolve(null); - }); - - const clientMock = sinon.mock(client); - - clientMock.expects('on').callsFake((event, callback) => { - callback({ - files: [], - subscription: 'foo', - }); - setTimeout(() => { - callback({ - files: [], - subscription: 'foo', - }); - setTimeout(() => { - callback({ - files: [], - subscription: 'foo', - }); - }); - }); - }); - - await subscribe(client, trigger); - - expect(onChange.callCount).toBe(3); -}); - -it('debounces onChange', async () => { - const abortController = new AbortController(); - - const client = new Client(); - const trigger = { - ...defaultTrigger, - abortSignal: abortController.signal, - debounce: { - wait: 100, - }, - } as Trigger; - - const subscriptionMock = sinon.mock(trigger); - - const onChange = subscriptionMock.expects('onChange').thrice(); - - setTimeout(() => { - abortController.abort(); - }, 200); - - onChange.onFirstCall().resolves(null); - - const clientMock = sinon.mock(client); - - clientMock.expects('on').callsFake((event, callback) => { - callback({ - files: [], - subscription: 'foo', - }); - setTimeout(() => { - callback({ - files: [], - subscription: 'foo', - }); - setTimeout(() => { - callback({ - files: [], - subscription: 'foo', - }); - }); - }); - }); - - await subscribe(client, trigger); - - expect(onChange.callCount).toBe(1); -}); - it('waits for onChange to complete when { interruptible: false }', async () => { const abortController = new AbortController(); - const client = new Client(); const trigger = { ...defaultTrigger, abortSignal: abortController.signal, @@ -229,20 +82,10 @@ it('waits for onChange to complete when { interruptible: false }', async () => { abortController.abort(); }); - const clientMock = sinon.mock(client); + const subscription = subscribe(trigger); - clientMock.expects('on').callsFake((event, callback) => { - callback({ - files: [], - subscription: 'foo', - }); - callback({ - files: [], - subscription: 'foo', - }); - }); - - await subscribe(client, trigger); + await subscription.trigger([]); + await subscription.trigger([]); expect(onChange.callCount).toBe(2); }); @@ -250,7 +93,6 @@ it('waits for onChange to complete when { interruptible: false }', async () => { it('throws if onChange produces an error', async () => { const abortController = new AbortController(); - const client = new Client(); const trigger = { ...defaultTrigger, abortSignal: abortController.signal, @@ -260,16 +102,9 @@ it('throws if onChange produces an error', async () => { subscriptionMock.expects('onChange').rejects(new Error('foo')); - const clientMock = sinon.mock(client); - - clientMock.expects('on').callsFake((event, callback) => { - callback({ - files: [], - subscription: 'foo', - }); - }); + const subscription = subscribe(trigger); - await expect(subscribe(client, trigger)).rejects.toThrowError('foo'); + await expect(subscription.trigger([])).rejects.toThrowError('foo'); await abortController.abort(); }); @@ -277,7 +112,6 @@ it('throws if onChange produces an error', async () => { it('retries failing routines', async () => { const abortController = new AbortController(); - const client = new Client(); const trigger = { ...defaultTrigger, abortSignal: abortController.signal, @@ -297,22 +131,16 @@ it('retries failing routines', async () => { return Promise.resolve(null); }); - const clientMock = sinon.mock(client); + const subscription = await subscribe(trigger); - clientMock.expects('on').callsFake((event, callback) => { - callback({ - files: [], - subscription: 'foo', - }); - }); + await subscription.trigger([]); - await subscribe(client, trigger); + expect(onChange.verify()); }); it('reports { first: true } only for the first event', async () => { const abortController = new AbortController(); - const client = new Client(); const trigger = { ...defaultTrigger, abortSignal: abortController.signal, @@ -330,20 +158,10 @@ it('reports { first: true } only for the first event', async () => { return Promise.resolve(null); }); - const clientMock = sinon.mock(client); - - clientMock.expects('on').callsFake((event, callback) => { - callback({ - files: [], - subscription: 'foo', - }); - callback({ - files: [], - subscription: 'foo', - }); - }); + const subscription = subscribe(trigger); - await subscribe(client, trigger); + await subscription.trigger([]); + await subscription.trigger([]); expect(onChange.args).toMatchObject([ [ @@ -364,7 +182,6 @@ it('reports { first: true } only for the first event', async () => { it('waits for onChange to complete before resolving when it receives a shutdown signal', async () => { const abortController = new AbortController(); - const client = new Client(); const trigger = { ...defaultTrigger, abortSignal: abortController.signal, @@ -387,82 +204,14 @@ it('waits for onChange to complete before resolving when it receives a shutdown }); }); - const clientMock = sinon.mock(client); - - clientMock - .expects('on') - .once() - .callsFake((event, callback) => { - callback({ - files: [], - subscription: 'foo', - }); - }); - - setImmediate(() => { - abortController.abort(); - }); - - await subscribe(client, trigger); - - expect(clientMock.verify()); - expect(subscriptionMock.verify()); - - expect(resolved).toBe(true); -}); - -it('waits for onTeardown to complete before resolving when it receives a shutdown signal', async () => { - const abortController = new AbortController(); - - const client = new Client(); - const trigger = { - ...defaultTrigger, - abortSignal: abortController.signal, - } as Trigger; - - let resolved = false; - - const subscriptionMock = sinon.mock(trigger); - - subscriptionMock - .expects('onChange') - .once() - .callsFake(() => { - return null; - }); - - subscriptionMock - .expects('onTeardown') - .once() - .callsFake(() => { - return new Promise((resolve) => { - setTimeout(() => { - resolved = true; - - resolve(null); - }, 100); - }); - }); - - const clientMock = sinon.mock(client); - - clientMock - .expects('on') - .once() - .callsFake((event, callback) => { - callback({ - files: [], - subscription: 'foo', - }); - }); + const subscription = subscribe(trigger); setImmediate(() => { abortController.abort(); }); - await subscribe(client, trigger); + await subscription.trigger([]); - expect(clientMock.verify()); expect(subscriptionMock.verify()); expect(resolved).toBe(true); diff --git a/src/subscribe.ts b/src/subscribe.ts index a573976..cc8163a 100644 --- a/src/subscribe.ts +++ b/src/subscribe.ts @@ -1,258 +1,181 @@ import { createSpawn } from './createSpawn'; import { generateShortId } from './generateShortId'; import { Logger } from './Logger'; -import { type Trigger, type WatchmanClient } from './types'; -import path from 'node:path'; +import { + type ActiveTask, + type ChokidarEvent, + type Subscription, + type SubscriptionEvent, + type Trigger, +} from './types'; import retry from 'p-retry'; -import { debounce } from 'throttle-debounce'; const log = Logger.child({ namespace: 'subscribe', }); -type WatchmanEvent = { - version: string; -}; +export const subscribe = (trigger: Trigger): Subscription => { + let activeTask: ActiveTask | null = null; -type SubscriptionEvent = { - files: Array<{ name: string }>; - root: string; - subscription: string; - warning?: string; -}; + let first = true; -export const subscribe = async ( - client: WatchmanClient, - trigger: Trigger, -): Promise => { - try { - await new Promise((resolve, reject) => { - client.command( - [ - 'subscribe', - trigger.watch, - trigger.id, - { - expression: trigger.expression, - fields: ['name', 'size', 'mtime_ms', 'exists', 'type'], - relative_root: trigger.relativePath, - }, - ], - (error, response: WatchmanEvent & { subscribe: string }) => { - if (error) { - reject(error); + const handleSubscriptionEvent = async (event: SubscriptionEvent) => { + if (trigger.abortSignal?.aborted) { + log.warn('ignoring event because Turbowatch is shutting down'); - return; - } + return undefined; + } - log.info('subscription %s established', response.subscribe); + if (event.files.length > 10) { + log.trace( + { + files: event.files.slice(0, 10).map((file) => { + return file.name; + }), }, + '%d files changed; showing first 10', + event.files.length, ); + } else { + log.trace( + { + files: event.files.map((file) => { + return file.name; + }), + }, + '%d files changed', + event.files.length, + ); + } - /** - * @property queued Indicates that a follow action has been queued. - */ - type ActiveTask = { - abortController: AbortController | null; - id: string; - promise: Promise; - queued: boolean; - }; + let reportFirst = first; - let activeTask: ActiveTask | null = null; + if (first) { + reportFirst = true; + first = false; + } - let first = true; + let controller: AbortController | null = null; - let handleSubscriptionEvent = async (event: SubscriptionEvent) => { - if (trigger.abortSignal?.aborted) { - log.warn('ignoring event because Turbowatch is shutting down'); + if (trigger.interruptible) { + controller = new AbortController(); + } - return; - } + let abortSignal = controller?.signal; - if (event.files.length > 10) { - log.trace( - { - files: event.files.slice(0, 10).map((file) => { - return file.name; - }), - }, - '%d files changed; showing first 10', - event.files.length, - ); - } else { - log.trace( - { - files: event.files.map((file) => { - return file.name; - }), - }, - '%d files changed', - event.files.length, - ); - } + if (abortSignal && trigger.abortSignal) { + trigger.abortSignal.addEventListener('abort', () => { + controller?.abort(); + }); + } else if (trigger.abortSignal) { + abortSignal = trigger.abortSignal; + } - let reportFirst = first; + if (activeTask) { + if (trigger.interruptible) { + log.warn('aborted task %s (%s)', trigger.name, activeTask.id); - if (first) { - reportFirst = true; - first = false; + if (!activeTask.abortController) { + throw new Error('Expected abort controller to be set'); } - let controller: AbortController | null = null; - - if (trigger.interruptible) { - controller = new AbortController(); - } + activeTask.abortController.abort(); - let abortSignal = controller?.signal; + activeTask = null; + } else { + log.warn( + 'waiting for %s (%s) task to complete', + trigger.name, + activeTask.id, + ); - if (abortSignal && trigger.abortSignal) { - trigger.abortSignal.addEventListener('abort', () => { - controller?.abort(); - }); - } else if (trigger.abortSignal) { - abortSignal = trigger.abortSignal; + if (activeTask.queued) { + return undefined; } - if (activeTask) { - if (trigger.interruptible) { - log.warn('aborted task %s (%s)', trigger.name, activeTask.id); - - if (!activeTask.abortController) { - throw new Error('Expected abort controller to be set'); - } - - activeTask.abortController.abort(); - - activeTask = null; - } else { - log.warn( - 'waiting for %s (%s) task to complete', - trigger.name, - activeTask.id, - ); - - if (activeTask.queued) { - return; - } + activeTask.queued = true; - activeTask.queued = true; + try { + await activeTask.promise; + } catch { + // nothing to do + } + } + } - try { - await activeTask.promise; - } catch { - // nothing to do - } + const taskId = generateShortId(); + + const taskPromise = retry( + (attempt: number) => { + return trigger.onChange({ + abortSignal, + attempt, + files: event.files.map((file) => { + return { + name: file.name, + }; + }), + first: reportFirst, + spawn: createSpawn(taskId, { + abortSignal, + throttleOutput: trigger.throttleOutput, + }), + taskId, + }); + }, + { + ...trigger.retry, + onFailedAttempt: ({ retriesLeft }) => { + if (retriesLeft > 0) { + log.warn('retrying task %s (%s)...', trigger.name, taskId); } + }, + }, + ) + // eslint-disable-next-line promise/prefer-await-to-then + .then(() => { + if (taskId === activeTask?.id) { + log.trace('completed task %s (%s)', trigger.name, taskId); + + activeTask = null; } + }); - const taskId = generateShortId(); - - const taskPromise = retry( - (attempt: number) => { - return trigger.onChange({ - abortSignal, - attempt, - // eslint-disable-next-line @typescript-eslint/no-explicit-any - files: event.files.map((file: any) => { - return { - exists: file.exists, - mtime: file.mtime_ms, - name: path.join(event.root, file.name), - size: file.size, - }; - }), - first: reportFirst, - spawn: createSpawn(taskId, { - abortSignal, - throttleOutput: trigger.throttleOutput, - }), - taskId, - warning: event.warning ?? null, - }); - }, - { - ...trigger.retry, - onFailedAttempt: ({ retriesLeft }) => { - if (retriesLeft > 0) { - log.warn('retrying task %s (%s)...', trigger.name, taskId); - } - }, - }, - ) - // eslint-disable-next-line promise/prefer-await-to-then - .then(() => { - if (taskId === activeTask?.id) { - log.trace('completed task %s (%s)', trigger.name, taskId); + // eslint-disable-next-line require-atomic-updates + activeTask = { + abortController: controller, + id: taskId, + promise: taskPromise, + queued: false, + }; - activeTask = null; - } - }) - // eslint-disable-next-line promise/prefer-await-to-then - .catch((error) => { - reject(error); - }); + log.trace('started task %s (%s)', trigger.name, taskId); - // eslint-disable-next-line require-atomic-updates - activeTask = { - abortController: controller, - id: taskId, - promise: taskPromise, - queued: false, - }; + return taskPromise; + }; - log.trace('started task %s (%s)', trigger.name, taskId); - }; + return { + activeTask, + expression: trigger.expression, + teardown: async () => { + if (trigger.onTeardown) { + const taskId = generateShortId(); - if (trigger.debounce) { - handleSubscriptionEvent = debounce( - trigger.debounce.wait, - handleSubscriptionEvent, - { - atBegin: trigger.debounce.leading, - }, - ); + await trigger.onTeardown({ + spawn: createSpawn(taskId, { + throttleOutput: trigger.throttleOutput, + }), + }); } - - client.on('subscription', async (event: SubscriptionEvent) => { - if (event.subscription !== trigger.id) { - return; - } - - handleSubscriptionEvent(event); - }); - - trigger.abortSignal?.addEventListener( - 'abort', - () => { - client.command(['unsubscribe', trigger.watch, trigger.id], () => { - log.debug('unsubscribed'); - }); - - if (activeTask?.promise) { - // eslint-disable-next-line promise/prefer-await-to-then - activeTask?.promise.finally(() => { - resolve(null); - }); - } else { - resolve(null); - } - }, - { - once: true, - }, - ); - }); - } finally { - if (trigger.onTeardown) { - const taskId = generateShortId(); - - await trigger.onTeardown({ - spawn: createSpawn(taskId, { - throttleOutput: trigger.throttleOutput, + }, + trigger: async (events: readonly ChokidarEvent[]) => { + await handleSubscriptionEvent({ + files: events.map((event) => { + return { + name: event.path, + }; }), }); - } - } + }, + }; }; diff --git a/src/testExpression.test.ts b/src/testExpression.test.ts new file mode 100644 index 0000000..7601f3f --- /dev/null +++ b/src/testExpression.test.ts @@ -0,0 +1,96 @@ +import { testExpression } from './testExpression'; +import { expect, it } from 'vitest'; + +it('[allof] evaluates as true if all of the grouped expressions also evaluated as true (true)', () => { + expect( + testExpression(['allof', ['match', 'bar', 'basename']], '/foo/bar'), + ).toBe(true); +}); + +it('[allof] evaluates as true if all of the grouped expressions also evaluated as true (false, true)', () => { + expect( + testExpression( + ['allof', ['match', 'foo', 'basename'], ['match', 'bar', 'basename']], + '/foo/bar', + ), + ).toBe(false); +}); + +it('[allof] evaluates as true if all of the grouped expressions also evaluated as true (false)', () => { + expect( + testExpression(['allof', ['match', 'foo', 'basename']], '/foo/bar'), + ).toBe(false); +}); + +it('[anyof] evaluates as true if any of the grouped expressions also evaluated as true (true)', () => { + expect( + testExpression(['anyof', ['match', 'bar', 'basename']], '/foo/bar'), + ).toBe(true); +}); + +it('[anyof] evaluates as true if any of the grouped expressions also evaluated as true (false, true)', () => { + expect( + testExpression( + ['anyof', ['match', 'foo', 'basename'], ['match', 'bar', 'basename']], + '/foo/bar', + ), + ).toBe(true); +}); + +it('[anyof] evaluates as true if any of the grouped expressions also evaluated as true (false)', () => { + expect( + testExpression(['anyof', ['match', 'foo', 'basename']], '/foo/bar'), + ).toBe(false); +}); + +it('[dirname] evaluates as true if a given file has a matching parent directory (foo)', () => { + expect(testExpression(['dirname', 'foo'], '/foo/bar')).toBe(true); +}); + +it('[dirname] evaluates as true if a given file has a matching parent directory (bar)', () => { + expect(testExpression(['dirname', 'bar'], '/foo/bar')).toBe(false); +}); + +it('[idirname] evaluates as true if a given file has a matching parent directory (foo)', () => { + expect(testExpression(['idirname', 'FOO'], '/foo/bar')).toBe(true); +}); + +it('[idirname] evaluates as true if a given file has a matching parent directory (bar)', () => { + expect(testExpression(['idirname', 'BAR'], '/foo/bar')).toBe(false); +}); + +it('[match] matches basename (bar)', () => { + expect(testExpression(['match', 'bar', 'basename'], '/foo/bar')).toBe(true); +}); + +it('[match] matches basename (b*r)', () => { + expect(testExpression(['match', 'b*r', 'basename'], '/foo/bar')).toBe(true); +}); + +it('[match] does not match basename (bar)', () => { + expect(testExpression(['match', 'foo', 'basename'], '/foo/bar')).toBe(false); +}); + +it('[match] matches basename (BAR) (case insensitive)', () => { + expect(testExpression(['imatch', 'bar', 'basename'], '/foo/bar')).toBe(true); +}); + +it('[match] matches basename (B*R) (case insensitive)', () => { + expect(testExpression(['imatch', 'b*r', 'basename'], '/foo/bar')).toBe(true); +}); + +it('[match] does not match basename (BAR) (case insensitive)', () => { + expect(testExpression(['imatch', 'foo', 'basename'], '/foo/bar')).toBe(false); +}); + +it('[not] evaluates as true if the sub-expression evaluated as false, i.e. inverts the sub-expression (true -> false)', () => { + expect( + testExpression(['not', ['match', 'bar', 'basename']], '/foo/bar'), + ).toBe(false); +}); + +it('[not] evaluates as true if the sub-expression evaluated as false, i.e. inverts the sub-expression (false -> true)', () => { + expect( + testExpression(['not', ['match', 'foo', 'basename']], '/foo/bar'), + ).toBe(true); +}); diff --git a/src/testExpression.ts b/src/testExpression.ts new file mode 100644 index 0000000..588b47a --- /dev/null +++ b/src/testExpression.ts @@ -0,0 +1,87 @@ +import { type Expression } from './types'; +import micromatch from 'micromatch'; +import path from 'node:path'; + +export const testExpression = (expression: Expression, fileName: string) => { + const name = expression[0]; + + if (name === 'allof') { + const nextExpressions = expression.slice(1) as Expression[]; + + return nextExpressions.every((nextExpression) => { + return testExpression(nextExpression, fileName); + }); + } + + if (name === 'anyof') { + const nextExpressions = expression.slice(1) as Expression[]; + + return nextExpressions.some((nextExpression) => { + return testExpression(nextExpression, fileName); + }); + } + + if (name === 'dirname') { + const patternInput = expression[1]; + + if (patternInput.startsWith('/')) { + throw new Error('dirname cannot start with /'); + } + + if (patternInput.endsWith('/')) { + throw new Error('dirname cannot end with /'); + } + + const pattern = '/' + patternInput; + + const lastIndex = path.dirname(fileName).lastIndexOf(pattern); + + return lastIndex !== -1; + } + + if (name === 'idirname') { + const patternInput = expression[1]; + + if (patternInput.startsWith('/')) { + throw new Error('dirname cannot start with /'); + } + + if (patternInput.endsWith('/')) { + throw new Error('dirname cannot end with /'); + } + + const pattern = '/' + patternInput.toLowerCase(); + + const lastIndex = path.dirname(fileName.toLowerCase()).lastIndexOf(pattern); + + return lastIndex !== -1; + } + + if (name === 'match') { + const pattern = expression[1]; + const subject = + expression[2] === 'basename' ? path.basename(fileName) : fileName; + + return micromatch.isMatch(subject, pattern, { + dot: true, + }); + } + + if (name === 'imatch') { + const pattern = expression[1]; + const subject = + expression[2] === 'basename' ? path.basename(fileName) : fileName; + + return micromatch.isMatch(subject.toLowerCase(), pattern.toLowerCase(), { + dot: true, + }); + } + + if (name === 'not') { + const subExpression = expression[1]; + + return !testExpression(subExpression, fileName); + } + + throw new Error('Unknown expression'); +}; diff --git a/src/types.ts b/src/types.ts index b3693e9..8293b06 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,45 +1,9 @@ -// cspell:words idirname imatch iname ipcre pcre wholename oclock +// cspell:words idirname imatch iname wholename import { type ProcessOutput } from 'zx'; -export { type Client as WatchmanClient } from 'fb-watchman'; - -type RelationalOperator = - // Equal = - | 'eq' - // Greater or equal >= - | 'ge' - // Greater > - | 'gt' - // Lower or equal <= - | 'le' - // Lower < - | 'lt' - // Not equal != - | 'ne'; - -type FileType = - // an unknown file type - | '?' - // block special file - | 'b' - // character special file - | 'c' - // Solaris Door - | 'D' - // directory - | 'd' - // regular file - | 'f' - // symbolic link - | 'l' - // named pipe (fifo) - | 'p' - // socket - | 's'; - /* eslint-disable @typescript-eslint/sort-type-union-intersection-members */ -type Expression = +export type Expression = // Evaluates as true if all of the grouped expressions also evaluated as true. // https://facebook.github.io/watchman/docs/expr/allof.html | ['allof', ...Expression[]] @@ -49,37 +13,12 @@ type Expression = // Evaluates as true if a given file has a matching parent directory. // https://facebook.github.io/watchman/docs/expr/dirname.html | ['dirname' | 'idirname', string] - | ['dirname' | 'idirname', string, ['depth', RelationalOperator, number]] - // Evaluates as true if the file exists, has size 0 and is a regular file or directory. - // https://facebook.github.io/watchman/docs/expr/empty.html - | ['empty'] - // Evaluates as true if the file exists. - // https://facebook.github.io/watchman/docs/expr/exists.html - | ['exists'] // Evaluates as true if a glob matches against the basename of the file. // https://facebook.github.io/watchman/docs/expr/match.html - | ['match' | 'imatch', string | string[], 'basename' | 'wholename'] - // Evaluates as true if file matches the exact string. - // https://facebook.github.io/watchman/docs/expr/name.html - | ['name', string, 'basename' | 'wholename'] + | ['match' | 'imatch', string, 'basename' | 'wholename'] // Evaluates as true if the sub-expression evaluated as false, i.e. inverts the sub-expression. // https://facebook.github.io/watchman/docs/expr/not.html - | ['not', Expression] - // Evaluates as true if file matches a Perl Compatible Regular Expression. - // https://facebook.github.io/watchman/docs/expr/pcre.html - | ['pcre' | 'ipcre', string, 'basename' | 'wholename'] - // Evaluates as true if the specified time property of the file is greater than the since value. - // https://facebook.github.io/watchman/docs/expr/since.html - | ['since', string | number, 'mtime' | 'ctime', 'oclock'] - // Evaluates as true if the size of a (not deleted) file satisfies the condition. - // https://facebook.github.io/watchman/docs/expr/size.html - | ['size', RelationalOperator, number] - // Evaluates as true if the file suffix matches the second argument. - // https://facebook.github.io/watchman/docs/expr/suffix.html - | ['suffix', string | string[]] - // Evaluates as true if the type of the file matches that specified by the second argument. - // https://facebook.github.io/watchman/docs/expr/type.html - | ['type', FileType]; + | ['not', Expression]; /* eslint-enable @typescript-eslint/sort-type-union-intersection-members */ type JsonValue = @@ -96,14 +35,8 @@ export type JsonObject = { [k: string]: JsonValue; }; -/** - * @property mtime The timestamp indicating the last time this file was modified. - */ type File = { - exists: boolean; - mtime: number; name: string; - size: number; }; /** @@ -112,7 +45,6 @@ type File = { * @property first Identifies if this is the first event. * @property signal Instance of AbortSignal used to signal when the routine should be aborted. * @property spawn Instance of zx bound to AbortSignal. - * @property warning Watchman warnings. */ export type ChangeEvent = { abortSignal?: AbortSignal; @@ -124,7 +56,6 @@ export type ChangeEvent = { ...args: any[] ) => Promise; taskId: string; - warning: string | null; }; // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -169,7 +100,6 @@ export type Throttle = { * @property persistent Label a task as persistent if it is a long-running process, such as a dev server or --watch mode. */ type TriggerInput = { - debounce?: Debounce; expression: Expression; interruptible?: boolean; name: string; @@ -182,17 +112,14 @@ type TriggerInput = { export type Trigger = { abortSignal?: AbortSignal; - debounce?: Debounce; expression: Expression; id: string; interruptible: boolean; name: string; onChange: OnChangeEventHandler; onTeardown?: OnTeardownEventHandler; - relativePath: string; retry: Retry; throttleOutput: Throttle; - watch: string; }; /** @@ -200,6 +127,7 @@ export type Trigger = { */ export type ConfigurationInput = { readonly abortSignal?: AbortSignal; + readonly debounce?: Debounce; readonly project: string; readonly triggers: readonly TriggerInput[]; }; @@ -209,3 +137,29 @@ export type Configuration = { readonly project: string; readonly triggers: readonly TriggerInput[]; }; + +export type ChokidarEvent = { + event: 'add' | 'addDir' | 'change' | 'unlink' | 'unlinkDir'; + path: string; +}; + +export type SubscriptionEvent = { + files: Array<{ name: string }>; +}; + +/** + * @property queued Indicates that a follow action has been queued. + */ +export type ActiveTask = { + abortController: AbortController | null; + id: string; + promise: Promise; + queued: boolean; +}; + +export type Subscription = { + activeTask: ActiveTask | null; + expression: Expression; + teardown: () => Promise; + trigger: (events: readonly ChokidarEvent[]) => Promise; +}; diff --git a/src/watch.ts b/src/watch.ts index 9f5d92d..ce04bf9 100644 --- a/src/watch.ts +++ b/src/watch.ts @@ -1,13 +1,17 @@ import { generateShortId } from './generateShortId'; import { Logger } from './Logger'; import { subscribe } from './subscribe'; +import { testExpression } from './testExpression'; import { + type ChokidarEvent, type Configuration, type ConfigurationInput, type JsonObject, + type Subscription, } from './types'; -import { Client } from 'fb-watchman'; +import * as chokidar from 'chokidar'; import { serializeError } from 'serialize-error'; +import { debounce } from 'throttle-debounce'; const log = Logger.child({ namespace: 'watch', @@ -38,14 +42,29 @@ export const watch = (configurationInput: ConfigurationInput) => { abortSignal = abortController.signal; } - const client = new Client(); - return new Promise((resolve, reject) => { + const subscriptions: Subscription[] = []; + + const watcher = chokidar.watch(project); + + const close = async () => { + for (const subscription of subscriptions) { + const { activeTask } = subscription; + + if (activeTask?.promise) { + await activeTask?.promise; + } + } + + // eslint-disable-next-line promise/prefer-await-to-then + await watcher.close().then(resolve).catch(reject); + }; + if (abortSignal) { abortSignal.addEventListener( 'abort', () => { - client.end(); + close(); }, { once: true, @@ -53,61 +72,68 @@ export const watch = (configurationInput: ConfigurationInput) => { ); } - client.command(['watch-project', project], (error, response) => { - if (error) { - log.error( - { - error: serializeError(error) as unknown as JsonObject, + watcher.on('error', (error) => { + log.error( + { + error: serializeError(error) as unknown as JsonObject, + }, + 'could not watch project', + ); + + close(); + }); + + for (const trigger of triggers) { + subscriptions.push( + subscribe({ + abortSignal, + expression: trigger.expression, + id: generateShortId(), + interruptible: trigger.interruptible ?? true, + name: trigger.name, + onChange: trigger.onChange, + onTeardown: trigger.onTeardown, + retry: trigger.retry ?? { + factor: 2, + maxTimeout: Number.POSITIVE_INFINITY, + minTimeout: 1_000, + retries: 10, }, - 'could not watch project', - ); + throttleOutput: trigger.throttleOutput ?? { delay: 1_000 }, + }), + ); + } - reject(error); + let queuedChokidarEvents: ChokidarEvent[] = []; - client.end(); + const evaluateSubscribers = debounce(100, () => { + const currentChokidarEvents = + queuedChokidarEvents as readonly ChokidarEvent[]; - return; - } + queuedChokidarEvents = []; - if ('warning' in response) { - // eslint-disable-next-line no-console - console.warn(response.warning); + for (const subscription of subscriptions) { + const relevantEvents = currentChokidarEvents.filter((chokidarEvent) => { + return testExpression(subscription.expression, chokidarEvent.path); + }); + + if (relevantEvents.length) { + subscription.trigger(relevantEvents); + } } + }); - log.info( - 'watch established on %s relative_path %s', - response.watch, - response.relative_path, - ); + watcher.on('ready', () => { + log.info('Initial scan complete. Ready for changes'); - const subscriptions: Array> = []; - - for (const trigger of triggers) { - subscriptions.push( - subscribe(client, { - abortSignal, - debounce: trigger.debounce, - expression: trigger.expression, - id: generateShortId(), - interruptible: trigger.interruptible ?? true, - name: trigger.name, - onChange: trigger.onChange, - onTeardown: trigger.onTeardown, - relativePath: response.relative_path, - retry: trigger.retry ?? { - factor: 2, - maxTimeout: Number.POSITIVE_INFINITY, - minTimeout: 1_000, - retries: 10, - }, - throttleOutput: trigger.throttleOutput ?? { delay: 1_000 }, - watch: response.watch, - }), - ); - } + watcher.on('all', (event, path) => { + queuedChokidarEvents.push({ + event, + path, + }); - // eslint-disable-next-line promise/prefer-await-to-then - Promise.all(subscriptions).then(resolve).catch(reject); + evaluateSubscribers(); + }); }); }); };