Trying Node.js SEA
A little closer to native binaries but not quite
July 23, 2023
This weekend I randomly thought about trying Node.js Single Executable Applications (SEA). The gist of this feature is that it lets you "turn your Node.js app into a native executable". I say that in quotes because technically, it's not doing any compiling of JavaScript code into native code. Instead, it does the next best thing: Embed the JavaScript into a copy of the Node.js executable. And once that modified executable is executed, it runs the embedded script if present. It's a very clever solution in my opinion.
The preparation process is quite simple:
- Create a config file that tells Node.js which script to convert into a blob.
- Use the previous config to convert a script into a blob.
- Grab a copy of the Node.js executable installed in your system.
- Inject the created blob into the copy of the Node.js executable.
- ???
- Profit!
Of course, given that this feature is experimental, constraints and differences exist. However, I find that the choice of going for CommonJS instead of ES modules to be most intriguing, as ES modules have been supported for a while now. While it doesn't matter much, as we have tools to translate from one format to another, it's just not seamless in most cases.
For small standalone scripts, everything works fine. It's when you try it with real third-party modules that it starts to show its experimental status. Not the fault of Node.js SEA, just features of regular Node.js that don't translate over cleanly. For instance, following are the things I found while trying to bundle standard
into one CommonJS script so that I can turn it into an executable:
Not all third-party modules are readily bundleable. One example of this is
standard-engine
which imports itsbin/cmd
module, which doubles as a shell script. Bundlers like Rollup don't know what to do with the "shebang" (the#!/usr/bin/env node
) at the beginning of the file because it's not JavaScript syntax.The embedded
require()
does not work the same way as the regularrequire()
which can cause issues with modules that userequire()
to dynamically load modules. An example iseslint
loadingespree
, by first finding its path withrequire.resolve()
and then usingrequire()
on the discovered path later. Rollup cannot bundle dynamic imports done this way, leaving the code as is, which does not work embedded.Some modules assume the current directory is a Node.js package, and therefore assume that things like
package.json
is present. For instance,standard
will attempt to read the project'spackage.json
for configuration and project info - which will not exist when embedded.
So far, my workaround to the above is to patch the modules with patch-package
or replace lines using @rollup/plugin-replace
. Not the most elegant solution, I'd have to patch/replace every quirk I find which can easily become a game of cat-and-mouse, but this lets me move forward.
PS: Now you might be wondering, why not just learn a language that compiles to a native executable (e.g. C, C++, Rust, Go)? I did try that (Rust) but the learing curve is quite steep on the more advanced end, like once you start building stuff like parsers and renderers. Also, JavaScript's syntax is more concise and flexible, allowing you to be clever with its use. For instance, you can create an HTML-like structure in Mithril.js with just JavaScript - something that you'd have to create a template DSL/elaborate macros for for with typed languages.