2026-02-09 Node modules working as command-line scripts revisited

Over five years ago I wrote a short post about writing JavaScript scripts which also work as CJS modules. I wrote that I didn’t know how to perform a similar trick with ESM modules, and that it might even be impossible. Well, I was wrong. I found a solution in the book Shell scripting with Node.js by Axel Rauschmayer. He proposes the following solution in the chapter about file system paths and file URLs:

import {fileURLToPath} from 'node:url';

const modulePath = fileURLToPath(import.meta.url);
if (process.argv[1] === modulePath) {
    main();
}

(well actually, his solution is a bit more complex, but this is the gist of it – head to his book for more details).

I started using it and it worked great… until it didn’t. It turned out that I was able to trick this code by symlinking to my script and calling the symlink instead. After a bit of poking around, I arrived at this code:

import {fileURLToPath} from 'node:url';
import {realpath} from 'node:fs/promises';

const modulePath = await realpath(fileURLToPath(import.meta.url));
if (await realpath(process.argv[1]) === modulePath) {
        main();
}

Note that it uses top-level await, which Node.js has been supporting for some time now.

From now on, I can both call my ESM script directly (even as a symlink) or import it in other code (for example, unit tests).

That’s it for today, thanks for reading!

CategoryEnglish, CategoryBlog, CategoryJavaScript