From 3fabd1d6525d656bc5ba1d8bd313a9eb9ed97915 Mon Sep 17 00:00:00 2001 From: "sven.franck@nexedi.com" Date: Fri, 27 Jan 2017 13:16:26 +0000 Subject: [PATCH] WIP serviceworker storage prototype --- src/jio.storage/serviceworkerstorage.js | 245 +++++++++++++++ test/serviceworker.js | 393 ++++++++++++++++++++++++ test/serviceworker.test.js | 0 3 files changed, 638 insertions(+) create mode 100644 src/jio.storage/serviceworkerstorage.js create mode 100644 test/serviceworker.js create mode 100644 test/serviceworker.test.js diff --git a/src/jio.storage/serviceworkerstorage.js b/src/jio.storage/serviceworkerstorage.js new file mode 100644 index 0000000..9b128ab --- /dev/null +++ b/src/jio.storage/serviceworkerstorage.js @@ -0,0 +1,245 @@ +/** + * JIO Service Worker Storage Type = "serviceworker". + * Servieworker "filesystem" storage. + */ +/*global Blob, jIO, RSVP, navigator MessageChannel*/ +/*jslint indent: 2 nomen: true maxerr: 3*/ + +(function (jIO, RSVP, navigator) { + "use strict"; + + // no need to validate attachment name, because serviceworker.js will throw + function restrictDocumentId(id) { + if (id.indexOf("/") > -1) { + throw new jIO.util.jIOError("id should be a name, not a path)", 400); + } + return id; + } + + // validate browser support, serviceworker registration must be done in gadget + function validateConnection() { + if ("serviceWorker" in navigator === false) { + throw new jIO.util.jIOError("Serviceworker not available in browser", 503); + } + } + + // This wraps the message posting/response in a promise, which will resolve if + // the response doesn't contain an error, and reject with the error if it does. + // Alternatively, onmessage handle and controller.postMessage() could be used + function sendMessage(message) { + return new RSVP.Promise(function (resolve, reject, notify) { + var messageChannel = new MessageChannel(); + messageChannel.port1.onmessage = function (event) { + if (event.data.error) { + reject(event.data.error); + } else { + resolve(event.data.data); + } + }; + + // This sends the message data as well as transferring + // messageChannel.port2 to the service worker. The service worker can then + // use the transferred port to reply via postMessage(), which will in turn + // trigger the onmessage handler on messageChannel.port1. + // See https://html.spec.whatwg.org/multipage/workers.html + return navigator.serviceWorker.controller + .postMessage(message, [messageChannel.port2]); + }); + } + + /** + * The JIO Serviceworker Storage extension + * + * @class ServiceWorkerStorage + * @constructor + */ + function ServiceWorkerStorage () {} + + ServiceWorkerStorage.prototype.post = function () { + throw new jIO.util.jIOError("Storage requires 'put' to create new cache", + 400); + }; + + ServiceWorkerStorage.prototype.get = function (id) { + return new RSVP.Queue() + .push(function () { + return validateConnection(); + }) + .push(function () { + return sendMessage({ + command: "get", + id: restrictDocumentId(id) + }); + }) + .push(undefined, function (error) { + if (error.status === 404) { + throw new jIO.util.jIOError(error.message, 404); + } + throw error; + }); + }; + + ServiceWorkerStorage.prototype.put = function (id) { + return new RSVP.Queue() + .push(function () { + return validateConnection(); + }) + .push(function () { + return sendMessage({ + command: "get", + id: restrictDocumentId(id) + }); + }) + .push(undefined, function (error) { + if (error.status === 404) { + return new RSVP.Queue() + .push(function () { + return sendMessage({ + command: "put", + id: id + }); + }); + } + throw error; + }); + }; + + ServiceWorkerStorage.prototype.remove = function (id) { + return new RSVP.Queue() + .push(function () { + return validateConnection(); + }) + .push(function () { + return sendMessage({ + command: "allAttachments", + id: restrictDocumentId(id) + }); + }) + .push(function (attachment_dict) { + var url_list = [], + url; + for (url in attachment_dict) { + if (attachment_dict.hasOwnProperty(url)) { + url_list.append(sendMessage({ + command: "removeAttachment", + id: url + })); + } + } + return RSVP.all(url_list); + }) + .push(function () { + return sendMessage({ + command: "remove", + id: restrictDocumentId(id) + }); + }); + }; + + ServiceWorkerStorage.prototype.removeAttachment = function (id, url) { + return new RSVP.Queue() + .push(function () { + return validateConnection(); + }) + .push(function () { + return sendMessage({ + command: "removeAttachment", + id: restrictDocumentId(id), + name: url + }); + }); + }; + + ServiceWorkerStorage.prototype.getAttachment = function (id, url) { + + // NOTE: alternatively get could also be run "official" way via + // an ajax request, which the serviceworker would catch via fetch listener! + // for a filesystem equivalent however, we don't assume fetching resources + // from the network, so all methods will go through sendMessage + + return new RSVP.Queue() + .push(function () { + return validateConnection(); + }) + .push(function () { + return sendMessage({ + command: "getAttachment", + id: restrictDocumentId(id), + name: url + }); + }) + .push(function (my_blob_response) { + return my_blob_response; + }); + }; + + ServiceWorkerStorage.prototype.putAttachment = function (id, name, param) { + return new RSVP.Queue() + .push(function () { + return validateConnection(); + }) + .push(function () { + return sendMessage({ + command: "putAttachment", + id: id, + name: name, + content: param + }); + }); + }; + + ServiceWorkerStorage.prototype.allAttachments = function (id) { + return new RSVP.Queue() + .push(function () { + return validateConnection(); + }) + .push(function () { + return sendMessage({ + command: "allAttachments", + id: restrictDocumentId(id) + }); + }); + }; + + ServiceWorkerStorage.prototype.hasCapacity = function (name) { + return (name === "list"); + }; + + // returns a list of all caches ~ folders + ServiceWorkerStorage.prototype.allDocs = function (options) { + var context = this; + + if (options === undefined) { + options = {}; + } + + return new RSVP.Queue() + .push(function () { + return validateConnection(); + }) + .push(function () { + if (context.hasCapacity("list")) { + return context.buildQuery(options); + } + }) + .push(function (result) { + return result; + }); + }; + + ServiceWorkerStorage.prototype.buildQuery = function (options) { + return new RSVP.Queue() + .push(function () { + return validateConnection(); + }) + .push(function () { + return sendMessage({ + command: "allDocs", + options: options + }); + }); + }; + + jIO.addStorage("serviceworker", ServiceWorkerStorage); + +}(jIO, RSVP, navigator, MessageChannel)); diff --git a/test/serviceworker.js b/test/serviceworker.js new file mode 100644 index 0000000..1ff63e3 --- /dev/null +++ b/test/serviceworker.js @@ -0,0 +1,393 @@ +/* + * JIO Service Worker Storage Backend. + */ + +// this polyfill provides Cache.add(), Cache.addAll(), and CacheStorage.match(), +// should not be needed for Chromium > 47 And Firefox > 39 +// see https://developer.mozilla.org/en-US/docs/Web/API/Cache +// importScripts('./serviceworker-cache-polyfill.js'); + +// debug: +// chrome://cache/ +// chrome://inspect/#service-workers +// chrome://serviceworker-internals/ +// +// bar = new Promise(function (resolve, reject) { +// return caches.keys() +// .then(function (result) { +// console.log(result); +// return caches.open(result[0]) +// .then(function(cache){ +// return cache.keys() +// .then(function (request_list) { +// console.log(request_list); +// console.log("DONE"); +// resolve(); +// }); +// }); +// }); +//}); + +// clear last cache +// caches.keys().then(function(key_list) {console.log(key_list);return caches.open(key_list[0]);}).then(function(cache) {return cache.keys().then(function(request_list) {console.log(request_list); return cache.delete(request_list[0]);})}); +// list caches +// caches.keys().then(function(key_list) {console.log(key_list);return caches.open(key_list[0]);}).then(function(cache) {return cache.keys().then(function(request_list) {console.log(request_list);})}); + +// multiple serviceworkers => https://github.com/w3c/ServiceWorker/issues/921 +// https://developer.mozilla.org/en-US/docs/Web/API/Service_Worker_API/Using_Service_Workers +// intro http://www.html5rocks.com/en/tutorials/service-worker/introduction/ +// selective cache https://github.com/GoogleChrome/samples/blob/gh-pages/service-worker/selective-caching/service-worker.js +// handling POST with indexedDB: https://serviceworke.rs/request-deferrer.html + +// versioning allows to keep a clean cache, current_cache is accessed on fetch +var CURRENT_CACHE_VERSION = 1; +var CURRENT_CACHE; +var CURRENT_CACHE_DICT = { + "self": "self-v" + CURRENT_CACHE_VERSION +}; + +// runs while an existing worker runs or nothing controls the page (update here) +//self.addEventListener('install', function (event) { +// +//}); + +// runs active page, changes here (like deleting old cache) breaks page +self.addEventListener("activate", function (event) { + + var expected_cache_name_list = Object.keys(CURRENT_CACHE_DICT).map(function(key) { + return CURRENT_CACHE_DICT[key]; + }); + + event.waitUntil(caches.keys() + .then(function(cache_name_list) { + return Promise.all( + cache_name_list.map(function(cache_name) { + version = cache_name.split("-v")[1]; + + // removes caches which are out of version + if (!(version && parseInt(version, 10) === CURRENT_CACHE_VERSION)) { + return caches.delete(cache_name); + } + + // removes caches which are not on the list of expected names + if (expected_cache_name_list.indexOf(cache_name) === -1) { + return caches.delete(cache_name); + } + }) + ); + }) + ); +}); + +// XXX "Server" +// intercept GET/POST network requests, serve from cache or network +/* +self.addEventListener("fetch", function (event) { + var url = event.request.url, + cacheable_list = [], + isCacheable = function (el) { + return url.indexOf(el) >= 0; + }; + + if (event.request.method === "GET") { + event.respondWith(caches.open(CURRENT_CACHE_DICT["self"]) + .then(function(cache) { + return cache.match(event.request) + .then(function(response) { + + // cached, return from cache + if (response) { + return response; + + // not cached, fetch from network + } + + // clone call, because any operation like fetch/put... will + // consume the request, so we need a copy of the original + // (see https://fetch.spec.whatwg.org/#dom-request-clone) + return fetch(event.request.clone()) + .then(function(response) { + + // add resource to cache + if (response.status < 400 && cacheable_list.some(isCacheable)) { + cache.put(event.request, response.clone()); + } + return response; + }); + }); + }) + .catch(function(error) { + + // This catch() will handle exceptions that arise from the match() + // or fetch() operations. Note that a HTTP error response (e.g. + // 404) will NOT trigger an exception. It will return a normal + // response object that has the appropriate error code set. + throw error; + }) + ); + + // XXX handle post with indexedDB here + //} else { + // event.respondWith(fetch(event.request)); + } +}); +*/ + +self.addEventListener("message", function (event) { + var param = event.data, + item, + mime_type, + result_list; + + switch (param.command) { + + // case "post" not supported + + // test if cache exits + case "get": + caches.keys().then(function(key_list) { + var i, len; + CURRENT_CACHE = param.id + "-v" + CURRENT_CACHE_VERSION; + for (i = 0, len = key_list.length; i < len; i += 1) { + if (key_list[i] === CURRENT_CACHE) { + event.ports[0].postMessage({ + error: null + }); + } + } + + // event.ports[0] corresponds to the MessagePort that was transferred + // as part of the controlled page's call to controller.postMessage(). + // Therefore, event.ports[0].postMessage() will trigger the onmessage + // handler from the controlled page. It's up to you how to structure + // the messages that you send back; this is just one example. + event.ports[0].postMessage({ + error: { + "status": 404, + "message": "Cache does not exist." + } + }); + }) + .catch(function(error) { + event.ports[0].postMessage({ + error: {"message": error.toString()} + }); + }); + + break; + + // create new cache by opening it. this will only run once per cache/folder + case "put": + if (param.id === "self") { + event.port[0].postMessage({ + error: { + "status": 406, + "message": "Reserved cache name. Please choose a different name." + } + }); + } + CURRENT_CACHE = param.id + "-v" + CURRENT_CACHE_VERSION; + CURRENT_CACHE_DICT[param.id] = CURRENT_CACHE; + caches.open(CURRENT_CACHE) + .then(function() { + event.ports[0].postMessage({ + error: null, + data: param.id + }); + }) + .catch(function(error) { + event.ports[0].postMessage({ + error: {"message": error.toString()} + }); + }); + break; + + // remove a cache + case "remove": + delete CURRENT_CACHE_DICT[param.id]; + CURRENT_CACHE = param.id + "-v" + CURRENT_CACHE_VERSION; + caches.delete(CURRENT_CACHE) + .then(function() { + event.ports[0].postMessage({ + error: null + }); + }) + .catch(function(error) { + event.ports[0].postMessage({ + error: {"message": error.toString()} + }); + }); + break; + + // return list of caches ~ folders + case "allDocs": + caches.keys().then(function(key_list) { + var result_list = [], + id, + i; + for (i = 0; i < key_list.length; i += 1) { + id = key_list[i].split("-v")[0]; + if (id !== "self") { + result_list.push({ + "id": id, + "value": {} + }); + } + } + event.ports[0].postMessage({ + error: null, + data: result_list + }); + }) + .catch(function(error) { + event.ports[0].postMessage({ + error: {"message": error.toString()} + }); + }); + break; + + // return all urls stored in a cache + case "allAttachments": + CURRENT_CACHE = param.id + "-v" + CURRENT_CACHE_VERSION; + + // returns a list of the URLs corresponding to the Request objects + // that serve as keys for the current cache. We assume all files + // are kept in cache, so there will be no network requests. + + caches.open(CURRENT_CACHE) + .then(function(cache) { + cache.keys() + .then(function (request_list) { + var result_list = request_list.map(function(request) { + return request.url; + }), + attachment_dict = {}, + i, + len; + + for (i = 0, len = result_list.length; i < len; i += 1) { + attachment_dict[result_list[i]] = {}; + } + event.ports[0].postMessage({ + error: null, + data: attachment_dict + }); + }); + }) + .catch(function(error) { + event.ports[0].postMessage({ + error: {"message": error.toString()} + }); + }); + break; + + case "removeAttachment": + CURRENT_CACHE = param.id + "-v" + CURRENT_CACHE_VERSION; + + caches.open(CURRENT_CACHE) + .then(function(cache) { + request = new Request(param.name, {mode: 'no-cors'}); + cache.delete(request) + .then(function(success) { + event.ports[0].postMessage({ + error: success ? null : { + "status": 404, + "message": "Item not found in cache." + } + }); + }); + }) + .catch(function(error) { + event.ports[0].postMessage({ + error: {'message': error.toString()} + }); + }); + break; + + case "getAttachment": + CURRENT_CACHE = param.id + "-v" + CURRENT_CACHE_VERSION; + caches.open(CURRENT_CACHE) + .then(function(cache) { + return cache.match(param.name) + .then(function(response) { + + // the response body is a ReadableByteStream which cannot be + // passed back through postMessage apparently. This link + // https://jakearchibald.com/2015/thats-so-fetch/ explains + // what can be done to get a Blob to return + + // XXX Improve + // However, calling blob() does not allow to set mime-type, so + // for currently the blob is created, read, stored as new blob + // and returned (to be read again) + // https://github.com/whatwg/streams/blob/master/docs/ReadableByteStream.md + mime_type = response.headers.get("Content-Type"); + return response.clone().blob(); + }) + .then(function (response_as_blob) { + return new Promise(function(resolve) { + var blob_reader = new FileReader(); + blob_reader.onload = resolve; + blob_reader.readAsText(response_as_blob); + }); + }) + .then(function (reader_response) { + return new Blob([reader_response.target.result], { + "type": mime_type + }); + }) + .then(function (converted_response) { + if (converted_response) { + event.ports[0].postMessage({ + error: null, + data: converted_response + }); + } else { + event.ports[0].postMessage({ + error: { + "status": 404, + "message": "Item not found in cache." + } + }); + } + }); + }) + .catch(function(error) { + event.ports[0].postMessage({ + error: {'message': error.toString()} + }); + }); + break; + + case "putAttachment": + CURRENT_CACHE = param.id + "-v" + CURRENT_CACHE_VERSION; + caches.open(CURRENT_CACHE) + .then(function(cache) { + + // If event.data.url isn't a valid URL, new Request() will throw a + // TypeError which will be handled by the outer .catch(). + // Hardcode {mode: 'no-cors} since the default for new Requests + // constructed from strings is to require CORS, and we don't have any + // way of knowing whether an arbitrary URL that a user entered + // supports CORS. + request = new Request(param.name, {mode: "no-cors"}); + response = new Response(param.content); + return cache.put(request, response); + }) + .then(function() { + event.ports[0].postMessage({ + error: null + }); + }) + .catch(function(error) { + event.ports[0].postMessage({ + error: {"message": error.toString()} + }); + }); + break; + + // refuse all else + default: + throw "Unknown command: " + event.data.command; + } +}); diff --git a/test/serviceworker.test.js b/test/serviceworker.test.js new file mode 100644 index 0000000..e69de29 -- 2.30.9