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.
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.
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 nodeStructured 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.
| Concern | Callback-based | Structured concurrency |
|---|---|---|
| Cancellation | Manual flag, checked everywhere | Automatic, via task cancellation |
| Error propagation | Threaded through callbacks | Regular throws |
| Leak surface | Every unclosed handler | Bounded 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.
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.