Air — Architecture#
Architectural style#
Event-driven pipeline with a monolithic core. Air follows a classic watch → build → run pipeline, driven entirely by file system events funnelled through a buffered channel. There is no clean separation of layers or plugin system — a single Engine struct owns all components (watcher, proxy, logger, process management) and orchestrates them imperatively. This is appropriate for a small, single-responsibility CLI tool; the “event-driven” label refers only to the reactive rebuild loop, not an enterprise event bus.
Evidence: engine.go starts a goroutine per watched path that pushes file change strings into eventCh, and start() runs an infinite select loop consuming those events to trigger buildRun().
Component diagram (textual)#
┌─────────────────────────────────────────────────────────────┐
│ main.go │
│ ───────────────────────────────────────────────────────── │
│ flag parse → InitConfig → NewEngineWithConfig → r.Run() │
│ signal goroutine (SIGINT/SIGTERM/SIGHUP) → r.Stop() │
└──────────────────────────┬──────────────────────────────────┘
│ creates
▼
┌─────────────────────────────────────────────────────────────┐
│ Engine (runner/engine.go) │
│ │
│ ┌──────────┐ events ┌─────────────────────────────────┐ │
│ │ Watcher │──────────▶│ eventCh (chan string, 1000) │ │
│ │(filenotify)│ └─────────────┬───────────────────┘ │
│ └──────────┘ │ start() loop │
│ ▼ │
│ ┌─────────────────┐ │
│ │ buildRun() │ │
│ │ pre_cmd │ │
│ │ go build │ │
│ │ run binary │ │
│ └────────┬────────┘ │
│ │ │
│ ┌──────────┐ Reload/BuildFailed │ │
│ │ Proxy │◀────────────────────────┘ │
│ │ (HTTP RP)│ │
│ │ + SSE │ │
│ └──────────┘ │
│ │
│ exitCh ◀── r.Stop() ──── signal handler in main.go │
└─────────────────────────────────────────────────────────────┘Core components#
Engine#
- Package:
github.com/air-verse/air/runner - File:
runner/engine.go - Responsibility: Central orchestrator. Walks configured directories to register file watchers, runs the event loop that triggers builds, manages the subprocess lifecycle (start, kill, restart), and coordinates shutdown.
- Key types:
Enginestruct (owns all channels and sub-components) - Key channels:
eventCh chan string(buffer 1000) — file path events from watcher goroutinesbuildRunCh chan chan struct{}(buffer 1) — semaphore + cancellation token for build serializationbinStopCh chan<- chan int— send-only channel to terminate the running subprocessexitCh chan bool— closed byStop()to signal full shutdown
- Dependencies: Config, Proxy, logger, filenotify.FileWatcher, godotenv
Config#
- Package:
github.com/air-verse/air/runner - File:
runner/config.go - Responsibility: Loads, validates, and merges configuration from TOML file, hard-coded defaults, and CLI flag overrides. Produces a
*Configvalue consumed by all other components. - Key types:
Config,cfgBuild,cfgProxy,cfgLog,cfgColor,cfgMisc,cfgScreen - Dependencies:
go-toml(parse),mergo(merge with defaults),reflect(flag override injection viaflag.go)
Proxy#
- Package:
github.com/air-verse/air/runner - File:
runner/proxy.go,runner/proxy_stream.go - Responsibility: Optional HTTP reverse proxy that sits in front of the user’s app. Transparently forwards all requests; for HTML responses, injects a
<script>containing the embeddedproxy.jslive-reload client. Exposes an SSE endpoint (/__air_internal/sse) that pushes reload/build-failed events to connected browsers. - Key types:
Proxy,Streamerinterface,ProxyStream,Subscriber - Key embedded assets:
proxy.js,worker.js(via//go:embed) - Dependencies: stdlib
net/http,brotli,gzip
Watcher#
- Package:
github.com/gohugoio/hugo/watcher/filenotify(external dependency) - Responsibility: Abstracts file system event delivery. Supports native fsnotify-backed watching or polling-based watching (configured via
build.poll+build.poll_interval). Engine callswatcher.Add(path)for each directory/file to watch. - Key types:
filenotify.FileWatcherinterface (defined in Hugo’s package, consumed here) - Integration: Each call to
engine.watchPath()spawns a goroutine that readswatcher.Events()and forwards matching events toeventCh.
Logger#
- Package:
github.com/air-verse/air/runner - File:
runner/logger.go - Responsibility: Prefixed, color-coded console output with four named channels: main, watcher, build, runner. Colors are configurable per channel via
cfgColor. Also handles silencing and timestamp control. - Key types:
loggerstruct - Dependencies:
fatih/color
Exiter#
- Package:
github.com/air-verse/air/runner - File:
runner/exiter.go - Responsibility: Wraps
os.Exitbehind theexiterinterface so that tests can intercept exit calls without terminating the test process. - Key types:
exiterinterface,defaultExiter(production),testExiter(used in tests)
Data flow#
A typical rebuild cycle:
1. File saved on disk
│
▼
2. filenotify.FileWatcher emits event
│ goroutine per watched path
▼
3. engine.watchPath() goroutine validates event
(checks include/exclude rules, regex filters, checksum if ExcludeUnchanged)
│
▼
4. eventCh ← ev.Name (buffered, non-blocking)
│
▼
5. engine.start() select loop receives filename
- Sleeps buildDelay (debounce, default 1000ms)
- Calls flushEvents() to drain any queued events (coalescing)
- Cancels any in-flight build (closes old buildRunCh token)
- Calls stopBin() to SIGINT/kill the running subprocess
│
▼
6. go buildRun()
a. loadEnvFile() — reload .env files, sync os.Setenv
b. runPreCmd() — execute pre_cmd shell commands
c. building() — exec `build.cmd` (default: `go build -o ./tmp/main .`)
│ on error → proxy.BuildFailed(); optional early return
▼
d. runBin() — exec compiled binary as subprocess
│ on proxy.Enabled → proxy.Reload() → SSE event to browsers
▼
7. Browser receives SSE "reload" event → JavaScript reloads the pageFor graceful shutdown (SIGINT/SIGTERM):
signal → r.Stop() → runPostCmd() → close(exitCh) → start() loop exits
→ cleanup(): proxy.Stop(), stopBin(), watcher.Close(), optional rmdir tmp/Initialization / Bootstrap#
main() {
parseFlag() // stdlib flag + runner.ParseConfigFlag (reflection-based)
cfg = runner.InitConfig() // TOML load + merge with defaults + CLI overrides
r = runner.NewEngineWithConfig(cfg, debugMode)
// creates logger, watcher, proxy (always, even if disabled)
// initializes all channels
// does NOT start anything yet
go { <-sigs; r.Stop() } // signal handler
r.Run() // blocks until exit
}No dependency injection framework. All wiring is manual: main.go calls constructors in sequence and passes the results down. Within the Engine, sub-components are created inline in NewEngineWithConfig. There is no service locator, no wire/fx/dig. The only abstraction used for testability is the exiter interface and the Streamer interface (for the SSE stream).
Configuration#
- Format: TOML (
.air.tomlin the working directory) - Discovery order:
-c <path>flag (explicit path)$air_wd/.air.toml(ifair_wdenv var is set)$PWD/.air.toml- Hard-coded defaults (no file needed)
- Merge strategy:
mergo.Mergewith a customsliceTransformerthat prevents non-zero user slices from being overwritten by defaults. Result: defaults fill in any missing fields without clobbering user choices. - CLI override:
runner.ParseConfigFlag(inflag.go) uses reflection to walk theConfigstruct and register aflag.Flagfor every field that has ausagestruct tag. Users can pass e.g.--build.cmd="make"at the CLI to override any config value. - Environment:
air_wdenv var overrides the working directory;.envfiles listed inenv_filesconfig key are loaded/reloaded before each build viagodotenv.
Key design decisions#
buildRunChas semaphore + cancellation carrier.buildRunCh chan chan struct{}(buffer 1) is an elegant dual-purpose channel: its capacity-1 buffer prevents twobuildRun()goroutines from being “in-flight” simultaneously (the second would block trying to send its stop channel), and it carries the stop token so thatstart()can cancel a slow build by closing the token it retrieves. See comments atengine.go:40-43.Optional proxy always constructed, never started unless enabled.
NewProxy()is called unconditionally inNewEngineWithConfig, butproxy.Run()is only called instart()whencfg.Proxy.Enabledis true. This simplifies the Engine struct (no nil checks everywhere) while keeping the startup path for proxy-disabled runs cheap.Embedded live-reload JS in the Go binary.
proxy.jsandworker.jsare embedded via//go:embeddirectives inproxy.go. The binary ships everything needed; no CDN, no side-car files, no network dependency for the live-reload feature.Polling fallback for file watching.
cfg.Build.Pollswitches from native fsnotify to a polling-based watcher (fromgohugoio/hugo/watcher/filenotify), both implementing the sameFileWatcherinterface. This enables Air to work in Docker volumes and network filesystems where inotify is unreliable.exiterandStreameras the only interfaces. The codebase defines exactly two interfaces for the purpose of testability. Everything else is concrete. This is consistent with the project’s philosophy of minimal abstraction — add an interface only when you need to mock a boundary in tests, not by default.