Vintage Machine Works

Writing Machines

State, actions, config, and lifecycle hooks.

The BaseMachine class

Every machine extends BaseMachine from @vintage/core. The class handles state management, event broadcasting, and action registration — you only need to define the shape of your machine.

import { BaseMachine } from "@vintage/core";

class MyMachine extends BaseMachine<MyState> {
  __NAME = "my-machine";
  protected initialState: MyState = { ... };
  actions = { ... };
}

export default MyMachine;

__NAME is the machine's identifier. It's used as the URL path segment for the REST API and WebSocket messages.


State

State is a plain TypeScript object. All keys must be JSON-serializable by default.

interface SensorState {
	temperature: number
	humidity: number
	status: "idle" | "reading" | "error"
}

Use updateState() to apply a partial update. It merges the patch and broadcasts the new state to all connected clients:

this.updateState({ temperature: 23.5 })

Private state

Keys prefixed with _ are stripped before broadcasting. Use them to hold non-serializable values like hardware handles, timers, or open connections:

interface SensorState {
	temperature: number
	_serialPort: SerialPort // never sent over the wire
}

Actions

Actions are the public interface of your machine — they appear in the web UI and are callable via the REST API.

No-argument actions

Use createNoArgAction for actions that take no input:

actions = {
	read: this.createNoArgAction(async () => {
		const value = await this.readSensor()
		this.updateState({ temperature: value })
	}, "Trigger a sensor read"),
}

Actions with input

Use createAction with a Zod schema to validate input. The schema is also used to render form controls in the UI:

import { z } from "zod"

actions = {
	setThreshold: this.createAction(
		z.object({ value: z.number().min(-50).max(150) }),
		({ value }) => {
			this.updateState({ threshold: value })
		},
		"Set the alert threshold (°C)",
	),
}

Configuration

Machines can accept external configuration — useful for hardware pin numbers, connection strings, or per-instance settings.

Define a Zod schema as a static property:

import { z } from "zod"

class SensorMachine extends BaseMachine<SensorState, {}, SensorConfig> {
	__NAME = "sensor"

	static configSchema = z.object({
		pin: z.number(),
		pollInterval: z.number().default(1000),
	})
}

Config is validated at startup and accessible via this.config:

async init() {
  this._poller = setInterval(() => this.readSensor(), this.config.pollInterval);
}

Pass config at startup:

vintage serve sensor.ts --config config.json

Lifecycle hooks

init()

Called once when the machine starts. Use it to open connections, start timers, or run homing sequences:

async init() {
  this._port = await SerialPort.open(this.config.portPath);
  this.emitInfo("Serial port open");
}

kill()

Called when the machine shuts down. Clean up resources here:

async kill() {
  clearInterval(this._poller);
  await this._port.close();
}

Emitting events

Beyond state updates, machines can emit informational messages, warnings, and errors. These are broadcast over the WebSocket and visible in the web UI:

this.emitInfo("Sensor calibrated")
this.emitWarn("Temperature approaching threshold")
this.emitError("Sensor read timeout")

Full example

import { BaseMachine } from "@vintage/core"
import { z } from "zod"

interface SensorState {
	temperature: number
	threshold: number
	_poller: ReturnType<typeof setInterval> | null
}

type SensorConfig = { pollInterval: number }

class TemperatureSensor extends BaseMachine<SensorState, {}, SensorConfig> {
	__NAME = "temperature-sensor"

	static configSchema = z.object({
		pollInterval: z.number().default(2000),
	})

	protected initialState: SensorState = {
		temperature: 0,
		threshold: 80,
		_poller: null,
	}

	async init() {
		this.state._poller = setInterval(
			() => this.poll(),
			this.config.pollInterval,
		)
	}

	async kill() {
		if (this.state._poller) clearInterval(this.state._poller)
	}

	private async poll() {
		const temp = await readThermocoupleI2C()
		this.updateState({ temperature: temp })
		if (temp > this.state.threshold) {
			this.emitError(`Temperature exceeded threshold: ${temp}°C`)
		}
	}

	actions = {
		setThreshold: this.createAction(
			z.object({ value: z.number() }),
			({ value }) => this.updateState({ threshold: value }),
			"Set alert threshold (°C)",
		),
	}
}

export default TemperatureSensor

On this page