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: Engine struct (owns all channels and sub-components)
  • Key channels:
    • eventCh chan string (buffer 1000) — file path events from watcher goroutines
    • buildRunCh chan chan struct{} (buffer 1) — semaphore + cancellation token for build serialization
    • binStopCh chan<- chan int — send-only channel to terminate the running subprocess
    • exitCh chan bool — closed by Stop() 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 *Config value 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 via flag.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 embedded proxy.js live-reload client. Exposes an SSE endpoint (/__air_internal/sse) that pushes reload/build-failed events to connected browsers.
  • Key types: Proxy, Streamer interface, 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 calls watcher.Add(path) for each directory/file to watch.
  • Key types: filenotify.FileWatcher interface (defined in Hugo’s package, consumed here)
  • Integration: Each call to engine.watchPath() spawns a goroutine that reads watcher.Events() and forwards matching events to eventCh.

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: logger struct
  • Dependencies: fatih/color

Exiter#

  • Package: github.com/air-verse/air/runner
  • File: runner/exiter.go
  • Responsibility: Wraps os.Exit behind the exiter interface so that tests can intercept exit calls without terminating the test process.
  • Key types: exiter interface, 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 page

For 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.toml in the working directory)
  • Discovery order:
    1. -c <path> flag (explicit path)
    2. $air_wd/.air.toml (if air_wd env var is set)
    3. $PWD/.air.toml
    4. Hard-coded defaults (no file needed)
  • Merge strategy: mergo.Merge with a custom sliceTransformer that 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 (in flag.go) uses reflection to walk the Config struct and register a flag.Flag for every field that has a usage struct tag. Users can pass e.g. --build.cmd="make" at the CLI to override any config value.
  • Environment: air_wd env var overrides the working directory; .env files listed in env_files config key are loaded/reloaded before each build via godotenv.

Key design decisions#

  1. buildRunCh as semaphore + cancellation carrier. buildRunCh chan chan struct{} (buffer 1) is an elegant dual-purpose channel: its capacity-1 buffer prevents two buildRun() goroutines from being “in-flight” simultaneously (the second would block trying to send its stop channel), and it carries the stop token so that start() can cancel a slow build by closing the token it retrieves. See comments at engine.go:40-43.

  2. Optional proxy always constructed, never started unless enabled. NewProxy() is called unconditionally in NewEngineWithConfig, but proxy.Run() is only called in start() when cfg.Proxy.Enabled is true. This simplifies the Engine struct (no nil checks everywhere) while keeping the startup path for proxy-disabled runs cheap.

  3. Embedded live-reload JS in the Go binary. proxy.js and worker.js are embedded via //go:embed directives in proxy.go. The binary ships everything needed; no CDN, no side-car files, no network dependency for the live-reload feature.

  4. Polling fallback for file watching. cfg.Build.Poll switches from native fsnotify to a polling-based watcher (from gohugoio/hugo/watcher/filenotify), both implementing the same FileWatcher interface. This enables Air to work in Docker volumes and network filesystems where inotify is unreliable.

  5. exiter and Streamer as 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.