Build Video/Chat App with AWS Websocket, WebRTC, and Vue Part 2

kevin_odongo35

Kevin Odongo

Posted on January 22, 2021

Build Video/Chat App with AWS Websocket, WebRTC, and Vue Part 2

In case you read my first article https://dev.to/kevin_odongo35/build-video-chat-app-with-aws-websocket-webrtc-and-vue-part-1-5fob and it left you stuck like I was with other tutorials. Yes! I was a victim of knowing how to configure a Websocket but how to integrate it into an application was a mystery. This is the mystery we are breaking into pieces for others not to fall victims.

This is where I got stuck the first time I tried working with Websocket. All tutorials leave you where my first article left you. Quite irritating when you want to go further in using Websocket in your application.

Here is a sample application, I have used the Vue framework and Vuetify CSS.

This application has the following features:

  • User can create a meeting (the meeting can either be chat or video)
  • Once the user starts a meeting they will get a code to share with others to join.
  • Supports video and chat.
  • You can add more features.

STEP ONE

To begin, you need to configure how users will connect and disconnect from your application. In our first article, we talked about the following. The only thing we need to add to our previous discussion is securing your connection and persisting user.

In the above application, we only want to persist user connectionId and other details when they create a meeting by calling a custom route called createMeeting.

To secure your application you need to add an authorizer. This will only be added to the @connect route.

You can use the following mechanisms for authentication and authorization:

  • Standard AWS IAM roles and policies offer flexible and robust access controls. You can use IAM roles and policies for controlling who can create and manage your APIs, as well as who can invoke them. For more information, see Using IAM authorization.

  • IAM tags can be used together with IAM policies to control access.

  • Lambda authorizers are Lambda functions that control access to APIs.

Alt Text

Alt Text

exports.handler = function(event, context, callback) {        
    console.log('Received event:', JSON.stringify(event, null, 2));

   // A simple REQUEST authorizer example to demonstrate how to use request 
   // parameters to allow or deny a request. In this example, a request is  
   // authorized if the client-supplied HeaderAuth1 header, QueryString1 query parameter,
   // stage variable of StageVar1 and the accountId in the request context all match
   // specified values of 'headerValue1', 'queryValue1', 'stageValue1', and
   // '123456789012', respectively.

   // Retrieve request parameters from the Lambda function input:
   var headers = event.headers;
   var queryStringParameters = event.queryStringParameters;
   var stageVariables = event.stageVariables;
   var requestContext = event.requestContext;

   // Parse the input for the parameter values
   var tmp = event.methodArn.split(':');
   var apiGatewayArnTmp = tmp[5].split('/');
   var awsAccountId = tmp[4];
   var region = tmp[3];
   var restApiId = apiGatewayArnTmp[0];
   var stage = apiGatewayArnTmp[1];
   var route = apiGatewayArnTmp[2];

   // Perform authorization to return the Allow policy for correct parameters and 
   // the 'Unauthorized' error, otherwise.
   var authResponse = {};
   var condition = {};
    condition.IpAddress = {};

   if (headers.HeaderAuth1 === "headerValue1"
       && queryStringParameters.QueryString1 === "queryValue1"
       && stageVariables.StageVar1 === "stageValue1"
       && requestContext.accountId === "123456789012") {
        callback(null, generateAllow('me', event.methodArn));
    }  else {
        callback("Unauthorized");
    }
}

// Help function to generate an IAM policy
var generatePolicy = function(principalId, effect, resource) {
   // Required output:
   var authResponse = {};
    authResponse.principalId = principalId;
   if (effect && resource) {
       var policyDocument = {};
        policyDocument.Version = '2012-10-17'; // default version
       policyDocument.Statement = [];
       var statementOne = {};
        statementOne.Action = 'execute-api:Invoke'; // default action
       statementOne.Effect = effect;
        statementOne.Resource = resource;
        policyDocument.Statement[0] = statementOne;
        authResponse.policyDocument = policyDocument;
    }
   // Optional output with custom properties of the String, Number or Boolean type.
   authResponse.context = {
       "stringKey": "stringval",
       "numberKey": 123,
       "booleanKey": true
    };
   return authResponse;
}

var generateAllow = function(principalId, resource) {
   return generatePolicy(principalId, 'Allow', resource);
}

var generateDeny = function(principalId, resource) {
   return generatePolicy(principalId, 'Deny', resource);
}
Enter fullscreen mode Exit fullscreen mode

This is a sample Lambda authorizer. Edit your headers and test as follows

wscat -c 'wss://myapi.execute-api.us-east-1.amazonaws.com/beta?QueryAuth1=queryValue1' -H HeaderAuth1:headerValue1
Enter fullscreen mode Exit fullscreen mode

Ensure you update Lambda role for all the services you want it to access

This will secure your application in that a request which does not match your headers will get rejected and get a 401 error.

STEP TWO

Now that we have configured how users will connect and disconnect we are ready to begin creating other routes.

Once a user connects the next screen allows them to choose between a video meeting or chat meeting. Hence we need a route to persist the user details. Here you can decide to collect more information from the user like name, room name/meeting name, email, etc.

In your WebSocket add a new route called createMeeting. Then add a Lambda function called createMeeting for this route.

createMeeting

This Lambda function will persist the user's details when they start a meeting.

const AWS = require('aws-sdk');

/**
 * This function will be called when createMeeting route is called in your application
 * It will create a new group/meeting or channel.
 * The user who creates the channel will have to share the link to others for
 * them to join.
 * ==========================================================================
*/

let send = undefined;
function onConnect(event) {
    var dynamodb = new AWS.DynamoDB();
    send = async (data) => {
        await dynamodb.putItem(data).promise();
    }
}

// event
exports.handler = async (event) => {
   onConnect(event);
   var params = {
        TableName: "chat_app_table", // Table Name
        Item: {
            connectionId: { S: event.requestContext.connectionId }, // connection ID
            group_name: { S: JSON.parse(event.body).data }, // group name
            group_id: { S: event.requestContext.connectionId }, // connection ID
            createdAt: { S: event.requestContext.requestTime}
        }
    };
   // update dynamodb
   await send(params);
   // return on succesfull creation;
   // the return will enable you get a response from the server
    return { 
        statusCode: 200, 
        body: JSON.stringify({ 
            connection_id: event.requestContext.connectionId, 
            source: "create" ,
            createdAt: event.requestContext.requestTime
        }) /*required on lambda proxy integration*/
    };
};
Enter fullscreen mode Exit fullscreen mode

Ensure you update Lambda role for all the services you want it to access

STEP TWO

We now have a user who has securely connected and created a new meeting. They will get the following code to share. You can choose a different method of how to encrypt the connection id for the meeting. Ensure when a user joins you can decrypt and get the connection id for the meeting they want to join.

Alt Text

We are going to create another route called joinMeeting in our Websocket for users to join our application. Let us add another lambda function called joinMeeting.

joinMeeting

This Lambda function will add new users' details and persist their details.

const AWS = require('aws-sdk');

/**
 * This function will be called when joinMeeting route is called in your application
 * It will add a new user who joins your meeting/channel or group.
 * ==========================================================================
*/

let send = undefined;
function onConnect(event) {
    var dynamodb = new AWS.DynamoDB();
    send = async (data) => {
        await dynamodb.putItem(data).promise();
    };
}

// event
exports.handler = async (event) => {
   onConnect(event);
   var params = {
        TableName: "chat_app_table", // Table Name
        Item: {
            connectionId: { S: event.requestContext.connectionId }, // connection ID
            group_name: { S: JSON.parse(event.body).data.group_name }, // save the group name
            group_id: { S: JSON.parse(event.body).data.group_id }, // save the group id
            createdAt: { S: event.requestContext.requestTime}
        }
    };
   await send(params);
   // scan and send back all connected
    var dynamodb = new AWS.DynamoDB();
    var scanParams = {
      TableName: "chat_app_table", // Table Name
      ExpressionAttributeNames: {
        "#ID": "connectionId"
      },
      ExpressionAttributeValues: {
         ":connectionId": {
           S: JSON.parse(event.body).data.group_id
         },
      },
      FilterExpression: "group_id = :connectionId", // scan to get all users with same connection id 
       ProjectionExpression: "#ID",
    };
  const response = await dynamodb.scan(scanParams).promise();
  if(response.Items.length > 0){
     return { 
        statusCode: 200, 
        body: JSON.stringify({ connected_users: response.Items, source: "join"}) /*required on lambda proxy integration*/
    };
  } else {
    // return on succesfull creation;
   // the return will enable you get a response from the server
    var item = [];
    return { 
        statusCode: 200, 
        body: JSON.stringify({ connected_users: item, source: "join"}) /*required on lambda proxy integration*/
    };

  }

};
Enter fullscreen mode Exit fullscreen mode

Ensure you update Lambda role for all the services you want it to access

NOTE

In case you want to add another logic for the users who started the meeting to accept or reject those who try to join their meeting, then we can add a new Lambda function and add a new logic.

STEP THREE

Sending a message in the application was discussed in the first article.

onMessage

const AWS = require('aws-sdk');
var dynamodb = new AWS.DynamoDB();

let send = undefined;
// post conenction
function init(event) {
  const apigwManagementApi = new AWS.ApiGatewayManagementApi({
    apiVersion: '2018-11-29',
    endpoint: event.requestContext.domainName + '/' + event.requestContext.stage
  });
  send = async (connectionId, data) => {
    await apigwManagementApi.postToConnection({ ConnectionId: connectionId, Data: `${data}` }).promise();
  }
}


exports.handler = async(event) => {
  // send the event
  init(event);

  // get user details
  var getParams = {
    TableName: "chat_app_table", // Table Name
    Key: {
      connectionId: { S: event.requestContext.connectionId}
    }
  }
  console.log("GET PARAMS", getParams)
  const user_response = await dynamodb.getItem(getParams).promise()
  const user_results =  Object.values(user_response.Item.group_id)
  const group_id = user_results[0]

  console.log("USER RESPONSE", group_id)

  // scan dynamodb table
  var scanParams = {
      TableName: "chat_app_table", // Table Name
      ExpressionAttributeNames: {
        "#ID": "connectionId"
      },
      ExpressionAttributeValues: {
         ":connectionId": {
           S: group_id
         },
      },
      FilterExpression: "group_id = :connectionId", // scan to get all users with same connection id 
       ProjectionExpression: "#ID",
    };

  const response = await dynamodb.scan(scanParams).promise();
  let data = JSON.parse(event.body).data;
  if(response.Items.length > 0){
  console.log('Checking....')
  let results = response.Items.map((el) => {
      const value =  Object.values(el.connectionId)
      const connectionId = value[0]
      send(connectionId, data);
   })
   await Promise.all(results)
   }

  // the return value is ignored when this function is invoked from the WebSocket gateway
  return {};
};
Enter fullscreen mode Exit fullscreen mode

Ensure you update Lambda role for all the services you want it to access

Now we have all the routes and our backend configured, let us integrate everything and make our application function.

Script.js

Install all the required packages.

//https://github.com/heineiuo/isomorphic-ws
yarn add isomorphic-ws ws
yarn add aws-sdk
Enter fullscreen mode Exit fullscreen mode

Create .env file and update it with your credentials:

VUE_APP_MY_REGION =
VUE_APP_SECRET_ACCESS_KEY =
VUE_APP_ACCESS_KEY_ID =
VUE_APP_WEBSOCKET_URL = wss://
Enter fullscreen mode Exit fullscreen mode

Update Script.js file

const WebSocket = require("isomorphic-ws");
const websocket_url = process.env.VUE_APP_WEBSOCKET_URL;
const ws = new WebSocket(`${websocket_url}`);
// aws config file
var AWS = require("aws-sdk");
AWS.config.update({
  region: process.env.VUE_APP_MY_REGION,
  secretAccessKey: process.env.VUE_APP_SECRET_ACCESS_KEY,
  accessKeyId: process.env.VUE_APP_ACCESS_KEY_ID
});
Enter fullscreen mode Exit fullscreen mode

Once we have our file let's add three functions that we export and reuse in our components. The others are helpers.

  • createnewmeeting
  • joinnewmeeting
  • sendmessage
const WebSocket = require("isomorphic-ws");
const websocket_url = process.env.VUE_APP_WEBSOCKET_URL;
const ws = new WebSocket(`${websocket_url}`);
// aws config file
var AWS = require("aws-sdk");
AWS.config.update({
  region: process.env.VUE_APP_MY_REGION,
  secretAccessKey: process.env.VUE_APP_SECRET_ACCESS_KEY,
  accessKeyId: process.env.VUE_APP_ACCESS_KEY_ID
});

/**
 * Action called
 * ==================
 * Open a connection
 * Create a meeting
 * Join a meeting
 * Send a message
 * ====================
 */

// on connect
const onConnect = event => {
  if (event !== undefined) {
    ws.send(event);
  }
};

// on incoming
let interval = 0;
const inComing = () => {
  ws.onmessage = function incoming(data) {
    if (data) {
      interval = 5000; // set the timeout to 5000
    }
    // set timeout
    // you will be disconnected after 10 min
    setTimeout(() => {
      pong();
    }, interval);
  };
};

// on close
const onClose = () => {
  ws.onclose = function close() {
    console.log("disconnected");
    interval = 0;
    clearTimeout(interval);
  };
};

// create a meeting
// eslint-disable-next-line no-unused-vars
const pong = () => {
  const msg = JSON.stringify({
    action: "send",
    data: "ping"
  });
  ws.send(msg);
};

export const createnewmeeting = event => {

  ws.onopen = onConnect(event);
  onClose();
  inComing();
};

// sample create meeting
export const joinnewmeeting = event => {
  ws.onopen = onConnect(event);
  onClose();
  inComing();
};

// send message function
export const sendnewmessage = event => {
  onConnect(event);
  onClose();
  inComing();
};
// =======================

Enter fullscreen mode Exit fullscreen mode

Now we have a way of connecting to our WebSocket from our application. Your messages need to be in JSON format, for example in the above application here are our formats:

create meeting

/**
   * MESSAGE FORMAT
   * ===================
   * const event = JSON.stringify({
   *   action: "createMeeting"
   *   data: "Room One" // incase you are only collecting room name
   *   data: { "group_name": "": user_name: "": email: ""} // if you want to collect more user information 
   * })
  */
Enter fullscreen mode Exit fullscreen mode

join meeting

* MESSAGE FORMAT
   * ===================
   * const event = JSON.stringify({
   *   action: "joinMeeting"
   *   data: { "group_id": "": user_name: "": email: ""} // if you want to collect more user information 
   * })
  */
Enter fullscreen mode Exit fullscreen mode

send message

* MESSAGE FORMAT
   * ===================
   * const event = JSON.stringify({
   *   action: "onMessage"
   *   data: "Hello world"
   * })
  */
Enter fullscreen mode Exit fullscreen mode

SUMMARY

I hope you are now getting the logic of how to configure and integrate AWS Websocket to your application. To debug your application enable Cloudwatch logs for your stage, you can also view the lambda logs when they are triggered.

One more question to answer. How do I use the HTTPS URL
https://[id].execute-api.us-east-2.amazonaws.com/production/@connections.

Your backend service can use the following WebSocket connection HTTP requests to send a callback message to a connected client, get connection information, or disconnect the client.

To make a callback in your lambda function create a callback URL

exports.handler = function(event, context, callback) {
var domain = event.requestContext.domainName;
var stage = event.requestContext.stage;
var connectionId = event.requestContext.connectionId;
var callbackUrl = util.format(util.format('https://%s/%s/@connections/%s', domain, stage, connectionId));
// Do a SigV4 and then make the call
 let request = {
        host: "[id].execute-api.us-east-2.amazonaws.com",
        service: execute-api
        method: "POST",
        url: `${callbackUrl}`
        path: "/"
      };
      let signedRequest = aws4.sign(request, {
        secretAccessKey: process.env.VUE_APP_SECRET_ACCESS_KEY,
        accessKeyId: process.env.VUE_APP_ACCESS_KEY_ID
      });
      console.log(signedRequest);

      delete signedRequest.headers["Host"];
      delete signedRequest.headers["Content-Length"];

      let response = await axios(signedRequest);
      console.log("RESPONSE", response);
}
Enter fullscreen mode Exit fullscreen mode

How do you do a SigV4 ?
Please read through to understand more about SigV4
https://docs.aws.amazon.com/general/latest/gr/sigv4_signing.html
https://github.com/mhart/aws4

yarn add aws4 axios
var aws4  = require('aws4')
import axios from axios
Enter fullscreen mode Exit fullscreen mode

You can make a POST, DELETE OR GET a user connection information.

Add-ons

If authorization fails on $connect, the connection will not be established, and the client will receive a 401 or 403 response.

API Gateway supports message payloads up to 128 KB with a maximum frame size of 32 KB. If a message exceeds 32 KB, you must split it into multiple frames, each 32 KB or smaller. If a larger message (or frame) is received, the connection is closed with code 1009.

For a route that is configured to use AWS_PROXY or LAMBDA_PROXY integration, communication is one-way, and API Gateway will not pass the backend response through to the route response automatically. For example, in the case of LAMBDA_PROXY integration, the body that the Lambda function returns will not be returned to the client. If you want the client to receive integration responses, you must define a route response to make two-way communication possible.

Our chat section is now up and running. Next is adding the video section.

In the video section, we will introduce another AWS service called Kinesis Video. Ops! let's digest and understand the above first.

Once we have a working connection and your users can interact to implement the video will be a walk in the park.

I hope this tutorial was helpful.

Thank you.

💖 💪 🙅 🚩
kevin_odongo35
Kevin Odongo

Posted on January 22, 2021

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related