Comment transformer un site en Progressive Web App

publié le

Petit retour d’expérience sur le moyen de transformer son blog en PWA, et sur les outils permettant d’y arriver.

Avant de commencer, je souhaite clarifier 2 choses :

  • Une PWA est sensée être bien plus qu’un blog, cet exemple est donc un “PWA for dummies” assez léger
  • Je ne présente ici que la vision client de la PWA, mais vous aurez forcément un travail à faire coté serveur pour les notifications

Maintenant que vous savez à quoi vous en tenir, nous pouvons commencer.

Définition PWA

Google dit :

Progressive Web Apps are user experiences that have the reach of the web, and are: Reliable - Load instantly and never show the downasaur, even in uncertain network conditions. Fast - Respond quickly to user interactions with silky smooth animations and no janky scrolling. Engaging - Feel like a natural app on the device, with an immersive user experience. This new level of of quality allows Progressive Web Apps to earn a place on the user’s home screen.

Pour faire simple une Progressive Web App consiste à faire tout ce qui est en notre pouvoir pour améliorer au maximum l’expérience utilisateur des applications web.

Cela passe par :

  • le temps de chargement jusqu’à l’interactivité (performance)
  • la capacité de fournir une expérience même avec une connectivité faible ou inexistante (offline)
  • des interactions intuitives et immédiates
  • similaire à une application mobile : fullscreen, installée sur le desktop, et avec un design familier
  • capable de réengager l’utilisateur : notifications même quand l’application fermée (similaire à une mise à jour)

Par où commencer

Normalement, les performances de votre application sont déjà bonnes avant même de songer à passer à la PWA, car la performance c’est important dans tous les contextes du web, de la même façon votre ergonome et votre designer se sont occupés des interactions et du design efficace (j’ai oublié de payer le mien d’où les défauts de ce site), donc en tant que développeur il vous reste à traiter :

  • offline & Li-FI (réseau présent mais sans débit)
  • fullscreen
  • installable sur le desktop
  • notifications

Fournir le mode offline

Pour ça on utilise la sw-toolbox qui va nous permettre de générer le service worker qui va bien pour notre application, si pour des raisons de performance, vous passez par un état de preload, pensez à utiliser également sw-precache. Dans mon exemple je ne charge les ressources qu’à la demande pour ne pas générer de trafic superflu pour mon utilisateur.

Sw-toolbox est une boîte à outils pour Service Workers (Thank you captain obvious) à destination de gestion du cache et donc de stratégie de rendu réseau. Dans mon cas je veux retourner le contenu du cache d’abord(afin de fonctionner en offline et en LI-FI) puis mettre à jour ce dernier et notifier l’utilisateur d’un besoin de rafraichir si une nouvelle page est arrivée. Je vais donc appliquer une stratégie toolbox.cacheFirst pour la plupart de mon contenu, les seules exceptions étant la home, la page formations et la page Blog qui sont les pages qui changent le plus souvent et pour lesquelles j’applique la stratégie toolbox.networkFirst : on sert la page la plus fraiche en premier et en cas d’indisponibilité on charge depuis le cache.

//sw.js
importScripts('/public/sw-toolbox.js')

toolbox.options.networkTimeoutSeconds=1//ne pas trainer si offline
toolbox.options.cache.name="clgv1"//le nom unique du cache
toolbox.options.cache.maxEntries=100//le nb max de ressource à mettre en cache (image, css, html, js, ...) pour ne pas polluer

toolbox.router.get("/", toolbox.networkFirst)
toolbox.router.get("/pages/home/", toolbox.networkFirst)
toolbox.router.get("/pages/formations/", toolbox.networkFirst)
toolbox.router.get("/pages/blog/", toolbox.networkFirst)
toolbox.router.default = toolbox.cacheFirst

On n’oublie pas d’enregistrer le service worker à la racine du site :

<script>
navigator.serviceWorker.register('/sw.js')
</script>

En effet, un service worker ne peut cacher que des ressources relatives à son propre emplacement (au même niveau ou dans des routes filles, il ne faut dont pas le mettre dans un dossier parallèle comme public ou static)

fullscreen & desktop

Pour ce faire, rien de plus simple il suffit de déclarer un manifest.json à la racine de votre application web, pour que chrome(mobile) propose l’installation quand l’utilisateur visite votre site.

{
    "name": "Blog de Cédric Le Gallo",
    "short_name": "Cédric Le Gallo",
    "icons": [{
            "src": "public/images/icon-64.png",
            "sizes": "64x64",
            "type": "image/png"
        }, {
            "src": "public/images/icon-128.png",
            "sizes": "128x128",
            "type": "image/png"
        }, {
            "src": "public/images/icon-144.png",
            "sizes": "144x144",
            "type": "image/png"
        }, {
            "src": "public/images/icon-192.png",
            "sizes": "192x192",
            "type": "image/png"
        },{
            "src": "public/images/icon-384.png",
            "sizes": "384x384",
            "type": "image/png"
        }],
    "start_url": "/",
    "background_color": "#f8f8f8",
    "display": "standalone",
    "theme_color": "#3F51B5"
}

Le manifest contient :

  • la même icône en plusieurs tailles différentes dépendant des résolutions d’écran cible
  • l’entry point de votre site (ici /)
  • la couleur de fond de l’application
  • la façon de s’afficher(fullscreen)
  • la couleur de fond de l’entête de l’écran android (qui contient les icônes réseaux, batteries, heure, …)

Ne pas oublier d’inclure le manifest dans votre page :

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

Chrome proposera automatiquement l’installation de votre site sur le téléphone de l’utilisateur si :

  • vous disposez d’un manifest avec un short_name, un name, une icône 144x144, et une start_url fonctionnelle
  • vous disposez d’un service worker enregistré
  • vous êtes servis en https
  • votre utilisateur revient pour la deuxième fois espacé d’au minimum 5 minutes

Notifications

Il est intéressant de ré-engager régulièrement votre utilisateur au travers de notifications informant votre utilisateur de mise à jour (nouveau contenu, offre publicitaire, …). Afin que l’utilisateur reçoive la notification vous avez besoin de 3 choses :

  • un service worker de messagerie
  • un manifest pour relier votre client à votre serveur de push
  • un serveur de push

Le code à venir dépend grandement de votre serveur de push, voici par exemple ce qu’il faut faire pour communiquer avec le service de messagerie de Firebase :

//sw.js suite
//Importer les scripts firebase app et messaging (le reste de firebase n'est pas dispo en SW)
importScripts('https://www.gstatic.com/firebasejs/3.6.4/firebase-app.js')
importScripts('https://www.gstatic.com/firebasejs/3.6.4/firebase-messaging.js')

// Initialiser Firebase app dans le service worker
firebase.initializeApp({
    'messagingSenderId': 'YOUR-SENDER-ID'
})

//traiter les messages
const messaging = firebase.messaging();
messaging.setBackgroundMessageHandler(payload => {
    return self.registration.showNotification(payload.title, {
        body: payload.body,
        icon: payload.icon,
        "click_action" : payload.click_action
    })
})

On transforme également l’enregistrement du service worker pour notifier le module firebase client d’utiliser le serviceWorker :

if ('serviceWorker' in navigator) { 
    navigator.serviceWorker.register('/sw.js').then(registration => messaging.useServiceWorker(registration))
}

On met à jour le manifest pour le relier à notre serveur de push

{
    //...
    "gcm_sender_id": "103953800507"
}

Le serveur de push est de-facto le service de messaging fourni par Firebase, il ne vous reste qu’à gérer la mise en relation :

//coté client
// Initialize Firebase
var config = {
    messagingSenderId: "YOUR-SENDER-ID"
}
firebase.initializeApp(config)
const messaging = firebase.messaging()

messaging.getToken()
.then(currentToken => {
    if (currentToken) {
        sendTokenToServer(currentToken)
    } else {
        messaging.requestPermission()
        .then(_ => {
            messaging.getToken()
            .then(currentToken => sendTokenToServer(currentToken))
            .catch(_ => setTokenSentToServer(false))
        })
        setTokenSentToServer(false)
    }
})
.catch(_ => setTokenSentToServer(false))

messaging.onTokenRefresh(_ => {
    messaging.getToken()
    .then(refreshedToken => {
        setTokenSentToServer(false)
        sendTokenToServer(refreshedToken)
    })
})

const sendTokenToServer = currentToken => {
    if (!isTokenSentToServer()) {
        //TODO developer send token to server
        setTokenSentToServer(true)
    }
}
const isTokenSentToServer = _ => window.localStorage.getItem('sentToServer') === 1
const setTokenSentToServer = sent => window.localStorage.setItem('sentToServer', sent ? 1 : 0)

currentToken est à expédier à votre serveur de mise en relation (compléter le TODO), ensuite pour notifier le client d’un message il suffit d’émettre une requête au service de Firebase :

curl --header "Authorization: key=FIREBASE-SERVER-ID-KEY" 
        --header Content-Type:"Application/json"
        https://fcm.googleapis.com/fcm/send -d "{\"to\":currentToken}"

Conclusion

Ce blog est désormais une Progressive Web App, en effet vous pouvez l’installer sur votre home, si vous avez accepté les notifications vous recevrez mes prochaines publications directement sur votre téléphone et il s’utilise en fullscreen standalone. Les pages consultées sont accessibles même si vous êtes déconnectés ou en LI-FI. C’est un usage de base mais complet et très facile à mettre en place car exploitant des services existants (et gratuits).