LogoFreestyle

Composing VMs (VmWith)

What is VmWith?

VmWith is a composable way to add dependencies and helper classes to VMs. They are able to provide any configuration you can otherwise provide when creating a VM. Some common use cases for this are adding support for languages.

import { freestyle } from "freestyle-sandboxes";
import { VmNodeJs } from "freestyle-with-nodejs";
import { VmPython } from "freestyle-with-python";

const { vm } = await freestyle.vms.create({
  with: {
    js: new VmNodeJs(),
    python: new VmPython(),
  },
});

// the properties defined under `with` are now available on the vm class.
await vm.js.run(`console.log("Hello World!")`);

Importantly, these classes encapsulate their own caching logic via templates.

import { freestyle } from "freestyle-sandboxes";
import { VmNodeJs } from "freestyle-with-nodejs";

// first time is slow because this configuration has never been created and cached before
const { vm } = await freestyle.vms.create({
  with: {
    js: new VmNodeJs({ nodeVersion: "20"}),
  },
});

// second time is fast because the configuration has been cached
const { vm: vm2 } = await freestyle.vms.create({
    with: {
      js: new VmNodeJs({ nodeVersion: "20" }),
    },
});

How it works

VmWith uses a builder pattern to compose VM configurations like middleware. Each VmWith class has two main responsibilities:

1. Configuration (configure method)

The configure method transforms VM configuration by merging your component's settings with existing configuration. It receives the current CreateVmOptions and returns a modified version with your changes applied.

A common pattern is to put most of your configuration inside VmTemplate so that it will be cached next time the same configuration is used. For example with NodeJs, we can add run an installation script via systemd inside the template. For more details on caching, see the Templates and Snapshots.

class VmNodeJs extends VmWith<NodeJsRuntimeInstance> {
  configure(existingConfig: CreateVmOptions): CreateVmOptions {
    const nodeJsConfig: CreateVmOptions = {
      template: new VmTemplate({
        additionalFiles: {
          "/opt/install-nodejs.sh": { content: installScript },
          "/etc/profile.d/nvm.sh": { content: nvmInit },
        },
        systemd: {
          services: [
            {
              name: "install-nodejs",
              mode: "oneshot",
              exec: ["bash /opt/install-nodejs.sh"],
            },
          ],
        },
      }),
    };
    
    // Merge with existing config using compose helper
    return this.compose(existingConfig, nodeJsConfig);
  }
}

The compose method semantically merges configurations:

  • Simple fields (like workdir, idleTimeoutSeconds) - last value wins
  • Arrays (like ports, users, groups) - concatenated and deduplicated by key
  • Objects (like additionalFiles) - merged with last value winning per key
  • Nested arrays (like systemd.services) - merged by name with last value winning

2. Runtime Instance

The createInstance method returns an instance that will be attached to the VM, providing convenient interaction methods. This instance has access to the VM via the vm property.

class NodeJsRuntimeInstance extends VmWithInstance {
  async exec(script: string) {
    return await this.vm.exec({
      command: `npm run '${script}'`,
    });
  }
}

class VmNodeJs extends VmWith<NodeJsRuntimeInstance> {
  createInstance(): NodeJsRuntimeInstance {
    return new NodeJsRuntimeInstance();
  }
}

Dependencies and Generics

If you're building a VmWith that depends on another VmWith, we recommend requiring it in the constructor. For example, a VmDevServer depends on the generic VmJavaScriptRuntime so that users choose between NodeJs, Deno, Bun, etc.

const js = new VmNodeJs();
const devServer = new VmDevServer({
      placeholderRepo: "https://github.com/example/placeholder",
      repo: "https://github.com/example/app",
      runtime: js,
});

const { vm } = await freestyle.vms.create({
  with: {
    js,
    devServer,
  },
});

Internally, VmDevServer can then use the runtime's helper methods to configure it's own installation correctly.

export class VmDevServer extends VmWith {
  constructor(private options: { runtime: VmJavaScriptRuntime }) { super() }
  
  configure(existingConfig: CreateVmOptions): CreateVmOptions {
    const devServerConfig: CreateVmOptions = {
      template: new VmTemplate({
        systemd: {
          services: [
            {
              name: "dev-server-install-dependencies",
              mode: "oneshot",
              // use the install command provided by the runtime
              exec: [this.options.runtime.installCommand()],
              // ensure we don't run npm install before the runtime is installed
              after: [ this.options.runtime.installServiceName(),],
              // ...
            },
            // ...
          ],
        },
      }),
    };

    return this.compose(existingConfig, devServerConfig);
  }
  // ...
}

If you need to access a dependencies' instance, you can do so using the .instance property. This will only be available after the VM has been created, so you must read it inside a VmWithInstance.

class VmDevServer extends VmWith {
  // ...

  createInstance(): VmDevServerInstance {
    return new VmDevServerInstance(
      this.options.runtime
    );
  }
}

class VmDevServerInstance extends VmWithInstance {
  constructor(public runtime: VmJavaScriptRuntime) {
    super();
  }

  helloWorld() {
    return this.runtime.instance.runCode("console.log('Hello World!')");
  }
}

On this page

Freestyle AI

Documentation assistant

Experimental: AI responses may not always be accurate—please verify important details with the official documentation.

How can I help?

Ask me about Freestyle while you browse the docs.