SwifterLite is a small HTTP/1.1 server you can embed in an iOS, macOS or tvOS app. It is a cut-down fork of Swifter by Damian Kołakowski, built for one job: serving a local REST API and HLS audio to AVPlayer inside the same app, quickly and reliably.
StarPlayrX uses it to run a server on localhost. The player loads playlists, AES keys and AAC segments from that server, and the server fetches them from upstream.
SwifterLite is about half the size of Swifter. Many Swifter features were removed, so it can't be merged back into Swifter.
- GET and POST routes. Each method has its own route table.
- Path parameters such as
/m3u/:channelid. - Keep-alive connections (
Connection: keep-alive). - Four body types: JSON, plain text,
Dataand[UInt8], each with an optional content type. - Custom response headers.
- One thread per connection. Each accepted socket is handled on a global dispatch queue.
- No dependencies. It uses only Foundation and Darwin sockets.
- No HTTPS. It only speaks plain HTTP. Put a proxy such as nginx or Cloudflare in front of it if you need TLS.
- IPv4 only.
- Two status codes:
200 OKand404 Not Found. - No PUT/DELETE, WebSockets, static file serving, chunked transfer encoding or middleware.
- Apple platforms only. It calls Darwin directly, so it won't build on Linux.
Note: The listening socket binds to
INADDR_ANY(all interfaces), not just127.0.0.1, so other devices on the network can reach it. Keep that in mind when you choose what to serve.
Swift Package Manager:
dependencies: [
.package(url: "https://github.com/AgentiLoop/SwifterLite", branch: "mustang")
],
targets: [
.target(
name: "YourTarget",
dependencies: [.product(name: "SwifterLite", package: "SwifterLite")]
)
]In Xcode, use File > Add Package Dependencies…, enter the URL above and choose the mustang branch.
import SwifterLite
let server = HttpServer()
server.get["/api/v3/ping"] = { request in
HttpResponse.ok(.ping("pong"))
}
try server.start(8080) // listens on port 8080 and returns right away
// ...
server.stop()start(_:priority:) opens the socket and returns. Connections are accepted on a background queue (QoS .userInteractive by default), and the server keeps running until you call stop() or the HttpServer is deallocated, so keep a strong reference to it.
curl -H 'Connection: keep-alive' http://localhost:8080/api/v3/ping # pongNote: Send
Connection: keep-alive. Without it the server closes the socket right after writing the response, and because sockets useSO_LINGER0 the close is a reset, so the client can lose the body (curl reports error 56).AVPlayersends keep-alive, so StarPlayrX isn't affected.
Assign a handler to a path on server.get or server.post. A handler is a httpReq, which is a (HttpRequest) -> HttpResponse closure.
server.get ["/api/v3/key"] = keyRoute()
server.post["/api/v3/login"] = loginRoute()
server.get ["/api/v3/m3u/:channelid"] = playlistRoute()The usual pattern is a function that returns the closure, as StarPlayrX does:
func keyRoute() -> httpReq {{ request in
guard let data = Data(base64Encoded: key) else {
return HttpResponse.notFound(.none)
}
return HttpResponse.ok(.data(data, contentType: "application/octet-stream"))
}}A path segment that starts with : is a variable. Read it from request.params. The key keeps the colon:
server.get["/api/v3/aac/:aac"] = { request in
guard let segment = request.params[":aac"] else {
return HttpResponse.notFound(.none)
}
// ...
}A variable only matches the last segment of a route. Requests that don't match any route get 404 Not Found.
server.routes returns every node in the route tree as a string. The method is not included, intermediate segments are listed too, and the order is not fixed:
print(server.routes.sorted()) // ["/api", "/api/v3", "/api/v3/login", "/api/v3/ping", ...]HttpRequest has these properties:
| Property | Type | Notes |
|---|---|---|
method |
String |
"GET" or "POST" |
path |
String |
Path without the query string |
queryParams |
[(String, String)] |
Query items in their original order |
headers |
[String: String] |
Header names are lowercased |
body |
[UInt8] |
Read using Content-Length |
params |
[String: String] |
Path parameters, keyed with the colon (":id") |
address |
String? |
Defaults to "127.0.0.1" |
Parsing a JSON POST body:
server.post["/api/v3/session"] = { request in
let json = try? JSONSerialization.jsonObject(with: Data(request.body)) as? [String: String]
guard let channelId = json?["channelid"] else { return HttpResponse.notFound(.none) }
return HttpResponse.ok(.json(["success": true, "data": channelId]))
}public enum HttpResponse {
case ok(HttpResponseBody, [String: String] = [:]) // 200, with optional extra headers
case notFound(HttpResponseBody? = nil) // 404
}
public enum HttpResponseBody {
case json(Any, contentType: String? = "application/json")
case ping(String, contentType: String? = "text/plain")
case data(Data, contentType: String? = nil)
case byts([UInt8], contentType: String? = nil)
}Examples:
HttpResponse.ok(.json(["success": true]))
HttpResponse.ok(.ping(playlistText, contentType: "application/x-mpegURL"))
HttpResponse.ok(.data(aacSegment, contentType: "audio/aac"), ["X-Channel-ID": channelId])
HttpResponse.ok(.byts([0x00, 0x01], contentType: "application/octet-stream"))
HttpResponse.notFound(.none).json is encoded with JSONSerialization. If encoding fails, the body becomes the text Http error: ….
Every response gets a Content-Length header and a Server: SwifterLite mustang header. A Connection: keep-alive header is added when the client asked for keep-alive.
Every example below was compiled and run against the mustang branch.
Pass 0 and the system picks a port. port() returns it, and state tracks the lifecycle.
let server = HttpServer()
try server.start(0)
let port = try server.port() // e.g. 50367
print(server.state) // running
server.stop()
print(server.state) // stoppedtry server.start(8080, priority: .utility) // default is .userInteractivestart throws a SocketError. Each case carries errno as a string.
do {
try server.start(8080)
} catch SocketError.bindFailed(let errno) {
print("bind failed, errno \(errno)")
} catch {
print("could not start: \(error)")
}Because the listening socket sets SO_REUSEPORT, starting a second server on a port that's already in use does not throw; both sockets end up sharing the port.
queryParams is an array of (name, value) tuples, so repeated names are kept.
server.get["/api/v3/search"] = { request in
let q = request.queryParams.first { $0.0 == "q" }?.1 ?? ""
let all = Dictionary(request.queryParams, uniquingKeysWith: { first, _ in first })
return .ok(.json(["q": q, "count": all.count]))
}
// GET /api/v3/search?q=jazz&q=rock&x=1 -> {"q":"jazz","count":2}Header names are lowercased.
server.get["/api/v3/agent"] = { request in
let agent = request.headers["user-agent"] ?? "unknown"
let range = request.headers["range"] ?? "-"
return .ok(.ping("\(agent) \(range)"))
}server.post["/api/v3/echo"] = { request in
.ok(.byts(request.body, contentType: request.headers["content-type"]),
["X-Method": request.method, "X-Path": request.path])
}server.get and server.post expose their HttpRouter. Setting a route to nil makes it return 404, though it stays in server.routes.
server.get.router.register("GET", path: "/api/v3/direct") { _ in .ok(.ping("direct")) }
server.get["/api/v3/old"] = { _ in .ok(.ping("old")) }
server.get["/api/v3/old"] = nil // now 404route(_:path:) returns the matched path parameters and the handler, or nil.
if let (params, handler) = server.get.router.route("GET", path: "/api/v3/aac/seg1.aac") {
print(params) // [":aac": "seg1.aac"]
}
server.get.router.route("GET", path: "/api/v3/old") == nil // true after example 7Subclass HttpServer (its init() is public; HttpServerIO's isn't) to log requests or answer some paths before the router runs.
final class LoggingServer: HttpServer {
override func dispatch(_ request: HttpRequest) -> dispatchHttpReq {
print(request.method, request.path)
if request.path == "/health" {
return ([:], { _ in .ok(.ping("ok")) })
}
return super.dispatch(request)
}
}let response = HttpResponse.ok(.data(segment, contentType: "audio/aac"), ["Cache-Control": "no-cache"])
response.statusCode // 200
response.reasonPhrase // "OK"
response.headers() // ["Cache-Control": "no-cache", "Content-Type": "audio/aac", "Server": "SwifterLite mustang"]
HttpResponse.notFound().headers() // ["Server": "SwifterLite mustang"]HttpServer.version // "mustang"Socket can be used on its own. This echoes one line back to a client:
let listener = try Socket.tcpSocketForListen(9000) // backlog defaults to SOMAXCONN
print(try listener.port()) // 9000
DispatchQueue.global().async {
guard let client = try? listener.acceptClientSocket() else { return }
defer { client.close() }
let line = (try? client.readLine()) ?? "" // reads up to \n, drops \r
try? client.writeUtf8("echo: \(line)\n")
}Other calls: read() (one byte), read(length:), writeData(_:), writeUInt8(_:), Socket(socketFileDescriptor:) to wrap an existing descriptor, and the class helpers Socket.setNoSigPipe(_:) and Socket.close(_:). ErrNumString.description() returns the current errno as a string.
HttpParser.readHttpRequest(_:)is public, butHttpParserhas no public initializer.HttpRequesthas no public initializer, so you only get one inside a handler ordispatch.HttpServerIODelegateanddelegateexist, but SwifterLite never callssocketConnectionReceived(_:).HttpResponseBodyWriteris only used internally to write bodies.SerializationErrorisn't thrown anywhere.request.addressis never set by the parser, so it's always"127.0.0.1".
Most apps only need HttpServer, but these are public too:
HttpServerIO: the accept loop and connection handling. SubclassHttpServer(notHttpServerIO, whose initializer is internal) and overridedispatch(_:)to route requests yourself.state(.starting,.running,.stopping,.stopped) andport()report the server's status.HttpRouter/MethodRoute: the route tree and theserver.get[...]/server.post[...]subscripts.Socket: a POSIX TCP socket withtcpSocketForListen,acceptClientSocket,read,readLine,writeUtf8,writeData,writeUInt8andport(). Sockets setSO_REUSEADDR,SO_REUSEPORT,SO_LINGER(0) andSO_NOSIGPIPE. These free the port as soon as a socket closes and stop the app from crashing on a broken pipe when it goes into the background.SocketError: errors thrown by socket calls. Each case includeserrnoas a string.
Sources/SwifterLite/
├── HttpServer.swift HttpServer: GET/POST route tables, dispatch, version
├── HttpServerIO.swift Accept loop, keep-alive, response writing, start/stop
├── HttpRouter.swift Route tree and :param matching
├── MethodRoute.swift server.get[...] / server.post[...] subscripts
├── HttpParser.swift Request line, headers and body parsing
├── HttpRequest.swift HttpRequest
├── HttpResponse.swift HttpResponse (200 / 404) and headers
├── HttpRespBody.swift HttpResponseBody (.json / .ping / .data / .byts)
├── HttpRespBodyWriter.swift HttpResponseBodyWriter protocol
├── Socket*.swift POSIX socket wrapper: open, accept, read, write, port, errors
└── TypeAliases.swift httpReq, dispatchHttpReq, httpWriter
mustang is the current branch and the one StarPlayrX uses. horse and viper are older branches whose last commits are from 2022.
- StarPlayrX (iOS and macOS): the app's local streaming server lives in each app's
Routes/folder, and it uses StarPlayrRadioKit for the upstream API calls.
BSD 3-Clause. See LICENSE. Swifter © 2014–2016 Damian Kołakowski. SwifterLite changes © 2022 Todd Bruss.
AgentiLoop: agentiloop.ai