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,
- Start the timer so that server is periodically sending values to connected clients
- Add the
newConnectionHandler
to server listener object so that we can actions against new client connections - Add the
stateUpdateHandler
to update the server on its state change so that we can take appropriate actions - 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,
startTimer
method kicks off the timer which executes the functionsendMessageToClient
every 5 secondssendMessageToClient
constructs a mock trading quote object of typeSocketQuoteResponse
, encodes it intoData
and usessendMessageToClient
method to send it to all the connected clientssendMessageToAllClients
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,
- 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.
- 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.
- Click on top-level Xcode project in the left pane
- Select App target under
Targets
and chooseDebug
- Select
Signing Capabilities
and click on+Capability
- Choose
App Sandbox
, add that capability and checkIncoming Connections
andOutgoing Connections
underNetwork
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.