Bytenode
A minimalist bytecode compiler for Node.js.
This tool truly compiles your JavaScript code into V8 bytecode, so that you can protect your source code. It runs on Node.js and Electron. See examples/ for current runnable projects.
Install
npm install --save bytenode
Or globally:
sudo npm install -g bytenode
Security and Code Protection Note
Bytenode compiles JavaScript into V8 bytecode, which removes original source formatting, comments, and variable names, drastically increasing the barrier to entry for reverse engineering. However, be aware that V8 bytecode is not encryption. Decompilation and analysis tools exist, such as View8 and Ghidra plugins. These tools are valuable for the cybersecurity community to audit applications and analyze malicious code. Some of them extract only abstract pseudo-code from .jsc files, while others can decompiles them back to readable JavaScript.
If your goal is maximum intellectual property protection, Bytenode should not be your only line of defense. We recommend adding an obfuscation step (using tools like javascript-obfuscator) into your build pipeline before compiling the code with Bytenode. This ensures that even if the bytecode is decompiled, the resulting JavaScript remains unreadable and hard to reverse-engineer.
Known Issues and Limitations
- In Node 10.x, Bytenode does not work in debug mode. See #29.
- Any code depends on
Function.prototype.toStringfunction will break, because Bytenode removes the source code from.jscfiles and puts a dummy code instead. See #34. For a workaround, see #163
- Async Arrow Functions (and Arrow Functions in general) cause crashes in Puppeteer and in Electron apps. See #106, #47. They also cause issues with the ndb debugger. See #135. It seems that whenever there is a context change (or even when called from another file or module), arrow functions break because
V8inspects them internally usingFunction.prototype.toStringin these cases. See #157.
- Electron ≥ 42 (V8 ≥ 14.8): bytecode compiled via
ELECTRON_RUN_AS_NODEcrashes the main process withSIGTRAP. V8's serialized bytecode header carries a _read-only snapshot checksum_ (header offset 16). The Electron main/browser process boots V8 from Chromium'sv8_context_snapshot, whose read-only heap differs from the Node default snapshot used byELECTRON_RUN_AS_NODE. So a.jscproduced withcompileElectronCode()(run-as-node) carries a checksum that does not match the main process. Starting with V8 14.8 this no longer fails gracefully (cachedDataRejected) but hard-aborts (EXC_BREAKPOINT/SIGTRAP) while deserializing. The fix is to compile in the same process type that will load the.jsc— usecompileElectronMainCode()orcompileFile({ electronMain: true }), which compile inside an actual Electron main process. (On Electron ≤ 41 / V8 ≤ 14.6 the two checksums happened to be compatible, soelectron: trueworked there.)
- Electron renderer bytecode is context-specific. On Electron 43.5.1 and later, the renderer snapshot differs from the Node and main-process snapshots. The project’s reported checks found that bytecode compiled with
electron: trueorelectronMain: truewas rejected by renderer preloads/pages, while renderer-compiled bytecode loaded in both. UsecompileElectronRendererCode()orcompileFile({ electronRenderer: true })for renderer code. This mode compiles in a hidden window’s preload, so the result is suitable for renderer/preload contexts and should not be used in the main process or worker threads. The reported compatibility checks covered Electron 43.4.1, 43.5.1, 43.7.3, and 44.4.3; verify the mode against the exact Electron versions you ship. See #265.
worker_threads | electronMain | electronMain or electron |
| utilityProcess | electron | electron or electronMain |
| preload, renderer page | electron or electronRenderer | electronRenderer only |
Resources
- How To Compile Node.js Code Using Bytenode
- Bytenode Webpack Plugin
- Creating JS Binaries For Electron
- Electron Bytenode Example
Examples
- Node.js CommonJS module: compile a module,
- Electron main process: compile the main
Bytenode CLI
Usage: bytenode [option] [ FILE... | - ] [arguments]
Options:
-h, --help show help information.
-v, --version show bytenode version.
-c, --compile [ FILE... | - ] compile stdin, a file, or a list of files
--compress compress bytecode
--source-map write an external source map beside each .jsc file
-n, --no-module compile without producing commonjs module
-e, --electron compile for Electron
-ep, --electron-path path to Electron executable
-l, --loader [ FILE | PATTERN ] create a loader file and optionally define
loader filename or pattern using % as
filename replacer
defaults to %.loader.js
--no-loader do not create a loader file, conflicts
with -l
-t, --loader-type type create a loader file of type commonjs or
module. Defaults to CommonJS
Examples:
$ bytenode -c script.js compile script.js to script.jsc.
$ bytenode -c server.js app.js
$ bytenode -c src/*.js compile all .js files in src/ directory.
$ bytenode -c *.js -l %.load.js create filename.load.js loader files along side .jsc files
$ bytenode script.jsc [arguments] run script.jsc with arguments.
$ bytenode open Node REPL with bytenode pre-loaded.
Examples:
- Compile
express-server.jstoexpress-server.jsc.
user@machine:~$ bytenode --compile express-server.js
- Run your compiled file
express-server.jsc.
user@machine:~$ bytenode express-server.jsc
Server listening on port 3000
- Compile all
.jsfiles in./appdirectory.
user@machine:~$ bytenode --compile ./app/*.js
- Compile all
.jsfiles in your project.
user@machine:~$ bytenode --compile ./*/.js
Note: you may need to enable globstar option in bash (you should add it to ~/.bashrc):
shopt -s globstar
- Starting from v1.0.0, bytenode can compile from
stdin.
echo 'console.log("Hello");' | bytenode --compile - > hello.jsc
Bytenode API
const bytenode = require("bytenode");
bytenode.compileCode(javascriptCode) → {Buffer}
Generates v8 bytecode buffer.
- Parameters:
- Returns:
- Example:
let helloWorldBytecode = bytenode.compileCode(
console.log('Hello World!');
43; // this will be returned,
);
This helloWorldBytecode bytecode can be saved to a file. However, if you want to use your code as a module (i.e. if your file has some exports), you have to compile it using bytenode.compileFile({compileAsModule: true}), or wrap your code manually, using Module.wrap() function.
bytenode.compileElectronCode(javascriptCode, options) → {Promise\}
Asynchronous function which generates v8 bytecode buffer for Electron.
Same as bytenode.compileCode(), but generates bytecode for the version of Electron currently installed in node_modules.
- Parameters:
- Returns:
- Example:
let helloWorldBytecode = await bytenode.compileElectronCode(
console.log('Hello World!');
43; // this will be returned,
);
This helloWorldBytecode bytecode can be saved to a file. However, if you want to use your code as a module (i.e. if your file has some exports), you have to compile it using bytenode.compileFile({compileAsModule: true}), or wrap your code manually, using Module.wrap() function.
bytenode.compileElectronMainCode(javascriptCode, options) → {Promise\}
Asynchronous function which generates v8 bytecode buffer by compiling inside an actual Electron main process (NOT ELECTRON_RUN_AS_NODE).
Use this instead of compileElectronCode() when the .jsc will be loaded from the Electron main process on Electron ≥ 42 (V8 ≥ 14.8). Because the main process boots V8 from Chromium's v8_context_snapshot, the bytecode's read-only snapshot checksum only matches the runtime when it is produced in the same process type. Compiling via run-as-node there leads to a SIGTRAP crash at load time (see Known Issues).
It briefly launches Electron (no window is created) to compile, then exits.
CI / build environment notes
> Because this spawns the Electron browser binary (not Node), the build machine must be able to run Electron:
> - macOS / Windows runners (e.g. GitHub Actionsmacos-,windows-): work out of the box.
- Linux runners: Electron needs a display, so run the build under a virtual framebuffer, e.g.xvfb-run -a(installxvfband the usual Electron shared libs:libgtk-3-0,libnss3,libasound2, …). Headless containers withoutxvfbwill fail.
- Running as root in a container: handled automatically — the compile launches Electron with --no-sandbox (no renderer is created, so the sandbox is unnecessary).
- The spawned Electron must be the same version that will run the app (it is, when resolved from your project's node_modules), so the read-only snapshot checksum matches.
- Cross-compiling bytecode for another OS/arch is not possible here — the .jsc must be produced by the Electron build for the target platform. Compile each platform on its own runner.
- Parameters:
electron binary resolved from node_modules. |
| options.compress | boolean | If true, compress the output bytecode with Brotli. |
- Returns:
- Example:
let helloWorldBytecode = await bytenode.compileElectronMainCode(
console.log('Hello World!');
43; // this will be returned,
);
bytenode.compileElectronRendererCode(javascriptCode, options) → {Promise\}
Asynchronous function which generates v8 bytecode buffer by compiling inside an Electron renderer process — the preload script of a hidden BrowserWindow.
Use this for code that will be loaded from a preload script or a renderer page on Electron versions where renderer bytecode needs its own snapshot (reported from Electron 43.5.1; see Known Issues). compileElectronCode() and compileElectronMainCode() output is not interchangeable with renderer output on those versions. The output is renderer-specific and should not be loaded in the main process or worker threads.
It briefly launches Electron with a hidden window, using a private temporary --user-data-dir, then exits. The CI notes for compileElectronMainCode() apply (on Linux, run it under xvfb-run).
- Parameters:
electron binary resolved from node_modules. |
| options.compress | boolean | If true, compress the output bytecode with Brotli. |
- Returns:
bytenode.runBytecode(bytecodeBuffer) → {any}
Runs v8 bytecode buffer and returns the result.
- Parameters:
- Returns:
- Example:
const result = bytenode.runBytecode(helloWorldBytecode);
// prints: Hello World!
console.log(result);
// prints: 43
bytenode.compileFile(args, output) → {Promise\}
Asynchronous function which compiles JavaScript file to .jsc file.
- Parameters:
ELECTRON_RUN_AS_NODE. Default: false. |
| args.electronMain | boolean | If true, compile inside an actual Electron main process instead of ELECTRON_RUN_AS_NODE. Required for Electron ≥ 42 (V8 ≥ 14.8) when the .jsc is loaded from the main process. See Known Issues. Default: false. |
| args.electronRenderer | boolean | If true, compile inside an Electron renderer (a hidden window's preload). Use for renderer/preload bytecode on Electron versions that require the renderer snapshot (reported from 43.5.1). Cannot be combined with electronMain. Default: false. |
| args.electronPath | string | Path to Electron executable. Default: Electron binary from node_modules. |
| args.output | string | The output filename. Defaults to the same path and name of the original file, but with .jsc extension. |
| args.sourceMap | boolean | If true, write and map runtime error stack traces back to the source file, or to the original sources when the input has its own source map. Default: false. |
| args.createLoader | boolean \| string | If true, create a CommonJS loader file. As a string, select between module or commonjs loader. Default: false |
| args.loaderFilename | string | Filename or pattern for generated loader files. Defaults to originalFilename.loader.js. Use % as a substitute for originalFilename. |
| output | string | The output filename. (Deprecated: use args.output instead) |
- Returns:
- Examples:
let compiledFilename = bytenode.compileFile({
filename: "/path/to/your/file.js",
sourceMap: true,
output: "/path/to/compiled/file.jsc", // if omitted, it defaults to '/path/to/your/file.jsc'
});
When sourceMap is enabled, Bytenode writes a separate standard source map at
(for example, file.jsc.map). It does not include source text in
the map. Keep this file next to the .jsc while debugging so Bytenode can map
runtime error stacks; delete it before shipping if you do not want to distribute
debugging metadata. The CLI equivalent is bytenode --compile --source-map file.js.
If the input already has a source map, for example because a bundler such as
Vite, esbuild, webpack or Rollup produced it, Bytenode rebases that map instead,
so stack traces point at the original sources. It looks for the input's
//# sourceMappingURL= comment (a file or an inline base64 data URL), then for
.map. The input map's sources, sourcesContent, names and
debugId are kept, with sources rewritten relative to the output map.
To symbolicate errors in a service such as Sentry, upload and do
not ship it. A frame from a .jsc is reported on line 1, at the character
offset into the compiled code, which is the coordinate space of the map. A
preserved debugId lets debug-ID based symbolication work without a release
name. Some services also require the generated source beside the map; the
.jsc contains no source, so upload a copy of the input file with every line
break replaced by a space, wrapped in Module.wrap() when compileAsModule is
true.
Previous code will produce a commonjs module that can be required using require function.
let compiledFilename = await bytenode.compileFile({
filename: "/path/to/your/file.js",
output: "/path/to/compiled/file.jsc",
compileAsModule: false,
});
Previous code will produce a direct .jsc file, that can be run using bytenode.runBytecodeFile() function. It can NOT be required as a module. Please note that compileAsModule MUST be false in order to turn it off. Any other values (including: null, "", etc) will be treated as true. (It had to be done this way in order to keep the old code valid.)
For Electron apps, compile through Electron so the bytecode matches Electron's V8. On Electron ≥ 42 (V8 ≥ 14.8) you must compile in the main process:
let compiledFilename = await bytenode.compileFile({
filename: "/path/to/your/main.js",
output: "/path/to/compiled/main.jsc",
electronMain: true, // Electron main-process compile (Electron >= 42 / V8 >= 14.8)
// electron: true // legacy ELECTRON_RUN_AS_NODE compile (Electron <= 41)
});
// Preload scripts and renderer pages when renderer-specific bytecode is needed:
await bytenode.compileFile({
filename: "/path/to/your/preload.js",
output: "/path/to/compiled/preload.jsc",
electronRenderer: true,
});
bytenode.runBytecodeFile(filename) → {any}
Runs .jsc file and returns the result.
- Parameters:
- Returns:
- Example:
// test.js
console.log("Hello World!");
43; // this will be returned
const result = bytenode.runBytecodeFile("/path/to/test.jsc");
// prints: Hello World!
console.log(result);
// prints: 43
require(filename) → {any}
- Parameters:
- Returns:
- Example:
let myModule = require("/path/to/your/file.jsc");
Just like regular .js modules. You can also omit the extension .jsc.
.jsc file must have been compiled using bytenode.compileFile(), or have been wrapped inside Module.wrap() function. Otherwise it won't work as a module and it can NOT be required.
Please note .jsc files must run with the same Node.js version that was used to compile it (using same architecture of course). Also, .jsc files are CPU-agnostic. However, you should run your tests before and after deployment, because V8 sanity checks include some checks related to CPU supported features, so this may cause errors in some rare cases.
Debugging
Set the BYTENODE_DEBUG=1 environment variable to print the V8 cached-data header when a .jsc is loaded. It dumps, field by field, the on-disk bytecode header alongside a dummy compiled in the current runtime process (i.e. what V8 expects), so you can see exactly which field is mismatched:
BYTENODE_DEBUG=1 ./your-electron-app
[bytenode] runtime: node v24.16.0, electron 42.4.1, v8 14.8.178.31-electron.0
[bytenode] on-disk .jsc header (before fixBytecode) ...
versionHash @4 = 0x995ac8a1 <- must match runtime
flagHash @12 = 0x24360e99 <- must match runtime (V8 flags)
roChecksum @16 = 0x2c3e475a <- must match runtime (read-only snapshot)
[bytenode] runtime dummy header (what V8 expects) ...
[bytenode] vm.Script created, cachedData accepted.
If versionHash, flagHash or roChecksum differ from the runtime dummy, the cached data is incompatible. A mismatched roChecksum specifically indicates the .jsc was compiled in a different process type than the one loading it (see Known Issues).
Acknowledgements
I had the idea of this tool many years ago. However, I finally decided to implement it after seeing this issue by @hashseed. Also, some parts were inspired by v8-compile-cache by @zertosh.