Build Your First

Development Manager

ase@enonic.com

Alan Semenov

Agenda

  • Introduction to the concept of PWA, Service Workers and Web Manifest
  • Introduction to Workbox, Webpack, Routing and Enonic's own CLI and PWA Starter
  • Hands-on: build a PWA on Enonic XP

--- Break ---

--- Break ---

  • Hands-on: build a Progressive Web... Site

--- Lunch ---

Introduction to the concept of Progressive Web Apps,

Service Workers

and

Web Manifest

Mobile (or Native) Apps

Web Apps

NATIVE VS WEB

NATIVE VS WEB

PWA

WTF  IS                ?

PROGRESSIVE

WEB

APPLICATIONS

Progressive Web Apps are:

  • Connectivity independent
  • Progressive
  • Responsive
  • App-like
  • Fresh
  • Re-engageable
  • Safe
  • Discoverable
  • Installable
  • Linkable

Progressive Web Apps

  • Web App Manifest
  • Offline Support with Service Worker
  • App Shell

SERVICE WORKER

SERVICE WORKER

(registration)

if ('serviceWorker' in navigator) {

  // Register a service worker hosted at the root of the
  // site using the default scope.

  navigator.serviceWorker.register('/sw.js').then(function(registration) {

    console.log('Service worker registration succeeded:', registration);

  }).catch(function(error) {

    console.log('Service worker registration failed:', error);
  });

} else {

  console.log('Service workers are not supported.');

}

SERVICE WORKER

(registration)

NETWORK FIRST

CACHE FIRST

CACHE ONLY

APP SHELL (CACHE ONLY)

APP SHELL (CACHE ONLY)

WEB MANIFEST

...is what turns your SITE into an APP

WEB MANIFEST

{
  "name": "PWA Starter for Enonic XP",
  "short_name": "PWA Starter",
  "theme_color": "#FFF",
  "background_color": "#FFF",
  "display": "standalone",
  "start_url": ".?source=web_app_manifest",
  "icons": [
    {
      "src": "precache/icons/icon.png",
      "sizes": "512x512",
      "type": "image/png"
    }
  ]
}
<head>
    <meta charset="utf-8"/>
    <meta http-equiv="X-UA-Compatible" content="IE=edge">
    <meta name="viewport" content="width=device-width, initial-scale=1.0"/>
    <title>My First PWA</title>

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

WEB MANIFEST

Developer Tools (Chrome)

Cmd-Alt-I

Developer Tools

"Application" tab

Developer Tools

Service Workers

Developer Tools

Service Workers

SERVICE WORKER

?

WEB MANIFEST

+

PWA

=

SERVICE WORKER

if (containsDataUrl(e.request.url) || responseUpdate) {
    let url = responseUpdate ? e.request.url.replace("?update=true", "") : e.request.url;
    
    e.respondWith(
      caches.open(responseUpdate ? cacheName : dataCacheName).then(function(cache) {
          consoleLog("Fetching data url " + url);
          return fetch(e.request)
              .then(function (response) {
                  cache.put(url, response.clone());
                  return response;
              })
              .catch(function (ex) {
                  consoleLog('Network is down. Trying to serve from cache...');
                  return cache.match(e.request, {
                      ignoreVary: true
                  })
                  .then(function (response) {
                      consoleLog((response ? 'Serving from cache' : 'No cached response found') + ': ' + e.request.url);
    
                      return response || getFallbackPage(e.request.url);
                  });
              });
      })
    );
  }
  else {
      e.respondWith(
          caches.match(e.request, {
              ignoreVary: true
          })
          .then(function (response) {
              consoleLog((response ? 'Serving from cache' : 'Requesting from the server') + ': ' + e.request.url);

              return response || fetch(e.request);
          })
      );
  }

...IS COMPLICATED

REMEMBER TO CACHE YOUR ASSETS

const filesToCache = [
    offlineUrl,
    offlineCompactUrl,
    '{{siteUrl}}',
    '{{siteUrl}}/',
    '{{assetUrl}}/js/main.js',
    '{{assetUrl}}/js/image.js',
    '{{assetUrl}}/js/material.js',
    '{{assetUrl}}/js/dialog-polyfill.js',
    '{{assetUrl}}/css/main.css',
    '{{assetUrl}}/css/image.css',
    '{{assetUrl}}/css/material.css',
    '{{assetUrl}}/css/dialog-polyfill.css',
    '{{assetUrl}}/img/cancel.svg',
    '{{assetUrl}}/img/download_image.svg',
    '{{assetUrl}}/img/info.svg',
    '{{assetUrl}}/img/pencil.svg',
    '{{assetUrl}}/img/spinner.svg',
    '{{assetUrl}}/img/placeholder.png',
    '{{assetUrl}}/img/noisy-texture.png',
    '//fonts.googleapis.com/icon?family=Material+Icons',
    '//fonts.googleapis.com/css?family=Roboto:regular,bold,italic,thin,light,bolditalic,black,medium&amp;lang=en'
];


self.addEventListener('install', function(e) {
  consoleLog('Install');

  e.waitUntil(self.skipWaiting());

  e.waitUntil(
    caches.open(cacheName).then(function(cache) {
      consoleLog('Caching app shell');
      return cache.addAll(filesToCache);
    }).catch(function(err) {
        console.log(err);
    })
  );
});

ENONIC PWA STARTER

E    NONIC PWA STARTER

DOES ALL THE DIRTY JOB FOR YOU!

E    NONIC PWA STARTER

  • Generates and registers Service Worker

  • Implements Web Manifest

  • Bundles JS files and CSS stylesheets

  • Precaches all assets

  • Routes requests

  • Passes all audits on Lighthouse with 100 score

E    NONIC PWA STARTER

  • Integration with NPM, Gulp and Webpack
  • Automatic generation of Web Manifest
  • Automatic generation of Service Worker
  • Routing with configurable caching strategy
  • Run-time caching
  • Background synchronisation
  • ...and much more...

Easy Precaching

{
    globDirectory: 'resources/assets',
    globPatterns: ['precache/**\/*'],
    globIgnores: ['*.svg'],
    swSrc: 'resources/js/sw-template.js',
    swDest: 'build/sw.js'
}

Config (webpack.config.js):

Easy Precaching

self.__precacheManifest = [
  {
    "revision": "6036fa3a8437615103937662723c1b67",
    "url": "precache/css/material.indigo-pink.min.css"
  },
  {
    "revision": "713af0c6ce93dbbce2f00bf0a98d0541",
    "url": "precache/js/material.min.js"
  },
  {
    "revision": "a578c0212d0f6ff037d399a8775c0878",
    "url": "precache/css/pushform.css"
  },
  {
    "revision": "d2ec1a67c9bc349b9da1",
    "url": "bundles/js/bs-bundle.js"
  },
  {
    "revision": "e91ee5ca9aa7bec924d6bb1085f407d6",
    "url": "precache/browserconfig.xml"
  },
  {
    "revision": "c14d7e6ad1bb6cd130604a0f0a07e0be",
    "url": "precache/css/icon.css"
  },
  {
    "revision": "564aac0fecc0634c889e6629d57ef814",
    "url": "precache/css/material.icons.woff2"
  },
  {
    "revision": "334d73d2565b31cb9f537a7732e88153",
    "url": "precache/icons/safari-pinned-tab.svg"
  },
  {
    "revision": "a60a7f90abb7617bcbe6",
    "url": "bundles/js/push-bundle.js"
  },
  {
    "revision": "b4956f40834b4787e2f3c596d3888e94",
    "url": "precache/icons/icon.png"
  },
  {
    "revision": "d55ee8e6215e0cdaf07625a0f6504ef4",
    "url": "precache/icons/offline.svg"
  },
  {
    "revision": "2c06553157be434b9627791a513de76b",
    "url": "precache/icons/online.svg"
  },
  {
    "revision": "33c3a22c05e810d2bb622d7edb27908a",
    "url": "precache/icons/pwa-logo.png"
  },
  {
    "revision": "1cdc62d73e9e7d166561",
    "url": "bundles/js/app-bundle.js"
  },
  {
    "revision": "1cdc62d73e9e7d166561",
    "url": "bundles/css/main.css"
  }
];

Generated precache manifest

(matching globPatterns: ['precache/*/*.*'])

Easy Precaching

importScripts("precache-manifest.03308e51ba1755398260e0a2ca52e0a0.js", "https://storage.googleapis.com/workbox-cdn/releases/4.3.1/workbox-sw.js");

workbox.core.setCacheNameDetails({
    prefix: 'enonic-pwa-starter',
    suffix: '{{appVersion}}',
    precache: 'precache',
    runtime: 'runtime'
});

workbox.core.clientsClaim();

workbox.precaching.precacheAndRoute(self.__precacheManifest || []);

workbox.precaching.precacheAndRoute([{
    "revision": "{{appVersion}}",
    "url": "{{appUrl}}"
},{
    "revision": "{{appVersion}}",
    "url": "{{appUrl}}manifest.json"
}]);

workbox.routing.setDefaultHandler(new workbox.strategies.NetworkFirst());

Generated Service Worker (build/sw.js):

Runtime Caching

importScripts("precache-manifest.03308e51ba1755398260e0a2ca52e0a0.js", "https://storage.googleapis.com/workbox-cdn/releases/4.3.1/workbox-sw.js");

workbox.routing.registerRoute('/schedule', new workbox.strategies.StaleWhileRevalidate());

workbox.routing.registerRoute('/news', new workbox.strategies.CacheFirst());

workbox.routing.registerRoute('/about', new workbox.strategies.CacheOnly());

/**
 * For all other URL patterns try contacting the network first
 */
workbox.routing.setDefaultHandler(new workbox.strategies.NetworkFirst());

Simple routing with caching strategies:

E    NONIC PWA STARTER

E    NONIC PWA STARTER

ENONIC CLI

CHECKLIST

  • Terminal/CLI
  • Brew (on Mac) or Scoop (on Windows)
  • IDE of your choice
  • Google Chrome

2. Clone, build and deploy the PWA Workshop app

7. Try different caching strategies

6. Build a site

Hands-on exercises

3. Get familiar with the structure

4. Add a new page with routing

5. Implement a fallback page

1. Install Enonic CLI

Set up the project with Enonic CLI

Set up the project with Enonic CLI

2. Go to your "/dev" folder where a new project will be created

3. Clone the app from our repository

$ git clone https://github.com/enonic/workshop-pwa.git

1. Install Enonic CLI

$ brew install enonic
scoop bucket add enonic https://github.com/enonic/cli-scoop.git

5. Deploy the project to an XP Sandbox

$ enonic project deploy

4. Go inside "workshop-pwa" directory

$ cd workshop-pwa
$ cd myprojects

Test the app in your browser

App Structure

Open the app in your IDE

  • /assets

Client-side assets

  • /assets/precache

Building blocks of the app shell: client-side JS, icons, fonts, CSS, manifest

  • /templates

Templates for server-side rendering

  • /webapp/webapp.js

Main server-side entry point of the app

  • /templates/workbox-sw.js

Template of the Service Worker

  • /assets/js/app.js

Main client-side entry point

  • /assets/css/styles.less

Stylesheets (will be converted to CSS with Webpack)

/webpack.config.js
const path = require('path');
const MiniCssExtractPlugin = require('mini-css-extract-plugin');
const InjectManifest = require('workbox-webpack-plugin').InjectManifest;

const paths = {
    templates: 'src/main/resources/templates/',
    assets: 'src/main/resources/assets/',
    buildAssets: 'build/resources/main/assets/',
    buildTemplates: 'build/resources/main/templates/'
};

const templatesPath = path.join(__dirname, paths.templates);
const assetsPath = path.join(__dirname, paths.assets);
const buildAssetsPath = path.join(__dirname, paths.buildAssets);
const buildTemplatesPath = path.join(__dirname, paths.buildTemplates);

module.exports = {

    entry: path.join(assetsPath, 'js/app.js'),

    output: {
        path: buildAssetsPath,
        filename: 'precache/bundle.js',
        libraryTarget: 'var',
        library: 'Starter'
    },

    resolve: {
        extensions: ['.js', '.less']
    },

    module: {
        rules: [
            {
                test: /.less$/,
                use: [
                    {loader: MiniCssExtractPlugin.loader, options: {publicPath: '../' }},
                    {loader: 'css-loader', options: {sourceMap: true, importLoaders: 1}},
                    {loader: 'less-loader', options: {sourceMap: true}},
                ]
            }
        ]
    },
    plugins: [
        new MiniCssExtractPlugin({
            filename: 'precache/bundle.css'
        }),
        new InjectManifest({
            globDirectory: buildAssetsPath,
            globPatterns: ['precache/**/*.*'],
            swSrc: path.join(templatesPath, 'workbox-sw.js'),
            swDest: path.join(buildTemplatesPath, 'sw.js')
        })
    ],
    mode: 'development',
    devtool: 'source-map'
};
/webapp/webapp.js
var portalLib = require('/lib/xp/portal');
var thymeleaf = require('/lib/thymeleaf');
var router = require('/lib/router')();
var mustache = require('/lib/mustache');
var siteTitle = 'PWA Workshop';

function getAppUrl() {
    return portalLib.url({path:'/webapp/' + app.name}) + '/';
}

function renderPage(pageId, title) {
    var model = {
        version: app.version,
        appUrl: getAppUrl(),
        pageId: pageId,
        title: title || siteTitle
    };

    return {
        body: thymeleaf.render(resolve('/templates/page.html'), model)
    };
}


function renderSW() {
    var appUrl = getAppUrl();

    return {
        headers: {
            'Service-Worker-Allowed': appUrl
        },
        contentType: 'application/javascript',
        // sw.js will be generated during build by Workbox from webpack.config.js
        body: mustache.render(resolve('/templates/sw.js'), {
            appUrl: appUrl,
            appVersion: app.version
        })
    };
}

function renderManifest() {

    return {
        contentType: 'application/json',
        body: mustache.render(resolve('/templates/manifest.json'), {
            startUrl: getAppUrl() + '?source=web_app_manifest'
        })
    };
}

router.get('/sw.js', renderSW);
router.get('/manifest.json', renderManifest);

router.get('/', function() { return renderPage('main'); });

exports.get = function (req) {
    return router.dispatch(req);
};
/templates/page.html
<!DOCTYPE html>
<html lang="en">
<head>

    <link rel="apple-touch-icon" data-th-href="${portal.assetUrl({'_path=precache/icons/icon.png'})}">
    <link rel="icon" data-th-href="${portal.assetUrl({'_path=precache/icons/icon.png'})}">
    <link rel="manifest" data-th-href="${appUrl + 'manifest.json'}">
    <link rel="mask-icon" data-th-href="${portal.assetUrl({'_path=precache/icons/safari-pinned-tab.svg'})}" color="#f53d3d">

    <script type="text/javascript" data-th-src="${portal.assetUrl({'_path=precache/js/material.min.js'})}" async></script>
    <script type="text/javascript" data-th-src="${portal.assetUrl({'_path=precache/bundle.js'})}" async></script>

    <link rel="stylesheet" type="text/css" data-th-href="${portal.assetUrl({'_path=precache/css/material.indigo-pink.min.css'})}">
    <link rel="stylesheet" type="text/css" data-th-href="${portal.assetUrl({'_path=precache/bundle.css'})}">
    <link rel="stylesheet" type="text/css"  data-th-href="${portal.assetUrl({'_path=precache/css/icon.css'})}">

    <script type="text/javascript" data-th-if="${pageId=='main'}">
        if (!document.location.href.endsWith('/')) {
            document.location = document.location.href + "/";
        }
    </script>
</head>

<body>
<div class="mdl-layout mdl-js-layout mdl-layout--fixed-header">

    <div data-th-replace="/templates/fragments/common::fragment-site-menu(title=${title}, pageId=${pageId}, appUrl=${appUrl})"></div>

    <main class="mdl-layout__content" id="main-content">
        <div id="main-container" data-th-switch="${pageId}">

            <div data-th-case="'main'" data-th-remove="tag">
                <div data-th-replace="/templates/fragments/common::fragment-page-main"></div>
            </div>
        </div>
    </main>

    <footer id="notification-bar" class="mdl-js-snackbar mdl-snackbar" aria-hidden="true">
        <div class="mdl-snackbar__text"></div>
        <button class="mdl-snackbar__action" type="button"></button>
    </footer>
</div>

<script type="text/javascript">
    const registerServiceWorker = function(appUrl) {

        if ('serviceWorker' in navigator) {
            navigator.serviceWorker
                .register(appUrl + 'sw.js', {scope: appUrl})
                .then(function(reg) {
                    reg.onupdatefound = function() {
                        Starter.notifyAboutNewVersion();
                    };
                    console.log('Service Worker registered with scope '  + reg.scope);
                }, function() {
                    console.log('Service Worker registration failure.');
                });
        }
    }
</script>

<script type="text/javascript" data-th-utext="'registerServiceWorker(\'' + ${appUrl} + '\');'"></script>

</body>
</html>

 Make the app work offline

Let's check that our app doesn't work offline and fix that by registering a Service Worker

1. Open "Developer Tools" panel (Cmd-Alt-I)

2. Go to the "Application" tab and select "Service Workers" on the left hand-side

3. Check off the "Offline" checkbox and reload the page

4. Uncomment this block of code at the bottom of "page.html"


<!--<script type="text/javascript" data-th-utext="'registerServiceWorker(\'' + ${appUrl} + '\');'"></script>-->

</body>
</html>

5. Build and deploy the app

$ enonic project deploy

6. Uncheck "Offline" and reload the page

7. Again, go "Offline" and reload the page

The app still works!

Add your own page

Let's add a simple page where contents will change depending on whether you're online or offline

git checkout 01-custom-page

1. Get code from branch "01-custom-page"

2. Add new routing to webapp/webapp.js:

Note two new files: /templates/fragments/my-page.html and /assets/css/my-page.less

router.get('/my-page', function() { return renderPage('my-page', "My First page"); });

3. Include new fragment inside div id="main-container" in page.html:

<div data-th-case="'my-page'" data-th-remove="tag">
    <div data-th-replace="/templates/fragments/my-page::fragment-page-my-page"></div>
</div>

4. Import my-page.less into styles.less:

@import 'my-page.less'; // add this on the very first line of styles.less

5. Deploy the project

$ enonic project deploy

Test the page in your browser

TIP: Toggle offline status for some funny action

Implement a fallback page

Let's make any URL work!

What if you try to open a URL that doesn't exist?

git checkout 02-fallback-page

1. Get code from branch "02-fallback-page"

Note a new fragment file: /templates/fragments/under-construction.html

2. Include new fragment in page.html:

<div data-th-case="*" data-th-remove="tag">
    <div data-th-replace="/templates/fragments/under-construction::fragment-page-under-construction"></div>
</div>
$ enonic project deploy

4. Deploy the project

3. Add new routing to webapp/webapp.js:

router.get('/{path:.+}', function() { return renderPage('fallback', 'Under construction'); });

Note data-th-case="*" attribute on the outer <div>

Try the same (or any other) URL

Build a Progressive Web... Site

git checkout 03-website

1. Get code from branch "03-website"

$ enonic project deploy

2. Deploy the app

3. Open Applications module of XP

4. Install Content Studio from Enonic Market

5. Open Content Studio and create a new Site

6. Give it a display name: PWA Workshop and assign application "PWA Workshop"

7. Open Edit permissions dialog, uncheck "Inherit permissions" and give Full Access to Everyone

8. Select controller "Default template" in the right-hand panel

9. Publish the site using the                       button

10. Open the site in the browser

8. Try switching background colours in the Site config dialog

(NB! Don't forget to Publish the changes)

Caching Strategies

workbox.routing.setDefaultHandler(new workbox.strategies.NetworkFirst());

1. Change default caching strategy in workbox-sw.js

to

workbox.routing.setDefaultHandler(new workbox.strategies.CacheFirst());
$ enonic project deploy

2. Deploy the changes

3. Change the color and Publish the site

4. Refresh the site and note how color no longer changes on refresh (as the opposite to how it was before)

workbox.routing.setDefaultHandler(new workbox.strategies.NetworkFirst());

5. Now change

to

workbox.routing.setDefaultHandler(new workbox.strategies.StaleWhileRevalidate());

6. Deploy the changes

7. Refresh the site and note how you get the cached version on first refresh and the updated version from then on

$ enonic project deploy

That's all!

Enonic PWA Workshop

By Alan Semenov

Enonic PWA Workshop

Enonic PWA Workshop

  • 1,826