All articles
nodejs

Executing JavaScript Inside a Virtual Machine

Share this article

Share on LinkedIn Share on X (formerly Twitter)

As JavaScript developers, our day-to-day usually looks the same: we write code, a bundler transforms it, and an environment (the browser or Node.js) executes it.

But what happens when you need to build the tools themselves? What if you are building the next CodeSandbox, a custom test runner like Vitest, or a dynamic plugin system? You can't just rely on node script.js. You need your JavaScript to compile, link, and safely execute other JavaScript dynamically.

You might think of using eval(), but we all know the old adage: eval is evil. It's insecure, it leaks scope, and worst of all, it has no idea what to do with modern ECMAScript Modules (import/export) or TypeScript.

Today, we're going past the surface level. We are going to look at the primitives that power modern developer tooling: In-Memory Bundling and Node.js Virtual Machines.

Step 1: Ditching the CLI for In-Memory Bundling

Usually, when we think of a bundler like Esbuild, Vite, or Webpack, we think of terminal commands and configuration files. But beneath the CLI, these tools offer powerful programmatic APIs.

Let's say we have some raw code string—maybe submitted by a user on a website—and we need to compile it. Instead of writing it to a temporary file on disk, we can run Esbuild entirely in memory:

import * as esbuild from 'esbuild';
 
const result = await esbuild.build({
  // Provide a string directly instead of a file path
  stdin: {
    contents: `export * from "./another-file"`,
    resolveDir: './src',
    loader: 'ts',
  },
  platform: 'node',
  format: 'esm',
  bundle: true,
  write: false, // 👈 The magic wand
});
 
// The compiled code is right here in memory as a Uint8Array!
const compiledCode = result.outputFiles[0].text;

By setting write: false, esbuild never touches your hard drive. It does all the heavy lifting—transpiling TypeScript, resolving local imports, tree-shaking—and returns a buffer. This is incredibly fast and perfect for on-the-fly execution.

Step 2: The V8 Sandbox (node:vm)

Now we have our compiled code string. How do we run it?

Node.js has a built-in module called vm (Virtual Machine). It allows you to compile and run code within V8 Virtual Machine contexts. This means you can create a sandbox where the executing code has its own global object, isolated from your main application's environment.

import vm from 'node:vm';

If we were running standard CommonJS, we could just use vm.runInNewContext(). But the JavaScript ecosystem has moved to ESM, and evaluating ESM dynamically requires stepping into experimental territory.

Step 3: Taming ESM with SourceTextModule

To run ECMAScript modules in a VM, Node.js provides vm.SourceTextModule. Because ESM handles imports asynchronously and has a static structure, it requires a much more complex lifecycle than standard scripts.

(Note: Because this is cutting-edge, you have to run Node with the --experimental-vm-modules flag to use it).

When we create a SourceTextModule, we have to do a bit of manual labor that the browser usually does for us. For example, what happens if the code we are evaluating tries to use import.meta.url? Since the file doesn't actually exist on disk, we have to fake it!

const module = new vm.SourceTextModule(compiledCode, {
  // We manually intercept and populate the import.meta object
  initializeImportMeta(meta) {
    meta.url = "file:///virtual/test2.js";
  },
});

Step 4: The Missing Link

Before a module can be evaluated, its dependencies must be resolved. In a browser, the network fetches the imports. In our VM, we have to provide a linker function.

Since we already used Esbuild to bundle all our dependencies into a single file in Step 1, we don't actually have any external imports left to resolve! We can just provide a dummy linker that does nothing:

// Link the module (resolve its imports)
await module.link(() => {});
 
// Execute the code!
await module.evaluate();

Putting it all together

When you combine programmatic bundling with VM contexts, you get an incredibly powerful pattern. Here is the entire flow in just about 30 lines of code:

import vm from "node:vm";
import * as esbuild from 'esbuild';
 
// 1. Bundle the code in-memory
let result = await esbuild.build({
  stdin: {
    contents: `
      const message = "Hello from the Sandbox!";
      console.log("Current URL:", import.meta.url);
      console.log(message);
    `,
    loader: 'ts',
  },
  format: 'esm',
  write: false,
  bundle: true
});
 
// 2. Extract the compiled string
const virtualFile = result.outputFiles[0];
 
// 3. Create the Virtual Machine Module
const module = new vm.SourceTextModule(virtualFile.text, {
  initializeImportMeta(meta) {
    meta.url = "file:///sandbox/dynamic-script.js";
  },
});
 
// 4. Link and Evaluate
await module.link(() => {});
await module.evaluate();

Why does this matter?

It’s easy to view JavaScript as just the language we use to build buttons or query databases. But under the hood, the ecosystem is powered by primitives that allow code to manipulate, compile, and execute other code.

By understanding programmatic bundlers and V8 Virtual Machines, you aren't just learning how to use a tool—you are learning how to build the tools. Whether you want to build a secure plugin system for your backend, a live-coding environment on the frontend, or just want to understand what Jest and Vite are doing behind the scenes, mastering these concepts opens up a whole new tier of software engineering.


Comments