DEV Community

Cover image for Consistent Command-Line Execution of ES6 Module Entry Points
Brian Kirkpatrick
Brian Kirkpatrick

Posted on

Consistent Command-Line Execution of ES6 Module Entry Points

Shorter post today with a modest snippet I cobbled together that I will be reusing a lot from here on out, I suspect.

ES6 modules are great. You should use them more often. They finally give us a way to consistently write reusable code elements across command-line (e.g., NodeJS) and browser-side contexts. But it's not without it's quirks and catches.

Entry Points in SFJM ES6

One particular problem arises when we try and write an ES6 module that includes an "entry point". Consider the SFJM approach taken here:

https://dev.to/tythos/single-file-javascript-modules-7aj

This could easily extend to ES6 implementations using something like an "export default Object.assign({exports}, {metadata})", which in turn begs the question: if the metadata includes something like a __main__ property, how could we make sure this is consistently invoked when the script is called from the command line (using NodeJS) but NOT when loaded in other contexts? I'm thinking, of course, of something like the Python entry point behavior:

def main():
    pass

if __name__ == "__main__":
    main()
Enter fullscreen mode Exit fullscreen mode

In the case of SFJM, for example, this might look something like:

const exports = Object.assign({
    "myfunction": (a) => { console.log(`the square of ${a} is ${a*a}`) }
}, {
    "__main__": () => exports.myfunction(Math.PI)
});

export default exports;
Enter fullscreen mode Exit fullscreen mode

You'll notice in the above case we separate the definition of a constant exports object from the export default "return". We do this for two reasons:

  1. The __main__ behavior can reference specific export symbols

  2. The remaining content in this article can focus on implementing a specific "entry point" handler behavior after exports is defined but before it is "returned"

Module Contexts

First, let's think about the contexts a module might be loaded. There are four specific contexts for this use case.

  1. The module could be imported within a browser context. Obviously here we want the export behavior to be consistent but we don't want the __main__ behavior to be invoked.

  2. The module could be loaded within the NodeJS context by the REPL. NodeJS support for ES6 modules is no longer experimental but it does come with some caveats--you need to use a dynamic import, for example, and not all context resources will be available.

  3. The module could be loaded within the NodeJS context by some other module (e.g., a downstream dependency). Like the previous context, we wouldn't want a __main__ behavior to be invoked, but we do need to handle the case transparently without errors.

  4. Finally, we could be invoking the module as a script from the command line by passing it to the node executable directly. This is the only case in which the __main__ behavior should be invoked.

NodeJS ES6 Caveats

If you try and define or invoke an ES6 module in NodeJS, there are a few things you'll notice.

The first and most obvious is that NodeJS will want you to use the ".MJS" file extension. Otherwise you need to define a "type": "module" property in your package.json file--and SFJM won't have a package.json file. So, ".MJS" it is.

The second, and more subtle, is that a lot of the resources you are used to having in a NodeJS module may or may not exist. One relevant example for later is the querystring symbol, which you need to import explicitly. But you'll need to do it dynamically, because NodeJS is evaluating you as an ES6 module, so require() calls are rejected!

Lastly, we need to think about what values we do need to reference to determine that we are in a command-line invocation (e.g., case #4 above) and none of the others. Typically, from a NodeJS module, we might be able to check module.id and compare it against "." (a rough equivalent of Python's __name__ == "__main__" behavior). But the module symbol (much like the exports and require symbols) is not present in the NodeJS ES6 context!

What We Need

Instead, we need to check two values:

  • process.argv.length (thank goodness the process symbol still exists), which will effectively assert that we are in a NodeJS context (and not a browser context)

  • import.meta.url (thank goodness for import.meta.url), which will effectively assert that we are in an ES6 module context

So far, so good. We will want to check that these values point to the same path (specifically, that of our modules). If you print these, though, you will notice slightly different values:

  • process.argv[1] will have something like "C:\Users\My User\testmod.mjs"

  • import.meta.url will have something like "file:///C:/Users/My%20User/testmod.mjs"

So, we'll need to "massage" before comparison:

  1. For process.argv[1], we'll replace "\" with "/" to make sure we support both Windows and *nix operating systems; we'll also want to prepend a "file:///" string

  2. For import.meta.url, we'll want to unescape any spaces or other HTTP-style path encodings; typically we would use the querystring symbol, but it's not available in the NodeJS ES6 context, so we need to import it. But we can't import the "normal" way (e.g., require() or import stuff from "stuff"), because NodeJS requires that we use dynamic imports. So, into a closure it goes.

Finally There!

The final result looks something like this:

const exports = Object.assign({
    "myfunction": (a) => { console.log(`the square of ${a} is ${a*a}`) }
}, {
    "__main__": () => exports.myfunction(Math.PI)
});

if (process.argv.length > 1 && import.meta) {
    import("querystring").then(querystring => {
        if (`file:///${process.argv[1].replace(/\\/g, "/")}` === querystring.unescape(import.meta.url)) {
            exports.__main__();
        }
    });
}

export default exports;
Enter fullscreen mode Exit fullscreen mode

Testing

Let's try it out! First, from the command-line invocation:

$ node testmod.mjs
the square of 3.141592653589793 is 9.869604401089358
Enter fullscreen mode Exit fullscreen mode

Then, from the NodeJS REPL context:

$ node
> import("testmod.mjs").then(console.log)
...
[Module: null prototype] {
  default: {
    myfunction: [Function: myfunction],
    __main__: [Function: __main__]
  }
}
Enter fullscreen mode Exit fullscreen mode

Hey, not bad! You could even invoke the entry point from within the REPL if you want, after the dynamic import, but (thanks, NodeJS!) you will need to specifically extract it from the default symbol:

> import("../sfjm/testmod.mjs").then(testmod => testmod.default.__main__())
...
> the square of 3.141592653589793 is 9.869604401089358
Enter fullscreen mode Exit fullscreen mode

This should be resilient against dependency imports and browser contexts, as well. It's not super-pretty, but it's short enough, and generic enough, to copy-paste into the end of any SFJM module that defines a __main__ export. Enjoy!

Top comments (0)