WittCode💻

Code a Video Streaming App with Node

By

Learn how to code a video streaming app with Node. We will also go over the HTTP 206 status code, what streams are, and the HTTP headers involved in streaming.

Table of Contents 📖

Project Demonstration

The GIF below is what we are building. Notice how when the page loads the video is already almost finished. This is because our server is already streaming the video file and lets the client know what time the video is currently at. Also notice how when the video ends the text above changes from "We are Live!" to "Live Stream Ended".

Image

What is a Stream?

A stream is a collection of data where the data is available chunk by chunk, or piece of data by piece of data. This makes streams handy for working with large amounts of data as all the data isn't loaded into memory at once. For example, instead of sending a whole video file to a client from a server, we can create a stream to the video and send down chunks of it. Video files are often several GBs in size, so sending a whole video file at once would spike a server's memory and slow it down. Streams allow the video file to be sent in chunks, saving the server's performance.

Project Setup

Now lets set up our project. First lets initialize this as an ES6 npm project by running the command npm init es6 -y.

npm init es6 -y

We will also install express to handle user requests.

npm i express

Next, lets install nodemon as a development dependency. We will use this package to update our code.

npm i nodemon -D

Creating the HTML File

Before we create our server, lets create the HTML page that displays our video file.

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Document</title>
</head>
<body style="background-color: #131313;">
  <h1 id="title" style="color: red;">WE ARE LIVE!</h1>
  <!-- To autoplay, the video needs to be muted-->
  <video id="video" width="320" height="240" controls autoplay muted>
    <source src="video" type="video/mp4">
    Your browser does not support the video tag.
  </video>

  <script>
    const video = document.getElementById('video');
    const title = document.getElementById('title');

    video.addEventListener('ended', () => {
      title.innerText = 'Live Stream Ended';
      video.src = '';
    })
    
    fetch('/currentTime').then(res => res.text()).then(currentTime => {
      if (isNaN(currentTime)) {
        title.innerText = 'Live Stream Ended';
        video.src = '';
      } else {
        video.currentTime = currentTime;
      }
    });
  </ script>
</body>
</html>
  • Create an h1 element to display if the live stream is going on or if it has ended.
  • Create a video element to display the video. The video data is returned from the /video route on the server.
  • Add a listener to the video element to change the title of the h1 element if the stream has ended.
  • Make a fetch request to get the current time of the video. If the video has ended, we will set the title to "Live Stream Ended". Otherwise we will set the video time to the current time of the video. When the stream ends, we set the src attribute of the video element to an empty string. This is so the video is no longer playing.

Setting Up Express Server

Now lets create a server with Express to serve up our video. We will place all of our Express logic inside a class called ExpressService.

import express from 'express';
import path from 'path';

export default class ExpressService extends VideoService {
  #app;
  static PORT = process.env.PORT;
  static NODE_ENV = process.env.NODE_ENV;

  /**
   * @constructor
   * When the app starts, we need to begin streaming the video
   */
  constructor() {
    super();
    this.#app = express();
  }

  #initialize() {
    this.#app.use(express.static(path.resolve('public')));
  }

  #addLoggingMiddleware() {
    this.#app.use((req, res, next) => {
      console.log(`${req.method} ${req.path} ${req.headers.range}`);
      return next();
    });
  }
}
  • Import the express and path modules.
  • Define a class called ExpressService that extends a class called VideoService. The VideoService class will be used to work with our video logic. We will code this class later.
  • Create a private variable called #app. This will be the Express app itself.
  • Define two properties called PORT and NODE_ENV. Their values will be environment variables defined inside the package.json start script.
  • Create an express app in the constructor and call the superclass constructor.
  • Create a private method called #initialize to serve up static content from the public folder. This is done with the express.static middleware. The static content will include the index.html file and MP4 video.
  • Create a private method called addLoggingMiddleware to add some logging. For each request we will log out the request method, path, and range header.

Range Header

In our logging middleware method, we log out the request range header. This header is important when streaming. The Range HTTP header indicates the part of the data that the server should return. It has the following format.

Range: <unit>=<range-start>-<range-end>

Here, unit is the unit in which the range is specified (which is usually in bytes). Range-start is an integer that indicates the beginning of the request range. is an integer that indicates the end of the requested range. For example, the code below specifies that we only want the first 200 bytes of the document.

Range: bytes=0-199

Note that the numbers in the Range header are inclusive, so bytes 0 and 199 are included. Therefore, the total number would be 200 in this range. It should be noted that the range header has a few more formats such as the one below.

Range: <unit>=<range-start>-
Range: bytes=0-

Here the range-end is omitted. This means that the end of the document is taken as the end of the range. This is the header that our HTTP video element will be including in its request. Internally it will be the browser that requests the range it needs. We will parse this header and return the requested range of bytes from our video.

Handle the Video Request

Now lets handle the video request. We will do this in a method called addVideoRoute. This method will handle requests coming from the HTML video element. Specifically, it will return the desired range of data to the client.

#addVideoRoute() {
  this.#app.get('/video', async (req, res) => {
    const range = req.headers.range;
    const {start, end, contentLength} = this.calculateVideoInfo(range);
    const headers = {
      'Content-Range': `bytes ${start}-${end}/${VideoService.VIDEO.size}`,
      'Accept-Ranges': 'bytes',
      'Content-Length': contentLength,
      'Content-Type': 'video/mp4',
    };
    res.writeHead(206, headers);
    const videoStream = fs.createReadStream(VideoService.VIDEO.path, {start, end});
    videoStream.pipe(res);
  });
}
  • Create a route middleware to handle GET requests to /video.
  • Use a method called calculateVideoInfo to obtain the desired range of bytes in the video file from the HTTP range header. We will create this method inside our VideoService class.
  • Create a headers object with the Content-Range, Accept-Ranges, Content-Length, and Content-Type headers. These headers will be used by the client to render the video appropriately.
  • Add an HTTP status code of 206 (partial content) and headers to the response.
  • Create a video stream from the video file. This allows us to send chunks of the video file. We use the start and end properties to read a range from the file and not the entire file.
  • Pipe the read stream to the response, a write stream to the client. The pipe method is used to pass data from a readable stream to a writeable stream.

HTTP 206 Status Code

The HTTP 206 response code is used to indicate that the request was successful and that the response body contains the requested chunk of data. This chunk of data is known as partial content. Partial content is a chunk of an available resource stored on a server. Sending partial content is often used to avoid the overhead of fetching unused resources, as such, it is often used in video streaming. For example, it isn't uncommon for users to watch only part of a video, so why send all the data? Instead, send separate chunks over time and stop if the user leaves.

Headers Required for Partial Content

In our code above we set quite a few HTTP response headers. This is because certain HTTP headers are required to send partial content. One of these headers is Content-Type. This header indicates the media type of the resource being provided. In our code we specify the content as video/mp4.

'Content-Type': 'video/mp4'

Another HTTP header for sending partial content is the Accept-Ranges header. The Accept-Ranges header is a response header used by the server to let the client know it supports partial requests for file downloads. It has the following syntax.

Accept-Ranges: <range-unit>

The supplied range unit value defines the range unit that the server supports. In this code we are using bytes as the range unit.

'Accept-Ranges': 'bytes'

Another HTTP header for sending partial content is the Content-Range header. The Content-Range header is a response HTTP header that indicates where in a full body message a partial message belongs. It has the following syntax.

Content-Range: <unit> <range-start>-<range-end>/<size>

Here, unit is the unit the ranges are specified in (usually bytes). is an integer indicating the start position of the request range. is an integer that indicates the end position of the requested range. is the total length of the document. In our code, we will calculate the start and end of the requested range.

'Content-Range': `bytes ${start}-${end}/${VideoService.VIDEO.size}`

Getting the Current Video Time

Now lets focus on getting the current video time. This is the time in seconds that the video is currently being streamed at live. This will be done inside a method called addVideoTimeRoute.

#addVideoTimeRoute() {
  this.#app.get('/currentTime', (req, res) => {
    return res.send(this.currentTime.toString());
  });
}

This code simply returns the current video time. Note that the currentTime property will be defined inside the VideoService class.

Bootstrapping the Server

Now lets create a method to add all the middleware to the Express server and then start it. We will call this method start.

start() {
  this.#initialize();
  this.#addLoggingMiddleware();
  this.#addVideoRoute();
  this.#addVideoTimeRoute();
  this.#app.listen(ExpressService.PORT, () => {
    console.log(`Server is listening on port ${ExpressService.PORT}`);
  });
}
  • Add the static global middleware.
  • Add the logging middleware.
  • Add the route to handle the video.
  • Add the route to get the current video time.
  • Start the server on the provided port.

Creating a Video Entity

Before we create the VideoService class, lets create an entity to represent the video we will be streaming. We will call it StackOverflowVideo.js as the video is a YouTube short about the stack overflow error.

export default {
  path: './public/stack-overflow-SHORT.mp4',
  // seconds
  duration: 58,
  // bytes
  size: 23651806
};

This entity simply contains the path to the video file, the duration of the video in seconds, and the size of the video in bytes.

Creating the Video Service

Now lets create the VideoService class to work with our video.

import StackOverflowVideo from '../entities/StackOverflowVideo.js';

export default class VideoService {
  static CHUNK_SIZE = 1024 * 1024; // 1 MB
  static VIDEO = StackOverflowVideo;

  currentTime;

  constructor() {
    this.currentTime = 0;
    this.#incrementTime();
  }

  /**
   * The video has started playing, increment it
   */
  #incrementTime() {
    const interval = setInterval(() => {
      console.log('Current time: ' + this.currentTime);
      this.currentTime += 1;
      if (this.currentTime >= VideoService.VIDEO.duration) {
        console.log('Video ended');
        clearInterval(interval);
        this.currentTime = 'Video ended!';
      }
    }, 1000);
  }

  calculateVideoInfo(range) {
    const start = Number(range.match(/\d+/)[0]);
    const end = Math.min(start + VideoService.CHUNK_SIZE, VideoService.VIDEO.size - 1);
    const contentLength = end - start + 1;
    return {start, end, contentLength};
  }
}
  • Create two static variables. One is the size of the chunk of data we want to return to the client. The other is the video entity.
  • Next we have an instance variable for current time. This is the current time of the video that is being streamed.
  • Create the constructor. When an instance of this class is created we will set the currentTime to 0 and start incrementing it.
  • Create a method to increment the current time of the video. This will increase the current time by 1 every second until the video duration has been reached.
  • Create a method called calculateVideoInfo that will use the HTTP range header to obtain the desired range of bytes in the video file. First we extract the first number that appears in the range header. We then take either the start with the chunk size added to it or the end of the video file if the end is greater than the video file size. We then calculate the length of bytes that will be sent to the client.

Running the Application

Now all we need to do is run the application. To do this, we just instantiate an instance of the ExpressService and call start.

import ExpressService from './services/ExpressService.js';

const expressService = new ExpressService();
expressService.start();

Now lets create a start script inside package.json to run this file.

"scripts": {
  "start": "PORT=5001 NODE_ENV=development nodemon ."
},

The . in this script will be replaced with what the main property is. In this case it is our server.js file.

"main": "./src/server.js",

Then all we need to do is run the script with npm start.

npm start
Server is listening on port 5001
Current time: 0
Current time: 1
Current time: 2
...
Current time: 57
Video ended

Note how as soon as we start the server the current time of the video is being incremented.

Code a Video Streaming App with Node