My Minimal Monorepo Setup with ZX

fastify-vite is a Fastify plugin, perhaps slowly evolving into a mini framework, that enables client application SSR for Fastify using a variety of client frameworks like Vue and React. At its core, fastify-vite is a simple Fastify route handler factory that will generate a renderer based on the configured Vite build. It has some additional utilities, like project blueprints for the renderer you're using, but in essence all it does is register Vite-flavored Fastify route handlers for you, both in development mode (enabling hot reload) and for production.

There's a main package fastify-vite which will work in conjunction with a renderer adapter package such as fastify-vite-vue or fastify-vite-react.

Monorepo needs

Splitting them in multiple repositories would be painful to manage and develop, because the main package can often have tweaks and fixes originating from work in renderer adapters, and all renderer adapter packages ideally need to receive replications of the updates and changes that happens in any of them.

I needed two simple things:

  • To be able to link local packages and have changes in them reflected instantly.
  • To be able to publish multiple packages to npm at once.

So I decided to skip on Lerna, Nx and Turborepo and just wrote two simple ZX scripts. Here's a rundown of what they do and how to they work.

Package linking

The first script is devinstall.mjs. To develop fastify-vite, I work by testing directly in the kitchen sink examples/. Each of those example packages need to have both external dependencies and locally linked dependencies.

Unfortunately, npm link somehow doesn't work for my needs, because Vite will insist on treating them as locally available instead of external dependencies, handling ESM packages in a different manner. To be honest I don't know if it was just me not grasping a few things, or if the problem persists to date, but the way I worked around the problem was to just manually list external and local dependencies in each example's package.json file.

For fastify-vite-vue, this is what it looks like:

{
  "type": "module",
  "name": "vue-example",
  "private": true,
  "scripts": {
    "dev": "zx ../../devinstall.mjs vue -- node server.js"
  },
  "external": {
    "fastify": "^3.17.0"
  },
  "local": {
    "fastify-vite": "^3.0.0-alpha.12",
    "fastify-vite-vue": "^3.0.0-alpha.12"
  }
}

Now, I know which packages I should just install externally and which ones I should copy locally. The trick is that I need to go into the local packages, collect their dependencies and turn those into external dependencies for each example package. Here's how it's done with devinstall.mjs:

import chokidar from 'chokidar'
const { name: example } = path.parse(process.cwd())
const exRoot = path.resolve(__dirname, 'examples', example)
const command = process.argv.slice(5)

if (!fs.existsSync(exRoot)) {
  console.log('Must be called from a directory under examples/.')
  process.exit()
}

await $`rm -rf ${exRoot}/node_modules/vite`
await $`rm -rf ${exRoot}/node_modules/.vite`

const template = require(path.join(exRoot, 'package.json'))
const localPackages = fs.readdirSync(path.join(__dirname, 'packages'))

const { external, local } = template
const dependencies = { ...external }

for (const localDep of Object.keys(local)) {
  for (const [dep, version] of Object.entries(
    require(path.join(__dirname, 'packages', localDep, 'package.json')).dependencies)
  ) {
    dependencies[dep] = version
  }
}

await createPackageFile(exRoot, dependencies)
await $`npm install -f`

Using local and external, I build the dependency map for each example package dynamically: installing all external ones via npm i, and then I just manually copy (and watch for changes) the ones listed in local:

for (const localDep of Object.keys(local)) {
  await $`cp -r ${__dirname}/packages/${localDep} ${exRoot}/node_modules/${localDep}`
  const changed = (reason) => async (path) => {
    console.log(`${reason} ${path}`)
    await $`cp -r ${__dirname}/packages/${localDep} ${exRoot}/node_modules/${localDep}`
  }
  const watcher = chokidar.watch(`${__dirname}/packages/${localDep}`, {
    ignored: [/node_modules/],
    ignoreInitial: true,
  })
  watcher.on('add', changed('A'))
  watcher.on('unlink', changed('D'))
  watcher.on('change', changed('M'))
}

await $`${command}`

Get the full source here:

https://github.com/fastify/fastify-vite/blob/dev/devinstall.mjs

Publishing releases

The other is release.mjs. Taking a cue from several open source projects I follow, I went with this basic flow for evolving the project releases: patch (0.0.x) ➔ minor (0.x.0) ➔ premajor alpha (x.0.0-alpha.y) ➔ premajor beta (x.0.0-beta.y) ➔ major (x.0.0). It's simple enough and gives a lot of room for testing releases.

This script will parse out the current release of the main package (fastify-vite) and bump all versions in all other packages (so they stay synced, for consistency) however you specify it. Below are a few sample commands that work:

npx zx release.mjs patch
npx zx release.mjs minor
npx zx release.mjs premajor --alpha
npx zx release.mjs premajor --beta
npx zx release.mjs major

Get the full source here:

https://github.com/fastify/fastify-vite/blob/dev/release.mjs

Since this is very tricky and very easy to get wrong, I made sure to watch a small self-contained test suite for it using brittle from David Mark Clements.

import check from 'brittle'

function test () {
  check('patch', ({is}) => {
    is(bump('0.0.1', 'patch') , '0.0.2')
  })
  check('minor', ({is}) => {
    is(bump('0.0.1', 'minor') , '0.1.0')
  })
  check('major', ({is}) => {
    is(bump('0.0.1', 'major') , '1.0.0')
  })
  check('new premajor', ({is}) => {
    is(bump('0.0.1', 'premajor', 'alpha') , '1.0.0-alpha.0')
  })
  check('premajor increment', ({is}) => {
    is(bump('1.0.0-alpha.0', 'premajor') , '1.0.0-alpha.1')
  })
  check('premajor to major', ({is}) => {
    is(bump('1.0.0-alpha.0', 'major') , '1.0.0')
  })
}

I don't think this project will ever require the full set of features from Lerna, Nx or Turborepo. It's very rewarding to pay attention to what your actual needs are and stay focused on them. What's the simplest possible way to get it working? What does it actually need to accomplish under the hood? Turns out for my monorepo needs, these two relatively simple scripts are all that's needed.

Want to hear about new articles on this site? Follow me on Twitter Twitter