Skip to content

Instantly share code, notes, and snippets.

@guest271314
Last active November 8, 2024 18:30
Show Gist options
  • 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/[email protected]

 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/[email protected]"; 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.

@guest271314
Copy link
Author

@StreetStrider FWIW a recent compilation to executable using Node.js Compile Node.js Native Messaging host to Single executable application.

@guest271314
Copy link
Author

@StreetStrider You can now also run deno clean to get rid of denort in Deno's cache.

@guest271314
Copy link
Author

Compiling npm to a standalone executable: Which runtime can do this out of the box; node, deno, or bun?

node v23.0.0-nightly20241016019efe1453, CommonJS input exclusively, 116.3 MB.
deno 2.0.0+661882f (canary, release, x86_64-unknown-linux-gnu) 105.0 MB.
bun 1.1.31 96.4 MB.

@guest271314
Copy link
Author

@StreetStrider

So both deno and bun generates more or less the same size executable with size approx 90-100MB.

Another difference I noticed is that Bun appears to still depend on the source files the executable was generated from, Deno doesn't. See oven-sh/bun#14676.

@StreetStrider
Copy link

@guest271314

Deno doesn't

That's interesting how it is possible. I thought any runtime just bundles JS along with itself, more or less. I wonder if they use bytecode cache.

denort

Fun thing about denort is that it seemes to be very internal. I've tried to find comprehensive docs on it, no luck.

It is also very strange that Node supports only CJS as an input. I think overall deno looks like the best option for creating standalone executables (as of now).

@guest271314
Copy link
Author

Yes, Deno is the option that works when the source files are deleted.

If you compile deno fetches denort, which I think is the core Deno runtime, without a bund of the Web API's. You should be able to run strings on denort after compiling to see what's in there.

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