Polyhedric

Cristian Merighi's Multi-Faceted Blog

PWA Experience with TypeScript

PWA Experience with TypeScript

Wednesday, July 11, 2018

How I ended up building my first PWA using TypeScript.


Progressive Web Apps (PWAs) are a (quite) new technology. They're meant to deliver web content in a more pleasant way to users that expect a native-like feeling on their mobiles.
Essentially, PWA is about Offline-first and viceversa.
Essentially (2), PWA is about Service Workers and intercepting fetching events.

I want to type down my first experience with a PWA and how I managed to develop it using TypeScript.

My First PWA

The project I decided to enrich with offline capabilities and PWA stuff is my online documentation for Pacem JS.
A core requirement for a PWA to be "accepted" by a modern browser is https: Pacem JS Docs is hosted on Azure and exploits Let's Encrypt as a certificate issuer (see my relevant blog post).

The building of a PWA stands on the following pillars:

  • Responding 200 (OK) when offline (no dinosaurs allowed).
  • Provide metadata for stand-alone installation via manifest file.
  • Assign a URL to every page, aka Deep linking (i.e. do exploit HTML5 History API in SPAs).

Service Worker

Let's jump straight into the first one: offline management.
Here's were the Service Worker takes over the navigation process. First thing you have to do is to register the service worker in your landing page, putting the following code in a <script> tag:

// Service Worker registration if ('serviceWorker' in navigator) { navigator.serviceWorker .register('/sw.js') .then(function () { console.log("Service Worker registered."); }); }

What do we put in the sw.js file?
Mainly, the caching strategy. We may choose among many, in this case I went for the network first, falling back to cache.

I've opted - as usual - for TypeScript (v2.9.2 at the time of this article) as the tool for client side development. Nothing to report, apart the need of a proper dedicated ts configuration:

// /src/serviceWorker/tsconfig.json { "compilerOptions": { "noImplicitAny": false, "noEmitOnError": true, "removeComments": true, "sourceMap": false, "target": "es6", "lib": [ "es2015", "webworker" ], "outFile": "../../wwwroot/sw.js", "types": [] }, "compileOnSave": false }

Notice how webworker substitutes dom as the main imported lib, since they're incompatible.

Let's have now the Service Worker listening to the fetch event and handling the response caching, given the request as the cache key:

// part of file sw.ts -> transpiles into /sw.js const VERSION = "<WHATEVER>"; const CACHE_KEY = 'pacemjs-v' + VERSION; const OFFLINE_PAGE = '/views/offline.html'; const PREFETCHED = [ '/menu.json', '/intro/start', OFFLINE_PAGE, // [...] other requests ]; const EXCLUDED = ['/manifest.json']; async function _tryCache(request: Request, response: Response) { // Only GET requests are allowed to be `put` into the cache. if (request.method.toLowerCase() != 'get') return; const url = request.url; // check if the requested url must not be included in the cache for (var excluded of EXCLUDED) { if (url.endsWith(excluded)) { return; } } // if caching is allowed for this request, then... const cache = await caches.open(CACHE_KEY); cache.put(request, // responses might be allowed to be used only once, thus cloning response.clone()); } const _networkFirst = (evt: FetchEvent) => { // Network first (refreshes the cache), falling back to cache if offline. evt.respondWith( caches.match(evt.request) .then( async (cachedResponse: Response) => { const url = evt.request.url; try { const r = await fetch(evt.request); // might respond `200 ok` even if offline, depending on the browser HTTP cache. console.log('fresh response for ' + url); await _tryCache(evt.request, r); return r; } catch (_) { console.warn(_); if (cachedResponse) { console.log('cached response for ' + url); return cachedResponse; } else return caches.match(OFFLINE_PAGE); } } ) ); } self.addEventListener('fetch', _networkFirst);

As soon as the Service Worker gets registered, it fires the activate event.
The following piece of code intercepts this event and eventually clears an obsolete cache.

self.addEventListener('activate', function (event: ExtendableEvent) { console.log('Service Worker v'+VERSION+' activated.') // In order to clear the cache, then update the {VERSION} above: // former cache version, if any, will be cleared. // Delete all caches that aren't named {CACHE_KEY} event.waitUntil( caches.keys().then(function (cacheNames) { return Promise.all( cacheNames.map(function (cacheName) { if (cacheName !== CACHE_KEY) { console.info('Removing outdated cache:', cacheName); return caches.delete(cacheName); } }) ); }) ); });

As a totally stand-alone piece of js that specifically fits peculiar app characteristics and has no access to the DOM, it is not easy to centralize core pieces of information in a DRY way into the sw.js file. I'm quite annoyed by the need to duplicate constants and similia. This annoyance could lead to a mildly wild idea of server-side rendering for the Service Worker file. That is an option, almost an obligation in multi-tenant scenarios.
Same for the manifest file...

Manifest

Second list-item: the manifest.json file stands for letting the browser know that/how the PWA can be installed.
Make it discoverable like this:

<!-- head of your html page --> <link href="/manifest.json" rel="manifest" />

Here's its content:

{ "name": "Pacem JS Docs", "short_name": "Pacem JS Docs", "lang": "en-us", "icons": [ { "src": "/assets/pwa_icon_192x192.png", "sizes": "192x192", "type": "image/png" }, { "src": "/assets/pwa_icon_512x512.png", "sizes": "512x512", "type": "image/png" } ], "start_url": "/intro/start", "display": "standalone", "scope": "/", "background_color": "#fff", "theme_color": "#090c41" }

Please, note the icon sizes. They're meant to appear both as a launch button in your home screen and as a splash screen as the application boots (here's a reference for all the manifest properties).
Again, remember to ...duplicate(!) your theme color into a html head metatag:

<meta name="theme-color" content="#090c41" />

Routing

Pacem JS Docs is a SPA having a <pacem-router> managing the browser history state. Thanks to that specific relevant content for each different URL is guaranteed.

Lighthouse

Google Chrome offers audits about performance and other site characteristics (Audits tab in Dev Tools). Through their report UI (named "Lighthouse") you may ask Chrome to judge the PWA-ness of your app.
You get a perfect score when you fulfill all the relevant 11 audits.

Lighthouse

Recommend
Add Comment
Thank you for your feedback!
Your comment will be processed as soon as possible.
error Obsolete Browser
You're using an old-fashioned, unsecure, non performing browser. We strongly recommend you to upgrade it.
This site, by the way, cannot be viewed using your
CCBot/2.0 (https://commoncrawl.org/faq/)
Pick your Favorite