Alan Semenov
UX Lead at Enonic and blogger at WebAgility.com
Development Manager
ase@enonic.com
Alan Semenov
--- Break ---
--- Break ---
--- Lunch ---
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.');
}{
"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>Cmd-Alt-I
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);
})
);
}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&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);
})
);
});
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
{
globDirectory: 'resources/assets',
globPatterns: ['precache/**\/*'],
globIgnores: ['*.svg'],
swSrc: 'resources/js/sw-template.js',
swDest: 'build/sw.js'
}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"
}
];(matching globPatterns: ['precache/*/*.*'])
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());
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());
2. Clone, build and deploy the PWA Workshop app
7. Try different caching strategies
6. Build a site
3. Get familiar with the structure
4. Add a new page with routing
5. Implement a fallback page
1. Install 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.git1. Install Enonic CLI
$ brew install enonicscoop bucket add enonic https://github.com/enonic/cli-scoop.git5. Deploy the project to an XP Sandbox
$ enonic project deploy4. Go inside "workshop-pwa" directory
$ cd workshop-pwa$ cd myprojects/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>
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 deploy6. Uncheck "Offline" and reload the page
7. Again, go "Offline" and reload the page
Let's add a simple page where contents will change depending on whether you're online or offline
git checkout 01-custom-page1. 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.less5. Deploy the project
$ enonic project deployLet's make any URL work!
git checkout 02-fallback-page1. 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 deploy4. 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>
git checkout 03-website1. Get code from branch "03-website"
$ enonic project deploy2. 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)
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 deploy2. 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 deployBy Alan Semenov
Enonic PWA Workshop
UX Lead at Enonic and blogger at WebAgility.com