Express/NodeJs

Command-line shell for Express projects

December 15th, 2021

One of the nice things Django has built-in is the Django shell. It's basically the Python's interactive shell but with various things preloaded and active, such as Django settings and database connection.

This is very useful for quick debugging and experimentation while developing your project. It turns out it's easy to implement a similar shell for Express as well, using the built-in Node repl module.

Read, Eval, Print Loop

The repl module implements what's known as a REPL, short for Read, Eval, Print Loop. In a nutshell, this is what's started when you just start node without a script to run.

We want to have something similar, but with Express and database already initialized.

We can simulate something like this with command line parameters --eval and --interactive but that's pretty limited.

The repl module

Instead, we can use the built-in repl module. This module is used when you start node without a script, but we can also pile some additional functionality onto it.

The basic usage is:

const repl = require('repl');
repl.start();

This gets you the same shell as just running node, including command completion and result preview.

Injecting state into context

The REPL has its context, which is an object holding all the global variables and constants defined within that REPL. We can set up that context:

const repl = require('repl');
r = repl.start();
r.context.add = (a, b) => a + b;

This would define the add function that we can use within the shell.

If you're wondering why we can modify context after starting the shell, remember that Node is async. The fact that shell has started doesn't mean we can't run the rest of the commands in the script.

We can also modify the context at any time, which will immediately be visible within the shell. Similarly, we can inspect the context object at any point to access variables defined from the shell.

Database initialization

We can also set up initialization to be done before the shell starts. This is useful if we need to explicitly establish the database connection, like when using Mongoose.

const repl = require('repl');
const mongoInit = require('./db.js'); // assume "mongoInit" sets up the db conn

const main = async () => {
  const db = await mongoInit();
  const r = repl.start();
  r.context.db = db;
}

main();

Shell history

By default, the repl module doesn't enable shell history. This means that each time a shell starts, it will have no memory of the commands you ran previously.

While experimenting and in debugging, it's useful to be able to quickly find previous commands. The repl module makes this easy - we only need to give it the location of a file where it'll store the commands you enter:

const repl = require('repl');
const r = repl.start();
r.setupHistory('.shell_history'), () => {}); // our "ready" callback is a no-op

The Express shell

Putting this all together brings us to this example:

const repl = require('repl');

// app and configuration
const app = require('./app.js');
const config = require('./config.js');

// database initialization
const mongoInit = require('./models/db.js');

// Mongoose models
const SomeModel = require('./models/some-model.js');
const AnotherModel = require(./models/another-model.js');

const main = async () => {
  await mongoInit(config.DATABASE_URL);

  process.stdout.write('Database and Express app initialized.\n');

  const r = repl.start();
  r.context.app = app;
  r.context.config = config;
  r.context.SomeModel = SomeModel;
  r.context.AnotherModel = AnotherModel;

  r.on('exit', () => {
    process.exit();
  });

  r.setupHistory('.shell_history', () => {});
}

main();

In this example, we've loaded some Mongoose models directly into the shell and also set up an event listener so we can exit the script once the shell session is over.

Async/await

If you're using a Node v16 or newer, the shell supports async/await by default. This means things like database or other I/O are very easy in the shell. For example, assuming the above script, you can do:

> const foo = await SomeModel.findOne();

If you're using an older Node version, you'll need to use the --experimental-repl-await flag when starting Node to enable async/await:

node --experimental-repl-await scripts/shell.js

Beyond Express

In this article, we've talked about a shell in the context of an Express app, but it's in no way tied to that framework. This approach is useful no matter the framework you use.

Whenever you need to do some tedious setup and importing of the state into your shell, setting up a script like this will make the day-to-day experimentation and debugging much easier.