Skip to content

Instantly share code, notes, and snippets.

@guest271314
Last active April 9, 2024 08:48
Show Gist options
  • Star 19 You must be signed in to star a gist
  • Fork 1 You must be signed in to fork a gist
  • Save guest271314/9b1adad3db3deba64e118f844a77bad6 to your computer and use it in GitHub Desktop.
Save guest271314/9b1adad3db3deba64e118f844a77bad6 to your computer and use it in GitHub Desktop.
Compiling a standalone executable using modern JavaScript/TypeScript runtimes

Compiling a standalone executable using modern JavaScript/TypeScript runtimes

We have the same code working using node, deno, and bun.

E.g.,

bun run index.js
deno run -A --unstable-byonm index.js
node --experimental-default-type=module index.js

which each produce a Signed Web Bundle and that is an Isolated Web App.

We have a node_modules folder that node, deno and bun each utilize for module source.

For deno we pass --unstable-byonm flag to use the node_modules folder.

For node we use the --experimental-default-type=module flag to use Ecmascript modules with .js extension.

OS: Linux x86.

References:

That's it. Let's see how simple or complicated it is to compile a JavaScript application to a single executable containing your source code and the given JavaScript runtime.

What does bun have to say on the first run?

bun build ./index.js --compile --outfile=bun_exe
  [35ms]  bundle  38 modules
 [115ms] compile  bun_exe
./bun_exe
isolated-app://<ISOLATED_WEB_APP_ID>/

signed.swbn, 8450 bytes.

bun build --compile works on first run.

Let's try Deno next.

deno compile -A --unstable-byonm ./index.js --output=deno_exe
Check file:///home/user/index.js
error: Uncaught Error: Could not find a matching package for 'npm:@types/node' in '/home/user/package.json'. You must specify this as a package.json dependency when the node_modules folder is not managed by Deno.
    at ext:deno_tsc/99_main_compiler.js:644:32
    at Array.map (<anonymous>)
    at Object.resolveTypeReferenceDirectives (ext:deno_tsc/99_main_compiler.js:633:33)
    at actualResolveTypeReferenceDirectiveNamesWorker (ext:deno_tsc/00_typescript.js:119495:154)
    at resolveTypeReferenceDirectiveNamesWorker (ext:deno_tsc/00_typescript.js:119871:22)
    at resolveTypeReferenceDirectiveNamesReusingOldState (ext:deno_tsc/00_typescript.js:120033:16)
    at processTypeReferenceDirectives (ext:deno_tsc/00_typescript.js:121349:158)
    at findSourceFileWorker (ext:deno_tsc/00_typescript.js:121245:11)
    at findSourceFile (ext:deno_tsc/00_typescript.js:121115:22)
    at ext:deno_tsc/00_typescript.js:121064:24

Why would deno, a TypeScript runtime need npm:@types/node?

Whatever, alright, we'll install npm:@types/node.

bun add @types/node
bun add v1.0.22 (b400b36c)

 installed @types/node@20.10.8

 2 packages installed [36.00ms]
bun install
bun install v1.0.22 (b400b36c)

Checked 10 installs across 11 packages (no changes) [29.00ms]

Let's try Deno again, and make sure --output=deno_exe is not expected to be index.js

deno compile -A --unstable-byonm --unstable --output deno_exe ./index.js
Check file:///home/user/index.js
error: Uncaught Error: Could not find a matching package for 'npm:@types/node' in '/home/user/package.json'. You must specify this as a package.json dependency when the node_modules folder is not managed by Deno.
    at ext:deno_tsc/99_main_compiler.js:644:32
    at Array.map (<anonymous>)
    at Object.resolveTypeReferenceDirectives (ext:deno_tsc/99_main_compiler.js:633:33)
    at actualResolveTypeReferenceDirectiveNamesWorker (ext:deno_tsc/00_typescript.js:119495:154)
    at resolveTypeReferenceDirectiveNamesWorker (ext:deno_tsc/00_typescript.js:119871:22)
    at resolveTypeReferenceDirectiveNamesReusingOldState (ext:deno_tsc/00_typescript.js:120033:16)
    at processTypeReferenceDirectives (ext:deno_tsc/00_typescript.js:121349:158)
    at findSourceFileWorker (ext:deno_tsc/00_typescript.js:121245:11)
    at findSourceFile (ext:deno_tsc/00_typescript.js:121115:22)
    at ext:deno_tsc/00_typescript.js:121064:24

deno compile -A --unstable-byonm --unstable --output deno_exe ./index.js
Check file:///home/user/index.js
Compile file:///home/user/index.js to deno_exe

Alright! Deno created the self-contained executable!

Let's run the output executable file

./deno_exe
error: Parsing version constraints in the application-level package.json is more strict at the moment.

Not implemented scheme 'https'

Foiled again.

My estimation is that the error has to do with an entry in package.json is pointing to a .git extension on GitHub. I have not confirmed that is the case, yet.

Update

I got deno to compile by using an import map, deno.json with the NPM mime package, which is CommonJS, pointing to "https://esm.sh/mime@2.6.0"; making sure the cborg package points to cborg.js in the esm folder in the library; and including "node:" specifier before "fs" and "path".

deno compile -A --unstable-byonm --unstable --output deno_exe ./index.js
Check file:///home/user/index.js
Compile file:///home/user/index.js to deno_exe
./deno_exe
isolated-app://efjntlnfcij5k2sourpthwhbyqhfxy34bihkw4bimnxrl6hdwsfqaaic/

signed.swbn, 8524 bytes.

Next up, Node.js.

There's a bit to unpack and re-read in the Node.js version. Some key points which essentially prevent us from proceeding as-is

The single executable application feature currently only supports running a single embedded script using the CommonJS module system.

I'm using Ecmascript Modules, not CommonJS. We should try anyway.

echo '{ "main": "index.js", "output": "sea-prep.blob" }' > sea-config.json 

bun x postject node_exe NODE_SEA_BLOB sea-prep.blob --sentinel-fuse NODE_SEA_FUSE_fce680ab2cc467b6e072b8b5df1996b2 
Error: Can't read resource file

Update

I bundled index.js to a browser format with bun

bun build index.js --target=browser --outfile bun_node_bundle.js

  bun_node_bundle.js  1103.25 KB
75 |   const parsedAssetPath = path.parse(relativeAssetPath);
                                    ^
warn: Browser polyfill for module "node:path" doesn't have a matching export named "parse"
   at /home/user/wbn-bundle.js:75:32

75 |   const parsedAssetPath = path.parse(relativeAssetPath);
                                    ^
note: Bun's bundler defaults to browser builds instead of node or bun builds. If you want to use node or bun builds, you can set the target to "node" or "bun" in the bundler options.
   at /home/user/wbn-bundle.js:75:32

96 |     const filePath = path.join(dir, fileName);
                               ^
warn: Browser polyfill for module "node:path" doesn't have a matching export named "join"
   at /home/user/wbn-bundle.js:96:27

96 |     const filePath = path.join(dir, fileName);
                               ^
note: Bun's bundler defaults to browser builds instead of node or bun builds. If you want to use node or bun builds, you can set the target to "node" or "bun" in the bundler options.
   at /home/user/wbn-bundle.js:96:27

[248ms] bundle 41 modules

then ran postject

bun x postject node_exe NODE_SEA_BLOB sea-prep.blob --sentinel-fuse NODE_SEA_FUSE_fce680ab2cc467b6e072b8b5df1996b2 
Start injection of NODE_SEA_BLOB in node_exe...
warning: Can't find string offset for section name '.note.100'
warning: Can't find string offset for section name '.note.100'
warning: Can't find string offset for section name '.note.100'
warning: Can't find string offset for section name '.note.100'
warning: Can't find string offset for section name '.note.100'
warning: Can't find string offset for section name '.note.100'
warning: Can't find string offset for section name '.note.100'
warning: Can't find string offset for section name '.note.100'
warning: Can't find string offset for section name '.note.100'
warning: Can't find string offset for section name '.note.100'
warning: Can't find string offset for section name '.note.100'
warning: Can't find string offset for section name '.note'
💉 Injection done!

then ran the single executable application

./node_exe
bun_node_bundle.js:21686
globalThis.Buffer ??= (await Promise.resolve().then(() => (init_buffer(), exports_buffer))).Buffer;
                             ^^^^^^^

SyntaxError: Unexpected identifier 'Promise'
    at internalCompileFunction (node:internal/vm:77:18)
    at wrapSafe (node:internal/modules/cjs/loader:1290:20)
    at embedderRunCjs (node:internal/util/embedding:19:27)
    at node:internal/main/embedding:18:8

Node.js v22.0.0-nightly2024010657c22e4a22

which throws a syntax error for the bundled representation of globalThis.Buffer ??= (await import("node:buffer")).Buffer in the original script.

Results:

  • bun successfully compiled the standalone executable. After strip bun the resulting executable is 89.1 MB.

  • deno compiled the standalong executable, after installing @types/node (which also installs undici-types), however, the standalone executable throws and error. Update: Got deno to compile a working executable. After strip deno the resulting executable is 98.1 MB.

  • node only supports CommonJS. We tried anyway where we know the source is Ecmascript Modules. bun x equivalent of npx postject hello NODE_SEA_BLOB sea-prep.blob \ --sentinel-fuse NODE_SEA_FUSE_fce680ab2cc467b6e072b8b5df1996b2 per the Node.js Single Executable Application documentation throws an error.

These are the empirical results I'm sharing experimenting and testing compiling a standalone executable that achieves the same result when run in the given JavaScript runtime using the same source code.

@bogdanbiv
Copy link

If you don't need the node_modules dir as an input, it's not needed for compiling to a binary

@guest271314
Copy link
Author

@bogdanbiv I figured out how to compile to a binary using deno without node_modules initially in the current working directory. deno winds up creating the node_modules folder and fetching @types/node and undici-types. For the dependencies deno.json is used, which points to esm.sh and raw.githubusercontent.com.

@miladvafaeifard
Copy link

Awesome experiment! Thanks for sharing!

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