Note: This update is an early-access release. This chapter has not yet been updated to Vapor 4.

WebSockets, like HTTP, define a protocol used for communication between two devices. Unlike HTTP, the WebSocket protocol is designed for realtime communication. WebSockets can be a great option for things like chat or other features that require realtime behavior. Vapor provides a succinct API to create a WebSocket server or client. This chapter focuses on building a basic server.

In this chapter, you’ll build a simple client-server application that allows users to share their current location with others, who can then view this on a map in realtime.

Tools

Testing WebSockets can be a bit tricky since you can’t visit a URL in the browser or use a simple CURL request. To work around this, you’re going to utilize an aptly named Google Chrome extension called Simple WebSocket Client. It can be installed, for free, from https://chrome.google.com/webstore/detail/simple-websocket-client/pfdhoblngboilpfeibdedpjgfnlcodoo.

After you’ve installed the tool, open it in Chrome.

A basic server

Now your tools are ready, it’s time to set up a very basic WebSocket server. Copy this chapter’s starter project to your favorite location and open a Terminal window in that directory.

cd location-track-server
vapor xcode -y

Echo server

Open websockets.swift and add the following to the end of sockets(_:) to create an echo endpoint:

// 1
websockets.get("echo-test") { ws, req in
  print("ws connnected")

  // 2
  ws.onText { ws, text in
    print("ws received: \(text)")
    ws.send("echo - \(text)")
  }
}

iOS project

The materials for this chapter include a nearly complete iOS app. You’ll add the ability to follow a user later. The app includes a WebSocket client implementation written by Josh Baker. You can find more information and his original source code at https://github.com/tidwall/SwiftWebSocket.

sending sending echo 1521066998.62272
got message: echo - sending echo 1521066998.62272
sending sending echo 1521066999.33329
got message: echo - sending echo 1521066999.33329
sending sending echo 1521066999.90972
got message: echo - sending echo 1521066999.90972

Server word API

Now you’ve verified your client and server can communicate, it’s time to add more capabilities to the server. The server starter project includes a random word generator you’ll use to create tracking session IDs.

router.get("word-test") { request in
  return wordKey(with: request)
}
exercise.green.power

Session Manager

Your server app supports two types of client users:

Create a session

When a Poster creates a new tracking session, you must assign a new ID and return that to the user. The starter project includes a thread-safe LockedDictionary implementation to make storing session information simple.

private(set) var sessions: 
  LockedDictionary<TrackingSession, [WebSocket]> = [:]
func createTrackingSession(for request: Request) 
  -> Future<TrackingSession> {
  // 1
  return wordKey(with: request)
    .flatMap(to: TrackingSession.self) { [unowned self] key in
      // 2
      let session = TrackingSession(id: key)
            
      // 3
      guard self.sessions[session] == nil else {
        return self.createTrackingSession(for: request)
      }
            
	  // 4
      self.sessions[session] = []
            
	  // 5
      return Future.map(on: request) { session }
    }
}

Update location

The starter project includes a Location model that conforms to Content. Take advantage of this and add a bit of magic to make it easy to send locations as JSON. Close your Xcode project. In Terminal, enter the following:

touch Sources/App/WebSocket+Extensions.swift
vapor xcode -y
import Vapor
import WebSocket
import Foundation

extension WebSocket {
  func send(_ location: Location) {
    let encoder = JSONEncoder()
    guard let data = try? encoder.encode(location) else { 
      return
    }

    send(data)
  }
}
func update(_ location: Location,
            for session: TrackingSession) {
  guard let listeners = sessions[session] else {
    return
  }

  listeners.forEach { ws in 
    ws.send(location)
  }
}

Close session

You’ve built logic that allows a Poster to create and update a tracking session. The final capability a Poster needs is that of closing a session.

func close(_ session: TrackingSession) {
  guard let listeners = sessions[session] else {
    return
  }

  listeners.forEach { ws in
    ws.close()
  }

  sessions[session] = nil
}

Observer behaviors

The Tracking Session Manager must provide two interactions for Observers:

func add(listener: WebSocket, to session: TrackingSession) {
  // 1
  guard var listeners = sessions[session] else {
    return
  }

  listeners.append(listener)
  sessions[session] = listeners

  // 2
  listener.onClose.always { [weak self, weak listener] in
    guard let listener = listener else {
      return
    }

    self?.remove(listener: listener, from: session)
  }
}
    
func remove(listener: WebSocket, 
            from session: TrackingSession) {
  // 3
  guard var listeners = sessions[session] else {
    return
  }

  listeners = listeners.filter { $0 !== listener }
  sessions[session] = listeners
}

Endpoints

Now that TrackingSessionManager is complete, you must create some endpoints to make its behaviors accessible to clients. The endpoints that support the Poster can all be implemented as regular HTTP routes. It doesn’t need to use WebSockets because it doesn’t require realtime updates.

Create

Open routes.swift and add the following to the end of routes(_:):

router.post("create", use: sessionManager.createTrackingSession)
curl -X POST http://localhost:8080/create
{ "id": "pumped.arch.dime" }

Close

Next up, it’s time to implement “close” support. To do this, you’ll create an endpoint at /close/:tracking-session-id. Add the following to the end of routes(_:):

router.post(
  "close", 
  TrackingSession.parameter) { req -> HTTPStatus in
    let session = try req.parameters.next(TrackingSession.self)
    sessionManager.close(session)
    return .ok
}
curl -w "%{response_code}\n" -X POST \
  http://localhost:8080/close/<tracking.session.id.goes.here>

Update

Finally, the Poster needs an endpoint to receive location updates. You’ll create an endpoint at /update/:tracking-session-id to implement this. Add the following to the end of routes(_:):

// 1
router.post(
  "update", 
  TrackingSession.parameter) { req -> Future<HTTPStatus> in
    // 2
    let session = try req.parameters.next(TrackingSession.self)
    // 3
    return try Location.decode(from: req)
      .map(to: HTTPStatus.self) { location in
        // 4
        sessionManager.update(location, for: session)
        return .ok
  }
}
curl -w "%{response_code}\n" \
  -d '{"latitude": 37.331, "longitude": -122.031}' \
  -H "Content-Type: application/json" -X POST \
  http://localhost:8080/update/<tracking.session.id.goes.here>

Observer endpoint

An Observer only needs one endpoint, used to connect a WebSocket. To do this, you must define a new WebSocket route. Open websockets.swift and add the following at the end of sockets(_:):

// 1
websockets.get("listen", TrackingSession.parameter) { ws, req in
  // 2
  let session = try req.parameters.next(TrackingSession.self)
  // 3
  guard sessionManager.sessions[session] != nil else {
    ws.close()
    return
  }
  // 4
  sessionManager.add(listener: ws, to: session)
}

iOS follow location

As you saw earlier, the starter project iOS app is nearly complete. All that remains is for you to implement its WebSocket abilities. When a user wishes to observe a Poster, the app prompts for a tracking session ID. It then calls startSocket() to register as an Observer and process the location updates.

func startSocket() {
  // 1
  let ws = WebSocket("ws://\(host)/listen/\(session.id)")

  // 2
  ws.event.close = { [weak self] code, reason, clean in
    self?.navigationController?
      .popToRootViewController(animated: true)
  }

  // 3
  ws.event.message = { [weak self] message in
    guard let bytes = message as? [UInt8] else { 
      fatalError("invalid data")
    }
    let data = Data(bytes: bytes)
    let decoder = JSONDecoder()
    do {
      // 4
      let location = try decoder.decode(
        Location.self,
        from: data
      )
      // 5
      self?.focusMapView(location: location)
    } catch {
      print("decoding error: \(error)")
    }
  }
}
curl -X POST http://localhost:8080/create
{ "id": "rabbit.callsign.skirt" }

curl -w "%{response_code}\n" \
  -d '{"latitude": 37.331, "longitude": -122.031}' \
  -H "Content-Type: application/json" -X POST \
  http://localhost:8080/update/<tracking.session.id.goes.here>

curl -w "%{response_code}\n" \
  -d '{"latitude": 37.332, "longitude": -122.030}' \
  -H "Content-Type: application/json" -X POST \
  http://localhost:8080/update/<tracking.session.id.goes.here>
curl -w "%{response_code}\n" \
  -d '{"latitude": 51.510, "longitude": -0.134}' \
  -H "Content-Type: application/json" -X POST \
  http://localhost:8080/update/<tracking.session.id.goes.here>

Where to go from here?

You’ve done it. Your iOS Application communicates in realtime via WebSockets with your Swift server. Many different kinds of apps can benefit from the instantaneous communications made possible by WebSockets, including things such as chat applications, games, live stock tickers and so much more. If the app you imagine needs to respond in real time, WebSockets may be your answer!

Challenges

For more practice with WebSockets, try these challenges:

Have a technical question? Want to report a bug? You can ask questions and report bugs to the book authors in our official book forum here.

Have feedback to share about the online reading experience? If you have feedback about the UI, UX, highlighting, or other features of our online readers, you can send them to the design team with the form below:

© 2020 Razeware LLC

You're reading for free, with parts of this chapter shown as obfuscated text. Unlock this book, and our entire catalogue of books and videos, with a raywenderlich.com Professional subscription.

Unlock Now

To highlight or take notes, you’ll need to own this book in a subscription or purchased by itself.