├── .editorconfig ├── .github └── workflows │ ├── checks.yml │ ├── labels.yml │ ├── release.yml │ └── stale.yml ├── .gitignore ├── .npmrc ├── .prettierignore ├── LICENSE.md ├── README.md ├── bin └── test.ts ├── eslint.config.js ├── index.ts ├── package.json ├── src ├── config_parser.ts ├── debug.ts ├── source_files_manager.ts ├── types.ts └── watcher.ts ├── tests ├── config_parser.spec.ts └── source_files_manager.spec.ts └── tsconfig.json /.editorconfig: -------------------------------------------------------------------------------- 1 | # http://editorconfig.org 2 | 3 | [*] 4 | indent_style = space 5 | indent_size = 2 6 | end_of_line = lf 7 | charset = utf-8 8 | trim_trailing_whitespace = true 9 | insert_final_newline = true 10 | 11 | [*.json] 12 | insert_final_newline = ignore 13 | 14 | [**.min.js] 15 | indent_style = ignore 16 | insert_final_newline = ignore 17 | 18 | [MakeFile] 19 | indent_style = space 20 | 21 | [*.md] 22 | trim_trailing_whitespace = false 23 | -------------------------------------------------------------------------------- /.github/workflows/checks.yml: -------------------------------------------------------------------------------- 1 | name: checks 2 | on: 3 | - push 4 | - pull_request 5 | - workflow_call 6 | 7 | jobs: 8 | test: 9 | uses: poppinss/.github/.github/workflows/test.yml@next 10 | 11 | lint: 12 | uses: poppinss/.github/.github/workflows/lint.yml@next 13 | 14 | typecheck: 15 | uses: poppinss/.github/.github/workflows/typecheck.yml@next 16 | -------------------------------------------------------------------------------- /.github/workflows/labels.yml: -------------------------------------------------------------------------------- 1 | name: Sync labels 2 | on: 3 | workflow_dispatch: 4 | permissions: 5 | issues: write 6 | jobs: 7 | labels: 8 | runs-on: ubuntu-latest 9 | steps: 10 | - uses: actions/checkout@v4 11 | - uses: EndBug/label-sync@v2 12 | with: 13 | config-file: 'https://raw.githubusercontent.com/thetutlage/static/main/labels.yml' 14 | delete-other-labels: true 15 | token: ${{ secrets.GITHUB_TOKEN }} 16 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: release 2 | on: workflow_dispatch 3 | permissions: 4 | contents: write 5 | id-token: write 6 | jobs: 7 | checks: 8 | uses: ./.github/workflows/checks.yml 9 | release: 10 | needs: checks 11 | runs-on: ubuntu-latest 12 | steps: 13 | - uses: actions/checkout@v4 14 | with: 15 | fetch-depth: 0 16 | 17 | - uses: actions/setup-node@v4 18 | with: 19 | node-version: 20 20 | 21 | - name: git config 22 | run: | 23 | git config user.name "${GITHUB_ACTOR}" 24 | git config user.email "${GITHUB_ACTOR}@users.noreply.github.com" 25 | 26 | - name: Init npm config 27 | run: npm config set //registry.npmjs.org/:_authToken $NPM_TOKEN 28 | env: 29 | NPM_TOKEN: ${{ secrets.NPM_TOKEN }} 30 | 31 | - run: npm install 32 | 33 | - run: npm run release -- --ci 34 | env: 35 | NPM_TOKEN: ${{ secrets.NPM_TOKEN }} 36 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 37 | NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} 38 | -------------------------------------------------------------------------------- /.github/workflows/stale.yml: -------------------------------------------------------------------------------- 1 | name: 'Close stale issues and PRs' 2 | on: 3 | schedule: 4 | - cron: '30 0 * * *' 5 | 6 | jobs: 7 | stale: 8 | runs-on: ubuntu-latest 9 | steps: 10 | - uses: actions/stale@v9 11 | with: 12 | stale-issue-message: 'This issue has been marked as stale because it has been inactive for more than 21 days. Please reopen if you still need help on this issue' 13 | stale-pr-message: 'This pull request has been marked as stale because it has been inactive for more than 21 days. Please reopen if you still intend to submit this pull request' 14 | close-issue-message: 'This issue has been automatically closed because it has been inactive for more than 4 weeks. Please reopen if you still need help on this issue' 15 | close-pr-message: 'This pull request has been automatically closed because it has been inactive for more than 4 weeks. Please reopen if you still intend to submit this pull request' 16 | days-before-stale: 21 17 | days-before-close: 5 18 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | coverage 3 | .DS_STORE 4 | .nyc_output 5 | .idea 6 | .vscode/ 7 | *.sublime-project 8 | *.sublime-workspace 9 | *.log 10 | build 11 | dist 12 | yarn.lock 13 | shrinkwrap.yaml 14 | test/__app 15 | package-lock.json 16 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | package-lock=false 2 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | build 2 | docs 3 | config.json 4 | .eslintrc.json 5 | package.json 6 | *.html 7 | *.md 8 | *.txt 9 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | # The MIT License 2 | 3 | Copyright (c) 2023 Poppinss 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the 'Software'), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 6 | 7 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 8 | 9 | THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 10 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Chokidar TS 2 | > A thin wrapper on top of [chokidar](https://github.com/paulmillr/chokidar) file watcher that relies on the `tsconfig.json` file to distinguish between the TypeScript source files and other files. 3 | 4 | [![gh-workflow-image]][gh-workflow-url] [![typescript-image]][typescript-url] [![npm-image]][npm-url] [![license-image]][license-url] 5 | 6 | ## Why does this package exists? 7 | When running a Node.js backend development server with a file watcher, we need to know whether a newly added or changed file is part of our TypeScript project. 8 | 9 | The best way to establish if a file is part of a TypeScript project is to rely on the `tsconfig.json` file. 10 | 11 | This is precisely what this package does. It will create a file watcher using chokidar and then uses the `includes` and `excludes` patterns from the `tsconfig.json` file to know if a changed file is part of a TypeScript project. 12 | 13 | ## Setup 14 | Install the package from the npm packages registry. In addition, the package has a peer dependency on the `typescript` package, so make sure to install that as well. 15 | 16 | ```sh 17 | npm i @poppinss/chokidar-ts 18 | ``` 19 | 20 | ```sh 21 | yarn add @poppinss/chokidar-ts 22 | ``` 23 | 24 | ```sh 25 | pnpm add @poppinss/chokidar-ts 26 | ``` 27 | 28 | And use it as follows. 29 | 30 | ```ts 31 | import typescript from 'typescript' 32 | import { ConfigParser, Watcher } from '@poppinss/chokidar-ts' 33 | 34 | const projectRoot = new URL('./', import.meta.url) 35 | const configFileName = 'tsconfig.json' 36 | 37 | const { config } = new ConfigParser( 38 | projectRoot, 39 | configFileName, 40 | typescript, 41 | ).parse() 42 | 43 | if (config) { 44 | const watcher = new Watcher(projectRoot, config) 45 | watcher.watch(['.']) 46 | } 47 | ``` 48 | 49 | ## Listening for events 50 | The `Watcher` class emits the following events. Events prefixed with `source` refers to files included by the `tsconfig.json` file, and other events refer to non-typescript or files excluded by the `tsconfig.json` file. 51 | 52 | - `add`: A new file has been added. The file is either not a TypeScript file or is excluded by the `tsconfig.json` file. 53 | - `source:add`: A new TypeScript source file has been added. 54 | - `change`: An existing file has been updated. The file is either not a TypeScript file or is excluded by the `tsconfig.json` file. 55 | - `source:change`: An existing TypeScript source file has been changed. 56 | - `unlink`: An existing file has been deleted. The file is not a TypeScript source file. 57 | - `source:unlink`: An existing TypeScript source file has been deleted. 58 | 59 | ```ts 60 | const watcher = new Watcher(projectRoot, config) 61 | 62 | watcher.on('add', (file) => { 63 | console.log(file.absPath) 64 | console.log(file.relativePath) 65 | }) 66 | 67 | watcher.on('source:add', (file) => { 68 | console.log(file.absPath) 69 | console.log(file.relativePath) 70 | }) 71 | 72 | watcher.on('change', (file) => { 73 | console.log(file.absPath) 74 | console.log(file.relativePath) 75 | }) 76 | 77 | watcher.on('source:change', (file) => { 78 | console.log(file.absPath) 79 | console.log(file.relativePath) 80 | }) 81 | 82 | watcher.on('unlink', (file) => { 83 | console.log(file.absPath) 84 | console.log(file.relativePath) 85 | }) 86 | 87 | watcher.on('source:unlink', (file) => { 88 | console.log(file.absPath) 89 | console.log(file.relativePath) 90 | }) 91 | 92 | watcher.watch(['.']) 93 | ``` 94 | 95 | ## Handling config parser errors 96 | Parsing the `tsconfig.json` file can produce errors, and you can display them using the TypeScript compiler as follows. 97 | 98 | ```ts 99 | import typescript from 'typescript' 100 | const { error, config } = new ConfigParser( 101 | projectRoot, 102 | configFileName, 103 | typescript, 104 | ).parse() 105 | 106 | if (error) { 107 | const compilerHost = typescript.createCompilerHost({}) 108 | console.log( 109 | typescript.formatDiagnosticsWithColorAndContext([error], compilerHost) 110 | ) 111 | 112 | return 113 | } 114 | 115 | if (!config) { 116 | return 117 | } 118 | 119 | if (config.errors) { 120 | const compilerHost = typescript.createCompilerHost({}) 121 | console.log( 122 | typescript.formatDiagnosticsWithColorAndContext(config.errors, compilerHost) 123 | ) 124 | 125 | return 126 | } 127 | ``` 128 | 129 | [gh-workflow-image]: https://img.shields.io/github/actions/workflow/status/poppinss/chokidar-ts/checks.yml?style=for-the-badge 130 | [gh-workflow-url]: https://github.com/poppinss/chokidar-ts/actions/workflows/checks.yml "Github action" 131 | 132 | [typescript-image]: https://img.shields.io/badge/Typescript-294E80.svg?style=for-the-badge&logo=typescript 133 | [typescript-url]: "typescript" 134 | 135 | [npm-image]: https://img.shields.io/npm/v/@poppinss/chokidar-ts.svg?style=for-the-badge&logo=npm 136 | [npm-url]: https://npmjs.org/package/@poppinss/chokidar-ts 'npm' 137 | 138 | [license-image]: https://img.shields.io/npm/l/@poppinss/chokidar-ts?color=blueviolet&style=for-the-badge 139 | [license-url]: LICENSE.md 'license' 140 | -------------------------------------------------------------------------------- /bin/test.ts: -------------------------------------------------------------------------------- 1 | import { assert } from '@japa/assert' 2 | import { fileSystem } from '@japa/file-system' 3 | import { processCLIArgs, configure, run } from '@japa/runner' 4 | 5 | /* 6 | |-------------------------------------------------------------------------- 7 | | Configure tests 8 | |-------------------------------------------------------------------------- 9 | | 10 | | The configure method accepts the configuration to configure the Japa 11 | | tests runner. 12 | | 13 | | The first method call "processCliArgs" process the command line arguments 14 | | and turns them into a config object. Using this method is not mandatory. 15 | | 16 | | Please consult japa.dev/runner-config for the config docs. 17 | */ 18 | processCLIArgs(process.argv.slice(2)) 19 | configure({ 20 | files: ['tests/**/*.spec.ts'], 21 | plugins: [assert(), fileSystem()], 22 | }) 23 | 24 | /* 25 | |-------------------------------------------------------------------------- 26 | | Run tests 27 | |-------------------------------------------------------------------------- 28 | | 29 | | The following "run" method is required to execute all the tests. 30 | | 31 | */ 32 | run() 33 | -------------------------------------------------------------------------------- /eslint.config.js: -------------------------------------------------------------------------------- 1 | import { configPkg } from '@adonisjs/eslint-config' 2 | export default configPkg({ 3 | ignores: ['coverage'], 4 | }) 5 | -------------------------------------------------------------------------------- /index.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * @poppinss/chokidar-ts 3 | * 4 | * (c) Poppinss 5 | * 6 | * For the full copyright and license information, please view the LICENSE 7 | * file that was distributed with this source code. 8 | */ 9 | 10 | export { Watcher } from './src/watcher.js' 11 | export { ConfigParser } from './src/config_parser.js' 12 | export { SourceFilesManager } from './src/source_files_manager.js' 13 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@poppinss/chokidar-ts", 3 | "version": "4.1.9", 4 | "description": "File watcher for TypeScript projects", 5 | "main": "build/index.js", 6 | "type": "module", 7 | "files": [ 8 | "build", 9 | "!build/bin", 10 | "!build/tests" 11 | ], 12 | "exports": { 13 | ".": "./build/index.js", 14 | "./types": "./build/src/types.js" 15 | }, 16 | "engines": { 17 | "node": ">=18.16.0" 18 | }, 19 | "scripts": { 20 | "pretest": "npm run lint", 21 | "test": "cross-env NODE_DEBUG=chokidar:ts c8 npm run quick:test", 22 | "lint": "eslint .", 23 | "format": "prettier --write .", 24 | "clean": "del-cli build", 25 | "typecheck": "tsc --noEmit", 26 | "precompile": "npm run lint && npm run clean", 27 | "compile": "tsup-node && tsc --emitDeclarationOnly --declaration", 28 | "build": "npm run compile", 29 | "version": "npm run build", 30 | "prepublishOnly": "npm run build", 31 | "release": "release-it", 32 | "quick:test": "node --import=@poppinss/ts-exec --enable-source-maps bin/test.ts" 33 | }, 34 | "devDependencies": { 35 | "@adonisjs/eslint-config": "^2.1.0", 36 | "@adonisjs/prettier-config": "^1.4.5", 37 | "@adonisjs/tsconfig": "^1.4.0", 38 | "@japa/assert": "^4.0.1", 39 | "@japa/file-system": "^2.3.2", 40 | "@japa/runner": "^4.2.0", 41 | "@poppinss/ts-exec": "^1.2.0", 42 | "@release-it/conventional-changelog": "^10.0.1", 43 | "@types/node": "^22.15.21", 44 | "@types/picomatch": "^4.0.0", 45 | "c8": "^10.1.3", 46 | "cross-env": "^7.0.3", 47 | "del-cli": "^6.0.0", 48 | "eslint": "^9.27.0", 49 | "prettier": "^3.5.3", 50 | "release-it": "^19.0.2", 51 | "tsup": "^8.5.0", 52 | "typescript": "^5.8.3" 53 | }, 54 | "dependencies": { 55 | "chokidar": "^4.0.3", 56 | "emittery": "^1.1.0", 57 | "memoize": "^10.1.0", 58 | "picomatch": "^4.0.2", 59 | "slash": "^5.1.0" 60 | }, 61 | "peerDependencies": { 62 | "typescript": "^5.0.0" 63 | }, 64 | "homepage": "https://github.com/poppinss/chokidar-ts#readme", 65 | "repository": { 66 | "type": "git", 67 | "url": "git+https://github.com/poppinss/chokidar-ts.git" 68 | }, 69 | "bugs": { 70 | "url": "https://github.com/poppinss/chokidar-ts/issues" 71 | }, 72 | "keywords": [ 73 | "typescript", 74 | "tsc", 75 | "tsc-watch", 76 | "chokidar" 77 | ], 78 | "author": "Harminder Virk ", 79 | "license": "MIT", 80 | "publishConfig": { 81 | "access": "public", 82 | "provenance": true 83 | }, 84 | "tsup": { 85 | "entry": [ 86 | "./index.ts", 87 | "./src/types.ts" 88 | ], 89 | "outDir": "./build", 90 | "clean": true, 91 | "format": "esm", 92 | "dts": false, 93 | "sourcemap": false, 94 | "target": "esnext" 95 | }, 96 | "release-it": { 97 | "git": { 98 | "requireCleanWorkingDir": true, 99 | "requireUpstream": true, 100 | "commitMessage": "chore(release): ${version}", 101 | "tagAnnotation": "v${version}", 102 | "push": true, 103 | "tagName": "v${version}" 104 | }, 105 | "github": { 106 | "release": true 107 | }, 108 | "npm": { 109 | "publish": true, 110 | "skipChecks": true 111 | }, 112 | "plugins": { 113 | "@release-it/conventional-changelog": { 114 | "preset": { 115 | "name": "angular" 116 | } 117 | } 118 | } 119 | }, 120 | "c8": { 121 | "reporter": [ 122 | "text", 123 | "html" 124 | ], 125 | "exclude": [ 126 | "tests/**" 127 | ] 128 | }, 129 | "prettier": "@adonisjs/prettier-config" 130 | } 131 | -------------------------------------------------------------------------------- /src/config_parser.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * @poppinss/chokidar-ts 3 | * 4 | * (c) Poppinss 5 | * 6 | * For the full copyright and license information, please view the LICENSE 7 | * file that was distributed with this source code. 8 | */ 9 | 10 | import { join } from 'node:path' 11 | import type tsStatic from 'typescript' 12 | import { fileURLToPath } from 'node:url' 13 | 14 | import debug from './debug.js' 15 | 16 | /** 17 | * Exposes the API to parse typescript config file using the 18 | * TypeScript's official compiler. 19 | */ 20 | export class ConfigParser { 21 | #cwd: string 22 | #configFileName: string 23 | #ts: typeof tsStatic 24 | 25 | constructor(cwd: string | URL, configFileName: string, ts: typeof tsStatic) { 26 | this.#cwd = typeof cwd === 'string' ? cwd : fileURLToPath(cwd) 27 | this.#configFileName = configFileName 28 | this.#ts = ts 29 | } 30 | 31 | /** 32 | * Parse file. The errors the return back inside the `error` property 33 | */ 34 | parse(optionsToExtend?: tsStatic.CompilerOptions): { 35 | error: tsStatic.Diagnostic | null 36 | config?: tsStatic.ParsedCommandLine 37 | } { 38 | let hardException: null | tsStatic.Diagnostic = null 39 | debug('parsing config file "%s"', this.#configFileName) 40 | 41 | const parsedConfig = this.#ts.getParsedCommandLineOfConfigFile( 42 | join(this.#cwd, this.#configFileName), 43 | optionsToExtend || {}, 44 | { 45 | ...this.#ts.sys, 46 | useCaseSensitiveFileNames: true, 47 | getCurrentDirectory: () => this.#cwd, 48 | onUnRecoverableConfigFileDiagnostic: (error) => (hardException = error), 49 | } 50 | ) 51 | 52 | return { config: parsedConfig, error: hardException } 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /src/debug.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * @poppinss/chokidar-ts 3 | * 4 | * (c) Poppinss 5 | * 6 | * For the full copyright and license information, please view the LICENSE 7 | * file that was distributed with this source code. 8 | */ 9 | 10 | import { debuglog } from 'node:util' 11 | 12 | export default debuglog('chokidar:ts') 13 | -------------------------------------------------------------------------------- /src/source_files_manager.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * @poppinss/chokidar-ts 3 | * 4 | * (c) Poppinss 5 | * 6 | * For the full copyright and license information, please view the LICENSE 7 | * file that was distributed with this source code. 8 | */ 9 | 10 | import slash from 'slash' 11 | import memoize from 'memoize' 12 | import { join } from 'node:path' 13 | import picomatch from 'picomatch' 14 | 15 | import debug from './debug.js' 16 | import type { SourceFilesManagerOptions } from './types.js' 17 | 18 | /** 19 | * Exposes the API to manage the source files for a typescript project. 20 | * All paths are stored as unix paths 21 | */ 22 | export class SourceFilesManager { 23 | #appRoot: string 24 | #included: picomatch.Matcher 25 | #excluded: picomatch.Matcher 26 | 27 | /** 28 | * A collection of project files collected as part of the first scan. 29 | */ 30 | #projectFiles: Record = {} 31 | 32 | /** 33 | * A memoized function to match the file path against included and excluded 34 | * picomatch patterns 35 | */ 36 | #matchAgainstPattern = memoize((filePath: string) => { 37 | if (!this.#included(filePath)) { 38 | debug('file rejected by includes %s', filePath) 39 | return false 40 | } 41 | 42 | if (this.#excluded(filePath)) { 43 | debug('file rejected by excludes %s', filePath) 44 | return false 45 | } 46 | 47 | return true 48 | }) 49 | 50 | constructor(appRoot: string, options: SourceFilesManagerOptions) { 51 | this.#appRoot = slash(appRoot).replace(/\/$/, '') 52 | 53 | options.files.forEach((file) => this.add(file)) 54 | 55 | this.#included = picomatch( 56 | (options.includes || []).map((pattern) => { 57 | return slash(join(this.#appRoot, pattern)) 58 | }) 59 | ) 60 | 61 | this.#excluded = picomatch( 62 | (options.excludes || []).map((pattern) => { 63 | return slash(join(this.#appRoot, pattern)) 64 | }) 65 | ) 66 | } 67 | 68 | /** 69 | * Track a new source file 70 | */ 71 | add(filePath: string): void { 72 | filePath = slash(filePath) 73 | this.#projectFiles[filePath] = true 74 | debug('adding new source file "%s"', filePath) 75 | } 76 | 77 | /** 78 | * Remove file from the list of existing source files 79 | */ 80 | remove(filePath: string) { 81 | filePath = slash(filePath) 82 | debug('removing source file "%s"', filePath) 83 | delete this.#projectFiles[filePath] 84 | } 85 | 86 | /** 87 | * Returns true when filePath is part of the source files after checking 88 | * them against `includes`, `excludes` and custom set of `files`. 89 | */ 90 | isSourceFile(filePath: string): boolean { 91 | debug('matching for watched file %s', filePath) 92 | filePath = slash(filePath) 93 | 94 | return !!this.#projectFiles[filePath] || this.#matchAgainstPattern(filePath) 95 | } 96 | 97 | /** 98 | * Returns true if the file should be watched 99 | */ 100 | shouldWatch(filePath: string) { 101 | /** 102 | * Always watch the project root 103 | */ 104 | if (filePath === this.#appRoot) { 105 | return true 106 | } 107 | 108 | return this.isSourceFile(filePath) 109 | } 110 | 111 | /** 112 | * Returns a copy of project source files 113 | */ 114 | toJSON() { 115 | return this.#projectFiles 116 | } 117 | } 118 | -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * @poppinss/chokidar-ts 3 | * 4 | * (c) Poppinss 5 | * 6 | * For the full copyright and license information, please view the LICENSE 7 | * file that was distributed with this source code. 8 | */ 9 | 10 | /** 11 | * Options accepted by source files manager 12 | */ 13 | export type SourceFilesManagerOptions = { 14 | includes?: string[] 15 | excludes?: string[] 16 | files: string[] 17 | } 18 | 19 | /** 20 | * Events emitted by the watcher 21 | */ 22 | export type WatcherEvents = { 23 | 'add': { absPath: string; relativePath: string } 24 | 'source:add': { absPath: string; relativePath: string } 25 | 'change': { absPath: string; relativePath: string } 26 | 'source:change': { absPath: string; relativePath: string } 27 | 'unlink': { absPath: string; relativePath: string } 28 | 'source:unlink': { absPath: string; relativePath: string } 29 | } 30 | -------------------------------------------------------------------------------- /src/watcher.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * @poppinss/chokidar-ts 3 | * 4 | * (c) Poppinss 5 | * 6 | * For the full copyright and license information, please view the LICENSE 7 | * file that was distributed with this source code. 8 | */ 9 | 10 | import slash from 'slash' 11 | import Emittery from 'emittery' 12 | import { join } from 'node:path' 13 | import tsStatic from 'typescript' 14 | import chokidar, { type ChokidarOptions } from 'chokidar' 15 | 16 | import debug from './debug.js' 17 | import type { WatcherEvents } from './types.js' 18 | import { SourceFilesManager } from './source_files_manager.js' 19 | 20 | const DEFAULT_INCLUDES = ['**/*'] 21 | const ALWAYS_EXCLUDE = ['.git/**', 'coverage/**', '.github/**'] 22 | const DEFAULT_EXCLUDES = ['node_modules/**', 'bower_components/**', 'jspm_packages/**'] 23 | 24 | /** 25 | * Exposes the API to build the typescript project and then watch it 26 | * for changes. 27 | */ 28 | export class Watcher extends Emittery { 29 | #cwd: string 30 | #config: tsStatic.ParsedCommandLine 31 | #sourceFilesManager: SourceFilesManager 32 | 33 | constructor(cwd: string, config: tsStatic.ParsedCommandLine) { 34 | const outDir = config.raw.compilerOptions?.outDir 35 | const includes = config.raw.include || DEFAULT_INCLUDES 36 | const excludes = ALWAYS_EXCLUDE.concat( 37 | config.raw.exclude || (outDir ? DEFAULT_EXCLUDES.concat(outDir) : DEFAULT_EXCLUDES) 38 | ) 39 | 40 | debug('initiating watcher %O', { 41 | includes: includes, 42 | excludes: excludes, 43 | outDir, 44 | files: config.fileNames, 45 | }) 46 | 47 | super() 48 | this.#cwd = cwd 49 | this.#config = config 50 | this.#sourceFilesManager = new SourceFilesManager(this.#cwd, { 51 | includes, 52 | excludes, 53 | files: config.fileNames, 54 | }) 55 | } 56 | 57 | /** 58 | * Returns a boolean telling if it is a script file or not. 59 | * 60 | * We check for the `compilerOptions.allowJs` before marking 61 | * `.js` files as a script files. 62 | */ 63 | #isScriptFile(filePath: string): boolean { 64 | if (filePath.endsWith('.ts') || filePath.endsWith('.tsx')) { 65 | return true 66 | } 67 | 68 | if (this.#config.options.allowJs && filePath.endsWith('.js')) { 69 | return true 70 | } 71 | 72 | return false 73 | } 74 | 75 | /** 76 | * Initiates chokidar watcher 77 | */ 78 | #initiateWatcher(watchPattern: string | string[] = ['.'], watcherOptions?: ChokidarOptions) { 79 | watcherOptions = Object.assign( 80 | { 81 | ignored: (filePath: string) => { 82 | return !this.#sourceFilesManager.shouldWatch(filePath) 83 | }, 84 | cwd: this.#cwd, 85 | ignoreInitial: true, 86 | }, 87 | watcherOptions 88 | ) 89 | 90 | debug('initating watcher with %j options', watcherOptions) 91 | return chokidar.watch(watchPattern, watcherOptions) 92 | } 93 | 94 | /** 95 | * Invoked when chokidar notifies for a new file addtion 96 | */ 97 | #onNewFile(filePath: string) { 98 | const absPath = join(this.#cwd, filePath) 99 | 100 | if (!this.#isScriptFile(filePath) || !this.#sourceFilesManager.isSourceFile(absPath)) { 101 | debug('new file added "%s"', filePath) 102 | this.emit('add', { relativePath: slash(filePath), absPath }) 103 | return 104 | } 105 | 106 | debug('new source file added "%s"', filePath) 107 | this.#sourceFilesManager.add(absPath) 108 | this.emit('source:add', { relativePath: slash(filePath), absPath }) 109 | } 110 | 111 | /** 112 | * Invoked when chokidar notifies for changes the existing 113 | * source file 114 | */ 115 | #onChange(filePath: string) { 116 | const absPath = join(this.#cwd, filePath) 117 | 118 | if (!this.#isScriptFile(filePath) || !this.#sourceFilesManager.isSourceFile(absPath)) { 119 | debug('file changed "%s"', filePath) 120 | this.emit('change', { relativePath: slash(filePath), absPath }) 121 | return 122 | } 123 | 124 | debug('source file changed "%s"', filePath) 125 | this.emit('source:change', { relativePath: slash(filePath), absPath }) 126 | } 127 | 128 | /** 129 | * Invoked when chokidar notifies for file deletion 130 | */ 131 | #onRemove(filePath: string) { 132 | const absPath = join(this.#cwd, filePath) 133 | 134 | if (!this.#isScriptFile(filePath) || !this.#sourceFilesManager.isSourceFile(absPath)) { 135 | debug('file removed "%s"', filePath) 136 | this.emit('unlink', { relativePath: slash(filePath), absPath }) 137 | return 138 | } 139 | 140 | debug('source file removed "%s"', filePath) 141 | 142 | /** 143 | * Clean up tracking for a given file 144 | */ 145 | this.#sourceFilesManager.remove(absPath) 146 | 147 | /** 148 | * Notify subscribers 149 | */ 150 | this.emit('source:unlink', { relativePath: slash(filePath), absPath }) 151 | } 152 | 153 | /** 154 | * Build and watch project for changes 155 | */ 156 | watch(watchPattern: string | string[] = ['.'], watcherOptions?: ChokidarOptions) { 157 | const watcher = this.#initiateWatcher(watchPattern, watcherOptions) 158 | 159 | watcher.on('ready', () => { 160 | debug('watcher ready') 161 | this.emit('watcher:ready') 162 | }) 163 | 164 | watcher.on('add', (path: string) => this.#onNewFile(path)) 165 | watcher.on('change', (path: string) => this.#onChange(path)) 166 | watcher.on('unlink', (path: string) => this.#onRemove(path)) 167 | 168 | return watcher 169 | } 170 | } 171 | -------------------------------------------------------------------------------- /tests/config_parser.spec.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * @poppinss/chokidar-ts 3 | * 4 | * (c) Poppinss 5 | * 6 | * For the full copyright and license information, please view the LICENSE 7 | * file that was distributed with this source code. 8 | */ 9 | 10 | import slash from 'slash' 11 | import ts from 'typescript' 12 | import { join } from 'node:path' 13 | import { test } from '@japa/runner' 14 | 15 | import { ConfigParser } from '../src/config_parser.js' 16 | 17 | test.group('Config Parser', () => { 18 | test('raise error when config file is missing', ({ fs, assert }) => { 19 | const configParser = new ConfigParser(fs.basePath, 'tsconfig.json', ts) 20 | const { error, config } = configParser.parse() 21 | 22 | assert.isUndefined(config) 23 | assert.equal( 24 | error!.messageText as string, 25 | `Cannot read file '${join(fs.basePath, 'tsconfig.json')}'.` 26 | ) 27 | }) 28 | 29 | test('raise error when config file has unknown options', async ({ fs, assert }) => { 30 | await fs.create( 31 | 'tsconfig.json', 32 | JSON.stringify({ 33 | compilerOptions: { 34 | foo: true, 35 | }, 36 | }) 37 | ) 38 | 39 | const configParser = new ConfigParser(fs.basePath, 'tsconfig.json', ts) 40 | const { error, config } = configParser.parse() 41 | 42 | assert.isNull(error) 43 | assert.lengthOf(config!.errors, 2) 44 | assert.equal(config!.errors[0].messageText, "Unknown compiler option 'foo'.") 45 | }) 46 | 47 | test('parse config file and populate include files', async ({ fs, assert }) => { 48 | await fs.create( 49 | 'tsconfig.json', 50 | JSON.stringify({ 51 | include: ['./**/*'], 52 | }) 53 | ) 54 | 55 | await fs.create('bar/foo.ts', '') 56 | 57 | const configParser = new ConfigParser(fs.basePath, 'tsconfig.json', ts) 58 | const { error, config } = configParser.parse() 59 | 60 | assert.isNull(error) 61 | assert.lengthOf(config!.errors, 0) 62 | assert.deepEqual(config!.fileNames, [slash(join(fs.basePath, 'bar/foo.ts'))]) 63 | }) 64 | 65 | test('parse config file and respect exclude pattern', async ({ fs, assert }) => { 66 | await fs.create( 67 | 'tsconfig.json', 68 | JSON.stringify({ 69 | include: ['./**/*'], 70 | exclude: ['./bar/foo.ts'], 71 | }) 72 | ) 73 | 74 | await fs.create('bar/foo.ts', '') 75 | 76 | const configParser = new ConfigParser(fs.basePath, 'tsconfig.json', ts) 77 | const { error, config } = configParser.parse() 78 | 79 | assert.isNull(error) 80 | assert.lengthOf(config!.errors, 1) 81 | assert.deepEqual(config!.fileNames, []) 82 | }) 83 | 84 | test('parse config file and respect explicit files array', async ({ fs, assert }) => { 85 | await fs.create( 86 | 'tsconfig.json', 87 | JSON.stringify({ 88 | include: ['./**/*'], 89 | exclude: ['./bar/foo.ts'], 90 | files: ['./bar/foo.ts'], 91 | }) 92 | ) 93 | 94 | await fs.create('bar/foo.ts', '') 95 | 96 | const configParser = new ConfigParser(fs.basePath, 'tsconfig.json', ts) 97 | const { error, config } = configParser.parse() 98 | 99 | assert.isNull(error) 100 | assert.lengthOf(config!.errors, 0) 101 | assert.deepEqual(config!.fileNames, [slash(join(fs.basePath, 'bar/foo.ts'))]) 102 | }) 103 | }) 104 | -------------------------------------------------------------------------------- /tests/source_files_manager.spec.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * @poppinss/chokidar-ts 3 | * 4 | * (c) Poppinss 5 | * 6 | * For the full copyright and license information, please view the LICENSE 7 | * file that was distributed with this source code. 8 | */ 9 | 10 | import slash from 'slash' 11 | import ts from 'typescript' 12 | import { test } from '@japa/runner' 13 | import { join, normalize } from 'node:path' 14 | 15 | import { ConfigParser } from '../src/config_parser.js' 16 | import { SourceFilesManager } from '../src/source_files_manager.js' 17 | 18 | test.group('Source Files Manager', () => { 19 | test('return files based on the includes pattern', async ({ assert, fs }) => { 20 | await fs.create( 21 | 'tsconfig.json', 22 | JSON.stringify({ 23 | include: ['./**/*'], 24 | }) 25 | ) 26 | await fs.create('foo/bar/baz.ts', "import path from 'path'") 27 | 28 | const { config } = new ConfigParser(fs.basePath, 'tsconfig.json', ts).parse() 29 | 30 | const sourceFilesManager = new SourceFilesManager(fs.basePath, { 31 | includes: config!.raw.include, 32 | excludes: config!.raw.exclude, 33 | files: config!.fileNames.map((fileName) => normalize(fileName)), 34 | }) 35 | 36 | assert.deepEqual(sourceFilesManager.toJSON(), { 37 | [slash(join(fs.basePath, 'foo', 'bar', 'baz.ts'))]: true, 38 | }) 39 | }) 40 | 41 | test('return files based on the includes and exclude patterns', async ({ assert, fs }) => { 42 | await fs.create( 43 | 'tsconfig.json', 44 | JSON.stringify({ 45 | include: ['./**/*'], 46 | exclude: ['./foo/bar/*.ts'], 47 | }) 48 | ) 49 | await fs.create('foo/bar/baz.ts', "import path from 'path'") 50 | 51 | const { config } = new ConfigParser(fs.basePath, 'tsconfig.json', ts).parse() 52 | 53 | const sourceFilesManager = new SourceFilesManager(fs.basePath, { 54 | includes: config!.raw.include, 55 | excludes: config!.raw.exclude, 56 | files: config!.fileNames.map((fileName) => normalize(fileName)), 57 | }) 58 | 59 | assert.deepEqual(sourceFilesManager.toJSON(), {}) 60 | }) 61 | 62 | test('add new file when to the project source files', async ({ assert, fs }) => { 63 | await fs.create( 64 | 'tsconfig.json', 65 | JSON.stringify({ 66 | include: ['./**/*'], 67 | exclude: ['./foo/bar/*.ts'], 68 | }) 69 | ) 70 | await fs.create('foo/bar/baz.ts', "import path from 'path'") 71 | 72 | const { config } = new ConfigParser(fs.basePath, 'tsconfig.json', ts).parse() 73 | 74 | const sourceFilesManager = new SourceFilesManager(fs.basePath, { 75 | includes: config!.raw.include, 76 | excludes: config!.raw.exclude, 77 | files: config!.fileNames.map((fileName) => normalize(fileName)), 78 | }) 79 | 80 | sourceFilesManager.add(join(fs.basePath, './foo', 'baz.ts')) 81 | assert.deepEqual(sourceFilesManager.toJSON(), { 82 | [slash(join(fs.basePath, './foo', 'baz.ts'))]: true, 83 | }) 84 | }) 85 | 86 | test('delete source file', async ({ assert, fs }) => { 87 | await fs.create( 88 | 'tsconfig.json', 89 | JSON.stringify({ 90 | include: ['./**/*'], 91 | }) 92 | ) 93 | await fs.create('foo/bar/baz.ts', "import path from 'path'") 94 | 95 | const { config } = new ConfigParser(fs.basePath, 'tsconfig.json', ts).parse() 96 | 97 | const sourceFilesManager = new SourceFilesManager(fs.basePath, { 98 | includes: config!.raw.include, 99 | excludes: config!.raw.exclude, 100 | files: config!.fileNames.map((fileName) => normalize(fileName)), 101 | }) 102 | 103 | sourceFilesManager.remove(join(fs.basePath, './foo', 'bar', 'baz.ts')) 104 | assert.deepEqual(sourceFilesManager.toJSON(), {}) 105 | }) 106 | 107 | test('return true when file is part of include pattern', async ({ assert, fs }) => { 108 | await fs.create( 109 | 'tsconfig.json', 110 | JSON.stringify({ 111 | include: ['./**/*'], 112 | }) 113 | ) 114 | 115 | const { config } = new ConfigParser(fs.basePath, 'tsconfig.json', ts).parse() 116 | const sourceFilesManager = new SourceFilesManager(fs.basePath, { 117 | includes: config!.raw.include, 118 | excludes: config!.raw.exclude, 119 | files: config!.fileNames.map((fileName) => normalize(fileName)), 120 | }) 121 | 122 | assert.isTrue(sourceFilesManager.isSourceFile(join(fs.basePath, './foo', 'bar', 'baz.ts'))) 123 | }) 124 | 125 | test('return false when file is part of exclude pattern', async ({ assert, fs }) => { 126 | await fs.create( 127 | 'tsconfig.json', 128 | JSON.stringify({ 129 | include: ['./**/*'], 130 | exclude: ['./foo/bar/baz.ts'], 131 | }) 132 | ) 133 | 134 | const { config } = new ConfigParser(fs.basePath, 'tsconfig.json', ts).parse() 135 | const sourceFilesManager = new SourceFilesManager(fs.basePath, { 136 | includes: config!.raw.include, 137 | excludes: config!.raw.exclude, 138 | files: config!.fileNames.map((fileName) => normalize(fileName)), 139 | }) 140 | 141 | assert.isFalse(sourceFilesManager.isSourceFile(join(fs.basePath, './foo', 'bar', 'baz.ts'))) 142 | }) 143 | 144 | test('return false when file is outside the source directory', async ({ assert, fs }) => { 145 | await fs.create( 146 | 'tsconfig.json', 147 | JSON.stringify({ 148 | include: ['./**/*'], 149 | }) 150 | ) 151 | 152 | const { config } = new ConfigParser(fs.basePath, 'tsconfig.json', ts).parse() 153 | const sourceFilesManager = new SourceFilesManager(fs.basePath, { 154 | includes: config!.raw.include, 155 | excludes: config!.raw.exclude, 156 | files: config!.fileNames.map((fileName) => normalize(fileName)), 157 | }) 158 | 159 | assert.isFalse(sourceFilesManager.isSourceFile(join(fs.basePath, '../foo', 'bar', 'baz.ts'))) 160 | }) 161 | 162 | test('return true when file part of project files', async ({ assert, fs }) => { 163 | await fs.create( 164 | 'tsconfig.json', 165 | JSON.stringify({ 166 | include: ['./**/*'], 167 | exclude: ['./foo/bar/baz.ts'], 168 | files: [join(fs.basePath, './foo', 'bar', 'baz.ts')], 169 | }) 170 | ) 171 | 172 | const { config } = new ConfigParser(fs.basePath, 'tsconfig.json', ts).parse() 173 | const sourceFilesManager = new SourceFilesManager(fs.basePath, { 174 | includes: config!.raw.include, 175 | excludes: config!.raw.exclude, 176 | files: config!.fileNames.map((fileName) => normalize(fileName)), 177 | }) 178 | 179 | assert.isTrue(sourceFilesManager.isSourceFile(join(fs.basePath, './foo', 'bar', 'baz.ts'))) 180 | }) 181 | 182 | test('test if a file or folder should be watched', async ({ assert, fs }) => { 183 | await fs.create( 184 | 'tsconfig.json', 185 | JSON.stringify({ 186 | include: ['./**/*'], 187 | exclude: ['./foo/bar/baz.ts'], 188 | }) 189 | ) 190 | 191 | const { config } = new ConfigParser(fs.basePath, 'tsconfig.json', ts).parse() 192 | const sourceFilesManager = new SourceFilesManager(fs.basePath, { 193 | includes: config!.raw.include, 194 | excludes: config!.raw.exclude, 195 | files: config!.fileNames.map((fileName) => normalize(fileName)), 196 | }) 197 | 198 | /** 199 | * Should watch the app root. Since chokidar ignore paths are in unix, we 200 | * must convert the input path to unix as well. 201 | */ 202 | assert.isTrue(sourceFilesManager.shouldWatch(slash(fs.basePath))) 203 | 204 | /** 205 | * Should watch included script files 206 | */ 207 | assert.isTrue(sourceFilesManager.shouldWatch(join(fs.basePath, './foo', 'baz.ts'))) 208 | 209 | /** 210 | * Should watch included directories 211 | */ 212 | assert.isTrue(sourceFilesManager.shouldWatch(join(fs.basePath, './foo', 'bar'))) 213 | 214 | /** 215 | * Should not watch excluded file 216 | */ 217 | assert.isFalse(sourceFilesManager.shouldWatch(join(fs.basePath, './foo', 'bar', 'baz.ts'))) 218 | }) 219 | }) 220 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@adonisjs/tsconfig/tsconfig.package.json", 3 | "compilerOptions": { 4 | "rootDir": "./", 5 | "outDir": "./build" 6 | } 7 | } --------------------------------------------------------------------------------