Automatically Watch and Build Your Turborepo Monorepo with Turbotree

It makes a lot of sense to think about how best to organize large codebases into smaller pieces. It can also be a lot of work to further develop those smaller pieces. I’m a big fan of the monorepo concept when it comes to breaking up big JavaScript projects into little ones. Keeping those smaller pieces in sync cuts a lot of overhead.

There’s no shortage of tools for working with JavaScript monorepos. A few months back, I was encouraged to check out Turborepo alongside workspaces. It works really well and saves a lot of time.

The problem is, it works really well if you are working in just one part of your monorepo. If you’re working more broadly, it’s not quite there yet.

The Watch Problem

For the past several years, just about any JavaScript setup has been equipped with a watch script. These scripts will wait for changes in your source files and kick off rebuilds when you save new changes.

Watching the source files in one project is a pretty well-defined problem at this point. Build tools like Vite are effectively zero-config these days.

Modern tools are also really good at being surgical about rebuilds and reloads. I mentioned Turborepo earlier; Turborepo won’t build the same thing twice. Vite will reload only changed code in the browser you’re developing in.

Where it starts getting complicated quickly is when you want to do this across many projects in a single monorepo.

Let’s say, for example, you have three packages, A, B, and C. A is a dependency for B, and B is a dependency for C, like this:

A → B → C

Now let’s say you’re working on C, a web app, in your browser, in watch mode. You determine A needs a change. It’s a monorepo, so it’s right there — but, critically, A is not being watched; C is. Additionally, when A rebuilds, B might also need to be rebuilt on top of A, and C on top of B.

When people are trying to use Turborepo and watch mode together, they often try to start watch modes in every project they might change. This doesn’t work, because it can’t tell that C needs a rebuild if B changes. Projects don’t pick up changes in dependencies; nothing works.

Complex build environments need complex tools.

Enter Turbowatch

I went looking for a solution when I ran into this, and I found that Gajus Kuizinas had brought the excellent Turbowatch to the scene. Turbowatch puts a lot of work into the very complex problem of watching lots of things at once and then taking actions based on that.

To tell Turbowatch that an action needs to be taken, you need to write a trigger. I set to work writing triggers that would watch the source directories as well as the “node_modules” folder where dependencies live. I spent some time wrestling with package managers to make sure the dependencies that came from the monorepo were visible to Turbowatch. Finally, I had Turborepo run turbowatch in each of our directories, in parallel.

If you’re already lost, don’t worry. Suffice it to say I was already in too deep. But, it mostly worked! The builds would often need to “settle” as one change would reverberate across dependencies several times, resulting in multiple rebuilds.

Still, there had to be a better way. Right?

Understanding the Tree

Several of us who’d hoped Turborepo could have a watch mode had been congregating around a GitHub feature request. Turbowatch featured prominently.

Then, Ryan Wheale shared a configuration based on Microsoft’s workspace-tools. This demonstrated it was possible to set up Turbowatch based on the monorepo structure.

I then came up with an iteration of this idea. Now we could, instead of having Turborepo be in charge and run lots of Turbowatch configurations, figure out the monorepo structure ourselves and dynamically construct a turbotree configuration with full awareness of our entire monorepo.

This configuration relies on Turborepo still, but to do what it does best — do a one-off build, and do it quickly. When any package changes, it invokes Turborepo exactly once, asking it to build the dependent leaves of that package. Turborepo then looks at its own view of the tree and its build state, rebuilds any dependencies of that leaf that have changed (including the one we’re watching), and we’re done.

No settling. We get full caching of builds that are already done. It automatically picks up every dependency based on the package.json files we’re already maintaining. It’s fast, it’s correct, and most importantly, it’s reliable.

Don’t repeat yourself

The watch script I posted to GitHub lived in the repository I was working on for a while. Eventually, I started using it in a few other projects. It was a lot to copy-paste, and very little of it was project-specific, so I pulled the common bits out into a new project, turbotree.

A basic turbotree watch script looks like this:

import { KickstartContext, PackageInfo, Trigger, watchTree } from "turbotree";

const triggers = (p: PackageInfo): Trigger[] => [
  {
    expression: tsViteBuild(p),
    name: `${p.name}:build`,
    initialRun: false,
    onChange: async ({ spawn, files }) => {
      console.log(`${p.root}: changes detected: ${files.map((f) => f.name)}`);
      await spawn`npx turbo build --output-logs=new-only ${p.turboFilterFlags}`;
    },
  },
];

const kickstartCommand = async (k: KickstartContext) =>
  k.$`npx turbo build --output-logs=new-only ${k.turboFilterFlags}`;

watchTree(__dirname, triggers, kickstartCommand);

turbotree’s entry point — “watchTree”— takes three things: the directory name at the root of your monorepo, a function that will create a Turbowatch trigger from a package in your repository, and an optional kickstart script that gets run before watching begins.

The function you supply (called “triggers”) here will be called several times, one for each package in your repository, as turbotree walks your monorepo. You just need to supply a Turbowatch trigger.

We’re using a prebuilt watch expression for the trigger called “tsViteBuild”. It’s included with turbotree, and it’s designed to cover basic TypeScript/Vite projects. You can supply your own, of course. It’s defined in prebuilt.ts.

The “turboFilterFlags” property of the package info provides ready-made flags to Turborepo that target the build operation to the dependent leaves that should be built for the package we’re currently generating a trigger for. (These leaves are also available by themselves on the “leaves” property.)

The kickstart command, when supplied, is called before watching begins. Watch scripts can’t pick up changes that happen when they’re not running, so the kickstart command can be used to make sure a build is done before watching is started up again. (“$” in the kickstart context is zx, like “spawn” in Turbowatch triggers.)

Where can you go from here?

This basic watch script is all you may need. Of course, you can do more! Have a look at the README for more.

There’s also this watch script from a toy project of mine. This script demonstrates multiple triggers per project; one project has an additional server that is kicked off and restarted whenever its code has been rebuilt.

turbotree was built for my use cases, of course, but I’ve tried to design it in a way that it can be useful for many different kinds of projects. If you have contributions that help make it a better tool for you, I’d love to consider them!

With turbotree, I get the impression I am finally at a point where I can work across complex JavaScript projects with ease. I couldn’t have gotten there without Turbowatch or Turborepo; I’m very grateful to the folks that made those happen. I hope it’s something that you can use, too!

 
Conversation

Join the conversation

Your email address will not be published. Required fields are marked *