Maxim Salnikov

@webmaxru

Diving deep, deep offline –

the web can do it!

What is an offline-ready web application

And how to build it today

Maxim Salnikov

  • Web Platform & Cloud speaker, writer, trainer

  • Developer communities facilitator

  • Technical conferences organizer

Developer Audience Lead at Microsoft

Web as an app platform

  • Historically depends on the "connection status"

  • Evergreen browsers

  • Versatile language

  • Performant JS engines

  • Excellent tooling

  • Huge community

Proper offline-ready web app

  • App itself

  • Online runtime data

  • Offline runtime data

  • Connection failures

  • Updates

  • Platform features

  • Always available

  • Thoughtfully collected

  • Safely preserved

  • Do not break the flow

  • Both explicit and implicit

  • For the win!

While keeping its web nature!

Web Almanac 2022

Application UI

Let's build an App shell

My App

  • Define assets

  • Put in the cache

  • Serve from the cache

  • Manage versions

}

Service worker

Logically

Physically

-file(s)

App

Service worker

Browser/OS

Event-driven worker

Cache

fetch
push
sync

In theory

self.addEventListener('install', event => {
    // Putting resources into the Cache Storage
})

self.addEventListener('activate', event => {
    // Managing versions
})

self.addEventListener('fetch', event => {
    // Exctracting from the cache and serving
})

handmade-service-worker.js

In the guide

const PRECACHE = 'precache-v1';
const RUNTIME = 'runtime';

const PRECACHE_URLS = [
  'index.html',
  './',
  'styles.css',
  '../../styles/main.css',
  'demo.js'
];

self.addEventListener('install', event => {
  event.waitUntil(
    caches.open(PRECACHE)
      .then(cache => cache.addAll(PRECACHE_URLS))
      .then(self.skipWaiting())
  );
});

self.addEventListener('activate', event => {
  const currentCaches = [PRECACHE, RUNTIME];
  event.waitUntil(
    caches.keys().then(cacheNames => {
      return cacheNames.filter(cacheName => !currentCaches.includes(cacheName));
    }).then(cachesToDelete => {
      return Promise.all(cachesToDelete.map(cacheToDelete => {
        return caches.delete(cacheToDelete);
      }));
    }).then(() => self.clients.claim())
  );
});

self.addEventListener('fetch', event => {
  if (event.request.url.startsWith(self.location.origin)) {
    if (event.request.url.indexOf('api/') != -1) {
      event.respondWith(
        caches.match(event.request.clone()).then((response) => {
          return response || fetch(event.request.clone()).then((r2) => {
            return caches.open(RUNTIME).then((cache) => {
              cache.put(event.request.url, r2.clone());
              return  r2.clone();
            });
          });
        })
      );
    } else {
      event.respondWith(
        caches.match(event.request).then(cachedResponse => {
          if (cachedResponse) {
            return cachedResponse;
          }
          return caches.open(RUNTIME).then(cache => {
            return fetch(event.request).then(response => {
              return cache.put(event.request, response.clone()).then(() => {
                return response;
              });
            });
          });
        })
      );
    }
  }
});

handmade-service-worker.js

− Build automation

− Configurability

− Extensibility

− Smart caching

− All possible fallbacks

− Communication with the app

− Debug information 

− ...

In production

Redirects?

Fallbacks?

Opaque response?

Versioning?

Cache invalidation?

Spec updates?

Cache storage space?

Variable asset names?

Feature detection?

Minimal required cache update?

Caching strategies?

Routing?

Fine-grained settings?

Kill switch?

I see the old version!!!

It's only partially a joke

  • Balanced abstraction level

  • Declarativeness where appropriate

  • Modularity and extensibility

  • Rich functionality out of the box

  • Powerful tooling 

~30% of the service workers — are...

Open source, active maintenance and support 

Implementing offline-readiness

import { precacheAndRoute } from "workbox-precaching";

// Cache and serve resources from __WB_MANIFEST array
precacheAndRoute(self.__WB_MANIFEST);

src/service-worker.js

Remaining steps:

  1. Populate __WB_MANIFEST

  2. Bundle

  3. Register in the application

 

# Using Workbox as a Node module
$ npm install workbox-build

}

On every app build

Build script

const { injectManifest } = require("workbox-build");

let workboxConfig = {
  swSrc: "src/service-worker.js",
  swDest: "dist/sw.js",
  globPatterns: ["index.html", "*.css", "*.js"]
};

injectManifest(workboxConfig).then(() => {
  console.log(`Generated ${workboxConfig.swDest}`);
});

sw-build.js

[Almost] ready service worker

import { precacheAndRoute } from "workbox-precaching";

precacheAndRoute([
  { revision: "866bcc582589b8920dbc", url: "index.html" },
  { revision: "c2761edff7776e1e48a3", url: "styles.css" },
  { revision: "3469613435532733abd9", url: "main.js" }
]);

dist/sw.js

import { precacheAndRoute } from "workbox-precaching";

precacheAndRoute(self.__WB_MANIFEST);

src/service-worker.js

App build integration

"build-pwa":
  "npm run build-app &&
   node build-sw.js &&
   npx rollup -c"

package.json / scripts

Registering in the app

import { Workbox, messageSW } from 'workbox-window';

if ('serviceWorker' in navigator) {
      const wb = new Workbox('/sw.js');      
      wb.register();
    
      // Interactive update flow with messageSW
      // code sample is at https://aka.ms/workbox6
}

src/main.js

New version is available. Click to reload.

Runtime application data

Intercepting requests

self.addEventListener('fetch', event => {

  if (event.request.url.indexOf('/api/breakingnews') != -1) {
    event.respondWith(
      // Network-First Strategy
    )
  } else if (event.request.url.indexOf('/api/archive') != -1 {
    event.respondWith(
      // Cache-First Strategy
    )
  }
})

handmade-service-worker.js

Runtime caching

import { registerRoute } from "workbox-routing";
import {
  CacheFirst,
  NetworkFirst,
  StaleWhileRevalidate,
} from "workbox-strategies";

src/service-worker.js

// Avatars can always be taken from the cache
registerRoute(
  new RegExp("https://www.gravatar.com/avatar/.*"),
  new CacheFirst()
);

API responses caching

// Keeping article list always fresh
registerRoute(
  ({url}) => url.pathname.startsWith('/api/articles/'),
  new NetworkFirst()
);

// Retrieving article from the cache and checking for updates
import { BroadcastUpdatePlugin } from 'workbox-broadcast-update';

registerRoute(
  ({url}) => url.pathname.startsWith('/api/article/'),
  new StaleWhileRevalidate({
    plugins: [
      new BroadcastUpdatePlugin()
    ],
  })
);

src/service-worker.js

Strategies

  • CacheFirst

  • CacheOnly

  • NetworkFirst

  • NetworkOnly

  • StaleWhileRevalidate

  • ...your own strategy?

Plugins

  • Expiration

  • CacheableResponse

  • BroadcastUpdate

  • BackgroundSync

  • ...your own plugin?

More platform tools

  • Background Fetch API

  • Native File System API

  • Badging API

  • Contact Picker API

  • Notification Triggers API

 + more than 100 other APIs

  • Full-fledged application platform

  • Offline-ready mechanisms are in production

  • Awesome tools are available

  • User experience & security is the key

And this is just the beginning!

Web platform today

  • Source code

  • Extra features

  • Demo hosted on Azure Static Web Apps

Extended PWA example

Thank you!

Maxim Salnikov

@webmaxru

Power App portals as PWAs

Microsoft 365 web apps as PWAs

Staying in sync

Background

Sync

No active app tab required

Periodic Background

Sync

One-off event coming when sync is possible

To periodically synchronize data 

async function postTweet(post) {
  const registration = await navigator.serviceWorker.ready;
  await savePost(post); // Storing data in IndexedDB
  await registration.sync.register('post-tweet');
}

main.js

self.addEventListener('sync', event => {
  if (event.tag == 'postTweet') {
    event.waitUntil(
        // Extract stored data and repeat sending
    );
  }
});

handmade-service-worker.js

import {BackgroundSyncPlugin} from 'workbox-background-sync';

const postTweetPlugin =
    new BackgroundSyncPlugin('tweetsQueue', {
        maxRetentionTime: 24 * 60 // Max retry period
    })

src/service-worker.js

registerRoute(
  ({url}) => url.pathname.startsWith('/api/post-tweet/'),
  new NetworkOnly({plugins: [postTweetPlugin]}),
  'POST'
)

...and host it for free in the cloud

Maxim Salnikov

@webmaxru

Taking your web app offline (in a good sense)

Questions?

Maxim Salnikov

@webmaxru

Made with Slides.com