Help us shape the future of Go! Take the 2026 Go Developer Survey

vero

package module
v0.17.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Oct 11, 2026 License: MIT Imports: 20 Imported by: 0

README

vero

Go backend with native cross-platform GUI frontends. Build on macOS, run anywhere Go runs.

Run a Go binary embedded in a native (e.g. macOS SwiftUI) app. The Go binary and the native (e.g. SwiftUI) app communicate over a pipe (an OS pipe or io.Pipe depending on the operating system).

Go reference

Ship an app from one Go file · Build and run the macOS example · Create a vero macOS app · Build and run the example on all platforms using your Mac · Taking your app to more platforms, and packaging it

Operating system Example Required on your Mac to build and run Build and run on your Mac
macOS — darwin/amd64, darwin/arm64 Xcode, from the App Store. Run setup-macos.sh to check that Xcode has been installed Builds and runs. SwiftUI, in the menu bar, built by running example/menubar-app/build.sh.

If you already have Xcode and Go installed, the macOS example below builds a running app.
Windows — windows/386, windows/amd64, windows/arm64 qemu, the .NET SDK and a Windows 11 ARM64 ISO, which can be installed by running setup-windows.sh Builds and runs. WPF, started in a Windows VM by running scripts/run-windows.sh, which with --ssh lets scripts into it over SSH (how).
Linux — linux/386, linux/amd64, linux/arm, linux/arm64, linux/loong64, linux/mips, linux/mips64, linux/mips64le, linux/mipsle, linux/ppc64, linux/ppc64le, linux/riscv64, linux/s390x Docker and colima, which can be installed by running setup-linux.sh Builds and runs. GTK4, started in a Docker container by running scripts/run-linux.sh. The same app runs on a Chromebook, once Linux is turned on in Settings → Developers.
FreeBSD — freebsd/386, freebsd/amd64, freebsd/arm, freebsd/arm64 qemu, which can be installed by running setup-freebsd.sh Builds and runs. GTK4, the same app as Linux, NetBSD and OpenBSD, started in a VM by running scripts/run-freebsd.sh.
OpenBSD — openbsd/386, openbsd/amd64, openbsd/arm, openbsd/arm64, openbsd/ppc64, openbsd/riscv64 qemu, which can be installed by running setup-openbsd.sh Builds and runs. GTK4, the same app as Linux, FreeBSD and NetBSD. OpenBSD ships an installer rather than a disk image, so the first run installs it, answering autoinstall(8) from a response file: scripts/run-openbsd.sh.
NetBSD — netbsd/386, netbsd/amd64, netbsd/arm, netbsd/arm64 qemu, which can be installed by running setup-netbsd.sh Builds and runs. GTK4, the same app as Linux, FreeBSD and OpenBSD, started in a VM by running scripts/run-netbsd.sh.
DragonFly — dragonfly/amd64 qemu, which can be installed by running setup-dragonfly.sh Builds and runs. GTK4, the same app as the other BSDs, started in a VM by running scripts/run-dragonfly.sh. DragonFly is x86 only, so that VM is emulated rather than virtualised: a minute to boot, and an hour to install GTK4 the first time.
illumos — illumos/amd64 qemu, which can be installed by running setup-illumos.sh Builds and runs. GTK4, the same app as the BSDs, on OpenIndiana in a VM: scripts/run-illumos.sh --install installs it, which takes about an hour because illumos is x86 only and this Mac emulates that, and run-illumos.sh runs it afterwards.
Android — android/386†, android/amd64†, android/arm†, android/arm64 the Android SDK, a JDK and Kotlin, which can be installed by running setup-android.sh Builds and runs in the emulator, built and started by running scripts/run-android.sh.
iOS — ios/amd64†, ios/arm64 Xcode and a Simulator runtime, which can be installed by running setup-ios.sh Builds and runs in the Simulator, not on a phone. An app may not start a program on iOS, so the worker is compiled into the app and runs on a goroutine. Built and launched by running scripts/run-ios.sh.
wasm, a browser — js/wasm a browser Builds and runs. A page may not start a program, so the worker is compiled into the same wasm as the UI, as on iOS. Built and opened by running scripts/run-web.sh.
wasm, WASI — wasip1/wasm wasmtime, which can be installed by running setup-wasm.sh Builds and runs, as example/wasi-app: your Go program starts wasmtime, which runs worker.wasm. It has to be a Go program, because the Python, C# and Kotlin bindings would ask worker.wasm to start a second copy of itself, which it cannot do in a sandbox. Note too that worker.wasm answers one request at a time, and can do nothing in between.
Plan 9 — plan9/386, plan9/amd64, plan9/arm qemu, which can be installed by running setup-plan9.sh Builds and runs. The same three rows drawn with libdraw in a rio window, started in a VM by running scripts/run-plan9.sh; --test runs vero's test suite there instead. Plan 9 is x86 only, so the VM is emulated. We also had to fix the Go libdraw it uses because it did not build for Plan 9 until 9fans/go#147, so the example pins our fork carrying that fix.
Solaris — solaris/amd64 — — Builds by running scripts/build-all.sh, and does not run. Oracle Solaris needs an Oracle account and licence to download, and runs on x86, which this Mac emulates rather than virtualises. Oracle's repository ships GTK 3, not GTK 4, so the GTK example would not run there as written; the worker and vero's tests would work.
AIX — aix/ppc64 — — Builds by running scripts/build-all.sh, and does not run. There is no public AIX media: IBM provides it only under entitlement, with POWER hardware. qemu can emulate pseries, but there is nothing to boot on qemu.

Ship an app from one Go file

Before you start

You need:

  • a Mac with Apple silicon;
  • Homebrew, from https://brew.sh;
  • Go, with brew install go;
  • Xcode, from the App Store;
  • a Developer ID Application certificate in your keychain, from the Apple Developer Program, and a Developer ID Installer certificate as well if you want a .pkg;
  • for Windows, Microsoft's Windows 11 ARM64 ISO in ~/vm/vero-windows/. Microsoft will not serve it to a script, so download it yourself from https://www.microsoft.com/en-us/software-download/windows11arm64.

Then install vero into Homebrew's folder of programs, which is already on your PATH, and let it set up the rest:

GOBIN=$(brew --prefix)/bin go install github.com/imclaren/vero/cmd/vero@latest
vero setup

vero setup installs the tools that every system needs, such as colima, Docker, qemu, the .NET SDK, makensis and ffmpeg. It makes the notarytool profile named vero, for which it asks for your Apple ID, your team ID and an app-specific password. Then it lists anything still missing.

Write the worker

A worker is a Go program that keeps the app's state and answers its requests. Every window, on every system, shows the state that the worker pushes and sends the requests that it answers. This one counts:

package main

import (
	"flag"

	"github.com/imclaren/vero"
)

var version = "0.1.0"

// Status is what every window shows. The worker pushes it on each change.
type Status struct {
	Count int `json:"count"`
}

// By is what the add request takes.
type By struct {
	N int `json:"n"`
}

func main() {
	var opts vero.WorkerOptions
	opts.Version = version
	opts.RegisterFlags(flag.CommandLine)
	flag.Parse()
	opts.PrintVersionAndExit()

	w := vero.NewWorker(opts)
	state := vero.NewState(w, Status{})
	vero.Update(state, "status", func(*Status) error { return nil })
	vero.UpdateWith(state, "add", func(s *Status, req By) error {
		s.Count += req.N
		return nil
	})
	if err := w.Serve(); err != nil {
		w.Log("stopped: %v", err)
	}
}
Ship it
vero ship counter.go

vero ship makes an app called counter from the file, with a window for the Mac, for Linux, the BSDs and illumos, and for Windows. Before it does anything else, it prints what it will build and test on this Mac, what it will skip and why, and how long each virtual machine takes to make the first time. Then it tests the app on every system it can, records each one, and releases it. It stops at the first failure, and says where its log is.

What you get
The app counter/, a Go module with the worker in cmd/worker, a SwiftUI app in macos/, a GTK app in gtk/, a WPF app in windows/, an icon, and vero-app.toml, which describes it
Installers a signed and notarised .dmg for the Mac, packages for Debian, Fedora, openSUSE, Arch, Alpine, Void, Chimera, FreeBSD, DragonFly, NetBSD, OpenBSD and illumos, and installers for Windows
A site dist/site, plain files to put on any web server, with a page that offers each system its installer, and signed repositories through which every system updates the app
Recordings each system's window as a GIF and a picture, in ~/.cache/vero/test-results/, with one page of every result, which the site's page shows too
What it cannot do
  • It cannot make an app for iOS, Android or the browser by itself, since each of those runs the worker inside the app. vero add ios, vero add android and vero add web write those front ends, and PACKAGING.md says how to release them.
  • It does not choose where the site lives. Put its address in site in vero-app.toml, and vero ship --upload user@host:/path copies it to your server, or copy dist/site there yourself. Until then, the site is made for http://localhost:8080, to look at.
  • It does not sign the Windows installers, which needs a certificate of your own. Until then, Windows asks people once whether to run them, as the site's page explains.
After the first time

Change counter/cmd/worker/main.go, or the windows in counter/macos, counter/gtk and counter/windows, and run vero ship in counter/. Each release takes the next version. Add steps to [test] in vero-app.toml so that each recording shows the app at work. vero ship --plan says what it would do and stops, --no-release tests without releasing, and --only "macos debian" tests on just those systems.

Bringing an existing app to vero

An app whose worker is already a Go program needs the same layout: the worker in cmd/worker of the app's own module, and vero add desktop in the module's folder for the front ends and vero-app.toml. An existing SwiftUI app uses vero's Swift package with the worker bundled in its Resources, as macos/ does, and names its Xcode project in [macos]. Then vero ship works as above. PACKAGING.md covers the settings in full.

Build and run the macOS example

The example is a menu bar app driving the Go worker (main.go below):

git clone https://github.com/imclaren/vero && cd vero
cd example/menubar-app && ./build.sh
./.build/debug/MenuBarExample

Look for the icon in the menu bar. The source is available at example/menubar-app.

Create a vero macOS app

1. Build the go worker

Get vero:

mkdir -p ~/vero-example/macos-app && cd ~/vero-example/macos-app
go mod init macos-app
go get github.com/imclaren/vero

Create main.go (file also available at example/worker/main.go):

// Command worker is the logic half of the example: the part you would write.
//
// The flags come from vero.WorkerOptions: -json puts the event stream on
// stdout, -quiet silences the log, and -version is what the frontend asks
// before replacing the copy of this it has on disk.
package main

import (
	"flag"
	"fmt"
	"math/rand"
	"slices"
	"time"

	"github.com/imclaren/vero"
)

// version has to increase on every release, so the frontend can tell which
// of two copies is newer.
var version = "0.13.0"

type Job struct {
	ID       int    `json:"id"`
	Name     string `json:"name"`
	Phase    string `json:"phase"`
	Progress int    `json:"progress"`
}

// Key is how an id in a request finds one job.
func (j Job) Key() int { return j.ID }

func (j *Job) Restart() { j.Phase, j.Progress = "waiting", 0 }

type Status struct {
	Jobs    []Job  `json:"jobs"`
	Working bool   `json:"working"`
	Since   string `json:"since"`
}

func working(s *Status) {
	s.Working = slices.ContainsFunc(s.Jobs, func(j Job) bool {
		return j.Phase != "waiting" && j.Phase != "done"
	})
}

func main() {
	var opts vero.WorkerOptions
	opts.Version = version
	opts.RegisterFlags(flag.CommandLine)
	flag.Parse()
	opts.PrintVersionAndExit()

	w := vero.NewWorker(opts)

	// The state.  vero watches it and sends it to the frontend whenever its
	// JSON changes, which is how the frontend tracks status without asking.
	// Each handler below replies with it as well.
	state := vero.NewState(w, Status{
		Jobs: []Job{
			{ID: 1, Name: "Photos", Phase: "waiting"},
			{ID: 2, Name: "Documents", Phase: "waiting"},
			{ID: 3, Name: "Team share", Phase: "waiting"},
		},
		Since: time.Now().Format("15:04:05"),
	})

	go work(w, state)

	// Update handles a request with no accompanying data.  "status" returns
	// the state.
	vero.Update(state, "status", func(*Status) error { return nil })

	// UpdateWith handles a request with accompanying data.  "restartJob"
	// takes the id of the job to restart.
	vero.UpdateWith(state, "restartJob", func(s *Status, req vero.ID[int]) error {
		if err := vero.Edit(s.Jobs, req.ID, (*Job).Restart); err != nil {
			return fmt.Errorf("no job with id %d", req.ID)
		}
		working(s)
		return nil
	})

	if err := w.Serve(); err != nil {
		w.Log("stopped: %v", err)
	}
}

// work is the business logic - we have added random state changes to
// demonstrate how this works.
func work(w *vero.Worker, state *vero.State[Status]) {
	phases := []string{"looking for changes", "scanning", "uploading", "done"}
	for {
		time.Sleep(time.Duration(200+rand.Intn(400)) * time.Millisecond)

		var name, phase, before string
		state.Do(func(s *Status) {
			j := &s.Jobs[rand.Intn(len(s.Jobs))]
			before = j.Phase
			switch i := slices.Index(phases, j.Phase); {
			case j.Phase == "waiting" || j.Phase == "done":
				j.Phase, j.Progress = phases[0], 0
			case j.Progress < 100:
				j.Progress = min(j.Progress+20+rand.Intn(30), 100)
			case i+1 < len(phases):
				j.Phase, j.Progress = phases[i+1], 0
			}
			name, phase = j.Name, j.Phase
			working(s)
		})

		if phase != before {
			w.Log("%s: %s", name, phase)
		}
	}
}

Build it for both architectures (old Intel Macs and new Apple Silicon Macs) and join them into one binary, so the app runs on either. lipo comes with Xcode:

GOOS=darwin GOARCH=amd64 go build -o worker-amd64 . && \
GOOS=darwin GOARCH=arm64 go build -o worker-arm64 . && \
lipo -create worker-amd64 worker-arm64 -output worker
2. Create a new macOS SwiftUI Xcode project

In Xcode, File -> New -> Project -> macOS -> App, with Interface set to SwiftUI. Then:

  • File -> Add Package Dependencies... -> https://github.com/imclaren/vero, with the Dependency Rule set to Up to Next Major Version. Before 1.0, Xcode's other rule, Up to Next Minor Version, keeps an app on 0.13 when 0.15 is out, so it never gets what came after.
  • drag ~/vero-example/macos-app/worker into the project, ticking your app under Add to targets
3. Add the app to the project

Create ExampleApp.swift (file also available at example/menubar-app/Sources/MenuBarExample/ExampleApp.swift):

import SwiftUI
import Vero

// What the worker pushes: the same shape as Status and Job in main.go.
struct Job: Decodable, Identifiable {
    let id: Int
    let name: String
    let phase: String
    let progress: Int
}

struct Status: Decodable {
    let jobs: [Job]
    let working: Bool
    let since: String
}

// One of these per handler on the worker. The name routes the request -
// "restartJob" is the vero.UpdateWith above - and Reply says what comes back,
// so nothing has to guess at it.
struct RestartJob: NamedRequest {
    static let name = "restartJob"
    typealias Reply = Status
    let id: Int
}

@main
struct ExampleApp: App {
    // One copy at a time. A second one - opened from Downloads while the one
    // in Applications is running, say - would be refused the worker and sit
    // there with nothing to show, so it brings the first forward and quits.
    init() { VeroSingleCopy.yieldToRunningCopy() }

    // The worker, the last state it pushed, and the reason it could not start
    // if it did not: everything a view needs to draw, created once, here.
    @StateObject private var vero = VeroModel<Status>(
        bundledWorker: "worker", directoryName: "Example/bin")

    var body: some Scene {
        MenuBarExtra {
            VStack(spacing: 10) {
                ForEach(vero.state?.jobs ?? []) { job in
                    HStack(spacing: 10) {
                        // The press. This goes to the "restartJob" handler in
                        // main.go. There is no reply to deal with here,
                        // because the worker pushes the new state, and that
                        // is what redraws the row.
                        Button("Restart") { vero.call(RestartJob(id: job.id)) }
                        Text(job.name)
                        Text(job.phase).foregroundStyle(.secondary)
                        ProgressView(value: Double(job.progress) / 100)
                    }
                }
            }
            .frame(width: 460)
            .padding(16)
        } label: {
            // Pushed as well, so the icon spins while the worker is busy
            // without anything here asking it whether it is.
            Image(systemName: vero.state?.working == true
                  ? "arrow.triangle.2.circlepath"
                  : "checkmark.circle")
        }
        .menuBarExtraStyle(.window)
    }
}

Delete the ContentView.swift and <YourApp>App.swift that Xcode generated: ExampleApp.swift is the @main entry point.

4. Run it

Press Cmd-R in Xcode. The icon appears in the menu bar, and spins while the worker has jobs in flight. Click the menu bar icon to see progress changes. Click the button on a row to restart that job.

Build and run the example on all platforms using your Mac

./scripts/setup.sh installs what every platform needs, with Homebrew. Four things it cannot install for you:

./scripts/doctor.sh says what is there and what is missing, which VMs are made, and how much disk they need. The scripts assume a Mac with Apple silicon.

./scripts/run.sh then builds and opens the example on macOS (natively), and Linux and Windows (by showing the app in VMs) side by side. Each other system has a scripts/run-*.sh of its own, in the table above, which makes its VM the first time.

./scripts/test-all.sh tests the example, or your app with --app vero-app.toml, on every system at once: it installs it from a site it builds, starts it and plays its test steps, records each system as a GIF and a picture, checks that the system's own updates bring the next version, and shows every result on one page.

All targets in bold (e.g. darwin/arm64), other than android/arm64, ios/arm64 and js/wasm, can be built by running ./scripts/build-all.sh. android/arm64, ios/arm64 and js/wasm have their own build scripts because they do not build a standalone worker. To build targets not in bold, specify them when building: TARGETS=plan9/386 ./scripts/build-all.sh.

† targets need a C toolchain: either an NDK for the three Androids that are not arm64, or the Simulator SDK for ios/amd64. The other 43 targets only need Go.

Taking your app to more platforms, and packaging it

vero can add front ends for more platforms to your app: vero add writes a starter for each, wired to your worker, with a checklist of what to change. vero release then builds your app's installers for every system, and a website of plain files that people install from and get updates from: signed repositories for Linux and the BSDs, a disk image and Sparkle updates for macOS, an installer and winget for Windows, and more. A release needs only your Mac, and no account with anyone; vero credentials is the checklist for signing and the stores, when you want them.

Taking your app to more platforms, and packaging it explains both, step by step.

Licence

MIT

Documentation

Overview

Package vero is a Go backend with native macOS, Windows and Linux frontends, all built on macOS. See the README for how to build one.

Index

Constants

View Source
const DefaultStateInterval = 100 * time.Millisecond

DefaultStateInterval is how often WorkerOptions.State is sampled for a change when StateInterval is not set. Fast enough that a progress bar moves smoothly, slow enough that nothing is sent while nothing is happening.

View Source
const MaxLineSize = 16 << 20 // 16 MiB

MaxLineSize caps one protocol line, so a corrupt or hostile stream cannot ask for an unbounded allocation. Payloads larger than this belong in a file with the path sent through the pipe.

Variables

View Source
var ErrAlreadyRunning = errors.New("a worker is already running for this application")

ErrAlreadyRunning is returned when another process is already supervising this worker.

Two copies of an application - the one in /Applications and the one still in ~/Downloads after an update - are two processes, and without this they would each start a worker and both would work on the same state. One window too many is a nuisance; two workers is corruption.

View Source
var ErrWorkerNotRunning = errors.New("vero: the worker is not running")

ErrWorkerNotRunning is returned by Request when the worker has died and has not been restarted yet. It is worth distinguishing in a frontend: it means wait, not that anything you did was wrong.

View Source
var ShutdownGrace = 5 * time.Second

ShutdownGrace is how long Run waits for handlers to notice that the frontend has gone before exiting regardless.

Functions

func Edit

func Edit[T Keyed[K], K comparable](items []T, key K, change func(*T)) error

Edit finds the item the frontend means and changes it, which is most of what a handler does.

func (s *Status) Pause(req vero.ID[int]) error {
    return vero.Edit(s.Jobs, req.ID, func(j *Job) { j.Paused = !j.Paused })
}

A request naming something that is gone is refused, and the worker carries on.

func Handle

func Handle[Req any, Rep any](w *Worker, name string, fn func(ctx context.Context, request Req) (Rep, error))

Handle answers one request name.

Req is what the request decodes into and Rep is what the reply is encoded from, so neither the handler nor the caller has to guess. Use struct{} for a request that carries nothing.

func Hosting added in v0.12.0

func Hosting() bool

Hosting reports whether this process was started to supervise a worker rather than be one. NewWorker calls it, so an application only needs this if it builds its worker without NewWorker.

func InProcessWorker added in v0.12.0

func InProcessWorker() func(in io.Reader, out io.Writer) error

InProcessWorker returns what ServeInProcess was given, or nil. The C shim uses it; an application has no reason to.

func ServeInProcess added in v0.12.0

func ServeInProcess(serve func(in io.Reader, out io.Writer) error)

ServeInProcess hands vero a worker to run inside this process, for the frontends that cannot start one beside it.

iOS is the case that needs it: an application there may not start another program, but it may compile Go in. The application calls this before its frontend asks for a worker, with the same function it would have given SupervisorOptions.Serve:

vero.ServeInProcess(func(in io.Reader, out io.Writer) error {
    w := vero.NewWorker(vero.WorkerOptions{In: in, Out: out})
    jobs := vero.NewState(w, Status{})
    vero.Update(jobs, "addJob", func(s *Status) error { ... })
    return w.Serve()
})

Everywhere else, leave it alone: a worker in a process of its own survives a panic that would take the frontend with it, which is the whole reason the two are separate when they can be.

func Update

func Update[T any](s *State[T], name string, fn func(*T) error)

Update answers a request that carries nothing - a button with no payload - changes the state, and replies with it.

func (s *Status) Add() error { … }

vero.Update(jobs, "addJob", (*Status).Add)

UpdateWith is the same for a request that carries something, and UpdateItem for one that names an item.

func UpdateItem

func UpdateItem[T any, J Keyed[K], K comparable](s *State[T], name string, change func(*J) error)

UpdateItem answers a request that names one item and changes it, which is what a button on a row sends.

func (j *Job) Pause() error { j.Paused = !j.Paused; return nil }

vero.UpdateItem(jobs, "pauseJob", (*Job).Pause)

The request is an ID, the item is found by Key, change runs under the lock with a pointer into the state, and the reply is the new state. A request naming something that is gone is refused and the worker carries on.

Which collection is worked out from the item type: the state's one []J field, or the state itself when it is a []J. A state with two fields of the same item type cannot say which, and says so at registration rather than guessing.

func UpdateWith

func UpdateWith[T any, Req any](s *State[T], name string, fn func(state *T, request Req) error)

Update answers a request by changing the state, and replies with it.

jobs.Update("restartJob", func(s *Status, req RestartJob) error {
    s.Jobs[req.ID].Progress = 0
    return nil
})

fn runs under the lock, with the state and the decoded request, and the reply is the state as it leaves. So a frontend cannot draw the state from before its own change, and there is nothing to copy or forget to return.

Returning an error sends that to the frontend and changes nothing else.

The lock is held for the duration, so this is for changing state, not for work: anything slow belongs in a goroutine the handler starts, writing back through Do. A handler that replies with something other than the state is Worker.Handle.

Types

type Envelope

type Envelope struct {
	// Kind is "event" or "reply" on the way out of a worker, and empty on the
	// way in, where every line is a request.
	Kind string `json:"t,omitempty"`

	// ID matches a reply to the request that caused it.  Events have none.
	ID uint64 `json:"id,omitempty"`

	// Name routes a request to a handler registered with Handle or Update.
	// Empty when
	// the worker has a single handler that dispatches for itself.
	//
	// It lives here rather than inside the payload so that vero can route
	// without knowing anything about the message, and so an application is
	// not obliged to carry a type field in every request it defines.
	Name string `json:"n,omitempty"`

	// Payload is your own message, carried through untouched.
	Payload json.RawMessage `json:"p,omitempty"`

	// Error is set instead of Payload when a handler returned an error.
	Error string `json:"e,omitempty"`

	// Code classifies an error for a frontend that cannot match on English:
	// "refused", "not_running", "already_running" or "failed".  A host sets
	// it; a worker does not, because the supervisor on the other side of it
	// has the Go errors themselves.
	Code string `json:"c,omitempty"`
}

Envelope is one line of the protocol. Everything is optional except what the message kind requires, so the same struct decodes in both directions.

type Handler

type Handler func(ctx context.Context, request json.RawMessage) (reply any, err error)

Handler answers one request.

ctx is cancelled when the frontend goes away, so a handler that waits - a long poll, most obviously - must select on ctx.Done() as well. Without that the worker cannot exit when its parent does: it has stopped reading requests, but it is still waiting for the answer to one nobody will read. Returning an error sends the message to the caller rather than closing anything: the frontend can then tell "the worker refused this" from "the worker is not there", which want opposite responses.

Returning a nil reply and a nil error answers nothing at all, which suits a request that only causes an action.

type ID

type ID[T any] struct {
	ID T `json:"id"`
}

ID is a request that carries nothing but an identifier, which is most of them: restart this, cancel that, open the other. T is whatever your ids are.

jobs.Update("restartJob", func(s *Status, req vero.ID[int]) error {
    …
})

type Keyed

type Keyed[K comparable] interface {
	Key() K
}

Keyed is anything the frontend can name: a job with an id, a row with a primary key. Key is what the ID in a request matches.

func (j Job) Key() int { return j.ID }

type RemoteError

type RemoteError struct{ Message string }

RemoteError is what a Handler's error becomes on the other side of the pipe.

func (*RemoteError) Error

func (e *RemoteError) Error() string

Error is the worker's own message, not vero's.

type RunState

type RunState int

RunState is what the supervisor last saw the worker doing. It is the worker's lifecycle, not the state it publishes: that is Worker.NewState on the other side of the pipe.

const (
	// Starting means a worker is being launched, including a restart.
	Starting RunState = iota
	// Running means a worker is up and answering.
	Running
	// Restarting means one died and another is on the way.
	Restarting
	// Stopped means Stop was called; nothing further will be launched.
	Stopped
)

func (RunState) String

func (s RunState) String() string

String is what the bindings report: "starting", "running", "restarting" or "stopped".

type State

type State[T any] struct {
	// contains filtered or unexported fields
}

State is everything the frontend draws, and the lock that guards it.

Create one with Worker.NewState. The worker emits it whenever it changes and a handler registered with Worker.Update replies with it, so a frontend never polls and never draws the state from before its own change.

The worker reads it as JSON, under the same lock that Do takes, so there is no copy to get wrong: a snapshot cannot be encoded halfway through a change, and nothing can hand out a value that keeps changing underneath the comparison that decides whether to emit.

func NewState

func NewState[T any](w *Worker, initial T) *State[T]

NewState gives this worker its state, and returns it for the application to change with Do.

jobs := vero.NewState(w, Status{Jobs: []Job{{ID: 1, Name: "Photos"}}})
jobs.Do(func(s *Status) { s.Jobs[0].Progress++ })

One worker has one state: calling this twice replaces the first, because the event channel carries no name and a frontend would have no way to tell two kinds of event apart.

func (*State[T]) Do

func (s *State[T]) Do(f func(*T))

Do runs f with the state, under the lock. Every change goes through it.

jobs.Do(func(s *Status) { s.Jobs = append(s.Jobs, Job{}) })

Do not keep the pointer, start a goroutine with it, or block in f: the worker cannot encode the state while f holds the lock.

func (*State[T]) Every

func (s *State[T]) Every(interval time.Duration, fn func(*T))

Every runs fn with the state, under the lock, every interval, until the frontend goes away. It returns straight away: the loop is a goroutine of its own.

jobs.Every(200*time.Millisecond, func(s *Status) {
    for i := range s.Jobs { s.Jobs[i].Progress++ }
})

This is where a worker's own work goes when it is periodic. Writing the loop by hand costs a goroutine, a ticker that time.Tick never reclaims, and a nested Do.

func (*State[T]) JSON

func (s *State[T]) JSON() (json.RawMessage, error)

JSON is the state, encoded under the lock.

This is the only way the state leaves: a Go value handed out would share the slices and maps inside it, and whatever received it would be reading them while Do writes. Use it to reply with the state by hand - Update does it for you - and pass the result straight back as the reply.

type Supervisor

type Supervisor struct {
	// contains filtered or unexported fields
}

Supervisor runs a worker, keeps it running, and talks to it.

func Supervise

func Supervise(opts SupervisorOptions) *Supervisor

Supervise launches the worker and keeps it alive until Stop.

It returns immediately; the worker comes up on its own goroutine. Requests made before it is ready return ErrWorkerNotRunning rather than blocking, so a frontend can draw itself at once and show that it is waiting.

func (*Supervisor) Call

func (s *Supervisor) Call(ctx context.Context, name string, request any) (json.RawMessage, error)

Call sends a request to the handler registered under name, for a worker registered by name.

The name travels beside the payload rather than inside it, so an application's own message types stay exactly as it defined them.

func (*Supervisor) Err

func (s *Supervisor) Err() error

Err reports why the worker never started, or nil.

The one that matters is ErrAlreadyRunning: another process holds the lock, so this Supervisor will not run a worker at all.

func (*Supervisor) Latest

func (s *Supervisor) Latest() json.RawMessage

Latest returns the most recent event, or nil before the first one.

A frontend that has just drawn a window needs the current state without waiting for the next change, and this is that. Events keep it up to date afterwards.

func (*Supervisor) Request

func (s *Supervisor) Request(ctx context.Context, request any) (json.RawMessage, error)

Request sends a request and waits for its reply.

A handler's error comes back as *RemoteError, which is worth telling apart from ErrWorkerNotRunning: one means the worker refused what you asked, the other that it is not there to ask.

func (*Supervisor) Restarts

func (s *Supervisor) Restarts() int

Restarts counts how many times the worker has been relaunched without one of them lasting: a worker that runs for Healthy and then exits, whatever its reason, puts this back to zero.

So it answers "is this worker failing to start", which is the question worth asking, and not "how many times has it been started", which counts every deliberate exit as evidence of a fault.

func (*Supervisor) State

func (s *Supervisor) State() RunState

State reports what the worker is doing now.

func (*Supervisor) Stop

func (s *Supervisor) Stop() error

Stop shuts the worker down and waits for it to go.

Closing its standard input is the whole shutdown protocol: Run returns, the process exits, and anything it was holding is released the way it would be on any other exit. A worker that ignores that is killed after a grace period.

type SupervisorOptions

type SupervisorOptions struct {
	// Path is the worker binary.  Args are passed to it verbatim, so the
	// application keeps its own command line.
	Path string
	Args []string

	// Dir and Env, if set, are the worker's working directory and
	// environment.  VERO_SERVE is added to Env either way.
	Dir string
	Env []string

	// Serve runs a worker in this process, reading requests from in and
	// writing events and replies to out, instead of Path being started as a
	// program.  Everything else is unchanged: the same requests, the same
	// events, the same replies, over an io.Pipe rather than a pipe to a
	// child.
	//
	// iOS is why this exists.  An application there may not start another
	// program, but it may compile Go in, so the worker runs on a goroutine
	// of its own.  Restarts lose their meaning when it does: a goroutine
	// dies with the process it is in, and there is nothing left to restart
	// it into.
	Serve func(in io.Reader, out io.Writer) error

	// OnEvent receives every event the worker emits, in order.
	OnEvent func(event json.RawMessage)

	// OnLog receives the worker's standard error, a line at a time.  Workers
	// log there precisely so this does not have to guess at framing.
	OnLog func(line string)

	// OnStateChange is told when the worker starts, dies, or is given up on.
	OnStateChange func(RunState)

	// Backoff is the wait before the first restart, doubling up to
	// MaxBackoff.  A worker that crashes on startup would otherwise be
	// relaunched as fast as the machine can fork.  Zero means 200ms and 30s.
	Backoff    time.Duration
	MaxBackoff time.Duration

	// Healthy is how long a worker has to run before the restarts behind it
	// are forgotten: the backoff returns to Backoff and Restarts returns to
	// zero.  Zero means 30 seconds.
	//
	// Workers exit on purpose - to pick up a change to their configuration,
	// or to run a version they have just downloaded - and one that served for
	// an hour before doing so has told us nothing except that it is working.
	// Without this the backoff climbs to MaxBackoff over a day of ordinary
	// use, so an intentional exit costs half a minute of nothing, and any
	// frontend watching the count concludes the worker is broken.
	Healthy time.Duration

	// Lock keys the single-worker lock: one process at a time supervises a
	// worker under a given key.  Empty means the worker's own path, which is
	// what you want unless two applications share a binary and should still
	// be allowed to run at once - then give them a key each.
	Lock string

	// NoLock turns the lock off, for a program that genuinely wants several
	// workers at once.
	NoLock bool
}

SupervisorOptions describes the worker to run and who to tell about it.

The callbacks are called from the supervisor's own goroutines, never on the frontend's main thread. Whatever you do in them, hop to the main thread before touching the frontend.

type Worker

type Worker struct {
	// contains filtered or unexported fields
}

Worker is the logic half of a vero application.

func NewWorker

func NewWorker(opts WorkerOptions) *Worker

NewWorker prepares a worker. Whether a supervisor launched it is read from the environment, so the same binary and the same code run both ways.

Call this before starting any goroutine

In serve mode standard output belongs to the protocol, so a stray Println anywhere in the program would corrupt a frame. Rather than ask every author of every line in the program to remember that, this takes the real standard output for itself and points the package-level os.Stdout at standard error.

fmt.Println and friends read os.Stdout at the moment they are called, so every existing print in the program - and every one anybody adds later, in any package, including ones you depend on and cannot edit - lands harmlessly on stderr from here on. There is nothing to audit and nothing to keep auditing.

It does mean reassigning a package-level variable, so call NewWorker before anything else could be printing: another goroutine writing while this reassigns is a data race, and one the race detector will find.

func (*Worker) EmitOnChange

func (w *Worker) EmitOnChange(ctx context.Context, interval time.Duration, snapshot func() any)

EmitOnChange emits what snapshot returns, whenever it differs from what was emitted last. It runs until ctx is done, so start it with go.

go w.EmitOnChange(ctx, 250*time.Millisecond, func() any { return state() })

Most workers do not call this: set WorkerOptions.State instead and Serve runs it for you, on a context that ends when the frontend does. This is for the ones whose events are not one state snapshot.

Emitting on a timer instead is the obvious thing to write and it defeats the point: a frontend is sent the same thing several times a second, and the quiet-when-nothing-happens property - the reason events are pushed rather than polled - is gone.

Comparison is by reflect.DeepEqual, so snapshot should return a value rather than something holding a pointer to state that keeps changing underneath it.

func (*Worker) Fallback

func (w *Worker) Fallback(h Handler)

Fallback answers requests whose name matches nothing registered, including requests that carry no name at all.

func (*Worker) FallbackCalls

func (w *Worker) FallbackCalls() uint64

FallbackCalls counts the requests that have reached the fallback, so an application can tell when the old shape has stopped being used.

func (*Worker) Log

func (w *Worker) Log(format string, a ...any)

Log writes a line to standard error, where logs belong. Standard output is reserved for the protocol, and a stray write there corrupts the stream.

func (*Worker) Serve

func (w *Worker) Serve() error

Serve answers the requests registered with Handle and Update.

Otherwise identical to Run: it blocks, it returns when the frontend goes away, and on its own it never returns.

func (*Worker) Supervised

func (w *Worker) Supervised() bool

Supervised reports whether a Supervisor launched this worker. Useful when the standalone form wants to behave differently - printing a summary and exiting, say, rather than running forever.

type WorkerOptions

type WorkerOptions struct {
	// JSON emits the event stream on standard output.  A Supervisor turns
	// this on for you; set it yourself when a script is watching.
	JSON bool

	// Quiet suppresses the human readable log on standard error.
	Quiet bool

	// Version is what -version reports.  A frontend that ships this worker
	// in its bundle asks for it to decide whether the copy it has on disk is
	// older than the one it was built with, so the two halves have to agree
	// on the flag: vero registers it here so they cannot drift.
	Version string

	// In and Out replace standard input and output, which is how a worker
	// runs inside the application rather than beside it.  Setting them also
	// says this worker is being supervised, since nothing else would.
	//
	// iOS is why they exist: an application there may not start another
	// program, so the only way to run a worker is in the same process, on
	// the other end of an io.Pipe.
	In  io.Reader
	Out io.Writer

	// StateInterval is how often the state is sampled for a change, for a
	// worker that has one.  Zero means DefaultStateInterval.
	StateInterval time.Duration

	// ShowVersion is set by RegisterFlags when -version was passed. Answer it
	// and exit before anything else starts - it runs as its own short-lived
	// process, several times per launch, and must not open a port, touch a
	// configuration directory, or print anything else.
	ShowVersion bool
}

WorkerOptions controls what a worker writes, and where.

The two settings are deliberately separate. Format and volume are different questions: a frontend wants every state change as JSON, and a person reading a terminal or a journal wants a handful of lines saying what actually happened. Emitting the first to satisfy the second is how logs become unreadable.

func (*WorkerOptions) PrintVersionAndExit

func (o *WorkerOptions) PrintVersionAndExit()

PrintVersionAndExit answers -version if it was asked for, and does not return in that case.

Call it immediately after flag.Parse, before anything else starts.

func (*WorkerOptions) RegisterFlags

func (o *WorkerOptions) RegisterFlags(fs *flag.FlagSet)

RegisterFlags adds -json and -quiet to fs. Call it before flag.Parse.

A worker does not need a flag for serve mode: a Supervisor sets it through the environment, so nothing on the command line has to describe an arrangement the user did not ask for.

Directories

Path Synopsis
cmd
vero module
Command cshim builds vero as a C archive, so a native frontend can embed it and drive a worker without knowing anything about pipes, JSON framing or process supervision.
Command cshim builds vero as a C archive, so a native frontend can embed it and drive a worker without knowing anything about pipes, JSON framing or process supervision.
example
ios-app/archive command
The worker, compiled into the application.
The worker, compiled into the application.
wasi-app command
Command wasi-app drives a worker that is not a program.
Command wasi-app drives a worker that is not a program.
wasi-app/worker command
Command worker is the WASI example's worker: the half that would be a program anywhere else, and is a single worker.wasm here.
Command worker is the WASI example's worker: the half that would be a program anywhere else, and is a single worker.wasm here.
web-app command
worker command
Command worker is the logic half of the example: the part you would write.
Command worker is the logic half of the example: the part you would write.
kit module

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL