Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Feature: WebSockets support and sample Chat application #36

Merged
merged 44 commits into from
Jul 25, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
44 commits
Select commit Hold shift + click to select a range
45834c9
Added folder for SimpleWebsocketChat
juhoaws Apr 22, 2024
da14695
WIP: Initial structure for the simple websocket app backend
juhoaws Apr 22, 2024
7c3008b
WIP: Baseline infra stack with WSS connections
juhoaws Apr 23, 2024
165c744
Server side validation for valid JWT working
juhoaws Apr 23, 2024
97089f3
CloudFront distribution in front of the ALB for TLS-encrypted connect…
juhoaws Apr 24, 2024
c6cd50f
Unity websocket client WIP
juhoaws Apr 30, 2024
f93b6c8
Websocket server WIP
juhoaws Apr 30, 2024
407f19b
Node.js server WIP
juhoaws Apr 30, 2024
978a41d
Websocket chat server WIP
juhoaws May 2, 2024
c443de4
Json-based messaging for Websockets
juhoaws May 2, 2024
33bc4ac
Chat server redis channel management
juhoaws May 10, 2024
0850588
Websocket chat Unity UI work
juhoaws May 10, 2024
38b12b2
Websocket chat features integrated with Unity UI
juhoaws May 10, 2024
211c59c
Don't allow messages to channels user has not joined
juhoaws May 10, 2024
7bf7671
Unity Chat UI positioning and clean up log over time
juhoaws May 13, 2024
070d2d1
Properly remove the channel subscriptions when last person leaves cha…
juhoaws May 13, 2024
c55f9a9
Websockets: Split out redis management to a separate class
juhoaws May 13, 2024
a9d1fd9
Websockets: Clean up Unity integration log rows after 20+ rows inserted
juhoaws May 13, 2024
b738c0b
Moved the Websocket client under AWS Game SDK in Unity integration
juhoaws May 13, 2024
f96ab86
Websockets: Some initial boilerplate code for Unreal websockets support
juhoaws May 13, 2024
ab7d40d
Baseline Websocket connection for Unreal
juhoaws May 30, 2024
fcafb6f
Fixed a logging bucket issue
juhoaws Jun 14, 2024
e6b479c
Added CloudFormation Output value for the Websocket endpoint
juhoaws Jul 23, 2024
9cca07e
Initial work on websocket messages
juhoaws Jul 23, 2024
efb348b
Full chat functionality in Unreal sample
juhoaws Jul 23, 2024
5a7e60b
Initial Readme work
juhoaws Jul 23, 2024
5a96757
Readme WIP
juhoaws Jul 23, 2024
0e83969
Readme WIP
juhoaws Jul 23, 2024
c7dd0ac
Readme minor fixes
juhoaws Jul 23, 2024
8427748
Minor Readme fixes
juhoaws Jul 23, 2024
2dc4847
Readme Unity integration
juhoaws Jul 23, 2024
60f315b
Websocket chat Readme work
juhoaws Jul 24, 2024
3916ced
Minor Readme update
juhoaws Jul 24, 2024
3a82ca7
Minor Readme fix
juhoaws Jul 24, 2024
730eb0b
Main Readme updates
juhoaws Jul 24, 2024
644b460
Main Readme updates
juhoaws Jul 24, 2024
5118eb0
WebSocket mention in the SDK Readmes
juhoaws Jul 24, 2024
422162c
link fix
juhoaws Jul 24, 2024
65f24fb
Package updates
juhoaws Jul 24, 2024
c5640ee
Readme minor fix
juhoaws Jul 24, 2024
94a59ff
upgraded packages for the chat app
juhoaws Jul 24, 2024
3c130e2
Fixed Unity map
juhoaws Jul 24, 2024
77a001a
Added note on sharing VPC resources to the Readme:s
juhoaws Jul 24, 2024
fd4bbd9
Merge pull request #35 from aws-solutions-library-samples/main
juhoaws Jul 24, 2024
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions BackendFeatures/SimpleWebsocketChat/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
!jest.config.js
*.d.ts
node_modules

# CDK asset staging directory
.cdk.staging
cdk.out
6 changes: 6 additions & 0 deletions BackendFeatures/SimpleWebsocketChat/.npmignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
*.ts
!*.d.ts

# CDK asset staging directory
.cdk.staging
cdk.out
151 changes: 151 additions & 0 deletions BackendFeatures/SimpleWebsocketChat/Readme.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
# AWS Game Backend Framework Features: Simple WebSocket Chat

This feature of the AWS Game Backend Framework showcases how you can host a WebSocket backend on AWS for simple chat application that supports the following features:

* Set your user name (stored in an ElastiCache for Redis Serverless cluster)
* Join a channel (using Pub/Sub mechanism of Redis)
* Leave a channel
* Send a message to a channel

While this is a simple sample application, it is designed for scale. The chat channels are managed with ElastiCache for Redis Serverless that automatically scales based on demand. The Node.js backend is hosted on Amazon ECS Fargate as a stateless application, which allows you to configure scaling based on selected metrics. By default, it will automatically scale to keep a maximum of 80% CPU load across the ECS Tasks.

**NOTE**: There are however some key considerations when you start working towards a more production ready setup:

* We are using encrypted WebSocket connections over Amazon CloudFront, but the communication from CloudFront to the Application Load Balancer is not encrypted. You should set up your own certificates on the ALB level to make that connection encrypted as well.
* We are not limiting access to join channels, you should implement any logic that makes sense for your game to validate on the backend side which channels the player can join
* We are allowing players to set any chat name they want. You might want to grab this name from a database instead and have control on for example the uniqueness of these names
* We are not filtering the chat traffic in any way. You can implement content moderation tooling on the backend side to control what is written in the chat

**Note on VPC implementation of the feature:**

This feature deploys a VPC which includes resources such as NAT Gateways that generate cost. This makes it easy to test the feature, but you likely want to share a VPC between multiple components and provide that as a parameter to the different CDK applications.

## Architecture

Here's the high level architecture for the solution:

![High Level Reference Architecture](WebsocketChatArchitecture.png)

Key things to note:

* The AWS Game SDK for Unity and Unreal include a WebSocket connection option for any WebSocket needs, which is utilized by this implementation
* Client connects with a secure WebSocket connection (wss) to a CloudFront distribution that accelerates the connection at the edge
* CloudFront routes the traffic to an Application Load Balancer that routes the WebSocket connection to a cluster of Amazon ECS Fargate Tasks
* A Node.js application will validate the authentication token received from the client as part of the connection. It will validate the token with the public keys provided by the Identity Component. Any invalid connection will be terminated
* After connection is established, the client and server can send any messages both directions over the WebSocket connection
* Amazon ElastiCache for Redis Serverless is used to manage the chat channels. The servers will use Redis Pub/Sub features to send and receive messages

## Required preliminary setup

This backend feature **requires** that you have deployed the [Identity component](../../CustomIdentityComponent/README.md)[^1]. Once that is done, **set** the `const ISSUER_ENDPOINT` in `BackendFeatures/SimpleWebSocketChat/bin/simple_websocket_chat.ts` to the value of `IssuerEndpointUrl` found in the stack outputs of the _CustomIdentityComponentStack_. You can find it in the CloudFormation console, or in the terminal after deploying the identity component.

## Deploying the Simple WebSocket Chat feature

To deploy the component, follow the _Preliminary Setup_, and then run the following commands:

1. Make sure you have __Docker running__ before you open the terminal, as the deployment process creates a Docker image
2. Navigate to `BackendFeatures/SimpleWebSocketChat/` folder in your terminal or Powershell[^2].
3. Run `npm install` to install CDK app dependencies.
4. Run `cdk deploy --all --require-approval never` to the deploy the backend feature to your AWS account[^3].
5. After the `SimpleWebsocketChat` stack has been deployed, capture the value of `WebSocketEndpoint` found in the outputs of the _SimpleWebsocketChat_ stack. You can find it in the CloudFormation console, or in the terminal after deploying the component.

## Testing the Simple WebSocket Chat feature

You can quickly test that the solution is correctly deployed on a Linux or MacOS terminal by first installing [websocat](https://github.com/vi/websocat), setting up the correct endpoints in the script below, and running it. You should get a response of a successful connection (`{"message":"Successfully connected!"}`):

```bash
# SET THESE FIRST
login_endpoint=https://YOURENDPOINT/prod/
websocket_endpoint=wss://YOURENDPOINT.cloudfront.net

# GET A USER AND CONNECT
auth_token=$(curl $login_endpoint/login-as-guest | jq -j '.auth_token')
websocat "$websocket_endpoint/?auth_token=$auth_token"
```
## Integration with the Game Engines

### Unity integration

To test the integrations with Unity, **open** the Unity sample project (`UnitySample`) with Unity 2021 (or above).

* Then **open** the scene `BackendFeatures/SimpleWebsocketChat/SimpleWebsocketChat.unity`

This is a test level that will login as a new guest user if a PlayePrefs configuration is not present. It has a UI to 1/ set name, 2/ join channels, 3/ leave channels, and 4/ send messages. You can see the output of using the UI in the output and when you join a channel and a message is sent to that, it will be passed to the client.

Configure the `SimpleWebsocketChat` component of the `SimpleWebsocketChatIntegration` GameObject to set up API endpoints. Set `Login Endpoint Url` value to the `LoginEndpoint` value found in the CustomIdentityComponentStack Outputs, and the `Websocket Endpoint Url` to the `WebSocketEndpoint` value found in the *SimpleWebsocketChat* Outputs.

Press play to test the integration. You'll see the login and WebSocket connection happen. You can then use the UI to test the chat application.

**Key code files:**
* `UnitySample/Assets/AWSGameSDK/WebSocketClient.cs`: A WebSocket client class that can be used by any WebSocket integration.
* `UnitySample/Assets/BackendFeatures/SimpleWebsocketChat/ChatSerializationClasses.cs`: The data structure for messages between client and server that are sent over in JSON format
* `UnitySample/Assets/BackendFeatures/SimpleWebsocketChat/SimpleWebsocketChat.cs`: The main class for the chat application

### Unreal Engine integration

To test the integrations with Unreal, **open** the Unreal sample project (`UnrealSample`) in Unreal Engine 5 first.

**NOTE:** On Windows it will prompt you if you don't have Visual Studio installed yet. Once you have Visual Studio installed and set up for Unreal, you can open the project in the Unreal Editor and generate the project files from *Tools -> Generate Visual Studio Project*. On MacOS, you need to do *right click -> Services -> Generate XCode Project* on the uproject file in Finder. If you have problems generating the project files on MacOS, [this forum post](https://forums.unrealengine.com/t/generate-xcode-project-doesnt-do-anything/123149/3) can help run the shell script correctly from your UE installation folder against the project in the terminal.

* Then **open** the level `BackendFeatures/SimpleWebSocketChat`

This is a test level that will login as a new guest user if a save file is not present, or login using the user_id and guest_secret found in the save file if available to login as an existing user. It will then use the credentials of the logged in user to test the WebSocket connection to the chat application, set name, join channel, send message, and leave channel.

Configure the `SimpleWebsocketChat` component of the `SimpleWebsocketChat` Actor to set up API and WebSocket endpoints. Set `M Login Endpoint` value to the `LoginEndpoint` value found in the CustomIdentityComponentStack Outputs. Then set the `M Websocket Endpoint Url` to the endpoint value `WebSocketEndpoint` found in the *SimpleWebsocketChat* Outputs.

Press play to test the integration. You'll see the login as a guest user and starting the websocket connection with this user. Then you'll see joining a channel, sending and receiving a message on the channel, and leaving the channel in the end.

**Adding the integration to your custom project:** You can follow the [guidelines found in the Unreal Engine Integration Readme](../../UnrealSample/README.md#adding-the-sdk-to-an-existing-project) to add the AWS Game SDK to your own project. After that, you can use `UnrealSample/Source/UnrealSample/BackendFeatures/SimpleWebsocketChat/SimpleWebsocketChat.cpp.cpp` as a reference for how to implement the WebSocket connection.

**Key code files:**
* `UnrealSample/Source/UnrealSample/AWSGameSDK/WebSocketClient.cpp`: A WebSocket client class that can be used by any WebSocket integration.
* `UnrealSample/Source/UnrealSample/BackendFeatures/SimpleWebsocketChat/SimpleWebsocketChat.cpp`: The main class for the chat application

## WebSocket message reference

The initial connection to the websocket expects to receive the `auth_token` as a URL Parameter, for example `wss://abcdefghijklm.cloudfront.net/?auth_token=eyMYTOKEN`.

Server will disconnect any client that doesn't send an auth token that validates correctly against the public key found in the Identity component endpoint. After this, the messages use a JSON format for the different features of the chat application.

### Message types

#### type: set-name

Sets the name of the user. This must be called before any messages can be sent to any channel as the broadcasted messages will have the name included.

Message content:

`{ "type" : "set-name", "payload" : { "username" : "YOUR NAME" }}`

#### type: join

Joins the defined channel. After this, all messages sent to this channel will be sent over the WebSocket to this user

Message content:

`{ "type" : "join", "payload" : { "channel" : "YOUR CHANNEL" }}`

#### type: leave

Leaves the defined channel. After this, no messages are received from this channel. User will also disconnect from all channels when disconnecting from the backend.

Message content:

`{ "type" : "leave", "payload" : { "channel" : "YOUR CHANNEL" }}`

#### type: message

Sends a message to the defined channel. The message is broadcasted to all users who have joined the channel.

Message content:

`{ "type" : "message", "payload" : { "channel" : "YOUR CHANNEL", "message" : "YOUR MESSAGE" }}`

---

**Notes:**

[^1]: You're also expected to have all the tools listed in [Custom Identity Component Readme](../../CustomIdentityComponent/README.md#deploy-the-custom-identity-component) installed.
[^2]: On **Windows** make sure to run in Powershell as **Administrator**.
[^3]: If you are deploying the backend feature in a different AWS Account, or AWS Region from the _CustomIdentityComponentStack_, make sure to run ```cdk bootstrap``` to bootstrap the account for CDK (see [Bootstrapping](https://docs.aws.amazon.com/cdk/v2/guide/bootstrapping.html) for more information).
[^4]: Run the command with just the `--dry-run` parameter first to verify script functionality.
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
node_modules
npm-debug.log
20 changes: 20 additions & 0 deletions BackendFeatures/SimpleWebsocketChat/SimpleWebsocketApp/Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
FROM --platform=linux/amd64 node:16

# Create app directory
WORKDIR /usr/src/app

# Install app dependencies
# A wildcard is used to ensure both package.json AND package-lock.json are copied
# where available (npm@5+)
COPY package*.json ./

RUN npm install
# If you are building your code for production
# RUN npm ci --omit=dev

# Bundle app source
COPY . .

EXPOSE 80
EXPOSE 8080
CMD ["node", "server.js"]
118 changes: 118 additions & 0 deletions BackendFeatures/SimpleWebsocketChat/SimpleWebsocketApp/RedisManager.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
const redis = require('redis');

class RedisManager {
constructor(redisEndpoint) {
this.redisClient = redis.createClient({
socket: {
host: redisEndpoint,
port: 6379,
tls: true
}
});
this.redisPubSubClient = redis.createClient({
socket: {
host: redisEndpoint,
port: 6379,
tls: true
}
});

this.channelSubscriptions = new Map();
this.websockets = new Map();

this.redisClient.connect();
this.redisPubSubClient.connect();

this.redisClient.on('error', (err) => {
console.error('Redis error:', err);
});

this.redisClient.on('end', () => {
console.log('Redis connection closed');
});

this.redisClient.on('reconnecting', () => {
console.log('Reconnecting to Redis...');
});

this.redisPubSubClient.on('error', (err) => {
console.error('Redis error:', err);
});

this.redisPubSubClient.on('end', () => {
console.log('Redis connection closed');
});

this.redisPubSubClient.on('reconnecting', () => {
console.log('Reconnecting to Redis...');
});
}

setUsername(userID, username) {
console.log(`Setting username for ${userID} to ${username}`);
this.redisClient.set(userID, username);
}

async getUsername(userID) {
return await this.redisClient.get(userID);
}

subscribeToChannel(channel, ws, listener) {
console.log(`Subscribing ${this.websockets.get(ws)} to ${channel}`);
if (!this.channelSubscriptions.has(channel)) {
console.log('Channel subscription not set yet on this server, creating...');
this.channelSubscriptions.set(channel, new Set());
this.redisPubSubClient.sSubscribe(channel, listener);
console.log('Done!');
}
if (!this.channelSubscriptions.get(channel).has(ws)) {
this.channelSubscriptions.get(channel).add(ws);
ws.send(JSON.stringify({ message: `You have joined ${channel}` }));
} else {
ws.send(JSON.stringify({ message: `You have already joined ${channel}` }));
}
}

unsubscribeFromChannel(channel, ws) {
console.log(`Unsubscribing ${this.websockets.get(ws)} from ${channel}`);
this.channelSubscriptions.get(channel).delete(ws);
if (this.channelSubscriptions.get(channel).size === 0) {
console.log('No more subscribers, unsubscribing...');
this.redisPubSubClient.sUnsubscribe(channel);
console.log('Done!');
this.channelSubscriptions.delete(channel);
}
ws.send(JSON.stringify({ message: `You have left ${channel}` }));
}

publishToChannel(channel, message) {
this.redisClient.publish(channel, message);
}

addWebsocket(ws, userID) {
this.websockets.set(ws, userID);
}

removeWebsocket(ws) {
const userID = this.websockets.get(ws);
this.websockets.delete(ws);

const channelsToRemove = new Set();
this.channelSubscriptions.forEach((subscriberMap, channel) => {
subscriberMap.delete(ws);
if (subscriberMap.size === 0) {
channelsToRemove.add(channel);
}
});

channelsToRemove.forEach((channel) => {
this.redisPubSubClient.sUnsubscribe(channel);
console.log(`No more people on channel, Unsubscribed server from ${channel}`);
this.channelSubscriptions.delete(channel);
});

console.log(`User ${userID} disconnected`);
}
}

module.exports = RedisManager;
Loading