Profile
Back to NewsBack
GitHub Trending 14 min
Reader Mode
bytenode/bytenode: A minimalist bytecode compiler for Node.js

bytenode/bytenode: A minimalist bytecode compiler for Node.js

15 hours ago

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.toString function will break, because Bytenode removes the source code from .jsc files 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 V8 inspects them internally using Function.prototype.toString in these cases. See #157.
  • Electron ≥ 42 (V8 ≥ 14.8): bytecode compiled via ELECTRON_RUN_AS_NODE crashes the main process with SIGTRAP. V8's serialized bytecode header carries a _read-only snapshot checksum_ (header offset 16). The Electron main/browser process boots V8 from Chromium's v8_context_snapshot, whose read-only heap differs from the Node default snapshot used by ELECTRON_RUN_AS_NODE. So a .jsc produced with compileElectronCode() (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 — use compileElectronMainCode() or compileFile({ electronMain: true }), which compile inside an actual Electron main process. (On Electron ≤ 41 / V8 ≤ 14.6 the two checksums happened to be compatible, so electron: true worked 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: true or electronMain: true was rejected by renderer preloads/pages, while renderer-compiled bytecode loaded in both. Use compileElectronRendererCode() or compileFile({ 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.
| Loaded in | Electron 43.4.1 | Electron 43.5.1, 43.7.3, 44.4.3 | | ---------------------- | -------------------------------- | ------------------------------- | | main, worker_threads | electronMain | electronMain or electron | | utilityProcess | electron | electron or electronMain | | preload, renderer page | electron or electronRenderer | electronRenderer only |

Resources


Examples

generate a loader, and require its exports. process in Electron and run it through the generated loader.

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.js to express-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 .js files in ./app directory.
user@machine:~$ bytenode --compile ./app/*.js
  • Compile all .js files 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:
| Name | Type | Description | | -------------- | ------ | ---------------------------------------------------- | | javascriptCode | string | JavaScript source that will be compiled to bytecode. |
  • Returns:
{Buffer} The generated bytecode.
  • 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:
| Name | Type | Description | | -------------------- | ------ | ---------------------------------------------------- | | javascriptCode | string | JavaScript source that will be compiled to bytecode. | | options | object | Options object. | | options.electronPath | string | Path to Electron executable. |
  • Returns:
{Promise\} A Promise which resolves with the generated bytecode.
  • 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 Actions macos-, 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 (install xvfb and the usual Electron shared libs: libgtk-3-0, libnss3, libasound2, …). Headless containers without xvfb will 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:
| Name | Type | Description | | -------------------- | ------- | ------------------------------------------------------------------------------------------ | | javascriptCode | string | JavaScript source that will be compiled to bytecode. | | options | object | Options object. | | options.electronPath | string | Path to Electron executable. Defaults to the electron binary resolved from node_modules. | | options.compress | boolean | If true, compress the output bytecode with Brotli. |
  • Returns:
{Promise\} A Promise which resolves with the generated bytecode.
  • 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:
| Name | Type | Description | | -------------------- | ------- | ------------------------------------------------------------------------------------------ | | javascriptCode | string | JavaScript source that will be compiled to bytecode. | | options | object | Options object. | | options.electronPath | string | Path to Electron executable. Defaults to the electron binary resolved from node_modules. | | options.compress | boolean | If true, compress the output bytecode with Brotli. |
  • Returns:
{Promise\} A Promise which resolves with the generated bytecode.

bytenode.runBytecode(bytecodeBuffer) → {any}

Runs v8 bytecode buffer and returns the result.

  • Parameters:
| Name | Type | Description | | -------------- | ------ | -------------------------------------------------------------- | | bytecodeBuffer | Buffer | The buffer object that was created using compileCode function. |
  • Returns:
{any} The result of the very last statement executed in the script.
  • 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:
| Name | Type | Description | | --------------------- | ----------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | args | object \| string | | args.filename | string | The JavaScript source file that will be compiled. | | args.compileAsModule | boolean | If true, the output will be a commonjs module. Default: true. | | args.electron | boolean | If true, the output will be compiled through Electron via 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 .map 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:
{Promise\}: A Promise that resolves as the compiled filename.
  • 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 .map (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 .map 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:
| Name | Type | | -------- | ------ | | filename | string |
  • Returns:
{any} The result of the very last statement executed in the script.
  • 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:
| Name | Type | | -------- | ------ | | filename | string |
  • Returns:
{any} exported module content
  • 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.

Chat with me