8

A while back I found this function in some tutorial for streaming video so that the whole file doesn't need to be loaded into RAM for the file to be served (so you can serve big video files without crashing due to exceeding the memory cap of Node.js - which isn't hard to exceed with a movie-length video file, and increasing memory allocation is just a band-aid solution).

var fs = require("fs"), 
    http = require("http"), 
    url = require("url"), 
    path = require("path");
var dirPath = process.cwd();
var videoReqHandler = function(req, res, pathname) {
    var file = dirPath + "/client" + pathname;
    var range = req.headers.range;
    var positions = range.replace(/bytes=/, "").split("-");
    var start = parseInt(positions[0], 10);
    fs.stat(file, function(err, stats) {
        if (err) {
            throw err;
        } else {
            var total = stats.size;
            var end = positions[1] ? parseInt(positions[1], 10) : total - 1;
            var chunksize = (end - start) + 1;
            res.writeHead(206, {
                "Content-Range" : "bytes " + start + "-" + end + "/" + total,
                "Accept-Ranges" : "bytes",
                "Content-Length" : chunksize,
                "Content-Type" : "video/mp4"
            });
            var stream = fs.createReadStream(file, {
                start : start,
                end : end
            }).on("open", function() {
                stream.pipe(res);
            }).on("error", function(err) {
                res.end(err);
            });
        }
    });
};
module.exports.handle = videoReqHandler;

It works fine in Chrome and FF, however, when Internet Explorer, or Edge, if you will, (new name, same pathetic feature support) requests an mp4, it crashes the server.

var positions = range.replace(/bytes=/, "").split("-");
                     ^
TypeError: Cannot read property 'replace' of undefined

I suspect that's because of the fact that range headers aren't mandatory, and this function requires them.

My question is, how can I modify this function to avoid crashing when the range header isn't sent? I don't know much about headers or video streaming, and I'm pretty iffy on my understanding of reading files in chunks, so I could really use some help on this function which involves all three.


What I've Tried Based on Comments

So far, based on the answer @AndréDion linked:

Figured it out. IE11 does support pseudo streaming but it wants the "Accept-Ranges: bytes" header before it will bother requesting a range, so the server needs to respond with that regardless of whether it is actually sending a byte range. I had to modify my vid-streamer module to do that.

I tried wrapping the handler code in:

if (req.headers.range) {
    console.log('Video request sent WITH range header!');
    // ... handler code ...
} else {
    console.log('Video request sent without range header!');
    res.writeHead(206, {
        "Accept-Ranges": "bytes"
    });
    res.end();
}

but nothing seems to happen - the server stops crashing, and continues to work on other browsers, but IE doesn't seem to be loading the video. I was expecting for IE to wait for that response then send another request, this time including a range header, but that doesnt appear to be happening.

Maybe I need to send a different response code than 206 when sending IE the accept-ranges header? I have no idea what it would be, but when I respond with the accept-rages header, IE seems to repeat the request twice (but not with the range header included, which is what I need).

As it stands now, if I run the server with the conditional as shown above, then try to load the video once in IE, then try to load it in Chrome, I get these logs:

Video request sent without range header!
Video request sent without range header!
Video request sent without range header!
Video request sent WITH range header!

IE sends three requests without range headers, Chrome of course always sends the range header as expected, sending one request and successfully loading the video. And I'm just not sure where to go from here.

  • 1
    If you're going to down-vote, please have the decency to explain how you think the question could be improved, or what's wrong with it, thanks. –  May 25 '17 at 16:14
  • 3
    Not sure why people are downvoting your question. You seem to have a well written question. Maybe it's the jab at IE/Edge? Although, I'm not sure who would defend those browsers. – Joseph Marikle May 25 '17 at 16:30
  • "it crashes" and "I suspect" aren't helpful to anyone willing to help. Your whole question is based on conjecture since you haven't actually deduced _why_ your server is crashing. Instead you chalk it up to "pathetic IE" and then moan about losing fake internet points. – André Dion May 25 '17 at 16:32
  • Question seems pretty clear to me! – Matt May 25 '17 at 16:35
  • @AndréDion I don't care about the points - it's just if my question is downvoted it'll probably be ignored by more people. Also, you make a good point - I forgot to include the error log that confirms the problem is what I say it is! –  May 25 '17 at 16:35
  • [It looks like you may need to respond with `Accept-Ranges` first before IE will send `Range`](https://stackoverflow.com/questions/25654422/http-pseudo-streaming-in-ie11). – André Dion May 25 '17 at 16:45
  • @AndréDion Alright, I Googled it and I'm not seeing any explanations on how I "respond with accept-ranges" . I found something about it using Express but I'm not using Express. The code already seems to be responding with 206 and includes "Accept-Ranges", "bytes" as a header, but it's unclear to me how I need to handle it exactly. Respond with 206 and include only the "Accept-Ranges", "bytes" header when I get a request without a range header, then wait for another request? –  May 25 '17 at 16:54
  • I'm not super familiar with what you're doing here, but from what I gather from the thread I linked to you'd simply not assume to always have a range like the code is doing now. Given your code you'd omit the `Content-Range` and `Content-Length` headers and send the response as-is. – André Dion May 25 '17 at 16:58
  • So far I've tried wrapping the handler code in `if (req.headers.range) { ..} else { res.writeHead(206, { "Accept-Ranges": "bytes" }) }` but nothing seems to happen - the server stops crashing, and continues to work on other browsers, but IE doesn't seem to be loading the video. From [the answers @AndréDion linked](https://stackoverflow.com/questions/25654422/http-pseudo-streaming-in-ie11) I was expecting for IE to wait for that response then send another request, this time including a range header, but that doesnt appear to be happening. –  May 25 '17 at 17:03
  • Maybe I need to send a different response code than 206 when sending IE the `accept-ranges` header? I have no idea what it would be, but when I respond with the `accept-rages` header, IE seems to repeat the request twice (but not with the `range` header included, which is what I need). –  May 25 '17 at 17:19
  • Internet Explorer includes an inspector tool that can capture web requests and responses, can you perhaps post the request/response sequence when loading/seeking one of your videos in IE? – ashbygeek Jun 01 '17 at 19:56
  • i think you can safely assume that those with no range headers are starting from 0, then send the info to the browser, so when browser thinks he is on wrong position, browser can send desired range again. How about it? – Chris Qiang Jun 03 '17 at 08:35
  • Why not just use the [send](https://github.com/pillarjs/send) npm module? – idbehold Jun 04 '17 at 16:27

2 Answers2

3

Looks like Internet Explorer expects more than just the Accept-Ranges header. This MSDN forum post indicates that you also need to respond with an If-Range header that returns an ETag. Something like this:

console.log('Video request sent without range header!');
res.writeHead(206, {
    "Accept-Ranges": "bytes",
    "If-Range": "\"<ETag#>\""
});

Unfortunately I can't do any testing right now since, so let me know how it goes and when I get a chance I'll investigate further and edit my post.


UPDATE:

I got around to that further investigation and found that the If-Range isn't necessary. In fact, it kind of seems that this request handler just wasn't robust enough for internet explorer. Here's my code that works:

var videoReqHandler = function(req, res, pathname) {
    var file = path.resolve(__dirname, pathname);

    fs.stat(file, function(err, stats) {
        if (err) {
            if (err.code === 'ENOENT'){
                //404 Error if file not found
                res.writeHead(404, {
                    "Accept-Ranges" : "bytes",
                    "Content-Range" : "bytes " + start + "-" + end + "/" + total,
                    "Content-Length" : chunksize,
                    "Content-Type" : "video/mp4"
                });
            }
            res.end(err);
        }

        var start;
        var end;
        var chunksize;
        var total = stats.size;

        var range = req.headers.range;
        if (range) {

            var positions = range.replace(/bytes=/, "").split("-");
            end = positions[1] ? parseInt(positions[1], 10) : total - 1;
            start = parseInt(positions[0], 10);
        }else{
            start = 0;
            end = total - 1;
        }


        if (start > end || start < 0 || end > total - 1){
            //error 416 is "Range Not Satisfiable"
            res.writeHead(416, {
                "Accept-Ranges" : "bytes",
                "Content-Range" : "*/" + stats.size,
                "Content-Type" : "video/mp4"
            });
            res.end();
            return;
        }

        if (start == 0 && end == total -1){
            res.writeHead(200, {
                "Accept-Ranges" : "bytes",
                "Content-Range" : "bytes " + start + "-" + end + "/" + total,
                "Content-Length" : total,
                "Content-Type" : "video/mp4"
            });
        }else{
            res.writeHead(206, {
                "Accept-Ranges" : "bytes",
                "Content-Range" : "bytes " + start + "-" + end + "/" + total,
                "Content-Length" : end - start + 1,
                "Content-Type" : "video/mp4"
            });
        }

        var stream = fs.createReadStream(file, {
            start : start,
            end : end
        }).on("open", function() {
            stream.pipe(res);
        }).on("error", function(err) {
            res.end(err);
        });
    });
};

This adds some error checks that were missing, such as making sure the range requested is a valid range, and returns the entire file with a code 200 if the request doesn't have a range header. There are a couple more things that should be done to make it rock solid for a production server though:

  • More research indicated that the intent of the If-Range header is to handle the situation where someone has requested a part of the file, it gets changed on the server, and then they request another part of the file. The If-Range allows this situation to be detected and the server can send the entirety of the new file rather than breaking everything and confusing the poor requester with part of this new file.
  • This code does not check to make sure that the requested range is in bytes. If the request specifies any other unit of measurement, this code will probably crash the server. It should be checked.
  • The range header allows for multiple ranges to be specified at a single time. The response when multiple ranges are requested consists of headers, and the requested data separated by a specifiable split string. More info on MDN. This point is arguable, you could say that you could get away with only handling the first part on a production server, so long as having more doesn't crash anything, but I would contend that it is part of the spec. To be fully spec compliant you need to implement it, and I get tired of running into services that aren't quite spec compliant.

One last note: this code gets things working in IE and Edge, but I did notice that for some reason they wind up requesting the FULL file twice! The requests follow like this with my setup:

  1. Partial file is requested with `Range bytes=0-', in other words the full file
  2. A very small portion of the file is requested with a normal Range statement
  3. The Full file is requested without a Range header

One final note, before I added in the ability to respond with the full file and a 200 status when there was no Range header IE would show the black box for the video with a 'Decode Error' emblazoned on it. If anyone can let me know why, that would be awesome!

ashbygeek
  • 759
  • 3
  • 20
  • As a side note for anyone who finds this posting, it would be a lot easier to let a library handle file requests. [NodeJS's Express framework](https://expressjs.com/) can do it or you can [pair Node and Nginx together](https://stackoverflow.com/questions/5009324/node-js-nginx-what-now) by letting nginx be a frontend that serves static files and proxies web requests to your Node server. – ashbygeek Jun 05 '17 at 12:53
0

I understand that Viziionary wants to stick to vanilla Node.js, but for anyone else who sees this and isn't so particular about sticking to Node, here are a few alternatives that don't involve manually serving static files.

  1. If you want to stick to Node but you're fine with adding a framework, the Express framework has functions for serving static files and directories. With Express one or two lines of code will let you serve static files with range request and etag support. It's a lot easier than programming your own request handler.

  2. If you're fine with stepping outside of Node, you can pair Node and Nginx together. In this configuration Nginx is a frontend that serves static files and proxies web requests to your Node server. In fact, Nginx was designed to work this way. Note that you can proxy to any number of entirely different servers and

  3. You can also do this with Apache on the frontend, I leave it to you to look up how. I've never done it, so all I could do would be to direct you towards a helpful tutorial.

I'm sure there are other configurations, but this should give you some food for thought. For most small to mid size websites you'll be hard pressed to notice any negative effects of using one system or the other. If you're running a large website or service, then you'd better do your homework because there are lots of other options and factors to consider: CDNs, other scripting languages, caching, scalability, etc., etc.

ashbygeek
  • 759
  • 3
  • 20