Offline Options

Barcamp GR

26 August 2017

Karl Swedberg

Show Offline Status

Add an "alert" div:

<body>
  <!-- put all your stuff here -->

  <div
    class="Alert"
    id="alert"
    aria-live="assertive"
    aria-relevant="text"
    hidden
  ></div>

  <script src="yourscript.js"></script>
</body>

And, add a function to change its state:

let alert = document.getElementById('alert');

let updateStatus = () => {
  let isOnline = navigator.onLine;

  document.documentElement.classList.toggle('is-offline', !isOnline);
  alert.textContent = isOnline ? 'Online' : 'Offline';

  if (isOnline) {
    setTimeout(() => {
      alert.hidden = true;
    }, 3000);
  } else {
    alert.hidden = false;
  }
};

// Then we need to hook up function to event listeners…

Show Offline Status: Alert & Forms

let alert = document.getElementById('alert');

let updateStatus = () => {
  let isOnline = navigator.onLine;

  document.documentElement.classList.toggle('is-offline', !isOnline);
  alert.textContent = isOnline ? 'Online' : 'Offline';

  if (isOnline) {
    setTimeout(() => {
      alert.hidden = true;
    }, 3000);
  } else {
    alert.hidden = false;
  }
};

// hook up updateStatus to event listeners…
let checkConnectivity = () => {
  if (typeof navigator.onLine === 'undefined') {
    return;
  }

  updateStatus();
  window.addEventListener('online', updateStatus);
  window.addEventListener('offline', updateStatus);
};

window.addEventListener('load', checkConnectivity);

Offline Form

.Alert {
  color: #070;
  background-color: #efe;
  border: 1px solid #070;
  position: fixed;
  right: 20px;
  top: 20px;
  padding: 10px;
  z-index: 10;
}
.is-offline {
  & .Alert {
    color: #700;
    background-color: #fee;
    border: 1px solid #700;
  }
  & form {
    position: relative;
    opacity: .5;
    cursor: not-allowed;
    pointer-events: none;
  }
}

Show Offline Status: Uncached Links

See https://mxb.at/blog/youre-offline/

const links = document.querySelectorAll('a[href]');

[...links].forEach((link) => {
  const sameOrigin = location.origin === link.origin;
  const samePath = location.pathname === link.pathname;

  if (sameOrigin && samePath) {
    link.classList.add('is-cached');
  }

  caches
  .match(link.href, {ignoreSearch: true})
  .then((response) => {
    if (response) {
      link.classList.add('is-cached');
    }
  });
});
.is-offline {
  & a:not(.is-cached) {
    opacity: .5;
    cursor: not-allowed;
    pointer-events: none;
    text-decoration: none;
    &::before {
      content: '\26D4';
      text-decoration: none;
      font-family: Helvetica, Arial, sans-serif;
      margin-right: .25em;
    }
  }
}

Application Cache

Yes, yes, we all know it's "a douchebag" and the W3C has deprecated it.

But if your browser doesn't support Service Workers (*cough* Safari *cough*), it's better than nothing.

http://caniuse.com/#search=serviceworker

PWAs: Progressive Web Apps

See Google Web App Checklist

PWAs: Service Workers

Intercept network requests to do different things based on network connection, cached status of assets, your preferences.

Some possibilities:

Registering a Service Worker — Minimal

if (navigator.serviceWorker.controller) {
  console.log('Active service worker found. No need to register');
} else {

// Register the ServiceWorker
  navigator.serviceWorker.register('/sw.js', {
    // Default scope:
    scope: './'
  })
  .then(function(reg) {
    console.log(`Registered service worker for scope: ${reg.scope}`);
  });
}

Registering a Service Worker — Robust

let host = location.hostname;
let httpsOnly = location.protocol === 'https:';

let registerWorker = (filePath) => {

  if (!navigator.serviceWorker ||
    typeof Cache === 'undefined' || !Cache.prototype.addAll ||
    !(httpsOnly || host === '127.0.0.1' || host === 'localhost')
  ) {
    return console.log('[Register]', 'Your browser does not support serviceWorker at this domain');
  }

  navigator.serviceWorker.register(filePath)
  .then((registration) => {

    // updatefound is fired if sw.js changes.
    registration.onupdatefound = () => {
      // The updatefound event implies that registration.installing is set
      var installingWorker = registration.installing;

      installingWorker.onstatechange = function() {
        let state = installingWorker.state;

        console.log('[Register]', 'onstatechange triggered', state);

        if (state === 'installed') {

          if (navigator.serviceWorker.controller) {
            // At this point, the old content has been purged and the fresh content has been added to the cache.
            // You might wnat to display a "New content is available; please refresh."
            console.log('[Register]', 'New or updated content is available.');
          } else {
            // At this point, everything has been precached, but the service worker is not
            // controlling the page. The service worker will not take control until the next
            // reload or navigation to a page under the registered scope.
            // It's the perfect time to display a "Content is cached for offline use." message.
            console.log('[Register]', 'Content is cached, and will be available for offline use the next time the page is loaded.');
          }

        } else if (state === 'redundant') {
          console.error('[Register]', 'The installing service worker became redundant.');
        }
      };
    };

    // Check to see if there's an updated version of sw.js with new files to cache
    if (typeof registration.update === 'function') {
      registration.update();
    }

  })
  .catch((event) => {
    console.error('[Register]', 'Error during service worker registration:', event);
  });
};

registerWorker('sw.js');

Service Worker: Offline Page Fallback

Regarding waitUntil():

In service workers, extending the life of an event prevents the browser from terminating the service worker before asynchronous operations within the event have completed.
const cacheId = 'barcamp2017';
const precacheFiles = [
  // Fallback pages:
  'offline.html',
  '404.html',

  // Important assets:
  '/barcamp2017/css/app.css',
  '/barcamp2017/js/app.js',
  'sw-register.js'
];

const precache = () => {};
const getFromServer = (request) => {};
const getFallbackFile = (matching) => {};
const getFromCache = (request) => {};
const addToCache = (request) => {};

// Set up the offline/404 pages (& primary assets) in the cache and open a new cache
self.addEventListener('install', (event) => {
  event.waitUntil(precache());
});

self.addEventListener('fetch', (event) => {
  // Network first: try getFromServer(), and if it fails getFromCache()
  event.respondWith(
    getFromServer(event.request)
    .catch(() => {
      return getFromCache(event.request);
    })
  );

  event.waitUntil(addToCache(event.request));
});

Service Worker: Offline Page Fallback

example

Complete code:

const cacheId = 'barcamp2017';
const precacheFiles = [
  'offline.html',
  '404.html',
  '/barcamp2017/css/app.css',
  '/barcamp2017/js/app.js',
  'sw-register.js'
];

const precache = () => {
  return caches.open(cacheId)
  .then((cache) => {
    return cache.addAll(precacheFiles);
  });
};

const getFromServer = (request) => {
  return new Promise((resolve, reject) => {
    fetch(request)
    .then((response) => {
      if (response.status !== 404) {
        resolve(response);
      } else {
        reject();
      }
    }, reject);
  });
};

const getFallbackFile = (matching) => {
  if (!matching || matching.status === 404) {
    return !matching ? 'offline.html' : '404.html';
  }

  return null;
};

const getFromCache = (request) => {
  return caches.open(cacheId)
  .then((cache) => {
    return cache.match(request)
    .then((matching) => {
      let fallback = getFallbackFile(matching);

      if (fallback) {
        return cache.match(fallback);
      }

      return matching;
    });
  });
};

const addToCache = (request) => {
  return caches.open(cacheId)
  .then((cache) => {
    return fetch(request)
    .then((response) => {
      if (!response.url) {
        return;
      }

      return cache.put(request, response);
    });
  });
};

self.addEventListener('install', (event) => {
  event.waitUntil(precache());
});

self.addEventListener('fetch', (event) => {
  event.respondWith(
    getFromServer(event.request)
    .catch(() => {
      return getFromCache(event.request)
      .catch((err) => {
        console.error('Getting from cache failed:', err);

        throw err;
      });
    })
  );

  event.waitUntil(addToCache(event.request));
});

Service Worker: Cache-first

const cacheId = 'barcamp2017';
const precacheFiles = [
  'offline.html',
  '404.html',
  '/css/app.css',
  '/js/app.js',
  'sw-register.js'
];

const precache = () => {
  return caches.open(cacheId)
  .then(function(cache) {
    return cache.addAll(precacheFiles);
  });
};

const getFromCache = (request) => {
  return caches.open(cacheId)
  .then((cache) => {
    return cache.match(request)
    .then((matches) => {
      return matches || Promise.reject('no-match');
    });
  });
};

const getFromServer = (request) => {
  return fetch(request)
  .then(function(response) {
    return response;
  });
};

// Get newest version of file for next time
const update = (request) => {
  return caches.open(cacheId)
  .then(function(cache) {
    return fetch(request)
    .then(function(response) {
      return cache.put(request, response);
    });
  });
};

// LISTENERS
self.addEventListener('install', function(event) {
  event.waitUntil(precache()
  .then(function() {
    // Forces trigger of 'activate' without waiting for browser refresh
    return self.skipWaiting();
  }));
});

self.addEventListener('activate', (event) => {
  // Along with self.skipWaiting() in the install listener,
  // lets the serviceWorker start working immediately without navigation in the browser
  return self.clients.claim();
});

self.addEventListener('fetch', (event) => {
  event.respondWith(
    getFromCache(event.request)
    .catch(getFromServer(event.request))
  );

  // Only do this for same-site resources
  if (location.origin === event.request.url.origin) {
    event.waitUntil(update(event.request));
  }

});

Service Worker: A Few Tips

Service Worker: Clean Up

// Name and version could be pulled from package.json & inserted during build process
// or output to a different file and included here with self.importScripts();
const name = 'barcamp2017';
const version = '1.1.3';

const cacheId = `${name}-${version}`;

// Do the typical sw business with install and fetch lifecycle events...

// Then delete unused/unexpected caches in activate event:
self.addEventListener('activate', (event) => {

  event.waitUntil(
    caches.keys()
    .then(function(cacheIds) {
      return Promise.all(
        cacheIds
        .filter((item) => item !== cacheId)
        .map((item) => caches.delete(item))
      );
    })
  );
});

Service Worker: Advanced Caching

Use workbox.js:

const path = require('path');
const dest = path.join(process.cwd(), 'public');

const wbBuild = require('workbox-build');

wbBuild.generateSW({
  cacheId: 'barcamp2017',
  swDest: path.join(dest, 'sw-workbox.js'),
  globDirectory: `${dest}/`,
  globPatterns: [
    '**/*-*.css',
    'js/*-*.js',
    '**/img/*.png',
    '**/img/sprites/*.svg',
  ],
  globIgnores: [
    '**/admin*/**',
  ],
  clientsClaim: true,
  runtimeCaching: [
    {
      urlPattern: /game\//,
      handler: 'staleWhileRevalidate',
    },
    {
      urlPattern: /\/fonts\//,
      handler: 'cacheFirst',
    }
  ],

  // Skip cache busting for revved files
  dontCacheBustUrlsMatching: /-[0-9a-f]{10}\./,
})
.then(() => {
  console.log('Built serviceWorker');
})
.catch(console.error);

Service Worker: workbox.js Generated Config

Workbox.js produces this sw-workbox.js file:

// eslint-disable-next-line no-undef
importScripts('workbox-sw.prod.v1.0.1.js');

const fileManifest = [
  '/css/app-dc30f5ecff.css',
  '/css/print-d41d8cd98f.css',
  '/js/tail-fd0b93d19a.js',
  {
    url: '/img/logo.alpha.png',
    revision: '54cad1ce8891f9305a4d3daacca15dad'
  },
  {
    url: '/img/logo.alpha@2x.png',
    revision: '54cad1ce8891f9305a4d3daacca15dad'
  },
  {
    url: '/img/waiting.png',
    revision: '224dcc910d81ab892d41e3004d80e2a2'
  },
  {
    url: '/img/sprites/sprites.svg',
    revision: '5bafa6cbdd8d36a460e92f5e902399f4'
  }
];

const workboxSW = new self.WorkboxSW({
  cacheId: 'barcamp2017',
  clientsClaim: true
});

workboxSW.precache(fileManifest);

workboxSW.router.registerRoute(/game\//, workboxSW.strategies.staleWhileRevalidate());
workboxSW.router.registerRoute(/\/fonts\//, workboxSW.strategies.cacheFirst());

Service Worker: Background Sync

Background Sync Resources

My Experience

Databases

…Versus SW background sync (ideally)

Example: Image Gallery

Databases: PouchDB / CouchDB

PouchDB was created to help web developers build applications that work as well offline as they do online.

It enables applications to store data locally while offline, then synchronize it with CouchDB and compatible servers when the application is back online, keeping the user's data in sync no matter where they next login.

// Copied/modified from pouchdb.com

import PouchDB from 'pouchdb';

var db = new PouchDB('barcamp2017');

db.changes().on('change', function() {
  console.log('Ch-Ch-Changes');
});

db.put({
  _id: 'karl@example.com',
  name: 'Karl',
  age: 32
});

db.replicate.to('http://example.com/mydb');

Databases: GUN

GUN on GitHub

GUN works even if your internet or cell reception doesn't. Users can still plug away and save data as normal, and then when the network comes back online GUN will automatically synchronize all the changes and handle any conflicts for you.
// Copied/modified from https://github.com/amark/gun/wiki/Getting-Started-%28v0.3.x%29
import Gun from './gun.js';

// Connect local db to  peers
var peers = ['example1.com', 'example2.com'];

var gun = new Gun(peers);

// Create an interface for the `greetings`
// key, storing it in a variable.
var greetings = gun.get('greetings');

// Update the value on `greetings`.
greetings.put({hello: 'world'});

// Read the value and listen for
// any changes.
greetings.on(function(data) {
  console.log('Update!', data);
});

Offline-first Framework: Hoodie

hood.ie

Further Resources

Info

Tools

My Examples

Thanks!