A Simple Ejectable CLI Pattern

I've been toying around with frameworks lately. Looking for inspiration and things I haven't thought about yet. I spent an uncanny amount of hours going through the Remix and SvelteKit codebases. I'm working on my own full stack framework based on Fastify — trying to come up with my dream stack, and fastify-vite will of course be a part of it.

Jérôme Beau eloquently talks about the framework tax:

Framework services come with a cost. They require you to: comply with their API so that they can provide your their services. This is just the way a framework works: your code will have to adhere to some rules, including more or less boilerplate code. So it’s the framework way, or the highway. Your daily challenges will be less about “how to do this” than “how to make the framework (not) do this”. Dodge those constraints at your own risks: if you bypass a framework by directly calling low-level APIs, don’t expect it to understand what you’re trying to do, don’t expect it to stay consistent. So it’s a false promise frameworks make that you’ll be “focusing on your business”: in reality you have to care on the framework too, and a lot.

I often find it hard to explain my vision, but one way I've found to get the message across is this: I want my framework to be mechanical, not digital. I don't want it to work because of a highly arbitrary runtime, I want it work like a Lego set: just because of the way things fit together. I want a minimal set of files that load other files in an clean, elegant way. It must be a CLI, but I also need to be able to run it without the CLI if I want to — it needs to be ejectable from the CLI.

The basic boilerplate for nearly every Fastify app I build looks like this:

import Fastify from 'fastify'
import FastifySensible from 'fastify-sensible'

const server = Fastify({ logger: true })

server.register(FastifySensible)

server.get('/', (_, reply) => {
  reply.send('foobar')
})

await server.listen(3000)

Import Fastify, import and register fastify-sensible, register some routes, listen. Ideally I shouldn't need to have all this boilerplate repated every time when all I want is to set routes and the code associated to the routes.

The State of the Art

There's actually a pretty good solution for that: fastify-cli.

With fastify-cli, you can just run your Fastify app as a Fastify plugin and have it automatically loaded. The server itself is created and executed by fastify-cli, which can pick up a number of settings. This is what it would look like:

export default (fastify, options, next) => {
  fastify.get('/', (_, reply) => {
    reply.send('foobar')
  })
  next()
}

Which works great. Thing is: it requires you to run things as Fastify plugins, which means, you either use an async function, or you must explicitly call next() at the end. Not particularly ergonomic. It's one tiny detail, but it has to be recognized such constraint makes for unappealing hello-world snippets.

Another point I dislike about fastify-cli is that it opts to have all settings defined via either the CLI itself or environment variables, but not from a configuration file. I want to be able to export all settings I need from my application file.

What's in a CLI?

As I mentioned before, I'm working on a new full stack framework. Right now, my recommendation is to use fastify-cli but hopefully it'll be in a matching state when it's done. My goal is to make it as clean and transparent as possible, so starting with a good core foundation is essential. I created a repository illustrating the basic design of the CLI, with each commit showing its evolution:

https://github.com/galvez/ejectable

Here's my first interation: an index.mjs file that loads a function from a file specified through the command-line, and simply runs it with a Fastify server instance ready to use passed as the first parameter.

import { resolve } from 'path'
import Fastify from 'fastify'
import FastifySensible from 'fastify-sensible'

const file = process.argv[2]

if (!file) {
  throw new Error('No file specified')
}

const app = await import(resolve(process.cwd(), file))

const server = Fastify({ logger: true })

server.register(FastifySensible)
server.register(async scope => await app.default(scope))

await server.listen(3000)

You're looking at a radically minimal version of fastify-cli, the main difference being the automatic wrapping of the exported function as a Fastify plugin.

Next step is setting up package.json to register the CLI:

{
  "name": "ef",
  "files": [
    "index.mjs"
  ],
  "bin": {
    "ef": "./index.mjs"
  },
  "dependencies": {
    "fastify": "^3.27.4",
    "fastify-sensible": "^3.1.2"
  }
}

Now I can have my app file look like this:

export default (app) => {
  app.get('/', (_, reply) => {
    reply.send('foobar')
  })
}

And run like it this:

npx ef app.mjs

Picking up settings

Let's take it a step further and make that file able to specify the port and address the Fastify server will listen on. We just have to look for the extra exports:

await server.listen(app.port ?? 3000, app.address)

And now we can do:

export const port = 3000

export default (app) => {
  app.get('/', (_, reply) => {
    reply.send('foobar')
  })
}

Making it ejectable

At this point we have a nice little CLI that saves us from a lot of boilerplate. But it's not ejectable, if this package was installed through npm, we'd be dependent on it. The fastify-cli docs detail how to migrate away from it — just create your own server.js as specified. What if the CLI was able to eject itself automatically? Let's start by importing an ejectable() function and calling it at the top before anything happens. This is a section that doesn't need to land in the ejected file, so I'll preceed it with C-style comments for easy identification.

#!/usr/bin/env node

import { resolve } from 'path'
import Fastify from 'fastify'
import FastifySensible from 'fastify-sensible'

/**/ import ejectable from './ejectable.mjs'
/**/ 
/**/ await ejectable(process.cwd())

Here's the full commit. All the ejectable() function needs to do is create a copy of index.mjs from node_modules in the project directory. We can use the C-style comments to remove those lines from the source:

import { readFile, writeFile } from 'fs/promises'
import { fileURLToPath } from 'url'
import { dirname, resolve } from 'path'

const __dirname = dirname(fileURLToPath(import.meta.url))

export default async (root) => {
  if (process.argv.includes('--eject')) {
    const source = await readFile(resolve(__dirname, 'index.mjs'), 'utf8')
    await writeFile(
      resolve(root, 'ejected.mjs'),
      source.replace(/\/\*\*\/.*(\r?\n)*/mg, '')
    )
    process.exit()
  }
}

Transparent glue code

Frameworks need to be demystified. The better the framework, the more it'll look like just a design pattern. The more it'll look like just glue code. The better the framework, the more transparent the glue code.

Adding a dozen more options to our little CLI might evolve it into a full blown code generator, with multiple conditional blocks. That is why I think making the framework ejectable is a key design constraint. As long as you're keeping things configurable and all the code is ejectable, you likely have a good design.

Follow me on Twitter for news on what I'm building.

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