Part 3 - Deploy to the web

This is the third part of the tutorial.

  • In the first part, you created a server and connected one browser; you could play if you shared the same browser.

  • In this second part, you connected a second browser; you could play from different browsers on a local network.

  • In this third part, you will deploy the game to the web; you can play from any browser connected to the Internet.

In the first and second parts of the tutorial, for local development, you ran an HTTP server on http://localhost:8000/ with:

$ python -m http.server

and a WebSocket server on ws://localhost:8001/ with:

$ python app.py

Now you want to deploy these servers on the Internet. There’s a vast range of hosting providers to choose from. For the sake of simplicity, we’ll rely on:

Koyeb is a modern Platform as a Service provider whose free tier allows you to run a web application, including a WebSocket server.

Commit project to git

Perhaps you committed your work to git while you were progressing through the tutorial. If you didn’t, now is a good time, because GitHub and Koyeb offer git-based deployment workflows.

Initialize a git repository:

$ git init -b main
Initialized empty Git repository in websockets-tutorial/.git/
$ git commit --allow-empty -m "Initial commit."
[main (root-commit) 8195c1d] Initial commit.

Add all files and commit:

$ git add .
$ git commit -m "Initial implementation of Connect Four game."
[main 7f0b2c4] Initial implementation of Connect Four game.
 6 files changed, 500 insertions(+)
 create mode 100644 app.py
 create mode 100644 connect4.css
 create mode 100644 connect4.js
 create mode 100644 connect4.py
 create mode 100644 index.html
 create mode 100644 main.js

Sign up or log in to GitHub.

Create a new repository. Set the repository name to websockets-tutorial, the visibility to Public, and click Create repository.

Push your code to this repository. You must replace python-websockets by your GitHub username in the following command:

$ git remote add origin git@github.com:python-websockets/websockets-tutorial.git
$ git branch -M main
$ git push -u origin main
...
To github.com:python-websockets/websockets-tutorial.git
 * [new branch]      main -> main
Branch 'main' set up to track remote branch 'main' from 'origin'.

Adapt the WebSocket server

Before you deploy the server, you must adapt it for Koyeb’s environment. This involves three small changes:

  1. Koyeb provides the port on which the server should listen in the $PORT environment variable.

  2. Koyeb requires a health check to verify that the server is running. We’ll add a HTTP health check.

  3. Koyeb sends a SIGTERM signal when terminating the server. We’ll catch it and trigger a clean exit.

Adapt the main() coroutine accordingly:

import http
import os
import signal
def health_check(connection, request):
    if request.path == "/healthz":
        return connection.respond(http.HTTPStatus.OK, "OK\n")
async def main():
    port = int(os.environ.get("PORT", "8001"))
    async with serve(handler, "", port, process_request=health_check) as server:
        loop = asyncio.get_running_loop()
        loop.add_signal_handler(signal.SIGTERM, server.close)
        await server.wait_closed()

The process_request parameter of serve() is a callback that runs for each request. When it returns an HTTP response, websockets sends that response instead of opening a WebSocket connection. Here, requests to /healthz return an HTTP 200 status code.

main() registers a signal handler that closes the server when receiving the SIGTERM signal. Then, it waits for the server to be closed. Additionally, using serve() as a context manager ensures that the server will always be closed cleanly, even if the program crashes.

Deploy the WebSocket server

Create a requirements.txt file with this content to install websockets when building the image:

websockets

Koyeb treats requirements.txt as a signal to detect a Python app.

That’s why you don’t need to declare that you need a Python runtime.

Create a Procfile file with this content to configure the command for running the server:

web: python app.py

Commit and push your changes:

$ git add .
$ git commit -m "Deploy to Koyeb."
[main 4a4b6e9] Deploy to Koyeb.
 3 files changed, 15 insertions(+), 2 deletions(-)
 create mode 100644 Procfile
 create mode 100644 requirements.txt
$ git push
...
To github.com:python-websockets/websockets-tutorial.git
+ 6bd6032...4a4b6e9 main -> main

Sign up or log in to Koyeb.

In the Koyeb control panel, create a web service with GitHub as the deployment method. Install and authorize Koyeb’s GitHub app if you haven’t done that yet.

Follow the steps to create a new service:

  1. Select the websockets-tutorial repository in the list of your repositories.

  2. Confirm that the Free instance type is selected. Click Next.

  3. Configure health checks: change the protocol from TCP to HTTP and set the path to /healthz. Review other settings; defaults should be correct. Click Deploy.

Koyeb builds the app, deploys it, verifies that the health checks passes, and makes the deployment active.

You can test the WebSocket server with the interactive client exactly like you did in the first part of the tutorial. The Koyeb control panel provides the URL of your app in the format: https://<app>-<user>-<id>.koyeb.app/. Replace https with wss in the URL and connect the interactive client:

$ websockets wss://<app>-<user>-<id>.koyeb.app/
Connected to wss://<app>-<user>-<id>.koyeb.app/.
> {"type": "init"}
< {"type": "init", "join": "54ICxFae_Ip7TJE2", "watch": "634w44TblL5Dbd9a"}

Press Ctrl-D to terminate the connection.

It works!

Prepare the web application

Before you deploy the web application, perhaps you’re wondering how it will locate the WebSocket server? Indeed, at this point, its address is hard-coded in main.js:

const websocket = new WebSocket("ws://localhost:8001/");

You can take this strategy one step further by checking the address of the HTTP server and determining the address of the WebSocket server accordingly.

Add this function to main.js; replace python-websockets by your GitHub username and websockets-tutorial by the name of your app on Koyeb:

function getWebSocketServer() {
  if (window.location.host === "python-websockets.github.io") {
    return "wss://websockets-tutorial.koyeb.app/";
  } else if (window.location.host === "localhost:8000") {
    return "ws://localhost:8001/";
  } else {
    throw new Error(`Unsupported host: ${window.location.host}`);
  }
}

Then, update the initialization to connect to this address instead:

const websocket = new WebSocket(getWebSocketServer());

Commit your changes:

$ git add .
$ git commit -m "Configure WebSocket server address."
[main 0903526] Configure WebSocket server address.
 1 file changed, 11 insertions(+), 1 deletion(-)
$ git push
...
To github.com:python-websockets/websockets-tutorial.git
+ 4a4b6e9...968eaaa main -> main

Deploy the web application

Go back to GitHub, open the Settings tab of the repository and select Pages in the menu. Select the main branch as source and click Save. GitHub tells you that your site is published.

Open https://<your-username>.github.io/websockets-tutorial/ and start a game!

Summary

In this third part of the tutorial, you learned how to deploy a WebSocket application with Koyeb.

You can start a Connect Four game, send the JOIN link to a friend, and play over the Internet!

Congratulations for completing the tutorial. Enjoy building real-time web applications with websockets!

Solution

app.py
  1#!/usr/bin/env python
  2
  3import asyncio
  4import http
  5import json
  6import os
  7import secrets
  8import signal
  9
 10from websockets.asyncio.server import broadcast, serve
 11
 12from connect4 import PLAYER1, PLAYER2, Connect4
 13
 14
 15JOIN = {}
 16
 17WATCH = {}
 18
 19
 20async def error(websocket, message):
 21    """
 22    Send an error message.
 23
 24    """
 25    event = {
 26        "type": "error",
 27        "message": message,
 28    }
 29    await websocket.send(json.dumps(event))
 30
 31
 32async def replay(websocket, game):
 33    """
 34    Send previous moves.
 35
 36    """
 37    # Make a copy to avoid an exception if game.moves changes while iteration
 38    # is in progress. If a move is played while replay is running, moves will
 39    # be sent out of order but each move will be sent once and eventually the
 40    # UI will be consistent.
 41    for player, column, row in game.moves.copy():
 42        event = {
 43            "type": "play",
 44            "player": player,
 45            "column": column,
 46            "row": row,
 47        }
 48        await websocket.send(json.dumps(event))
 49
 50
 51async def play(websocket, game, player, connected):
 52    """
 53    Receive and process moves from a player.
 54
 55    """
 56    async for message in websocket:
 57        # Parse a "play" event from the UI.
 58        event = json.loads(message)
 59        assert event["type"] == "play"
 60        column = event["column"]
 61
 62        try:
 63            # Play the move.
 64            row = game.play(player, column)
 65        except ValueError as exc:
 66            # Send an "error" event if the move was illegal.
 67            await error(websocket, str(exc))
 68            continue
 69
 70        # Send a "play" event to update the UI.
 71        event = {
 72            "type": "play",
 73            "player": player,
 74            "column": column,
 75            "row": row,
 76        }
 77        broadcast(connected, json.dumps(event))
 78
 79        # If move is winning, send a "win" event.
 80        if game.winner is not None:
 81            event = {
 82                "type": "win",
 83                "player": game.winner,
 84            }
 85            broadcast(connected, json.dumps(event))
 86
 87
 88async def start(websocket):
 89    """
 90    Handle a connection from the first player: start a new game.
 91
 92    """
 93    # Initialize a Connect Four game, the set of WebSocket connections
 94    # receiving moves from this game, and secret access tokens.
 95    game = Connect4()
 96    connected = {websocket}
 97
 98    join_key = secrets.token_urlsafe(12)
 99    JOIN[join_key] = game, connected
100
101    watch_key = secrets.token_urlsafe(12)
102    WATCH[watch_key] = game, connected
103
104    try:
105        # Send the secret access tokens to the browser of the first player,
106        # where they'll be used for building "join" and "watch" links.
107        event = {
108            "type": "init",
109            "join": join_key,
110            "watch": watch_key,
111        }
112        await websocket.send(json.dumps(event))
113        # Receive and process moves from the first player.
114        await play(websocket, game, PLAYER1, connected)
115    finally:
116        del JOIN[join_key]
117        del WATCH[watch_key]
118
119
120async def join(websocket, join_key):
121    """
122    Handle a connection from the second player: join an existing game.
123
124    """
125    # Find the Connect Four game.
126    try:
127        game, connected = JOIN[join_key]
128    except KeyError:
129        await error(websocket, "Game not found.")
130        return
131
132    # Register to receive moves from this game.
133    connected.add(websocket)
134    try:
135        # Send the first move, in case the first player already played it.
136        await replay(websocket, game)
137        # Receive and process moves from the second player.
138        await play(websocket, game, PLAYER2, connected)
139    finally:
140        connected.remove(websocket)
141
142
143async def watch(websocket, watch_key):
144    """
145    Handle a connection from a spectator: watch an existing game.
146
147    """
148    # Find the Connect Four game.
149    try:
150        game, connected = WATCH[watch_key]
151    except KeyError:
152        await error(websocket, "Game not found.")
153        return
154
155    # Register to receive moves from this game.
156    connected.add(websocket)
157    try:
158        # Send previous moves, in case the game already started.
159        await replay(websocket, game)
160        # Keep the connection open, but don't receive any messages.
161        await websocket.wait_closed()
162    finally:
163        connected.remove(websocket)
164
165
166async def handler(websocket):
167    """
168    Handle a connection and dispatch it according to who is connecting.
169
170    """
171    # Receive and parse the "init" event from the UI.
172    message = await websocket.recv()
173    event = json.loads(message)
174    assert event["type"] == "init"
175
176    if "join" in event:
177        # Second player joins an existing game.
178        await join(websocket, event["join"])
179    elif "watch" in event:
180        # Spectator watches an existing game.
181        await watch(websocket, event["watch"])
182    else:
183        # First player starts a new game.
184        await start(websocket)
185
186
187def health_check(connection, request):
188    if request.path == "/healthz":
189        return connection.respond(http.HTTPStatus.OK, "OK\n")
190
191
192async def main():
193    port = int(os.environ.get("PORT", "8001"))
194    async with serve(handler, "", port, process_request=health_check) as server:
195        loop = asyncio.get_running_loop()
196        loop.add_signal_handler(signal.SIGTERM, server.close)
197        await server.wait_closed()
198
199
200if __name__ == "__main__":
201    asyncio.run(main())
index.html
 1<!DOCTYPE html>
 2<html lang="en">
 3  <head>
 4    <title>Connect Four</title>
 5  </head>
 6  <body>
 7    <div class="actions">
 8      <a class="action new" href="/">New</a>
 9      <a class="action join" href="">Join</a>
10      <a class="action watch" href="">Watch</a>
11    </div>
12    <div class="board"></div>
13    <script src="main.js" type="module"></script>
14  </body>
15</html>
main.js
 1import { createBoard, playMove } from "./connect4.js";
 2
 3function getWebSocketServer() {
 4  if (window.location.host === "python-websockets.github.io") {
 5    return "wss://websockets-tutorial.koyeb.app/";
 6  } else if (window.location.host === "localhost:8000") {
 7    return "ws://localhost:8001/";
 8  } else {
 9    throw new Error(`Unsupported host: ${window.location.host}`);
10  }
11}
12
13function initGame(websocket) {
14  websocket.addEventListener("open", () => {
15    // Send an "init" event according to who is connecting.
16    const params = new URLSearchParams(window.location.search);
17    let event = { type: "init" };
18    if (params.has("join")) {
19      // Second player joins an existing game.
20      event.join = params.get("join");
21    } else if (params.has("watch")) {
22      // Spectator watches an existing game.
23      event.watch = params.get("watch");
24    } else {
25      // First player starts a new game.
26    }
27    websocket.send(JSON.stringify(event));
28  });
29}
30
31function showMessage(message) {
32  window.setTimeout(() => window.alert(message), 50);
33}
34
35function receiveMoves(board, websocket) {
36  websocket.addEventListener("message", ({ data }) => {
37    const event = JSON.parse(data);
38    switch (event.type) {
39      case "init":
40        // Create links for inviting the second player and spectators.
41        document.querySelector(".join").href = "?join=" + event.join;
42        document.querySelector(".watch").href = "?watch=" + event.watch;
43        break;
44      case "play":
45        // Update the UI with the move.
46        playMove(board, event.player, event.column, event.row);
47        break;
48      case "win":
49        showMessage(`Player ${event.player} wins!`);
50        // No further messages are expected; close the WebSocket connection.
51        websocket.close(1000);
52        break;
53      case "error":
54        showMessage(event.message);
55        break;
56      default:
57        throw new Error(`Unsupported event type: ${event.type}.`);
58    }
59  });
60}
61
62function sendMoves(board, websocket) {
63  // Don't send moves for a spectator watching a game.
64  const params = new URLSearchParams(window.location.search);
65  if (params.has("watch")) {
66    return;
67  }
68
69  // When clicking a column, send a "play" event for a move in that column.
70  board.addEventListener("click", ({ target }) => {
71    const column = target.dataset.column;
72    // Ignore clicks outside a column.
73    if (column === undefined) {
74      return;
75    }
76    const event = {
77      type: "play",
78      column: parseInt(column, 10),
79    };
80    websocket.send(JSON.stringify(event));
81  });
82}
83
84window.addEventListener("DOMContentLoaded", () => {
85  // Initialize the UI.
86  const board = document.querySelector(".board");
87  createBoard(board);
88  // Open the WebSocket connection and register event handlers.
89  const websocket = new WebSocket(getWebSocketServer());
90  initGame(websocket);
91  receiveMoves(board, websocket);
92  sendMoves(board, websocket);
93});
Procfile
1web: python app.py
requirements.txt
1websockets