Creating a WebSocket server on iOS using NWConnection

In the last couple of blog posts, we saw how to write a client-side code to handle web socket connections using native and third-party support. In this post, I will go over creating a web socket server on iOS in Swift using newly introduced NWConnection in iOS 13.

New Project

To create a server, let's start with a new Mac project named SwiftWebSocketServer which will run indefinitely and send over the stock quote values over time.

New File

To get started with the web socket server, we will introduce a new file name SwiftWebSocketServer in the project, which will contain the web socket related code.

Initialization of WebSocket server parameters

Before we kick off our server, there is some initialization we need to perform. This involves initializing an instance of NWListener with necessary parameters such as endpoint configurations and/or whether WebSocket protocol should automatically reply to pings from the client.

We will also keep track of connected clients so that we can relay messages to them or remove them from messaging queue if the server receives a request to unsubscribe and close the specific connection.


class SwiftWebSocketServer {
    var listener: NWListener
    var connectedClients: [NWConnection] = []
    
    init(port: UInt16) {
        
        let parameters = NWParameters(tls: nil)
        parameters.allowLocalEndpointReuse = true
        parameters.includePeerToPeer = true
        
        let wsOptions = NWProtocolWebSocket.Options()
        wsOptions.autoReplyPing = true
        
        parameters.defaultProtocolStack.applicationProtocols.insert(wsOptions, at: 0)
        
        do {
            if let port = NWEndpoint.Port(rawValue: port) {
                listener = try NWListener(using: parameters, on: port)
            } else {
                fatalError("Unable to start WebSocket server on port \(port)")
            }
        } catch {
            fatalError(error.localizedDescription)
        }
    }
}

Starting a WebServer

Next, we will add a code to start a web server. In this method, we will kick off the server to run on the port specified in our initializer. We will do the following while starting a web server,

  1. Start the timer so that server is periodically sending values to connected clients
  2. Add the newConnectionHandler to server listener object so that we can actions against new client connections
  3. Add the stateUpdateHandler to update the server on its state change so that we can take appropriate actions
  4. Start the listener on a dedicated dispatch queue so that it can start listening to incoming client connections

var timer: Timer?
...

func startServer() {
                        
        let serverQueue = DispatchQueue(label: "ServerQueue")
        
        listener.newConnectionHandler = { newConnection in
            
        }
        
        listener.stateUpdateHandler = { state in
            print(state)
            switch state {
            case .ready:
                print("Server Ready")
            case .failed(let error):
                print("Server failed with \(error.localizedDescription)")
            default:
                break
            }
        }
        
        listener.start(queue: serverQueue)
        startTimer()
    }
  	
    func startTimer() {
        timer = Timer.scheduledTimer(withTimeInterval: 5.0, repeats: true, block: { timer in
            
            guard !self.connectedClients.isEmpty else {
                return
            }
            
            self.sendMessageToAllClients()
            
        })
        timer?.fire()
    }
    
    func sendMessageToAllClients() {
        let data = getTradingQuoteData()
        for (index, client) in self.connectedClients.enumerated() {
            print("Sending message to client number \(index)")
            try! self.sendMessageToClient(data: data, client: client)
        }
    }
    
    func sendMessageToClient(data: Data, client: NWConnection) throws {
        let metadata = NWProtocolWebSocket.Metadata(opcode: .binary)
        let context = NWConnection.ContentContext(identifier: "context", metadata: [metadata])
        
        client.send(content: data, contentContext: context, isComplete: true, completion: .contentProcessed({ error in
            if let error = error {
                print(error.localizedDescription)
            } else {
                // no-op
            }
        }))
    }
    
    func getTradingQuoteData() -> Data {
        let data = SocketQuoteResponse(t: "trading.quote", body: QuoteResponseBody(securityId: "100", currentPrice: String(Int.random(in: 1...1000))))
        return try! JSONEncoder().encode(data)
    }
    
 

And here are all the Encodable structs used to send data back to the client,


struct SocketQuoteResponse: Encodable {
    let t: String
    let body: QuoteResponseBody
}

struct QuoteResponseBody: Encodable {
    let securityId: String
    let currentPrice: String
}

struct ConnectionAck: Encodable {
    let t: String
    let connectionId: Int
}

There is a lot going on inside startTimer method. Let's go one by one to see how it works,

  1. startTimer method kicks off the timer which executes the function sendMessageToClient every 5 seconds
  2. sendMessageToClient constructs a mock trading quote object of type SocketQuoteResponse, encodes it into Data and uses sendMessageToClient method to send it to all the connected clients
  3. sendMessageToAllClients method iterates over all connected clients, get the mock data and the connection and send the data to each of them in sequence

Handling and setting callbacks for incoming connections

Now that we are ready to accept new connections, what next? There are three things we need to do,

  1. Adding a callback for receiving messages from the client

In order to continue receiving messages from clients, we need to add a callback to the incoming connection. Due to Apple's quirky receiveMessage API, we need to set this callback first time and then every time after we receive a message. If you set it up just once, you will receive a client-initiated message just once.

2. Adding stateUpdateHandler callback

In order to get periodic updates on the client's state, we also add stateUpdateHandler a callback that indicates the state client is in. For example, ready, failure or waiting

3. Third and final, we need to start the new connection on the specified queue. In this case, we will run it on the same queue on which our listener is running.


listener.newConnectionHandler = { newConnection in
                print("New connection connecting")
                
                func receive() {
                    newConnection.receiveMessage { (data, context, isComplete, error) in
                        if let data = data, let context = context {
                            print("Received a new message from client")
                            receive()
                        }
                    }
                }
                receive()
                
                newConnection.stateUpdateHandler = { state in
                    switch state {
                    case .ready:
                        print("Client ready")
                        try! self.sendMessageToClient(data: JSONEncoder().encode(["t": "connect.connected"]), client: newConnection)
                    case .failed(let error):
                        print("Client connection failed \(error.localizedDescription)")
                    case .waiting(let error):
                        print("Waiting for long time \(error.localizedDescription)")
                    default:
                        break
                    }
                }

                newConnection.start(queue: serverQueue)
            }

Right after the client is ready, we will send the connected message indicating the server has successfully established the connection.

Handling messages sent by the client

In the next step, we will set up to handle messages sent by the client. There are two kinds of messages we expect. The first one is to subscribe for updates and the second one is to unsubscribe from updates so that they can stop receiving messages.

  1. Subscribing to quote

 When the server sends a payload with a request to subscribe for the quote, we will add that connection to connectedClients array and immediately send an acknowledgment indicating we received their request along with a unique connection identifier.

 Immediately after sending an ack, we will also send them the first stock quote value and then successive quote values separated by the interval of 5 seconds as specified by the timer.

 As long as the client is subscribed to quotes, they will keep receiving quote values.

2. Unsubscribing from quote

 When the user navigates away from the quotes page or the client no longer wishes to receive messages, it will send an unsubscribe payload to server. Receiving it, server will remove that client from connectedClients array,  cancel the connection and cause all update handlers to be canceled.


listener.newConnectionHandler = { newConnection in
                print("New connection connecting")
                
                func receive() {
                    newConnection.receiveMessage { (data, context, isComplete, error) in
                        if let data = data, let context = context {
                            print("Received a new message from client")
                            try! self.handleMessageFromClient(data: data, context: context, stringVal: "", connection: newConnection)
                            receive()
                        }
                    }
                }
                receive()
                
                .....
                ...
                ..
                
func handleMessageFromClient(data: Data, context: NWConnection.ContentContext, stringVal: String, connection: NWConnection) throws {
    
    if let message = try? JSONSerialization.jsonObject(with: data, options: []) as? [String: Any] {
        if message["subscribeTo"] != nil {
                
            print("Appending new connection to connectedClients")
            
            self.connectedClients.append(connection)
            
            self.sendAckToClient(connection: connection)
            
            let tradingQuoteData = self.getTradingQuoteData()
            try! self.sendMessageToClient(data: tradingQuoteData, client: connection)

        } else if message["unsubscribeFrom"] != nil {
            
            print("Removing old connection from connectedClients")
            
            if let id = message["unsubscribeFrom"] as? Int {
                let connection = self.connectedClients.remove(at: id)
                connection.cancel()
                print("Cancelled old connection with id \(id)")
            } else {
                print("Invalid Payload")
            }
        }
    } else {
        print("Invalid value from client")
    }
}

func sendAckToClient(connection: NWConnection) {
    let model = ConnectionAck(t: "connect.ack", connectionId: self.connectedClients.count - 1)
    let data = try! JSONEncoder().encode(model)
    
    try! self.sendMessageToClient(data: data, client: connection)
}

Enabling network capabilities

Since I am building a Mac app, I need to enable network capabilities for my app by adding App Sandbox capability and enabling incoming and outgoing network connections.

  1. Click on top-level Xcode project in the left pane
  2. Select App target under Targets and choose Debug
  3. Select Signing Capabilities and click on +Capability
  4. Choose App Sandbox, add that capability and check Incoming Connections and Outgoing Connections under Network tab for that capability

Running the server

To run the server, we will keep our code as soon as the app is finished launching. To do that, go to your AppDelegate class and add the following code inside applicationDidFinishLaunching method,


func applicationDidFinishLaunching(_ aNotification: Notification) {
    let server = SwiftWebSocketServer(port: 8080)
    server.startServer()
}

Once the app is running, it will initiate and run our web socket on port 8080.

Verifying Server Connection

In order to verify your server is correctly running on a given port number, run the following command from the Mac terminal and it should output the process running on that port,


sudo lsof -i :8080

// Output
COMMAND    PID    USER         FD  TYPE                     DEVICE SIZE/OFF  NODE NAME
SwiftWebS  43441  jayeshkawli  4u  IPv6 0xb931cedecd39f349  0t0              TCP *:http-alt (LISTEN)

Summary

And that's how we created a web socket server running on a specific port on iOS using NWConnection APIs. Now that our server is ready and running, let's focus on creating a client that will establish a connection and communicate with this endpoint. In the next article, we will build the full ecosystem that involves both client and server interaction and we will see how they collaborate with each other in real-time. We will see how to do that in the next post.