Creating a Simple File-Transfer WebRTC React Web Application

Written by
Victor Gonzalez

In this post, you will learn how to use the WebRTC API methods to transfer data peer-to-peer.

WebRTC is an open-source project that allows web applications to transfer data peer-to-peer. In this post, you will learn how to use the WebRTC API methods to transfer data peer-to-peer.

Communication between web applications is possible with WebRTC. You can use an API to create a peer-to-peer connection between two clients and share data. To achieve a connection between a local client and a remote client, you will use the JavaScript interface RTCPeerConnection, as you can see in the image below named Code Reference #1.

Create a connection using WebRTC

To create a connection between a local peer and a remote, you can use The RTCPeerConnection interface. It provides methods to connect, maintain, and monitor the connection and close the connection to a remote peer.


const peerConnection = new RTCPeerConnection(
  iceServers: [
     { urls: "stun:stun.l.google.com:19302" },
     { urls: "stun:stun.stunprotocol.org:3478" },
   ],
);

Code Reference #1

One of the main options to create a connection is the iceServers. The URL’s property specifies the URL of the server to be used for ICE negotiations. These STUN servers are used to discover between each peer its public IP and port, and then pass the public IP on to another peer. The TURN servers are used to relay traffic if the connection peer-to-peer fails

This post assumes that you have a WeRTC connection between clients and it will focus on the way to send data. A WebRTC will be called peerConnection. For this, WebRTC has the option to create a network channel between the clients when it has established the connection. According to the above, it should use the RTCDataChannel JavaScript. Code Reference #2 shows an example.

Create channel

You can create a network channel between the peers when the connection between the peers is established.


class Channel {
 static BYNARY_TYPE_CHANNEL = "arraybuffer";
}
 
const channelLabel = ‘Test Channel’;
this.#peerConnection = new RTCPeerConnection(config);
 
this.#channel = this.#peerConnection.createDataChannel(channelLabel);
this.#channel.binaryType = Channel.BYNARY_TYPE_CHANNEL;
 

Code Reference #2

According to Code Reference #2, each peerConnection will create a channel by calling the method createDataChannel and you will need to set the binaryType property as arraybuffer. On the other side, it must implement the ondatachannel event handler to process the data sent as you can see in the next Code Reference #3.


const onDataChannelCallback = (event) => {
  const { channel } = event;
 
  let receivedBuffer = [];
  let totalBytesFileBuffer = 0;
  let totalBytesArrayBuffers = 0;
 
  channel.onmessage = (event) => {
   const { data } = event;
 
   [...]
  };
};

Code Reference #3

The file transfer has a problem when it needs to send a big file because the browser has by default a maximum buffer of 256kB.

Transfer data by chunks

One solution for the bigger files is to send small messages through the channel. Therefore, you will use the method arrayBuffer to split the file into an array buffer and it will send a flag to say that it has sent the last file chunk. The file chunks will have a size of 64kB. It also will send the arrayBuffer byteLength through the channel (see code reference #4). Furthermore, it is important to know that all the chunks MUST arrive and in the right order to get the file correctly.


static BYNARY_TYPE_CHANNEL = "arraybuffer";
static MAXIMUM_SIZE_DATA_TO_SEND = 65535;
static BUFFER_THRESHOLD = 65535;
static LAST_DATA_OF_FILE = "LDOF7";

Code Reference #4


transferFile(fileToShare) {
   this.#channel.onopen = async () => {
     const arrayBuffer = await fileToShare.arrayBuffer();
 
     try {
       this.send(
         JSON.stringify({
           totalByte: arrayBuffer.byteLength,
           dataSize: Channel.MAXIMUM_SIZE_DATA_TO_SEND,
         })
       );
 
       for (
         let index = 0;
         index < arrayBuffer.byteLength;
         index += Channel.MAXIMUM_SIZE_DATA_TO_SEND
       ) {
         this.send(
           arrayBuffer.slice(index, index + Channel.MAXIMUM_SIZE_DATA_TO_SEND)
         );
       }
       this.send(Channel.LAST_DATA_OF_FILE);
     } catch (error) {
       console.error("error sending big file", error);
     }
   };
 
   return true;
 }

Code Reference #5

Furthermore, it is important to know that the chunks are sequentially sending and if it will overflow the outgoing channel buffer. So, you will add all chunks to a queue to pop and send each one when the channel buffer is below the threshold specified. To know if the channel buffer is below the threshold, you will use the bufferedamountlow event. In the code reference #6 below, you can see the queue handler and send handler.


 send(data) {
   this.#queue.push(data);
 
   if (this.#paused) {
     return;
   }
 
   this.shiftQueue();
 }
 
 shiftQueue() {
   this.#paused = false;
   let message = this.#queue.shift();
 
   while (message) {
     if (
       this.#channel.bufferedAmount &&
       this.#channel.bufferedAmount > Channel.BUFFER_THRESHOLD
     ) {
       this.#paused = true;
       this.#queue.unshift(message);
 
       const listener = () => {
         this.#channel.removeEventListener("bufferedamountlow", listener);
         this.shiftQueue();
       };
 
       this.#channel.addEventListener("bufferedamountlow", listener);
       return;
     }
 
     try {
       this.#channel.send(message);
       message = this.#queue.shift();
     } catch (error) {
       throw new Error(
         `Error to send the next data: ${error.name} ${error.message}`
       );
     }
   }
 }

Code Reference #6

The side where it will receive all file chunks will set an array to store the chunks or array buffers. The stop method to know if it has received all file chunks is when the channel gets a Channel.LAST_DATA_OF_FILE value-like message. The next step is to download the file and the final step is to close the channel (see Code Reference #7).


const onDataChannelCallback = (event) => {
 const { channel } = event;
 
 let receivedBuffer = [];
 let totalBytesFileBuffer = 0;
 let totalBytesArrayBuffers = 0;
 
 channel.onmessage = (event) => {
   const { data } = event;
 
   try {
     if (data.byteLength) {
       receivedBuffer.push(data);
       totalBytesArrayBuffers += data.byteLength;
 
       if (totalBytesFileBuffer > 0) {
         this.setState({
           progressTransferFile:
             (totalBytesArrayBuffers * 100) / totalBytesFileBuffer,
         });
       }
     } else if (data === Channel.LAST_DATA_OF_FILE) {
       getCompleteFile(
         receivedBuffer,
         totalBytesArrayBuffers,
         channel.label
       );
       channel.close();
 
       receivedBuffer = [];
       totalBytesFileBuffer = 0;
       totalBytesArrayBuffers = 0;
     } else {
       const initMessage = JSON.parse(data);
       totalBytesFileBuffer = initMessage.totalByte || 0;
     }
   } catch (err) {
     receivedBuffer = [];
     totalBytesFileBuffer = 0;
     totalBytesArrayBuffers = 0;
   }
 };
};

Code Reference #7

Finally, you will set all chunks into a new array buffer and put the new array buffer into a Blob after it downloads, as you can see in the Code Reference #8.


export const getCompleteFile = (
 receivedArrayBuffers,
 totalBytesArrayBuffers,
 fileName
) => {
 let offset = 0;
 const uintArrayBuffer = new Uint8Array(totalBytesArrayBuffers, 0);
 
 receivedArrayBuffers.forEach((arrayBuffer) => {
   uintArrayBuffer.set(
     new Uint8Array(arrayBuffer.buffer || arrayBuffer, arrayBuffer.byteOffset),
     offset
   );
   offset += arrayBuffer.byteLength;
 });
 
 const blobObject = new Blob([uintArrayBuffer]);
 
 return downloadFile(blobObject, fileName);
};

Code Reference #8

The application to test the above example is divided into two projects: UI and Server and I’ve created a small demo on YouTube to show you how it works.