Go Fetch 2! (JavaScript Fetch API)

Courtesy of publicdomainpictures.net

Last time we discussed the Fetch API in general, taking a look at how it differed from the XMLHttpRequest API, and some of its advantages. Today, we’re going to take a look at a little library that you can include in your projects today that offers you localStorage caching for the Fetch API.

TL;WR

If you’re anxious to get to the good stuff, you can head straight to the fetchCache Github Repo.

Cache Control

Before we get started, those of you more familiar with the Fetch API might be wondering why we need to use localStorage for caching fetch request responses at all. Isn’t that what the cache property in the fetch settings is for?

Yes and no. The cache setting allows you greater control over the standard HTTP cache, so that you don’t need to use the old trick of appending a datetime string to your request for ‘cache-busting’ purposes, as you might have done in the past with XHR. Instead, you can simply specify cache:'no-store' or cache:'no-cache' or cache:'reload', depending on your use case.

You can also bypass a network request (potentially useful for offline usage) and pull from the HTTP cache via cache:'force-cache', which appears to accomplish one of the goals of caching in localStorage.

However, that’s as far as our control over the cache goes. We can’t specify how long we want items to remain in the cache, or what requests line up with which contents of the cache, or validate the contents of the cache before returning them. For that, we need to wrap fetch with a bit of custom logic to allow us to handle the caching ourselves.

Looks Familiar

Just before we dive in, you may find that the API I’m about to present looks familiar – maybe because you’ve used JALC, the plugin for similar caching with jQuery.ajax. The API similarity is most definitely on purpose, and you should feel comfortable moving from one to the other.

The Breakdown

So, let’s take a look at the code, and discuss what’s happening.

For those of you who’ve used JALC, much of this will look familiar, and you can reference this previous breakdown of the core parts of JALC for more details, so we’ll skip over those. Let’s take a look at our cacheResponse and provideResponse functions, as they’re the juicy bits.

   /**
    * Cache the response into our storage object.
    * We clone the response so that we can drain the stream without making it
    * unavailable to future handlers.
    *
    * @param {string} cacheKey Key under which to cache the data string. Bound in
    * fetch override.
    * @param {Storage} storage Object implementing Storage interface to store cached data
    * (text or json exclusively) in. Bound in fetch override.
    * @param {Number} hourstl Number of hours this value shoud remain in the cache.
    * Bound in fetch override.
    * @param {Response} response
    */
function cacheResponse(cacheKey, storage, hourstl, response) {
    var cres = response.clone(),
        dataType = (response.headers.get('Content-Type') || 'text/plain').toLowerCase();

    cres.text().then((text) => {
        try {
            storage.setItem(cacheKey, text);
            storage.setItem(cacheKey + 'cachettl', +new Date() + 1000 * 60 * 60 * hourstl);
            storage.setItem(cacheKey + 'dataType', dataType);
        } catch (e) {
            // Remove any incomplete data that may have been saved before the exception was caught
            removeFromStorage(storage, cacheKey);
            console.log('Cache Error: ' + e, cacheKey, text);
        }
    });

    return response;
}

The interesting bit here is you’ll see we clone the response. This is so we can ‘drain’ it with the text reader and store the results, without making the response unavailable to future promise handler blocks. By cloning the response, we get a buffered clone of the response value that we can do with as we wish, and we can pass the original response on to be handled however the future then blocks wish to.

   /**
    * Create a new response containing the cached value, and return a promise
    * that resolves with this response.
    *
    * @param value
    * @param dataType
    * @returns {Promise}
    */
function provideResponse(value, dataType) {
    var response = new Response(
        value,
        {
            status: 200,
            statusText: 'success',
            headers: {
                'Content-Type': dataType
            }
        }
    );

    return new Promise(function (resolve, reject) {
        resolve(response);
    });
}

Here’s the last really interesting bit, and it demonstrates the advantage of having access to the Response primitive – we can create our own streamable response by simply feeding in the value to the Response constructor, and return a new Promise as the fetch API expects immediately resolving with our response.

All Together Now

Here’s the full code as of the time of this writing, and I recommend taking a look at the github repo for the up-to-date version. As always, let me know if you have any questions in the comments.

/**
 * Copyright (c) Christopher Keefer, 2016.
 * https://github.com/SaneMethod/fetchCache
 *
 * Override fetch in the global context to allow us to cache the response to fetch in a Storage interface
 * implementing object (such as localStorage).
 */
(function (fetch) {
    /* If the context doesn't support fetch, we won't attempt to patch in our
     caching using fetch, for obvious reasons. */
    if (!fetch) return;

    /**
     * Generate the cache key under which to store the local data - either the cache key supplied,
     * or one generated from the url, the Content-type header (if specified) and the body (if specified).
     *
     * @returns {string}
     */
    function genCacheKey(url, settings) {
        var {headers:{'Content-type': type}} = ('headers' in settings) ? settings : {headers: {}},
            {body} = settings;

        return settings.cacheKey || url + (type || '') + (body || '');
    }

    /**
     * Determine whether we're using localStorage or, if the user has specified something other than a boolean
     * value for options.localCache, whether the value appears to satisfy the plugin's requirements.
     * Otherwise, throw a new TypeError indicating what type of value we expect.
     *
     * @param {boolean|object} storage
     * @returns {boolean|object}
     */
    function getStorage(storage) {
        if (!storage) return false;
        if (storage === true) return self.localStorage;
        if (typeof storage === "object" && 'getItem' in storage &&
            'removeItem' in storage && 'setItem' in storage) {
            return storage;
        }
        throw new TypeError("localCache must either be a boolean value, " +
            "or an object which implements the Storage interface.");
    }

    /**
     * Remove the item specified by cacheKey and its attendant meta items from storage.
     *
     * @param {Storage|object} storage
     * @param {string} cacheKey
     */
    function removeFromStorage(storage, cacheKey) {
        storage.removeItem(cacheKey);
        storage.removeItem(cacheKey + 'cachettl');
        storage.removeItem(cacheKey + 'dataType');
    }

    /**
     * Cache the response into our storage object.
     * We clone the response so that we can drain the stream without making it
     * unavailable to future handlers.
     *
     * @param {string} cacheKey Key under which to cache the data string. Bound in
     * fetch override.
     * @param {Storage} storage Object implementing Storage interface to store cached data
     * (text or json exclusively) in. Bound in fetch override.
     * @param {Number} hourstl Number of hours this value shoud remain in the cache.
     * Bound in fetch override.
     * @param {Response} response
     */
    function cacheResponse(cacheKey, storage, hourstl, response) {
        var cres = response.clone(),
            dataType = (response.headers.get('Content-Type') || 'text/plain').toLowerCase();

        cres.text().then((text) => {
            try {
                storage.setItem(cacheKey, text);
                storage.setItem(cacheKey + 'cachettl', +new Date() + 1000 * 60 * 60 * hourstl);
                storage.setItem(cacheKey + 'dataType', dataType);
            } catch (e) {
                // Remove any incomplete data that may have been saved before the exception was caught
                removeFromStorage(storage, cacheKey);
                console.log('Cache Error: ' + e, cacheKey, text);
            }
        });

        return response;
    }

    /**
     * Create a new response containing the cached value, and return a promise
     * that resolves with this response.
     *
     * @param value
     * @param dataType
     * @returns {Promise}
     */
    function provideResponse(value, dataType) {
        var response = new Response(
            value,
            {
                status: 200,
                statusText: 'success',
                headers: {
                    'Content-Type': dataType
                }
            }
        );

        return new Promise(function (resolve, reject) {
            resolve(response);
        });
    }

    /**
     * Override fetch on the global context, so that we can intercept
     * fetch calls and respond with locally cached content, if available.
     * New parameters available on the call to fetch:
     * localCache   : true // required - either a boolean (if true, localStorage is used,
     * if false request is not cached or returned from cache), or an object implementing the
     * Storage interface, in which case that object is used instead.
     * cacheTTL     : 5, // optional, cache time in hours, default is 5. Use float numbers for
     * values less than a full hour (e.g. 0.5 for 1/2 hour).
     * cacheKey     : 'post', // optional - key under which cached string will be stored.
     * isCacheValid : function  // optional - return true for valid, false for invalid.
     */
    self.fetch = function (url, settings) {
        var storage = getStorage(settings.localCache),
            hourstl = settings.cacheTTL || 5,
            cacheKey = genCacheKey(url, settings),
            cacheValid = settings.isCacheValid,
            ttl,
            value,
            dataType;

        if (!storage) return fetch(url, settings);

        ttl = storage.getItem(cacheKey + 'cachettl');

        if (cacheValid && typeof cacheValid === 'function' && !cacheValid()) {
            removeFromStorage(storage, cacheKey);
            ttl = 0;
        }

        if (ttl && ttl < +new Date()) {
            removeFromStorage(storage, cacheKey);
        }

        value = storage.getItem(cacheKey);

        if (!value) {
            /* If not cached, we'll make the request and add a then block to the resulting promise,
             in which we'll cache the result. */
            return fetch(url, settings).then(cacheResponse.bind(null, cacheKey, storage, hourstl));
        }

        /* Value is cached, so we'll simply create and respond with a promise of our own,
         and provide a response object. */
        dataType = storage.getItem(cacheKey + 'dataType') || 'text/plain';
        return provideResponse(value, dataType);
    };
})(self.fetch);

Image Courtesy of publicdomainpictures.net

Christopher Keefer

Christopher Keefer

Christopher Keefer is a Senior Software Engineer at Art+Logic. He generally spends his spare time on the computer too, so there isn't much hope for him.
Christopher Keefer

Latest posts by Christopher Keefer (see all)

Tags:

Creative Commons License

This work is licensed under a Creative Commons Attribution-NonCommercial-NoDerivatives 4.0 International License.