-
-
Save ankit-kumar-jat/57e63dea66796ddbf66087f3beb9a88b to your computer and use it in GitHub Desktop.
Full-fledged service worker which caches API requests and responses using IndexedDB
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
const version = 1; | |
const cacheName = `our_awesome_cache` | |
// the static files we want to cache | |
const staticFiles = [ | |
'index.html', | |
'src/css/styles.css', | |
'src/img/logo.png', | |
'src/js/app.js' | |
]; | |
// here we configure IndexedDB with two stores: one for api requests and one for api responses | |
// 'keyPath' is the key we use to retrieve the request or response, in this case it's the url | |
// of the request or response | |
const IDBConfig = { | |
name: 'my_awesome_idb', | |
version, | |
stores: [ | |
{ | |
name: 'api_requests', | |
keyPath: 'url' | |
}, | |
{ | |
name: 'api_responses', | |
keyPath: 'url' | |
} | |
] | |
}; | |
// here we create our IndexedDB database | |
const createIndexedDB = ({name, version, stores}) => { | |
const request = self.indexedDB.open(name, version); | |
return new Promise((resolve, reject) => { | |
request.onupgradeneeded = e => { | |
const db = e.target.result; | |
// here we loop over our stores (one for api requests, one for api responses) | |
// we check if they already exist. If not, they are created | |
stores.map(({name, keyPath}) => { | |
if(!db.objectStoreNames.contains(name)) { | |
db.createObjectStore(name, {keyPath}); | |
} | |
}); | |
}; | |
request.onsuccess = () => resolve(request.result); | |
request.onerror = () => reject(request.error); | |
}); | |
}; | |
// here we prefetch the api call and cache the response so we can serve it later | |
const prefetch = async url => { | |
const response = await fetch(url); | |
// since a response can only be used once, we clone it to store it and then serve the original response | |
const clone = response.clone(); | |
const json = await clone.json(); | |
cacheApiResponse({url, json}); | |
return response.json(); | |
}; | |
// factory function to get the correct store | |
// we pass it the name and version of the IndexedDB database name and version and it returns | |
// a function that we pass the name of the correct store (api requests or api responses) | |
const getStoreFactory = (dbName, version) => ({name}, mode = 'readonly') => { | |
return new Promise((resolve, reject) => { | |
const request = self.indexedDB.open(dbName, version); | |
request.onsuccess = e => { | |
const db = request.result; | |
const transaction = db.transaction(name, mode); | |
const store = transaction.objectStore(name); | |
return resolve(store); | |
}; | |
request.onerror = e => reject(request.error); | |
}); | |
}; | |
// function to open the correct store, uses the factory function | |
const openStore = getStoreFactory(IDBConfig.name, version); | |
// function that caches a POST request made to our api so we can retry it when we're back online | |
const cacheApiRequest = async request => { | |
const headers = [...request.headers.entries()].reduce((obj, [key, value]) => Object.assign(obj, {[`${key}`]: value}), {}); | |
const body = await request.text(); | |
const serialized = { | |
headers, | |
body, | |
url: request.url, | |
method: request.method, | |
mode: request.mode, | |
credentials: request.credentials, | |
cache: request.cache, | |
redirect: request.redirect, | |
referrer: request.referrer | |
}; | |
const requestStore = await openStore(IDBConfig.stores[0], 'readwrite'); | |
requestStore.add(serialized); | |
}; | |
// here we cache the api responses so we can use them later if needed when offline | |
const cacheApiResponse = async response => { | |
try { | |
const store = await openStore(IDBConfig.stores[1], 'readwrite'); | |
store.add(response); | |
} | |
catch(error) { | |
console.log('idb error', error); | |
} | |
}; | |
// here we return a cached api response whenever our api is not available | |
const getCachedApiResponse = request => { | |
return new Promise((resolve, reject) => { | |
openStore(IDBConfig.stores[1]) | |
.then(store => { | |
const cachedRequest = store.get(request.url); | |
// when there's no error the 'onsuccess' handler is called, but that doesn't mean we have a response in our cache | |
// we still have to check if the result is not 'undefined' | |
cachedRequest.onsuccess = e => { | |
return cachedRequest.result === undefined ? resolve(null) : resolve(new Response(JSON.stringify(cachedRequest.result.json))); | |
}; | |
cachedRequest.onerror = e => { | |
console.log('cached response error', e, cachedRequest.error); | |
return reject(cachedRequest.error); | |
}; | |
}); | |
}); | |
}; | |
// here we make a call to the api and then cache the response for later use | |
const networkThenCache = async request => { | |
const {method, url} = request; | |
const requestClone = request.clone(); | |
try { | |
const response = await fetch(request); | |
const json = await response.clone().json(); | |
if(method === 'GET') { | |
cacheApiResponse({url, json}); | |
} | |
return response; | |
} | |
// if an error occured and it was a POST call, we cache the api request so we can retry it later | |
// otherwise we serve a fallback response since there is no response in the cache and the network is not available | |
catch(e) { | |
return method === 'POST' ? cacheApiRequest(requestClone) : new Response(JSON.stringify({message: 'no response'})); | |
} | |
}; | |
// here we try to serve a cached api response and if it doesn't exist we try the network | |
const getCachedOrNetworkApiResponse = async request => await getCachedApiResponse(request) || networkThenCache(request); | |
// here we retry any cached api requests that were stored earlier when the network was not available | |
// this is typically called when the client comes back online and the 'sync' event is fired | |
const retryApiCalls = () => { | |
return new Promise((resolve, reject) => { | |
openStore(IDBConfig.stores[0]) | |
.then(store => { | |
const cursor = store.openCursor(); | |
cursor.onsuccess = e => { | |
const cursor = e.target.result; | |
if(cursor) { | |
fetch(new Request(cursor.value.url, cursor.value)) | |
.then(() => resolve(true)); | |
cursor.continue(); | |
} | |
}; | |
}); | |
}); | |
}; | |
// when our service worker is installed we populate the cache with our static assets | |
const installHandler = e => { | |
e.waitUntil( | |
caches.open(cacheName) | |
.then(cache => cache.addAll(staticFiles)) | |
); | |
}; | |
const activateHandler = e => { | |
if(self.indexedDB) { | |
createIndexedDB(IDBConfig); | |
} | |
e.waitUntil(async function() { | |
const postings = [ | |
'/api/blog/1', | |
'/api/blog/12', | |
'/api/blog/7', | |
]; | |
await Promise.all(postings.map(url => prefetch(url))); | |
}()); | |
}; | |
// our fetch handler which is called on every outgoing request | |
const fetchHandler = async e => { | |
const {request} = e; | |
const {url} = request; | |
const {pathname} = new URL(url); | |
// if there's a call to our api, we try to serve a cached response, otherwise we call the api | |
// and cache the response for later use | |
if(url.includes('/api')) { | |
e.respondWith(getCachedOrNetworkApiResponse(request)); | |
} | |
// if it's a request for a static asset we serve it from the cache and when it's not cached we | |
// fetch it from the network | |
else { | |
e.respondWith( | |
caches.match(request) | |
.then(response => response ? response : fetch(request)) | |
); | |
} | |
}; | |
// when we were offline and come back online, a 'sync' event is fired | |
// we can now retry any api requests that were cached earlier (if any) | |
const syncHandler = e => { | |
e.waitUntil(retryApiCalls()); | |
}; | |
self.addEventListener('install', installHandler); | |
self.addEventListener('activate', activateHandler); | |
self.addEventListener('fetch', fetchHandler); | |
self.addEventListener('sync', syncHandler); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment