So far, we've learned how to intercept requests and either return or even enhance the response from our local system. Now, we will learn how to save requests when we are in offline mode and then send the calls to the server once we appear online.
Let's go ahead and set up a new folder for just this. Follow these steps:
- Create a folder called offline_storage and add the following files to it:
- index.html
- main.css
- interactions.js
- OfflineServiceWorker.js
- Add the following boilerplate code to index.html:
<!DOCTYPE html>
<html>
<head><!-- add css file --></head>
<body>
<h1>Offline Storage</h1>
<button id="makeRequest">Request</button>
<table>
<tbody id="body"></tbody>
</table>
<p>Are we online?: <span id="online">No</span>
<script src="interactions.js"></script>
<script>
let online = false;
const onlineNotification =
document.querySelector('#online');
window.addEventListener('load', function() {
const changeOnlineNotification = function(status) {
onlineNotification.textContent = status ? "Yes"
: "No";
online = status;
}
changeOnlineNotification(navigator.onLine);
navigator.serviceWorker.register('.
/OfflineCacheWorker.js', {scope : '/'})
window.addEventListener('online', () => {
changeOnlineNotification(navigator.onLine) });
window.addEventListener('offline', () => {
changeOnlineNotification(navigator.onLine) });
});
</script>
</body>
</html>
- Add the following boilerplate code to OfflineServiceWorker.js:
self.addEventListener('install', (event) => {
event.waitUntil(
// normal cache opening
);
});
self.addEventListener('fetch', (event) => {
event.respondWith(
caches.match(event.request).then((response) => {
// normal response handling
})
)
});
- Finally, add the following boilerplate code to interactions.js:
const requestMaker = document.querySelector('#makeRequest');
const tableBody = document.querySelector('#body');
requestMaker.addEventListener('click', (ev) => {
fetch('/request').then((res) => res.json()).then((fin) => {
const row = document.createElement('tr');
row.innerHTML = `
<td>${fin.id}</td>
<td>${fin.name}</td>
<td>${fin.phone}</td>
<td><button id=${fin.id}>Delete</button></td>
`
row.querySelector('button').addEventListener('click', (ev)
=> {
fetch(`/delete/${ev.target.id}`).then(() => {
tableBody.removeChild(row);
});
});
tableBody.appendChild(row);
})
})
With all of this code in place, let's go ahead and change our Node.js server so that it points to this new folder location. We'll do this by stopping our old server and changing the app.js file so that it points to our offline_storage folder:
const server = http.createServer((req, res) => {
return handler(req, res, {
public : 'offline_storage'
});
});
With this, we can rerun our server by running node app.js. We may experience our old page showing up. If this is the case, we can go to the Application tab in our developer tools and click the Unregister option under the Service workers section. Once we reload the page, we should see the new index.html page show up. Our handlers aren't working at the moment, so let's add some stub code inside of our ServiceWorker that will handle the two fetch cases that we added in interactions.js. Follow these steps to do so:
- Add the following support inside the fetch event handler for the request:
caches.match(event.request).then((response) => {
if( event.request.url.includes('/request') ) {
return handleRequest();
}
})
// below in the global scope of the ServiceWorker
let counter = 0;
let name = 65;
const handleRequest = function() {
const data = {
id : counter,
name : String.fromCharCode(name),
phone : Math.round(Math.random() * 10000)
}
counter += 1;
name += 1;
return new Response(new Blob([JSON.stringify(data)], {type :
'application/json'}), {status : 200});
}
- Let's make sure this code handles the response correctly by making sure that it adds a row to our table. Reload the page and make sure a new row is added when we click the Request button:
- Now that we have made sure that that handler is working, let's go ahead and add the other handler for our delete request. We will mimic a delete for a database on the server in our ServiceWorker:
caches.match(event.request).then((response) => {
if( event.request.url.includes('/delete') ) {
return handleDelete(event.request.url);
}
})
// place in the global scope of the Service Worker
const handleDelete = function(url) {
const id = url.split("/")[2];
return new Response(new Blob([id], {type : 'text/plain'}),
{status : 200});
}
- With this, let's go ahead and test it to make sure that our rows are deleting when we click the Delete button. If all of this is working, we will have a functioning application that can work online or offline.
Now, all we need to do is add support for requests that are going to go out but can't because we are currently offline. To do this, we will store requests in an array, and once we detect that we are back online in our ServiceWorker, we will send all the requests out. We will also add some support to let our frontend know that we are waiting on so many requests and that if we want, we can cancel them out. Let's add this now:
In Chrome, switching from offline to online will trigger our online handler, but switching from online to offline doesn't seem to trigger the event. We can test the offline-to-online system functionality, but testing the other way around can be a bit more difficult. Just note that this limitation could be in place on many development systems and that trying to account for this can be quite difficult.
- First, move most of our caches.match code to a standalone function, like so:
caches.match(event.request).then((response) => {
if( response ) {
return response
}
return actualRequestHandler(event);
})
- Code the standalone function, as follows:
const actualRequestHandler = function(req) {
if( req.request.url.includes('/request') ) {
return handleRequest();
}
if( req.request.url.includes('/delete') ) {
return handleDelete(req.request.url);
}
return fetch(req.request);
}
- We will handle requests by polling them to see if we are back online. Set up a poll timer that will work every 30 seconds and change our caches.match handler like so:
const pollTime = 30000;
self.addEventListener('fetch', (event) => {
event.respondWith(
caches.match(event.request).then((response) => {
if( response ) {
return response
}
if(!navigator.onLine ) {
return new Promise((resolve, reject) => {
const interval = setInterval(() => {
if( navigator.onLine ) {
clearInterval(interval);
resolve(actualRequestHandler(event));
}
}, pollTime)
})
} else {
return actualRequestHandler(event);
}
})
)
});
What we have just done is set up a return for a promise. If we can't see the system online, we will keep polling every 30 seconds to see if we are back online. Once we are back online, our promise will clear the interval and actually handle the request in the resolve handler. We could set a system of so many attempts before we cancel the request. All we would have to do is add a reject handler after so many times through the interval.
Finally, we will add a way to stop all currently outstanding requests. To do this, we will need a way of keeping track of whether we have requests outstanding and a way to abort them in the ServiceWorker. This will be quite simple since we can easily keep track of what is still pending in the frontend. We can add this by doing the following:
- First, we will add a display that shows how many outstanding requests we have in the frontend. We will put this right after our online status system:
// inside of our index.html
<p>Oustanding requests: <span id="outstanding">0</span></p>
//inside our interactions.js
const requestAmount = document.querySelector('#outstanding');
let numRequests = 0;
requestMaker.addEventListener('click', (ev) => {
numRequests += 1;
requestAmount.textContent = numRequests;
fetch('/request').then((res) => res.json()).then((fin) => {
// our previous fetch handler
numRequests -= 1;
requestAmount.textContent = numRequests;
});
// can be setup for delete requests also
});
- Add a button that will cancel all outstanding requests to our index.html file. Also, add the corresponding JavaScript code to our interactions.js file:
//index.html
<button id="stop">Stop all Pending</button>
//interactions.js
const stopRequests = document.querySelector('#stop');
stopRequests.addEventListener('click', (ev) => {
fetch('/stop').then((res) => {
numRequests = 0;
requestAmount.textContent = numRequests;
});
});
- Add the corresponding handler to our ServiceWorker for the stop request:
caches.match(event.request).then((response) => {
if( response ) {
return response
}
if( event.request.url.includes('/stop') ) {
controller.abort();
return new Response(new Blob(["all done"], {type :
'text/plain'}), {status : 200});
}
// our previous handler code
})
Now, we will utilize something called an AbortController. This system allows us to send signals to things such as fetch requests so that we can say that we want to stop the pending request. While this system is mainly for stopping fetch requests, we can actually utilize the signal in order to stop any asynchronous requests. We do this by creating an AbortController and grabbing the signal from it. Then, inside of our promise, we listen for the abort event on the signal and reject the promise.
- Add the AbortController, as follows:
const controller = new AbortController();
const signal = controller.signal;
const pollTime = 30000;
self.addEventListener('fetch', (event) => {
event.respondWith(
caches.match(event.request).then((response) => {
if( response ) {
return response
}
if( event.request.url.includes('/stop') ) {
controller.abort();
return new Response(new Blob(["all done"], {type :
'text/plain'}), {status : 200});
}
if(!navigator.onLine ) {
return new Promise((resolve, reject) => {
const interval = setInterval(() => {
if( navigator.onLine ) {
clearInterval(interval);
resolve(actualRequestHandler(event));
}
}, pollTime)
signal.addEventListener('abort', () => {
reject('aborted');
})
});
} else {
return actualRequestHandler(event);
}
})
)
});
Now, if we go into our system and ready up some requests in offline mode and then click the Cancel button, we will see that all of our requests get canceled! We could have put the AbortController on the fetch request in our frontend interactions.js file, but once we go back online, all of the promises would have still run, so we wanted to make sure that nothing was running. This is why we put it in the ServiceWorker.
By doing this, we have seen how we can not only handle requests by caching data for them but that we can also store those requests when we have spotty locations. On top of this, we have seen how we can utilize the AbortController to stop pending promises and how to utilize them besides just stopping fetch requests.