Automatically serve .webp or .avif with Node.js based on browser request headers.

Let’s chew down another issue: we have a /public folder with images, and depending on the browser we want to serve a WEBP or AVIF to save bandwidth and decrease page load time.

We will go through steps required by setting up a simple Express (Express.js) server. The setup will be very minimal, however, it will be a fully working server. Let’s start by creating a project folder and creating an empty package.json with my favorite echo {} > package.json. After that, add the Express package.

Bootstrapping

npm i --save express

After that, prepare a minimalistic server bootstrap, knowing that we need to serve static files from a /public folder (don’t forget to create a /public folder as well):

const path = require('path')
const express = require('express')
const app = express()
const port = process.env.PORT || 3000

app.use(express.static(path.join(__dirname, 'public')))

app.listen(port, () => {
    console.log(`App is running on port ${port}`);
})

At this point, we can already test our server by running node index.js:

% node index.js
App is running on port 3000

WEBP / AVIF middleware preparations

Now we will need to create our image middleware. For that, we will do a few things:

  1. Check if the request method is GET or HEAD.
  2. Check if the browser sent an Accept header with image/webp or image/avif.
  3. Check if the URL requested is actually a .jpg, .jpeg or .png.
  4. Check if the alternative file exists.
  5. Server that file instead of the requested one.

Now I’m going to start creating our middleware. I’ll create a file named webp-avif-middleware.js and start preparing my middleware function:

module.exports = function(folder) {
  return function(req, res, next) {
    next()
  }
}

At this point, our middleware will do nothing, but at least it won’t crash the server. For the testing purposes, let’s download the Node.js logo from Wikimedia Commons and save it into /public/logo.png. You will also need a logo.png.webp, which I’m pretty sure you can create with any of a million possible ways (i.e. using an online tool like https://cloudconvert.com/png-to-webp). Import our middleware, and use it in our express bootstrapping block. Your server should now look more or less like this:

const path = require('path')
const express = require('express')
const webpavif = require('./webp-avif-middleware')
const app = express()
const port = process.env.PORT || 3000

const static = path.join(__dirname, 'public')

app.use(webpavif(static))
app.use(express.static(static))

app.listen(port, () => {
    console.log(`App is running on port ${port}`);
})

Run the server and you should be able to load the logo at http://localhost:3000/logo.png

Actual WEBP / AVIF middleware

Now, we are ready to write our middleware. As noted before, we need to verify 5 steps in order to serve the alternative image, so let’s get to it. All code below will go inside our middleware function(req, res, next). The first step is simply checking if the request method is GET or HEAD:

return function(req, res, next) {
    const method = req.method.toUpperCase()
    if (method !== 'GET' && method !== 'HEAD') {
        return next()
    }

This was simple. Next step is actually checking if the browser can even handle those images:

if (!req.headers.accept) {
    return next()
}
const canwebp = (req.headers.accept.indexOf('image/webp') !== -1)
const canavif = (req.headers.accept.indexOf('image/avif') !== -1)

if (!canwebp && !canavif) {
    return next()
}

We save the matched headers into canwebp and canavif so we could pick which one to use based on our (and browser’s) availability. Next step, check if the URL requested looks like an image to you.

Inside the middleware file, import the path module, and create a list of passable extensions. We will use those in our checks.

const path = require('path')
const extensions = ['.jpg', '.jpeg', '.png']

Now we can use that:

const ext = path.extname(req.url)
if (extensions.indexOf(ext) === -1) {
    return next()
}

Next (and final) step is to check if the alternative file exists, and then serve it if it does. We will need to do some preparations for this. First, let’s include additional modules, we will need them to parse the URL and check for file existence:

const url = require('url')
const fs = require('fs')

Now, the heavy lifting part, which involves checking the file stat and rewriting the request URL. I’ll wrap this in a function so we could use it multiple times, and nest as much as we want:

const probeFile = function(req, extension, callback) {
    const pathname = url.parse(req.url).pathname;
    const check_path = path.join(folder, pathname + extension)
    const new_path = pathname + extension

    fs.stat(check_path, function(err, stats) {
        if (err) {
            callback(false)
        } else if (stats.isFile()) {
            req.url = req.url.replace(pathname, new_path);
            callback(true)
        } else {
            callback(false)
        }
    })
}

Here’s what’s happening. As you can see, the function accepts 3 arguments: the request, the extension to check for, and the callback. Inside, it parses the URL to get the path to the original filename, then adds the extension to verify that the alternative image exists, and if everything is successful it will replace the request’s /logo.png with /logo.png.webp or /logo.png.avif. In any case, the callback is called telling us that it finished with a failure or success.

Now with that function, we can finally use it. Notice how first we try .avif, and then fallback to .webp. AVIF is known to have a better compression rate, so we give it a priority first, and fallback to WEBP.

if (canavif) {
    probeFile(req, '.avif', function(ok) {
        if (ok) {
            return next()
        }

        probeFile(req, '.webp', function(ok) {
            next()
        })
    })
} else {
    probeFile(req, '.webp', function(ok) {
        next()
    })
}

Final middleware

After all our changes, here is the final middleware to serve .avif or .webp images on the fly:

const path = require('path')
const url = require('url')
const fs = require('fs')

module.exports = function(folder) {
    const extensions = ['.jpg', '.jpeg', '.png']

    const probeFile = function(req, extension, callback) {
        const pathname = url.parse(req.url).pathname
        const check_path = path.join(folder, pathname + extension)
        const new_path = pathname + extension

        fs.stat(check_path, function(err, stats) {
            if (err) {
                callback(false)
            } else if (stats.isFile()) {
                req.url = req.url.replace(pathname, new_path)
                callback(true)
            } else {
                callback(false)
            }
        })
    }

    return function(req, res, next) {
        const method = req.method.toUpperCase()
        if (method !== 'GET' && method !== 'HEAD') {
            return next()
        }

        if (!req.headers.accept) {
            return next()
        }
        
        const canwebp = (req.headers.accept.indexOf('image/webp') !== -1)
        const canavif = (req.headers.accept.indexOf('image/avif') !== -1)

        if (!canwebp && !canavif) {
            return next()
        }

        const ext = path.extname(req.url)
        if (extensions.indexOf(ext) === -1) {
            return next()
        }

        if (canavif) {
            probeFile(req, '.avif', function(ok) {
                if (ok) {
                    return next()
                }

                probeFile(req, '.webp', function(ok) {
                    next()
                })
            })
        } else {
            probeFile(req, '.webp', function(ok) {
                next()
            })
        }
    }
}

Did you know that I made an in-browser image converter, where you can convert your images without uploading them anywhere? Try it out and let me know what you think. It's free.

Leave a Comment

Your email address will not be published. Required fields are marked *