Barcamp GR
26 August 2017
Karl Swedberg
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…
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);
.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; } }
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; } } }
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.
Make sure you don't cache the manifest itself.
ExpiresByType text/cache-manifest "access plus 0 seconds"
Intercept network requests to do different things based on network connection, cached status of assets, your preferences.
Some possibilities:
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}`); }); }
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');
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)); });
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)); });
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)); } });
let isGetHtml = (request) => { return request.mode === 'navigate' || (request.method === 'GET' && request.headers.get('accept').includes('text/html')); }; self.addEventListener('fetch', event => { let request = event.request; if (isGetHtml(request)) { // Do network-first event.respondWith() } else { // Do cache-first event.respondWith() } });
// 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)) ); }) ); });
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);
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());
Example: Image Gallery
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');
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); });