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()
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;
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:
The
__main__
behavior can reference specific export symbolsThe 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.
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.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.
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.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 theprocess
symbol still exists), which will effectively assert that we are in a NodeJS context (and not a browser context)import.meta.url
(thank goodness forimport.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:
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:///" stringFor
import.meta.url
, we'll want to unescape any spaces or other HTTP-style path encodings; typically we would use thequerystring
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()
orimport 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;
Testing
Let's try it out! First, from the command-line invocation:
$ node testmod.mjs
the square of 3.141592653589793 is 9.869604401089358
Then, from the NodeJS REPL context:
$ 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:
> 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!
Top comments (0)