Views: 581
Number of votes: 8
Average rating:

PWA - Making your Episerver Commerce site available offline

A lot of you would have heard of Progressive Web Apps (PWA) and the benefits for building this type of user experience that has been coined and promoted by Google. Without going too much detail about why you should upgrade your standard web app into a progressive web application, which is covered in the above documentation from Google, in a nutshell a PWA means your web application is

  • Reliable : Even in flaky networks, your website will load instantly
  • Fast : Website responds instantaneously with silky smooth animations and no janky scrolling
  • Engaging : Feels like a natural app, which you can launch from home screen and can receive push notification

Following the above, one key strategy to take on and consider when building a PWA is caching. And according to Google, there are no hard and fast requirements around what to cache. Following the terminology itself, it can be useful to think of our caching and offline strategy progressively, following a series of milestones. The first step we can take is to add a simple service worker and cache static assets, such as stylesheets and images, so these can be quickly loaded on repeat visits. Each milestone allows us to deploy separately for it, measure the performance gains with every step taken, and progressively roll out a better PWA.

Recently my team got the chance to build a proof of concept for one of our retail clients who have an Episerver Commerce website that they wanted to be upgraded to a progressive web application. In this PoC, we started by enabling offline viewing for the website, allowing the online shoppers to navigate to the site with seamless experience regardless of network conditions even those without Internet at all. Unfortunately, this Episerver Commerce site is rendered from the server, which means we can only cache the pages the customers would have visited, as opposed to Single Page App (SPA) where you typically can cache all page templates as soon as customers hit the site for the first time. In this post, I'd like to share the first steps we've taken to make a PWA out of this existing website.

Below are the offline cache strategies we have applied:

  • Network First (Network Falling Back to Cache) - This strategy is applied on 
    • All server-rendering pages including product listing page, product details page, cart page, etc
    • All API's consumed for retrieving data to ensure responses are retrieved as quickly as possible. Cached responses will then subsequently be returned, falling back to a network request if not cached. As expected, the network request will the updated the cache.
  • Network Only - This strategy is applied on the Checkout API as we don't want to apply any cache to the checkout process
  • Stale-While-Revalidate - This strategy is applied on
    • Global assets, Site resources and links
    • 3rd party script and css references

For more details about the cache strategies, please refer to the following articles

Now, let's look at the implementation.

With the current frontend build based on Angular JS 1.x, where it's build relies on Node and Gulp toolkit, we started our implementation using the WorkBox module, a set of libraries and Node modules that makes it easy to cache assets and take full advantage of the features used to build Progressive Web Apps.

First, let's declare workbox module in the package.json file

{
  ...
  "devDependencies": {
    ...,
    "workbox-build": "^4.1.1"
  },
  ...
}


Then in the gulpfile.js, we can simply define a task for generating the ServiceWorker "sw.js", like this

	var gulp = require('gulp'),
			...
			workboxBuild = require('workbox-build');

	...

	gulp.task('buildSw', buildSw);
		
	function buildSw() {
	  return workboxBuild.injectManifest({
		swSrc: 'src/sw.js',
		swDest: '../CMS/sw.js',
		globDirectory: '../CMS/',
		globPatterns: [
		  'site.resource/js/*.js',
		  'site.resource/css/*.css',
		  'site.resource/fonts/*'
		]
	  }).catch(err => {
		console.log('Uh oh ??', err);
	  });
	}


Quick explanation on the couple of items mentioned in the above script:

  • "swSrc" is the ServiceWorker definition where we define all the cache strategies
  • "swDest" is the destination in which we'd like to generate the output ServiceWorker file to. And in our case is the root folder of CMS.

Below is what the source "sw.js" looks like

/*
Copyright 2018 Google Inc.

Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at

    http://www.apache.org/licenses/LICENSE-2.0

Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/

importScripts('https://storage.googleapis.com/workbox-cdn/releases/3.1.0/workbox-sw.js');

workbox.setConfig({ debug: false });
workbox.skipWaiting();
workbox.clientsClaim();

workbox.precaching.precacheAndRoute([]);

// Cache view
workbox.routing.registerRoute(
  new RegExp('/$'),
  new workbox.strategies.NetworkFirst({
    cacheName: 'pages',
    plugins: [
      new workbox.cacheableResponse.Plugin({statuses: [200]}),
      new workbox.expiration.Plugin({
        maxEntries: 100,
        maxAgeSeconds: 7 * 24 * 60 * 60
      })
    ]
  })
);

// Cache api
workbox.routing.registerRoute(
  new RegExp('/api/checkout/'),
  new workbox.strategies.NetworkOnly({
    cacheName: 'view-checkout',
    plugins: [
      new workbox.cacheableResponse.Plugin({statuses: [200]}),
      new workbox.expiration.Plugin({
        maxEntries: 1,
        maxAgeSeconds: 7 * 24 * 60 * 60
      })
    ]
  })
);

workbox.routing.registerRoute(
  new RegExp('/api/(?!checkout).+/.*'),
  new workbox.strategies.NetworkFirst({
    cacheName: 'api-pages',
    plugins: [
      new workbox.cacheableResponse.Plugin({statuses: [200]}),
      new workbox.expiration.Plugin({
        maxEntries: 50,
        maxAgeSeconds: 7 * 24 * 60 * 60
      })
    ]
  })
);

workbox.routing.registerRoute(
  new RegExp('\.(?:png|gif|jpg|jpeg|svg)$'),
  new workbox.strategies.StaleWhileRevalidate({
    cacheName: 'images',
    plugins: [
      new workbox.cacheableResponse.Plugin({statuses: [0,200]}),
      new workbox.expiration.Plugin({
        maxEntries: 1000,
        maxAgeSeconds: 30 * 24 * 60 * 60, // 30 Days
      }),
    ],
  })
);

// Cache global assets
workbox.routing.registerRoute(
  new RegExp('/(globalassets|site\.resource)/'),
  new workbox.strategies.StaleWhileRevalidate({
    cacheName: 'assets-global',
    plugins: [
      new workbox.cacheableResponse.Plugin({statuses: [200]}),
      new workbox.expiration.Plugin({
        maxEntries: 500,
        maxAgeSeconds: 7 * 24 * 60 * 60
      })
    ]
  })
);

// Cache third party libraries
workbox.routing.registerRoute(
  new RegExp('^(http|https)://([a-zA-Z0-9]+\.){1,4}(com|net|io|){1}/'),
  new workbox.strategies.StaleWhileRevalidate({
    cacheName: '3rd-party',
    plugins: [
      new workbox.cacheableResponse.Plugin({statuses: [200]}),
      new workbox.expiration.Plugin({
        maxEntries: 100,
        maxAgeSeconds: 7 * 24 * 60 * 60
      })
    ]
  })
);




Next, you can simply run the "buildSw" task in Command Prompt

gulp "buildSw"

Finally, let's put all the ServiceWorker and Manifest together into the layout of our web app. Here we created 2 different partial shared views to achieve that

1. "_PWA_Manifest.cshtml" which will then be included into the <head> tag

<meta name="theme-color" content="#808285" />
<link rel="manifest" href="/manifest.json">    
<link rel="apple-touch-icon" href="/site.resource/images/icon_180.png">

and as you can see we will need to create some app icons declared using "apple-touch-icon" or a "manifest.json" file

"manifest.json" should look like this

{
  "short_name": "YOUR_APPLICATION_NAME",
  "name": "YOUR_APPLICATION_NAME",
  "icons": [
    {
      "src": "/site.resource/images/icon_192.png",
      "type": "image/png",
      "sizes": "192x192"
    },
    {
      "src": "/site.resource/images/icon_512.png",
      "type": "image/png",
      "sizes": "512x512"
    }
  ],
  "start_url": "/",
  "background_color": "#17b29e",
  "display": "standalone",
  "scope": "/",
  "theme_color": "#808285"
}


2. "_PWA.cshtml" for the ServiceWorker which will then be placed right before the body closing tag </body>

	<script>
        // Check to make sure service workers are supported in the current browser,
        // and that the current page is accessed from a secure origin. Using a
        // service worker from an insecure origin will trigger JS console errors. See
        // http://www.chromium.org/Home/chromium-security/prefer-secure-origins-for-powerful-new-features
        var isLocalhost = Boolean(window.location.hostname === 'localhost' ||
            // [::1] is the IPv6 localhost address.
            window.location.hostname === '[::1]' ||
            // 127.0.0.1/8 is considered localhost for IPv4.
            window.location.hostname.match(
                /^127(?:\.(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)){3}$/
            ) ||
            window.location.hostname.indexOf('localhost') >= 0
        );

        // Register the service worker
        if ('serviceWorker' in navigator && (window.location.protocol === 'https:' || isLocalhost)) {
            window.addEventListener('load', function(){
                navigator.serviceWorker.register('/sw.js')
                    .then(function(registration){
                        // updatefound is fired if service-worker.js changes.
                        registration.onupdatefound = function(){
                            // updatefound is also fired the very first time the SW is installed,
                            // and there's no need to prompt for a reload at that point.
                            // So check here to see if the page is already controlled,
                            // i.e. whether there's an existing service worker.
                            if (navigator.serviceWorker.controller) {
                                // The updatefound event implies that registration.installing is set:
                                // https://slightlyoff.github.io/ServiceWorker/spec/service_worker/index.html#service-worker-container-updatefound-event
                                var installingWorker = registration.installing;

                                installingWorker.onstatechange = function(){
                                    switch (installingWorker.state) {
                                        case 'installed':
                                            // At this point, the fresh content will have been added to
                                            // the cache. It's the perfect time to display a "New content
                                            // is available; please refresh." message in the page's
                                            // interface.
                                            break;

                                        case 'redundant':
                                            throw new Error('The installing service worker became redundant.');
                                        default:
                                        // Ignore
                                    }
                                };
                            }
                        };
                    }).catch(function(e){
                        console.error('Error during service worker registration:', e);
                    });
            });
        }
	</script>

Ta-da! We've now taken the first step into building a PWA out of an existing Episerver web site. We've just enabled offline viewing for visited pages on the website. Please note this doesn't yet support offline checkouts and will be progressively (pun intended) improved to make a full-blown PWA out of this. 

Oct 14, 2019

Nicola Ayan
(By Nicola Ayan, 10/14/2019 11:43:05 AM)

Great article, Tien :)

Tien Quach
(By Tien Quach, 10/14/2019 11:53:31 AM)

Thanks heaps, Nicola! :)

Marcus B
(By Marcus B, 10/15/2019 12:52:16 AM)

Agreed. Thanks for the share

Darren S
(By Darren S, 10/19/2019 4:43:25 AM)

This looks really interesting thanks sharing it @Tien

Please login to comment.