Skip to content

Instantly share code, notes, and snippets.

@WebReflection
Last active March 4, 2024 20:55
Show Gist options
  • Star 34 You must be signed in to star a gist
  • Fork 4 You must be signed in to fork a gist
  • Save WebReflection/8840ec29d296f2fa98d8be0102f08590 to your computer and use it in GitHub Desktop.
Save WebReflection/8840ec29d296f2fa98d8be0102f08590 to your computer and use it in GitHub Desktop.
NodeJS Executable Standalone Module

Update

If you're OK in having a node-esm executable, please consider this solution.

#!/usr/bin/env sh
# the /usr/local/bin/node-esm executable
input_file=$1
shift
exec node --input-type=module - $@ <$input_file

Example:

#!/usr/bin/env node-esm
import { version } from 'process';
console.log(version);

About

In this tweet I've shown how the incoming version of NodeJS could be used to run a standalone file as ESM through the flag --input-type=module.

The technique would work for files without an extension, like myprogram, as well as files with any extension, including myprogram.js, myprogram.mjs, myprogram.exe, or myprogram.iscool.

This gist is an attempt to describe the technique I've used.

Special thanks to Geoffrey Booth for hints, suggestions, and review of this content.

TL;DR - The Solution

#!/usr/bin/env sh
J=S//;echo "\n\n$(sed "1,2d" "$0")"|node --input-type=module "$@";exit $?
/* your 100% ESM code from this line on */
console.log(import.meta);

Compatibility

  • OSX and macOS
  • most unix and Linux based distribution (please LMK if one is incompatible, thanks)
  • Windows WSL, and every other way to have a bash, or shell, environment on Windows

The First line

It's a regular hashbang, that could usually be written either as #!/bin/sh or, in a wider compatible way, as #!/usr/bin/env sh, to carry on the environment, and execute via sh, or even bash.

This first line requires that the file is executable, something easy to obtain via chmod, as in chmod +x filename.

The Second Line

This part was inspired by cgjs executable.

Technically speaking, the J=S//; part of the string could be omitted, but then any JavaScript editor would show errors due usage of incompatible shell syntax.

Using J=S//; as prefix instead, would simply assign, in the shell world, as well as in the bash one, the string S// to a variable J, but it won't trigger errors, neither it will ever execute in the NodeJS world (no global J leak whatsoever).

However, it will start an inline comment, for your IDE of choice, so that no errors would be shown while editing such file.

In shell world, as well as in the bash one, it will simply keep executing whatever is after the semicolon.

Keep in mind, shell scripts, as well as bash scripts, execute as a stream, instead of requiring upfront parsing and syntax validation, meaning that exiting a file at any time won't cause syntax errors, even if the content after exiting is invalid.

The echo command

The echo command simply prints, usually via the standard output, some content.

The reason echo is part of this technique, is that we'd like to maintain the line number, in case any error occurs during NodeJS execution.

I'm not sure sed could do that for me right away, but echo works just as well.

It's important to use doublequotes to concatenate two new lines, "\n\n" and the consumed sed output.

In shell, as well as in bash, double quotes can contain the output produced by system calls, as it is in the case of echo "1$(echo 2)3", which will simply output 123.

The sed part

The micro utility sed is a quite universal way to replace, hence transform, some content.

sed "1,2d" "$0"

Above command strips out the first two lines from the file specified by $0. In shell, as well as in bash, it's always good to expand variables that could contain spaces via double quotes, which is why $0 is wrapped as "$0", so that any path would be accepted.

In shell, as well as in bash, the $0 always refers to the file that is currently executing, like __filename would be in CommonJS.

The pipe

In shell, as in bash, the pipe operator | streams output produced by the left hand side, as right hand side input.

In this example, the pipe passes the echo output, including two \n and the output produced by sed, as node stdin.

To Recap

The echo "\n\n$(sed "1,2d" "$0")" will pass along an output containing two new lines \n plus whatever sed outputted before.

       as node stdin
     ┏━━━━━━━━━━━━━━┳━━▶
echo "\n\n$(sed ...)" | node ...

The node execution

The upcoming version of node, and as part of its –experimental-modules flag switch, will make possible to execute any sort of stdin as pure ESM, instead of CommonJS.

This is particularly handy for evaluation cases, or input produced by 3rd parts software, without the need to write intermediate files on the disk, as it is in this very same case.

This technique perfectly addresses indeed the stdin execution case, making the file itself, the source of the ESM that will be executed.

node --input-type=module "$@"

Above shell or bash command, switches node input parsing goal as module, which practically means CommonJS is not available, and everything would run as pure ESM, even if an input couldn't really ever be used as module, but that's another story (naming is hard, and this whole module story is not scope of this gist).

The "$@" in shell, as well as in bash, would properly expand any optional extra arguments passed to the initial, shell, program.

Are imports ESM too ?

Please note that, even if executed as ESM, the program won't act as if there was a "type": "module" field within a package.json in the same, or upper, directories.

However, if you import globally installed modules, you don't need to worry about it, while if you'll import local files, you either need to have a package.json file with "type":"module" field in the same folder, or in one of its parent folders.

How to have all imports as ESM

If you want to always threat any imported NodeJS content as ESM, you can echo '{"type":"module"}'>>~/package.json so that the top most folder, per the current user, reached by npm, will understand the default import parsing goal of any file.

To opt out, you can always explicitly import a .cjs file, so that legacy compatibility is preserved.

The exit

Inspired by this suggestion, and coincidentally with the end of this gist, the last part of the technique ;exit $? simply splits, in two different commands, the previous node execution, and the exiting of the initial shell program itself, so that shell won't parse any extra content and it will exit passing along the same exit code node produced.

This is thanks to the $? special shell, and bash, variable, which simply contains the last exit code.

$ echo $?
0

$ trigger error
-bash: trigger: command not found

$ echo $?
127

Alternative: enforced ESM

This version ensures if there's no package.json in the folder it puts one with {"type":"module"} content.

#!/usr/bin/env sh
J="$(dirname $0)/package.json"//;if [ ! -f "${J:0:-2}" ];then echo '{"type":"module"}'>"${J:0:-2}";fi;echo -e "\n\n$(sed "1,2d" "$0")"|node --input-type=module "$@";exit $?

import { version } from 'process';
console.log(`Running Node ${version} in ESM mode!`);
@chardskarth
Copy link

chardskarth commented Aug 16, 2022

@WebReflection , this is awesome, thousand thanks for this! 🙏 💯

However, I can't seem to pass a param. The "$@" doesn't work and is instead treating params as if I was requiring/running other scripts.

tmp sPkJwmUv


EDIT, Workaround:

I was able to pass a parameter by adding a string substitution instead. and seeing that "$@" doesn't seem to work I removed it:

#!/usr/bin/env sh
J=S//;title=$1
J=S//;cd $( npm root -g ) && echo "\n\n$(sed -e "1,3d" -e "s/\$notestitle/'$title'/" "$0")"|node --input-type=module;exit $?
/* your 100% ESM code from this line on */

then added an undefined variable in my esm script, $notestitle.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment