Streaming HTML5 video through Node.js

nodejs

The server side to support streaming HTML5 video needs to be able to handle headers sent in from the browser. So unfortunately you can’t simply just read the video bytes and send everything back in the response.

The main thing to note here is that the browser may send in a Range HTTP header, which will specify what byte range from the video the browser is requesting. If the range is missing, we can send the whole video starting from byte 0. If it’s there, we’ll want to send only the range of bytes requested. The range header will be in this format:

Range: bytes=0-

…meaning it’s requesting the whole video (or at least it’s the initial request so the size of the video can be determined by the browser from the Content-Length header in the response).

Or:

Range: bytes=5000-10000

…meaning the browser is requesting the video starting from 5000 bytes to 10000 bytes (the user may have skipped ahead).

Also important to note the response headers sent back from the server. These should include:

Accept-Ranges: bytes
Content-Type: video/html
Content-Length: (length)
Content-Range: bytes (start)-(end)/(total)

The Accept-Ranges tells the browser that the server side supports HTML5 video streaming and can take byte ranges. Content-Length sends the total length of the file in bytes. And Content-Range sends the range of the content being returned, in bytes.

So in our Node.js API that handles the HTML5 video streaming requests, we need to be able to handle these headers, and ship the video file back accordingly in ranges (if so requested).

Here’s what the code would look like:

exports.stream = function(req, res) {
  var fileName = req.params.fileName ? req.params.fileName : null;
  if(!fileName)
    return res.status(404).send();

  fs.stat(fileName, function(err, stats) {
    if (err) {
      if (err.code === 'ENOENT') {
        return res.status(404).send();
      }
    }

    var start;
    var end;
    var total = 0;
    var contentRange = false;
    var contentLength = 0;

    var range = req.headers.range;
    if (range)
    {
      var positions = range.replace(/bytes=/, "").split("-");
      start = parseInt(positions[0], 10);
      total = stats.size;
      end = positions[1] ? parseInt(positions[1], 10) : total - 1;
      var chunksize = (end - start) + 1;
      contentRange = true;
      contentLength = chunksize;
    }
    else
    {
      start = 0;
      end = stats.size;
      contentLength = stats.size;
    }

    if(start<=end)
    {
      var responseCode = 200;
      var responseHeader =
      {
        "Accept-Ranges": "bytes",
        "Content-Length": contentLength,
        "Content-Type": "video/mp4"
      };
      if(contentRange)
      {
        responseCode = 206;
        responseHeader["Content-Range"] = "bytes " + start + "-" + end + "/" + total;
      }
      res.writeHead(responseCode, responseHeader);

      var stream = fs.createReadStream(file, { start: start, end: end })
        .on("readable", function() {
          var chunk;
          while (null !== (chunk = stream.read(1024))) {
            res.write(chunk);
          }
        }).on("error", function(err) {
          res.end(err);
        }).on("end", function(err) {
          res.end();
        });
    }
    else
    {
      return res.status(403).send();
    }
  });
};

Let’s analyze this code briefly. There is a function called stream() which is passed the request/response through Node.js. The function looks for a request parameter named fileName, though you can pass around the file identifier, or whatever you please, as long as you have a way to map it to a exact file path on disk.

First we look if the HTTP headers include the Range header. If they do, we can assume the browser requested only a certain range of bytes, so we proceed accordingly. Otherwise if the header is not present, we plan on shipping the whole file back. Node.js’s fs.createReadStream() allows you to create a read stream from a file with specifying what bytes to start and and how many to return (as directed by the browser’s request, or all of it). And then we ship back that stream to the browser.

Note: I did this a while back so I’m forgetting the exact reasons now, but the chunked return is important, otherwise the browser acts funny when playing the video. You may want to adjust the chunk size, but I find 1024 works nicely.

And that’s it!

Leave a Reply