Add PWA to your Hugo site

Adding PWA to your Hugo static site is quite easy.

What is PWA and why should I add it?

Progressive web applications (PWA) are a nice way to give your websites an “app makeover”. Using PWAs your sites are perceived to load faster, are available offline, and in general improve user experience.

PWAs are supported by all web browsers. Using PWAs on single page applications have shown remarkable improvement in site speed and have provided us the ability to continuing to serve static content even when the connectivity is down.

Back to Hugo - although I can see incremental gains, nothing jumped out to make up for a compelling case for PWA. The sites generated by Hugo are static - pages are already generated and waiting to be loaded. Depending on the theme, you may already be seeing the best load times that your site can achieve. So, YMMV.

How do I add PWA to Hugo?

At a minimum a PWA needs a manifest and service worker. We will use a offline-first cache strategy to enable PWA.

Create your icons

PWAs are available offline and can be installed on the phone. This requires icons of different sizes for your website. Just pluck your logo, remove the text if necessary and generate icons of all popular sizes. You can use a service like Favicomatic to make that a one-click process.

Create manifest

Create a new file called manifest.json in your /static folder and add content similar to below

{
  "name": "Techformist",
  "short_name": "techformist",
  "icons": [
    {
      "src": "/appicons/favicon-128.png",
      "sizes": "128x128",
      "type": "image/png"
    },
    {
      "src": "/appicons/apple-touch-icon-144x144.png",
      "sizes": "144x144",
      "type": "image/png"
    },
    {
      "src": "/appicons/apple-touch-icon-152x152.png",
      "sizes": "152x152",
      "type": "image/png"
    },
    {
      "src": "/appicons/favicon-196x196.png",
      "sizes": "196x196",
      "type": "image/png"
    },
    {
      "src": "/appicons/splash.png",
      "sizes": "512x512",
      "type": "image/png"
    }
  ],
  "start_url": "/",
  "display": "standalone",
  "orientation": "portrait",
  "background_color": "#FFFFFF",
  "theme_color": "#FFFFFF"
}
Create service worker

Create a new javascript file sw.js in /static. This script does all the heavy lifting like maintaining cache and enabling your site to be treated like an app.

I just copied the code from the sample service worker script available at [https://github.com/wildhaber/offline-first-sw].

Modify the file and choose to make some or all files available offline - I typically include CSS, common Javascript files and images. See the end of this article for the file used by this blog.

Include manifest in head

Include a link to your manifest in your header.

I use Minimo theme and included the following line in <root>/layouts/partials/head/extra.html. Note that I am not over-writing the file in the theme folder, but overriding it using my own version (as is typical to Hugo themes).

<link rel="manifest" href="/manifest.json">

Include the service worker JS file reference in your footer. For some strange reason, I chose to be different and included that in my sidebar.html.

<script>
  if('serviceWorker' in navigator) {
      navigator.serviceWorker
          .register('/sw.js', { scope: '/' })
          .then(function(registration) {
              //console.log('Service Worker Registered');
          });

      navigator.serviceWorker
          .ready
          .then(function(registration) {
              //console.log('Service Worker Ready');
          });
  }
</script>

See the Results

That is it. Your site automagically is a PWA now.

Check out Chrome developer tools -

A site or an app?

Go to Application tab.

You should see details provided in your manifest including the different icons. You will also see the different offline files you specified in the Cache Storage section.

Lighthouse scores

Go to Audit tab and do a Lighthouse audit for your site.

Mobile Lighthouse score for home page -

lighthouse-score-home-page

Lighthouse score for an article -

lighthouse-score-article

Rejoice.

Conclusion

Adding and setting up PWA to Hugo is trivial. But, I did not clearly make out any significant advantages of enabling PWA on my blog - given that I am not after providing a read-offline feature.

Post Script

sw.js code
const CACHE_VERSION = 1;

const BASE_CACHE_FILES = [
    '/css/custom.css',
    '/js/custom.js',
    '/search/index.json',
    '/manifest.json',
    '/favicon.png',
    '/images/logo.png',
    '/techformist-logo-no-text.png',
    '/assets/css/main.6a060eb7.css',
    '/assets/js/main.67d669ac.js',
    '/assets/js/sidebar.9ea42a6e.js',
    '/assets/js/fuse_search.1ada4bca.js',
];

const OFFLINE_CACHE_FILES = [
    '/images/logo.png',
    '/techformist-logo-no-text.png',
    '/assets/css/main.6a060eb7.css',
    '/assets/js/main.67d669ac.js',
    '/assets/js/sidebar.9ea42a6e.js',
    '/assets/js/fuse_search.1ada4bca.js',
];

const NOT_FOUND_CACHE_FILES = [
    
    '/404.html',
];

const OFFLINE_PAGE = '/offline/index.html';
const NOT_FOUND_PAGE = '/404.html';

const CACHE_VERSIONS = {
    assets: 'assets-v' + CACHE_VERSION,
    content: 'content-v' + CACHE_VERSION,
    offline: 'offline-v' + CACHE_VERSION,
    notFound: '404-v' + CACHE_VERSION,
};

// Define MAX_TTL's in SECONDS for specific file extensions
const MAX_TTL = {
    '/': 3600,
    html: 3600,
    json: 86400,
    js: 86400,
    css: 86400,
};

const CACHE_BLACKLIST = [
    (str) => {
       return !str.startsWith('http://localhost') ;
    },
];

const SUPPORTED_METHODS = [
    'GET',
];

/**
 * isBlackListed
 * @param {string} url
 * @returns {boolean}
 */
function isBlacklisted(url) {
    return (CACHE_BLACKLIST.length > 0) ? !CACHE_BLACKLIST.filter((rule) => {
        if(typeof rule === 'function') {
            return !rule(url);
        } else {
            return false;
        }
    }).length : false
}

/**
 * getFileExtension
 * @param {string} url
 * @returns {string}
 */
function getFileExtension(url) {
    let extension = url.split('.').reverse()[0].split('?')[0];
    return (extension.endsWith('/')) ? '/' : extension;
}

/**
 * getTTL
 * @param {string} url
 */
function getTTL(url) {
    if (typeof url === 'string') {
        let extension = getFileExtension(url);
        if (typeof MAX_TTL[extension] === 'number') {
            return MAX_TTL[extension];
        } else {
            return null;
        }
    } else {
        return null;
    }
}

/**
 * installServiceWorker
 * @returns {Promise}
 */
function installServiceWorker() {
    return Promise.all(
        [
            caches.open(CACHE_VERSIONS.assets)
                .then(
                    (cache) => {
                        return cache.addAll(BASE_CACHE_FILES);
                    }
                ),
            caches.open(CACHE_VERSIONS.offline)
                .then(
                    (cache) => {
                        return cache.addAll(OFFLINE_CACHE_FILES);
                    }
                ),
            caches.open(CACHE_VERSIONS.notFound)
                .then(
                    (cache) => {
                        return cache.addAll(NOT_FOUND_CACHE_FILES);
                    }
                )
        ]
    );
}

/**
 * cleanupLegacyCache
 * @returns {Promise}
 */
function cleanupLegacyCache() {

    let currentCaches = Object.keys(CACHE_VERSIONS)
        .map(
            (key) => {
                return CACHE_VERSIONS[key];
            }
        );

    return new Promise(
        (resolve, reject) => {

            caches.keys()
                .then(
                    (keys) => {
                        return legacyKeys = keys.filter(
                            (key) => {
                                return !~currentCaches.indexOf(key);
                            }
                        );
                    }
                )
                .then(
                    (legacy) => {
                        if (legacy.length) {
                            Promise.all(
                                legacy.map(
                                    (legacyKey) => {
                                        return caches.delete(legacyKey)
                                    }
                                )
                            )
                                .then(
                                    () => {
                                        resolve()
                                    }
                                )
                                .catch(
                                    (err) => {
                                        reject(err);
                                    }
                                );
                        } else {
                            resolve();
                        }
                    }
                )
                .catch(
                    () => {
                        reject();
                    }
                );

        }
    );
}


self.addEventListener(
    'install', event => {
        event.waitUntil(installServiceWorker());
    }
);

// The activate handler takes care of cleaning up old caches.
self.addEventListener(
    'activate', event => {
        event.waitUntil(
            Promise.all(
                [
                    cleanupLegacyCache(),
                ]
            )
                .catch(
                    (err) => {
                        event.skipWaiting();
                    }
                )
        );
    }
);

self.addEventListener(
    'fetch', event => {

        event.respondWith(
            caches.open(CACHE_VERSIONS.content)
                .then(
                    (cache) => {

                        return cache.match(event.request)
                            .then(
                                (response) => {

                                    if (response) {

                                        let headers = response.headers.entries();
                                        let date = null;

                                        for (let pair of headers) {
                                            if (pair[0] === 'date') {
                                                date = new Date(pair[1]);
                                            }
                                        }

                                        if (date) {
                                            let age = parseInt((new Date().getTime() - date.getTime()) / 1000);
                                            let ttl = getTTL(event.request.url);

                                            if (ttl && age > ttl) {

                                                return new Promise(
                                                    (resolve) => {

                                                        return fetch(event.request)
                                                            .then(
                                                                (updatedResponse) => {
                                                                    if (updatedResponse) {
                                                                        cache.put(event.request, updatedResponse.clone());
                                                                        resolve(updatedResponse);
                                                                    } else {
                                                                        resolve(response)
                                                                    }
                                                                }
                                                            )
                                                            .catch(
                                                                () => {
                                                                    resolve(response);
                                                                }
                                                            );

                                                    }
                                                )
                                                    .catch(
                                                        (err) => {
                                                            return response;
                                                        }
                                                    );
                                            } else {
                                                return response;
                                            }

                                        } else {
                                            return response;
                                        }

                                    } else {
                                        return null;
                                    }
                                }
                            )
                            .then(
                                (response) => {
                                    if (response) {
                                        return response;
                                    } else {
                                        return fetch(event.request) 
                                            .then(
                                                (response) => {

                                                    if(response.status < 400) {
                                                        if (~SUPPORTED_METHODS.indexOf(event.request.method) && !isBlacklisted(event.request.url)) {
                                                            cache.put(event.request, response.clone());
                                                        }
                                                        return response;
                                                    } 
                                                    else {
                                                        return caches.open(CACHE_VERSIONS.notFound).then((cache) => {
                                                            return cache.match(NOT_FOUND_PAGE);
                                                        })
                                                    }
                                                }
                                            )
                                            .then((response) => {
                                                if(response) {
                                                    return response;
                                                }
                                            })
                                            .catch(
                                                () => {

                                                    return caches.open(CACHE_VERSIONS.offline)
                                                        .then(
                                                            (offlineCache) => {
                                                                return offlineCache.match(OFFLINE_PAGE)
                                                            }
                                                        )

                                                }
                                            )
                                        
                                    }
                                }
                            )
                            .catch(
                                (error) => {
                                    console.error('  Error in fetch handler:', error);
                                    throw error;
                                }
                            );
                    }
                )
        );

    }
);
comments powered by Disqus