Consistent Command-Line Execution of ES6 Module Entry Points
Tags: javascript, es6, node, sfjm
Published: 2022-12-16 01:50:28

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](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:

```python
def main():
    pass

if __name__ == "__main__":
    main()
```

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

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

export default exports;
```

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

1. 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.

1. 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.

1. 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.

1. 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

1. 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:

```javascript
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;
```

## Testing

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

```bash
$ node testmod.mjs
the square of 3.141592653589793 is 9.869604401089358
```

Then, from the NodeJS REPL context:

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

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:

```bash
> import("../sfjm/testmod.mjs").then(testmod => testmod.default.__main__())
...
> the square of 3.141592653589793 is 9.869604401089358
```

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!