From cb78434b629d85690148a44700d8e758b68be0c6 Mon Sep 17 00:00:00 2001 From: Andrew Imm Date: Tue, 26 Jan 2016 20:27:23 -0800 Subject: [PATCH 01/14] Implement Promise.catch, Promise.all, Promise.race --- src/ParsePromise.js | 118 +++++++++++++++++++++ src/__tests__/ParsePromise-test.js | 158 +++++++++++++++++++++++++++++ 2 files changed, 276 insertions(+) diff --git a/src/ParsePromise.js b/src/ParsePromise.js index d2d27d11c..690267d9c 100644 --- a/src/ParsePromise.js +++ b/src/ParsePromise.js @@ -207,12 +207,21 @@ export default class ParsePromise { /** * Add handlers to be called when the Promise object is rejected + * Alias for catch(). * @method fail */ fail(callback) { return this.then(null, callback); } + /** + * Add handlers to be called when the Promise object is rejected + * @method catch + */ + catch(callback) { + return this.then(null, callback); + } + /** * Run the given callbacks after this promise is fulfilled. * @method _thenRunCallbacks @@ -435,6 +444,115 @@ export default class ParsePromise { return promise; } + /** + * Returns a new promise that is fulfilled when all of the promises in the + * iterable argument are resolved. If any promise in the list fails, then + * the returned promise will be immediately rejected with the reason that + * single promise rejected. If they all succeed, then the returned promise + * will succeed, with the results being the results of all the input + * promises. If the iterable provided is empty, the returned promise will + * be immediately resolved. + * + * For example:
+   *   var p1 = Parse.Promise.as(1);
+   *   var p2 = Parse.Promise.as(2);
+   *   var p3 = Parse.Promise.as(3);
+   *
+   *   Parse.Promise.all([p1, p2, p3]).then(function([r1, r2, r3]) {
+   *     console.log(r1);  // prints 1
+   *     console.log(r2);  // prints 2
+   *     console.log(r3);  // prints 3
+   *   });
+ * + * @method all + * @param {Iterable} promises an iterable of promises to wait for. + * @static + * @return {Parse.Promise} the new promise. + */ + static all(promises) { + let total = 0; + let objects = []; + + for (let p of promises) { + objects[total++] = p; + } + + if (total === 0) { + return ParsePromise.as([]); + } + + let hadError = false; + let promise = new ParsePromise(); + let resolved = 0; + let results = []; + objects.forEach((object, i) => { + if (ParsePromise.is(object)) { + object.then((result) => { + if (hadError) { + return false; + } + results[i] = result; + resolved++; + if (resolved >= total) { + promise.resolve(results); + } + }, (error) => { + // Reject immediately + promise.reject(error); + hadError = true; + }); + } else { + results[i] = object; + resolved++; + if (!hadError && resolved >= total) { + promise.resolve(results); + } + } + }); + + return promise; + } + + /** + * Returns a new promise that is immediately fulfilled when any of the + * promises in the iterable argument are resolved or rejected. If the + * first promise to complete is resolved, the returned promise will be + * resolved with the same value. Likewise, if the first promise to + * complete is rejected, the returned promise will be rejected with the + * same reason. + * + * @method race + * @param {Iterable} promises an iterable of promises to wait for. + * @static + * @return {Parse.Promise} the new promise. + */ + static race(promises) { + let completed = false; + let promise = new ParsePromise(); + for (let p of promises) { + if (ParsePromise.is(p)) { + p.then((result) => { + if (completed) { + return; + } + completed = true; + promise.resolve(result); + }, (error) => { + if (completed) { + return; + } + completed = true; + promise.reject(error); + }); + } else if (!completed) { + completed = true; + promise.resolve(p); + } + } + + return promise; + } + /** * Runs the given asyncFunction repeatedly, as long as the predicate * function returns a truthy value. Stops repeating if asyncFunction returns diff --git a/src/__tests__/ParsePromise-test.js b/src/__tests__/ParsePromise-test.js index f5a56d3b0..8e03c4e54 100644 --- a/src/__tests__/ParsePromise-test.js +++ b/src/__tests__/ParsePromise-test.js @@ -14,6 +14,18 @@ var ParsePromise = require('../ParsePromise'); var asyncHelper = require('./test_helpers/asyncHelper'); describe('Promise', () => { + it('can disable A+ compliance', () => { + ParsePromise.disableAPlusCompliant(); + expect(ParsePromise.isPromisesAPlusCompliant()).toBe(false); + }); + + it('can enable A+ compliance', () => { + ParsePromise.enableAPlusCompliant(); + expect(ParsePromise.isPromisesAPlusCompliant()).toBe(true); + }); +}); + +function promiseTests() { it('can be initially resolved', () => { var promise = ParsePromise.as('foo'); promise.then((result) => { @@ -428,6 +440,40 @@ describe('Promise', () => { }); }); + it('runs catch callbacks on error', () => { + var promise = ParsePromise.error('foo'); + promise.fail((error) => { + expect(error).toBe('foo'); + }).then((result) => { + if (ParsePromise.isPromisesAPlusCompliant()) { + expect(result).toBe(undefined); + } else { + // This should not be reached + expect(true).toBe(false); + } + }, (error) => { + if (ParsePromise.isPromisesAPlusCompliant()) { + // This should not be reached + expect(true).toBe(false); + } else { + expect(error).toBe(undefined); + } + }); + }); + + it('does not run catch callbacks on success', () => { + var promise = ParsePromise.as('foo'); + promise.catch((error) => { + // This should not be reached + expect(true).toBe(false); + }).then((result) => { + expect(result).toBe('foo'); + }, (error) => { + // This should not be reached + expect(true).toBe(false); + }); + }); + it('operates asynchonously', () => { var triggered = false; ParsePromise.as().then(() => { @@ -518,4 +564,116 @@ describe('Promise', () => { done(); }); })); + + it('resolves Promise.all with the set of resolved results', asyncHelper((done) => { + let firstSet = [ + new ParsePromise(), + new ParsePromise(), + new ParsePromise(), + new ParsePromise() + ]; + + let secondSet = [5,6,7]; + + ParsePromise.all([]).then((results) => { + expect(results).toEqual([]); + + return ParsePromise.all(firstSet); + }).then((results) => { + expect(results).toEqual([1,2,3,4]); + return ParsePromise.all(secondSet); + }).then((results) => { + expect(results).toEqual([5,6,7]); + done(); + }); + firstSet[0].resolve(1); + firstSet[1].resolve(2); + firstSet[2].resolve(3); + firstSet[3].resolve(4); + })); + + it('rejects Promise.all with the first rejected promise', asyncHelper((done) => { + let promises = [ + new ParsePromise(), + new ParsePromise(), + new ParsePromise() + ]; + + ParsePromise.all(promises).then(() => { + // this should not be reached + }, (error) => { + expect(error).toBe('an error'); + done(); + }); + promises[0].resolve(1); + promises[1].reject('an error'); + promises[2].resolve(3); + })); + + it('resolves Promise.race with the first resolved result', asyncHelper((done) => { + let firstSet = [ + new ParsePromise(), + new ParsePromise(), + new ParsePromise() + ]; + + let secondSet = [4, 5, ParsePromise.error()]; + + ParsePromise.race(firstSet).then((result) => { + expect(result).toBe(2); + + return ParsePromise.race(secondSet); + }).then((result) => { + expect(result).toBe(4); + done(); + }); + firstSet[1].resolve(2); + firstSet[0].resolve(1); + })); + + it('rejects Promise.race with the first rejected reason', asyncHelper((done) => { + let promises = [ + new ParsePromise(), + new ParsePromise(), + new ParsePromise() + ]; + + ParsePromise.race(promises).fail((error) => { + expect(error).toBe('error 2'); + done(); + }); + promises[1].reject('error 2'); + promises[0].resolve('error 1'); + })); + + it('can implement continuations', asyncHelper((done) => { + let count = 0; + let loop = () => { + count++; + return ParsePromise.as(); + } + ParsePromise._continueWhile( + () => { return count < 5 }, + loop + ).then(() => { + expect(count).toBe(5); + done(); + }); + })); +} + +describe('Promise (A Compliant)', () => { + beforeEach(() => { + ParsePromise.disableAPlusCompliant(); + }); + + promiseTests(); +}); + +describe('Promise (A+ Compliant)', () => { + beforeEach(() => { + ParsePromise.enableAPlusCompliant(); + }); + + promiseTests(); }); From 984a095ca1b292ba54699320ef55411529c13885 Mon Sep 17 00:00:00 2001 From: Andrew Imm Date: Tue, 26 Jan 2016 20:46:06 -0800 Subject: [PATCH 02/14] More promises coverage --- src/__tests__/ParsePromise-test.js | 35 ++++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/src/__tests__/ParsePromise-test.js b/src/__tests__/ParsePromise-test.js index 8e03c4e54..c5f17d959 100644 --- a/src/__tests__/ParsePromise-test.js +++ b/src/__tests__/ParsePromise-test.js @@ -309,6 +309,27 @@ function promiseTests() { jest.runAllTimers(); })); + it('immediately resolves when processing an empty array in parallel', asyncHelper((done) => { + ParsePromise.when([]).then((result) => { + expect(result).toEqual([]); + done(); + }); + })); + + it('can handle rejected promises in parallel', asyncHelper((done) => { + ParsePromise.when([ParsePromise.as(1), ParsePromise.error('an error')]).then(null, (errors) => { + expect(errors).toEqual([undefined, 'an error']); + done(); + }); + })); + + it('can automatically resolve non-promises in parallel', asyncHelper((done) => { + ParsePromise.when([1,2,3]).then((results) => { + expect(results).toEqual([1,2,3]); + done(); + }); + })); + it('passes on errors', () => { ParsePromise.error('foo').then(() => { // This should not be reached @@ -660,6 +681,20 @@ function promiseTests() { done(); }); })); + + it('can attach a universal callback to a promise', asyncHelper((done) => { + ParsePromise.as(15)._continueWith((result, err) => { + expect(result).toEqual([15]); + expect(err).toBe(null); + + ParsePromise.error('an error')._continueWith((result, err) => { + expect(result).toBe(null); + expect(err).toBe('an error'); + + done(); + }); + }); + })); } describe('Promise (A Compliant)', () => { From 23cdb48745df09941cb8c7414cfced72142f7154 Mon Sep 17 00:00:00 2001 From: Jason Maurer Date: Thu, 28 Jan 2016 00:27:33 -0700 Subject: [PATCH 03/14] Option to specify https for Parse.File --- src/ParseFile.js | 10 ++++++++-- src/__tests__/ParseFile-test.js | 9 +++++++++ 2 files changed, 17 insertions(+), 2 deletions(-) diff --git a/src/ParseFile.js b/src/ParseFile.js index 7784d965f..0b12c3417 100644 --- a/src/ParseFile.js +++ b/src/ParseFile.js @@ -134,10 +134,16 @@ export default class ParseFile { * Gets the url of the file. It is only available after you save the file or * after you get the file from a Parse.Object. * @method url + * @param {Object} options An object to specify url options * @return {String} */ - url(): ?string { - return this._url; + url(options?: { secure?: boolean }): ?string { + options = options || {}; + if (options.secure) { + return this._url.replace(/^http:\/\//i, 'https://'); + } else { + return this._url; + } } /** diff --git a/src/__tests__/ParseFile-test.js b/src/__tests__/ParseFile-test.js index 637642618..9aeec67c1 100644 --- a/src/__tests__/ParseFile-test.js +++ b/src/__tests__/ParseFile-test.js @@ -78,6 +78,15 @@ describe('ParseFile', () => { }).toThrow('Cannot create a Parse.File with that data.'); }); + it('returns secure url when specified', () => { + var file = new ParseFile('parse.txt', { base64: 'ParseA==' }); + file.save().then(function(result) { + expect(result).toBe(file); + expect(result.url({ secure: true })) + .toBe('https://files.parsetfss.com/a/parse.txt'); + }); + }); + it('updates fields when saved', () => { var file = new ParseFile('parse.txt', { base64: 'ParseA==' }); expect(file.name()).toBe('parse.txt'); From 0e40d44d864fd832d040b8737970d1d478a7fb1b Mon Sep 17 00:00:00 2001 From: Jason Maurer Date: Thu, 28 Jan 2016 11:32:12 -0700 Subject: [PATCH 04/14] Check url for undefined value & api change for secure url --- src/ParseFile.js | 7 +++++-- src/__tests__/ParseFile-test.js | 7 ++++++- 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/src/ParseFile.js b/src/ParseFile.js index 0b12c3417..bc38b607a 100644 --- a/src/ParseFile.js +++ b/src/ParseFile.js @@ -137,9 +137,12 @@ export default class ParseFile { * @param {Object} options An object to specify url options * @return {String} */ - url(options?: { secure?: boolean }): ?string { + url(options?: { forceSecure?: boolean }): ?string { options = options || {}; - if (options.secure) { + if (!this._url) { + return; + } + if (options.forceSecure) { return this._url.replace(/^http:\/\//i, 'https://'); } else { return this._url; diff --git a/src/__tests__/ParseFile-test.js b/src/__tests__/ParseFile-test.js index 9aeec67c1..9040e89b0 100644 --- a/src/__tests__/ParseFile-test.js +++ b/src/__tests__/ParseFile-test.js @@ -82,11 +82,16 @@ describe('ParseFile', () => { var file = new ParseFile('parse.txt', { base64: 'ParseA==' }); file.save().then(function(result) { expect(result).toBe(file); - expect(result.url({ secure: true })) + expect(result.url({ forceSecure: true })) .toBe('https://files.parsetfss.com/a/parse.txt'); }); }); + it('returns undefined when there is no url', () => { + var file = new ParseFile('parse.txt', { base64: 'ParseA==' }); + expect(file.url({ forceSecure: true })).toBeUndefined(); + }); + it('updates fields when saved', () => { var file = new ParseFile('parse.txt', { base64: 'ParseA==' }); expect(file.name()).toBe('parse.txt'); From 87cfd99eaf3489ef10721c9e84647aebe0dcf2ef Mon Sep 17 00:00:00 2001 From: Andrew Imm Date: Fri, 12 Feb 2016 09:43:59 -0800 Subject: [PATCH 05/14] Clear old data on fetch --- src/ParseObject.js | 12 +++++++- src/ParseQuery.js | 4 +-- src/__tests__/ParseObject-test.js | 47 +++++++++++++++++++++++++++++++ 3 files changed, 60 insertions(+), 3 deletions(-) diff --git a/src/ParseObject.js b/src/ParseObject.js index 95973e9e2..993631cb2 100644 --- a/src/ParseObject.js +++ b/src/ParseObject.js @@ -1305,10 +1305,12 @@ export default class ParseObject { * Creates a new instance of a Parse Object from a JSON representation. * @method fromJSON * @param {Object} json The JSON map of the Object's data + * @param {boolean} override In single instance mode, all old server data + * is overwritten if this is set to true * @static * @return {Parse.Object} A Parse.Object reference */ - static fromJSON(json) { + static fromJSON(json, override) { if (!json.className) { throw new Error('Cannot create an object without a className'); } @@ -1320,6 +1322,13 @@ export default class ParseObject { otherAttributes[attr] = json[attr]; } } + if (override) { + // id needs to be set before clearServerData can work + if (otherAttributes.objectId) { + o.id = otherAttributes.objectId; + } + o._clearServerData(); + } o._finishFetch(otherAttributes); if (json.objectId) { o._setExisted(true); @@ -1587,6 +1596,7 @@ var DefaultController = { ).then((response, status, xhr) => { if (target instanceof ParseObject) { target._clearPendingOps(); + target._clearServerData(); target._finishFetch(response); } return target; diff --git a/src/ParseQuery.js b/src/ParseQuery.js index 9e31b6146..63296f239 100644 --- a/src/ParseQuery.js +++ b/src/ParseQuery.js @@ -283,7 +283,7 @@ export default class ParseQuery { if (!data.className) { data.className = override; } - return ParseObject.fromJSON(data); + return ParseObject.fromJSON(data, true); }); })._thenRunCallbacks(options); } @@ -381,7 +381,7 @@ export default class ParseQuery { if (!objects[0].className) { objects[0].className = this.className; } - return ParseObject.fromJSON(objects[0]); + return ParseObject.fromJSON(objects[0], true); })._thenRunCallbacks(options); } diff --git a/src/__tests__/ParseObject-test.js b/src/__tests__/ParseObject-test.js index 38be9d9dc..afcc1643b 100644 --- a/src/__tests__/ParseObject-test.js +++ b/src/__tests__/ParseObject-test.js @@ -152,6 +152,27 @@ describe('ParseObject', () => { expect(o.dirty()).toBe(false); }); + it('can override old data when inflating from the server', () => { + var o = ParseObject.fromJSON({ + className: 'Item', + objectId: 'I01', + size: 'small' + }); + expect(o.get('size')).toBe('small'); + var o2 = ParseObject.fromJSON({ + className: 'Item', + objectId: 'I01', + disabled: true + }, true); + expect(o.get('disabled')).toBe(true); + expect(o.get('size')).toBe(undefined); + expect(o.has('size')).toBe(false); + + expect(o2.get('disabled')).toBe(true); + expect(o2.get('size')).toBe(undefined); + expect(o2.has('size')).toBe(false); + }); + it('is given a local Id once dirtied', () => { var o = new ParseObject('Item'); o.set('size', 'small'); @@ -1214,6 +1235,32 @@ describe('ParseObject', () => { }); })); + it('replaces old data when fetch() is called', asyncHelper((done) => { + CoreManager.getRESTController()._setXHR( + mockXHR([{ + status: 200, + response: { + count: 10 + } + }]) + ); + + var p = ParseObject.fromJSON({ + className: 'Person', + objectId: 'P200', + name: 'Fred', + count: 0 + }); + expect(p.get('name')).toBe('Fred'); + expect(p.get('count')).toBe(0); + p.fetch().then(() => { + expect(p.get('count')).toBe(10); + expect(p.get('name')).toBe(undefined); + expect(p.has('name')).toBe(false); + done(); + }); + })); + it('can destroy an object', asyncHelper((done) => { var xhr = { setRequestHeader: jest.genMockFn(), From 42f111c22b290721a0fe3124ef0ea04d86ae360b Mon Sep 17 00:00:00 2001 From: Andrew Imm Date: Fri, 12 Feb 2016 10:03:36 -0800 Subject: [PATCH 06/14] Allow cloning of objects with readonly attributes --- src/ParseObject.js | 17 +++++++++++++++-- src/__tests__/ParseSession-test.js | 13 +++++++++++++ src/__tests__/ParseUser-test.js | 15 +++++++++++++++ 3 files changed, 43 insertions(+), 2 deletions(-) diff --git a/src/ParseObject.js b/src/ParseObject.js index 993631cb2..f5939fd92 100644 --- a/src/ParseObject.js +++ b/src/ParseObject.js @@ -780,12 +780,25 @@ export default class ParseObject { * @return {Parse.Object} */ clone(): any { - var clone = new this.constructor(); + let clone = new this.constructor(); if (!clone.className) { clone.className = this.className; } + let attributes = this.attributes; + if (typeof this.constructor.readOnlyAttributes === 'function') { + let readonly = this.constructor.readOnlyAttributes() || []; + // Attributes are frozen, so we have to rebuild an object, + // rather than delete readonly keys + let copy = {}; + for (let a in attributes) { + if (readonly.indexOf(a) < 0) { + copy[a] = attributes[a]; + } + } + attributes = copy; + } if (clone.set) { - clone.set(this.attributes); + clone.set(attributes); } return clone; } diff --git a/src/__tests__/ParseSession-test.js b/src/__tests__/ParseSession-test.js index 93a0817ab..2792795c1 100644 --- a/src/__tests__/ParseSession-test.js +++ b/src/__tests__/ParseSession-test.js @@ -127,4 +127,17 @@ describe('ParseSession', () => { done(); }); })); + + it('can be cloned', () => { + var s = ParseObject.fromJSON({ + className: '_Session', + sessionToken: '123abc', + foo: 12 + }); + + var clone = s.clone(); + expect(clone.className).toBe('_Session'); + expect(clone.get('foo')).toBe(12); + expect(clone.get('sessionToken')).toBe(undefined); + }); }); diff --git a/src/__tests__/ParseUser-test.js b/src/__tests__/ParseUser-test.js index 83a37a57e..0d4cdbc44 100644 --- a/src/__tests__/ParseUser-test.js +++ b/src/__tests__/ParseUser-test.js @@ -85,6 +85,21 @@ describe('ParseUser', () => { expect(u2.getEmail()).toBe('bono@u2.com'); }); + it('can clone User objects', () => { + var u = ParseObject.fromJSON({ + className: '_User', + username: 'user12', + email: 'user12@parse.com', + sessionToken: '123abc' + }); + + var clone = u.clone(); + expect(clone.className).toBe('_User'); + expect(clone.get('username')).toBe('user12'); + expect(clone.get('email')).toBe('user12@parse.com'); + expect(clone.get('sessionToken')).toBe(undefined); + }); + it('makes session tokens readonly', () => { var u = new ParseUser(); expect(u.set.bind(u, 'sessionToken', 'token')).toThrow( From 287560dcc78b236c9e0530c10317ef2e84144337 Mon Sep 17 00:00:00 2001 From: wuotr Date: Tue, 2 Feb 2016 14:10:11 +0100 Subject: [PATCH 07/14] Updated the .gitignore file. --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index f37ee6b33..69f42c145 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,4 @@ node_modules test_output *~ .DS_Store +.idea/ From 0c2dae1ff8b7aa33c6590399c6cf4d23a7252fb7 Mon Sep 17 00:00:00 2001 From: wuotr Date: Tue, 2 Feb 2016 14:11:53 +0100 Subject: [PATCH 08/14] Made it possible to pass along the installationId (string) as property on the backbone style options object (especially on login() and signup(). --- src/CoreManager.js | 1 + src/ParseUser.js | 6 ++++++ src/RESTController.js | 13 +++++++++++-- 3 files changed, 18 insertions(+), 2 deletions(-) diff --git a/src/CoreManager.js b/src/CoreManager.js index 86f7eaf57..c0c1de515 100644 --- a/src/CoreManager.js +++ b/src/CoreManager.js @@ -21,6 +21,7 @@ import type { PushData } from './Push'; type RequestOptions = { useMasterKey?: boolean; sessionToken?: string; + installationId?: string; }; type AnalyticsController = { track: (name: string, dimensions: { [key: string]: string }) => ParsePromise; diff --git a/src/ParseUser.js b/src/ParseUser.js index 9c9e64d46..eb652e046 100644 --- a/src/ParseUser.js +++ b/src/ParseUser.js @@ -365,6 +365,9 @@ export default class ParseUser extends ParseObject { if (options.hasOwnProperty('useMasterKey')) { signupOptions.useMasterKey = options.useMasterKey; } + if (options.hasOwnProperty('installationId')) { + signupOptions.installationId = options.installationId; + } var controller = CoreManager.getUserController(); return controller.signUp( @@ -395,6 +398,9 @@ export default class ParseUser extends ParseObject { if (options.hasOwnProperty('useMasterKey')) { loginOptions.useMasterKey = options.useMasterKey; } + if (options.hasOwnProperty('installationId')) { + loginOptions.installationId = options.installationId; + } var controller = CoreManager.getUserController(); return controller.logIn(this, loginOptions)._thenRunCallbacks(options, this); diff --git a/src/RESTController.js b/src/RESTController.js index 7a4872928..fb16570c7 100644 --- a/src/RESTController.js +++ b/src/RESTController.js @@ -17,6 +17,7 @@ import Storage from './Storage'; export type RequestOptions = { useMasterKey?: boolean; sessionToken?: string; + installationId?: string; }; export type FullOptions = { @@ -24,6 +25,7 @@ export type FullOptions = { error?: any; useMasterKey?: boolean; sessionToken?: string; + installationId?: string; }; var XHR = null; @@ -181,9 +183,16 @@ const RESTController = { payload._RevocableSession = '1'; } - var installationController = CoreManager.getInstallationController(); + var installationId = options.installationId; + var installationIdPromise; + if (installationId && typeof installationId === 'string') { + installationIdPromise = ParsePromise.as(installationId); + } else { + var installationController = CoreManager.getInstallationController(); + installationIdPromise = installationController.currentInstallationId(); + } - return installationController.currentInstallationId().then((iid) => { + return installationIdPromise.then((iid) => { payload._InstallationId = iid; var userController = CoreManager.getUserController(); if (options && typeof options.sessionToken === 'string') { From 4f04f3cff66c61e3a352b276947837d1538bfd39 Mon Sep 17 00:00:00 2001 From: Andrew Imm Date: Wed, 17 Feb 2016 11:06:15 -0800 Subject: [PATCH 09/14] New API for creating a new instance of an object --- src/ParseObject.js | 23 ++++++- src/UniqueInstanceStateController.js | 17 ++++++ src/__tests__/ParseObject-test.js | 29 +++++++++ .../UniqueInstanceStateController-test.js | 61 +++++++++++++++++++ 4 files changed, 129 insertions(+), 1 deletion(-) diff --git a/src/ParseObject.js b/src/ParseObject.js index f5939fd92..89846b03b 100644 --- a/src/ParseObject.js +++ b/src/ParseObject.js @@ -775,7 +775,7 @@ export default class ParseObject { } /** - * Creates a new model with identical attributes to this one. + * Creates a new model with identical attributes to this one, similar to Backbone.Model's clone() * @method clone * @return {Parse.Object} */ @@ -803,6 +803,27 @@ export default class ParseObject { return clone; } + /** + * Creates a new instance of this object. Not to be confused with clone() + * @method newInstance + * @return {Parse.Object} + */ + newInstance(): any { + let clone = new this.constructor(); + if (!clone.className) { + clone.className = this.className; + } + clone.id = this.id; + if (singleInstance) { + // Just return an object with the right id + return clone; + } + + let stateController = CoreManager.getObjectStateController(); + stateController.duplicateState(this._getStateIdentifier(), clone._getStateIdentifier()); + return clone; + } + /** * Returns true if this object has never been saved to Parse. * @method isNew diff --git a/src/UniqueInstanceStateController.js b/src/UniqueInstanceStateController.js index 68ab0c6cf..2f86b0066 100644 --- a/src/UniqueInstanceStateController.js +++ b/src/UniqueInstanceStateController.js @@ -123,6 +123,23 @@ export function enqueueTask(obj: ParseObject, task: () => ParsePromise): ParsePr return state.tasks.enqueue(task); } +export function duplicateState(source: ParseObject, dest: ParseObject): void { + let oldState = initializeState(source); + let newState = initializeState(dest); + for (let key in oldState.serverData) { + newState.serverData[key] = oldState.serverData[key]; + } + for (let index = 0; index < oldState.pendingOps.length; index++) { + for (let key in oldState.pendingOps[index]) { + newState.pendingOps[index][key] = oldState.pendingOps[index][key]; + } + } + for (let key in oldState.objectCache) { + newState.objectCache[key] = oldState.objectCache[key]; + } + newState.existed = oldState.existed; +} + export function clearAllState() { objectState = new WeakMap(); } diff --git a/src/__tests__/ParseObject-test.js b/src/__tests__/ParseObject-test.js index afcc1643b..0fd8a9a5f 100644 --- a/src/__tests__/ParseObject-test.js +++ b/src/__tests__/ParseObject-test.js @@ -1720,6 +1720,20 @@ describe('ObjectController', () => { xhrs[0].onreadystatechange(); }); + + it('can create a new instance of an object', () => { + let o = ParseObject.fromJSON({ + className: 'Clone', + objectId: 'C12', + }); + let o2 = o.newInstance(); + expect(o.id).toBe(o2.id); + expect(o.className).toBe(o2.className); + o.set({ valid: true }); + expect(o2.get('valid')).toBe(true); + + expect(o).not.toBe(o2); + }); }); describe('ParseObject (unique instance mode)', () => { @@ -1902,6 +1916,21 @@ describe('ParseObject (unique instance mode)', () => { expect(o.has('name')).toBe(false); expect(o2.has('name')).toBe(true); }); + + it('can create a new instance of an object', () => { + let o = ParseObject.fromJSON({ + className: 'Clone', + objectId: 'C14', + }); + let o2 = o.newInstance(); + expect(o.id).toBe(o2.id); + expect(o.className).toBe(o2.className); + expect(o).not.toBe(o2); + o.set({ valid: true }); + expect(o2.get('valid')).toBe(undefined); + o2 = o.newInstance(); + expect(o2.get('valid')).toBe(true); + }); }); class MyObject extends ParseObject { diff --git a/src/__tests__/UniqueInstanceStateController-test.js b/src/__tests__/UniqueInstanceStateController-test.js index e7026a4b6..bf7df6deb 100644 --- a/src/__tests__/UniqueInstanceStateController-test.js +++ b/src/__tests__/UniqueInstanceStateController-test.js @@ -420,4 +420,65 @@ describe('UniqueInstanceStateController', () => { closure(); } }); + + it('can duplicate the state of an object', () => { + let obj = new ParseObject(); + UniqueInstanceStateController.setServerData(obj, { counter: 12, name: 'original' }); + let setCount = new ParseOps.SetOp(44); + let setValid = new ParseOps.SetOp(true); + UniqueInstanceStateController.setPendingOp(obj, 'counter', setCount); + UniqueInstanceStateController.setPendingOp(obj, 'valid', setValid); + + let duplicate = new ParseObject(); + UniqueInstanceStateController.duplicateState(obj, duplicate); + expect(UniqueInstanceStateController.getState(duplicate)).toEqual({ + serverData: { counter: 12, name: 'original' }, + pendingOps: [{ counter: setCount, valid: setValid }], + objectCache: {}, + tasks: new TaskQueue(), + existed: false + }); + + UniqueInstanceStateController.setServerData(duplicate, { name: 'duplicate' }); + expect(UniqueInstanceStateController.getState(obj)).toEqual({ + serverData: { counter: 12, name: 'original' }, + pendingOps: [{ counter: setCount, valid: setValid }], + objectCache: {}, + tasks: new TaskQueue(), + existed: false + }); + expect(UniqueInstanceStateController.getState(duplicate)).toEqual({ + serverData: { counter: 12, name: 'duplicate' }, + pendingOps: [{ counter: setCount, valid: setValid }], + objectCache: {}, + tasks: new TaskQueue(), + existed: false + }); + + UniqueInstanceStateController.commitServerChanges(obj, { o: { a: 12 } }); + expect(UniqueInstanceStateController.getState(obj)).toEqual({ + serverData: { counter: 12, name: 'original', o: { a: 12 } }, + pendingOps: [{ counter: setCount, valid: setValid }], + objectCache: { o: '{"a":12}' }, + tasks: new TaskQueue(), + existed: false + }); + expect(UniqueInstanceStateController.getState(duplicate)).toEqual({ + serverData: { counter: 12, name: 'duplicate' }, + pendingOps: [{ counter: setCount, valid: setValid }], + objectCache: {}, + tasks: new TaskQueue(), + existed: false + }); + + let otherDup = new ParseObject(); + UniqueInstanceStateController.duplicateState(obj, otherDup); + expect(UniqueInstanceStateController.getState(otherDup)).toEqual({ + serverData: { counter: 12, name: 'original', o: { a: 12 } }, + pendingOps: [{ counter: setCount, valid: setValid }], + objectCache: { o: '{"a":12}' }, + tasks: new TaskQueue(), + existed: false + }); + }); }); From 59eecf6061320899a002984e6c0e42030efb14f7 Mon Sep 17 00:00:00 2001 From: Andrew Imm Date: Thu, 18 Feb 2016 09:16:59 -0800 Subject: [PATCH 10/14] Add newInstance tests for ParseUser --- src/__tests__/ParseUser-test.js | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/src/__tests__/ParseUser-test.js b/src/__tests__/ParseUser-test.js index 0d4cdbc44..66776decc 100644 --- a/src/__tests__/ParseUser-test.js +++ b/src/__tests__/ParseUser-test.js @@ -100,6 +100,28 @@ describe('ParseUser', () => { expect(clone.get('sessionToken')).toBe(undefined); }); + it('can create a new instance of a User', () => { + ParseObject.disableSingleInstance(); + let o = ParseObject.fromJSON({ + className: '_User', + objectId: 'U111', + username: 'u111', + email: 'u111@parse.com', + sesionToken: '1313' + }); + let o2 = o.newInstance(); + expect(o.id).toBe(o2.id); + expect(o.className).toBe(o2.className); + expect(o.get('username')).toBe(o2.get('username')); + expect(o.get('sessionToken')).toBe(o2.get('sessionToken')); + expect(o).not.toBe(o2); + o.set({ admin: true }); + expect(o2.get('admin')).toBe(undefined); + o2 = o.newInstance(); + expect(o2.get('admin')).toBe(true); + ParseObject.enableSingleInstance(); + }); + it('makes session tokens readonly', () => { var u = new ParseUser(); expect(u.set.bind(u, 'sessionToken', 'token')).toThrow( From 14819994937d765405487286f0ba003242364e75 Mon Sep 17 00:00:00 2001 From: Andrew Imm Date: Thu, 18 Feb 2016 11:00:37 -0800 Subject: [PATCH 11/14] Remove user from local storage on destroy --- src/ParseUser.js | 20 ++++++++++++++++++++ src/__tests__/ParseUser-test.js | 31 +++++++++++++++++++++++++++++++ 2 files changed, 51 insertions(+) diff --git a/src/ParseUser.js b/src/ParseUser.js index eb652e046..8b799b1c2 100644 --- a/src/ParseUser.js +++ b/src/ParseUser.js @@ -419,6 +419,19 @@ export default class ParseUser extends ParseObject { }); } + /** + * Wrap the default destroy behavior with functionality that logs out + * the current user when it is destroyed + */ + destroy(...args: Array): ParsePromise { + return super.destroy.apply(this, args).then(() => { + if (this.isCurrent()) { + return CoreManager.getUserController().removeUserFromDisk(); + } + return this; + }); + } + /** * Wrap the default fetch behavior with functionality to save to local * storage if this is current user. @@ -750,6 +763,13 @@ var DefaultController = { }); }, + removeUserFromDisk() { + let path = Storage.generatePath(CURRENT_USER_KEY); + currentUserCacheMatchesDisk = true; + currentUserCache = null; + return Storage.removeItemAsync(path); + }, + setCurrentUser(user) { currentUserCache = user; user._cleanupAuthData(); diff --git a/src/__tests__/ParseUser-test.js b/src/__tests__/ParseUser-test.js index 0d4cdbc44..03987162b 100644 --- a/src/__tests__/ParseUser-test.js +++ b/src/__tests__/ParseUser-test.js @@ -431,6 +431,37 @@ describe('ParseUser', () => { }); })); + it('removes the current user from disk when destroyed', asyncHelper((done) => { + ParseUser.enableUnsafeCurrentUser(); + ParseUser._clearCache(); + Storage._clear(); + CoreManager.setRESTController({ + request() { + return ParsePromise.as({ + objectId: 'uid9', + }, 201); + }, + ajax() {} + }); + + ParseUser.signUp('destroyed', 'password').then((u) => { + expect(u.isCurrent()).toBe(true); + CoreManager.setRESTController({ + request() { + return ParsePromise.as({}, 200); + }, + ajax() {} + }); + return u.destroy(); + }).then((u) => { + expect(ParseUser.current()).toBe(null); + return ParseUser.currentAsync(); + }).then((current) => { + expect(current).toBe(null); + done(); + }); + })); + it('updates the current user on disk when fetched', asyncHelper((done) => { ParseUser.enableUnsafeCurrentUser(); ParseUser._clearCache(); From c5611a5f9acb07c7bb3d9c133e9909a3f7670b8a Mon Sep 17 00:00:00 2001 From: Andrew Imm Date: Thu, 18 Feb 2016 17:44:15 -0800 Subject: [PATCH 12/14] 1.8.0 RC1 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 42d1d8ad9..9181e924c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "parse", - "version": "1.7.1", + "version": "1.8.0-rc1", "description": "The Parse JavaScript SDK", "homepage": "https://www.parse.com", "keywords": [ From 7a49ca7ac9f1bb26c42763ace504126118dec026 Mon Sep 17 00:00:00 2001 From: Peter Shin Date: Wed, 16 Mar 2016 20:27:21 -0700 Subject: [PATCH 13/14] Live Query Support. --- package.json | 1 + src/CoreManager.js | 21 ++ src/LiveQueryClient.js | 442 ++++++++++++++++++++++++++ src/LiveQuerySubscription.js | 109 +++++++ src/Parse.js | 10 + src/ParseLiveQuery.js | 152 +++++++++ src/ParseQuery.js | 11 + src/__tests__/LiveQueryClient-test.js | 416 ++++++++++++++++++++++++ 8 files changed, 1162 insertions(+) create mode 100644 src/LiveQueryClient.js create mode 100644 src/LiveQuerySubscription.js create mode 100644 src/ParseLiveQuery.js create mode 100644 src/__tests__/LiveQueryClient-test.js diff --git a/package.json b/package.json index 9181e924c..190d8c175 100644 --- a/package.json +++ b/package.json @@ -29,6 +29,7 @@ }, "dependencies": { "babel-runtime": "^5.8.20", + "ws": "^1.0.1", "xmlhttprequest": "^1.7.0" }, "devDependencies": { diff --git a/src/CoreManager.js b/src/CoreManager.js index eb9d394cb..300c141b9 100644 --- a/src/CoreManager.js +++ b/src/CoreManager.js @@ -117,6 +117,7 @@ var config: { [key: string]: mixed } = { !process.version.electron), REQUEST_ATTEMPT_LIMIT: 5, SERVER_URL: 'https://api.parse.com/1', + LIVEQUERY_SERVER_URL: null, VERSION: 'js' + require('../package.json').version, APPLICATION_ID: null, JAVASCRIPT_KEY: null, @@ -455,5 +456,25 @@ module.exports = { getUserController(): UserController { return config['UserController']; + }, + + setLiveQueryController(controller: any) { + if (typeof controller.subscribe !== 'function') { + throw new Error('LiveQueryController must implement subscribe()'); + } + if (typeof controller.unsubscribe !== 'function') { + throw new Error('LiveQueryController must implement unsubscribe()'); + } + if (typeof controller.open !== 'function') { + throw new Error('LiveQueryController must implement open()'); + } + if (typeof controller.close !== 'function') { + throw new Error('LiveQueryController must implement close()'); + } + config['LiveQueryController'] = controller; + }, + + getLiveQueryController(): any { + return config['LiveQueryController']; } } diff --git a/src/LiveQueryClient.js b/src/LiveQueryClient.js new file mode 100644 index 000000000..ff34816e4 --- /dev/null +++ b/src/LiveQueryClient.js @@ -0,0 +1,442 @@ +/** + * Copyright (c) 2015-present, Parse, LLC. + * All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. An additional grant + * of patent rights can be found in the PATENTS file in the same directory. + * + */ + +import events from 'events'; +import ParsePromise from './ParsePromise'; +import ParseObject from './ParseObject'; +import LiveQuerySubscription from './LiveQuerySubscription'; + +// The LiveQuery client inner state +const CLIENT_STATE = { + INITIALIZED: 'initialized', + CONNECTING: 'connecting', + CONNECTED: 'connected', + CLOSED: 'closed', + RECONNECTING: 'reconnecting', + DISCONNECTED: 'disconnected' +}; + +// The event type the LiveQuery client should sent to server +const OP_TYPES = { + CONNECT: 'connect', + SUBSCRIBE: 'subscribe', + UNSUBSCRIBE: 'unsubscribe', + ERROR: 'error' +}; + +// The event we get back from LiveQuery server +const OP_EVENTS = { + CONNECTED: 'connected', + SUBSCRIBED: 'subscribed', + UNSUBSCRIBED: 'unsubscribed', + ERROR: 'error', + CREATE: 'create', + UPDATE: 'update', + ENTER: 'enter', + LEAVE: 'leave', + DELETE: 'delete' +}; + +// The event the LiveQuery client should emit +const CLIENT_EMMITER_TYPES = { + CLOSE: 'close', + ERROR: 'error', + OPEN: 'open' +}; + +// The event the LiveQuery subscription should emit +const SUBSCRIPTION_EMMITER_TYPES = { + OPEN: 'open', + CLOSE: 'close', + ERROR: 'error', + CREATE: 'create', + UPDATE: 'update', + ENTER: 'enter', + LEAVE: 'leave', + DELETE: 'delete' +}; + + +let generateInterval = (k) => { + return Math.random() * Math.min(30, (Math.pow(2, k) - 1)) * 1000; +} + +/** + * Creates a new LiveQueryClient. + * Extends events.EventEmitter + * cloud functions. + * + * A wrapper of a standard WebSocket client. We add several useful methods to + * help you connect/disconnect to LiveQueryServer, subscribe/unsubscribe a ParseQuery easily. + * + * javascriptKey and masterKey are used for verifying the LiveQueryClient when it tries + * to connect to the LiveQuery server + * + * @class Parse.LiveQueryClient + * @constructor + * @param {Object} options + * @param {string} options.applicationId - applicationId of your Parse app + * @param {string} options.serverURL - the URL of your LiveQuery server + * @param {string} options.javascriptKey (optional) + * @param {string} options.masterKey (optional) Your Parse Master Key. (Node.js only!) + * @param {string} options.sessionToken (optional) + * + * + * We expose three events to help you monitor the status of the LiveQueryClient. + * + *
+ * let Parse = require('parse/node');
+ * let LiveQueryClient = Parse.LiveQueryClient;
+ * let client = new LiveQueryClient({
+ *   applicationId: '',
+ *   serverURL: '',
+ *   javascriptKey: '',
+ *   masterKey: ''
+ *  });
+ * 
+ * + * Open - When we establish the WebSocket connection to the LiveQuery server, you'll get this event. + *
+ * client.on('open', () => {
+ * 
+ * });
+ * + * Close - When we lose the WebSocket connection to the LiveQuery server, you'll get this event. + *
+ * client.on('close', () => {
+ * 
+ * });
+ * + * Error - When some network error or LiveQuery server error happens, you'll get this event. + *
+ * client.on('error', (error) => {
+ * 
+ * });
+ * + * + */ +export default class LiveQueryClient extends events.EventEmitter { + attempts: number; + id: number; + requestId: number; + applicationId: string; + serverURL: string; + javascriptKey: ?string; + masterKey: ?string; + sessionToken: ?string; + connectPromise: Object; + subscriptions: Map; + socket: any; + state: string; + + constructor({ + applicationId, + serverURL, + javascriptKey, + masterKey, + sessionToken + }: LiveQueryConstructorArg) { + super(); + + if (!serverURL || serverURL.indexOf('ws') !== 0) { + throw new Error('You need to set a proper Parse LiveQuery server url before using LiveQueryClient'); + } + + this.attempts = 1;; + this.id = 0; + this.requestId = 1; + this.serverURL = serverURL; + this.applicationId = applicationId; + this.javascriptKey = javascriptKey; + this.masterKey = masterKey; + this.sessionToken = sessionToken; + this.connectPromise = new ParsePromise(); + this.subscriptions = new Map(); + this.state = CLIENT_STATE.INITIALIZED; + } + + shouldOpen(): any { + return this.state === CLIENT_STATE.INITIALIZED || this.state === CLIENT_STATE.DISCONNECTED; + } + + /** + * Subscribes to a ParseQuery + * + * If you provide the sessionToken, when the LiveQuery server gets ParseObject's + * updates from parse server, it'll try to check whether the sessionToken fulfills + * the ParseObject's ACL. The LiveQuery server will only send updates to clients whose + * sessionToken is fit for the ParseObject's ACL. You can check the LiveQuery protocol + * here for more details. The subscription you get is the same subscription you get + * from our Standard API. + * + * @method subscribe + * @param {Object} query - the ParseQuery you want to subscribe to + * @param {string} sessionToken (optional) + * @return {Object} subscription + */ + subscribe(query: Object, sessionToken: ?string): Object { + if (!query) { + return; + } + let where = query.toJSON().where; + let className = query.className; + let subscribeRequest = { + op: OP_TYPES.SUBSCRIBE, + requestId: this.requestId, + query: { + className, + where + } + }; + + if (sessionToken) { + subscribeRequest.sessionToken = sessionToken; + } + + let subscription = new LiveQuerySubscription(this.requestId, query, sessionToken); + this.subscriptions.set(this.requestId, subscription); + this.requestId += 1; + this.connectPromise.then(() => { + this.socket.send(JSON.stringify(subscribeRequest)); + }); + + // adding listener so process does not crash + // best practice is for developer to register their own listener + subscription.on('error', () => {}); + + return subscription; + } + + /** + * After calling unsubscribe you'll stop receiving events from the subscription object. + * + * @method unsubscribe + * @param {Object} subscription - subscription you would like to unsubscribe from. + */ + unsubscribe(subscription: Object) { + if (!subscription) { + return; + } + + this.subscriptions.delete(subscription.id); + let unsubscribeRequest = { + op: OP_TYPES.UNSUBSCRIBE, + requestId: subscription.id + } + this.connectPromise.then(() => { + this.socket.send(JSON.stringify(unsubscribeRequest)); + }); + } + + /** + * After open is called, the LiveQueryClient will try to send a connect request + * to the LiveQuery server. + * + * @method open + */ + open() { + let WebSocketImplementation = this._getWebSocketImplementation(); + if (!WebSocketImplementation) { + this.emit(CLIENT_EMMITER_TYPES.ERROR, 'Can not find WebSocket implementation'); + return; + } + + if (this.state !== CLIENT_STATE.RECONNECTING) { + this.state = CLIENT_STATE.CONNECTING; + } + + // Get WebSocket implementation + this.socket = new WebSocketImplementation(this.serverURL); + + // Bind WebSocket callbacks + this.socket.onopen = () => { + this._handleWebSocketOpen(); + }; + + this.socket.onmessage = (event) => { + this._handleWebSocketMessage(event); + }; + + this.socket.onclose = () => { + this._handleWebSocketClose(); + }; + + this.socket.onerror = (error) => { + console.log("error on socket"); + this._handleWebSocketError(error); + }; + } + + resubscribe() { + this.subscriptions.forEach((subscription, requestId) => { + let query = subscription.query; + let where = query.toJSON().where; + let className = query.className; + let sessionToken = subscription.sessionToken; + let subscribeRequest = { + op: OP_TYPES.SUBSCRIBE, + requestId, + query: { + className, + where + } + }; + + if (sessionToken) { + subscribeRequest.sessionToken = sessionToken; + } + + this.connectPromise.then(() => { + this.socket.send(JSON.stringify(subscribeRequest)); + }); + }); + } + + /** + * This method will close the WebSocket connection to this LiveQueryClient, + * cancel the auto reconnect and unsubscribe all subscriptions based on it. + * + * @method close + */ + close() { + if (this.state === CLIENT_STATE.INITIALIZED || this.state === CLIENT_STATE.DISCONNECTED) { + return; + } + this.state = CLIENT_STATE.DISCONNECTED; + this.socket.close(); + // Notify each subscription about the close + for (let subscription of this.subscriptions.values()) { + subscription.emit(SUBSCRIPTION_EMMITER_TYPES.CLOSE); + } + this._handleReset(); + this.emit(CLIENT_EMMITER_TYPES.CLOSE); + } + + _getWebSocketImplementation(): any { + let WebSocketImplementation; + if (process.env.PARSE_BUILD === 'node') { + WebSocketImplementation = require('ws'); + } else if (process.env.PARSE_BUILD === 'browser') { + if (window.WebSocket) { + WebSocketImplementation = WebSocket; + } + } + return WebSocketImplementation; + } + + // ensure we start with valid state if connect is called again after close + _handleReset() { + this.attempts = 1;; + this.id = 0; + this.requestId = 1; + this.connectPromise = new ParsePromise(); + this.subscriptions = new Map(); + } + + _handleWebSocketOpen() { + this.attempts = 1; + let connectRequest = { + op: OP_TYPES.CONNECT, + applicationId: this.applicationId, + javascriptKey: this.javascriptKey, + masterKey: this.masterKey, + sessionToken: this.sessionToken + }; + this.socket.send(JSON.stringify(connectRequest)); + } + + _handleWebSocketMessage(event: any) { + let data = event.data; + if (typeof data === 'string') { + data = JSON.parse(data); + } + let subscription = null; + if (data.requestId) { + subscription = + this.subscriptions.get(data.requestId); + } + switch(data.op) { + case OP_EVENTS.CONNECTED: + if (this.state === CLIENT_STATE.RECONNECTING) { + this.resubscribe(); + } + this.emit(CLIENT_EMMITER_TYPES.OPEN); + this.id = data.clientId; + this.connectPromise.resolve(); + this.state = CLIENT_STATE.CONNECTED; + break; + case OP_EVENTS.SUBSCRIBED: + if (subscription) { + subscription.emit(SUBSCRIPTION_EMMITER_TYPES.OPEN); + } + break; + case OP_EVENTS.ERROR: + if (data.requestId) { + if (subscription) { + subscription.emit(SUBSCRIPTION_EMMITER_TYPES.ERROR, data.error); + } + } else { + this.emit(CLIENT_EMMITER_TYPES.ERROR, data.error); + } + break; + case OP_EVENTS.UNSUBSCRIBED: + // We have already deleted subscription in unsubscribe(), do nothing here + break; + default: + // create, update, enter, leave, delete cases + let className = data.object.className; + // Delete the extrea __type and className fields during transfer to full JSON + delete data.object.__type; + delete data.object.className; + let parseObject = new ParseObject(className); + parseObject._finishFetch(data.object); + if (!subscription) { + break; + } + subscription.emit(data.op, parseObject); + } + } + + _handleWebSocketClose() { + if (this.state === CLIENT_STATE.DISCONNECTED) { + return; + } + this.state = CLIENT_STATE.CLOSED; + this.emit(CLIENT_EMMITER_TYPES.CLOSE); + // Notify each subscription about the close + for (let subscription of this.subscriptions.values()) { + subscription.emit(SUBSCRIPTION_EMMITER_TYPES.CLOSE); + } + this._handleReconnect(); + } + + _handleWebSocketError(error: any) { + this.emit(CLIENT_EMMITER_TYPES.ERROR, error); + for (let subscription of this.subscriptions.values()) { + subscription.emit(SUBSCRIPTION_EMMITER_TYPES.ERROR); + } + this._handleReconnect(); + } + + _handleReconnect() { + // if closed or currently reconnecting we stop attempting to reconnect + if (this.state === CLIENT_STATE.DISCONNECTED || this.state === CLIENT_STATE.RECONNECTING) { + return; + } + this.state = CLIENT_STATE.RECONNECTING; + let time = generateInterval(this.attempts); + console.info('attempting to reconnect after ' + time + 'ms'); + setTimeout((() => { + this.attempts++; + this.connectPromise = new ParsePromise(); + this.open(); + }).bind(this), time); + } +} diff --git a/src/LiveQuerySubscription.js b/src/LiveQuerySubscription.js new file mode 100644 index 000000000..7c326a43a --- /dev/null +++ b/src/LiveQuerySubscription.js @@ -0,0 +1,109 @@ +/** + * Copyright (c) 2015-present, Parse, LLC. + * All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. An additional grant + * of patent rights can be found in the PATENTS file in the same directory. + * + */ + +import events from 'events'; +import CoreManager from './CoreManager'; + +/** + * Creates a new LiveQuery Subscription. + * Extends events.EventEmitter + * cloud functions. + * + * @constructor + * @param {string} id - subscription id + * @param {string} query - query to subscribe to + * @param {string} sessionToken - optional session token + * + *

Open Event - When you call query.subscribe(), we send a subscribe request to + * the LiveQuery server, when we get the confirmation from the LiveQuery server, + * this event will be emitted. When the client loses WebSocket connection to the + * LiveQuery server, we will try to auto reconnect the LiveQuery server. If we + * reconnect the LiveQuery server and successfully resubscribe the ParseQuery, + * you'll also get this event. + * + *

+ * subscription.on('open', () => {
+ * 
+ * });

+ * + *

Create Event - When a new ParseObject is created and it fulfills the ParseQuery you subscribe, + * you'll get this event. The object is the ParseObject which is created. + * + *

+ * subscription.on('create', (object) => {
+ * 
+ * });

+ * + *

Update Event - When an existing ParseObject which fulfills the ParseQuery you subscribe + * is updated (The ParseObject fulfills the ParseQuery before and after changes), + * you'll get this event. The object is the ParseObject which is updated. + * Its content is the latest value of the ParseObject. + * + *

+ * subscription.on('update', (object) => {
+ * 
+ * });

+ * + *

Enter Event - When an existing ParseObject's old value doesn't fulfill the ParseQuery + * but its new value fulfills the ParseQuery, you'll get this event. The object is the + * ParseObject which enters the ParseQuery. Its content is the latest value of the ParseObject. + * + *

+ * subscription.on('enter', (object) => {
+ * 
+ * });

+ * + * + *

Update Event - When an existing ParseObject's old value fulfills the ParseQuery but its new value + * doesn't fulfill the ParseQuery, you'll get this event. The object is the ParseObject + * which leaves the ParseQuery. Its content is the latest value of the ParseObject. + * + *

+ * subscription.on('leave', (object) => {
+ * 
+ * });

+ * + * + *

Delete Event - When an existing ParseObject which fulfills the ParseQuery is deleted, you'll + * get this event. The object is the ParseObject which is deleted. + * + *

+ * subscription.on('delete', (object) => {
+ * 
+ * });

+ * + * + *

Close Event - When the client loses the WebSocket connection to the LiveQuery + * server and we stop receiving events, you'll get this event. + * + *

+ * subscription.on('close', () => {
+ * 
+ * });

+ * + * + */ +export default class Subscription extends events.EventEmitter { + constructor(id, query, sessionToken) { + super(); + this.id = id; + this.query = query; + this.sessionToken = sessionToken; + } + + /** + * @method unsubscribe + */ + unsubscribe() { + var liveQueryClient = CoreManager.getLiveQueryController().getDefaultLiveQueryClient(); + liveQueryClient.unsubscribe(this); + this.emit('close'); + } +} diff --git a/src/Parse.js b/src/Parse.js index 341f5a46e..eaef49f8f 100644 --- a/src/Parse.js +++ b/src/Parse.js @@ -80,6 +80,14 @@ Object.defineProperty(Parse, 'serverURL', { CoreManager.set('SERVER_URL', value); } }); +Object.defineProperty(Parse, 'liveQueryServerURL', { + get() { + return CoreManager.get('LIVEQUERY_SERVER_URL'); + }, + set(value) { + CoreManager.set('LIVEQUERY_SERVER_URL', value); + } +}); /** End setters **/ Parse.ACL = require('./ParseACL'); @@ -110,6 +118,8 @@ Parse.Role = require('./ParseRole'); Parse.Session = require('./ParseSession'); Parse.Storage = require('./Storage'); Parse.User = require('./ParseUser'); +Parse.LiveQuery = require('./ParseLiveQuery'); +Parse.LiveQueryClient = require('./LiveQueryClient'); Parse._request = function(...args) { return CoreManager.getRESTController().request.apply(null, args); diff --git a/src/ParseLiveQuery.js b/src/ParseLiveQuery.js new file mode 100644 index 000000000..5aec9643e --- /dev/null +++ b/src/ParseLiveQuery.js @@ -0,0 +1,152 @@ +import events from 'events'; +import url from 'url'; +import LiveQueryClient from './LiveQueryClient'; +import CoreManager from './CoreManager'; + +function open() { + var LiveQueryController = CoreManager.getLiveQueryController(); + LiveQueryController.open(); +} + +function close() { + var LiveQueryController = CoreManager.getLiveQueryController(); + LiveQueryController.close(); +} + +/** + * + * We expose three events to help you monitor the status of the WebSocket connection: + * + *

Open - When we establish the WebSocket connection to the LiveQuery server, you'll get this event. + * + *

+ * Parse.LiveQuery.on('open', () => {
+ * 
+ * });

+ * + *

Close - When we lose the WebSocket connection to the LiveQuery server, you'll get this event. + * + *

+ * Parse.LiveQuery.on('close', () => {
+ * 
+ * });

+ * + *

Error - When some network error or LiveQuery server error happens, you'll get this event. + * + *

+ * Parse.LiveQuery.on('error', (error) => {
+ * 
+ * });

+ * + * @class Parse.LiveQuery + * @static + * + */ +let LiveQuery = new events.EventEmitter(); + +/** + * After open is called, the LiveQuery will try to send a connect request + * to the LiveQuery server. + * + * @method open + */ +LiveQuery.open = open; + +/** + * When you're done using LiveQuery, you can call Parse.LiveQuery.close(). + * This function will close the WebSocket connection to the LiveQuery server, + * cancel the auto reconnect, and unsubscribe all subscriptions based on it. + * If you call query.subscribe() after this, we'll create a new WebSocket + * connection to the LiveQuery server. + * + * @method close + */ + +LiveQuery.close = close; +// Register a default onError callback to make sure we do not crash on error +LiveQuery.on('error', () => { +}); + +export default LiveQuery; + +let getSessionToken = () => { + let currentUser = CoreManager.getUserController().currentUser(); + let sessionToken; + if (currentUser) { + sessionToken = currentUser.getSessionToken(); + } + return sessionToken; +}; + +let getLiveQueryClient = () => { + return CoreManager.getLiveQueryController().getDefaultLiveQueryClient(); +}; + +let defaultLiveQueryClient; +let DefaultLiveQueryController = { + setDefaultLiveQueryClient(liveQueryClient: any) { + defaultLiveQueryClient = liveQueryClient; + }, + getDefaultLiveQueryClient(): any { + if (defaultLiveQueryClient) { + return defaultLiveQueryClient; + } + + let liveQueryServerURL = CoreManager.get('LIVEQUERY_SERVER_URL'); + + if (liveQueryServerURL && liveQueryServerURL.indexOf('ws') !== 0) { + throw new Error('You need to set a proper Parse LiveQuery server url before using LiveQueryClient'); + } + + // If we can not find Parse.liveQueryServerURL, we try to extract it from Parse.serverURL + if (!liveQueryServerURL) { + let host = url.parse(CoreManager.get('SERVER_URL')).host; + liveQueryServerURL = 'ws://' + host; + CoreManager.set('LIVEQUERY_SERVER_URL', liveQueryServerURL); + } + + let applicationId = CoreManager.get('APPLICATION_ID'); + let javascriptKey = CoreManager.get('JAVASCRIPT_KEY'); + let masterKey = CoreManager.get('MASTER_KEY'); + // Get currentUser sessionToken if possible + defaultLiveQueryClient = new LiveQueryClient({ + applicationId, + serverURL: liveQueryServerURL, + javascriptKey, + masterKey, + sessionToken: getSessionToken(), + }); + // Register a default onError callback to make sure we do not crash on error + defaultLiveQueryClient.on('error', (error) => { + LiveQuery.emit('error', error); + }); + defaultLiveQueryClient.on('open', () => { + LiveQuery.emit('open'); + }); + defaultLiveQueryClient.on('close', () => { + LiveQuery.emit('close'); + }); + return defaultLiveQueryClient; + }, + open() { + let liveQueryClient = getLiveQueryClient(); + liveQueryClient.open(); + }, + close() { + let liveQueryClient = getLiveQueryClient(); + liveQueryClient.close(); + }, + subscribe(query: any): any { + let liveQueryClient = getLiveQueryClient(); + if (liveQueryClient.shouldOpen()) { + liveQueryClient.open(); + } + return liveQueryClient.subscribe(query, getSessionToken()); + }, + unsubscribe(subscription: any) { + let liveQueryClient = getLiveQueryClient(); + return liveQueryClient.unsubscribe(subscription); + } +}; + +CoreManager.setLiveQueryController(DefaultLiveQueryController); diff --git a/src/ParseQuery.js b/src/ParseQuery.js index 63296f239..e84b77757 100644 --- a/src/ParseQuery.js +++ b/src/ParseQuery.js @@ -987,6 +987,17 @@ export default class ParseQuery { return this; } + /** + * Subscribe this query to get liveQuery updates + * @method subscribe + * @return {LiveQuerySubscription} Returns the liveQuerySubscription, it's an event emitter + * which can be used to get liveQuery updates. + */ + subscribe(): any { + let controller = CoreManager.getLiveQueryController(); + return controller.subscribe(this); + } + /** * Constructs a Parse.Query that is the OR of the passed in queries. For * example: diff --git a/src/__tests__/LiveQueryClient-test.js b/src/__tests__/LiveQueryClient-test.js new file mode 100644 index 000000000..fcf57f730 --- /dev/null +++ b/src/__tests__/LiveQueryClient-test.js @@ -0,0 +1,416 @@ +/** + * Copyright (c) 2015-present, Parse, LLC. + * All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. An additional grant + * of patent rights can be found in the PATENTS file in the same directory. + */ + +jest.dontMock('../LiveQueryClient'); +jest.dontMock('../arrayContainsObject'); +jest.dontMock('../canBeSerialized'); +jest.dontMock('../CoreManager'); +jest.dontMock('../decode'); +jest.dontMock('../encode'); +jest.dontMock('../equals'); +jest.dontMock('../escape'); +jest.dontMock('../ObjectStateMutations'); +jest.dontMock('../parseDate'); +jest.dontMock('../ParseError'); +jest.dontMock('../ParseFile'); +jest.dontMock('../ParseGeoPoint'); +jest.dontMock('../ParseObject'); +jest.dontMock('../ParseOp'); +jest.dontMock('../ParsePromise'); +jest.dontMock('../RESTController'); +jest.dontMock('../SingleInstanceStateController'); +jest.dontMock('../TaskQueue'); +jest.dontMock('../unique'); +jest.dontMock('../UniqueInstanceStateController'); +jest.dontMock('../unsavedChildren'); +jest.dontMock('../ParseACL'); +jest.dontMock('../ParseQuery'); +jest.dontMock('../LiveQuerySubscription'); + +var LiveQueryClient = require('../LiveQueryClient'); +var ParseObject = require('../ParseObject'); +var ParseQuery = require('../ParseQuery'); +var events = require('events'); + +describe('LiveQueryClient', () => { + it('can connect to server', () => { + var liveQueryClient = new LiveQueryClient({ + applicationId: 'applicationId', + serverURL: 'ws://test', + javascriptKey: 'javascriptKey', + masterKey: 'masterKey', + sessionToken: 'sessionToken' + }); + // Mock _getWebSocketImplementation + liveQueryClient._getWebSocketImplementation = function() { + return jest.genMockFunction(); + } + // Mock handlers + liveQueryClient._handleWebSocketOpen = jest.genMockFunction(); + liveQueryClient._handleWebSocketMessage = jest.genMockFunction(); + liveQueryClient._handleWebSocketClose = jest.genMockFunction(); + liveQueryClient._handleWebSocketError = jest.genMockFunction(); + + liveQueryClient.open(); + + // Verify inner state + expect(liveQueryClient.state).toEqual('connecting'); + // Verify handlers + liveQueryClient.socket.onopen({}); + expect(liveQueryClient._handleWebSocketOpen).toBeCalled(); + liveQueryClient.socket.onmessage({}); + expect(liveQueryClient._handleWebSocketMessage).toBeCalled(); + liveQueryClient.socket.onclose(); + expect(liveQueryClient._handleWebSocketClose).toBeCalled(); + liveQueryClient.socket.onerror(); + expect(liveQueryClient._handleWebSocketError).toBeCalled(); + }); + + it('can handle WebSocket open message', () => { + var liveQueryClient = new LiveQueryClient({ + applicationId: 'applicationId', + serverURL: 'ws://test', + javascriptKey: 'javascriptKey', + masterKey: 'masterKey', + sessionToken: 'sessionToken' + }); + liveQueryClient.socket = { + send: jest.genMockFunction() + }; + + liveQueryClient._handleWebSocketOpen(); + + expect(liveQueryClient.socket.send).toBeCalled(); + var messageStr = liveQueryClient.socket.send.mock.calls[0][0]; + var message = JSON.parse(messageStr); + expect(message.op).toEqual('connect'); + expect(message.applicationId).toEqual('applicationId'); + expect(message.javascriptKey).toEqual('javascriptKey'); + expect(message.masterKey).toEqual('masterKey'); + expect(message.sessionToken).toEqual('sessionToken'); + }); + + it('can handle WebSocket connected response message', () => { + var liveQueryClient = new LiveQueryClient({ + applicationId: 'applicationId', + serverURL: 'ws://test', + javascriptKey: 'javascriptKey', + masterKey: 'masterKey', + sessionToken: 'sessionToken' + }); + var data = { + op: 'connected', + clientId: 1 + }; + var event = { + data: JSON.stringify(data) + } + // Register checked in advance + var isChecked = false; + liveQueryClient.on('open', function(dataAgain) { + isChecked = true; + }); + + liveQueryClient._handleWebSocketMessage(event); + + expect(isChecked).toBe(true); + expect(liveQueryClient.id).toBe(1); + expect(liveQueryClient.connectPromise._resolved).toBe(true); + expect(liveQueryClient.state).toEqual('connected'); + }); + + it('can handle WebSocket subscribed response message', () => { + var liveQueryClient = new LiveQueryClient({ + applicationId: 'applicationId', + serverURL: 'ws://test', + javascriptKey: 'javascriptKey', + masterKey: 'masterKey', + sessionToken: 'sessionToken' + }); + // Add mock subscription + var subscription = new events.EventEmitter(); + liveQueryClient.subscriptions.set(1, subscription); + var data = { + op: 'subscribed', + clientId: 1, + requestId: 1 + }; + var event = { + data: JSON.stringify(data) + } + // Register checked in advance + var isChecked = false; + subscription.on('open', function(dataAgain) { + isChecked = true; + }); + + liveQueryClient._handleWebSocketMessage(event); + + expect(isChecked).toBe(true); + }); + + it('can handle WebSocket error response message', () => { + var liveQueryClient = new LiveQueryClient({ + applicationId: 'applicationId', + serverURL: 'ws://test', + javascriptKey: 'javascriptKey', + masterKey: 'masterKey', + sessionToken: 'sessionToken' + }); + var data = { + op: 'error', + clientId: 1, + error: 'error' + }; + var event = { + data: JSON.stringify(data) + } + // Register checked in advance + var isChecked = false; + liveQueryClient.on('error', function(error) { + isChecked = true; + expect(error).toEqual('error'); + }); + + liveQueryClient._handleWebSocketMessage(event); + + expect(isChecked).toBe(true); + }); + + it('can handle WebSocket event response message', () => { + var liveQueryClient = new LiveQueryClient({ + applicationId: 'applicationId', + serverURL: 'ws://test', + javascriptKey: 'javascriptKey', + masterKey: 'masterKey', + sessionToken: 'sessionToken' + }); + // Add mock subscription + var subscription = new events.EventEmitter(); + liveQueryClient.subscriptions.set(1, subscription); + let object = new ParseObject('Test'); + object.set('key', 'value'); + var data = { + op: 'create', + clientId: 1, + requestId: 1, + object: object._toFullJSON() + }; + var event = { + data: JSON.stringify(data) + } + // Register checked in advance + var isChecked = false; + subscription.on('create', function(parseObject) { + isChecked = true; + expect(parseObject.get('key')).toEqual('value'); + expect(parseObject.get('className')).toBeUndefined(); + expect(parseObject.get('__type')).toBeUndefined(); + }); + + liveQueryClient._handleWebSocketMessage(event); + + expect(isChecked).toBe(true); + }); + + it('can handle WebSocket close message', () => { + var liveQueryClient = new LiveQueryClient({ + applicationId: 'applicationId', + serverURL: 'ws://test', + javascriptKey: 'javascriptKey', + masterKey: 'masterKey', + sessionToken: 'sessionToken' + }); + // Add mock subscription + var subscription = new events.EventEmitter(); + liveQueryClient.subscriptions.set(1, subscription); + // Register checked in advance + var isChecked = false; + subscription.on('close', function() { + isChecked = true; + }); + var isCheckedAgain = false; + liveQueryClient.on('close', function() { + isCheckedAgain = true; + }); + + liveQueryClient._handleWebSocketClose(); + + expect(isChecked).toBe(true); + expect(isCheckedAgain).toBe(true); + }); + + it('can handle reconnect', () => { + var liveQueryClient = new LiveQueryClient({ + applicationId: 'applicationId', + serverURL: 'ws://test', + javascriptKey: 'javascriptKey', + masterKey: 'masterKey', + sessionToken: 'sessionToken' + }); + + liveQueryClient.open = jest.genMockFunction(); + + let attempts = liveQueryClient.attempts; + liveQueryClient._handleReconnect(); + expect(liveQueryClient.state).toEqual('reconnecting'); + + jest.runOnlyPendingTimers(); + + expect(liveQueryClient.attempts).toEqual(attempts + 1); + expect(liveQueryClient.open).toBeCalled(); + }); + + it('can handle WebSocket error message', () => { + var liveQueryClient = new LiveQueryClient({ + applicationId: 'applicationId', + serverURL: 'ws://test', + javascriptKey: 'javascriptKey', + masterKey: 'masterKey', + sessionToken: 'sessionToken' + }); + var error = {}; + var isChecked = false; + liveQueryClient.on('error', function(errorAgain) { + isChecked = true; + expect(errorAgain).toEqual(error); + }); + + liveQueryClient._handleWebSocketError(error); + + expect(isChecked).toBe(true); + }); + + it('can subscribe', () => { + var liveQueryClient = new LiveQueryClient({ + applicationId: 'applicationId', + serverURL: 'ws://test', + javascriptKey: 'javascriptKey', + masterKey: 'masterKey', + sessionToken: 'sessionToken' + }); + liveQueryClient.socket = { + send: jest.genMockFunction() + }; + var query = new ParseQuery('Test'); + query.equalTo('key', 'value'); + + var subscription = liveQueryClient.subscribe(query); + liveQueryClient.connectPromise.resolve(); + + expect(subscription).toBe(liveQueryClient.subscriptions.get(1)); + expect(liveQueryClient.requestId).toBe(2); + var messageStr = liveQueryClient.socket.send.mock.calls[0][0]; + var message = JSON.parse(messageStr); + expect(message).toEqual({ + op: 'subscribe', + requestId: 1, + query: { + className: 'Test', + where: { + key: 'value' + } + } + }); + }); + + it('can unsubscribe', () => { + var liveQueryClient = new LiveQueryClient({ + applicationId: 'applicationId', + serverURL: 'ws://test', + javascriptKey: 'javascriptKey', + masterKey: 'masterKey', + sessionToken: 'sessionToken' + }); + liveQueryClient.socket = { + send: jest.genMockFunction() + }; + var subscription = { + id: 1 + } + liveQueryClient.subscriptions.set(1, subscription); + + liveQueryClient.unsubscribe(subscription); + liveQueryClient.connectPromise.resolve(); + + expect(liveQueryClient.subscriptions.size).toBe(0); + var messageStr = liveQueryClient.socket.send.mock.calls[0][0]; + var message = JSON.parse(messageStr); + expect(message).toEqual({ + op: 'unsubscribe', + requestId: 1 + }); + }); + + it('can resubscribe', () => { + var liveQueryClient = new LiveQueryClient({ + applicationId: 'applicationId', + serverURL: 'ws://test', + javascriptKey: 'javascriptKey', + masterKey: 'masterKey', + sessionToken: 'sessionToken' + }); + liveQueryClient.socket = { + send: jest.genMockFunction() + }; + var query = new ParseQuery('Test'); + query.equalTo('key', 'value'); + var subscription = liveQueryClient.subscribe(query); + liveQueryClient.connectPromise.resolve(); + + liveQueryClient.resubscribe(); + + expect(liveQueryClient.requestId).toBe(2); + var messageStr = liveQueryClient.socket.send.mock.calls[0][0]; + var message = JSON.parse(messageStr); + expect(message).toEqual({ + op: 'subscribe', + requestId: 1, + query: { + className: 'Test', + where: { + key: 'value' + } + } + }); + }); + + it('can close', () => { + var liveQueryClient = new LiveQueryClient({ + applicationId: 'applicationId', + serverURL: 'ws://test', + javascriptKey: 'javascriptKey', + masterKey: 'masterKey', + sessionToken: 'sessionToken' + }); + liveQueryClient.state = 'connected'; + liveQueryClient.socket = { + close: jest.genMockFunction() + } + var subscription = new events.EventEmitter(); + liveQueryClient.subscriptions.set(1, subscription); + // Register checked in advance + var isChecked = false; + subscription.on('close', function() { + isChecked = true; + }); + var isCheckedAgain = false; + liveQueryClient.on('close', function() { + isCheckedAgain = true; + }); + + liveQueryClient.close(); + + expect(liveQueryClient.subscriptions.size).toBe(0); + expect(isChecked).toBe(true); + expect(isCheckedAgain).toBe(true); + expect(liveQueryClient.socket.close).toBeCalled(); + expect(liveQueryClient.state).toBe('disconnected'); + }); +}); From b918929fc43979ae4e832aac130d670d7caa1493 Mon Sep 17 00:00:00 2001 From: Andrew Imm Date: Fri, 18 Mar 2016 01:06:35 -0700 Subject: [PATCH 14/14] Remove url module --- src/ParseLiveQuery.js | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/ParseLiveQuery.js b/src/ParseLiveQuery.js index 5aec9643e..52c80c2d8 100644 --- a/src/ParseLiveQuery.js +++ b/src/ParseLiveQuery.js @@ -1,5 +1,4 @@ import events from 'events'; -import url from 'url'; import LiveQueryClient from './LiveQueryClient'; import CoreManager from './CoreManager'; @@ -100,7 +99,7 @@ let DefaultLiveQueryController = { // If we can not find Parse.liveQueryServerURL, we try to extract it from Parse.serverURL if (!liveQueryServerURL) { - let host = url.parse(CoreManager.get('SERVER_URL')).host; + let host = CoreManager.get('SERVER_URL').replace(/^https?:\/\//, ''); liveQueryServerURL = 'ws://' + host; CoreManager.set('LIVEQUERY_SERVER_URL', liveQueryServerURL); }