diff --git a/README.md b/README.md index 20c37e0..aa48ac6 100644 --- a/README.md +++ b/README.md @@ -18,6 +18,8 @@ Features an API using ES6 promises. * [CrossStorageClient.prototype.getKeys()](#crossstorageclientprototypegetkeys) * [CrossStorageClient.prototype.clear()](#crossstorageclientprototypeclear) * [CrossStorageClient.prototype.close()](#crossstorageclientprototypeclose) + * [CrossStorageClient.prototype.listen(callback)](#crossstorageclientprototypelisten) + * [CrossStorageClient.prototype.unlisten(key)](#crossstorageclientprototypeunlisten) * [Compatibility](#compatibility) * [Compression](#compression) * [Building](#building) @@ -125,12 +127,12 @@ Accepts an array of objects with two keys: origin and allow. The value of origin is expected to be a RegExp, and allow, an array of strings. The cross storage hub is then initialized to accept requests from any of the matching origins, allowing access to the associated lists of methods. -Methods may include any of: get, set, del, getKeys and clear. A 'ready' +Methods may include any of: get, set, del, getKeys, clear and listen. A 'ready' message is sent to the parent window once complete. ``` javascript CrossStorageHub.init([ - {origin: /localhost:3000$/, allow: ['get', 'set', 'del', 'getKeys', 'clear']} + {origin: /localhost:3000$/, allow: ['get', 'set', 'del', 'getKeys', 'clear', 'listen']} ]); ``` @@ -246,6 +248,34 @@ storage.onConnect().then(function() { }); ``` +#### CrossStorageClient.prototype.listen(callback) + +Adds an event listener to the `storage` event in the hub. All `storage` events +will be sent to the client and used to call the given callback. + +The callback will be called on each `storage` event, with an object with the +keys `key`, `newValue`, `oldValue` and `url` taken from the original event. + +``` javascript +var storageEventListenerKey; +storage.onConnect().then(function() { + return storage.listen(console.log); +}).then(function(key) { + storageEventListenerKey = key +}); +``` + +#### CrossStorageClient.prototype.unlisten(eventKey) + +Removes the storage event listener. + +The client will ignore any events as soon as this is called. Returns a promise +that is settled on successful event listener removal from the hub. + +``` javascript +storage.unlisten(storageEventListenerKey); +``` + ## Compatibility For compatibility with older browsers, simply load a Promise polyfill such as diff --git a/lib/client.js b/lib/client.js index 2a78d0f..247148e 100644 --- a/lib/client.js +++ b/lib/client.js @@ -48,6 +48,8 @@ this._count = 0; this._timeout = opts.timeout || 5000; this._listener = null; + this._storageEventListeners = {}; + this._storageEventListenerCount = 0; this._installListener(); @@ -193,6 +195,44 @@ return this._request('get', {keys: args}); }; + /** + * Accepts a callback which will be called on `storage` events from the hub. + * + * The callback will be called on changes to the hub's storage (trigger from + * other documents than the hub). It will be called with an object with + * the keys `key`, `newValue`, `oldValue` and `url`, as defined by the `storage` + * event in the hub. + * + * Returns a promise that is settled on success (in adding the event listener), + * in which case it is fullfilled with a key that can be used to remove the + * listener. On failure, it is rejected with the corresponding error message. + * + * @param {function} callback Function to be called on storage changes + * @returns {Promise} A promise that is settled on hub response or timeout + */ + CrossStorageClient.prototype.listen = function(callback) { + this._storageEventListenerCount++; + var eventKey = this._id + ":" + this._storageEventListenerCount; + this._storageEventListeners[eventKey] = callback; + return this._request('listen', {eventKey: eventKey}).then(function () { + return eventKey + }); + }; + + /** + * Removes the storage event listener. + * + * The client will ignore any events as soon as this is called. Returns a promise + * that is settled on successful event listener removal from the hub. + * + * @param {string} eventKey The key returned initiating the listener with `listen` + * @returns {Promise} A promise that is settled on hub response or timeout + */ + CrossStorageClient.prototype.unlisten = function(eventKey) { + delete this._storageEventListeners[eventKey]; + return this._request('unlisten', {eventKey: eventKey}); + }; + /** * Accepts one or more keys for deletion. Returns a promise that is settled on * hub response or timeout. @@ -307,6 +347,13 @@ return; } + if(response.type === 'event') { + if (response.eventKey in client._storageEventListeners) { + client._storageEventListeners[response.eventKey](response.eventData); + } + return; + } + if (!response.id) return; if (client._requests[response.id]) { diff --git a/lib/hub.js b/lib/hub.js index 85f395a..d067e9c 100644 --- a/lib/hub.js +++ b/lib/hub.js @@ -38,6 +38,7 @@ } CrossStorageHub._permissions = permissions || []; + CrossStorageHub._eventListeners = {}; CrossStorageHub._installListener(); window.parent.postMessage('cross-storage:ready', '*'); }; @@ -120,7 +121,7 @@ /** * Returns a boolean indicating whether or not the requested method is * permitted for the given origin. The argument passed to method is expected - * to be one of 'get', 'set', 'del' or 'getKeys'. + * to be one of 'get', 'set', 'del', 'clear', 'listen' or 'getKeys'. * * @param {string} origin The origin for which to determine permissions * @param {string} method Requested action @@ -128,8 +129,8 @@ */ CrossStorageHub._permitted = function(origin, method) { var available, i, entry, match; - - available = ['get', 'set', 'del', 'clear', 'getKeys']; + if (method==='unlisten') method = 'listen'; + available = ['get', 'set', 'listen', 'del', 'clear', 'getKeys']; if (!CrossStorageHub._inArray(method, available)) { return false; } @@ -185,6 +186,57 @@ return (result.length > 1) ? result : result[0]; }; + /** + * Adds an event listener to `storage` events which sends all events to the client with the given eventKey + * + * @param {object} params An object with an eventKey + */ + CrossStorageHub._listen = function(params) { + if (params.eventKey in CrossStorageHub._eventListeners) { + throw new Error("Can't reuse eventKeys") + } + var handler = function(event) { + if (event.storageArea != window.localStorage) return; + var data = { + type: 'event', + eventKey: params.eventKey, + eventData: { + key: event.key, + newValue: event.newValue, + oldValue: event.oldValue, + url: event.url + // storageArea, ignored because we only use localStorage + } + }; + window.parent.postMessage(JSON.stringify(data), '*'); + }; + + // Support IE8 with attachEvent + if (window.addEventListener) { + window.addEventListener('storage', handler, false); + } else { + window.attachEvent('onstorage', handler); + } + CrossStorageHub._eventListeners[params.eventKey] = handler + }; + + /** + * Removes an event listener with the given eventKey + * + * @param {object} params An object with an eventKey + */ + CrossStorageHub._unlisten = function(params) { + var handler = CrossStorageHub._eventListeners[params.eventKey]; + + // Support IE8 with attachEvent + if (window.removeEventListener) { + window.removeEventListener('storage', handler, false); + } else { + window.detachEvent('onstorage', handler); + } + CrossStorageHub._eventListeners[params.eventKey] = null + }; + /** * Deletes all keys specified in the array found at params.keys. * diff --git a/test/hub.html b/test/hub.html index 0895165..bbfc8d6 100644 --- a/test/hub.html +++ b/test/hub.html @@ -6,7 +6,7 @@ diff --git a/test/test.js b/test/test.js index fd7cbc9..03c6bcf 100644 --- a/test/test.js +++ b/test/test.js @@ -53,6 +53,18 @@ describe('CrossStorageClient', function() { }; }; + var timeoutPromise = function(timeout) { + return function() { + return new Promise(function (resolve) { + window.setTimeout(function () { + resolve() + }, timeout + ); + }); + }; + }; + + // Used to delete keys before each test var cleanup = function(fn) { storage.onConnect().then(function() { @@ -333,5 +345,110 @@ describe('CrossStorageClient', function() { done(); })['catch'](done); }); + + it('can listen to updates', function(done) { + var keys = ['key1', 'key2']; + var values = ['foo', 'bar']; + var storageEvents1 = []; + var storageEvents2 = []; + var otherStorage = new CrossStorageClient(url, {timeout: 10000}); + + storage.onConnect() + .then(function(){return otherStorage.onConnect()}) + .then(function(){ + storage.listen(function(evt){storageEvents1.push(evt)}); + otherStorage.listen(function(evt){storageEvents2.push(evt)}); + }) + .then(setGet(keys[0], values[0])) + .then(timeoutPromise(100)) + .then(function(){ + expect(storageEvents1).to.have.length(0); + expect(storageEvents2).to.eql([{ + key: keys[0], + newValue: 'foo', + oldValue: null, + url: url + }]); + storageEvents2.pop(); + }) + .then(setGet(keys[0], values[1])) + .then(timeoutPromise(100)) + .then(function(){ + expect(storageEvents1).to.have.length(0); + expect(storageEvents2).to.eql([{ + key: keys[0], + newValue: 'bar', + oldValue: 'foo', + url: url + }]); + storageEvents2.pop(); + }) + .then(function() { + otherStorage.del(keys[0]); + }) + .then(timeoutPromise(100)) + .then(function(){ + expect(storageEvents2).to.have.length(0); + expect(storageEvents1).to.eql([{ + key: keys[0], + newValue: null, + oldValue: "bar", + url: url + }]); + done() + })['catch'](done); + }); + + it('can unlisten to updates', function(done) { + var keys = ['key1', 'key2']; + var values = ['foo', 'bar']; + var storageEvents1 = []; + var storageEvents2 = []; + var otherStorage = new CrossStorageClient(url, {timeout: 10000}); + var eventListenerKey; + + storage.onConnect() + .then(function(){return otherStorage.onConnect()}) + .then(function(){ + return Promise.all([ + storage.listen(function(evt){storageEvents1.push(evt)}), + otherStorage.listen(function(evt){storageEvents2.push(evt)}).then(function(key){eventListenerKey = key}) + ]); + }) + .then(setGet(keys[0], values[0])) + .then(timeoutPromise(100)) + .then(function(){ + expect(storageEvents1).to.have.length(0); + expect(storageEvents2).to.eql([{ + key: keys[0], + newValue: 'foo', + oldValue: null, + url: url + }]); + storageEvents2.pop(); + return otherStorage.unlisten(eventListenerKey) + }) + .then(setGet(keys[0], values[1])) + .then(timeoutPromise(100)) + .then(function(){ + expect(storageEvents1).to.have.length(0); + expect(storageEvents2).to.have.length(0); + storageEvents2.pop(); + }) + .then(function() { + otherStorage.del(keys[0]); + }) + .then(timeoutPromise(100)) + .then(function(){ + expect(storageEvents2).to.have.length(0); + expect(storageEvents1).to.eql([{ + key: keys[0], + newValue: null, + oldValue: "bar", + url: url + }]); + done() + })['catch'](done); + }); }); });