@poppinss/chokidar-ts

Simple typescript compiler using chokidar file watcher instead of tsc-watch

Usage no npm install needed!

<script type="module">
  import poppinssChokidarTs from 'https://cdn.skypack.dev/@poppinss/chokidar-ts';
</script>

README

Chokidar Ts

Typescript compiler using chokidar vs native Fs events.

gh-workflow-image typescript-image npm-image license-image synk-image

This module uses the compiler API of typescript to work as replacement for tsc and tsc --watch and uses chokidar for watching file changes.

Table of contents

❓ Upgrading from 2.x.x
  • The emitted events now emits an object with relativePath and absPath properties

    watcher.on('add', ({ relativePath, absPath }) => {
    })
  • Now you have to define an explicit watch mode when creating the watcher instance.

    const lspWatcher = compiler.watcher(config!, 'lsp')
    const watcher = compiler.watcher(config!, 'raw')

Why not simply use tsc?

You must use tsc, since it is the official command line tool provided by the Typescript team. However, it has following restrictions.

  1. It only watches for typescript source files.
  2. Uses fs.watch or fs.watchFile. Both have their own issues and that's why modules like chokidar were created.
  3. There is no way to hook custom transformers when using tsc.

If all of the above problems doesn't impact your projects, then simply use tsc and do waste time looking for alternatives.

Because of the above restrictions (and many more), communities like webpack and gulp also has to use the compiler API to add support for typescript in their build tools.

Why this module?

If you are user of Webpack or gulp and working in frontend space, then your life is all set, since they have first class support for Typescript projects.

However, I maintain a Node.js framework, which is bit different from frontend projects and has some unique challenges.

A backend project may have other files from .ts files. For example: Restart the server when .env file changes or when there is a change in a view template. Because of this, you will see many backend projects using the nodemon watcher to watch for all files and then rebuild the typescript project on every change and they end up making the build process too slow.

I created this module to address the above defined workflow by using the Language Service API of Typescript and build only the changed files.

Goals

The goal of this module is to stay as close as possible to the behavior of tsc and tsc --watch, while addressing the above mentioned issues.

  • Always rely on tsconfig.json file and do not invent new configuration options.
  • Use much of the defaults from the compiler API. We are not set out to create a compiler with different approach all together.
  • Allow custom AST transformers

How it works?

I make sure not to over engineer the process of compiling the code and keep it identical to the workings of tsc.

The module exposes 3 main sub-modules.

ConfigParser

The ConfigParser module exposes the API to parse the typescript config

Builder

The Builder module exposes the API to build the entire project. It is similar to tsc.

LSP Watcher

This is where things get's interesting. Instead of using the native fs events (which are super slow), we make use of chokidar to watch the entire project and handle file changes, as explained below.

Is Typescript file? Handle the event internally and process the file using the Language service API. We also check the file path against the includes and excludes to make sure, that we are processing the right files.

If not a typescript file? We will emit add, change or unlink event, so that you (the module consumer) can use and decide what to do on that event. For example: If filePath is .env, then restart the Node.js server.

By using this flow, you will always have one watcher in your entire project, that will process the Typescript files, restart the Node.js server or copy files to build folder.

Watcher

The Watch is similar to the LSP watcher, but instead of compiling files using the Typescript compiler, it will just emit the events.

This is helpful when you are running your application using a module like ts-node or @adonisjs/require-ts but want the watcher to restart the HTTP server on file change.

Instead of using a standard file watcher. The watcher class uses Typescript config to decide which files to watch or ignore.

Customer Transformers

You can also define custom transformers to transform the AST. You can read more about the transform API by following this article series.

Installation

Install the module from npm registry as follows:

npm i @poppinss/chokidar-ts

# yarn
yarn add @poppinss/chokidar-ts

Usage

import { TypescriptCompiler } from '@poppinss/chokidar-ts'

const compiler = new TypescriptCompiler(
  __dirname,
  'tsconfig.json',
  require('typescript/lib/typescript')
)

The constructor accepts three arguments:

  1. project root: The path to the project root.
  2. config file: The name of the config file from where to read the configuration.
  3. typescript: You must pass in the typescript reference, that is used by your project.

configParser(compileOptionsToExtend?: ts.CompilerOptions)

Parse the project config. Optionally, you can define your custom compiler options. There are helpful, when you want to overwrite some of the values from the tsconfig.json file.

const { error, config } = compiler.configParser().parse()

/**
 * Unable to read the config at all
 */
if (error) {
  console.log(error)
  return
}

/**
 * Config has been processed, but has some errors
 */
if (config && config.errors.length) {
  console.log(config.errors)
  return
}

// Use config

builder(options: ts.ParsedCommandLine)

Build the project. It is same as running tsc command. However, the incremental: true will have no impact.

The build command is used to build the project from scratch, it indirectly means, we should cleanup the old build before running this command and hence incremental: true has no impact once old build is deleted.

Why Delete the Old Build?

Because, the typescript compiler is not smart enough to delete the compiled file once the source file has been deleted and you will end up having files inside your build directory which doesn't even exists inside the source.

Deleting the build and re-building the project results in the most consistent and reliable output.

const { error, config } = compiler.configParser().parse()
if (error || !config) {
  console.log(error)
  return
}

if (config.errors.length) {
  console.log(config.errors)
  return
}

const { diagnostics, skipped } = compiler.builder(config!).build()

if (diagnostics.length) {
  console.log('Built with few errors')
  console.log(diagnostics)
} else {
  console.log('Built successfully')
}

watcher(options: ts.ParsedCommandLine, mode: 'raw' | 'lsp')

Returns an instance of watcher that uses chokidar and Typescript LanguageService to compile the files as they change.

const { error, config } = compiler.configParser().parse()
if (error || !config) {
  console.log(error)
  return
}

if (config.errors.length) {
  console.log(config.errors)
  return
}

const watcher = compiler.watcher(config!, 'lsp')

watcher.on('watcher:ready', () => {
  // Watcher is ready
})

watcher.on('subsequent:build', ({ relativePath, absPath, skipped, diagnostics }) => {
  // re-built source files
})

watcher.on('add', ({ relativePath, absPath }) => {
  // file other than `.ts` files has been added
})

watcher.on('change', ({ relativePath, absPath }) => {
  // file other than `.ts` files has changed
})

watcher.on('unlink', ({ relativePath, absPath }) => {
  // file other than `.ts` files has been removed
})

watcher.on('source:unlink', ({ relativePath, absPath }) => {
  // source file removed
})

watcher.watch(['.'], {
  ignored: ['node_modules', 'build'],
})

// Stop the watcher anytime you want to
watcher.chokidar.close()

When you choose raw mode over the lsp mode, then instead of emitting subsequent:build, it will emit following events.

  • source:add
  • source:change
  • source:unlink

use(transformer: PluginFn, lifecycle: 'before' | 'after')

Define your custom transformer. The transformer will receive the parsed config and the typescript reference passed to the constructor.

compiler.use((ts, config) => {
  return function transformer(ctx) {}
}, 'after')

API Docs

Following are the autogenerated files via Typedoc

Debug

You can debug the behavior of this module by running it as DEBUG=tsc:* node script-file

Reference Tree

In watch mode, we need to maintain a reference tree of files to re-process dependent files when a given file changes. This is how it works:

Let's say your project has just two file.

.
└── foo.ts
└── bar.ts

The foo.ts file has a dependency on bar.ts file.

// foo.ts

import { greet } from './bar'

console.log(greet('virk'))
// bar.ts

export function greet(name: string) {
  return `Hello ${name}`
}

When the bar.ts file changes, we also have to re-process the foo.ts to ensure that it is still valid.

To achieve the defined behavior, we maintain a reference tree of all the source files mentioned inside includes and not inside excludes of the tsconfig.json file.

Reference tree for node_modules is not maintained. So, if you update a package, you will have to re-start the compiler.