@systemfsoftware/effect-microsandbox
v5.0.0
Published
Effect-native microVM integration testing via microsandbox — pure-data container specs, Scope/Layer lifecycle, Schedule-driven waits, zero Docker.
Maintainers
Readme
@systemfsoftware/effect-microsandbox
Effect-native integration-test containers backed by hardware-isolated microVMs. You declare the container as pure data, the library acquires it inside an Effect Scope, and teardown is a finalizer — no Docker daemon, no beforeAll/afterAll, no orphaned guests.
Built on the microsandbox napi SDK, which bundles the msb runtime and libkrunfw per platform. Every microVM runs on real virtualization: KVM on Linux, Hypervisor.framework on Apple Silicon, WHP on Windows. If the host has none, startup fails fast with a typed error that names the fix.
Quick Start
pnpm add @systemfsoftware/effect-microsandboximport { NodeRuntime } from '@effect/platform-node'
import { layer as nodeServicesLayer } from '@effect/platform-node/NodeServices'
import { MicroVM } from '@systemfsoftware/effect-microsandbox'
import { Readiness } from '@systemfsoftware/effect-readiness'
import { Effect, HashMap, Layer } from 'effect'
const alpine = MicroVM.spec('alpine:3.20')
.withExposedPorts([6379])
.withWaitStrategy(MicroVM.Wait.forPort(6379))
const program = Effect.scoped(
Effect.gen(function*() {
const vm = yield* alpine.scoped
const out = yield* vm.exec('echo', ['hello'])
console.log(out.stdout) // hello
console.log(HashMap.get(vm.mappedPorts, 6379)) // Option.some(<free 127.0.0.1 port>)
}),
)
NodeRuntime.runMain(Effect.provide(program, Layer.merge(nodeServicesLayer, Readiness.NodeHostProber.layer)))When the scope closes — normally or through interruption — the sandbox is stopped, destroyed, and its record removed. There is nothing to clean up by hand.
Architecture
Why MicroVMs
Shared-kernel containers leak state between tests and require a local Docker socket. Process-level fakes drift from real service semantics. A microVM gives each test an isolated hardware kernel with container-like ergonomics: standard image references, port publishing, and execution boundaries governed entirely by an Effect Scope.
Layer & Scoped Execution
A configured container specification (MicroVM.spec(...)) directly exposes .scoped (to acquire inside an Effect.scoped block) and .layer (to provide as a testcontainer Layer). In accordance with compound-packs/cell-architecture, platform dependencies (Crypto, FileSystem, and the readiness HostProber) propagate to R — .scoped and .run carry them in the effect's requirements, and .layer carries them in the layer's input requirements — and are satisfied once at your application or test composition root (such as @effect/platform-node/NodeServices plus Readiness.NodeHostProber.layer).
Specifying Containers
Containers are configured through a lawful staged builder. You start with a mandatory image identity via MicroVM.spec(image) and chain combinators before executing:
import { MicroVM } from '@systemfsoftware/effect-microsandbox'
const redis = MicroVM.spec('redis:7-alpine')
.withExposedPorts([6379])
.withMemoryLimit(512)
.withWaitStrategy(MicroVM.Wait.forPort(6379))
// Acquire dynamically in a test scope:
const vm = yield* redis.scoped
// Or provide as a Layer (its input asks for the readiness prober):
const RedisLive = Layer.provide(redis.layer, Readiness.NodeHostProber.layer)Spec Combinators
| Combinator | Description |
| -------------------------- | --------------------------------------------------------- |
| .withEnv(env) | Merges environment variables |
| .withExposedPorts(ports) | Replaces exposed guest ports (services only) |
| .withMount(mount) | Appends a host bind mount |
| .withMemoryLimit(mb) | Sets memory limit in MiB |
| .withWaitStrategy(s) | Sets readiness wait strategy (services only) |
| .withHostAccess(enabled) | Lets a job reach host services (jobs only) |
| .withWorkdir(path) | Sets the working directory of a job's command (jobs only) |
Readiness Strategies
VM startup does not complete until the specified wait strategy passes (30-second budget before raising WaitTimeoutError):
- Port Probe:
MicroVM.Wait.forPort(guestPort)dials the mapped host loopback port. - HTTP Probe:
MicroVM.Wait.forHttp(path, guestPort)issues aGETrequest and checks for a2xxstatus. - Log Pattern:
MicroVM.Wait.forLog(pattern)polls the guest log stream for a matching regex.
One-shot Jobs
MicroVM.job(image, cmd) declares a VM whose only purpose is to run cmd once. Its .run boots the VM, runs the command as the image's default workload, waits for it to end, and returns how it ended. The VM is torn down when the enclosing scope closes, whether the job succeeded, failed, or was interrupted.
import { NodeRuntime } from '@effect/platform-node'
import { layer as nodeServicesLayer } from '@effect/platform-node/NodeServices'
import { MicroVM } from '@systemfsoftware/effect-microsandbox'
import { Readiness } from '@systemfsoftware/effect-readiness'
import { Effect, Layer, Match } from 'effect'
const probe = MicroVM.job('alpine:3.20', ['wget', '-T', '5', '-qO-', 'http://host.microsandbox.internal:4318/health'])
.withHostAccess(true)
.withWorkdir('/tmp')
const program = Effect.scoped(
Effect.gen(function*() {
const completion = yield* probe.run
const verdict = Match.value(completion.status).pipe(
Match.tag('JobExited', ({ code }) => `exited with ${code}`),
Match.tag('JobSignaled', () => 'killed by a signal'),
Match.exhaustive,
)
console.log(verdict, new TextDecoder().decode(completion.stdout))
}),
)
NodeRuntime.runMain(Effect.provide(program, Layer.merge(nodeServicesLayer, Readiness.NodeHostProber.layer)))A JobCompletion holds:
status:JobExitedwith the exit code, orJobSignaledwhen a signal ended the workload. A non-zero exit code is a successful.run; judging it is up to you.JobSignaledcarries no signal number, because the runtime reports every signal death the same way.stdoutandstderr: the complete output as bytes (Uint8Array), exactly as the workload wrote it..runholds all of it in memory until the workload ends.
.run fails with ExecError only when the workload could not be started or its result could not be collected. It waits as long as the workload runs; for a deadline, wrap it in Effect.timeout. The VM is torn down when the enclosing scope closes.
Host Access
A job cannot reach the host by default. .withHostAccess(true) opens the host to the guest: every service listening on the host, including one bound to 127.0.0.1, becomes reachable at host.microsandbox.internal. Public internet access stays on. Opt in only for jobs whose command you trust with every host service.
Runtime Behaviour
Automatic Port Allocation
Exposed guest ports are automatically paired with unallocated ephemeral ports on 127.0.0.1 prior to boot. Port mappings are accessible via vm.mappedPorts (HashMap<number, number>). Any rendered configuration targeting non-loopback interfaces fails immediately with LoopbackViolationError.
The RunningVM Handle
RunningVM provides safe, typed primitives to control the active microVM:
exec(cmd, args): Executes commands as argv lists (no shell injection vulnerabilities); resolves with{ code, stdout, stderr }.logs: Returns an EffectStreamof structured log entries (source,text).ping: Returns an Effect resolving totrueif the guest agent responds.mappedPorts: Read-onlyHashMapof guest-to-host port bindings.name: Unique sandbox identifier (effect-microsandbox-<pid>-<suffix>).
Failure Model
All operational failures are returned as typed errors in the MicroVMError.MicroVMError union:
| Error | Cause |
| -------------------------------- | -------------------------------------------------------------------------------------------------- |
| VirtualizationUnsupportedError | Missing KVM (/dev/kvm), Hypervisor.framework, or WHP with specific diagnostic remediation |
| SandboxBootError | Runtime failed to initialize or start the microVM sandbox |
| PortAllocationError | Unable to bind a free host loopback port |
| LoopbackViolationError | Port mapping targeted a disallowed non-loopback address |
| WaitTimeoutError | Readiness condition was not satisfied within the 30-second deadline |
| ExecError | An in-guest command or a job's workload could not be started, or its result could not be collected |
Verification
System Requirements
- Effect: v4 (
effectcatalog dependency). - Hardware Virtualization:
- Linux: KVM (
/dev/kvmaccessible with read/write permissions). - macOS: Apple Silicon with Hypervisor.framework.
- Windows: Windows Hypervisor Platform (WHP).
- Linux: KVM (
Smoke Journey
To verify end-to-end integration on a host with virtualization support:
pnpm --filter @systemfsoftware/effect-microsandbox smokeThe smoke journey boots Alpine Linux, executes an in-guest command, tests port mapping, confirms resource cleanup on interruption, and exercises layer reuse across sequential VM runs. Its job journeys check exit codes, signals, byte-exact output, host access with and without opt-in, and the working directory. On a host without virtualization it exits non-zero with VirtualizationUnsupportedError.
License
Licensed under the Apache-2.0 License.
