Skip to content

enonic/starter-pwa

Repository files navigation

PWA Starter for Enonic XP

This Starter enables you to build a basic application with PWA capabilities on Enonic XP Platform. It’s using modern tools like Webpack for the build process and Workbox for automatic generation of Service Worker file and dynamic response caching. Simple routing is powered by Enonic Router library.

Installation

1.Make sure you have Enonic XP of version 7.0.0 or later installed locally. If not, read here about how to get started

2.While using Enonic CLI to create a new project, choose Workbox PWA starter when asked what type of starter you want to base your new project on.

Let’s assume you accepted default project name suggested by CLI (com.example.myproject) and your project was created in a folder called myproject.

3.Once CLI has set up your new project based on the PWA Starter, go to the newly created project folder (myproject), build and deploy the application:

$ cd myproject
$ enonic project deploy

4.If the build completed without errors, you can now open your app in the browser via http://localhost:8080/webapp/com.example.myproject/ (if you used a different app name when creating the project with CLI, replace com.example.myproject with whatever name you picked).

Usage and Testing of PWA

We assume that XP service is running on localhost:8000 and your app is called com.example.myproject as in the example above.

  1. Open http://localhost:8080/webapp/com.example.myproject/ in your browser. You should see this:

main page

2.Click the burger icon in the header:

menu

3.This menu showcases different capabilities of the PWA Starter. Read about them below.

Tip
Some of the features in the menu are not implemented yet but will be added in future versions.

PWA Features

Tracking online/offline state

Click the "Offline" link in the Starter menu. That will open a new page looking like this:

page online

This page shows that it’s possible to easily determine online/offline status in the browser and show different content on the page based on that. Go offline by unplugging network cable, turning off Wi-Fi or simulating offline mode in the dev console. Now the page should change and look like this:

page offline

If you now - while staying offline - go to the main page, you will see additional note under the welcome text

main page offline

As you can see, the Starter can track its online/offline status and change content of its pages accordingly.

Webpack Config

The Starter is using Webpack to build all LESS files into one CSS bundle main.css and all Javascript assets into one main JS bundle main.js (some pages are using their own JS bundles in addition to the main one). The Workbox plugin is used by Webpack to automatically generate a template for the Service Worker (sw.js) based on a predefined file (workbox-sw.js). Final Service Worker file will be rendered on-the-fly by Enonic Router lib by intercepting a request to /sw.js file in the site root.

Dependencies

assets/js/app.js is used as entry point for the Webpack builder, so make sure you add the first level of dependencies to this file (using import). For example, if assets/js/app.js is using a LESS file called app.less in the css folder, add the following line to the app.js:

import '../css/app.less';

Same with JS-dependencies. For example, to include a file called new.js from the same js folder add this line to app.js:

import 'new.js';

You can then import other LESS or JS files directly from new.js effectively building a chain of dependencies that Webpack will resolve during the build.

As mentioned before, the build process will bundle all LESS and JS assets into bundles/js/main.css and bundles/js/main.css files respectively which can then be referenced directly from the page.html page.

Auto-precaching assets

When the application is launched for the first time, Service Worker will generate the manifest - a file containing the list of assets required for the application to continue working while offline.

workbox-sw.js:
import {setCacheNameDetails, clientsClaim} from 'workbox-core';
import {precacheAndRoute} from 'workbox-precaching';

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

clientsClaim();

precacheAndRoute(self.__WB_MANIFEST);

The last line above injects generated manifest of assets that will be precached upon application startup. The self.__WB_MANIFEST placeholder will be replaced with the list of assets processed by Webpack. If you notice that some of the assets don’t get added to the manifest file (and therefore don’t get cached) it means that your Webpack config file is not configured to process them.

Precaching custom assets

Sometimes you may need to cache assets that are not processed by Webpack build. In this case you have to explicitly specify the assets that you need to be cached (this might be an external resource, for example a font file). Add a new asset with revision and url properties in the call to precacheAndRoute method as shown below:

workbox-sw.js:
...

// Here we precache custom defined Urls
precacheAndRoute([{
    "revision": "{{appVersion}}",
    "url": "{{appUrl}}"
},{
    "revision": "{{appVersion}}",
    "url": "{{appUrl}}manifest.json"
}]);

{{appVersion}} and {{appUrl}} will be replaced at build-time with app version from gradle.properties and webapp url respectively.

Application Manifest file

Application Manifest is a file in JSON format which turns the application into a PWA. Starter comes with its own manifest.json with hardcoded title, color scheme, display settings and favicon. Feel free to change the predefined settings: the file is located in the /resources/templates/ folder.

manifest.json:
{
  "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"
    }
  ]
}

Changing favicon

Default favicon used by the Starter is called icon.png and located in images/icons/ folder, so you can simply replace this icon with your own of the same name. If you want to use a different icon file, add it to the same location and change icon tags inside page.html to point to the new icon.

main.html:
    <link rel="apple-touch-icon" data-th-href="${portal.assetUrl({'_path=images/icons/icon.png'})}" href="../assets/images/icons/icon.png">
    <link rel="icon" data-th-href="${portal.assetUrl({'_path=images/icons/icon.png'})}" href="../assets/images/icons/icon.png">

main.js

This Starter is not a traditional site with plain HTML pages - everything is driven by a controller. Just like resources/assets/js/app.js is an entry point of the Starter’s client-side bundle, resources/webapp/webapp.js is an entry point and the main controller for the server-side execution. Setting it up is simple - just add handler of the GET request to webapp.js file and return response in form of rendered template or a simple string:

webapp.js:
exports.get = function (req) {
    return {
        body: 'We are live'
    }
};

If your application name is com.enonic.starter.pwa and Enonic web server is launched on localhost:8000 then http://localhost:8080/webapp/com.enonic.starter.pwa/ will open the main page of your app.

Page rendering

As mentioned above, main.js` is used to render pages and serve the content. In our starter we use one main template (templates/page.html``) and then use fragments for showing different content based on which page you’re on. This is explained below.

Dynamic routing

If your application is not a single-page app, you are going to need some routing capabilities. The Starter is using Enonic Router library which makes it incredibly simple to dynamically route a request to correct page template. First, let’s change the default page to render a proper template instead of a simple string.

main.js:
const thymeleaf = require('/lib/thymeleaf');
const router = require('/lib/router');
const portalLib = require('/lib/xp/portal');

router.get('/', function (req) {
    return {
        body: thymeleaf.render(resolve('/templates/page.html'), {
            appUrl: portalLib.url({path: '/webapp/' + app.name}),
            pageId: 'main',
            title: 'Main page'
        })
    }
});

exports.get = function (req) {
    return router.dispatch(req);
};

Here we told the Router to respond to the "/" request (which is the app’s main page) with the rendered template from /templates/page.html.

Now let’s create a fragment showing the content of the main page that is different from other pages:

templates/fragments/common.html:

<div data-th-fragment="fragment-page-main" data-th-remove="tag">
    <div>
        This is the main page!
    </div>
</div>

Finally, inside the main template we should render correct fragment based on pageId: templates/page.html:

    <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 data-th-case="*" data-th-remove="tag">
                <div data-th-replace="/templates/fragments/under_construction::fragment-page-under-construction"></div>
            </div>
        </div>
    </main>

Now let’s expand this to enable routing to other pages. Let’s say, we need a new page called "About" which should open via /about URL.

main.js:
var thymeleaf = require('/lib/thymeleaf');
var router = require('/lib/router')();

router.get('/', function (req) {
    ...
});

router.get('/about', function (req) {
    return {
        body: thymeleaf.render(resolve('/templates/page.html'), {
            appUrl: portalLib.url({path:'/app/' + app.name}),
            pageId: 'about',
            title: 'About Us'
        })
    }
});

exports.get = function (req) {
    return router.dispatch(req);
};

Create a new fragment for the "About" page:

templates/fragments/about.html:

<div data-th-fragment="fragment-page-about" data-th-remove="tag">
    <div>
        This is the About Us page!
    </div>
</div>

Handle new fragment inside the main template: templates/page.html:

<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 data-th-case="'about'" data-th-remove="tag">
            <div data-th-replace="/templates/fragments/common::fragment-page-main"></div>
        </div>
        <div data-th-case="*" data-th-remove="tag">
            <div data-th-replace="/templates/fragments/under_construction::fragment-page-under-construction"></div>
        </div>
    </div>
</main>

Runtime caching

When you’re building a PWA you typically want a user to be able to open previously visited pages even when the application is offline. In this Starter we are using Workbox to dynamically cache URL requests for future use. Note that we are using `NetworkFirst as a default strategy, but you can specify a different strategy for specific pages.

workbox-sw.js:
import {registerRoute, setDefaultHandler} from 'workbox-routing';
import {NetworkOnly, NetworkFirst, CacheFirst} from 'workbox-strategies';

/**
 * Sets the default caching strategy for the client: tries contacting the network first
 */
setDefaultHandler(new NetworkFirst());

/**
 * Make sure SW won't precache non-GET calls to service URLs
 */
const routePath = new RegExp('{{serviceUrl}}/*');
registerRoute(routePath, new NetworkOnly(), 'POST');
registerRoute(routePath, new NetworkOnly(), 'PUT');
registerRoute(routePath, new NetworkOnly(), 'DELETE');

registerRoute(
    '{{baseUrl}}/about',
    new CacheFirst()
);

registerRoute(
    '//fonts.gstatic.com/s/materialicons/*',
    new CacheFirst()
);

Here we specify default caching strategy for the entire app and then specific caching strategy for /about URL and requests to the 3rd-party font file at an external URL.

Tip
Note that we by default are using NetworkFirst strategy which means that Service Worker will first check for the fresh version from the network and fall back to the cached version only if the network is down. Read more about possible caching strategies here.

Push notifications

The app is using the Notifications API to notify user about a new version of the Service Worker. This can happen in two cases: one of the client-side assets (and therefore the manifest) has been changed or version of the app (in gradle.properties) has changed. In this case user will receive a notification that a new version of the app is available and can update the app simply by clicking the "Update" button in the notification popup. After successful update user will be notified as well.

Important
In order for notifications to work properly, they have to be allowed not only for the browser page displaying the app, but also for the browser in general (under OS settings).