File Uploading and Streaming with BinaryJS

A while back, if you wanted to stream binary data via JavaScript - such as audio/video content, you'd be sore out of luck :(

You'd have to rely on either Flash, Java applets or 3rd party plugins that provided similar functionality. Uggh.

Over the past few years, advancements in JavaScript on both fronts: server-side and client-side, now allow you to do so without having to resort to otherwise tedious workarounds.

In this post, I'll show you how to upload and stream video files - yup, you heard that right :)

How exactly do you ask? By using an awesome Node module called BinaryJS, and some good ol' client-side Javascripting!

What We'll Need

Before we can get started writing code to stream binary data, we need to install some modules. We'll only need two: express and binaryjs.

Express

The defacto Node.js web framework! My framework of choice, and that of many fellow Node developers out there. It's fast, easy-to-use and well-documented.

To familiarize yourself with the express API, if you haven't already done so, check out the official ExpressJS API documentation

If express is not your cup of tea, you're most welcome to opt it out for something you're more comfortable with.

BinaryJS

The heart of our video streaming web app! This module uses WebSockets and the BinaryPack serialization scheme to stream binary content back-and-forth between the server and the client.

Want to find out more? Here's the official BinaryJS Website, and here's the API documentation for good measure.

The Workflow

First off, I'll outline the workflow for both the server and client portions of the video server we're building.

Server-side

  1. Create an instance of the BinaryJS server
  2. Register custom events and handlers for:
  • uploading videos
  • requesting for a video
  • listing available videos

Client-side

  1. Create an instance of the BinaryJS client
  2. Upon connecting to the BinaryJS server, retrieve a list of available videos and present it
  3. Clicking a link in the video list should load the affected video
  4. Add a means to upload video files:
  • use Drag n Drop for a better UX experience
  • refresh the list of available videos

Installation

First off, install the modules in your project directory via npm. I've added the version numbers that were installed for me:

  • Express v3.4.6
  • BinaryJS v0.2.1
  1. $ npm install express
  2. $ npm install binaryjs

Next up, bootstrap your web app using express:

  1. $ node_modules/express/bin/express .

Or if you have express installed globally:

  1. $ express .

Remove the directories we don't need:

  1. $ rm -rf routes/ views/

Replace the generated copy of package.json with this:

  1. {
  2. "name": "binaryjs-upload-stream",
  3. "version": "0.1.0",
  4. "private": true,
  5. "scripts": {
  6. "start": "node app.js"
  7. },
  8. "dependencies": {
  9. "express": "3.4.6",
  10. "binaryjs": "0.2.1"
  11. }
  12. }

Also, don't forget to clear out irrelevant code in app.js:

  1. // remove these lines
  2. app.set('views', path.join(__dirname, 'views'));
  3. app.set('view engine', 'jade');
  4.  
  5. // we won't need routing too
  6. app.get('/', routes.index);
  7. app.get('/users', user.list);

For the finishing touch, I renamed some directories in public/. This is a matter of preference and therefore entirely optional.

  1. $ cd public/
  2. $ ls
  3.  
  4. images javascript stylesheets
  5.  
  6. $ mv javascript/ js/
  7. $ mv stylesheets/ css/

Coding The Backend

app.js

Open up app.js and start coding! You'll need to create an instance of BinaryServer, which the binaryjs module provides.

Also, add a reference to the video library for later.

  1. // add these two lines near the variable declarations at the top
  2. BinaryServer = require('binaryjs').BinaryServer;
  3. video = require('./lib/video');

I set my instance to run on port 9000. If you don't specify a custom port, it'll piggyback on whatever port you've set on express after which you'll need to set a custom endpoint.

  1. // add this after the call to server.listen()
  2. bs = new BinaryServer({ port: 9000 });

Now we set the connection handler for the binaryjs server. It provides a client object which is of type binaryjs.BinaryClient

The client's stream event returns both a stream object as well as a meta object, configurable from the client-side.

Add handlers for the following meta events:

  • list
  • request
  • upload
  1. bs.on('connection', function (client) {
  2. client.on('stream', function (stream, meta) {
  3. switch(meta.event) {
  4. // list available videos
  5. case 'list':
  6. video.list(stream, meta);
  7. break;
  8.  
  9. // request for a video
  10. case 'request':
  11. video.request(client, meta);
  12. break;
  13.  
  14. // attempt an upload
  15. case 'upload':
  16. default:
  17. video.upload(stream, meta);
  18. }
  19. });
  20. });

video.js

Create a source file for managing the videos, I put mine in lib/video.js. This file will house the implementations for the following capabilities:

  • listing of available videos
  • requesting of a video for playback
  • uploading of a video to the server
  1. var fs, uploadPath, supportedTypes;
  2.  
  3. fs = require('fs');
  4. uploadPath = __dirname + '/../videos';
  5. supportedTypes = [
  6. 'video/mp4',
  7. 'video/webm',
  8. 'video/ogg'
  9. ];
  10.  
  11. module.exports = {
  12. list : list,
  13. request : request,
  14. upload : upload
  15. };

The list function does the simple task of reading filenames in the videos/ directory and streaming back a list of it to the client.

  1. function list(stream, meta) {
  2. fs.readdir(uploadPath, function (err, files) {
  3. stream.write({ files : files });
  4. });
  5. }

request creates a read stream for the requested video file, and streams it in chunks back to the client.

  1. function request(client, meta) {
  2. var file = fs.createReadStream(uploadPath + '/' + meta.name);
  3.  
  4. client.send(file);
  5. }

The file upload implementation in upload checks if the file is of a supported video type.

If the type matches, the function proceeds - otherwise, it returns an error.

For the sake of convenience, the function informs the client of the upload status as it writes the video to disk, chunk by chunk.

  1. function upload(stream, meta) {
  2. if (!~supportedTypes.indexOf(meta.type)) {
  3. stream.write({ err: 'Unsupported type: ' + meta.type });
  4. stream.end();
  5. return;
  6. }
  7.  
  8. var file = fs.createWriteStream(uploadPath + '/' + meta.name);
  9. stream.pipe(file);
  10.  
  11. stream.on('data', function (data) {
  12. stream.write({ rx: data.length / meta.size });
  13. });
  14.  
  15. stream.on('end', function () {
  16. stream.write({ end: true });
  17. });
  18. }

Coding the Frontend

index.html

Add the following HTML to your landing page's <body> tag.

  1. <h1>BinaryJS File Upload and Streaming</h1>
  2.  
  3. <section id="main">
  4. <fieldset>
  5. <legend>Drag n Drop</legend>
  6. <aside id="upload-box">
  7. <article id="progress">Drop file here</article>
  8. </aside>
  9. </fieldset>
  10.  
  11. <fieldset>
  12. <legend>Select a Link</legend>
  13.  
  14. <nav id="list" class="left"></nav>
  15. </fieldset>
  16.  
  17. <fieldset>
  18. <legend>Play the Video</legend>
  19.  
  20. <section class="left">
  21. <video id="video"></video>
  22. </section>
  23. </fieldset>
  24. </section>

Insert the following <script> tags at the end of the <body> tag in the order specified.

  1. <script src="/js/lib/binary.js"></script>
  2. <script src="/js/lib/jquery.js"></script>
  3. <script src="/js/lib/common.js"></script>
  4. <script src="/js/lib/video.js"></script>
  5. <script src="/js/main.js"></script>

common.js

Before anything else can work client-side, make sure to create an instance of BinaryClient with a port of 9000 - or whichever port you have changed it to - and save this to js/lib/common.js.

  1. var hostname, client;
  2.  
  3. hostname = window.location.hostname;
  4. client = new BinaryClient('ws://' + hostname + ':9000');

The common.js file also includes helper functions like fizzle, used to prevent event propagation in JavaScript ...

  1. function fizzle(e) {
  2. e.preventDefault();
  3. e.stopPropagation();
  4. }

And emit, which is essentially a wrapper to the BinaryClient method send.

client.send takes two arguments: tle file to be streamed over to the video server, and the accompanying meta data - in that order.

  1. function emit(event, data, file) {
  2. file = file || {};
  3. data = data || {};
  4. data.event = event;
  5.  
  6. return client.send(file, data);
  7. }

video.js

For js/lib/video.js, add functions that implement:

  • retrieving of video listings from the video server
  • uploading of a video file to the video server
  • requesting of a video file from the video server
  • downloading of a requested video file from the video server
  1. function list(cb) {
  2. var stream = emit('list');
  3.  
  4. stream.on('data', function (data) {
  5. cb(null, data.files);
  6. });
  7.  
  8. stream.on('error', cb);
  9. }

The upload method facilitates the uploading of a file - the streaming, and the resulting feedback of the upload as it progresses.

  1. function upload(file, cb) {
  2. var stream = emit('upload', {
  3. name : file.name,
  4. size : file.size,
  5. type : file.type
  6. }, file);
  7.  
  8. stream.on('data', function (data) {
  9. cb(null, data);
  10. });
  11.  
  12. stream.on('error', cb);
  13. }

The request function is nothing more than a wrapper function for the request event:

  1. function request(name) {
  2. emit('request', { name : name });
  3. }

In order to get downloading to work, the chunks of video data that get streamed in as ArrayBuffer objects need to be stitched together in a Blob instance.

The src object, containing the newly formed Blob, can then be returned in a callback.

  1. function download(stream, cb) {
  2. var parts = [];
  3.  
  4. stream.on('data', function (data) {
  5. parts.push(data);
  6. });
  7.  
  8. stream.on('error', function (err) {
  9. cb(err);
  10. });
  11.  
  12. stream.on('end', function () {
  13. var src = (window.URL || window.webkitURL).createObjectURL(new Blob(parts));
  14.  
  15. cb(null, src);
  16. });
  17. }

main.js

The final file, js/main.js ties the presentation layer with application logic.

Once the connection is up, as denoted by the open event, add handling for video listings and Drag n' Drop.

  1. client.on('open', function () {
  2. video.list(setupList);
  3. $box.on('drop', setupDragDrop);
  4. });

In the stream event, we assume that anything that gets streamed back without initiation from the client-side (list, video request, etc) is undoubtedly a video file.

  1. client.on('stream', function (stream) {
  2. video.download(stream, function (err, src) {
  3. $video.attr('src', src);
  4. });
  5. });

setupList refreshes the file listing visuals everytime a list request is sent.

  1. function setupList(err, files) {
  2. var $ul, $li;
  3.  
  4. $list.empty();
  5. $ul = $('<ul>').appendTo($list);
  6.  
  7. files.forEach(function (file) {
  8. $li = $('<li>').appendTo($ul);
  9. $a = $('<a>').appendTo($li);
  10.  
  11. $a.attr('href', '#').text(file).click(video.request);
  12. });
  13. }

setupDragDrop contains logic for dragging and dropping a file into the "drop" box (saw what I did there?), after which it initiates the upload of said file.

The progress is indicated directly in the text of the "drop" box (there I did it again!) as the file upload progresses.

  1. function setupDragDrop(e) {
  2. fizzle(e);
  3.  
  4. var file, tx;
  5.  
  6. file = e.originalEvent.dataTransfer.files[0];
  7. tx = 0;
  8.  
  9. video.upload(file, function (err, data) {
  10. var msg;
  11.  
  12. if (data.end) {
  13. msg = "Upload complete: " + file.name;
  14.  
  15. video.list(setupList);
  16. } else if (data.rx) {
  17. msg = Math.round(tx += data.rx * 100) + '% complete';
  18.  
  19. } else {
  20. // assume error
  21. msg = data.err;
  22. }
  23.  
  24. $progress.text(msg);
  25.  
  26. if (data.end) {
  27. setTimeout(function () {
  28. $progress.fadeOut(function () {
  29. $progress.text('Drop file here');
  30. }).fadeIn();
  31. }, 5000);
  32. }
  33. });
  34. }

Putting it Together

Alright, we're just about ready to test it out! Make sure to start your server up:

  1. $ node app.js

Access the landing site via your browser

Load the Landing Site

Drag and drop a video file into the gray box

Drag n Drop a video file

Click a video link and watch it stream

Play a video file

And there you have it - your very own, video server with support for uploading and streaming. Written in Node!

Caveats

I've tested uploading of video files both small and large - and they work fine.

Of course, since the example uses the html5 <video> tag, supported formats are limited to video/mp4, video/webm and video/ogg.

Streaming of large video files, however, takes a while and may freeze the page. Proceed with caution - you have been warned!

BinaryJS's client-side component works with the following browsers:

  • Chrome 15+
  • Firefox 11+
  • Internet Explorer 10
  • Safari nightly builds

If you're on an older browser, well ...

Why are you using an older browser again?

Sourcecode

For the full source - which includes the stuff I missed during the tutorial (css, helper functions, etc) - grab the tarball over here. Alternatively, visit the Github page over here.

Video Samples

I've taken the liberty of adding sample video files to the samples/ directory, in case you need some files to play with.

You can check the page I got them from too:

Sample WebM, Ogg, and MP4 Video Files for HTML5

Going Further

There's more you can do to spruce up this example that is well beyond the scope of this tutorial, such as:

  • robust error handling
  • MIME type checks on the server side after the file has been uploaded
  • a full-fledged Audio on Demand/Video on Demand server
  • adaptive bitrate streaming for clients with varying bandwidth and CPU capacity
  • anymore that you can think of goes here ...

If you do decide to build something along those lines, feel free to share it over here in the comments section, so the rest of us can revel in awe!

Well that's it folks, I hope you enjoyed my very first Node.js tutorial @ OlinData, expect more to come in the near future :)

Raj Kissu's picture

Add new comment

By submitting this form, you accept the Mollom privacy policy.