Simple Web Server in Swift
June, 2024
I’ve been wanting to improve the built-in web server of the static site generator I use for these articles, since I need to stop and restart the server any time I want to see my latest changes. It would be really nice to have the server hot reload files so I just have to refresh my browser to see the latest edits. Surely it should be easy to find a nice Swift package for a basic web server?
One of the first candidates that I came across was Swifter which bills itself as a “tiny http server engine written in Swift”. That sounds like a promising start! Hmm, it’s well over 2000 lines of code... we have different ideas of “tiny” 😅
The code is pretty clean and understandable though and it definitely does what I want, so maybe I can strip it down to only what I need:
- Run only on localhost
- Support only
GET
requests - Serve image files straight from disk
- Serve generated HTML files
I definitely didn’t need POST
requests, multi-part form decoding or WebSockets support, so that was easy enough to remove. But then most of what I had left was code like this:
var addr = sockaddr_in6(
sin6_len: UInt8(MemoryLayout<sockaddr_in6>.stride),
sin6_family: UInt8(AF_INET6),
sin6_port: port.bigEndian,
sin6_flowinfo: 0,
sin6_addr: in6addr_any,
sin6_scope_id: 0
)
...and that’s when my eyes started glazing over 😵💫. This looks like C code I was writing last century! Surely opening a network socket using a modern language is easier than this in the year 2024. Apple has really nice APIs for talking to a HTTP server, so there must be something I can use for building a server. Time for some more research.
Searching the SDK docs, I came across SocketPort which looks like it should be able to replace some of the gnarlier C APIs. There’s also NSStream which looks like it can read and write to the socket. How do I bind the data streams to the socket though, do I really still need to drop down to file descriptors?
More searching turned up CFStreamCreatePairWithSocketToHost which sounds like just what I need. Yes ok it uses CoreFoundation so we’re going backwards a bit here, but I can live with that. Oh wait, it’s deprecated. With no replacement API suggested 😒
Hang on, I remember rewriting a network status monitor at work recently that used a modern class called NWPathMonitor
. Turns out there is a modern framework for networking – helpfully called Network
– I’d forgotten all about it. And it has a class called NWListener
for handling incoming connections, perfect. Let’s get cracking.
HTTP messages
The format of basic HTTP messages is fairly straightforward, you can find what you need just from the Wikipedia entry for HTTP. A minimal HTTP request looks like this:
GET /some/path?param=foo HTTP/1.1
Host: www.example.com
The first line contains the method (GET
), the resource path and the HTTP version separated by spaces. Following this are the request headers which are colon-separated key/value pairs.
A response to this request can be as simple as:
HTTP/1.1 200 OK
Content-Length: 12
Hello world!
The first line is the protocol version, response status code and reason, again separated by spaces. Next are the response headers, a blank line, then any response body content.
Simple right? Let’s get a server running that serves just this response.
Handling connections
Looking at NWListener
, it has a callback-based API for dealing with state changes and new connections:
import Network
final class Server {
let listener: NWListener
init(port: NWEndpoint.Port) {
self.listener = try! NWListener(using: .tcp, on: port)
listener.stateUpdateHandler = {
print("listener.state = \($0)")
}
listener.newConnectionHandler = handleConnection
listener.start(queue: .main)
}
func handleConnection(_ connection: NWConnection) {
print("New connection!")
}
}
The
port
is just a number that identifies which service on a host you’re trying to talk to. For example, one host might have a web server, a mail server and an FTP all running at once, so they need to be on different ports. Every common protocol has a default port number (port 80 for HTTP) but for debug servers we normally pick some larger port number like 8000. That just means when you connect to your server you add the port number in your browser address bar likehttp://localhost:8000
.
I created a new Swift package that builds a macOS command-line app, since I don’t need a UI for this server. In main.swift
, start the server like this:
let server = Server(port: 8000)
RunLoop.current.run()
The second line is so that the app stays running and listening for connections, instead of immediately exiting.
Let’s fill out that handleConnection
function to actually open the connection:
func handleConnection(_ connection: NWConnection) {
print("New connection!")
connection.stateUpdateHandler = {
print("connection.state = \($0)")
}
connection.start(queue: .main)
}
Since we’re just building a simple debug server, receiving events on the main queue is fine.
Start the app and run curl -v localhost:8000
in your terminal to make a request to it, and you should see this:
listener.state = ready
New connection!
connection.state = preparing
connection.state = ready
As soon as we have a connection, we can start reading the HTTP request data from it. Add this function and call it from the end of handleConnection
:
func receive(from connection: NWConnection) {
connection.receive(
minimumIncompleteLength: 1,
maximumLength: connection.maximumDatagramSize
) { content, _, isComplete, error in
if let error {
print("Error: \(error)")
} else if let content {
print("Received request!")
print(String(data: content, encoding: .utf8)!)
}
if !isComplete {
self.receive(from: connection)
}
}
}
This reads as much data as we can from the connection into content
and prints it out. Since web browsers reuse connections for multiple requests (“keep alive”) we finish by waiting for the next request.
Sending a response
Finally, let’s actually send a response, by calling this after “Received request”:
func respond(on connection: NWConnection) {
let response = """
HTTP/1.1 200 OK
Content-Length: 12
Hello world!
"""
connection.send(
content: response.data(using: .utf8),
completion: .idempotent
)
}
You can actually load localhost:8000
in your web browser now and see the reply displayed.
Boom! A minimal web server in 50 lines of Swift 👊
Ok it’s not super useful as is, let’s add a couple of helper types to wrap the request and response messages.
Parsing HTTP requests
Here’s how we could parse the request:
struct Request {
let method: String // "GET"
let path: String // "/foo/bar"
let httpVersion: String // "HTTP/1.1"
let headers: [String: String]
init?(_ data: Data) {
let str = String(data: data, encoding: .utf8)!
let lines = str.components(separatedBy: "\r\n")
guard let firstLine = lines.first,
let lastLine = lines.last, lastLine.isEmpty else {
return nil
}
let parts = firstLine.components(separatedBy: " ")
guard parts.count == 3 else {
return nil
}
self.method = parts[0]
self.path = parts[1].removingPercentEncoding!
self.httpVersion = parts[2]
let headerPairs = lines.dropFirst()
.map { $0.split(separator: ":", maxSplits: 1) }
.filter { $0.count == 2 }
.map { ($0[0].lowercased(), $0[1].trimmingCharacters(in: .whitespaces)) }
self.headers = Dictionary(headerPairs, uniquingKeysWith: { old, _ in old })
}
}
This ensures that the message is in the correct format for a GET
request; it assumes there is no request body. Some things to note:
- Lines in the request should end with a carriage return and line feed, so this splits on
\r\n
- There must be a blank line at the end of the request
- Header lines should only be split on the first colon, because the value can also contain colons (e.g.
Host: localhost:8000
) - Header keys are lowercased because they should be treated as case-insensitive
- This is not production quality code! Those force-unwraps should be handled properly at least 😬
Building HTTP responses
Creating a valid response is not too tricky, it’s basically just outputting all the lines joined with CRLF:
struct Response {
let httpVersion = "HTTP/1.1"
let status: Int
let reason: String
let headers: [String: String]
let body: Data
var messageData: Data {
let statusLine = "\(httpVersion) \(status) \(reason)"
var lines = [statusLine]
lines.append(contentsOf: headers.map({ "\($0.key): \($0.value)" }))
lines.append("")
lines.append("") // adds extra required blank line
let header = lines.joined(separator: "\r\n").data(using: .utf8)!
return header + body
}
}
A response can now be created with:
Response(
status: 200,
reason: "OK",
headers: ["Content-Length": "12"],
body: "Hello world!".data(using: .utf8)!
)
There are plenty of improvements that could be made here, such as automatically determining the Content-Length
or using enums of known status codes and header names.
MIME types
One feature that I needed was serving images, and for that I need to set a header that says what the type of file is, such as Content-Type: image/jpeg
. That code is known as the MIME type of the file, and Swifter has a big map of known types.
We can do without that though, because macOS can figure out the MIME type for us. Using the UniformTypeIdentifiers
framework and calling UTType.jpeg.preferredMIMEType
will return “image/jpeg”. There’s even an API that will determine the type for any file.
I’m going to extend the Response
type to make it easy to return images, plus throw in a couple of other improvements:
import UniformTypeIdentifiers
struct Response {
let httpVersion = "HTTP/1.1"
let status: Status
let headers: [Header: String]
let body: Data
enum Status: Int, CustomStringConvertible {
case ok = 200
case notFound = 404
var description: String {
switch self {
case .ok: return "OK"
case .notFound: return "Not Found"
}
}
}
enum Header: String {
case contentLength = "Content-Length"
case contentType = "Content-Type"
}
init(
_ status: Status = .ok,
headers: [Header: String] = [:],
body: Data = Data(),
contentType: UTType? = nil
) {
self.status = status
self.body = body
self.headers = headers.merging(
[
.contentLength: String(body.count),
.contentType: contentType?.preferredMIMEType,
].compactMapValues { $0 },
uniquingKeysWith: { _, new in new }
)
}
init(_ text: String, contentType: UTType = .plainText) {
self.init(body: text.data(using: .utf8)!, contentType: contentType)
}
init(file url: URL) throws {
try self.init(
body: Data(contentsOf: url),
contentType: url.resourceValues(forKeys: [.contentTypeKey]).contentType
)
}
var messageData: Data {
let statusLine = "\(httpVersion) \(status.rawValue) \(status)"
var lines = [statusLine]
lines.append(contentsOf: headers.map({ "\($0.key.rawValue): \($0.value)" }))
lines.append("")
lines.append("") // adds extra blank line
let header = lines.joined(separator: "\r\n").data(using: .utf8)!
return header + body
}
}
Now different types of HTTP responses can be easily constructed like so:
Response("Hello world")
Response("<html><body>Hi!</body></html>", contentType: .html)
try! Response(file: URL(filePath: "~/Desktop/somepic.jpg"))
Response(.notFound)
There are of course loads more handy features you could build on top of this, like a router to map request paths to different responses. But that’s how you end up with 2000+ lines instead of 50 😆
It’s always nice to build something exactly how you want it from the ground up though, especially when you can use nice modern APIs!
The source code from this post can be found on Github. If you spot a bug, please create an issue on there.
Any comments or questions about this post? ✉️ nick @ this domain.
— Nick Randall