Chapters

Hide chapters

Server-Side Swift with Vapor

Third Edition - Early Acess 1 · iOS 13 · Swift 5.2 - Vapor 4 Framework · Xcode 11.4

Before You Begin

Section 0: 3 chapters
Show chapters Hide chapters

Section I: Creating a Simple Web API

Section 1: 13 chapters
Show chapters Hide chapters

29. WebSockets
Written by Logan Wright

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.

Enter the following to build and open an Xcode project:

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)")
  }
}

Here’s what this does:

  1. Create a route handler for the echo-test endpoint. It logs a message to the console each time it connects.
  2. Create a listener that fires each time the endpoint receives text. It logs the received text to the console, and then echoes it back to the sender after prepending echo -.

In Xcode’s scheme selector, choose the Run scheme and My Mac as the destination. Build and run. In Chrome, open Simple WebSocket Client and enter ws://localhost:8080/echo-test in the URL field. Click Open and Status will change to OPENED.

Check the Xcode console and you’ll see ws connected.

Enter a message in Simple WebSocket Client, and you’ll see your server respond with an appropriate echo.

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.

Build and run your server project; leave it running. Now build and run the iOS project in the simulator. Tap the Echo button on the home screen several times. You should see output similar to the following in the Xcode console.

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

Awesome! Your server is communicating with the iOS app via a WebSocket!

Note: If you try to run the iOS app on a device, you’ll need to change the definition of host in WebServices.swift.

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.

To demonstrate this generator, open routes.swift and add the following to the end of routes(_:):

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

This defines a GET handler for the endpoint word-test that simply returns the result of a call to wordKey(with:).

Build and run. Visit http://localhost:8080/word-test in your browser. You’ll see a result similar to the following:

exercise.green.power

Now you’ve built the basic structure of the server, it’s time to add the location sharing endpoints.

Session Manager

Your server app supports two types of client users:

  • Poster: a client sharing location for others to see.
  • Observer: a client watching and charting a Poster’s location.

Posters and Observers are connected via a TrackingSession, identified with a random word generated as you saw earlier.

For the purposes of this tutorial, you’re going to create a TrackingSessionManager to coordinate all of this. It will create tracking sessions, receive updates from Posters and notify Observers of those updates.

Note: The solution you’ll create is not scalable and only works with a single server instance. To make this more scalable, you’d need to connect TrackingSessionManager to a large-scale, realtime database such as Redis.

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.

Open SessionManager.swift and add the following inside TrackingSessionManager:

private(set) var sessions: 
  LockedDictionary<TrackingSession, [WebSocket]> = [:]

Each TrackingSession is associated with an array of WebSockets, each corresponding to an Observer.

A Poster needs a way to create a tracking session. Add the following to the end of TrackingSessionManager:

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 }
    }
}

Here’s what this does:

  1. Generate a new session ID. wordKey(with:) returns a Future<String> so it must be unwrapped to use in subsequent steps.
  2. Create a TrackingSession for this new session, using the created ID.
  3. Ensure the session ID is unique. If not, call yourself recursively to try again.
  4. Record the new TrackingSession and give it an empty list of Observers.
  5. Wrap the session in a future and return it.

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

This adds the file in the correct place in your project structure and generates an updated Xcode project. Add the following implementation in WebSocket+Extensions.swift:

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)
  }
}

This method simply converts the Location model into JSON for transmission over the wire.

Open SessionManager.swift and add the following to the end of the class:

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

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

When a Poster sends an updated location to the server, this sends that new location to each registered Observer.

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.

Add the following to the end of the TrackingSessionManager class:

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

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

  sessions[session] = nil
}

This closes each Observer’s WebSocket and removes the TrackingSession from the list of active sessions.

With all of the Poster’s required behaviors complete, it’s time to implement the Observer interactions.

Observer behaviors

The Tracking Session Manager must provide two interactions for Observers:

  • Register for updates.
  • Disconnect from the server.

Open SessionManager.swift and add the following to the end of TrackingSessionManager:

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
}

Here’s what this does:

  1. Verify that the session exists and add the Observer’s WebSocket to the list of listeners.

  2. Register an onClose handler that triggers when the Observer’s client closes the WebSocket. This handler removes the WebSocket from the list of listeners.

  3. Verify the session exists and remove the Observer’s WebSocket from the list of 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)

An empty POST request to /create will create and return a new tracking session to the client.

Build and run. Test session creation by entering the following in Terminal:

curl -X POST http://localhost:8080/create

The server will return a JSON object that looks something like this:

{ "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
}

This code receives the TrackingSession as a parameter, closes the session with the session manager, and subsequently returns an empty HTTPResponse to indicate success.

Build and run. Create a session as you did previously. Use the returned tracking session ID to send a close request as follows:

curl -w "%{response_code}\n" -X POST \
  http://localhost:8080/close/<tracking.session.id.goes.here>

You’ll see 200 printed on the next line, showing the server sent a 200 OK HTTP status.

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
  }
}

Here’s what this does:

  1. Create a POST handler for the endpoint.
  2. Extract the tracking session ID from the URL.
  3. Create a Location from the POST request’s body.
  4. Call the session manager to broadcast the updated location and then return a 200 OK HTTP status.

Build and run. Create a session as you did earlier. Use the returned tracking session ID to send an update request as follows:

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>

That’s it! You have implemented everything your Posters need!

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)
}

Here’s what this does:

  1. Create a WebSocket handler for the endpoint /listen/:tracking-session-id.
  2. Extract the tracking session ID from the URL.
  3. Ensure the session is still valid. Close the WebSocket if it isn’t.
  4. Add the WebSocket to the session as an Observer.

That’s it! Your server is complete and ready to run your new location sharing application. Build and run. Leave the server running in one window and open the iOS app’s project in another.

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.

Open FollowViewController.swift and replace the existing startSocket() with the following:

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)")
    }
  }
}

Here’s the play-by-play:

  1. Open a WebSocket to the server using the tracking session ID the user entered.
  2. Set up an event handler that’s called when the WebSocket is closed.
  3. Set up an event handler that’s called when the WebSocket receives data.
  4. Decode the received message into a Location.
  5. Plot the received location on the map.

Build and run. Tap the Echo button to verify that the app and your server are communicating. You’ll use curl commands in Terminal to simulate a Poster. Enter the following in Terminal to create a new session:

curl -X POST http://localhost:8080/create

As you’ve come to expect, you’ll receive a JSON response containing your tracking session ID. It will look something like this:

{ "id": "rabbit.callsign.skirt" }

In the iOS Simulator, tap FOLLOW and enter the tracking session ID and tap Track.

You now need to send a location update.

In Terminal, enter the following, inserting your tracking session ID as appropriate:

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>

The app on the simulator will immediately jump to the newly received location, which happens to be Apple’s Headquarters.

Verify the map updates as the location changes by changing the numbers slightly.

Enter the following in Terminal and watch the map update:

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>

Make one final test. Enter the following in Terminal:

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>

The map will jump to Piccadilly Circus in London!

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:

  • Add some more data to the application to personalize it a bit. Maybe an Observer includes a name or some other identifying information so the Poster knows who’s watching.
  • Provide the Poster with a live list of the Observers.
  • Try hosting your basic application on a remote server. Make sure to update the host variable in your iOS application and see if you can make it run with a couple of iPhones. You and a friend can move around and test your location updates.
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.
© 2026 Kodeco Inc.