Table of Contents
In this article, I would like to explain the basic concept of HTTP status 206 Partial Content
and a step-by-step implementation walkthrough with Node.js. Also, we will test the code with an example based on the most common scenario of its usage: an HTML5 page which is able to play video file starting at any second.
- Basic HTTP Knowledge
- Intermediate Node.js Skill
- Basic HTML5 Skill
- Basic JavaScript Skill
The HTTP 206 Partial Content
status code and its related headers provide a mechanism which allows browser and other user agents to receive partial content instead of entire one from server. This mechanism is widely used in streaming a video file and supported by most browsers and players such as Windows Media Player and VLC Player.
The basic workflow could be explained by these following steps:
- Browser requests the content.
- Server tells browser that the content can be requested partially with
Accept-Ranges
header. - Browser resends the request, tells server the expecting range with
Range
header. - Server responses browser in one of following situations:
- If range is available, server returns the partial content with status
206 Partial Content
. Range
of current content will be indicated in Content-Range
header. - If range is unavailable (for example, greater than total bytes of content), server returns status
416 Requested Range Not Satisfiable
. The available range will be indicated in Content-Range
header too.
Let's take a look at each key header of these steps.
Accept-Ranges: bytes
This is the header which is sent by server, represents the content that can be partially returned to browser. The value indicates the acceptable unit of each range request, usually is bytes in most situations.
Range: bytes=(start)-(end)
This is the header for browser telling server the expecting range of content. Note that start
and end
positions are both inclusive and zero-based. This header could be sent without one of them in the following meanings:
- If
end
position is omitted, server returns the content from indicated start position to the position of last available byte. - If
start
position is omitted, the end position will be described as how many bytes shall server returns counting from the last available byte.
Content-Range: bytes (start)-(end)/(total)
This is the header which shall appear following HTTP status 206. Values start
and end
represent the range of current content. Like Range
header, both values are inclusive and zero-based. Value total
indicates the total available bytes.
Content-Range: */(total)
This is same header but in another format and will only be sent following HTTP status 416
. Value total
also indicates the total available bytes of content.
Here are a couple examples of a file with 2048 bytes long. Note the different meaning of end
when start
is omitted.
Request First 1024 Bytes
What browser sends:
GET /dota2/techies.mp4 HTTP/1.1
Host: localhost:8000
Range: bytes=0-1023
What server returns:
HTTP/1.1 206 Partial Content
Date: Mon, 15 Sep 2014 22:19:34 GMT
Content-Type: video/mp4
Content-Range: bytes 0-1023/2048
Content-Length: 1024
(Content...)
Request Without End Position
What browser sends:
GET /dota2/techies.mp4 HTTP/1.1
Host: localhost:8000
Range: bytes=1024-
What server returns:
HTTP/1.1 206 Partial Content
Date: Mon, 15 Sep 2014 22:19:34 GMT
Content-Type: video/mp4
Content-Range: bytes 1024-2047/2048
Content-Length: 1024
(Content...)
Note that server does not have to return all remaining bytes in single response especially when content is too long or there are other performance considerations. So, the following two examples are also acceptable in this case:
Content-Range: bytes 1024-1535/2048
Content-Length: 512
Server only returns half of remaining content. The range of next request will start at 1536th byte.
Content-Range: bytes 1024-1279/2048
Content-Length: 256
Server only returns 256 bytes of remaining content. The range of next request will start at 1280th byte.
Request Last 512 Bytes
What browser sends:
GET /dota2/techies.mp4 HTTP/1.1
Host: localhost:8000
Range: bytes=-512
What server returns:
HTTP/1.1 206 Partial Content
Date: Mon, 15 Sep 2014 22:19:34 GMT
Content-Type: video/mp4
Content-Range: bytes 1536-2047/2048
Content-Length: 512
(Content...)
Request with Unavailable Range
What browser sends:
GET /dota2/techies.mp4 HTTP/1.1
Host: localhost:8000
Range: bytes=1024-4096
What server returns:
HTTP/1.1 416 Requested Range Not Satisfiable
Date: Mon, 15 Sep 2014 22:19:34 GMT
Content-Range: bytes */2048
With understanding of workflow and headers above, now we are able to implement the mechanism in Node.js.
Step 1 - Create a Simple HTTP Server
We will start from a basic HTTP server as the following example shows. This is pretty enough to handle most of requests from browsers. At first, we initialize each object we need, and indicate where the files are located at with initFolder. We also list couple of filename extensions and their corresponding MIME names to construct a dictionary, which is for generating Content-Type
header. In the callback function httpListener()
, we limit GET
as the only allowed HTTP method. Before we start to fulfill the request, server will return status 405 Method Not Allowed
if other methods appear and return status 404 Not Found
if file does not exist in initFolder
.
var http = require("http");
var fs = require("fs");
var path = require("path");
var url = require('url');
var initFolder = 'C:\\Users\\User\\Videos';
var mimeNames = {
'.css': 'text/css',
'.html': 'text/html',
'.js': 'application/javascript',
'.mp3': 'audio/mpeg',
'.mp4': 'video/mp4',
'.ogg': 'application/ogg',
'.ogv': 'video/ogg',
'.oga': 'audio/ogg',
'.txt': 'text/plain',
'.wav': 'audio/x-wav',
'.webm': 'video/webm'
};
http.createServer(httpListener).listen(8000);
function httpListener (request, response) {
if (request.method != 'GET') {
sendResponse(response, 405, {'Allow' : 'GET'}, null);
return null;
}
var filename =
initFolder + url.parse(request.url, true, true).pathname.split('/').join(path.sep);
var responseHeaders = {};
var stat = fs.statSync(filename);
if (!fs.existsSync(filename)) {
sendResponse(response, 404, null, null);
return null;
}
responseHeaders['Content-Type'] = getMimeNameFromExt(path.extname(filename));
responseHeaders['Content-Length'] = stat.size;
sendResponse(response, 200, responseHeaders, fs.createReadStream(filename));
}
function sendResponse(response, responseStatus, responseHeaders, readable) {
response.writeHead(responseStatus, responseHeaders);
if (readable == null)
response.end();
else
readable.on('open', function () {
readable.pipe(response);
});
return null;
}
function getMimeNameFromExt(ext) {
var result = mimeNames[ext.toLowerCase()];
if (result == null)
result = 'application/octet-stream';
return result;
}
Step 2 - Capture the Range Header by Using Regular Expression
With the basic HTTP server, now we can handle the Range
header as the following code shows. We split the header with regular expression to capture start
and end
string
s. Then use parseInt()
method to parse them to integers. If returned value is NaN
(not a number), the string
does not exist in header. The parameter totalLength
represents total bytes of current file. We will use it to calculate start
and end
positions.
function readRangeHeader(range, totalLength) {
if (range == null || range.length == 0)
return null;
var array = range.split(/bytes=([0-9]*)-([0-9]*)/);
var start = parseInt(array[1]);
var end = parseInt(array[2]);
var result = {
Start: isNaN(start) ? 0 : start,
End: isNaN(end) ? (totalLength - 1) : end
};
if (!isNaN(start) && isNaN(end)) {
result.Start = start;
result.End = totalLength - 1;
}
if (isNaN(start) && !isNaN(end)) {
result.Start = totalLength - end;
result.End = totalLength - 1;
}
return result;
}
Step 3 - Check If Range Can Be Satisfied
Back to the function httpListener()
, now we check if the range is available after the HTTP method gets approved. If browser does not send Range
header, the request will be directly treated as normal request. Server returns entire file and HTTP status is 200 OK
. Otherwise, we will see if start or end position is greater or equal to file length. If one of them is, the range can not be fulfilled. The status will be 416 Requested Range Not Satisfiable
and the Content-Range
will be sent.
var responseHeaders = {};
var stat = fs.statSync(filename);
var rangeRequest = readRangeHeader(request.headers['range'], stat.size);
if (rangeRequest == null) {
responseHeaders['Content-Type'] = getMimeNameFromExt(path.extname(filename));
responseHeaders['Content-Length'] = stat.size;
responseHeaders['Accept-Ranges'] = 'bytes';
sendResponse(response, 200, responseHeaders, fs.createReadStream(filename));
return null;
}
var start = rangeRequest.Start;
var end = rangeRequest.End;
if (start >= stat.size || end >= stat.size) {
responseHeaders['Content-Range'] = 'bytes */' + stat.size;
sendResponse(response, 416, responseHeaders, null);
return null;
}
Step 4 - Fulfill the Request
Finally the last puzzle piece comes. For the status 206 Partial Content
, we have another format of Content-Range
header including start
, end
and total bytes of current file. We also have Content-Length
header and the value is exactly equal to the difference between start
and end
. In the last statement, we call createReadStream()
and assign start
and end
values to the object of second parameter options
, which means the returned stream will be only readable from/to the positions.
responseHeaders['Content-Range'] = 'bytes ' + start + '-' + end + '/' + stat.size;
responseHeaders['Content-Length'] = start == end ? 0 : (end - start + 1);
responseHeaders['Content-Type'] = getMimeNameFromExt(path.extname(filename));
responseHeaders['Accept-Ranges'] = 'bytes';
responseHeaders['Cache-Control'] = 'no-cache';
sendResponse(response, 206,
responseHeaders, fs.createReadStream(filename, { start: start, end: end }));
Here is the complete httpListener()
callback function.
function httpListener(request, response) {
if (request.method != 'GET') {
sendResponse(response, 405, { 'Allow': 'GET' }, null);
return null;
}
var filename =
initFolder + url.parse(request.url, true, true).pathname.split('/').join(path.sep);
if (!fs.existsSync(filename)) {
sendResponse(response, 404, null, null);
return null;
}
var responseHeaders = {};
var stat = fs.statSync(filename);
var rangeRequest = readRangeHeader(request.headers['range'], stat.size);
if (rangeRequest == null) {
responseHeaders['Content-Type'] = getMimeNameFromExt(path.extname(filename));
responseHeaders['Content-Length'] = stat.size;
responseHeaders['Accept-Ranges'] = 'bytes';
sendResponse(response, 200, responseHeaders, fs.createReadStream(filename));
return null;
}
var start = rangeRequest.Start;
var end = rangeRequest.End;
if (start >= stat.size || end >= stat.size) {
responseHeaders['Content-Range'] = 'bytes */' + stat.size;
sendResponse(response, 416, responseHeaders, null);
return null;
}
responseHeaders['Content-Range'] = 'bytes ' + start + '-' + end + '/' + stat.size;
responseHeaders['Content-Length'] = start == end ? 0 : (end - start + 1);
responseHeaders['Content-Type'] = getMimeNameFromExt(path.extname(filename));
responseHeaders['Accept-Ranges'] = 'bytes';
responseHeaders['Cache-Control'] = 'no-cache';
sendResponse(response, 206,
responseHeaders, fs.createReadStream(filename, { start: start, end: end }));
}
Also, the entire workflow can be summarized with the following chart:
So how do we test our work? As we just mentioned in the Introduction, the most common scenario of partial content is streaming and playing videos. So we create an HTML5 page which includes a <video/>
with ID mainPlayer
and a <source/>
tag. Function onLoad()
will be triggered when mainPlayer
has preloaded the metadata of current video. It is used for checking if there is any numeric parameter existing in URL. If yes, mainPlayer
will skip to the indicated second.
<!DOCTYPE html>
<html>
<head>
<script type="text/javascript">
function onLoad() {
var sec = parseInt(document.location.search.substr(1));
if (!isNaN(sec))
mainPlayer.currentTime = sec;
}
</script>
<title>Partial Content Demonstration</title>
</head>
<body>
<h3>Partial Content Demonstration</h3>
<hr />
<video id="mainPlayer" width="640" height="360"
autoplay="autoplay" controls="controls" onloadedmetadata="onLoad()">
<source src="dota2/techies.mp4" />
</video>
</body>
</html>
Now we save our page as "player.html" under initFolder
along with video file "dota2/techies.mp4". Activate Node.js, execute the script, then open the URL in browser:
http://localhost:8000/player.html
This is how it looks like in Chrome:
Because there are no parameters in the URL, the file will be played starting at 0th second.
Next is the fun part. Let's try to open this one and see what happens:
http://localhost:8000/player.html?60
If you press F12 to open Chrome Developer Tools, switch to Network tab and click the detail of latest log. You will notice that the Range
header string
sent by your browser is something like this:
Range:bytes=225084502-
Pretty interesting, right? When function onLoad()
changes the currentTime
property, browser computes the corresponding byte position of 60th second in this video. Because mainPlayer
has preloaded the metadata, including format, bit rate and other fundamental information, the start
position comes out almost immediately. After that, the browser is able to download and play the video without requesting first 60 seconds. Same thing happens if you click the timeline before mainPlayer
reaches the position you just clicked. It works!!
We have implemented an HTTP server in Node.js which supports partial content. We also tested it with an HTML5 page. But this is just a beginning. If you have understood the whole thing about these headers and workflow, you can try to implement it with other frameworks such like ASP.NET MVC and Web WCF Service. But don't forget to enable Task Manager to see CPU and memory usage. Like we discussed in A brief of partial content, server does not have to return all remaining bytes in single response. To find out a balance point of performance will be an important mission.