Transform your Play Application into a Progressive Web App (PWA)

If you want to transform your Play Application (https://www.playframework.com) into a Progressive Web App, you can do this by the following steps.

The following example is implemented by using Play 2.6.15.

Why choosing a PWA?

PWA combine the best of both worlds: A responsive web application and native apps. The advantages are:

  • Works for every user and the visualisation is adapted to the choosen browser.
  • Responsive display of the content.
  • The possibility of service workers make the application also usable in offline mode.
  • It feels like an app and can be added to the home screen of the user device. No app store needed.
  • Served via HTTPS. Required to prevent snooping.
  • SEO dream. Can be indexed and crawled by the search engines.

The transformation of your application into a PWA requires the following steps.

Create a manifest.json file

The manifest.json file contains all relevant information of your PWA that makes it recognizable. It should be stored into the: public folder.

{
  "dir": "ltr",
  "scope": "/",
  "name": "My Application As PWA",
  "short_name": "MAP",
  "icons": [{
    "src": "assets/images/icons/icon-128x128.png",
      "sizes": "128x128",
      "type": "image/png"
    }, {
      "src": "assets/images/icons/icon-144x144.png",
      "sizes": "144x144",
      "type": "image/png"
    }, {
      "src": "assets/images/icons/icon-152x152.png",
      "sizes": "152x152",
      "type": "image/png"
    }, {
      "src": "assets/images/icons/icon-192x192.png",
      "sizes": "192x192",
      "type": "image/png"
    }, {
      "src": "assets/images/icons/icon-256x256.png",
      "sizes": "256x256",
      "type": "image/png"
    }, {
    "src": "assets/images/icons/icon-512x512.png",
    "sizes": "512x512",
    "type": "image/png"
  }],
  "start_url": "/",
  "display": "standalone",
  "background_color": "transparent",
  "theme_color": "transparent",
  "description": "This is a short description.",
  "orientation": "any",
  "related_applications": [],
  "prefer_related_applications": false
}

You must at least specify the name, short_name, icons. Additionally, the information about the colors background_color, theme_color and the scope are very helpful. The scope defines the files the service worker controls or from which path the service worker can intercept requests.

The specified icons must be stored to the public/images/icons folder!

The manifest.json must be integrated within the header of your pages. (At least it should be integrated no the entry page, but it is helpful to integrate the manifest.json on all pages).

Within the Template, put the link into the header.

<!DOCTYPE html>
<html>
  <head>
    <meta charset="utf-8">
    <meta http-equiv="X-UA-Compatible" content="IE=edge">
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <title>My application page</title>
    <link rel="manifest" href="/manifest.json">
  </head>
  <body>
    <h1 class="vertical-container">My application</h1>
  </body>
</html>

Register service worker

A service worker can be used to cache the files and make the app accessible without having an online connection. Moreover, it can provide additional funtionalities to update content and is necessary to make the application available as real PWA (with prompt to install the application to the homescreen).

First we create a simple sw.js file that provides the functionality to register the service worker to the browser. This file must be stored within public folder.

//Add this below content to your HTML page, or add the js file to your page at the very top to register service worker
if (navigator.serviceWorker.controller) {
  console.log('[PWA Info] active service worker found, no need to register')
} else {
  //Register the ServiceWorker
  navigator.serviceWorker.register('service-worker.js', {
    scope: '/'
  }).then(function(reg) {
    console.log('Service worker has been registered for scope:'+ reg.scope);
  });

Secondly, we define a basic service worker functionality that stores an offline page during startup of the page and displays this page if the app is offline. Store the following code into service-worker.js into the public folder.

// Installation: Store offline page
self.addEventListener('install', function(event) {
  var offlineSite = new Request('my-offline-page.html');
  event.waitUntil(
    fetch(offlineSite).then(function(response) {
      return caches.open('mypwa-offline').then(function(cache) {
        console.log('[PWA Info] Cached offline page');
        return cache.put(offlineSite, response);
      });
    }));
});

// Serve offline page if the fetch fails
self.addEventListener('fetch', function(event) {
  event.respondWith(
    fetch(event.request).catch(function(error) {
        console.error( '[PWA Info] App offline. Serving stored offline page: ' + error );
        return caches.open('mypwa-offline').then(function(cache) {
          return cache.match('my-offline-page.html');
        });
      }
    ));
});

// Event to update the offline page
self.addEventListener('refreshOffline', function(response) {
  return caches.open('mypwa-offline').then(function(cache) {
    console.log('[PWA Info] Offline page updated');
    return cache.put(offlineSite, response);
  });
});

Add the sw.js to the end of the main template file.

<!DOCTYPE html>
<html>
  <head>
    <meta charset="utf-8">
    <meta http-equiv="X-UA-Compatible" content="IE=edge">
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <title>My application page</title>
    <link rel="manifest" href="/manifest.json">
  </head>
  <body>
    <h1 class="vertical-container">My application</h1>

    <script type="text/javascript" src="/sw.js"></script>
  </body>
</html>

Add to routes

The manifest.json, the sw.js and the service-worker.js must be accessible from the root folder of the deployed application. Therefore, it should be added to the routes file and made accessible from root path.

GET  /service-worker.js controllers.Assets.at(path="/public", file="service-worker.js")
GET  /manifest.json     controllers.Assets.at(path="/public", file="manifest.json")
GET  /sw.js             controllers.Assets.at(path="/public", file="sw.js")
GET  /offline.html      controllers.HomeController.offline

# Map static resources from the /public folder to the /assets URL path
GET  /assets/*file      controllers.Assets.versioned(path="/public", file: Asset)
->   /webjars           webjars.Routes

Assets specification within application.conf

The specification for the Assets folder must be integrated into the application.conf file.

# The asset configuration
# ~~~~~
play.assets {
  path = "/public"
  urlPrefix = "/assets"
}

Additionally, the application can throw errors within the development console of the browsers if the play.filters.headers are not specified correctly. You must try the configuration depending on your application. An example configuration is the following.

play.filters.headers {
  contentSecurityPolicy = "default-src 'self' https://cdn.jsdelivr.net;"
  contentSecurityPolicy = ${play.filters.headers.contentSecurityPolicy}" img-src 'self' 'unsafe-inline' data:;"
  contentSecurityPolicy = ${play.filters.headers.contentSecurityPolicy}" style-src 'self' 'unsafe-inline' cdnjs.cloudflare.com maxcdn.bootstrapcdn.com fonts.googleapis.com;"
  contentSecurityPolicy = ${play.filters.headers.contentSecurityPolicy}" font-src 'self' fonts.gstatic.com fonts.googleapis.com cdnjs.cloudflare.com;"
  contentSecurityPolicy = ${play.filters.headers.contentSecurityPolicy}" script-src 'self' 'unsafe-inline' ws: wss: cdnjs.cloudflare.com;"
  contentSecurityPolicy = ${play.filters.headers.contentSecurityPolicy}" connect-src 'self' 'unsafe-inline' ws: wss:;"
}

Homecontroller action for Offline page

To serve the Offline page and adapt some content depending on your needs, you can create a simple Action within a Controller that returns the template for the page.

package controllers

import javax.inject._
import play.api.mvc._
import play.api.i18n.I18nSupport

@Singleton
class HomeController @Inject()
  (cc: ControllerComponents,
   implicit val webJarsUtil: org.webjars.play.WebJarsUtil,
   implicit val assets: AssetsFinder)
    extends AbstractController(cc)
    with I18nSupport {

  def offline() = Action { 
    implicit request: Request[AnyContent] =>
      Ok(views.html.offline())
  }
}

Additional integration of Safari and iOS browsers

Moreover, some other browsers need additional meta information to the header of the main template. Therefore, the following part is necessary.

<!DOCTYPE html>
<html>
  <head>
    <meta charset="utf-8">
    <meta http-equiv="X-UA-Compatible" content="IE=edge">
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <title>My application page</title>
    <link rel="manifest" href="/manifest.json">
    <!-- Add to home screen for Safari on iOS -->
    <meta name="apple-mobile-web-app-capable" content="yes">
    <meta name="apple-mobile-web-app-status-bar-style" content="black">
    <meta name="apple-mobile-web-app-title" content="HuST PWA">
    <link rel="apple-touch-icon" href="@assets.path("images/icons/icon-152x152.png")">
  </head>
  <body>
    <h1 class="vertical-container">My application</h1>

    <script type="text/javascript" src="/sw.js"></script>
  </body>
</html>

Test the application

You can add the Lighthouse Plugin to your Chrome browser that test the application a displays errors that would prevent the application to be served as PWA.

If you want to test the prompt that is shown on the devices (telephone) to install the application to the homescreen, the application must be served with HTTPS. You can test this locally by running ngrok. Ngrok is a tool that creates a tunnel to the local system and provides a HTTPS URL that can be used to call the application from the phone.