fate-studio: Seeing and Driving Statecharts in the Browser
A viewer and simulator for fate statecharts. Open a descriptor snapshot from disk and it redraws as the file changes; drive a live machine over Server-Sent Events, even one running inside another service. One static binary, and the page makes no external request.
If fate is the engine, fate-studio is where you watch it run. Open a machine and its diagram appears; then step through it (send events, fire delayed transitions, resolve or reject invocations) and the active state updates live on screen.
When this post was first written, that was all it did, and a machine could only be seen if it was registered in Go code, inside a program that linked the studio. The September work is about removing that requirement.
Why it’s a separate repo
This is the question it gets most, and the answer is one sentence: the engine has no dependencies, and the studio needs a web server.
In one repo, go get github.com/arisros/fate would drag the whole studio into the program of someone who only wanted a state machine. Splitting them means people pay only for what they use. The studio’s go.mod has exactly one requirement: fate v0.5.1, vendored, so the Docker build downloads nothing.
Snapshots: a chart without a runtime
A machine can now reach the studio as a file. fate/snapshot writes a machine’s descriptor as JSON, and the studio renders it with nothing running behind it:
go run ./cmd/fate-studio --snapshots ./.fate --watch
With --watch, the studio polls the directory every 500 ms and redraws a chart when its file changes, pushing a graph-changed event to the page over a server-wide SSE stream. It polls rather than using OS file notifications, because a notification library would be the studio’s first dependency. A half-written file fails to parse and the last good chart stays on screen, so saving from an editor never blanks the page.
With no flags at all, the binary loads ./.fate if that directory exists and falls back to the built-in demos if it does not.
Simulating a machine that lives somewhere else
A snapshot can be looked at but not driven, since there is no engine behind it. fate v0.5.0 added fate/httphandler, which exposes a machine as an HTTP simulator with the same wire format the studio already speaks. So the studio can now proxy a snapshot’s simulator to wherever the real machine runs:
{
"snapshots": ["./.fate"],
"watch": true,
"proxyURLs": { "checkout": "http://localhost:8081/fsm/checkout" }
}
The same mapping can come from the environment: FATE_PROXY_CHECKOUT, with the machine name upper-cased and everything that is not a letter or digit turned into _. Cookies the remote sets are narrowed to the studio’s own path, and if the remote is down the simulator answers with a 502 that names the machine instead of hanging.
A virtual simulator for charts with no backend
For a snapshot with no proxy at all, there is now a “virtual sim” button on every machine page. It steps through the chart in the browser using only the graph. It cannot run guards, because guards are Go functions, so when an event has several possible targets it stops and asks you which branch to take.
That is where fate v0.5.0’s guard metadata earns its place. A guard described with fate.Gates(fate.Field("$.approvals").Gte(1)) shows up in the inspector as a readable condition, and the virtual simulator checks it against a mock context you type in, marking each gate open, closed or unknown. Per-state view models show up the same way, next to the JSON Schema they were derived from.
It still embeds into your own program
The studio is an ordinary http.Handler, so it can be mounted inside a running application:
srv := studio.NewServer("my app")
srv.SetBasePath("/studio/")
srv.Register(studio.Entry{
Name: "checkout",
Summary: "the checkout flow",
Build: checkoutMachine().Describe,
BuildLive: func() studio.LiveInstance {
return studio.NewLiveActor(checkoutMachine(), dispatch, checkoutMachine().Describe)
},
})
http.Handle("/studio/", http.StripPrefix("/studio", srv.Handler()))
SetBasePath is new and is required when mounting under a prefix. Before it existed, the README already showed mounting under http.StripPrefix, but the page asked for its bundle and its API at the site root, so a mounted studio rendered blank. The server now rewrites the page’s <base> and asset paths to the prefix, scopes the session cookie to it, and an end-to-end test serves the demos under /studio/ to keep it that way.
The key type is still LiveInstance. fate is generic, Actor[Ctx, Evt], while the simulator has to drive any machine without knowing its concrete types. LiveInstance erases them, and the dispatch function you supply maps an event name from the UI onto one of your typed events. A machine registered with only Build shows its diagram without the live simulator.
Canvas and transport
The diagram is drawn with React Flow (@xyflow/react), laid out by elkjs, and its edges are now routed with libavoid, shipped as a WebAssembly file inside the binary. State changes travel from server to browser over Server-Sent Events rather than WebSockets, because the data really does flow one way; user events go back as ordinary requests. A timeline records every step, with undo, import and export.
One binary
The built UI (Vite output) is committed into assets/ and embedded in the Go binary. What gets deployed is a single file: no Node, no CDN, no external font request. The only outbound traffic the server ever makes is to a simulator you configured it to proxy.
go run ./cmd/fate-studio --demos # then open http://localhost:8090
The address comes from --addr or FATE_STUDIO_ADDR. The Docker image builds with CGO_ENABLED=0 onto a distroless, non-root base.
Demos as the test suite
There are nine demo machines, and each exists to cover a feature: traffic-light and pipeline for transitions and final states, media-player for parallel regions, editor for deep history, counter for live context, timeout for delayed transitions, fetch for invocations, order for parallel lanes with a view model per lane, and ticket for named and gated guards.
They are also the UI’s fixtures. make fixtures writes each demo out as a snapshot and as the graph the UI tests read, and go test fails while those files are stale, so a demo cannot drift from what the tests believe it looks like.