Skip to content
Mac Long

Systems

Building Swift CLI tools and libraries

What Score and Orchestrator show about Swift web tooling, explicit configuration, and the limits of a first production milestone.

· 7 min read
ScoreOrchestrator
A terminal card for Score and Orchestrator, two Swift command-line tools.

Swift outside the app sandbox

Most Swift code lives in an Xcode project for iOS or macOS. Command-line tools and libraries are a different discipline. There is no view lifecycle or simulator, just the Swift Package Manager, Process, and whatever the terminal gives you.

Two projects have made that concrete for me: Score, Swift web tooling for rendering HTML and CSS, and Orchestrator, a Swift-native HTTP server and reverse proxy for macOS. They solve different problems, but both make their trade-offs visible in the command line and in configuration.

Score: static by default, dynamic when needed

Score lets a developer declare views and routes in Swift, then render HTML, CSS, and JavaScript. It can statically export a site to .score/build/, but it is no longer accurate to describe every Score build as zero-runtime.

Interactive applications can include a modular browser runtime for DOM signals, actions, fetches, intersection observers, and view transitions. Development adds reload support through WebSockets and EventSource. A fully static, no-JavaScript build remains available when those features are disabled.

swift
struct ArticleCard: View {
  let post: ContentPost
  @State var saved = false

@Action func toggleSaved() { saved.toggle() }

var body: some View { Button(.ghost, action: toggleSaved) { saved ? “Saved” : “Save” } } }

That distinction is useful. Static output is a deployment choice, not a slogan. When an app needs interaction, the runtime is part of the product and should be small, explicit, and easy to inspect. When it does not, the runtime can be left out.

Score’s state model follows the same principle. @State values are Sendable. They only need Codable when using persistence or synchronisation modes. That is a better constraint than forcing every bit of UI state through a hydration format it may never need.

Structured concurrency makes server lifetimes easier to see

Orchestrator accepts connections, routes requests by host, serves static files, and proxies requests upstream. The implementation uses NIO’s async channels and structured concurrency for connection handling and graceful shutdown.

A connection runs in a structured child task. Cancelling the parent task propagates cancellation through the group, while the connection loop checks for cancellation and closes network work cooperatively. That is clearer than a scattered collection of flags and callbacks, but it is not magic: cancellation still depends on code reaching cancellation-aware points.

The current server handles HTTP/1.1 keep-alive, exact and wildcard host routing, longest-prefix proxy routing, static files with ETags and byte ranges, and pooled upstream requests. It is intentionally not a complete edge proxy yet. TLS and ACME, WebSockets, compression, metrics, tracing, hot reload, and streaming proxy bodies are still future work. In particular, proxy response bodies are collected before they are sent on, so it would be misleading to claim end-to-end streaming backpressure today.

Configuration deserves its own language

Orchestrator uses PKL, not YAML, for configuration. The server evaluates PKL through pkl-swift, decodes the result into Swift models, and searches for a configuration file in a predictable order:

  1. ~/Library/Application Support/Orchestrator/config.pkl
  2. ~/Library/Preferences/Orchestrator/config.pkl
  3. ~/.config/Orchestrator/config.pkl
  4. ./config.pkl

That gives the command line a useful contract. server validate can evaluate configuration before the server starts, and errors can point to configuration rather than surfacing later as an unexpected route or connection failure.

Score uses a different mix of configuration. Its content frontmatter is YAML, while its CLI scaffolds project structure and its build options choose between static output and runtime features. The common lesson is not “use YAML” or “use PKL.” It is to make configuration typed, discoverable, and validated before it becomes production behaviour.

A rootless LaunchAgent is a good default

A long-running macOS server should not need sudo just to start on login. Orchestrator installs a user LaunchAgent in ~/Library/LaunchAgents, with commands to install, reload, stop, inspect, and read its logs. The generated agent uses RunAtLoad and KeepAlive so it can start at login and recover from an exit without a privileged daemon.

Test the boundaries you actually have

It is tempting to say that a network service needs only real-socket integration tests. That is too simple. Orchestrator’s current test coverage is strongest around configuration, routing, static-file behavior, and application APIs; it does not yet provide a broad suite that binds real ports, performs TLS handshakes, or exercises LaunchAgent lifecycle. TLS is not implemented yet in any case.

The better split is practical. Pure behavior such as route matching, configuration validation, header rules, and backoff logic should have fast unit tests. Add integration tests where a real socket, process, filesystem, or service boundary can fail in a way a unit test cannot show. The goal is not a particular test ratio. It is confidence in the boundary that matters.

Both projects are still evolving, which is exactly why their limits are worth writing down. A production milestone is more useful when it says what it does, what it deliberately does not do, and what work remains before someone assumes it can carry more than it should.

Discuss on Bluesky