← Main

dax - Cross-platform shell tools for Node.js

by David Sherret

In July 2022, I released dax for Deno providing a cross-platform shell for JavaScript written in JavaScript:

const data = $.path("data.json").readJsonSync();
await $`git add . && git commit -m "Release ${data.version}"`;

This is similar and inspired by zx, but because it uses a cross-platform shell with common built-in cross-platform commands, more code is going to work the same way on different operating systems.

Initially, I wrote dax for Deno because Deno is by far the best JavaScript runtime for single file scripting—all dependencies can be expressed in the script file itself including npm dependencies; there's no node_modules folder (less clutter), and no separate install command necessary.

Once written, dax used APIs that only worked on Deno and creating a Node.js distribution was a decent amount of work.

Nowadays, Node.js has improved in its support for Web APIs and improvements to dnt (a tool I created for building Deno modules for Node) have made maintaining a Node.js distribution much easier.

Due to this, I'm happy to say that dax is now available on npm for users of Node.js:

// example.mjs
import $ from "dax-sh";

await $`echo 'Hello from dax!'`;
$ npm install --save-dev dax-sh
$ node example.mjs
Hello from dax!
$ time node example.mjs
Hello from dax!
node example.mjs 0.08s user 0.01 system 98% cpu 0.090 total

You can check out dax's documentation here for more details:

https://github.com/dsherret/dax

A long aside: build dax into Deno?

Part of what kicked off my desire to create a Node.js distribution for dax was the release of Bun's shell, which credits dax as a source of inspiration.

This led to requests for dax to be baked into Deno's runtime.

Discord message saying: 'Hope this means dax gets introduced into the runtime, you love to see it'

In my opinion, this would be a step backwards for dax and not a good long term decision for Deno.

I want to explain why I think this and it would be interesting to hear your feedback. Note these are my personal opinions and not the opinions of the Deno team (which I'm a member, but dax is a personal project I work on in my personal time).

Runtime coupling

Coupling a complex API like dax to the runtime means you can no longer upgrade them independently. Being able to depend on a specific version of dax and a specific version of your runtime is a massive benefit. It means you can freely upgrade your runtime version and the code using dax will mostly likely keep working too—the chance of encountering a new dax bug while upgrading your runtime is very low because they're decoupled.

Additionally, it also means when you upgrade your runtime, you don't need to also upgrade all your dax code at the same time in case there's a breaking change.

It also means you likely don't need to tell people to use a certain version of Deno in order to get the latest dax features ("hey, why doesn't this work? Oh, that dax feature is only in Deno version x.x.x"). Instead, the code specifies the dax version it depends on so when you execute it, it likely works or dax can provide specific error messages for the runtime when not.

Vendor Lock-in

Being able to use the same API in different runtimes is a massive benefit. It lowers vendor lock-in risk and lowers the complexity when working with multiple runtimes because the APIs you're using are the same. It also means when the next great runtime comes around you're not locked in with all this code depending on a specific runtime (or a specific version of a specific runtime 😱).

When dax is published as a library, you can switch runtimes and still depend on the same version of dax.

Scope

Dax is not only a shell, but a collection shell tools. It's a swiss army knife that provides opinionated ways of doing common tasks you need to do in automation scripts. It has APIs for...

  1. progress and selection,
  2. making URL requests,
  3. logging,
  4. dealing with paths,
  5. and in the future, CLI argument parsing and work caching.

All these APIs work together with each other and the shell. They're opinionated for simplicity. Baking opinionated APIs into a runtime wouldn't be a good idea because people have different opinions and opinions change over time. In the case of dax being a library, someone else can come along and improve on its API or make something better in the future, at which point dax can become a relic just like old JS frameworks.

One suggestion is to cut the scope of dax back to a shell only rather than a collection of shell tools, but the shell is still quite large. For example, you can build your own custom $ to suite your needs and inject your own custom shell commands written in JavaScript.

Cutting it back further to not include that and some other features is possible, but the shell itself is still quite intricate and there's lots of tiny design decisions that are better left to a library like dax to get wrong and then be improved upon by a future library or future major version of dax. Also at a certain point scope gets cut back enough that it starts becoming less useful.

Built-in runtime APIs should be permanent

I'm still slowly figuring out an appropriate API for dax. I don't believe anything is going to change drastically, but making a mistake if it were a built-in runtime API would be fatal. Built-in APIs and the decisions made should ideally be permanent. When they're not permanent or get removed, that creates a lot of headaches.

When it's in a library, it's behind a separately versioned API, so the chance of your code not working with the runtime anymore is slim, and making breaking changes in library that's behind a versioned API is much more manageable.

Imagine if a similar API to dax had been integrated into the runtime that made the mistake of spawning the system shell because we hadn't thought to make it cross platform yet? Image what other possibilities for this API we'll discover in the future and be glad we can easily make the changes to improve it because it exists as a library.

Performance?

Part of the argument to integrate this API into the runtime is for performance, but dax starts up in 90ms on my machine in Node.js and 70ms in Deno. It executes commands almost as fast as using Deno's Command API (2ms slower on my machine). Could it be faster? Probably... I haven't done any extensive benchmarking on dax because I develop it in my free time around all the other projects I do.

It's fast enough for my needs. You'd definitely be able to show it being slower than some native code in a hot loop, but generally automation scripts only execute a handful of commands (maybe ~10 commands) and spend most of their time waiting for long complex tasks to finish (for me, stuff like cargo build), so gaining some milliseconds by it being built-in and native doesn't help much in most real world scripts.

Plus being less productive writing automation scripts with a less featureful API will use up far more of your time than the few milliseconds saved with it being built-in, which won't even be meaningfully saved in most real world scenarios.

If we're optimizing for performance only, dax actually doesn't need to be built-in and could go native using Deno's FFI support, but in my opinion creating less portable less auditable code written in a language not as many people understand to have a slightly better performance experience is a bad trade.

Convenience of no dependency?

I wouldn't categorize having no dependency as a convenience because the runtime coupling I talked about in a previous section leads to inconvenience. Maybe it's slightly annoying in Node.js because it requires adding `dax-sh`` to a package.json and installing it, but in Deno you can just write:

#!/usr/bin/env -S deno run -A
import $ from "https://deno.land/x/dax/0.39.0/mod.ts";

await $`echo Hello`;

Is writing that difficult? I don't believe so, and now my script has all the information to know what version of dax to use or I can swap it out for a similar dependency that has the API I like instead.

It's great in Deno because I don't even need to run a separate install script—I just run that script directly and it will use the version I specified. Of course, I could use a bare specifier like "dax" by creating a deno.json with an embedded import map to make import $ from "dax"; work:

{
  "imports": {
    "dax": "https://deno.land/x/dax/0.39.0/mod.ts"
  }
}

jsr:@deno/shell@1?

Overall, I get the desire for having dax built-in, but I don't believe it's the right long term decision. Perhaps if there's a desire for a shell only and not a swiss army knife of automation scripts, then the core functionality in dax could be extracted out to a simpler package on the upcoming JSR registry behind its own versioned API.

import $ from "jsr:@deno/shell@1";

await $`echo 'Hello there!'`;

Let me know if there's a desire for a less functional, more lightweight version of dax like that and I'll look into making it happen.

Again, you can now install dax via npm install --save-dev dax-sh and use it in Node.js. Read the documentation here: https://github.com/dsherret/dax