While we have different protocols for transmitting data over the internet, sockets are a basic building block for understanding how data is transmitted. I have come across POSIX’s (or rather BSD’s) basic stream sockets while completing the excellent Systempraktikum for my studies. I’ll probably make a post on my motivation for studying at another time.

For the Systempraktikum, the task is to communicate with a (game) server through sockets (type man socket for details in your terminal) and a line-based protocol. Out of pure interest, I wondered how the same task would be facilitated with a more modern language, so I chose Swift to try it out. As TN3151: Choosing the right networking API points out, our best fit is the Network framework, available on all Apple platforms.

Connecting with NWConnection

The basic building block for us is NWConnection. In comparison to socket(), this provides us with a more general interface. To connect to our endpoint, we need to set up the connection and specify that we want to use TCP (to mimic what socket() does).

import Network
let connection = NWConnection(host: "example.com", port: 1337, using: .tcp)

We also need to prepare two important handlers: stateUpdateHandler, which lets us know when the connection status changes (i.e. if it was cancelled or when it is ready) and receive, which handles the data the socket sends to us.

Let’s start by creating the stateUpdateHandler:

connection.stateUpdateHandler = { state in
    switch state {
    case .setup:
        break
    case .waiting(let error):
        logger.info("waiting: \(error)")
    case .preparing:
        break
    case .ready:
        break
    case .failed(let error):
        logger.error("failed: \(error)")
        // maybe disconnect here
    case .cancelled:
        logger.error("cancelled")
        // maybe disconnect here
    @unknown default:
        break
    }
}

The logger was created before and instantiates a simple Logger:

import os
let logger = Logger(subsystem: "dev.timweiss.socket", category: "network")

Continue with building the receive handler, we’ll wrap it in a function to continuously receive data from the socket:

func startReceive() {
    connection.receive(minimumIncompleteLength: 1, maximumLength: 65536) { data, _, isDone, error in
        if let data = data, !data.isEmpty {
            let line = String(decoding: data, as: UTF8.self)
            receiveBuffered(content: line)
        }
        if let error = error {
            logger.error("Error: \(error)")
            // disconnect here
            return
        }
        if isDone {
            logger.info("Server disconnected!")
            // disconnect here
            return
        }
        startReceive()
    }
}

Because we want to handle what the socket sends line by line, we’re calling receiveBuffered(content:), which crudely consumes the data line by line and sends it off to handleLine, which you can freely implement:

var buffer = ""

func receiveBuffered(content: String) {
    logger.info("received: \(content)")
    
    // first, add to buffer
    buffer.append(content)
    
    // check if got any complete line
    if buffer.lastIndex(of: "\n") != nil {
        let lines = buffer.split(separator: "\n")
        let countLines = lines.count
        
        for (index, line) in lines.enumerated() {
            // incomplete last line, skipping handling for now
            if (index == (countLines - 1) && !buffer.hasSuffix("\n")) { break }
            handleLine(String(line))
            buffer = buffer.replacingOccurrences(of: line, with: "")
        }
    }
}

Starting to listen

We’ve created the most important building blocks. We can finally start listening to our socket with the following lines:

startReceive()
connection.start(queue: .main)
dispatchMain()

Sending data to the socket

Of course, communication also involves sending data from time to time, so we can call send to send something to the socket:

func send(line: String) {
    let data = Data("\(line)\n".utf8)
    connection.send(content: data, completion: NWConnection.SendCompletion.contentProcessed { error in
        if let error = error {
            logger.error("Client(Error): \(error)")
            // maybe disconnect
        } else {
            logger.info("Client: \(line)")
        }
    })
}

Full handler class

Thankfully, the great Quinn “The Eskimo!” from Apple Developer Relations provided a handy example, which I’ve extended to better fit my (line-by-line handling) purposes:

import Foundation
import Network
import os

class SocketConnector {
    let connection: NWConnection
    let logger: Logger
    
    init(hostName: String, port: Int) {
        let host = NWEndpoint.Host(hostName)
        let port = NWEndpoint.Port("\(port)")!
        self.logger = Logger(subsystem: "com.example.socket", category: "network")
        self.connection = NWConnection(host: host, port: port, using: .tcp)
    }
    
    func connect() {
        logger.info("connecting")
        self.connection.stateUpdateHandler = self.didChange(state:)
        self.startReceive()
        self.connection.start(queue: .main)
    }
    
    func disconnect() {
        self.connection.cancel()
        logger.info("did stop")
        exit(EXIT_FAILURE)
    }

    private func didChange(state: NWConnection.State) {
        switch state {
        case .setup:
            break
        case .waiting(let error):
            logger.info("waiting: \(error)")
        case .preparing:
            break
        case .ready:
            break
        case .failed(let error):
            logger.error("failed: \(error)")
            self.disconnect()
        case .cancelled:
            logger.error("cancelled")
            self.disconnect()
        @unknown default:
            break
        }
    }
    
    private func startReceive() {
        self.connection.receive(minimumIncompleteLength: 1, maximumLength: 65536) { data, _, isDone, error in
            if let data = data, !data.isEmpty {
                let line = String(decoding: data, as: UTF8.self)
                self.receiveBuffered(content: line)
            }
            if let error = error {
                self.logger.error("Error: \(error)")
                self.disconnect()
                return
            }
            if isDone {
                self.logger.info("Server disconnected!")
                self.disconnect()
                return
            }
            self.startReceive()
        }
    }
    
    var buffer: String = ""
    
    private func receiveBuffered(content: String) {
        logger.info("received: \(content)")
        
        // first, add to buffer
        buffer.append(content)
        
        // check if got any complete line
        if buffer.lastIndex(of: "\n") != nil {
            let lines = buffer.split(separator: "\n")
            let countLines = lines.count
            
            for (index, line) in lines.enumerated() {
                // incomplete last line, skipping handling for now
                if (index == (countLines - 1) && !buffer.hasSuffix("\n")) { break }
                handleLine(String(line))
                buffer = buffer.replacingOccurrences(of: line, with: "")
            }
        }
    }
    
    private func handleLine(_ line: String) {
        // your code to handle the line
    }
    
    func send(line: String) {
        let data = Data("\(line)\n".utf8)
        self.connection.send(content: data, completion: NWConnection.SendCompletion.contentProcessed { error in
            if let error = error {
                self.logger.error("Client(Error): \(error)")
                self.disconnect()
            } else {
                self.logger.info("Client: \(line)")
            }
        })
    }
    
    static func run() -> Never {
        let conn = SocketConnector(hostName: "example.com", port: 1337)
        conn.connect()
        dispatchMain()
    }
}

You can start the connection in any other place with SocketConnector.run(). If you need to access any of its functions, however, you need to create the SocketConnector first and then connect() to it, but don’t forget to dispatchMain() for it to run.