Build a Video Application with Breakout Rooms Using Twilio Programmable Video, React, TypeScript, and Express — Part 1

May 17, 2021
Written by
Mia Adjei
Twilion
Reviewed by

Build a Video Application with Breakout Rooms Using Twilio Programmable Video, React, TypeScript, and Express — Part 1

Have you ever attended a video meeting, conference, or class and had the opportunity to join a breakout room? Video breakout rooms are a great way to create space for smaller group discussion and collaboration alongside a larger-group video call.

In this tutorial and the one that follows it, you'll build a video chat application that allows you to create breakout rooms alongside your main video room.

By the end of this first tutorial you will have:

  • A server that handles your API calls to the Twilio Video APIs and communicates with your local database.
  • A database set up to store the associations between main rooms and breakout rooms.

Let's get started!

Prerequisites

You will need:

  • A free Twilio account. (If you register here, you'll receive $10 in Twilio credit when you upgrade to a paid account!)
  • Node.js (version 14.16.1 or higher) and npm installed on your machine.
  • HTTPie or cURL.

Get the starter code

The first step you'll need to take is to get the starter code. Choose a location on your machine where you would like to set up the project. Then, open up a terminal window and run the following command to clone the getting-started branch of the code repository:

git clone -b getting-started  https://github.com/adjeim/react-video-breakouts.git

Then, change into the root directory of the project by running the following command:

cd react-video-breakouts

Install dependencies and set environment variables

Now that you have the starter code, run the following command in your terminal window to install the required dependencies:

npm install

If you’re curious about which dependencies were installed, you can check them out in the package.json file, which is also at the root of the project.

Next, you’ll need to set up your environment variables. Run the following command to copy the .env.template file into a new file called .env:

cp .env.template .env

Open .env in your code editor, and you will see that the file contains the following environment variables:

TWILIO_ACCOUNT_SID=XXXXXXXXXXXXXXXXXXXXXXXXXXXXXX
TWILIO_API_KEY=XXXXXXXXXXXXXXXXXXXXXXXXXXXXXX
TWILIO_API_SECRET=XXXXXXXXXXXXXXXXXXXXXXXXXXXXXX

You’ll need to replace the placeholder text above with your actual Twilio credentials, which can be found in the Twilio Console. Log in to the Twilio Console and find your Account SID.

Twilio console, showing location of Account SID

Copy and paste the value for Account SID to replace the placeholder text for TWILIO_ACCOUNT_SID.

Then, navigate to the API Keys section of the console and generate a new API Key. Copy the API Key's values for SID and Secret to replace the placeholder text for TWILIO_API_KEY and TWILIO_API_SECRET.

Run the Express server

Now that you have your environment variables set up, it’s time to run your server.

Open up the server.ts file in your text editor. You will see that a bare-bones Express server has already been set up for you.

Run the following command from the root of your project to start the server:

npm run server

You should see the following log in your terminal, which means it’s running well:

Express server running on port 5000

Great! Now that your Express server is running, it’s time to connect a database.

Set up and connect a PouchDB database

For this project, you’ll use a database called PouchDB. PouchDB is an open-source JavaScript database that can run in Node.js or in the browser. To learn more about PouchDB, you can check out the guides or API documentation on their website.

To get started with PouchDB, install the pouchdb package and TypeScript types from the command line, using the following commands:

npm install pouchdb
npm install --save-dev @types/pouchdb

Open your server.ts file and import pouchdb into your project by adding the following line of code below the other import statements:

import twilio, { Twilio } from 'twilio';
import PouchDB from 'pouchdb';

Next, add an interface that describes the structure of the data you want to store. PouchDB is a NoSQL database, so each record is called a document. Each main video room you create will live in its own document.

For this project, you’ll create a VideoRoom interface for the main video rooms. To do this, add the following code to server.ts, just below the app.use(cors(options)); line:

app.use(cors(options));

export interface VideoRoom {
  _id: string,
  _rev: string;
  breakouts: string[];
}

You may want to check out the Rooms API documentation to learn more about Twilio Video Rooms. For the main VideoRoom, you’ll be storing the room’s sid as the _id. You'll associate the breakout rooms with their main rooms by storing their sids in the breakouts field on VideoRoom.

But what about that _rev field, you ask? The _rev field is the revision marker, which is a field used by PouchDB to mark when a record has been created or updated. Whenever you change a record in the database, this randomly-generated ID will be updated as well.

Now that you have a structure for your data, create a new PouchDB database called video_rooms by adding the following line of code to the file, just below the interface you created in the previous step:

export interface VideoRoom {
  _id: string,
  _rev: string;
  breakouts: string[];
}

const db = new PouchDB<VideoRoom>('video_rooms');

Now you’re all set to start creating routes for your API.

Build the API routes

For this project, you’ll be building 4 routes:

  • createRoom: create a new main room
  • createBreakoutRoom: create a new breakout room
  • listActiveRooms: list active video rooms
  • getToken: get an access token for a video room

Before you get started writing routes, however, create a new instance of the Twilio Client to handle creating and updating your video rooms. Add the following code to server.ts, just below where you created the database:

const db = new PouchDB<VideoRoom>('video_rooms');

const twilioClient = new Twilio(
  process.env.TWILIO_API_KEY as string,
  process.env.TWILIO_API_SECRET as string,
  { accountSid: process.env.TWILIO_ACCOUNT_SID as string }
);

Now you're ready to get started with video rooms!

Create a new main video room

First, build the createRoom function by adding the following code to server.ts, just above the app.listen line:

/**
 * Create a new main room
 */
const createRoom = async (request: Request, response: Response) => {
  // Get the room name from the request body.
  // If no room name is provided, the name will be set to the room's SID.
  const roomName: string = request.body.roomName || '';

  try {
    // Call the Twilio video API to create the new room.
    const room = await twilioClient.video.rooms.create({
        uniqueName: roomName,
        type: 'group'
      });

    const mainRoom: VideoRoom = {
      _id: room.sid,
      _rev: '',
      breakouts: [],
    }

    try {
      // Save the document in the db.
      await db.put(mainRoom);

      return response.status(200).send({
        message: `New video room ${room.uniqueName} created`,
        room: mainRoom
      });

    } catch (error) {
      return response.status(400).send({
        message: `Error saving new room to db -- room name=${roomName}`,
        error
      });
    }

  } catch (error) {
    // If something went wrong, handle the error.
    return response.status(400).send({
      message: `Unable to create new room with name=${roomName}`,
      error
    });
  }
};

In this function, the video room’s name comes from the incoming request. Then, it makes a request to the Twilio Video API to create the new room on the Twilio side. The details of the created room are then saved to the database, and the new room details are returned to the client side. Any errors will be handled by the catch statements.

Next, add the new route just above the app.listen line at the end of server.ts:

app.post('/rooms/main', createRoom);

app.listen(port, () => {
  console.log(`Express server running on port ${port}`);
});

If you want to try out creating a new video room from the command line, make sure the Express server is still running, then open up a second terminal window and run one of the following commands, selecting either cURL or HTTPie. Pass in a roomName. For this example, the room’s name will be Music Chat:

// cURL
curl -X POST localhost:5000/rooms/main \
    -d '{"roomName":"Music Chat"}' \
    -H "Content-Type: application/json"

// HTTPie
http POST localhost:5000/rooms/main roomName="Music Chat"

You will see a response that contains the new room that was created:

{
    "message": "New video room Music Chat created",
    "room": {
        "_id": "<ROOM_SID>",
        "_rev": "",
        "breakouts": []
    }
}

Create a new breakout video room

Next, build the createBreakoutRoom function. It is similar to the createRoom function but has a few differences. In addition to a roomName coming in from the request, the incoming request will also include the parentSid, which is the _id of the main video room that this breakout room will be associated with.

Add the following function to server.ts, just below the createRoom function you added in the previous step:

/**
 * Create a new breakout room
 */
const createBreakoutRoom = async (request: Request, response: Response) => {
  // Get the roomName and parentSid from the request body.
  const roomName: string = request.body.roomName || '';

  // If no parent was provided, return an error message.
  if (!request.body.parentSid) {
    return response.status(400).send({
      message: `No parentSid provided for new breakout room with name=${roomName}`,
    });
  }

  const parentSid: string = request.body.parentSid;

  try {
    // Call the Twilio video API to create the new room.
    const breakoutRoom = await twilioClient.video.rooms.create({
        uniqueName: roomName,
        type: 'group'
      });

    try {
      // Save the new breakout room on its parent's record (main room).
      const mainRoom: VideoRoom = await db.get(parentSid);
      mainRoom.breakouts.push(breakoutRoom.sid);
      await db.put(mainRoom);

      // Return the full room details in the response.
      return response.status(200).send({
        message: `Breakout room ${breakoutRoom.uniqueName} created`,
        room: mainRoom
      });

    } catch (error) {
      return response.status(400).send({
        message: `Error saving new breakout room to db -- breakout room name=${roomName}`,
        error
      });
    }

  } catch (error) {
    // If something went wrong, handle the error.
    return response.status(400).send({
      message: `Unable to create new breakout room with name=${roomName}`,
      error
    });
  }
};

In the createBreakoutRoom function, you save the new breakout room on its parent’s document. If no parentSid is provided, an error message is returned—you don't want to create a breakout room that is not associated with a main room.

Add this new route just below the createRoom route you added in the previous section:

app.post('/rooms/main', createRoom);
app.post('/rooms/breakout', createBreakoutRoom);

Try out creating a new main video room and breakout room from the command line. First, create a new main room, either using cURL or HTTPie:

// cURL
curl -X POST localhost:5000/rooms/main \
    -d '{"roomName":"Music Chat 2"}' \
    -H "Content-Type: application/json"

// HTTPie
http POST localhost:5000/rooms/main roomName="Music Chat 2"

Then, to create a breakout room called "Jazz", copy the returned _id from the main room you just created above, and pass it into the request to the createBreakoutRoom request, using either cURL or HTTPie:

// cURL
curl -X POST localhost:5000/rooms/breakout \
    -d '{"roomName":"Jazz", "parentSid":"<ROOM_SID>"}' \
    -H "Content-Type: application/json"

// HTTPie
http POST localhost:5000/rooms/breakout roomName="Jazz" parentSid="<ROOM_SID>"

You will see a response that contains the updated main room, now with a breakout room in the breakouts array:

{
    "message": "Breakout room Jazz created",
    "room": {
        "_id": "<ROOM_SID>",
        "_rev": "<REV_ID>",
        "breakouts": [
            "<BREAKOUT_ROOM_SID>"
        ]
    }
}

Sweet! Now there is a breakout room in your Music Chat 2 called Jazz, where all your friends who love jazz can talk about their favorite songs.

List active main rooms

Next, create a function for listing the active main rooms. This will be useful for your client side application, because you’ll want your users to only join rooms that are open and in progress.

To check the rooms' current statuses, you'll need to use the twilioClient to call the Rooms API and get a list of the rooms that are currently active. Then, you can filter the documents in the database based on this list, and return only the active main rooms and their breakout rooms to the client side of your application.

Just below the interface you created earlier to save VideoRooms to the database, add the following new interfaces that describe the main room and breakout room data that you'll be returning to the client side of your application:

export interface VideoRoom {
  _id: string,
  _rev: string;
  breakouts: string[];
}

interface MainRoomItem {
  _id: string,
  name: string;
  breakouts: BreakoutRoomItem[]
}

interface BreakoutRoomItem {
  _id: string;
  name: string;
}

The data you'll get back from the Twilio Rooms API will include the rooms' names, so name is included in these interfaces. This will allow you to display the room names on the client side of your application.

Next, just below your function for createBreakoutRoom, add the following code to server.ts to create the listActiveRooms function:

/**
* List active video rooms
*/
const listActiveRooms = async (request: Request, response: Response) => {
  try {
    // Get the last 20 rooms that are still currently in progress.
    const rooms = await twilioClient.video.rooms.list({status: 'in-progress', limit: 20});

    // Get a list of active room sids.
    let activeRoomSids = rooms.map((room) => room.sid);

    try {
      // Retrieve the room documents from the database.
      let dbRooms = await db.allDocs({
        include_docs: true,
      });

      // Filter the documents to include only the main rooms that are active.
      let dbActiveRooms = dbRooms.rows.filter((mainRoomRecord) => {
        return activeRoomSids.includes(mainRoomRecord.id) && mainRoomRecord
      });

    // Create a list of MainRoomItem that will associate a room's id with its name and breakout rooms.
    let videoRooms: MainRoomItem[] = [];

    // For each of the active rooms from the db, get the details for that main room and its breakout rooms.
    // Then pass that data into an array to return to the client side.
    if (dbActiveRooms) {
      dbActiveRooms.forEach((row) => {
        // Find the specific main room in the list of rooms returned from the Twilio Rooms API.
        const activeMainRoom = rooms.find((mainRoom) => {
          return mainRoom.sid === row.doc._id;
        })

        // Get the list of breakout rooms from this room's document.
        const breakoutSids = row.doc.breakouts;

        // Filter to select only the breakout rooms that are active according to
        // the response from the Twilio Rooms API.
        const activeBreakoutRooms = rooms.filter((breakoutRoom) => {
          return breakoutSids.includes(breakoutRoom.sid);
        });

        // Create a list of BreakoutRoomItems that will contain each breakout room's name and id.
        let breakouts: BreakoutRoomItem[] = [];

        // Get the names of each breakout room from the API response.
        activeBreakoutRooms.forEach((breakoutRoom) => {
          breakouts.push({
            _id: breakoutRoom.sid,
            name: breakoutRoom.uniqueName
          })
        });

        const videoRoom: MainRoomItem = {
          _id: activeMainRoom.sid,
          name: activeMainRoom.uniqueName,
          breakouts: breakouts
        };
        // Add this room to the list of rooms to return to the client side.
        videoRooms.push(videoRoom);
      });
    }

    // Return the list of active rooms to the client side.
    return response.status(200).send({
      rooms: videoRooms,
    });

    } catch (error) {
      return response.status(400).send({
        message: `Error retrieving video rooms from db`,
        error
      });
    }

  } catch (error) {
    return response.status(400).send({
      message: `Unable to list active rooms`,
      error
    });
  }
};

Then, add the listActiveRooms route to the list of other routes you created:

app.post('/rooms/main', createRoom);
app.post('/rooms/breakout', createBreakoutRoom);
app.get('/rooms/', listActiveRooms);

Now you can try listing the active video rooms using a cURL or HTTPie request:

// cURL
curl -X GET localhost:5000/rooms/

// HTTPie
http GET localhost:5000/rooms/

If there are no active video rooms, you will see a response like this:

{
    "rooms": []
}

Rooms that you previously created that have become inactive have been filtered out of this response.

Rooms created via the Twilio REST API exist for 5 minutes to allow participants to connect, but if no one connects within those 5 minutes, the Room times out.

If you would like to see what the response is like when rooms are active, try creating a new video room by sending another request to the createRoom route and then trying to listActiveRooms once more. You will see that the response contains your newly-created room.

Retrieve an access token to join a video room

The last route you will add to your server in Part 1 of this tutorial is one that grants access tokens to the users who will be joining your video rooms on the client side. For this request, the client side will send along the roomSid of the room that the user wants to access, as well as the identity of that user, which will likely be a name or username.

To learn more about access tokens, check out the access token documentation here.

Add the following function to server.ts just below the listActiveRooms function:

/**
 * Get a token for a user for a video room
 */
const getToken = (request: Request, response: Response) => {
  const AccessToken = twilio.jwt.AccessToken;
  const VideoGrant = AccessToken.VideoGrant;

  // Get the user's identity and roomSid from the query.
  const { identity, roomSid } = request.body;

// Create the access token.  
const token = new AccessToken(
    process.env.TWILIO_ACCOUNT_SID as string,
    process.env.TWILIO_API_KEY as string,
    process.env.TWILIO_API_SECRET as string,
    { identity: identity as string }
  );

  token.identity = identity;

  // Add a VideoGrant to the token to allow the user of this token to use Twilio Video
  const grant = new VideoGrant({ room: roomSid as string });
  token.addGrant(grant);

  response.json({
    accessToken: token.toJwt()
  });
};

Then, complete your list of routes by adding getToken to the list:

app.post('/rooms/main', createRoom);
app.post('/rooms/breakout', createBreakoutRoom);
app.get('/rooms/', listActiveRooms);
app.post('/token', getToken);

If you want to try out getting an access token for one of your video rooms, create a new room using the createRoom endpoint you wrote earlier. You can add a breakout room if you want as well! Copy the _id of the new room, and then pass it to your getToken endpoint using a cURL or HTTPie request. For example, if you had an active room called Flute Player Chat, you could get this room's _id and request a token for a participant named Lizzo. Using cURL or HTTPie, pass in the room's _id as the roomSid and the participant's identity as Lizzo:

// cURL
curl -X POST localhost:5000/token \
    -d '{"roomSid":"<ROOM_SID>", "identity":"Lizzo"}' \
    -H "Content-Type: application/json"

// HTTPie
http POST localhost:5000/token identity="Lizzo" roomSid="<ROOM_SID>"

The server will respond with an access token, as in the following example:

{
    "accessToken": "<ACCESS_TOKEN>"
}

Now Lizzo is able to join the Flute Player Chat. Very cool!

What's next for your video application with breakout rooms?

Now you have a great server setup. You are able to create and manage video rooms and their breakout sessions as well. Next, you'll probably want to actually start using the video call functionality in your front-end application.

If you would like to check out the entirety of the code from this first tutorial, check out the updated-server branch of this GitHub repository.

In the next tutorial, you'll build out the client side of your application, using React. If you're ready, go ahead and click here to get started! I can't wait to see what you build!

Mia Adjei is a Software Developer on the Developer Voices team. They love to help developers build out new project ideas and discover aha moments. Mia can be reached at madjei [at] twilio.com.