DEV Community

Cover image for Get a basic chat application working with WebRTC
Brian Neville-O'Neill
Brian Neville-O'Neill

Posted on • Originally published at blog.logrocket.com on

Get a basic chat application working with WebRTC

Written by Jeremy Kithome✏️

WebRTC (Web Real-Time Communication) is a technology that enables web browsers and native clients for major platforms to exchange video, audio, and generic data without the need for an intermediary such as a server.

It is used by applications like Google Hangouts, Facebook Messenger, Discord, Amazon Chime Houseparty, Whereby(formerly Appear.in), Gotomeeting, Peer5, and by companies such as Google, Facebook, Citrix, Sinch, Twilio, TokBox, Screenhero, and Amazon.

There are several libraries that wrap the browsers implementation of WebRTC that one can use to build WebRTC-based apps such as simpleRTC, peerJS, RTCMultiConnection and webRTC.io.

In this article, we will focus on building the chat application using the browser implementation of WebRTC and not the available libraries.

Terminology

Signaling – the process of determining communication protocols, channels, media codecs and formats, method of data transfer, and routing information needed to exchange information between peers.

RTCPeerConnection – an interface that represents a connection between two peers that monitors the state of the connection and closes the connection after the exchange of data or when a connection is no longer required.

RTCDataChannel – an interface that constitutes a network tunnel/channel that can be used for back and forth communication between peers. A data channel is associated with an RTCPeerConnection.

The theoretical maximum channels that can be associated with a connection is 65,534 (although this may be browser dependent).

STUN(Session Traversal Utilities for NAT(Network Address Translator)) server – returns the IP address, port, and connectivity status of a networked device behind a NAT.

TURN(Traversal Using Relays around NAT) server – a protocol that enables devices to receive and send data from behind a NAT or firewall.

A TURN server in some cases will be used to transmit data between peers if they are unable to connect. TURN servers are expensive to run and should be session authenticated to prevent unauthorized use.

LogRocket Free Trial Banner

Signaling server

Before we can build our chat app, we will need a signaling server. We will build our server using Node.js. Our server will be responsible for the following:

  • Keeping a list of connected clientsNotifying connected clients when a new client connects
  • Transmitting connection offers from one client to the other
  • Transmitting answers to connection offers
  • Exchanging IceCandidates between clients
  • Notifying a user when a client disconnects

Setup

Create the following folder structure for our server:

signalling-server
├── README.md
├── .gitignore
└── index.js
Enter fullscreen mode Exit fullscreen mode

Alternatively, this can be done through the terminal in the following way:

$ mkdir signalling-server
$ cd signalling-server
$ touch README.md index.js .gitignore
Enter fullscreen mode Exit fullscreen mode

You can add a description of what your project is about to the README.md. You should also add the node_modules folder to the .gitignore file like so:

node_modules/
Enter fullscreen mode Exit fullscreen mode

To generate the package.json file without prompts, run the following command:

$ npm init -y
Enter fullscreen mode Exit fullscreen mode

The contents of the package.json file will look like this:

{
  "name": "signalling-server",
  "version": "1.0.0",
  "description": "",
  "main": "index.js",
  "scripts": {
    "test": "echo \"Error: no test specified\" && exit 1"
  },
  "keywords": [],
  "author": "",
  "license": "ISC"
}
Enter fullscreen mode Exit fullscreen mode

Install dependencies

$ yarn add express uuid ws
Enter fullscreen mode Exit fullscreen mode

We will use web sockets to communicate between the server and the browser. Add the following code to your index.js file.

const express = require("express");
const WebSocket = require("ws");
const http = require("http");
const uuidv4 = require("uuid/v4");
const app = express();

const port = process.env.PORT || 9000;

//initialize a http server
const server = http.createServer(app);

//initialize the WebSocket server instance
const wss = new WebSocket.Server({ server });

wss.on("connection", ws => {
  ws.on("message", msg => {
    console.log("Received message: %s from client", msg);
  });
  //send immediate a feedback to the incoming connection
  ws.send(
    JSON.stringify({
      type: "connect",
      message: "Well hello there, I am a WebSocket server"
    })
  );
});

//start our server
server.listen(port, () => {
  console.log(`Signalling Server running on port: ${port}`);
});
Enter fullscreen mode Exit fullscreen mode

We first instantiate a simple http server using express, then add a WebSocket server using the express server.

Next, we add a connection event listener that handles all incoming connections from clients.

Once a client connects, we immediately send them a message indicating successful connection. We also register a message event listener to handle incoming messages from clients. Let’s go ahead and start our server:

$ node index.js
Enter fullscreen mode Exit fullscreen mode

You can use the wscat utility or the Smart Websocket Client chrome extension to test your server. If you have wscat installed, after starting the server, open a new terminal tab and run:

$ wscat -c ws://localhost:9000
Enter fullscreen mode Exit fullscreen mode

You should see the following

Using wscat to connect to a websocket server
Connecting to a websocket server

User connection

Since we will be handling different types of messages eventually, we will need to accept stringified JSON messages containing the type of message and other relevant data.

We will therefore need to make sure that the message is valid JSON before proceeding. Add the following code to your message handler:

ws.on("message", msg => {
    let data;
    //accepting only JSON messages
    try {
      data = JSON.parse(msg);
    } catch (e) {
      console.log("Invalid JSON");
      data = {};
    }
}
Enter fullscreen mode Exit fullscreen mode

When the user connects, we will need to assign them an id and ensure that their chosen username has not been taken. All connected users will also need to be stored.

Let’s expand on the message handler. We will also add two utility functions for sending messages to a single user and all other connected users except the user that triggered the message handler.

When a user logs in, we will use the first utility function to send them back a success message and all the users that are already connected. We will alsofa notify all the connected users that a new user has logged in.

.....
//initialize the WebSocket server instance
const wss = new WebSocket.Server({ server });

let users = {};

const sendTo = (connection, message) => {
  connection.send(JSON.stringify(message));
};
const sendToAll = (clients, type, { id, name: userName }) => {
  Object.values(clients).forEach(client => {
    if (client.name !== userName) {
      client.send(
        JSON.stringify({
          type,
          user: { id, userName }
        })
      );
    }
  });
};

wss.on("connection", ws => {
  ws.on("message", msg => {
    let data;
    //accepting only JSON messages
    try {
      data = JSON.parse(msg);
    } catch (e) {
      console.log("Invalid JSON");
      data = {};
    }
    const { type, name } = data;
    //Handle message by type
    switch (type) {
      //when a user tries to login
      case "login":
        //Check if username is available
        if (users[name]) {
          sendTo(ws, {
            type: "login",
            success: false,
            message: "Username is unavailable"
          });
        } else {
          const id = uuidv4();
          const loggedIn = Object.values(
            users
          ).map(({ id, name: userName }) => ({ id, userName }));
          users[name] = ws;
          ws.name = name;
          ws.id = id;
          sendTo(ws, {
            type: "login",
            success: true,
            users: loggedIn
          });
          sendToAll(users, "updateUsers", ws);
        }
        break;
      default:
        sendTo(ws, {
          type: "error",
          message: "Command not found: " + type
        });
        break;
    }

  });
  //send immediate a feedback to the incoming connection
  ws.send(
    JSON.stringify({
      type: "connect",
      message: "Well hello there, I am a WebSocket server"
    })
  );
});
Enter fullscreen mode Exit fullscreen mode

Let’s attempt to login a new user, then try to login with the same username as well as provide an unknown message type and see what happens.

A login message initiated after attempting to log in a new user.
Login message

Making a connection offer

Once a user has successfully connected, they will want to establish a connection with another user.

To do this, they will send the other user an offer to connect.

Once the server receives the offer message, it needs to confirm if the user exists before sending the offer.

Let’s add a case for the offer message type:

ws.on("message", msg => {
  ....
  const { type, name, offer } = data;
  //Handle message by type
  switch (type) {
    ....
    case "offer":
      //Check if user to send offer to exists
      const offerRecipient = users[name];
      if (!!offerRecipient) {
        sendTo(offerRecipient, {
          type: "offer",
          offer,
          name: ws.name
        });
      } else {
        sendTo(ws, {
          type: "error",
          message: `User ${name} does not exist!`
        });
      }
      break;
    ...
  }
}
Enter fullscreen mode Exit fullscreen mode

Answering to an offer

When a client receives an offer to connect, they will send back an answer to the offer creator. Our server just passes the answer along. When we build the frontend, the offer and answer process will become more clear.

ws.on("message", msg => {
  ....
  const { type, name, offer, answer } = data;
  //Handle message by type
  switch (type) {
    ....
    case "answer":
      //Check if user to send answer to exists
      const answerRecipient = users[name];
      if (!!answerRecipient) {
        sendTo(answerRecipient, {
          type: "answer",
          answer,
        });
      } else {
        sendTo(ws, {
          type: "error",
          message: `User ${name} does not exist!`
        });
      }
      break;
    ...
  }
}
Enter fullscreen mode Exit fullscreen mode

We can now test the offer and answer exchange using two connected users

testing the offer and answer exchange using two connected users.
Offer and answer exchange

Handling IceCandidates

Once the answer and offer process is done, the users will begin to send IceCandidates to each other until they agree on the best way to connect.

As with most of the other messages, our server will only act as an intermediary that passes messages between the users.

ws.on("message", msg => {
  ....
  const { type, name, offer, answer, candidate} = data;
  //Handle message by type
  switch (type) {
    ....
    case "candidate":
      //Check if user to send candidate to exists
      const candidateRecipient = users[name];
      if (!!candidateRecipient) {
        sendTo(candidateRecipient, {
          type: "candidate",
          candidate
        });
      } else {
        sendTo(ws, {
          type: "error",
          message: `User ${name} does not exist!`
        });
      }
      break;
    ...
  }
}
Enter fullscreen mode Exit fullscreen mode

Handling a user leaving

When a user leaves, we should notify all the other connected users that the user has left.

ws.on("message", msg => {
  ....
  //Handle message by type
  switch (type) {
    ....
    case "leave":
      sendToAll(users, "leave", ws);
      break;
    ...
  }
}
Enter fullscreen mode Exit fullscreen mode

We should also notify the other users when the connection drops.

wss.on("connection", ws => {
  ...
  ws.on("close", function() {
      delete users[ws.name];
      sendToAll(users, "leave", ws);
    }
  });
  ...
}
Enter fullscreen mode Exit fullscreen mode

Here is the final code for our completed signaling server:

const express = require("express");
const WebSocket = require("ws");
const http = require("http");
const uuidv4 = require("uuid/v4");

const app = express();

const port = process.env.PORT || 9000;

//initialize a http server
const server = http.createServer(app);

//initialize the WebSocket server instance
const wss = new WebSocket.Server({ server });

let users = {};

const sendTo = (connection, message) => {
  connection.send(JSON.stringify(message));
};

const sendToAll = (clients, type, { id, name: userName }) => {
  Object.values(clients).forEach(client => {
    if (client.name !== userName) {
      client.send(
        JSON.stringify({
          type,
          user: { id, userName }
        })
      );
    }
  });
};

wss.on("connection", ws => {
  ws.on("message", msg => {
    let data;
    //accept only JSON messages
    try {
      data = JSON.parse(msg);
    } catch (e) {
      console.log("Invalid JSON");
      data = {};
    }
    const { type, name, offer, answer, candidate } = data;
    switch (type) {
      //when a user tries to login
      case "login":
        //Check if username is available
        if (users[name]) {
          sendTo(ws, {
            type: "login",
            success: false,
            message: "Username is unavailable"
          });
        } else {
          const id = uuidv4();
          const loggedIn = Object.values(
            users
          ).map(({ id, name: userName }) => ({ id, userName }));
          users[name] = ws;
          ws.name = name;
          ws.id = id;
          sendTo(ws, {
            type: "login",
            success: true,
            users: loggedIn
          });
          sendToAll(users, "updateUsers", ws);
        }
        break;
      case "offer":
        //Check if user to send offer to exists
        const offerRecipient = users[name];
        if (!!offerRecipient) {
          sendTo(offerRecipient, {
            type: "offer",
            offer,
            name: ws.name
          });
        } else {
          sendTo(ws, {
            type: "error",
            message: `User ${name} does not exist!`
          });
        }
        break;
      case "answer":
        //Check if user to send answer to exists
        const answerRecipient = users[name];
        if (!!answerRecipient) {
          sendTo(answerRecipient, {
            type: "answer",
            answer,
          });
        } else {
          sendTo(ws, {
            type: "error",
            message: `User ${name} does not exist!`
          });
        }
        break;
      case "candidate":
        const candidateRecipient = users[name];
        if (!!candidateRecipient) {
          sendTo(candidateRecipient, {
            type: "candidate",
            candidate
          });
        }
        break;
      case "leave":
        sendToAll(users, "leave", ws);
        break;
      default:
        sendTo(ws, {
          type: "error",
          message: "Command not found: " + type
        });
        break;
    }
  });
  ws.on("close", function() {
    delete users[ws.name];
    sendToAll(users, "leave", ws);
  });
  //send immediatly a feedback to the incoming connection
  ws.send(
    JSON.stringify({
      type: "connect",
      message: "Well hello there, I am a WebSocket server"
    })
  );
});
//start our server
server.listen(port, () => {
  console.log(`Signalling Server running on port: ${port}`);
});
Enter fullscreen mode Exit fullscreen mode

With the signaling server in place, we can now start building the Chat app.

Chat app

Setup

Our folder structure for the app will look as follows:

simple-webrtc-chat-app
├── public
   ├── index.html
   ├── manifest.json
├── src
   ├── App.js
   ├── index.js
   ├── Container.js
   ├── Chat.js
   ├── MessageBox.js
   ├── UserList.js
├── .gitignore
├── README.md
└── package.json
Enter fullscreen mode Exit fullscreen mode

Most of the files will be created when we bootstrap the app. You can bootstrap the project using any of the following commands:

npx:

$ npx create-react-app simple-webrtc-chat-app
Enter fullscreen mode Exit fullscreen mode

npm (*npm init <initializer>* is available in npm 6+) :

$ npm init react-app simple-webrtc-chat-app
Enter fullscreen mode Exit fullscreen mode

yarn (*yarn create <starter-kit-package>* is available in Yarn 0.25+) :

$ yarn create react-app simple-webrtc-chat-app
Enter fullscreen mode Exit fullscreen mode

Once you have finished creating the project folder, you can open it and run it:

cd simple-webrtc-chat-app
npm start //or
yarn start
Enter fullscreen mode Exit fullscreen mode

This will run the app in development mode. You can view it in the browser using the link http://localhost:3000/.

Installing additional dependencies

We will require a couple of libraries to help us build our chat application: Semantic UI React for styling, date-fns for manipulating dates, and react-bootstrap-sweetalert to show success and error messages.

To install them, run the following command:

$ yarn add date-fns semantic-ui-react react-bootstrap-sweetalert
Enter fullscreen mode Exit fullscreen mode

To theme the Semantic UI components, we will need semantic UI stylesheets. The quickest way to get started is by using a CDN. Just add this link to the <head> of your index.html file in the public folder:

<link rel="stylesheet" href="//cdn.jsdelivr.net/npm/semantic-ui@2.4.2/dist/semantic.min.css" />
Enter fullscreen mode Exit fullscreen mode

Component setup

Our Chat application will have five constituent components:

  • The app component which will be the main component of the application
  • The Container component which will subscribe to context changes
  • The Chat component will create a websocket connection to the server and listen to and handle messages as well as establish connections with other users
  • The UserList component will list all the users that are currently online i.e connected to the signalling server and the user can attempt to connect with
  • The MessageBox component will show a thread of messages between connected peers
# Navigate to source directory
$ cd src/
#Create new files
$ touch Container.js Chat.js UserList.js MessageBox.js
Enter fullscreen mode Exit fullscreen mode

The App component

We will create contexts to hold the peer connection between users and the data channel for information exchange. Each context will have a function passed down to allow consumers to update the context.

We will render Provider React components for both the connection and the channel and pass them a value from state that will be null to start with.

Add the following code to your App.js file:

The Container component

import React from "react";
import Chat from "./Chat";
import { ConnectionConsumer, ChannelConsumer} from "./App";
const Container = () => {
  return (
    <ConnectionConsumer>
      {({ connection, updateConnection }) => (
        <ChannelConsumer>
          {({ channel, updateChannel }) => (
            <Chat
              connection={connection}
              updateConnection={updateConnection}
              channel={channel}
              updateChannel={updateChannel}
            />
          )}
        </ChannelConsumer>
      )}
    </ConnectionConsumer>
  );
};
export default Container
Enter fullscreen mode Exit fullscreen mode

To make the connection and channel available to nested components as props, we use Context.Consumer. This is a component that subscribes to context changes.

You will notice that we exported the Consumers for the connection and channel contexts in App.js. We will use those exports in the Container Component.

We will also render the Chat component within the Container component.

The Chat component

When the components renders for the first time, we use useEffect to create a websocket connection and store it in a Ref. The connection is created using the server url. Notice the ws at the beginning of the URL.

If you are using a secure URL, this will be wss. The connection will listen for messages and close event. The received messages will be added to state to be processed.

The initial code for the component should look something like this:

import React, { Fragment, useState, useEffect, useRef } from "react";
import {
  Header,
  Loader
} from "semantic-ui-react";

const Chat = ({ connection, updateConnection, channel, updateChannel }) => {
  const webSocket = useRef(null);
  const [socketOpen, setSocketOpen] = useState(false);
  const [socketMessages, setSocketMessages] = useState([]);
  const [alert, setAlert] = useState(null);

  useEffect(() => {
    webSocket.current = new WebSocket("ws://localhost:9000");
    webSocket.current.onmessage = message => {
      const data = JSON.parse(message.data);
      setSocketMessages(prev => [...prev, data]);
    };
    webSocket.current.onclose = () => {
      webSocket.current.close();
    };
    return () => webSocket.current.close();
  }, []);
  return (
    <div className="App">
      {alert}
      <Header as="h2" icon>
        <Icon name="users" />
        Simple WebRTC Chap App
      </Header>
      {(socketOpen && (
        <Fragment>
        </Fragment>
      )) || (
        <Loader size="massive" active inline="centered">
          Loading
        </Loader>
      )}
    </div>
  );
};
export default Chat;
Enter fullscreen mode Exit fullscreen mode

If a connection with the server has not been made yet, we show a loader.

the loading page for our simple WebRTC chat app
Simple WebRTC chap app

A user should be able to send messages to the server. The following component function will enable them to do just that.

const send = data => {
  webSocket.current.send(JSON.stringify(data));
};
Enter fullscreen mode Exit fullscreen mode

To handle messages we receive from the signaling server, we will use a useEffect that will fire whenever the socketMessages changes. It will take the last message and process it.

useEffect(() => {
    let data = socketMessages.pop();
    if (data) {
      switch (data.type) {
        case "connect":
          setSocketOpen(true);
          break;
        default:
          break;
      }
    }
  }, [socketMessages]);
Enter fullscreen mode Exit fullscreen mode

When we receive a connect message from the server, we will update the socketOpen variable so we can render the other contents. Messages of type login, updateUsers, removeUser, offer, answer, and candidate will also be handled.

Each message will call the respective handler. We will define the handlers later. The complete useEffect should look like this:

useEffect(() => {
    let data = socketMessages.pop();
    if (data) {
      switch (data.type) {
        case "connect":
          setSocketOpen(true);
          break;
        case "login":
          onLogin(data);
          break;
        case "updateUsers":
          updateUsersList(data);
          break;
        case "removeUser":
          removeUser(data);
          break;
        case "offer":
          onOffer(data);
          break;
        case "answer":
          onAnswer(data);
          break;
        case "candidate":
          onCandidate(data);
          break;
        default:
          break;
      }
    }
  }, [socketMessages]);
Enter fullscreen mode Exit fullscreen mode

User login

As soon as a connection has been established with the server, we will render an input with a button that will allow the user to enter their username and login.

Clicking the handleLogin function will send a login message to the server with the username the user has chosen. Once a user has successfully logged in, we will show their logged in state instead of the username input.

If their username is already taken, we will show them an alert indicating that. Add the following code to your Chat component:

...
import {
  ...
  Icon,
  Input,
  Grid,
  Segment,
  Button,
} from "semantic-ui-react";

const Chat = ({ connection, updateConnection, channel, updateChannel }) => {
  ....
  const [isLoggedIn, setIsLoggedIn] = useState(false);
  const [name, setName] = useState("");
  const [loggingIn, setLoggingIn] = useState(false);

  ...
  const handleLogin = () => {
    setLoggingIn(true);
    send({
      type: "login",
      name
    });
  };

  return (
    <div className="App">
      ....
      {(socketOpen && (
        <Fragment>
          <Grid centered columns={4}>
            <Grid.Column>
              {(!isLoggedIn && (
                <Input
                  fluid
                  disabled={loggingIn}
                  type="text"
                  onChange={e => setName(e.target.value)}
                  placeholder="Username..."
                  action
                >
                  <input />
                  <Button
                    color="teal"
                    disabled={!name || loggingIn}
                    onClick={handleLogin}
                  >
                    <Icon name="sign-in" />
                    Login
                  </Button>
                </Input>
              )) || (
                <Segment raised textAlign="center" color="olive">
                  Logged In as: {name}
                </Segment>
              )}
            </Grid.Column>
          </Grid>
        </Fragment>
      )) || (
        ...
      )}
    </div>
  );
};
export default Chat;
Enter fullscreen mode Exit fullscreen mode

We also need to add a handler for the login message from the server.

const Chat = ({ connection, updateConnection, channel, updateChannel }) => {
  ...  
  const [users, setUsers] = useState([]);
  ...
  const onLogin = ({ success, message, users: loggedIn }) => {
      setLoggingIn(false);
      if (success) {
        setAlert(
          <SweetAlert
            success
            title="Success!"
            onConfirm={closeAlert}
            onCancel={closeAlert}
          >
            Logged in successfully!
          </SweetAlert>
        );
        setIsLoggedIn(true);
        setUsers(loggedIn);
      } else {
        setAlert(
          <SweetAlert
            warning
            confirmBtnBsStyle="danger"
            title="Failed"
            onConfirm={closeAlert}
            onCancel={closeAlert}
          >
            {message}
          </SweetAlert>
        );
      }
    };
    ...
}
Enter fullscreen mode Exit fullscreen mode

the login input screen for our simple WebRTC chat app
Login Input

An example of a successful login on a simple WebRTC chat app
Login success

A failed display indicating an entered username is unavailable
Failed username

As you can see, the login response has a field containing the currently logged in users that we assign to the state users variable.

We will need to add a sidebar listing all the online users. Before we proceed with the rest of the Chat component, let’s look at the UsersList component.

UsersList component

This component will list all the users that are currently online.

Each listing will show a connected user’s username and a button for our user to initiate the connection process with another user.

If the user is already connected to another user, the button text will change to Disconnect and all other buttons will be disabled to prevent the user from establishing another connection until they close the current connection.

The connect buttons for each user will also be disabled if the user is in the process of connecting to another peer. The component will receive users, toggleConnection, connectedTo and connecting props from the Chat component.

Add the following code to src/UsersList.js:

import React from "react";
import {
  Grid,
  Segment,
  Card,
  List,
  Button,
  Image,
} from "semantic-ui-react";
import avatar from "./avatar.png";
const UsersList = ({ users, toggleConnection, connectedTo, connecting }) => {
  return (
    <Grid.Column width={5}>
      <Card fluid>
        <Card.Content header="Online Users" />
        <Card.Content textAlign="left">
          {(users.length && (
            <List divided verticalAlign="middle" size="large">
              {users.map(({ userName }) => (
                <List.Item key={userName}>
                  <List.Content floated="right">
                    <Button
                      onClick={() => {
                        toggleConnection(userName);
                      }}
                      disabled={!!connectedTo && connectedTo !== userName}
                      loading={connectedTo === userName && connecting}
                    >
                      {connectedTo === userName ? "Disconnect" : "Connect"}
                    </Button>
                  </List.Content>
                  <Image avatar src={avatar} />
                  <List.Content>
                    <List.Header>{userName}</List.Header>
                  </List.Content>
                </List.Item>
              ))}
            </List>
          )) || <Segment>There are no users Online</Segment>}
        </Card.Content>
      </Card>
    </Grid.Column>
  );
};
export default UsersList;
Enter fullscreen mode Exit fullscreen mode

Now that we have the UsersList component, we need to import it into the Chat component and render it.

...
import UsersList from "./UsersList";

const Chat = ({ connection, updateConnection, channel, updateChannel }) => {
  ....
  const [connectedTo, setConnectedTo] = useState("");
  const connectedRef = useRef();
  const [connecting, setConnecting] = useState(false);

  ...
  const toggleConnection = userName => {
    if (connectedRef.current === userName) {
      setConnecting(true);
      setConnectedTo("");
      connectedRef.current = "";
      setConnecting(false);
    } else {
      setConnecting(true);
      setConnectedTo(userName);
      connectedRef.current = userName;
      // To be discussed later
      handleConnection(userName);
      setConnecting(false);
    }

  return (
    <div className="App">
      ....
      {(socketOpen && (
        <Fragment>
          ...
          <Grid>
            <UsersList
              users={users}
              toggleConnection={toggleConnection}
              connectedTo={connectedTo}
              connection={connecting}
            />
          </Grid>
        </Fragment>
      )) || (
        ...
      )}
    </div>
  );
};
Enter fullscreen mode Exit fullscreen mode

A simple WebRTC chat app indicating there are no users online
No other user online

A simple WebRTC chat app displaying that there are other users online.
Users online

Creating connection

After a successful login, we should create a new RTCPeerConnection to enable the user to connect with other users.

Let’s add some code to create the peer connection.

On login success, we will create a new RTCPeerConnection. The RTCPeerConnection constructor takes a configuration containing STUN and TURN servers.

In our example, we will only be using Google’s public STUN server. We will add an oniceCandidate handler which sends all found Icecandidates to the other user.

Another handler that has to be added is the ondatachannel handler. This will be triggered when a remote peer adds a data channel to the connection by calling createDataChannel() .

Once the connection is created, we will call the context prop method updateConnection to update the context with the created channel.

When the ondatachannel handler is fired, we add an onmessage handler and then store the channel in context using updateChannel.

This method is triggered by the remote peer that accepts the connection request.

The peer that initiates the connection creates their own dataChannel. Modify the onLogin method as indicated below:

const configuration = {
  iceServers: [{ url: "stun:stun.1.google.com:19302" }]
};

const Chat = ({ connection, updateConnection, channel, updateChannel }) => {
  ....
  const onLogin = ({ success, message, users: loggedIn }) => {
      setLoggingIn(false);
      if (success) {
        setAlert(
          <SweetAlert
            success
            title="Success!"
            onConfirm={closeAlert}
            onCancel={closeAlert}
          >
            Logged in successfully!
          </SweetAlert>
        );
        setIsLoggedIn(true);
        setUsers(loggedIn);
        let localConnection = new RTCPeerConnection(configuration);
        //when the browser finds an ice candidate we send it to another peer
        localConnection.onicecandidate = ({ candidate }) => {
          let connectedTo = connectedRef.current;
          if (candidate && !!connectedTo) {
            send({
              name: connectedTo,
              type: "candidate",
              candidate
            });
          }
        };
        localConnection.ondatachannel = event => {
          let receiveChannel = event.channel;
          receiveChannel.onopen = () => {
            console.log("Data channel is open and ready to be used.");
          };
          receiveChannel.onmessage = handleDataChannelMessageReceived;
          updateChannel(receiveChannel);
        };
        updateConnection(localConnection);
      } else {
        setAlert(
          <SweetAlert
            warning
            confirmBtnBsStyle="danger"
            title="Failed"
            onConfirm={closeAlert}
            onCancel={closeAlert}
          >
            {message}
          </SweetAlert>
        );
      }
  }
  ...
}
Enter fullscreen mode Exit fullscreen mode

Handling data channel messages

As soon as the data channel is open, peers can send each other messages.

These messages need to be handled when received.

Since we have already assigned a handler to the onmessage event, let’s look at what it will do:

const configuration = {
  iceServers: [{ url: "stun:stun.1.google.com:19302" }]
};

const Chat = ({ connection, updateConnection, channel, updateChannel }) => {
  ....
  const onLogin = ({ success, message, users: loggedIn }) => {
      setLoggingIn(false);
      if (success) {
        setAlert(
          <SweetAlert
            success
            title="Success!"
            onConfirm={closeAlert}
            onCancel={closeAlert}
          >
            Logged in successfully!
          </SweetAlert>
        );
        setIsLoggedIn(true);
        setUsers(loggedIn);
        let localConnection = new RTCPeerConnection(configuration);
        //when the browser finds an ice candidate we send it to another peer
        localConnection.onicecandidate = ({ candidate }) => {
          let connectedTo = connectedRef.current;
          if (candidate && !!connectedTo) {
            send({
              name: connectedTo,
              type: "candidate",
              candidate
            });
          }
        };
        localConnection.ondatachannel = event => {
          let receiveChannel = event.channel;
          receiveChannel.onopen = () => {
            console.log("Data channel is open and ready to be used.");
          };
          receiveChannel.onmessage = handleDataChannelMessageReceived;
          updateChannel(receiveChannel);
        };
        updateConnection(localConnection);
      } else {
        setAlert(
          <SweetAlert
            warning
            confirmBtnBsStyle="danger"
            title="Failed"
            onConfirm={closeAlert}
            onCancel={closeAlert}
          >
            {message}
          </SweetAlert>
        );
      }
  }
  ...
}
Enter fullscreen mode Exit fullscreen mode

When a message is received, we first retrieve the existing messages before adding the new message.

We are using messagesRef and messages variables because of the way the component methods are created in functional components: constructors within these methods only have access to state values at the time they were created.

We use a Ref to ensure that we are retrieving the up-to-date messages. We then store the updated messages in messages and use that to render the message thread.

Each message contains the sender. We use the sender as the field name that will hold the messages between the local peer and the sender.

Starting negotiation

Earlier, when creating the UsersList component, we used the toggleConnection function to update state with the user that the local peer wanted to connect to.

We will take this a step further and call a handleConnection method when the user tries to connect with another user.

The handleConnection method will create a data channel on the local peer connection and then send an offer to the remote peer for connection.

First, we need to update the toggleConnection method to call the handleConnection method:

const toggleConnection = userName => {
  if (connectedRef.current === userName) {
    setConnecting(true);
    setConnectedTo("");
    connectedRef.current = "";
    setConnecting(false);
  } else {
    setConnecting(true);
    setConnectedTo(userName);
    connectedRef.current = userName;
    handleConnection(userName);
    setConnecting(false);
  }
};
Enter fullscreen mode Exit fullscreen mode

To create a data channel you call the peer connection createDataChannel method with the name of the channel.

We add an onmessage handler like we did for a channel created by a remote peer. We then update context with the created channel.

const handleConnection = name => {
  let dataChannel = connection.createDataChannel("messenger");
  dataChannel.onerror = error => {
    setAlert(
      <SweetAlert
        warning
        confirmBtnBsStyle="danger"
        title="Failed"
        onConfirm={closeAlert}
        onCancel={closeAlert}
      >
        An error has occurred.
      </SweetAlert>
    );
  };
  dataChannel.onmessage = handleDataChannelMessageReceived;
  updateChannel(dataChannel);
};
Enter fullscreen mode Exit fullscreen mode

After creating the channel, it is time to create an offer. This is done using the createOffer method of the RTCPeerConnection interface.

The createOffer method initiates the creation of an SDP(Session Description Protocol) offer for the purpose of starting a new WebRTC connection to a remote peer.

The offer includes information such as codec, options supported by the initiating browser, and any candidates already gathered by the ICE agent to be sent through the signaling server to a potential peer.

When the offer is created, we call the setLocalDescription of the RTCPeerConnection interface with the offer (session description). This method updates the local description of the connection, which defines the properties of the local end of the connection.

We then send the offer to the remote peer through the signaling server.

Add the following offer code to the handleConnection method:

const handleConnection = name => {
    ...
    connection
      .createOffer()
      .then(offer => connection.setLocalDescription(offer))
      .then(() =>
        send({ type: "offer", offer: connection.localDescription, name })
      )
      .catch(e =>
        setAlert(
          <SweetAlert
            warning
            confirmBtnBsStyle="danger"
            title="Failed"
            onConfirm={closeAlert}
            onCancel={closeAlert}
          >
            An error has occurred.
          </SweetAlert>
        )
      );
  };
Enter fullscreen mode Exit fullscreen mode

Handling offers from remote peers

When a peer receives an offer from a remote client, it will set its connectedTo value to the username of the remote peer.

We then call the setRemoteDescription method of the RTCPeerConnection interface with the session description received from the remote peer.

The setRemoteDescription method updates the remote description of the connection, which specifies the properties of the remote end of the connection.

After updating the remote description, we create a response.

This is done using the createAnswer method of the connection. The method creates an SDP answer to the offer from the remote peer.

We then call the setLocalDescription with the answer before sending it to the remote peer:

const onOffer = ({ offer, name }) => {
  setConnectedTo(name);
  connectedRef.current = name;
  connection
    .setRemoteDescription(new RTCSessionDescription(offer))
    .then(() => connection.createAnswer())
    .then(answer => connection.setLocalDescription(answer))
    .then(() =>
      send({ type: "answer", answer: connection.localDescription, name })
    )
    .catch(e => {
      console.log({ e });
      setAlert(
        <SweetAlert
          warning
          confirmBtnBsStyle="danger"
          title="Failed"
          onConfirm={closeAlert}
          onCancel={closeAlert}
        >
          An error has occurred.
        </SweetAlert>
      );
    });
};
Enter fullscreen mode Exit fullscreen mode

Handling answers from remote peers

On receipt of an answer from a remote peer, we update the remote description on the local connection with the answer we received.

const onAnswer = ({ answer }) => {
  connection.setRemoteDescription(new RTCSessionDescription(answer));
};
Enter fullscreen mode Exit fullscreen mode

onCandidate handler

During the negotiation process, each peer will send ICE candidates to the the other peer. When a peer receives a candidate message it calls the addIceCandidate method of the RTCPeerConnection.

This adds the candidate to the RTCPeerConnection’s remote description. The handler for candidates:

const onCandidate = ({ candidate }) => {
  connection.addIceCandidate(new RTCIceCandidate(candidate));
};
Enter fullscreen mode Exit fullscreen mode

Sending messages

The send method of the data channel enables us to exchange data between peers. Our messages will contain the time the message was sent, who sent it, and the text.

As previously discussed, when handling received messages, we store messages using the name of the user we are texting. Let’s add the sendMsg method.

const Chat = ({ connection, updateConnection, channel, updateChannel }) => {
  ...

  const [message, setMessage] = useState("");

  ...
  const sendMsg = () => {
    const time = format(new Date(), "yyyy-MM-dd'T'HH:mm:ss.SSSxxx");
    let text = { time, message, name };
    let messages = messagesRef.current;
    let connectedTo = connectedRef.current;
    let userMessages = messages[connectedTo];
    if (messages[connectedTo]) {
      userMessages = [...userMessages, text];
      let newMessages = Object.assign({}, messages, {
        [connectedTo]: userMessages
      });
      messagesRef.current = newMessages;
      setMessages(newMessages);
    } else {
      userMessages = Object.assign({}, messages, { [connectedTo]: [text] });
      messagesRef.current = userMessages;
      setMessages(userMessages);
    }
    channel.send(JSON.stringify(text));
    setMessage("");
  };
  ...
}
Enter fullscreen mode Exit fullscreen mode

Other utility functions

//close alerts
const closeAlert = () => {
  setAlert(null);
};
//add new user to users
const updateUsersList = ({ user }) => {
  setUsers(prev => [...prev, user]);
};
//remove a user from users
const removeUser = ({ user }) => {
  setUsers(prev => prev.filter(u => u.userName !== user.userName));
}
Enter fullscreen mode Exit fullscreen mode

MessageBox component

The MessageBox container will display a thread of messages sent between peers. It will also have an input for a peer to type their message and a button to send the message.

If the local peer is not connected to anyone, a message will indicate this and the input will be disabled. Add the following code to src/MessageBox.js:

import React from "react";
import {
  Header,
  Icon,
  Input,
  Grid,
  Segment,
  Card,
  Sticky,
  Button,
  Comment
} from "semantic-ui-react";
import { formatRelative } from "date-fns";
import avatar from "./avatar.png";
const MessageBox = ({ messages, connectedTo, message, setMessage, sendMsg, name }) => {
  return (
    <Grid.Column width={11}>
      <Sticky>
        <Card fluid>
          <Card.Content
            header={
              !!connectedTo ? connectedTo : "Not chatting with anyone currently"
            }
          />
          <Card.Content>
            {!!connectedTo && messages[connectedTo] ? (
              <Comment.Group>
                {messages[connectedTo].map(({ name: sender, message: text, time }) => (
                  <Comment key={`msg-${name}-${time}`}>
                    <Comment.Avatar src={avatar} />
                    <Comment.Content>
                      <Comment.Author>{sender === name ? 'You' : sender}</Comment.Author>
                      <Comment.Metadata>
                        <span>
                          {formatRelative(new Date(time), new Date())}
                        </span>
                      </Comment.Metadata>
                      <Comment.Text>{text}</Comment.Text>
                    </Comment.Content>
                  </Comment>
                ))}
              </Comment.Group>
            ) : (
              <Segment placeholder>
                <Header icon>
                  <Icon name="discussions" />
                  No messages available yet
                </Header>
              </Segment>
            )}
            <Input
              fluid
              type="text"
              value={message}
              onChange={e => setMessage(e.target.value)}
              placeholder="Type message"
              action
            >
              <input />
              <Button color="teal" disabled={!message} onClick={sendMsg}>
                <Icon name="send" />
                Send Message
              </Button>
            </Input>
          </Card.Content>
        </Card>
      </Sticky>
    </Grid.Column>
  );
};
export default MessageBox;
Enter fullscreen mode Exit fullscreen mode

After importing the MessageBox component in the Chat component, the latter should now contain the following final code:

import React, { Fragment, useState, useEffect, useRef } from "react";
import {
  Header,
  Icon,
  Input,
  Grid,
  Segment,
  Button,
  Loader
} from "semantic-ui-react";
import SweetAlert from "react-bootstrap-sweetalert";
import { format } from "date-fns";
import "./App.css";
import UsersList from "./UsersList";
import MessageBox from "./MessageBox";
// Use for remote connections
const configuration = {
  iceServers: [{ url: "stun:stun.1.google.com:19302" }]
};
// Use for local connections
// const configuration = null;
const Chat = ({ connection, updateConnection, channel, updateChannel }) => {
  const [socketOpen, setSocketOpen] = useState(false);
  const [socketMessages, setSocketMessages] = useState([]);
  const [isLoggedIn, setIsLoggedIn] = useState(false);
  const [name, setName] = useState("");
  const [loggingIn, setLoggingIn] = useState(false);
  const [users, setUsers] = useState([]);
  const [connectedTo, setConnectedTo] = useState("");
  const [connecting, setConnecting] = useState(false);
  const [alert, setAlert] = useState(null);
  const connectedRef = useRef();
  const webSocket = useRef(null);
  const [message, setMessage] = useState("");
  const messagesRef = useRef({});
  const [messages, setMessages] = useState({});
  useEffect(() => {
    webSocket.current = new WebSocket("ws://localhost:9000");
    webSocket.current.onmessage = message => {
      const data = JSON.parse(message.data);
      setSocketMessages(prev => [...prev, data]);
    };
    webSocket.current.onclose = () => {
      webSocket.current.close();
    };
    return () => webSocket.current.close();
  }, []);
  useEffect(() => {
    let data = socketMessages.pop();
    if (data) {
      switch (data.type) {
        case "connect":
          setSocketOpen(true);
          break;
        case "login":
          onLogin(data);
          break;
        case "updateUsers":
          updateUsersList(data);
          break;
        case "removeUser":
          removeUser(data);
          break;
        case "offer":
          onOffer(data);
          break;
        case "answer":
          onAnswer(data);
          break;
        case "candidate":
          onCandidate(data);
          break;
        default:
          break;
      }
    }
  }, [socketMessages]);
  const closeAlert = () => {
    setAlert(null);
  };
  const send = data => {
    webSocket.current.send(JSON.stringify(data));
  };
  const handleLogin = () => {
    setLoggingIn(true);
    send({
      type: "login",
      name
    });
  };
  const updateUsersList = ({ user }) => {
    setUsers(prev => [...prev, user]);
  };
  const removeUser = ({ user }) => {
    setUsers(prev => prev.filter(u => u.userName !== user.userName));
  }
  const handleDataChannelMessageReceived = ({ data }) => {
    const message = JSON.parse(data);
    const { name: user } = message;
    let messages = messagesRef.current;
    let userMessages = messages[user];
    if (userMessages) {
      userMessages = [...userMessages, message];
      let newMessages = Object.assign({}, messages, { [user]: userMessages });
      messagesRef.current = newMessages;
      setMessages(newMessages);
    } else {
      let newMessages = Object.assign({}, messages, { [user]: [message] });
      messagesRef.current = newMessages;
      setMessages(newMessages);
    }
  };
  const onLogin = ({ success, message, users: loggedIn }) => {
    setLoggingIn(false);
    if (success) {
      setAlert(
        <SweetAlert
          success
          title="Success!"
          onConfirm={closeAlert}
          onCancel={closeAlert}
        >
          Logged in successfully!
        </SweetAlert>
      );
      setIsLoggedIn(true);
      setUsers(loggedIn);
      let localConnection = new RTCPeerConnection(configuration);
      //when the browser finds an ice candidate we send it to another peer
      localConnection.onicecandidate = ({ candidate }) => {
        let connectedTo = connectedRef.current;
        if (candidate && !!connectedTo) {
          send({
            name: connectedTo,
            type: "candidate",
            candidate
          });
        }
      };
      localConnection.ondatachannel = event => {
        let receiveChannel = event.channel;
        receiveChannel.onopen = () => {
          console.log("Data channel is open and ready to be used.");
        };
        receiveChannel.onmessage = handleDataChannelMessageReceived;
        updateChannel(receiveChannel);
      };
      updateConnection(localConnection);
    } else {
      setAlert(
        <SweetAlert
          warning
          confirmBtnBsStyle="danger"
          title="Failed"
          onConfirm={closeAlert}
          onCancel={closeAlert}
        >
          {message}
        </SweetAlert>
      );
    }
  };
  //when somebody wants to message us
  const onOffer = ({ offer, name }) => {
    setConnectedTo(name);
    connectedRef.current = name;
    connection
      .setRemoteDescription(new RTCSessionDescription(offer))
      .then(() => connection.createAnswer())
      .then(answer => connection.setLocalDescription(answer))
      .then(() =>
        send({ type: "answer", answer: connection.localDescription, name })
      )
      .catch(e => {
        console.log({ e });
        setAlert(
          <SweetAlert
            warning
            confirmBtnBsStyle="danger"
            title="Failed"
            onConfirm={closeAlert}
            onCancel={closeAlert}
          >
            An error has occurred.
          </SweetAlert>
        );
      });
  };
  //when another user answers to our offer
  const onAnswer = ({ answer }) => {
    connection.setRemoteDescription(new RTCSessionDescription(answer));
  };
  //when we got ice candidate from another user
  const onCandidate = ({ candidate }) => {
    connection.addIceCandidate(new RTCIceCandidate(candidate));
  };
  //when a user clicks the send message button
  const sendMsg = () => {
    const time = format(new Date(), "yyyy-MM-dd'T'HH:mm:ss.SSSxxx");
    let text = { time, message, name };
    let messages = messagesRef.current;
    let connectedTo = connectedRef.current;
    let userMessages = messages[connectedTo];
    if (messages[connectedTo]) {
      userMessages = [...userMessages, text];
      let newMessages = Object.assign({}, messages, {
        [connectedTo]: userMessages
      });
      messagesRef.current = newMessages;
      setMessages(newMessages);
    } else {
      userMessages = Object.assign({}, messages, { [connectedTo]: [text] });
      messagesRef.current = userMessages;
      setMessages(userMessages);
    }
    channel.send(JSON.stringify(text));
    setMessage("");
  };
  const handleConnection = name => {
    let dataChannel = connection.createDataChannel("messenger");
    dataChannel.onerror = error => {
      setAlert(
        <SweetAlert
          warning
          confirmBtnBsStyle="danger"
          title="Failed"
          onConfirm={closeAlert}
          onCancel={closeAlert}
        >
          An error has occurred.
        </SweetAlert>
      );
    };
    dataChannel.onmessage = handleDataChannelMessageReceived;
    updateChannel(dataChannel);
    connection
      .createOffer()
      .then(offer => connection.setLocalDescription(offer))
      .then(() =>
        send({ type: "offer", offer: connection.localDescription, name })
      )
      .catch(e =>
        setAlert(
          <SweetAlert
            warning
            confirmBtnBsStyle="danger"
            title="Failed"
            onConfirm={closeAlert}
            onCancel={closeAlert}
          >
            An error has occurred.
          </SweetAlert>
        )
      );
  };
  const toggleConnection = userName => {
    if (connectedRef.current === userName) {
      setConnecting(true);
      setConnectedTo("");
      connectedRef.current = "";
      setConnecting(false);
    } else {
      setConnecting(true);
      setConnectedTo(userName);
      connectedRef.current = userName;
      handleConnection(userName);
      setConnecting(false);
    }
  };
  return (
    <div className="App">
      {alert}
      <Header as="h2" icon>
        <Icon name="users" />
        Simple WebRTC Chap App
      </Header>
      {(socketOpen && (
        <Fragment>
          <Grid centered columns={4}>
            <Grid.Column>
              {(!isLoggedIn && (
                <Input
                  fluid
                  disabled={loggingIn}
                  type="text"
                  onChange={e => setName(e.target.value)}
                  placeholder="Username..."
                  action
                >
                  <input />
                  <Button
                    color="teal"
                    disabled={!name || loggingIn}
                    onClick={handleLogin}
                  >
                    <Icon name="sign-in" />
                    Login
                  </Button>
                </Input>
              )) || (
                <Segment raised textAlign="center" color="olive">
                  Logged In as: {name}
                </Segment>
              )}
            </Grid.Column>
          </Grid>
          <Grid>
            <UsersList
              users={users}
              toggleConnection={toggleConnection}
              connectedTo={connectedTo}
              connection={connecting}
            />
            <MessageBox
              messages={messages}
              connectedTo={connectedTo}
              message={message}
              setMessage={setMessage}
              sendMsg={sendMsg}
              name={name}
            />
          </Grid>
        </Fragment>
      )) || (
        <Loader size="massive" active inline="centered">
          Loading
        </Loader>
      )}
    </div>
  );
};
export default Chat;
Enter fullscreen mode Exit fullscreen mode

Our completed chat application should like this:

An example of a completed simple WebRTC chat app before being connected to other users.
Not connected to another user

Using a simple WebRTC chat app while being connected to and chatting with another user
Connected and chatting with another user

That’s it! We have build a WebRTC chat app from scratch. If you want to test out this implementation, you can check out the demo. Please note that the demo might not work on remote peers.

To get that working, you need to add a TURN server. You can open two tabs on your device and connect and you should be able to see the app in action.

Conclusion

The code for the signaling server and the chat app can be found on GitHub. This article is by no means exhaustive and we just touched on the basics of WebRTC.

You can improve on it by adding handling RTCPeerConnection close as well data channel closure. In addition, a multi-user room would be a great next step.

WebRTC is still in development and changes quite often. It is important to keep updated on changes and modify your app accordingly.

Browser compatibility is also a significant issue.

You can use the adapter to ensure your app works across different browsers. I hope you enjoyed the article and feel free to let me know your thoughts in the comments.


Plug: LogRocket, a DVR for web apps

 
LogRocket Dashboard Free Trial Banner
 
LogRocket is a frontend logging tool that lets you replay problems as if they happened in your own browser. Instead of guessing why errors happen, or asking users for screenshots and log dumps, LogRocket lets you replay the session to quickly understand what went wrong. It works perfectly with any app, regardless of framework, and has plugins to log additional context from Redux, Vuex, and @ngrx/store.
 
In addition to logging Redux actions and state, LogRocket records console logs, JavaScript errors, stacktraces, network requests/responses with headers + bodies, browser metadata, and custom logs. It also instruments the DOM to record the HTML and CSS on the page, recreating pixel-perfect videos of even the most complex single-page apps.
 
Try it for free.


The post Get a basic chat application working with WebRTC appeared first on LogRocket Blog.

Top comments (0)