diff --git a/README.md b/README.md index 6d16d7d..dc4c09f 100644 --- a/README.md +++ b/README.md @@ -17,6 +17,8 @@ Features an API using ES6 promises. * [CrossStorageClient.prototype.del(key1, \[key2\], \[...\])](#crossstorageclientprototypedelkey1-key2-) * [CrossStorageClient.prototype.getKeys()](#crossstorageclientprototypegetkeys) * [CrossStorageClient.prototype.clear()](#crossstorageclientprototypeclear) + * [CrossStorageClient.prototype.listen(callback)](#crossstorageclientprototypelisten) + * [CrossStorageClient.prototype.unlisten(key)](#crossstorageclientprototypeunlisten) * [CrossStorageClient.prototype.close()](#crossstorageclientprototypeclose) * [Compatibility](#compatibility) * [Compression](#compression) @@ -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']} ]); ``` @@ -231,6 +233,34 @@ storage.onConnect().then(function() { }); ``` +#### CrossStorageClient.prototype.listen(fn) + +Adds an event listener to the storage event in the hub. The callback will +be invoked on any storage event not originating from that client. The +callback will be invoked with an object containing the following keys taken +from the original event: `key`, `newValue`, `oldValue` and `url`. Returns a +promise that resolves to a listener id that can be used to unregister the +listener. + +``` javascript +storage.onConnect().then(function() { + return storage.listen(function(event) { + console.log(event); + }); +}).then(function(id) { + // id can be passed to storage.unlisten +}); +``` + +#### CrossStorageClient.prototype.unlisten(id) + +Removes the registered listener with the supplied id. Returns a promise +that resolves on completion. + +``` javascript +storage.unlisten(id); +``` + #### CrossStorageClient.prototype.close() Deletes the iframe and sets the connected state to false. The client can diff --git a/lib/client.js b/lib/client.js index 2a78d0f..eaa7bf2 100644 --- a/lib/client.js +++ b/lib/client.js @@ -49,6 +49,9 @@ this._timeout = opts.timeout || 5000; this._listener = null; + this._storageListeners = {}; + this._storageListenerCount = 0; + this._installListener(); var frame; @@ -226,6 +229,39 @@ return this._request('getKeys'); }; + /** + * Adds an event listener to the storage event in the hub. The callback will + * be invoked on any storage event not originating from that client. The + * callback will be invoked with an object containing the following keys taken + * from the original event: `key`, `newValue`, `oldValue` and `url`. Returns a + * promise that resolves to a listener id that can be used to unregister the + * listener. + * + * @param {function} fn Callback to invoke on storage event + * @returns {Promise} A promise that is settled on hub response or timeout + */ + CrossStorageClient.prototype.listen = function(fn) { + this._storageListenerCount++; + var id = this._id + ":" + this._storageListenerCount; + this._storageListeners[id] = fn; + return this._request('listen', {listenerId: id}).then(function() { + return id; + }); + }; + + /** + * Removes the registered listener with the supplied id. Returns a promise + * that resolves on completion. + * + * @param {string} id The id of the listener to unregister + * @returns {Promise} A promise that is settled on hub response or timeout + */ + CrossStorageClient.prototype.unlisten = function(id) { + delete this._storageListeners[id]; + return this._request('unlisten', {listenerId: id}); + }; + + /** * Deletes the iframe and sets the connected state to false. The client can * no longer be used after being invoked. @@ -307,6 +343,13 @@ return; } + if (response.event) { + if (client._storageListeners[response.listenerId]) { + client._storageListeners[response.listenerId](response.event); + } + return; + } + if (!response.id) return; if (client._requests[response.id]) { diff --git a/lib/hub.js b/lib/hub.js index 85f395a..bf06963 100644 --- a/lib/hub.js +++ b/lib/hub.js @@ -38,6 +38,7 @@ } CrossStorageHub._permissions = permissions || []; + CrossStorageHub._storageListeners = {}; CrossStorageHub._installListener(); window.parent.postMessage('cross-storage:ready', '*'); }; @@ -120,7 +121,8 @@ /** * 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', 'getKeys', 'listen', or + * 'unlisten'. * * @param {string} origin The origin for which to determine permissions * @param {string} method Requested action @@ -128,8 +130,12 @@ */ CrossStorageHub._permitted = function(origin, method) { var available, i, entry, match; + available = ['get', 'set', 'listen', 'del', 'clear', 'getKeys', 'listen']; + + if (method === 'unlisten') { + method = 'listen'; + } - available = ['get', 'set', 'del', 'clear', 'getKeys']; if (!CrossStorageHub._inArray(method, available)) { return false; } @@ -185,6 +191,57 @@ return (result.length > 1) ? result : result[0]; }; + /** + * Listens to storage events, sending them to the client. + * + * @param {object} params An object with a listener id + */ + CrossStorageHub._listen = function(params) { + if (params.listenerId in CrossStorageHub._storageListeners) { + return; + } + + var handler = function(event) { + if (event.storageArea !== window.localStorage) return; + + var data = { + listenerId: params.listenerId, + event: { + key: event.key, + newValue: event.newValue, + oldValue: event.oldValue, + url: event.url + } + }; + + window.parent.postMessage(JSON.stringify(data), '*'); + }; + + CrossStorageHub._storageListeners[params.listenerId] = handler; + + if (window.addEventListener) { + window.addEventListener('storage', handler, false); + } else { + window.attachEvent('onstorage', handler); + } + }; + + /** + * Removes an event listener with the given id + * + * @param {object} params An object with an id + */ + CrossStorageHub._unlisten = function(params) { + var handler = CrossStorageHub._storageListeners[params.listenerId]; + CrossStorageHub._storageListeners[params.listenerId] = null; + + if (window.removeEventListener) { + window.removeEventListener('storage', handler, false); + } else { + window.detachEvent('onstorage', handler); + } + }; + /** * 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..0a4b37a 100644 --- a/test/test.js +++ b/test/test.js @@ -53,6 +53,14 @@ describe('CrossStorageClient', function() { }; }; + var timeoutPromise = function(timeout) { + return function() { + return new Promise(function(resolve) { + window.setTimeout(resolve, timeout); + }); + }; + }; + // Used to delete keys before each test var cleanup = function(fn) { storage.onConnect().then(function() { @@ -333,5 +341,78 @@ describe('CrossStorageClient', function() { done(); })['catch'](done); }); + + it('can listen to updates', function(done) { + var key = 'foo'; + var value = 'bar'; + var storageEvents1 = []; + var storageEvents2 = []; + var otherStorage = new CrossStorageClient(url, {timeout: 10000}); + + var listen = function() { + return Promise.all([ + storage.listen(function(evt) { + storageEvents1.push(evt) + }), + otherStorage.listen(function(evt) { + storageEvents2.push(evt) + }) + ]); + }; + + Promise.all([ + storage.onConnect(), + otherStorage.onConnect() + ]) + .then(listen) + .then(setGet(key, value)) + .then(timeoutPromise(100)) + .then(function() { + expect(storageEvents1).to.have.length(0); + expect(storageEvents2).to.eql([{ + key: key, + newValue: value, + oldValue: null, + url: url + }]); + done(); + })['catch'](done); + }); + + it('can unlisten to updates', function(done) { + var storageEvents1 = []; + var storageEvents2 = []; + var otherStorage = new CrossStorageClient(url, {timeout: 10000}); + var listenerId; + + var listen = function() { + return Promise.all([ + storage.listen(function(evt) { + storageEvents1.push(evt) + }), + otherStorage.listen(function(evt) { + storageEvents2.push(evt) + }).then(function(key){ + listenerId = key + }) + ]); + }; + + Promise.all([ + storage.onConnect(), + otherStorage.onConnect() + ]) + .then(listen) + .then(function() { + return otherStorage.unlisten(listenerId); + }) + .then(setGet('foo', 'bar')) + .then(timeoutPromise(100)) + .then(function() { + expect(storageEvents1).to.have.length(0); + expect(storageEvents2).to.have.length(0); + done(); + })['catch'](done); + }); }); });