Skip to content

Building Swift CLI tools and libraries

What building a zero-runtime view compiler and a reverse proxy in Swift taught me about shipping command-line tools.

Mac Long
Jun 25, 20264 min read

Swift outside the app sandbox

Most Swift code lives inside an Xcode project targeting iOS or macOS apps. Command-line tools and libraries are a different discipline — no view lifecycle, no simulator, just the Swift Package Manager, Process, and whatever the terminal gives you. Two projects taught me most of what I know about that discipline: Score, a DSL that compiles views, state and routing to plain HTML, CSS and JavaScript, and Orchestrator, a Swift-native HTTP server and reverse proxy built on structured concurrency.

Zero runtime is a design constraint, not a slogan

Score's entire premise is that a view written in Swift should produce plain markup at compile time — no framework runtime shipped to the browser, no virtual DOM reconciling on the client. That constraint forces decisions a typical SwiftUI-style API doesn't have to make: state has to be serializable to something a <script> tag can hydrate, and routing has to resolve to real URLs a server can match without a client-side router in the loop.

swift Copy
struct Counter: View {
  @State var count = 0
  var body: some View {
    Button("Count: \(count)") { count += 1 }
  }
}
// compiles to a <button> with a data-bound onclick, not a rendered VDOM node
InfoA compile-time constraint that feels limiting at first — "state must be serializable" — usually surfaces a design question you were going to hit in production anyway, just earlier and cheaper.

Structured concurrency makes a reverse proxy legible

Orchestrator's job is unglamorous: accept connections, route them to the right backend, hold the line under backpressure, restart cleanly. Before async/await and structured task trees, that kind of code tended to accrete completion handlers and manually-tracked cancellation flags. With structured concurrency, a connection's lifetime is a task tree — cancel the parent, and every child request handling that connection unwinds automatically, no manual bookkeeping required.

Before/after structured concurrency, informally
ConcernCallback-basedStructured concurrency
CancellationManual flag, checked everywhereAutomatic, via task cancellation
Error propagationThreaded through callbacksRegular throws
Leak surfaceEvery unclosed handlerBounded by task tree lifetime

Distributing as a rootless LaunchAgent

Shipping a long-running macOS tool without requiring sudo or a full installer package means leaning on launchd user agents — a plist in ~/Library/LaunchAgents, no root daemon, no privileged helper. It's more setup than a single binary, but it's the difference between "run this script" and "install this like normal macOS software," including surviving a reboot without the user thinking about it again.

TipIf a CLI tool needs to persist across reboots, reach for a user LaunchAgent before reaching for root — almost nothing actually needs system-level privileges, and every privilege you don't request is one less thing a user has to trust you with.

Testing a tool that talks to real sockets

Neither project gets much value from mocking the network layer — a reverse proxy's entire job is moving bytes between real connections, so the tests that catch actual regressions bind to a real local port and drive traffic through it.

What actually gets unit-tested vs. integration-tested

Pure logic — route matching, header rewriting rules, backoff timing — gets fast, isolated unit tests. Anything touching an actual socket, TLS handshake, or the LaunchAgent lifecycle gets a smaller number of slower integration tests that spin up the real thing. The split isn't about coverage percentage; it's that mocking a socket tends to test the mock, not the proxy.

A typed config language, in the end

Both tools ended up with the same conclusion for configuration: a small, strongly-typed Swift struct decoded from YAML beats a stringly-typed flag parser once a tool has more than a handful of options.1 The config becomes documentation — the type signature is the schema, and a missing required field fails at load time with a clear error instead of surfacing as nil-related behavior three layers deeper at runtime.

1. `ArgumentParser` is still the right tool for the flags that select which config or mode to run — the two aren't in competition, they solve different problems.

Did you enjoy this article?

Recommend it — Standard Reader surfaces well-loved writing to more readers across the network.

Across the AtmosphereDiscussions