Serving Static Websites With Azure Functions

With the new serverless craze I decided to create myself a little challenge to explore Azure Functions. The challenge I came up with was moving my personal website from a CMS to a static site hosted by Functions. This post covers how I achieved this.

I am not going to be covering what Functions are in this post. For more information on Functions please see my previous post: Introduction To Azure Functions

The Content

The first step involved moving the content of my website away from a dated Wordpress install. I investigated various static site generators and ended up using the amazing, brilliant, fantastic (I can’t praise this thing enough…), Hugo.

The migration was surprisingly quick due to the great templating features provided by Hugo.

The content was uploaded to a blob storage container so that I can easily access it from the function.

Once the content was ready I needed to figure out a way to serve it. Enter Functions…

The Function

Serving static content with Functions is surprisingly easy. You have access to proxies which can used to proxy any request to the function through to a blob storage container. This is however not the solution I implemented as I found various problems with this approach.

Firstly, blob storage has no concept of a default document. Imagine you have the following file in blob storage:

content/about-me/index.html

A request made to /content/about-me/ will not work. In a normal setup Nginx or Apache will take care of this for us and automatically load the index.html file.

The above can be fixed by adding some clever routing, but the routing setup quickly gets out of hand the deeper your folder structure goes.

The second problem, although minor, was that with the proxy approach, the 404 page needs to be hard coded in the function itself. Current me likes to save future me a lot of pain by keeping things together in a logical way. This means keeping the 404 page content together with all my other content.

The Custom “Proxy”

Keeping the above in mind I decided to find a way to get the content from the blob, but keep my routes completely dynamic with the option to display the 404 page from my blob if the original file could not be found. This is what I came up with:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
let azure = require('azure-storage');
let blobService = azure.createBlobService();
let pathUtil = require('path');

module.exports = function (context, req) {
    let path = context.bindingData.uriPath;

    // Default Root (www.geevcookie.com)
    if (!path) {
        path = 'index.html';
    }

    // Add index.html where needed
    if (path.slice(-1) === '/') {
        path += 'index.html';
    } else {
        if (pathUtil.extname(path) === '') {
            path += '/index.html';
        }
    }

    let stream = require('stream');
    let writer = new stream.Writable();

    // Override write method on stream to update our content
    writer.content = new Buffer('');
    writer._write = function (chunk, encoding, cb) {
        var buffer = (Buffer.isBuffer(chunk)) ?
            chunk :
            new Buffer(chunk, encoding);

        this.content = Buffer.concat([this.content, buffer]);
        cb();
    };

    // Attempt to retrieve the requested file based on the URL
    blobService.getBlobToStream('website', path, writer, function(err, res) {
        if (!err) {
            // File was found and written to the stream. Response with the Buffer
            context.res.setHeader('content-type', res.contentSettings.contentType);
            context.res.raw(writer.content);
        } else {
            if (err.statusCode === 404) {
                writer.content = new Buffer('');
                // As we received a 404 from the blob, retrieve our 404 page.
                blobService.getBlobToStream('website', '404/index.html', writer, function(err, res) {
                    if (!err) {
                        context.res.setHeader('content-type', res.contentSettings.contentType);
                        context.res.raw(writer.content);
                    } else {
                        context.res.raw("<h1>Unknown Error</h1>");
                    }
                });
            } else {
                // Meh, who knows what could happen to reach this point...
                context.res.raw("<h1>Unknown Error</h1>");
            }
        }
    });
};

The content of the function.json file is as follows:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
{
  "disabled": false,
  "bindings": [
    {
      "authLevel": "anonymous",
      "type": "httpTrigger",
      "direction": "in",
      "name": "req",
      "route": "{*uriPath}"
    },
    {
      "type": "http",
      "direction": "out",
      "name": "res"
    }
  ]
}

The above creates a function with a catch-all route pattern. It gets the requested path and then does some sanity checks. It first tries to retrieve the requested file and if that fails it will retrieve the 404 page. Once it has the content it will close the current context with the raw content set to the Buffer we created by overriding the write method on our stream.

Now we have our content in a blob and a function that can return it. The next problem to solve was the contact form.

The Contact Form API

Again, this is super easy with Functions. You have access to SendGrid bindings for output in your Function.

Once you have an account set up (This can be done through the Azure Portal) all you need to do is add an application setting containing your API key. The function.json should look something like this:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
{
  "disabled": false,
  "bindings": [
    {
      "authLevel": "function",
      "type": "httpTrigger",
      "direction": "in",
      "name": "req",
      "methods": [
        "post",
        "options"
      ]
    },
    {
      "type": "http",
      "direction": "out",
      "name": "res"
    },
    {
      "type": "sendGrid",
      "name": "message",
      "apiKey": "<application setting name>",
      "to": "<your email address>",
      "direction": "out"
    }
  ]
}

And a very basic Function to use this will look something like this:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
let util = require('util');

module.exports = function (context, req) {
    let form = req.body;

    if (
        typeof form.name !== 'undefined' &&
        typeof form.email !== 'undefined' &&
        typeof form.subject !== 'undefined' &&
        typeof form.message !== 'undefined'
    ) {
        let res = {
            status: 200,
            body: {"message": "success"}
        };
        let message = {
            from: { email: "<from email address>" },
            subject: util.format('New Message From Website: %s', form.subject),
            content: [{
                type: 'text/plain',
                value: util.format(`
Name: %s\r\n

Email: %s\r\n

Message:\r\n
%s
`, form.name, form.email, form.message)
            }]
        };
        context.done(null, {message, res});
    } else {
        let res = {
            status: 500,
            body: {"message": "Not all required fields provided!"}
        };
        context.done(null, {res});
    }
};

Now all that was needed was to post the content of a form via ajax to the URL for this function and viola! A serverless contact form!

Deployment

The last part of the challenge was to somehow set up continuous deployment for both the function and the content.

Function CD

To set up continuous delivery for the function, all I needed to do was set up the deployment options and connect it to my GitHub repo.

Azure Functions Deployment Options

Now every time I push my latest changes it will automatically deploy it.

Content CD

I thought the content part might be where this challenge beats me, but luckily I then remembered that Visual Studio Team Services is free for up to 5 users.

In VSTS I could set up a build pipeline that again automatically pulls my latest changes from GitHub and copies them over to the blob storage.

Build Process

The most important thing to remember here is that you need to set /SetContentType as an additional argument for the File Copy step, or all your files will default to octet-stream content type.

Conclusion

A lot of research, playing and testing and I now have a completely serverless website with continuous deployment.

Another benefit of this move is the price. With Azure Functions your first 1 000 000 requests per month are free.

You can find the source code for the above Function on GitHub: https://github.com/geevcookie/functions-static-content

comments powered by Disqus