Share a File over a WebRTC Data Channel with Twilio Video

September 28, 2021
Written by
Mia Adjei
Twilion
Reviewed by

Share a File over a WebRTC Data Channel with Twilio Video

When you're taking part in a video conversation, sometimes you have files you want to share with the other people on the call so that everyone can take a look. This is a great way to collaborate with your friends or colleagues when you're not in the same room or looking at the same screen.

In this tutorial, you'll build a video application with JavaScript and learn how to use the Twilio Video DataTrack API to share a file over WebRTC with the other participants on the call.

Follow along below step by step, or if you're interested in skipping ahead to take a look at the code, visit the project repository on GitHub here.

Let's get started!

Prerequisites

  • A free Twilio account. (If you register here, you'll receive $10 in Twilio credit when you upgrade to a paid account!)
  • Node.js v14+ and npm installed on your machine.
  • ngrok

Create the project directory and install dependencies

From your terminal or command prompt, navigate to where you would like to set up your project. Create a new directory called data-track-file-share and change into that directory by running the following commands in your terminal:

mkdir data-track-file-share
cd data-track-file-share

Next, set up a new Node.js project with a default package.json file by running the following command:

npm init -y

Once you have your package.json file, you're ready to install the needed dependencies.


For this project, you will need the following packages:

Run the following command in your terminal to install these packages:

npm install express twilio dotenv node-dev

If you check your package.json file now, you'll notice that the packages above have been installed as dependencies.

Save your Twilio credentials safely as environment variables

Create a new file named .env at the root of your project and open it in your code editor. The .env file is where you will keep your Twilio account credentials. Add the following variables to your new file:

TWILIO_ACCOUNT_SID=XXXXXXXXXXXXXXXXXXXXXXXXXXXXXX
TWILIO_API_KEY_SID=XXXXXXXXXXXXXXXXXXXXXXXXXXXXXX
TWILIO_API_KEY_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_SID and TWILIO_API_KEY_SECRET.

It’s important to keep these private credentials secure and out of version control. If you’re using GitHub, create a .gitignore file at the root of your project. Here you can list the files and directories that you want git to ignore from being tracked or committed. Open .gitignore in your code editor and add the .env file and the node_modules directory:

.env
node_modules

Now that you have your Twilio credentials set up, it's time to create your Express server.

Create a new Express server

Create a new file called server.js at the root of your project. This is the place where you will write your server-side code.

Open up server.js in your code editor. Add the following lines of code to server.js, which will load the environment variables from your .env file, create a new Express application, and set the application to run on port 5000:

require('dotenv').config();
const express = require('express');
const app = express();
const port = 5000;

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

Now, open package.json in your code editor. Inside the scripts section, add a start script as shown below. You can remove the placeholder test script that was automatically generated by npm init -y earlier. Then, in the main section, change the entry file listed there to server.js. When you are finished, your package.json file should look like the code below:

{
  "name": "data-track-file-share",
  "version": "1.0.0",
  "description": "",
  "main": "server.js",
  "scripts": {
    "start": "node-dev server.js"
  },
  "keywords": [],
  "author": "",
  "license": "ISC",
  "dependencies": {
    "dotenv": "10.0.0",
    "express": "4.17.1",
    "node-dev": "7.0.0",
    "twilio": "3.67.1"
  }
}

Now, to run the start script, return to your terminal window and run the following command:

npm start

Once you have done this, you should see the following log statement in your terminal window, letting you know that the Express server is running:

Express server running on port 5000

Now that your server is running, it's time to lay out the client side of your application.

Build the application layout and serve static files

Create a new directory called public at the root of your project. This directory will hold your static files:

mkdir public

Inside the public directory, create 3 new files: app.js, index.html, and styles.css.

Open public/index.html in your code editor and add the following code to the file:

<!DOCTYPE html>
<html>
  <head>
    <meta charset='utf-8'>
    <meta http-equiv='X-UA-Compatible'>
    <meta name='viewport' content='width=device-width, initial-scale=1'>
    <title>File Sharing Demo</title>
    <link rel='stylesheet' type='text/css' href='styles.css'/>
    <script defer src='https://sdk.twilio.com/js/video/releases/2.17.1/twilio-video.min.js'></script>
    <script defer src='app.js' type='text/javascript'></script>
  </head>
  <body>
    <section id='gallery'></section>

    <section id='controls'>
      <div>
        <input type='text' id='identity' placeholder='Enter your name' required>
        <button id='button-join'>Join Room</button>
        <button id='button-leave' disabled>Leave Room</button>
      </div>

      <div>
        <input type='file' id='file-input'>
        <button id='file-share' disabled>Share File</button>
      </div>
    </section>

    <section id='files'></section>
  </body>
</html>

The HTML code above creates the layout for your video chat application. In the <head>, you have a link to the CSS styles, as well as two <script> tags: one for the latest version of the Twilio Video library, and one for the JavaScript code you'll write later in this tutorial.

In the <body> of your HTML, there are three <section> elements:

  • gallery, which will include all participants' audio and video
  • controls, which contains the controls for a user to join or leave the video room, as well as the controls for selecting and sharing files
  • files, where the shared files will appear for participants to download

Now that you have the HTML, open public/styles.css. Copy the CSS from this repository and paste it into your file.

Now that you have some public files, you need to set up your server to serve these files when a user navigates to http://localhost:5000/ in their browser.

Return to server.js in your code editor. Add the following code to the file just below your constant definitions:

require('dotenv').config();
const express = require('express');
const app = express();
const port = 5000;

app.use(express.json());

// Serve static files from the public directory
app.use(express.static('public'));

app.get('/', (req, res) => {
  res.sendFile('public/index.html');
});

This code will add some Express middleware to your application, allowing it to parse JSON and to serve the static files you just created.

Now that you have a layout set up, the next step is to add JavaScript on the client side to show the user a preview of their video feed.

Display a video preview for the local participant

Open public/app.js in your code editor. Add the following lines of code to set up the variables you will need for this part of the project:

// sections
const video = document.getElementById('video');
const gallery = document.getElementById('gallery');
const files = document.getElementById('files');

// inputs
const identityInput = document.getElementById('identity');
const fileInput = document.getElementById('file-input');

// buttons
const joinRoomButton = document.getElementById('button-join');
const leaveRoomButton = document.getElementById('button-leave');
const shareFileButton = document.getElementById('file-share');

// local data track
const localDataTrack = new Twilio.Video.LocalDataTrack();

// other variables
const ROOM_NAME = 'my-video-room';
let videoRoom;

The first sets of variables refer to the HTML sections, inputs, and buttons in your app. After that, you have a variable that creates a new Twilio Video LocalDataTrack, which will be used later on to share files among participants. Following that are the variables for the room name — in this case, my-video-room — and a variable for the actual Twilio videoRoom that the participant will join.

To learn more about how data tracks work, you can take a look at the DataTrack API documentation here.

Next, paste the following code just below your list of variables. This is the function that will add a user's local video feed to the application:

const addLocalVideo = async () =>  {
  const videoTrack = await Twilio.Video.createLocalVideoTrack();
  const localVideoDiv = document.createElement('div');
  localVideoDiv.setAttribute('id', 'localParticipant');

  const trackElement = videoTrack.attach();
  localVideoDiv.appendChild(trackElement);

  gallery.appendChild(localVideoDiv);
};

In the addLocalVideo() function, you create a LocalVideoTrack, then attach it to a <div> and attach that <div> to the gallery section of your UI. This is where the preview video will appear.

Just below where the addLocalVideo() function is declared, call the function:

// Show the participant a preview of their video
addLocalVideo();

You can see your video preview if you navigate to http://localhost:5000/ in your browser:

Video application with a preview of the local user&#x27;s video feed.

You may see an alert dialog asking if localhost:5000 can use your camera. Once you allow this permission, you will be able to see your video feed.

Generate an Access Token for joining video rooms

Now that you can see your own video preview, it's time to get ready to join an actual video call. In order to do this, you will add code to your Express server to generate a short-lived credential called an Access Token.

Open server.js in your code editor and add the following imports to the list at the top of the file:

require('dotenv').config();
const express = require('express');
const app = express();
const port = 5000;
const AccessToken = require('twilio').jwt.AccessToken;
const VideoGrant = AccessToken.VideoGrant;

Then, add the following /token route just below your route for serving static files but before the app.listen() line:

app.post('/token', async (req, res) => {
  if (!req.body.identity || !req.body.room) {
    return res.status(400);
  }

  // Get the user's identity and the room name from the request
  const identity  = req.body.identity;
  const roomName  = req.body.room;

  try {
    // Create a video grant for this specific room
    const videoGrant = new VideoGrant({
      room: roomName,
    });

    // Create an access token
    const token = new AccessToken(
      process.env.TWILIO_ACCOUNT_SID,
      process.env.TWILIO_API_KEY_SID,
      process.env.TWILIO_API_KEY_SECRET,
    );

    // Add the video grant and the user's identity to the token
    token.addGrant(videoGrant);
    token.identity = identity;

    // Serialize the token to a JWT and return it to the client side
    return res.send({
      token: token.toJwt()
    });

  } catch (error) {
    return res.status(400).send({error});
  }
});

How will your application use this /token endpoint?

When a participant navigates to your application, enters their username in the input field, then clicks the Join Room button, their name and the name of the video room will be passed to this endpoint via a POST request.

The endpoint will then use your Twilio credentials and the Twilio Node Helper Library to create an access token specifically for this participant and this video room. Then, the endpoint will return the access token to the client side of your application. Your JavaScript code will then use this access token to connect to the video room.

Join the video room as a local participant

Now that you have the code for generating Access Tokens, it's time to write the code that will actually create the video room and let you join as a participant.

Return to public/app.js in your code editor. Just below your addLocalVideo() function definition and above its invocation, add the following new function called joinRoom():

const joinRoom = async (event) => {
  event.preventDefault();

  const identity = identityInput.value;

  try {
    const response = await fetch('/token', {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
      },
      body: JSON.stringify({
        'identity': identity,
        'room': ROOM_NAME
      })
    });

    const data = await response.json();

    // Create the audio and video tracks
    const localTracks = await Twilio.Video.createLocalTracks();

    // Include the data track
    const tracks = [...localTracks, localDataTrack];

    videoRoom = await Twilio.Video.connect(data.token, {
      name: ROOM_NAME,
      tracks: tracks
    });

    console.log(`You are now connected to Room ${videoRoom.name}`);

    const localParticipant = document.getElementById('localParticipant');
    const identityDiv = document.createElement('div');
    identityDiv.setAttribute('class', 'identity');
    identityDiv.innerHTML = identity;
    localParticipant.appendChild(identityDiv);

    videoRoom.participants.forEach(participantConnected);
    videoRoom.on('participantConnected', participantConnected);
    videoRoom.on('participantDisconnected', participantDisconnected);

    joinRoomButton.disabled = true;
    leaveRoomButton.disabled = false;
    shareFileButton.disabled = false;
    identityInput.disabled = true;
  } catch (error) {
    console.log(error);
  }
}

This function will handle when a user clicks the Join Room button. It will get the user's identity from what they have entered in the <input> field, then make a POST request to the /token endpoint you added to your server in the previous section.

Once your application receives the access token from the server side, it takes the user's local tracks for audio, video, and data, then connects to the video room using the token and these tracks. A new <div> is created with the participant's name (identity) and their media tracks. The function then sets up event listeners to listen for when other participants join the video call.

Now that the joinRoom() function is complete, it's time to create the function that will let users leave the video call. This function will disconnect a participant from the video room and remove all of the other participants' media from the UI.

Add the following leaveRoom() function just below the one for joinRoom():

const leaveRoom = (event) => {
  event.preventDefault();
  videoRoom.disconnect();
  console.log(`You are now disconnected from Room ${videoRoom.name}`);

  let removeParticipants = gallery.getElementsByClassName('participant');

  while (removeParticipants[0]) {
    gallery.removeChild(removeParticipants[0]);
  }

  localParticipant.removeChild(localParticipant.lastElementChild);

  joinRoomButton.disabled = false;
  leaveRoomButton.disabled = true;
  shareFileButton.disabled = true;
  identityInput.disabled = false;
}

Now, at the bottom of public/app.js, add click event listeners to the joinRoomButton and leaveRoomButton that will call joinRoom() and leaveRoom() respectively when users click on these buttons:

// Show the participant a preview of their video
addLocalVideo();

// Event listeners
joinRoomButton.addEventListener('click', joinRoom);
leaveRoomButton.addEventListener('click', leaveRoom);

Now that you have a way for the local participant to join and leave the video room, it's time to add some code to handle the file sharing aspect of your application.

Send a file over the local data track

In this tutorial, you will send a small text file over the data channel to understand how it works. If you are interested in sending a heavier data load, you will likely want to upload the file to a cloud storage solution (such as an AWS bucket) and then send a link to the file so other participants can download it. Find out more information about DataTracks in the documentation here.

In order to send a file, participants in your video application will need to click the Choose File button in the UI. Once they have done this, the file selection window will appear, and they can select the file they would like to share with the rest of the participants on the call.

Let's add a function that will handle uploading this file and sending it over the data track. Add the following sendFile() function to public/app.js, just below the leaveRoom() function:

const sendFile = async () => {
  const selectedFile = fileInput.files[0];

  // Create an array buffer from the file
  let buffer  = await selectedFile.arrayBuffer();
  localDataTrack.send(buffer);

  // Notify the local participant that their file was sent
  let notification = document.createElement('span');
  notification.classList.add('notification');
  notification.innerText = 'File sent! 📄';
  files.appendChild(notification);

  setTimeout(() => {
    notification.remove();
  }, 3000)
}

This function takes the selected file and converts it to an ArrayBuffer, which is a representation of the file as an array of bytes that can be sent over the local data track. A notification will then appear in the files section to let the local participant know that the file was sent.

Now that you have the function for sending the file, add the following new event listener to the event listeners section at the bottom of public/app.js. This new listener will call the sendFile() function when a user clicks on the shareFileButton:

// Event listeners
joinRoomButton.addEventListener('click', joinRoom);
leaveRoomButton.addEventListener('click', leaveRoom);
shareFileButton.addEventListener('click', () => { sendFile()} );

Now that you have a way for the local participant to send a file over the data track, it's time to add code to handle connecting the other participants to the video room and setting them up to receive the shared files.

Connect remote participants to the video room

You may have noticed, in the joinRoom() function you added before, that when a participantConnected event occurs in the video room, the function participantConnected() gets called. A similar thing occurs for the participantDisconnected event. It's time to add these two functions to the code in public/app.js.

Add the following participantConnected() function to the file, just below the sendFile() function:

const participantConnected = (participant) => {
  console.log(`${participant.identity} has joined the call.`);

  const participantDiv = document.createElement('div');
  participantDiv.setAttribute('id', participant.sid);
  participantDiv.setAttribute('class', 'participant');

  const tracksDiv = document.createElement('div');
  participantDiv.appendChild(tracksDiv);

  const identityDiv = document.createElement('div');
  identityDiv.setAttribute('class', 'identity');
  identityDiv.innerHTML = participant.identity;
  participantDiv.appendChild(identityDiv);

  gallery.appendChild(participantDiv);

  participant.tracks.forEach(publication => {
    if (publication.isSubscribed) {
      tracksDiv.appendChild(publication.track.attach());
    }
  });

  participant.on('trackSubscribed', track => {
    // Attach the video and audio tracks to the DOM
    if (track.kind === 'video' || track.kind === 'audio') {
      tracksDiv.appendChild(track.attach());
    }

    // Set up a listener for the data track
    if (track.kind === 'data') {
      // When a message is received, create a new blob from the data and download it
      track.on('message', data => {
        try {
          const blob = new Blob([data]);
          const newFileDownload = document.createElement('div');
          newFileDownload.setAttribute('class', 'file');

          const a = document.createElement('a');
          const linkText = document.createTextNode('📄');
          a.appendChild(linkText);
          a.href = window.URL.createObjectURL(blob);
          a.download = 'file'
          newFileDownload.appendChild(a);

          files.appendChild(newFileDownload);

        } catch (error) {
          console.log('Error transferring file');
          console.log(error);
        }
      });
    }
  });

  participant.on('trackUnsubscribed', track => {
    // Remove audio and video elements from the DOM
    if (track.kind === 'audio' || track.kind === 'video') {
      track.detach().forEach(element => element.remove());
    }
  });
};

The participantConnected() function creates a new <div> for each remote participant when they connect to the room, displays their username (identity), and attaches their video and audio tracks to the <div>.

It also creates event listeners for when these remote participants publish or unpublish their tracks — if someone starts or stops sharing their media, your application can attach or detach these tracks from the UI as needed.

Additionally, this function sets up a listener for when a shared file is received over the data track. When another participant shares a file, the received array of bytes is converted to a Blob object. Then, the application creates a clickable link to this file object using the file emoji (📄) and adds it to the files section in the UI. Now the other participants in the video call will be able to download the file that their friend has uploaded.

Now that you have code for participantConnected(), add the following participantDisconnected() function just below that:

const participantDisconnected = (participant) => {
  console.log(`${participant.identity} has left the call.`);
  document.getElementById(participant.sid).remove();
};

This function logs a message to the console about which participant has left the call and removes their <div> from the UI.

Your video chat is fully functional now. Before you test it out, let's run the application on ngrok so that it is easy to test with a friend.

Set up an ngrok tunnel

In this project, you'll be using ngrok to connect the Express application running locally on your machine to a temporary public URL. To start a new ngrok tunnel, open up a new terminal window and run the following command:

ngrok http 5000

Once ngrok is running, you will see text similar to the below in your terminal window:

ngrok by @inconshreveable                                                                                                             (Ctrl+C to quit)

Version                       2.3.40
Region                        <YOUR_REGION>
Web Interface                 http://127.0.0.1:4040
Forwarding                    http://XXXXXXXXXXX.ngrok.io -> http://localhost:5000
Forwarding                    https://XXXXXXXXXXX.ngrok.io -> http://localhost:5000

Connections                   ttl     opn     rt1     rt5     p50     p90
                              0       0       0.00    0.00    0.00    0.00

Copy the https:// URL next to Forwarding. Any requests that are made to this ngrok URL will be forwarded to your local server. You now have a temporary link to your application that you can share with a friend or colleague!

It's time to test your application and try sending a file.

Send a text file in your video chat application

Create a new file on your machine called secretNote.txt. Open this file and add a short message. The message can be whatever you wish, but here is what I added:

The secret ingredients in the hot cocoa are cinnamon and cayenne pepper. 🌶️

Open the https:// ngrok forwarding URL in a browser window. Your video application will load and you will be able to see your video preview.

Enter your name in the input field and click the Join Room button to connect to the video room.

If you are testing this application on your own, open the same URL in a second browser window and join the call again with a different name. If you are testing this application with a friend, wait for your friend to join the video call. You will be able to see everyone's video feed on your screen.

Video applications side by side, with two rubber ducks on the call.

Once everyone has joined the call, click the Choose File button and select secretNote.txt.

File selection window open, showing secretNote.txt being selected.

Once the file has been selected, click the Share File button to send the file to the other participants on the call. A File sent notification will appear for the participant who shared the file:

"File sent" notification for the selected file, secretNote.txt.

The file will be sent via the data track to the other participants, and they will see a file emoji appear in the blue section at the bottom of the screen.

File emoji in blue "files" section at the bottom of the screen.

Click on the file image to download the file to your computer. If you open the file, you will be able to read the secret message!

The received file, open on screen with the secret message displayed.

What's next for sharing files in your video chat?

You've just built a cool video chat application with a file sharing feature. This could be the beginning of a very interesting and fun project. Perhaps you're interested in making your application even more fun by adding some emoji confetti to your video chat. Or perhaps the file sharing aspect of this project was more interesting to you, and you're interested in adding a shared notepad to your chat as well?

If you would like to see the code for this project in its entirety, check out the repository on GitHub here.

There are so many exciting things you can create with this. 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.