skip to content

How to create a zero dependency HTTP/2 static file server with Node.js

HTTP/2 has been supported by the latest versions of the most popular browsers, including Google Chrome, Firefox, Safari and Microsoft Edge for quite some time now. Websites delivered using HTTP/2 enjoy a wide range of new features including -

Node.js launched support (v8.8.1) for HTTP/2 as part of their core. In this post, we will create a simple HTTP/2 server to serve static files and then demonstrate some cool features like HTTP/2 PUSH.

Step 0: Install Node.js v8.8.1

We will need at least Node.js v8.7.0 to be installed, you can download the latest version here.

Note - HTTP/2 is still considered as an experimental feature (Stability 0) even in the latest version of Node. It is not recommended to run production workloads using this yet, since the API might change.

Step 1: Get an SSL certificate

Even though the HTTP/2 spec does not mandate HTTPS, browsers have decided that they will only support HTTP/2 on a HTTPS connection. If you already have HTTPS enabled, you can skip this step. Otherwise, you can generate a free certificate with letsencrypt or a self-signed certificate for local development.

Step 2: Building a Static File server

Let us start with a simple server which just serves static files. We will be listening to the stream event and responding to it with the corresponding file from the server root using the respondWithFile API.

const http2 = require('http2');
const fs = require('fs');
const path = require('path');
const mime = require('mime-types');

const {
    HTTP2_HEADER_PATH,
    HTTP2_HEADER_METHOD,
    HTTP_STATUS_NOT_FOUND,
    HTTP_STATUS_INTERNAL_SERVER_ERROR
} = http2.constants;

const options = {
    key: fs.readFileSync('./selfsigned.key'),
    cert: fs.readFileSync('./selfsigned.crt')
}

const server = http2.createSecureServer(options);

const serverRoot = "./public";

function respondToStreamError(err, stream) {
    console.log(err);
    if (err.code === 'ENOENT') {
        stream.respond({ ":status": HTTP_STATUS_NOT_FOUND });
    } else {
        stream.respond({ ":status": HTTP_STATUS_INTERNAL_SERVER_ERROR });
    }
    stream.end();
}

server.on('stream', (stream, headers) => {
    const reqPath = headers[HTTP2_HEADER_PATH];
    const reqMethod = headers[HTTP2_HEADER_METHOD];

    const fullPath = path.join(serverRoot, reqPath);
    const responseMimeType = mime.lookup(fullPath);

    stream.respondWithFile(fullPath, {
        'content-type': responseMimeType
    }, {
        onError: (err) => respondToStreamError(err, stream)
    });
});

server.listen(443);

Server Push Example

Now that we have a simple HTTP/2 server running, let’s try to use one of the new features in HTTP/2 - HTTP/2 PUSH. This can lead to significant performance improvements in high latency environments, if done correctly.

We are loading a simple HTML file pointing to style.css which references our font file. The request to the font file is only made after the CSS file is discovered in the HTML, downloaded and then parsed. This is how the waterfall would have usually looked like.

waterfall without http2

You can initiate a new PUSH with the pushStream API. Since we know that the browser is going to be requesting the font file in the future, we can PUSH the font file as soon as the server receives the request for the HTML file.

waterfall with http2

const http2 = require('http2');
const fs = require('fs');
const path = require('path');
const mime = require('mime-types');

const {
    HTTP2_HEADER_PATH,
    HTTP2_HEADER_METHOD,
    HTTP_STATUS_NOT_FOUND,
    HTTP_STATUS_INTERNAL_SERVER_ERROR
} = http2.constants;

const options = {
    key: fs.readFileSync('./selfsigned.key'),
    cert: fs.readFileSync('./selfsigned.crt')
}

const server = http2.createSecureServer(options);

const serverRoot = "./public";

function respondToStreamError(err, stream) {
    console.log(err);
    if (err.code === 'ENOENT') {
        stream.respond({ ":status": HTTP_STATUS_NOT_FOUND });
    } else {
        stream.respond({ ":status": HTTP_STATUS_INTERNAL_SERVER_ERROR });
    }
    stream.end();
}

server.on('stream', (stream, headers) => {
    const reqPath = headers[HTTP2_HEADER_PATH];
    const reqMethod = headers[HTTP2_HEADER_METHOD];

    const fullPath = path.join(serverRoot, reqPath);
    const responseMimeType = mime.lookup(fullPath);

    if (fullPath.endsWith(".html")) {
        stream.respondWithFile(fullPath, {
            "content-type": "text/html"
        }, {
            onError: (err) => {
                respondToStreamError(err, stream);
            }
        });

        stream.pushStream({ ":path": "/font.woff" }, { parent: stream.id }, (pushStream) => {
            pushStream.respondWithFile(path.join(serverRoot, "/font.woff"), {
                'content-type': "text/css"
            }, {
                onError: (err) => {
                    respondToStreamError(err, pushStream);
                }
            });
        });
    } else {
        stream.respondWithFile(fullPath, {
            'content-type': responseMimeType
        }, {
            onError: (err) => respondToStreamError(err, stream)
        });
    }
});

server.listen(443);

You can check out the full API for HTTP/2 in node.js here.

Using the compatibility API

Node.js also provides a compatibility layer to make it easier to get started with HTTP/2 on your existing web application.

You simply need to replace your existing https module with the new http2 module and things should work out of the box. Make sure you pass in the allowHTTP1 flag when creating the server. This would downgrade browsers which don’t support HTTP/2 back to a HTTP/1.1 connection.

const http2 = require('http2');
const fs = require('fs');

const options = {
    key: fs.readFileSync('./selfsigned.key'),
    cert: fs.readFileSync('./selfsigned.crt'),
    allowHTTP1: true
}

const server = http2.createSecureServer(options, (req, res) => {
  res.setHeader('Content-Type', 'text/html');
  res.end('ok');
});

server.listen(443);

It would still take time before full-blown frameworks like Express catch up to ensure that their API doesn’t break when used with HTTP/2, but with HTTP/2 server and client adoption catching up rapidly, we should get there soon!