diff --git a/service-worker.js b/service-worker.js new file mode 100644 index 00000000..8a4dce1f --- /dev/null +++ b/service-worker.js @@ -0,0 +1,191 @@ +/** + * service-worker.js : Service Worker implementation, + * in order to capture the HTTP requests made by an article, and respond with the + * corresponding content, coming from the archive + * + * Copyright 2015 Mossroy and contributors + * License GPL v3: + * + * This file is part of Kiwix. + * + * Kiwix is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Kiwix is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Kiwix (file LICENSE-GPLv3.txt). If not, see + */ +'use strict'; + +self.addEventListener('install', function(event) { + event.waitUntil(self.skipWaiting()); + console.log("ServiceWorker installed"); +}); + +self.addEventListener('activate', function(event) { + // "Claiming" the ServiceWorker is necessary to make it work right away, + // without the need to reload the page. + // See https://developer.mozilla.org/en-US/docs/Web/API/Clients/claim + event.waitUntil(self.clients.claim()); + console.log("ServiceWorker activated"); +}); + +var regexpRemoveUrlParameters = new RegExp(/([^\?]+)\?.*$/); + +// This function is duplicated from uiUtil.js +// because using requirejs would force to add the 'fetch' event listener +// after the initial evaluation of this script, which is not supported any more +// in recent versions of the browsers. +// Cf https://bugzilla.mozilla.org/show_bug.cgi?id=1181127 +// TODO : find a way to avoid this duplication +function removeUrlParameters(url) { + if (regexpRemoveUrlParameters.test(url)) { + return regexpRemoveUrlParameters.exec(url)[1]; + } else { + return url; + } +} + +console.log("ServiceWorker startup"); + +var outgoingMessagePort = null; +var fetchCaptureEnabled = false; +self.addEventListener('fetch', fetchEventListener); +console.log('fetchEventListener set'); + +self.addEventListener('message', function (event) { + if (event.data.action === 'init') { + console.log('Init message received', event.data); + outgoingMessagePort = event.ports[0]; + console.log('outgoingMessagePort initialized', outgoingMessagePort); + fetchCaptureEnabled = true; + console.log('fetchEventListener enabled'); + } + if (event.data.action === 'disable') { + console.log('Disable message received'); + outgoingMessagePort = null; + console.log('outgoingMessagePort deleted'); + fetchCaptureEnabled = false; + console.log('fetchEventListener disabled'); + } +}); + +// TODO : this way to recognize content types is temporary +// It must be replaced by reading the actual MIME-Type from the backend +var regexpJPEG = new RegExp(/\.jpe?g$/i); +var regexpPNG = new RegExp(/\.png$/i); +var regexpJS = new RegExp(/\.js/i); +var regexpCSS = new RegExp(/\.css$/i); + +var regexpContentUrlWithNamespace = new RegExp(/\/(.)\/(.*[^\/]+)$/); +var regexpContentUrlWithoutNamespace = new RegExp(/^([^\/]+)$/); +var regexpDummyArticle = new RegExp(/dummyArticle\.html$/); + +function fetchEventListener(event) { + if (fetchCaptureEnabled) { + console.log('ServiceWorker handling fetch event for : ' + event.request.url); + + // TODO handle the dummy article more properly + if ((regexpContentUrlWithNamespace.test(event.request.url) + || regexpContentUrlWithoutNamespace.test(event.request.url)) + && !regexpDummyArticle.test(event.request.url)) { + + console.log('Asking app.js for a content', event.request.url); + event.respondWith(new Promise(function(resolve, reject) { + var nameSpace; + var title; + var titleWithNameSpace; + var contentType; + if (regexpContentUrlWithoutNamespace.test(event.request.url)) { + // When the request URL is in the same folder, + // it means it's a link to an article (namespace A) + var regexpResult = regexpContentUrlWithoutNamespace.exec(event.request.url); + nameSpace = 'A'; + title = regexpResult[1]; + } else { + var regexpResult = regexpContentUrlWithNamespace.exec(event.request.url); + nameSpace = regexpResult[1]; + title = regexpResult[2]; + } + + // The namespace defines the type of content. See http://www.openzim.org/wiki/ZIM_file_format#Namespaces + // TODO : read the contentType from the ZIM file instead of hard-coding it here + if (nameSpace === 'A') { + console.log("It's an article : " + title); + contentType = 'text/html'; + } + else if (nameSpace === 'I' || nameSpace === 'J') { + console.log("It's an image : " + title); + if (regexpJPEG.test(title)) { + contentType = 'image/jpeg'; + } + else if (regexpPNG.test(title)) { + contentType = 'image/png'; + } + } + else if (nameSpace === '-') { + console.log("It's a layout dependency : " + title); + if (regexpJS.test(title)) { + contentType = 'text/javascript'; + var responseInit = { + status: 200, + statusText: 'OK', + headers: { + 'Content-Type': contentType + } + }; + + var httpResponse = new Response(';', responseInit); + + // TODO : temporary before the backend actually sends a proper content + resolve(httpResponse); + return; + } + else if (regexpCSS.test(title)) { + contentType = 'text/css'; + } + } + + // We need to remove the potential parameters in the URL + title = removeUrlParameters(decodeURIComponent(title)); + + titleWithNameSpace = nameSpace + '/' + title; + + // Let's instanciate a new messageChannel, to allow app.s to give us the content + var messageChannel = new MessageChannel(); + messageChannel.port1.onmessage = function(event) { + if (event.data.action === 'giveContent') { + console.log('content message received for ' + titleWithNameSpace, event.data); + var responseInit = { + status: 200, + statusText: 'OK', + headers: { + 'Content-Type': contentType + } + }; + + var httpResponse = new Response(event.data.content, responseInit); + + console.log('ServiceWorker responding to the HTTP request for ' + titleWithNameSpace + ' (size=' + event.data.content.length + ' octets)' , httpResponse); + resolve(httpResponse); + } + else { + console.log('Invalid message received from app.js for ' + titleWithNameSpace, event.data); + reject(event.data); + } + }; + console.log('Eventlistener added to listen for an answer to ' + titleWithNameSpace); + outgoingMessagePort.postMessage({'action': 'askForContent', 'title': titleWithNameSpace}, [messageChannel.port2]); + console.log('Message sent to app.js through outgoingMessagePort'); + })); + } + // If event.respondWith() isn't called because this wasn't a request that we want to handle, + // then the default request/response behavior will automatically be used. + } +}