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!')");
}
}