Skip to content

Instantly share code, notes, and snippets.

@nyroDev
Last active February 15, 2020 19:43
Show Gist options
  • Select an option

  • Save nyroDev/d13f1f4891161a5493b9d80d78041c35 to your computer and use it in GitHub Desktop.

Select an option

Save nyroDev/d13f1f4891161a5493b9d80d78041c35 to your computer and use it in GitHub Desktop.
Service Worker with centralized listeners
import SWHandler from './sw/SWHandler.mjs';
import AuthHandler from './sw/Handler/AuthHandler.mjs';
import DemoHandler from './sw/Handler/DemoHandler.mjs';
import CacheHandler from './sw/Handler/CacheHandler.mjs';
const swHandler = new SWHandler(self);
swHandler.addHandler(new AuthHandler(), 'auth');
swHandler.addHandler(new DemoHandler());
// CacheHandler should always be at the end
swHandler.addHandler(new CacheHandler());
swHandler.init();
/*
* Abstract Handler that should be extended to create a new SW handler.
*
* Possibles function to implement are listed below.
*
* init()
* Called in the addHandler process, just after setting the swHandler
*
* install(event)
* @param event The install event
* @return Promise|void
* Called during a install event, promise will be used in the waitUntil in a Promise.all wrapper
*
* activate(event)
* @param event The activate event
* @return Promise|void
* Called during a activate event, promise will be used in the waitUntil in a Promise.all wrapper
*
* fetchRegular(payloadRequest)
* @param payloadRequest PayloadRequest object (see below)
* @return true if a regular fetch should be used
* Called at the beginning of the fetch event, to completly ignore SW fetch handling,
* if at least 1 handler return true from this function.
*
* fetchRequest(payloadRequest)
* @param payloadRequest PayloadRequest object (see below)
* @return Promise that should resolve with the payloadRequest or the payloadRequest
* Called at the beginning of the fetch event, one by one,
* in order to alter the payloadRequest used in the following fetchRequest and/or in the fetch call
*
* fetch(payloadRequest)
* @param payloadRequest object (see below)
* @return Promise|void that will be used in the event.waitUntil call
* Should handle the real fetch, if it's approprioate.
* If it's not, it should return false to be ignored, and the next fetch function will be used.
* The return is a Promise that will be used in the event.waitUntil function
* NB : Only the first function that return a promise will be used
*
* message(data)
* @param data Event data received from the message
* Called on every message received in the message listener
*
* sync(event)
* @param event The sync event
* @return Promise|event that should resolve to the with the sync event or the sync event
* Called on a sync event.
* Each function is called one by one, and the whole Promise chain will be used
* in the event.waitUntil call
*
* clean()
* use to trigger cleaning of indexDb data the handler is storing, (and eventually associated cache files...)
*
* pushNotif(notifData)
* @param notifData Object to use to build options for nativefunction .showNotification(title, options)
* @return Promise|void that will be used in the event.waitUntil call
* NB : Only the first function that return a promise will be used
*
* payloadRequest:
* This object have the following properties:
* - event: The original fetch event object
* - request: The request to use in a final fetch
* - requestUrl: URL object of the request to use in a final fetch
* - isSameOrigin: boolean indicates if the original request is on the same origin than the SW
*/
export default class AbstractHandler {
set swHandler(swHandler) {
this._swHandler = swHandler;
}
get swHandler() {
return this._swHandler;
}
}
import AbstractHandler from './AbstractHandler.mjs';
import StorableRequest from './../utils/StorableRequest.mjs'; // Same as found in workbox https://github.com/GoogleChrome/workbox/blob/master/packages/workbox-background-sync/models/StorableRequest.mjs
const loginCheckUrl = '/login_check';
const logoutUrl = '/logout';
export default class AuthHandler extends AbstractHandler {
getAuthHeaders() {
return Promise.resolve({
'X-AUTH-USER': '@todo',
'X-AUTH-TOKEN': 'todo'
});
}
_authRequest(request) {
return this.getAuthHeaders()
.then((headers) => {
const headersKeys = Object.keys(headers);
if (headersKeys.length === 0) {
return request;
}
return StorableRequest.fromRequest(request.clone())
.then(storableRequest => {
headersKeys.forEach((k) => {
storableRequest.requestInit.headers[k] = headers[k];
});
return storableRequest.toRequest();
});
});
}
fetchRequest(payload) {
if (
payload.isSameOrigin && ['OPTIONS', 'POST', 'PUT', 'PATCH', 'DELETE'].indexOf(payload.request.method) !== -1 &&
payload.requestUrl.pathname !== loginCheckUrl
) {
// This is an action/logged request, handle and authenticate it
return this._authRequest(payload.request)
.then((request) => {
payload.request = request;
return payload;
});
}
return Promise.payload;
}
fetch(payload) {
if (!payload.isSameOrigin) {
return;
}
if (payload.requestUrl.pathname === loginCheckUrl) {
// Pass request to server and save infos if success
return this.swHandler.fetch(payload.request.clone())
.then((response) => {
// add login info into idb
response.clone().json()
.then((loginInfos) => {
//@todo save it in indexedDb
});
return response;
});
} else if (payload.requestUrl.pathname === logoutUrl) {
return this._authRequest(payload.request)
.then((request) => {
return this.swHandler.fetch(request)
.then((response) => {
//@todo clean login info into idb
return response;
})
});
}
}
}
import AbstractHandler from './AbstractHandler.mjs';
const cachePrefix = 'cache_';
const cacheName = 'cache_1234'; // @todo should change on every build
const otherCacheToKeep = 'cache_name_to_keep';
const assets = [
'/path/to/cache.css',
'/path/to/cache.js'
];
export default class CacheHandler extends AbstractHandler {
install() {
let assetsToCache = assets.map(path => {
return new URL(path, location).toString();
});
return caches
.open(cacheName)
.then(cache => {
return cache.addAll(assetsToCache);
})
.catch(error => {
console.error(error);
throw error;
});
}
activate() {
const allCaches = [
cacheName,
otherCacheToKeep
];
return caches.keys().then(cacheNames => {
return Promise.all(
cacheNames.filter(cn => {
return cn.startsWith(cachePrefix) && !allCaches.includes(cn);
}).map(function(cn) {
return caches.delete(cn);
})
);
});
}
fetch(payload) {
// Ignore non GET request.
if (payload.request.method !== 'GET') {
return;
}
return caches.match(payload.request).then(response => {
if (response) {
if (payload.request.headers.get('range')) {
return workbox.rangeRequests.createPartialResponse(payload.request, response);
}
return response;
}
return response;
});
}
}
import AbstractHandler from './AbstractHandler.mjs';
const demoUrl = '/demo';
export default class DemoHandler extends AbstractHandler {
fetch(payload) {
if (!payload.isSameOrigin) {
return;
}
if (payload.requestUrl.pathname === demoUrl) {
// Request sync when a request fail or something...
this.swHandler.requestSync('syncEventDemo');
return this.swHandler.jsonResponse({
demo: 'This a demo response from DemoHandler'
});
}
}
message(data) {
if (data.action === 'messageFromClient') {
//@todo do something
}
}
sync(e) {
if (e.tag === 'syncEventDemo') {
// @todo do something
}
}
}
let BreakException = {};
export default class SWHandler {
constructor() {
this._handlers = [];
this._nameHandlers = {};
}
init() {
self.addEventListener('install', event => {
this._handleInstall(event);
});
self.addEventListener('activate', event => {
this._handleActivate(event);
});
self.addEventListener('fetch', event => {
this._handleFetch(event);
});
self.addEventListener('message', event => {
this._handleMessage(event);
});
self.addEventListener('sync', event => {
this._handleSync(event);
});
self.addEventListener('push', event => {
this._handlePush(event);
});
self.addEventListener('notificationclick', event => {
this._handleNotificationclick(event);
});
// Trigger clean
this._clean();
}
_handleInstall(event) {
let promises = [];
this._handlers.forEach((handler) => {
const handlerPromise = handler.install ? handler.install(event) : false;
if (handlerPromise) {
promises.push(handlerPromise);
}
});
if (promises.length) {
event.waitUntil(Promise.all(promises));
}
}
_handleActivate(event) {
let promises = [];
this._handlers.forEach((handler) => {
const handlerPromise = handler.activate ? handler.activate(event) : false;
if (handlerPromise) {
promises.push(handlerPromise);
}
});
if (promises.length) {
event.waitUntil(Promise.all(promises));
}
this._clean();
}
_handleFetch(event) {
const requestUrl = new URL(event.request.url);
let payload = {
event,
request: event.request,
requestUrl: requestUrl,
isSameOrigin: requestUrl.origin === location.origin
};
// First call fetchRequest callback to enable handler to update request if needed (like auth)
let payloadPromise = Promise.resolve(payload);
let regularFetch = false;
this._handlers.forEach((handler) => {
if (!regularFetch && handler.fetchRegular) {
regularFetch = handler.fetchRegular(payload);
}
if (!regularFetch && handler.fetchRequest) {
payloadPromise = payloadPromise.then(handler.fetchRequest.bind(handler));
}
});
if (regularFetch) {
// A handler indicated that we should use regular fetch, return here and do nothing with the event
return;
}
event.respondWith(
payloadPromise.then((payload) => {
// Then try each fetch request, and stop to the first one that returns something
let promise;
try {
this._handlers.forEach((handler) => {
if (handler.fetch) {
promise = handler.fetch(payload);
if (promise) {
throw BreakException;
}
}
});
} catch (e) {
if (e !== BreakException) {
throw e;
}
}
if (!promise) {
promise = fetch(payload.request.clone());
} else {
promise = promise.then(response => {
if (response) {
return response;
}
return fetch(payload.request.clone());
});
}
return promise;
})
);
}
triggerCustomMessage(data) {
return this._handleMessage({
data: data
});
}
_handleMessage(event) {
this._handlers.forEach((handler) => {
if (handler.message) {
handler.message(event.data);
}
});
}
_handleSync(event) {
let promises = [];
this._handlers.forEach((handler) => {
const handlerPromise = handler.sync ? handler.sync(event) : false;
if (handlerPromise) {
promises.push(handlerPromise);
}
});
if (promises.length) {
event.waitUntil(Promise.all(promises));
}
}
_handlePush(event) {
if (Notification.permission == 'granted') {
var notif = event.data.json();
let promise,
handled = false;
this._handlers.forEach((handler) => {
if (handler.pushNotif && !handled) {
promise = handler.pushNotif(notif);
if (promise) {
event.waitUntil(promise);
handled = true;
}
}
});
}
}
_handleNotificationclick(event) {
}
_clean() {
// call each handler having clean method.
this._handlers.forEach((handler) => {
if (handler.clean) {
if (!this._options.isProd) {
console.log(handler.constructor.name + '->clean()');
}
handler.clean();
}
});
}
addHandler(handler, name) {
handler.swHandler = this;
if (handler.init) {
handler.init();
}
this._handlers.push(handler);
if (name) {
this._nameHandlers[name] = this._handlers.length - 1;
}
}
getHandler(name) {
if (!(name in this._nameHandlers)) {
throw 'Handler ' + name + ' not found';
}
return this._handlers[this._nameHandlers[name]];
}
jsonResponse(data, init) {
if (!init) {
init = {};
}
init.headers = {
'Content-Type': 'application/json'
};
return Promise.resolve(new Response(JSON.stringify(data), init));
}
postMessage(msg) {
return clients.matchAll()
.then(function(clients) {
clients.map(function(client) {
client.postMessage(msg);
});
});
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment