diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index fe1875f53..cedf086df 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -34,6 +34,20 @@ jobs: - run: npm ci - name: Check Docs run: npm run docs + check-lint: + name: Lint + timeout-minutes: 15 + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Use Node.js + uses: actions/setup-node@v4 + with: + cache: npm + - name: Install dependencies + run: npm ci + - name: Lint + run: npm run lint build: runs-on: ubuntu-latest timeout-minutes: 30 @@ -55,12 +69,15 @@ jobs: node-version: ${{ matrix.NODE_VERSION }} cache: npm - run: npm ci - - run: npm run lint - run: npm test -- --maxWorkers=4 - run: npm run test:mongodb env: CI: true - - run: bash <(curl -s https://codecov.io/bash) + - name: Upload code coverage + uses: codecov/codecov-action@v4 + with: + fail_ci_if_error: true + token: ${{ secrets.CODECOV_TOKEN }} concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true diff --git a/README.md b/README.md index ae223379f..5111d1d52 100644 --- a/README.md +++ b/README.md @@ -28,6 +28,7 @@ A library that gives you access to the powerful Parse Server backend from your J - [Getting Started](#getting-started) - [Using Parse on Different Platforms](#using-parse-on-different-platforms) + - [Core Manager](#core-manager) - [Compatibility](#compatibility) - [Parse Server](#parse-server) - [Node.js](#nodejs) @@ -89,6 +90,18 @@ $ npm install @types/parse Types are updated manually after every release. If a definition doesn't exist, please submit a pull request to [@types/parse][types-parse] +#### Core Manager + +The SDK has a [Core Manager](src/CoreManager.js) that handles all configurations and controllers. These modules can be swapped out for customization before you initialize the SDK. For full list of all available modules take a look at the [Core Manager Documentation](src/CoreManager.js). + +```js +// Configuration example +Parse.CoreManager.set('REQUEST_ATTEMPT_LIMIT', 1) + +// Controller example +Parse.CoreManager.setRESTController(MyRESTController); +``` + ## Compatibility ### Parse Server diff --git a/changelogs/CHANGELOG_alpha.md b/changelogs/CHANGELOG_alpha.md index 819d4e3a4..0c119689c 100644 --- a/changelogs/CHANGELOG_alpha.md +++ b/changelogs/CHANGELOG_alpha.md @@ -1,3 +1,38 @@ +# [5.1.0-alpha.6](https://github.com/parse-community/Parse-SDK-JS/compare/5.1.0-alpha.5...5.1.0-alpha.6) (2024-04-25) + + +### Features + +* Allow setting custom queue for handling offline operations via `Parse.EventuallyQueue` ([#2106](https://github.com/parse-community/Parse-SDK-JS/issues/2106)) ([f92e4d4](https://github.com/parse-community/Parse-SDK-JS/commit/f92e4d42afdc1e55bcfff1ba9d0658d39943f3f0)) + +# [5.1.0-alpha.5](https://github.com/parse-community/Parse-SDK-JS/compare/5.1.0-alpha.4...5.1.0-alpha.5) (2024-04-22) + + +### Bug Fixes + +* Chrome browser console warning about unsafe header `access-control-expose-headers` when calling Cloud Function ([#2095](https://github.com/parse-community/Parse-SDK-JS/issues/2095)) ([7b73c03](https://github.com/parse-community/Parse-SDK-JS/commit/7b73c033eef8977c3e6c7e4af7146ffa74deed0c)) + +# [5.1.0-alpha.4](https://github.com/parse-community/Parse-SDK-JS/compare/5.1.0-alpha.3...5.1.0-alpha.4) (2024-04-15) + + +### Bug Fixes + +* Live Query not working on Expo React Native ([#2109](https://github.com/parse-community/Parse-SDK-JS/issues/2109)) ([7a89665](https://github.com/parse-community/Parse-SDK-JS/commit/7a8966522f06efb3f0303b2a3c6fd08f41d8aff9)) + +# [5.1.0-alpha.3](https://github.com/parse-community/Parse-SDK-JS/compare/5.1.0-alpha.2...5.1.0-alpha.3) (2024-04-14) + + +### Features + +* Lazy load `Parse.CoreManager` controllers to add support for swappable `CryptoController`, `LocalDatastoreController`, `StorageController`, `WebSocketController`, `ParseLiveQuery` ([#2100](https://github.com/parse-community/Parse-SDK-JS/issues/2100)) ([fbd0ab1](https://github.com/parse-community/Parse-SDK-JS/commit/fbd0ab1402792e241c4d9d6496b451e4cc268b8b)) + +# [5.1.0-alpha.2](https://github.com/parse-community/Parse-SDK-JS/compare/5.1.0-alpha.1...5.1.0-alpha.2) (2024-04-13) + + +### Bug Fixes + +* Local datastore throws error when `Parse.Query.notEqualTo` is set to `null` ([#2102](https://github.com/parse-community/Parse-SDK-JS/issues/2102)) ([6afd32a](https://github.com/parse-community/Parse-SDK-JS/commit/6afd32af3517c88b570505d5cb25bd5ab449f039)) + # [5.1.0-alpha.1](https://github.com/parse-community/Parse-SDK-JS/compare/5.0.0...5.1.0-alpha.1) (2024-03-31) diff --git a/integration/test/ParseEventuallyQueueTest.js b/integration/test/ParseEventuallyQueueTest.js index 1d8157553..8d5305915 100644 --- a/integration/test/ParseEventuallyQueueTest.js +++ b/integration/test/ParseEventuallyQueueTest.js @@ -195,7 +195,8 @@ describe('Parse EventuallyQueue', () => { const object = new TestObject({ hash: 'saveSecret' }); await new Promise((resolve) => parseServer.server.close(resolve)); await object.saveEventually(); - let length = await Parse.EventuallyQueue.length(); + + const length = await Parse.EventuallyQueue.length(); assert(Parse.EventuallyQueue.isPolling()); assert.strictEqual(length, 1); @@ -203,14 +204,6 @@ describe('Parse EventuallyQueue', () => { while (Parse.EventuallyQueue.isPolling()) { await sleep(100); } - assert.strictEqual(Parse.EventuallyQueue.isPolling(), false); - - while (await Parse.EventuallyQueue.length()) { - await sleep(100); - } - length = await Parse.EventuallyQueue.length(); - assert.strictEqual(length, 0); - const query = new Parse.Query(TestObject); query.equalTo('hash', 'saveSecret'); let results = await query.find(); @@ -233,10 +226,9 @@ describe('Parse EventuallyQueue', () => { object.setACL(acl); await new Promise((resolve) => parseServer.server.close(resolve)); - await object.saveEventually(); - let length = await Parse.EventuallyQueue.length(); + const length = await Parse.EventuallyQueue.length(); assert(Parse.EventuallyQueue.isPolling()); assert.strictEqual(length, 1); @@ -245,15 +237,6 @@ describe('Parse EventuallyQueue', () => { while (Parse.EventuallyQueue.isPolling()) { await sleep(100); } - assert.strictEqual(Parse.EventuallyQueue.isPolling(), false); - - length = await Parse.EventuallyQueue.length(); - while (length) { - await sleep(100); - } - length = await Parse.EventuallyQueue.length(); - assert.strictEqual(length, 0); - const query = new Parse.Query('TestObject'); query.equalTo('hash', 'saveSecret'); let results = await query.find(); @@ -269,7 +252,8 @@ describe('Parse EventuallyQueue', () => { await object.save(); await new Promise((resolve) => parseServer.server.close(resolve)); await object.destroyEventually(); - let length = await Parse.EventuallyQueue.length(); + const length = await Parse.EventuallyQueue.length(); + assert(Parse.EventuallyQueue.isPolling()); assert.strictEqual(length, 1); @@ -277,13 +261,6 @@ describe('Parse EventuallyQueue', () => { while (Parse.EventuallyQueue.isPolling()) { await sleep(100); } - assert.strictEqual(Parse.EventuallyQueue.isPolling(), false); - while (await Parse.EventuallyQueue.length()) { - await sleep(100); - } - length = await Parse.EventuallyQueue.length(); - assert.strictEqual(length, 0); - const query = new Parse.Query(TestObject); query.equalTo('hash', 'deleteSecret'); let results = await query.find(); diff --git a/integration/test/ParseLiveQueryTest.js b/integration/test/ParseLiveQueryTest.js index c33f21a00..06a2c89d9 100644 --- a/integration/test/ParseLiveQueryTest.js +++ b/integration/test/ParseLiveQueryTest.js @@ -4,6 +4,7 @@ const assert = require('assert'); const Parse = require('../../node'); const sleep = require('./sleep'); const { resolvingPromise } = require('../../lib/node/promiseUtils'); +const { EventEmitter } = require('events'); describe('Parse LiveQuery', () => { beforeEach(() => { @@ -367,4 +368,41 @@ describe('Parse LiveQuery', () => { client.state = 'closed'; await client.close(); }); + + it('can subscribe to query with EventEmitter private fields', async () => { + class CustomEmitter { + #privateEmitter; + + constructor() { + this.#privateEmitter = new EventEmitter(); + } + on(event, listener) { + this.#privateEmitter.on(event, listener); + } + emit(event, ...args) { + this.#privateEmitter.emit(event, ...args); + } + } + + const EV = Parse.CoreManager.getEventEmitter(); + + Parse.CoreManager.setEventEmitter(CustomEmitter); + const object = new TestObject(); + await object.save(); + const installationId = await Parse.CoreManager.getInstallationController().currentInstallationId(); + + const query = new Parse.Query(TestObject); + query.equalTo('objectId', object.id); + const subscription = await query.subscribe(); + const promise = resolvingPromise(); + subscription.on('update', (object, original, response) => { + assert.equal(object.get('foo'), 'bar'); + assert.equal(response.installationId, installationId); + promise.resolve(); + }); + object.set({ foo: 'bar' }); + await object.save(); + await promise; + Parse.CoreManager.setEventEmitter(EV); + }); }); diff --git a/integration/test/ParseLocalDatastoreTest.js b/integration/test/ParseLocalDatastoreTest.js index 91dd1b96c..4598539b7 100644 --- a/integration/test/ParseLocalDatastoreTest.js +++ b/integration/test/ParseLocalDatastoreTest.js @@ -1376,6 +1376,16 @@ function runTest(controller) { assert.equal(results.length, 9); }); + it(`${controller.name} can perform notEqualTo null queries`, async () => { + const nullObject = new Parse.Object({ className: 'BoxedNumber', number: null }); + await nullObject.save(); + const query = new Parse.Query('BoxedNumber'); + query.notEqualTo('number', null); + query.fromLocalDatastore(); + const results = await query.find(); + assert.equal(results.length, 10); + }); + it(`${controller.name} can perform containedIn queries`, async () => { const query = new Parse.Query('BoxedNumber'); query.containedIn('number', [3, 5, 7, 9, 11]); diff --git a/integration/test/ParseReactNativeTest.js b/integration/test/ParseReactNativeTest.js new file mode 100644 index 000000000..d7d30c68c --- /dev/null +++ b/integration/test/ParseReactNativeTest.js @@ -0,0 +1,104 @@ +'use strict'; + +const Parse = require('../../react-native'); +const { resolvingPromise } = require('../../lib/react-native/promiseUtils'); +const CryptoController = require('../../lib/react-native/CryptoController'); +const LocalDatastoreController = require('../../lib/react-native/LocalDatastoreController.default'); +const StorageController = require('../../lib/react-native/StorageController.default'); +const RESTController = require('../../lib/react-native/RESTController'); + +RESTController._setXHR(require('xmlhttprequest').XMLHttpRequest); + +describe('Parse React Native', () => { + beforeEach(() => { + // Set up missing controllers and configurations + Parse.CoreManager.setWebSocketController(require('ws')); + Parse.CoreManager.setEventEmitter(require('events').EventEmitter); + Parse.CoreManager.setLocalDatastoreController(LocalDatastoreController); + Parse.CoreManager.setStorageController(StorageController); + Parse.CoreManager.setRESTController(RESTController); + Parse.CoreManager.setCryptoController(CryptoController); + + Parse.initialize('integration'); + Parse.CoreManager.set('SERVER_URL', 'http://localhost:1337/parse'); + Parse.CoreManager.set('MASTER_KEY', 'notsosecret'); + Parse.enableLocalDatastore(); + }); + + afterEach(async () => { + await Parse.User.logOut(); + Parse.Storage._clear(); + }); + + it('can log in a user', async () => { + // Handle Storage Controller + await Parse.User.signUp('asdf', 'zxcv') + const user = await Parse.User.logIn('asdf', 'zxcv'); + expect(user.get('username')).toBe('asdf'); + expect(user.existed()).toBe(true); + }); + + it('can encrypt user', async () => { + // Handle Crypto Controller + Parse.User.enableUnsafeCurrentUser(); + Parse.enableEncryptedUser(); + Parse.secret = 'My Secret Key'; + const user = new Parse.User(); + user.setUsername('usernameENC'); + user.setPassword('passwordENC'); + await user.signUp(); + + const path = Parse.Storage.generatePath('currentUser'); + const encryptedUser = Parse.Storage.getItem(path); + + const crypto = Parse.CoreManager.getCryptoController(); + + const decryptedUser = crypto.decrypt(encryptedUser, Parse.CoreManager.get('ENCRYPTED_KEY')); + expect(JSON.parse(decryptedUser).objectId).toBe(user.id); + + const currentUser = Parse.User.current(); + expect(currentUser).toEqual(user); + + const currentUserAsync = await Parse.User.currentAsync(); + expect(currentUserAsync).toEqual(user); + await Parse.User.logOut(); + Parse.CoreManager.set('ENCRYPTED_USER', false); + Parse.CoreManager.set('ENCRYPTED_KEY', null); + }); + + it('can pin saved object LDS', async () => { + // Handle LocalDatastore Controller + function LDS_KEY(object) { + return Parse.LocalDatastore.getKeyForObject(object); + } + const object = new Parse.Object('TestObject'); + object.set('field', 'test'); + await object.save(); + await object.pin(); + const localDatastore = await Parse.LocalDatastore._getAllContents(); + const cachedObject = localDatastore[LDS_KEY(object)][0]; + expect(Object.keys(localDatastore).length).toBe(2); + expect(cachedObject.objectId).toBe(object.id); + expect(cachedObject.field).toBe('test'); + }); + + it('can subscribe to query', async () => { + // Handle WebSocket Controller + const object = new Parse.Object('TestObject'); + await object.save(); + const installationId = await Parse.CoreManager.getInstallationController().currentInstallationId(); + + const query = new Parse.Query('TestObject'); + query.equalTo('objectId', object.id); + const subscription = await query.subscribe(); + const promise = resolvingPromise(); + subscription.on('update', (object, _, response) => { + expect(object.get('foo')).toBe('bar'); + expect(response.installationId).toBe(installationId); + promise.resolve(); + }); + object.set({ foo: 'bar' }); + await object.save(); + await promise; + }); +}); diff --git a/integration/test/helper.js b/integration/test/helper.js index c32516c95..ee22aa42a 100644 --- a/integration/test/helper.js +++ b/integration/test/helper.js @@ -84,6 +84,7 @@ const defaultConfiguration = { revokeSessionOnPasswordReset: false, allowCustomObjectId: false, allowClientClassCreation: true, + encodeParseObjectInCloudFunction: true, emailAdapter: MockEmailAdapterWithOptions({ fromAddress: 'parse@example.com', apiKey: 'k', diff --git a/package-lock.json b/package-lock.json index 21c51a58d..25a369781 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "parse", - "version": "5.1.0-alpha.1", + "version": "5.1.0-alpha.6", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "parse", - "version": "5.1.0-alpha.1", + "version": "5.1.0-alpha.6", "license": "Apache-2.0", "dependencies": { "@babel/runtime-corejs3": "7.23.2", diff --git a/package.json b/package.json index 49ab843a6..e641a35ef 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "parse", - "version": "5.1.0-alpha.1", + "version": "5.1.0-alpha.6", "description": "Parse JavaScript SDK", "homepage": "https://parseplatform.org", "keywords": [ diff --git a/src/CoreManager.js b/src/CoreManager.js index 60467e2a6..de5efba95 100644 --- a/src/CoreManager.js +++ b/src/CoreManager.js @@ -6,7 +6,7 @@ import type { AttributeMap, ObjectCache, OpsMap, State } from './ObjectStateMuta import type ParseFile from './ParseFile'; import type { FileSource } from './ParseFile'; import type { Op } from './ParseOp'; -import type ParseObject from './ParseObject'; +import type ParseObject, {SaveOptions} from './ParseObject'; import type { QueryJSON } from './ParseQuery'; import type ParseUser from './ParseUser'; import type { AuthData } from './ParseUser'; @@ -73,6 +73,11 @@ type QueryController = { find: (className: string, params: QueryJSON, options: RequestOptions) => Promise, aggregate: (className: string, params: any, options: RequestOptions) => Promise, }; +type EventuallyQueue = { + save: (object: ParseObject, serverOptions: SaveOptions) => Promise, + destroy: (object: ParseObject, serverOptions: RequestOptions) => Promise, + poll: (ms: number) => void +}; type RESTController = { request: (method: string, path: string, data: mixed, options: RequestOptions) => Promise, ajax: (method: string, url: string, data: any, headers?: any, options: FullOptions) => Promise, @@ -363,6 +368,15 @@ const CoreManager = { return config['RESTController']; }, + setEventuallyQueue(controller: EventuallyQueue) { + requireMethods('EventuallyQueue', ['poll', 'save', 'destroy'], controller); + config['EventuallyQueue'] = controller; + }, + + getEventuallyQueue(): EventuallyQueue { + return config['EventuallyQueue']; + }, + setSchemaController(controller: SchemaController) { requireMethods( 'SchemaController', diff --git a/src/LiveQueryClient.js b/src/LiveQueryClient.js index f7d3e5318..9c65ef74d 100644 --- a/src/LiveQueryClient.js +++ b/src/LiveQueryClient.js @@ -1,5 +1,3 @@ -/* global WebSocket */ - import CoreManager from './CoreManager'; import ParseObject from './ParseObject'; import LiveQuerySubscription from './LiveQuerySubscription'; @@ -159,8 +157,8 @@ class LiveQueryClient { const EventEmitter = CoreManager.getEventEmitter(); this.emitter = new EventEmitter(); - this.on = this.emitter.on; - this.emit = this.emitter.emit; + this.on = (eventName, listener) => this.emitter.on(eventName, listener); + this.emit = (eventName, ...args) => this.emitter.emit(eventName, ...args); // adding listener so process does not crash // best practice is for developer to register their own listener this.on('error', () => {}); @@ -502,16 +500,4 @@ class LiveQueryClient { } } -if (process.env.PARSE_BUILD === 'node') { - CoreManager.setWebSocketController(require('ws')); -} else if (process.env.PARSE_BUILD === 'browser') { - CoreManager.setWebSocketController( - typeof WebSocket === 'function' || typeof WebSocket === 'object' ? WebSocket : null - ); -} else if (process.env.PARSE_BUILD === 'weapp') { - CoreManager.setWebSocketController(require('./Socket.weapp')); -} else if (process.env.PARSE_BUILD === 'react-native') { - CoreManager.setWebSocketController(WebSocket); -} - export default LiveQueryClient; diff --git a/src/LiveQuerySubscription.js b/src/LiveQuerySubscription.js index c70d234c1..8a1368eb8 100644 --- a/src/LiveQuerySubscription.js +++ b/src/LiveQuerySubscription.js @@ -99,8 +99,8 @@ class Subscription { const EventEmitter = CoreManager.getEventEmitter(); this.emitter = new EventEmitter(); - this.on = this.emitter.on; - this.emit = this.emitter.emit; + this.on = (eventName, listener) => this.emitter.on(eventName, listener); + this.emit = (eventName, ...args) => this.emitter.emit(eventName, ...args); // adding listener so process does not crash // best practice is for developer to register their own listener this.on('error', () => {}); diff --git a/src/LocalDatastoreController.default.js b/src/LocalDatastoreController.default.js new file mode 100644 index 000000000..7116f28fd --- /dev/null +++ b/src/LocalDatastoreController.default.js @@ -0,0 +1,67 @@ +/** + * @flow + */ +import { isLocalDatastoreKey } from './LocalDatastoreUtils'; +import Storage from './Storage'; + +const LocalDatastoreController = { + async fromPinWithName(name: string): Array { + const values = await Storage.getItemAsync(name); + if (!values) { + return []; + } + const objects = JSON.parse(values); + return objects; + }, + + pinWithName(name: string, value: any) { + const values = JSON.stringify(value); + return Storage.setItemAsync(name, values); + }, + + unPinWithName(name: string) { + return Storage.removeItemAsync(name); + }, + + async getAllContents(): Object { + const keys = await Storage.getAllKeysAsync(); + return keys.reduce(async (previousPromise, key) => { + const LDS = await previousPromise; + if (isLocalDatastoreKey(key)) { + const value = await Storage.getItemAsync(key); + try { + LDS[key] = JSON.parse(value); + } catch (error) { + console.error('Error getAllContents: ', error); + } + } + return LDS; + }, Promise.resolve({})); + }, + + // Used for testing + async getRawStorage(): Object { + const keys = await Storage.getAllKeysAsync(); + return keys.reduce(async (previousPromise, key) => { + const LDS = await previousPromise; + const value = await Storage.getItemAsync(key); + LDS[key] = value; + return LDS; + }, Promise.resolve({})); + }, + + async clear(): Promise { + const keys = await Storage.getAllKeysAsync(); + + const toRemove = []; + for (const key of keys) { + if (isLocalDatastoreKey(key)) { + toRemove.push(key); + } + } + const promises = toRemove.map(this.unPinWithName); + return Promise.all(promises); + }, +}; + +module.exports = LocalDatastoreController; diff --git a/src/LocalDatastoreController.js b/src/LocalDatastoreController.js index 7116f28fd..df2e666d9 100644 --- a/src/LocalDatastoreController.js +++ b/src/LocalDatastoreController.js @@ -1,67 +1,5 @@ -/** - * @flow - */ -import { isLocalDatastoreKey } from './LocalDatastoreUtils'; -import Storage from './Storage'; - -const LocalDatastoreController = { - async fromPinWithName(name: string): Array { - const values = await Storage.getItemAsync(name); - if (!values) { - return []; - } - const objects = JSON.parse(values); - return objects; - }, - - pinWithName(name: string, value: any) { - const values = JSON.stringify(value); - return Storage.setItemAsync(name, values); - }, - - unPinWithName(name: string) { - return Storage.removeItemAsync(name); - }, - - async getAllContents(): Object { - const keys = await Storage.getAllKeysAsync(); - return keys.reduce(async (previousPromise, key) => { - const LDS = await previousPromise; - if (isLocalDatastoreKey(key)) { - const value = await Storage.getItemAsync(key); - try { - LDS[key] = JSON.parse(value); - } catch (error) { - console.error('Error getAllContents: ', error); - } - } - return LDS; - }, Promise.resolve({})); - }, - - // Used for testing - async getRawStorage(): Object { - const keys = await Storage.getAllKeysAsync(); - return keys.reduce(async (previousPromise, key) => { - const LDS = await previousPromise; - const value = await Storage.getItemAsync(key); - LDS[key] = value; - return LDS; - }, Promise.resolve({})); - }, - - async clear(): Promise { - const keys = await Storage.getAllKeysAsync(); - - const toRemove = []; - for (const key of keys) { - if (isLocalDatastoreKey(key)) { - toRemove.push(key); - } - } - const promises = toRemove.map(this.unPinWithName); - return Promise.all(promises); - }, -}; - -module.exports = LocalDatastoreController; +if (process.env.PARSE_BUILD === 'react-native') { + module.exports = require('./LocalDatastoreController.react-native'); +} else { + module.exports = require('./LocalDatastoreController.default'); +} diff --git a/src/OfflineQuery.js b/src/OfflineQuery.js index efbc200c9..39220c2f8 100644 --- a/src/OfflineQuery.js +++ b/src/OfflineQuery.js @@ -315,11 +315,11 @@ function matchesKeyConstraints(className, object, objects, key, constraints) { for (const condition in constraints) { compareTo = constraints[condition]; - if (compareTo.__type) { + if (compareTo?.__type) { compareTo = decode(compareTo); } // is it a $relativeTime? convert to date - if (compareTo['$relativeTime']) { + if (compareTo?.['$relativeTime']) { const parserResult = relativeTimeToDate(compareTo['$relativeTime']); if (parserResult.status !== 'success') { throw new ParseError( diff --git a/src/Parse.ts b/src/Parse.ts index dbf3a0983..4aa88dbd1 100644 --- a/src/Parse.ts +++ b/src/Parse.ts @@ -30,8 +30,11 @@ import Schema from './ParseSchema' import Session from './ParseSession' import Storage from './Storage' import User from './ParseUser' -import LiveQuery from './ParseLiveQuery' +import ParseLiveQuery from './ParseLiveQuery' import LiveQueryClient from './LiveQueryClient' +import LocalDatastoreController from './LocalDatastoreController'; +import StorageController from './StorageController'; +import WebSocketController from './WebSocketController'; /** * Contains all Parse API classes and functions. @@ -78,7 +81,7 @@ interface ParseType { Session: typeof Session, Storage: typeof Storage, User: typeof User, - LiveQuery?: typeof LiveQuery, + LiveQuery: ParseLiveQuery, LiveQueryClient: typeof LiveQueryClient, initialize(applicationId: string, javaScriptKey: string): void, @@ -120,7 +123,6 @@ const Parse: ParseType = { CoreManager: CoreManager, Config: Config, Error: ParseError, - EventuallyQueue: EventuallyQueue, FacebookUtils: FacebookUtils, File: File, GeoPoint: GeoPoint, @@ -146,11 +148,22 @@ const Parse: ParseType = { Storage: Storage, User: User, LiveQueryClient: LiveQueryClient, - LiveQuery: undefined, IndexedDB: undefined, Hooks: undefined, Parse: undefined, + /** + * @member {EventuallyQueue} Parse.EventuallyQueue + * @static + */ + set EventuallyQueue(queue: EventuallyQueue) { + CoreManager.setEventuallyQueue(queue); + }, + + get EventuallyQueue() { + return CoreManager.getEventuallyQueue(); + }, + /** * Call this method first to set up your authentication tokens for Parse. * @@ -181,9 +194,13 @@ const Parse: ParseType = { CoreManager.set('MASTER_KEY', masterKey); CoreManager.set('USE_MASTER_KEY', false); CoreManager.setIfNeeded('EventEmitter', EventEmitter); + CoreManager.setIfNeeded('LiveQuery', new ParseLiveQuery()); + CoreManager.setIfNeeded('CryptoController', CryptoController); + CoreManager.setIfNeeded('LocalDatastoreController', LocalDatastoreController); + CoreManager.setIfNeeded('StorageController', StorageController); + CoreManager.setIfNeeded('WebSocketController', WebSocketController); - Parse.LiveQuery = new LiveQuery(); - CoreManager.setIfNeeded('LiveQuery', Parse.LiveQuery); + CoreManager.setIfNeeded('EventuallyQueue', EventuallyQueue); if (process.env.PARSE_BUILD === 'browser') { Parse.IndexedDB = CoreManager.setIfNeeded('IndexedDBStorageController', IndexedDBStorageController); @@ -289,6 +306,17 @@ const Parse: ParseType = { return CoreManager.get('SERVER_AUTH_TYPE'); }, + /** + * @member {ParseLiveQuery} Parse.LiveQuery + * @static + */ + set LiveQuery(liveQuery: ParseLiveQuery) { + CoreManager.setLiveQuery(liveQuery); + }, + get LiveQuery() { + return CoreManager.getLiveQuery(); + }, + /** * @member {string} Parse.liveQueryServerURL * @static @@ -380,7 +408,7 @@ const Parse: ParseType = { if (!this.LocalDatastore.isEnabled) { this.LocalDatastore.isEnabled = true; if (polling) { - EventuallyQueue.poll(ms); + CoreManager.getEventuallyQueue().poll(ms); } } }, @@ -433,7 +461,6 @@ const Parse: ParseType = { }, }; -CoreManager.setCryptoController(CryptoController); CoreManager.setInstallationController(InstallationController); CoreManager.setRESTController(RESTController); diff --git a/src/ParseLiveQuery.js b/src/ParseLiveQuery.js index eea9e59e2..c293133c7 100644 --- a/src/ParseLiveQuery.js +++ b/src/ParseLiveQuery.js @@ -39,9 +39,8 @@ class LiveQuery { constructor() { const EventEmitter = CoreManager.getEventEmitter(); this.emitter = new EventEmitter(); - this.on = this.emitter.on; - this.emit = this.emitter.emit; - + this.on = (eventName, listener) => this.emitter.on(eventName, listener); + this.emit = (eventName, ...args) => this.emitter.emit(eventName, ...args); // adding listener so process does not crash // best practice is for developer to register their own listener this.on('error', () => {}); diff --git a/src/ParseObject.js b/src/ParseObject.js index eafa7f45d..826a2458e 100644 --- a/src/ParseObject.js +++ b/src/ParseObject.js @@ -7,7 +7,6 @@ import canBeSerialized from './canBeSerialized'; import decode from './decode'; import encode from './encode'; import escape from './escape'; -import EventuallyQueue from './EventuallyQueue'; import ParseACL from './ParseACL'; import parseDate from './parseDate'; import ParseError from './ParseError'; @@ -1220,8 +1219,8 @@ class ParseObject { await this.save(null, options); } catch (e) { if (e.code === ParseError.CONNECTION_FAILED) { - await EventuallyQueue.save(this, options); - EventuallyQueue.poll(); + await CoreManager.getEventuallyQueue().save(this, options); + CoreManager.getEventuallyQueue().poll(); } } return this; @@ -1366,8 +1365,8 @@ class ParseObject { await this.destroy(options); } catch (e) { if (e.code === ParseError.CONNECTION_FAILED) { - await EventuallyQueue.destroy(this, options); - EventuallyQueue.poll(); + await CoreManager.getEventuallyQueue().destroy(this, options); + CoreManager.getEventuallyQueue().poll(); } } return this; diff --git a/src/ParseUser.js b/src/ParseUser.js index 9347d1239..26dec8820 100644 --- a/src/ParseUser.js +++ b/src/ParseUser.js @@ -546,10 +546,11 @@ class ParseUser extends ParseObject { /** * Verify whether a given password is the password of the current user. * - * @param {string} password A password to be verified - * @param {object} options - * @returns {Promise} A promise that is fulfilled with a user - * when the password is correct. + * @param {string} password The password to be verified. + * @param {object} options The options. + * @param {boolean} [options.ignoreEmailVerification=false] Set to `true` to bypass email verification and verify + * the password regardless of whether the email has been verified. This requires the master key. + * @returns {Promise} A promise that is fulfilled with a user when the password is correct. */ verifyPassword(password: string, options?: RequestOptions): Promise { const username = this.getUsername() || ''; @@ -865,13 +866,14 @@ class ParseUser extends ParseObject { /** * Verify whether a given password is the password of the current user. - * - * @param {string} username A username to be used for identificaiton - * @param {string} password A password to be verified - * @param {object} options * @static - * @returns {Promise} A promise that is fulfilled with a user - * when the password is correct. + * + * @param {string} username The username of the user whose password should be verified. + * @param {string} password The password to be verified. + * @param {object} options The options. + * @param {boolean} [options.ignoreEmailVerification=false] Set to `true` to bypass email verification and verify + * the password regardless of whether the email has been verified. This requires the master key. + * @returns {Promise} A promise that is fulfilled with a user when the password is correct. */ static verifyPassword(username: string, password: string, options?: RequestOptions) { if (typeof username !== 'string') { @@ -1262,7 +1264,12 @@ const DefaultController = { verifyPassword(username: string, password: string, options: RequestOptions) { const RESTController = CoreManager.getRESTController(); - return RESTController.request('GET', 'verifyPassword', { username, password }, options); + const data = { + username, + password, + ...(options.ignoreEmailVerification !== undefined && { ignoreEmailVerification: options.ignoreEmailVerification }), + }; + return RESTController.request('GET', 'verifyPassword', data, options); }, requestEmailVerification(email: string, options: RequestOptions) { diff --git a/src/RESTController.js b/src/RESTController.js index 73c962cff..59090e76e 100644 --- a/src/RESTController.js +++ b/src/RESTController.js @@ -111,11 +111,14 @@ const RESTController = { let response; try { response = JSON.parse(xhr.responseText); + const availableHeaders = typeof xhr.getAllResponseHeaders === 'function' ? xhr.getAllResponseHeaders() : ""; headers = {}; - if (typeof xhr.getResponseHeader === 'function' && xhr.getResponseHeader('access-control-expose-headers')) { + if (typeof xhr.getResponseHeader === 'function' && availableHeaders?.indexOf('access-control-expose-headers') >= 0) { const responseHeaders = xhr.getResponseHeader('access-control-expose-headers').split(', '); responseHeaders.forEach(header => { - headers[header] = xhr.getResponseHeader(header.toLowerCase()); + if (availableHeaders.indexOf(header.toLowerCase()) >= 0) { + headers[header] = xhr.getResponseHeader(header.toLowerCase()); + } }); } } catch (e) { @@ -250,10 +253,6 @@ const RESTController = { } } - if (options.ignoreEmailVerification !== undefined) { - payload.ignoreEmailVerification = options.ignoreEmailVerification; - } - if (CoreManager.get('FORCE_REVOCABLE_SESSION')) { payload._RevocableSession = '1'; } diff --git a/src/Storage.js b/src/Storage.js index 67392c249..8f7599ea5 100644 --- a/src/Storage.js +++ b/src/Storage.js @@ -97,13 +97,3 @@ const Storage = { module.exports = Storage; export default Storage; - -if (process.env.PARSE_BUILD === 'react-native') { - CoreManager.setStorageController(require('./StorageController.react-native')); -} else if (process.env.PARSE_BUILD === 'browser') { - CoreManager.setStorageController(require('./StorageController.browser')); -} else if (process.env.PARSE_BUILD === 'weapp') { - CoreManager.setStorageController(require('./StorageController.weapp')); -} else { - CoreManager.setStorageController(require('./StorageController.default')); -} diff --git a/src/StorageController.js b/src/StorageController.js new file mode 100644 index 000000000..33508d75b --- /dev/null +++ b/src/StorageController.js @@ -0,0 +1,9 @@ +if (process.env.PARSE_BUILD === 'react-native') { + module.exports = require('./StorageController.react-native'); +} else if (process.env.PARSE_BUILD === 'browser') { + module.exports = require('./StorageController.browser'); +} else if (process.env.PARSE_BUILD === 'weapp') { + module.exports = require('./StorageController.weapp'); +} else { + module.exports = require('./StorageController.default'); +} diff --git a/src/WebSocketController.js b/src/WebSocketController.js new file mode 100644 index 000000000..c3e1d0ca4 --- /dev/null +++ b/src/WebSocketController.js @@ -0,0 +1,18 @@ +/* global WebSocket */ + +let WebSocketController; + +try { + if (process.env.PARSE_BUILD === 'browser') { + WebSocketController = (typeof WebSocket === 'function' || typeof WebSocket === 'object' ? WebSocket : null); + } else if (process.env.PARSE_BUILD === 'node') { + WebSocketController = require('ws'); + } else if (process.env.PARSE_BUILD === 'weapp') { + WebSocketController = require('./Socket.weapp'); + } else if (process.env.PARSE_BUILD === 'react-native') { + WebSocketController = WebSocket; + } +} catch (_) { + // WebSocket unavailable +} +module.exports = WebSocketController; diff --git a/src/__tests__/InstallationController-test.js b/src/__tests__/InstallationController-test.js index 2085131c4..dcfad0a45 100644 --- a/src/__tests__/InstallationController-test.js +++ b/src/__tests__/InstallationController-test.js @@ -11,6 +11,8 @@ const CoreManager = require('../CoreManager'); const InstallationController = require('../InstallationController'); const Storage = require('../Storage'); +CoreManager.setStorageController(require('../StorageController.default')); + describe('InstallationController', () => { beforeEach(() => { CoreManager.set('APPLICATION_ID', 'A'); diff --git a/src/__tests__/LiveQueryClient-test.js b/src/__tests__/LiveQueryClient-test.js index 9cd4e4f5f..4588cc1cc 100644 --- a/src/__tests__/LiveQueryClient-test.js +++ b/src/__tests__/LiveQueryClient-test.js @@ -25,6 +25,7 @@ jest.dontMock('../ParseACL'); jest.dontMock('../ParseQuery'); jest.dontMock('../LiveQuerySubscription'); jest.dontMock('../LocalDatastore'); +jest.dontMock('../WebSocketController'); jest.useFakeTimers(); @@ -39,10 +40,12 @@ const EventEmitter = require('../EventEmitter'); const LiveQueryClient = require('../LiveQueryClient').default; const ParseObject = require('../ParseObject').default; const ParseQuery = require('../ParseQuery').default; +const WebSocketController = require('../WebSocketController'); const { resolvingPromise } = require('../promiseUtils'); const events = require('events'); CoreManager.setLocalDatastore(mockLocalDatastore); +CoreManager.setWebSocketController(WebSocketController); describe('LiveQueryClient', () => { beforeEach(() => { diff --git a/src/__tests__/Parse-test.js b/src/__tests__/Parse-test.js index 560585230..d74b76373 100644 --- a/src/__tests__/Parse-test.js +++ b/src/__tests__/Parse-test.js @@ -3,15 +3,20 @@ jest.dontMock('../CryptoController'); jest.dontMock('../decode'); jest.dontMock('../encode'); jest.dontMock('../Parse'); +jest.dontMock('../ParseObject'); +jest.dontMock('../ParseLiveQuery'); jest.dontMock('../LocalDatastore'); jest.dontMock('crypto-js/aes'); jest.setMock('../EventuallyQueue', { poll: jest.fn() }); global.indexedDB = require('./test_helpers/mockIndexedDB'); const CoreManager = require('../CoreManager'); +const ParseLiveQuery = require('../ParseLiveQuery').default; const EventuallyQueue = require('../EventuallyQueue'); const Parse = require('../Parse'); +CoreManager.setEventEmitter(require('events').EventEmitter); + describe('Parse module', () => { it('can be initialized with keys', () => { Parse.initialize('A', 'B'); @@ -89,6 +94,14 @@ describe('Parse module', () => { expect(CoreManager.getLocalDatastoreController()).toBe(controller); }); + it('cannot set EventuallyQueue controller with missing functions', () => { + const controller = { + }; + expect(() => Parse.EventuallyQueue = controller).toThrow( + 'EventuallyQueue must implement poll()' + ); + }); + it('can set AsyncStorage', () => { const controller = { getItem: function () {}, @@ -173,6 +186,14 @@ describe('Parse module', () => { CoreManager.set('REQUEST_BATCH_SIZE', 20); }); + it('can set and get live query', () => { + const temp = Parse.LiveQuery; + const LiveQuery = new ParseLiveQuery(); + Parse.LiveQuery = LiveQuery + expect(Parse.LiveQuery).toEqual(LiveQuery); + Parse.LiveQuery = temp; + }); + it('can set allowCustomObjectId', () => { expect(Parse.allowCustomObjectId).toBe(false); Parse.allowCustomObjectId = true; @@ -245,4 +266,16 @@ describe('Parse module', () => { process.env.PARSE_BUILD = 'node'; }); }); + + it('can set EventuallyQueue', () => { + const controller = { + poll: function () {}, + save: function () {}, + destroy: function () {}, + }; + + Parse.EventuallyQueue = controller; + expect(CoreManager.getEventuallyQueue()).toBe(controller); + expect(Parse.EventuallyQueue).toBe(controller); + }); }); diff --git a/src/__tests__/ParseConfig-test.js b/src/__tests__/ParseConfig-test.js index a3aa5546b..e662bd490 100644 --- a/src/__tests__/ParseConfig-test.js +++ b/src/__tests__/ParseConfig-test.js @@ -16,9 +16,11 @@ const CoreManager = require('../CoreManager'); const ParseConfig = require('../ParseConfig').default; const ParseGeoPoint = require('../ParseGeoPoint').default; const Storage = require('../Storage'); +const StorageController = require('../StorageController.default'); CoreManager.set('APPLICATION_ID', 'A'); CoreManager.set('JAVASCRIPT_KEY', 'B'); +CoreManager.set('StorageController', StorageController); describe('ParseConfig', () => { beforeEach(() => { diff --git a/src/__tests__/ParseObject-test.js b/src/__tests__/ParseObject-test.js index b22cee63b..d01603bb1 100644 --- a/src/__tests__/ParseObject-test.js +++ b/src/__tests__/ParseObject-test.js @@ -160,6 +160,7 @@ const flushPromises = require('./test_helpers/flushPromises'); CoreManager.setLocalDatastore(mockLocalDatastore); CoreManager.setRESTController(RESTController); +CoreManager.setEventuallyQueue(EventuallyQueue); CoreManager.setInstallationController({ currentInstallationId() { return Promise.resolve('iid'); diff --git a/src/__tests__/ParseUser-test.js b/src/__tests__/ParseUser-test.js index 631842b8e..16f25e682 100644 --- a/src/__tests__/ParseUser-test.js +++ b/src/__tests__/ParseUser-test.js @@ -34,6 +34,7 @@ const flushPromises = require('./test_helpers/flushPromises'); const mockAsyncStorage = require('./test_helpers/mockAsyncStorage'); const CoreManager = require('../CoreManager'); const CryptoController = require('../CryptoController'); +const StorageController = require('../StorageController.default'); const LocalDatastore = require('../LocalDatastore'); const ParseObject = require('../ParseObject').default; const ParseUser = require('../ParseUser').default; @@ -44,6 +45,7 @@ const AnonymousUtils = require('../AnonymousUtils').default; CoreManager.set('APPLICATION_ID', 'A'); CoreManager.set('JAVASCRIPT_KEY', 'B'); CoreManager.setCryptoController(CryptoController); +CoreManager.setStorageController(StorageController); describe('ParseUser', () => { beforeEach(() => { diff --git a/src/__tests__/RESTController-test.js b/src/__tests__/RESTController-test.js index 02d72e94d..640d83bc5 100644 --- a/src/__tests__/RESTController-test.js +++ b/src/__tests__/RESTController-test.js @@ -221,6 +221,9 @@ describe('RESTController', () => { getResponseHeader: function (header) { return headers[header]; }, + getAllResponseHeaders: function() { + return Object.keys(headers).map(key => `${key}: ${headers[key]}`).join('\n'); + }, send: function () { this.status = 200; this.responseText = '{}'; @@ -241,6 +244,9 @@ describe('RESTController', () => { getResponseHeader: function (header) { return headers[header]; }, + getAllResponseHeaders: function() { + return Object.keys(headers).map(key => `${key}: ${headers[key]}`).join('\n'); + }, send: function () { this.status = 200; this.responseText = '{}'; @@ -253,6 +259,63 @@ describe('RESTController', () => { expect(response._headers['X-Parse-Push-Status-Id']).toBe('5678'); }); + it('does not call getRequestHeader with no headers or no getAllResponseHeaders', async () => { + const XHR = function () {}; + XHR.prototype = { + open: function () {}, + setRequestHeader: function () {}, + getResponseHeader: jest.fn(), + send: function () { + this.status = 200; + this.responseText = '{"result":"hello"}'; + this.readyState = 4; + this.onreadystatechange(); + }, + }; + RESTController._setXHR(XHR); + await RESTController.request('GET', 'classes/MyObject', {}, {}); + expect(XHR.prototype.getResponseHeader.mock.calls.length).toBe(0); + + XHR.prototype.getAllResponseHeaders = jest.fn(); + await RESTController.request('GET', 'classes/MyObject', {}, {}); + expect(XHR.prototype.getAllResponseHeaders.mock.calls.length).toBe(1); + expect(XHR.prototype.getResponseHeader.mock.calls.length).toBe(0); + }); + + it('does not invoke Chrome browser console error on getResponseHeader', async () => { + const headers = { + 'access-control-expose-headers': 'a, b, c', + 'a' : 'value', + 'b' : 'value', + 'c' : 'value', + } + const XHR = function () {}; + XHR.prototype = { + open: function () {}, + setRequestHeader: function () {}, + getResponseHeader: jest.fn(key => { + if (Object.keys(headers).includes(key)) { + return headers[key]; + } + throw new Error("Chrome creates a console error here."); + }), + getAllResponseHeaders: jest.fn(() => { + return Object.keys(headers).map(key => `${key}: ${headers[key]}`).join('\r\n'); + }), + send: function () { + this.status = 200; + this.responseText = '{"result":"hello"}'; + this.readyState = 4; + this.onreadystatechange(); + }, + }; + RESTController._setXHR(XHR); + await RESTController.request('GET', 'classes/MyObject', {}, {}); + expect(XHR.prototype.getAllResponseHeaders.mock.calls.length).toBe(1); + expect(XHR.prototype.getResponseHeader.mock.calls.length).toBe(4); + }); + + it('handles invalid header', async () => { const XHR = function () {}; XHR.prototype = { diff --git a/src/__tests__/browser-test.js b/src/__tests__/browser-test.js index e89714204..80d84ad98 100644 --- a/src/__tests__/browser-test.js +++ b/src/__tests__/browser-test.js @@ -10,6 +10,7 @@ jest.dontMock('../Storage'); jest.dontMock('crypto-js/aes'); jest.setMock('../EventuallyQueue', { poll: jest.fn() }); +const CoreManager = require('../CoreManager'); const ParseError = require('../ParseError').default; const EventuallyQueue = require('../EventuallyQueue'); @@ -61,7 +62,9 @@ describe('Browser', () => { }); it('load StorageController', () => { - const StorageController = require('../StorageController.browser'); + const StorageController = require('../StorageController'); + CoreManager.setStorageController(StorageController); + jest.spyOn(StorageController, 'setItem'); const storage = require('../Storage'); storage.setItem('key', 'value'); diff --git a/src/__tests__/react-native-test.js b/src/__tests__/react-native-test.js index 02c0a1e76..731684760 100644 --- a/src/__tests__/react-native-test.js +++ b/src/__tests__/react-native-test.js @@ -8,7 +8,8 @@ jest.dontMock('../LiveQueryClient'); jest.dontMock('../LocalDatastore'); jest.dontMock('../ParseObject'); jest.dontMock('../Storage'); - +jest.dontMock('../LocalDatastoreController'); +jest.dontMock('../WebSocketController'); jest.mock( 'react-native/Libraries/vendor/emitter/EventEmitter', () => { @@ -54,14 +55,16 @@ describe('React Native', () => { }); it('load LocalDatastoreController', () => { - const LocalDatastoreController = require('../LocalDatastoreController.react-native'); + const LocalDatastoreController = require('../LocalDatastoreController'); require('../LocalDatastore'); const LDC = CoreManager.getLocalDatastoreController(); expect(LocalDatastoreController).toEqual(LDC); }); it('load StorageController', () => { - const StorageController = require('../StorageController.react-native'); + const StorageController = require('../StorageController'); + CoreManager.setStorageController(StorageController); + jest.spyOn(StorageController, 'setItemAsync'); const storage = require('../Storage'); storage.setItemAsync('key', 'value'); @@ -69,6 +72,9 @@ describe('React Native', () => { }); it('load WebSocketController', () => { + const WebSocketController = require('../WebSocketController'); + CoreManager.setWebSocketController(WebSocketController); + jest.mock('../EventEmitter', () => { return require('events').EventEmitter; }); diff --git a/src/__tests__/weapp-test.js b/src/__tests__/weapp-test.js index ca815502c..c693d087e 100644 --- a/src/__tests__/weapp-test.js +++ b/src/__tests__/weapp-test.js @@ -10,6 +10,7 @@ jest.dontMock('../ParseObject'); jest.dontMock('../RESTController'); jest.dontMock('../Socket.weapp'); jest.dontMock('../Storage'); +jest.dontMock('../StorageController.weapp'); jest.dontMock('../uuid'); jest.dontMock('crypto-js/aes'); jest.dontMock('./test_helpers/mockWeChat'); @@ -33,7 +34,8 @@ describe('WeChat', () => { }); it('load StorageController', () => { - const StorageController = require('../StorageController.weapp'); + const StorageController = require('../StorageController'); + CoreManager.setStorageController(StorageController); jest.spyOn(StorageController, 'setItem'); const storage = require('../Storage'); storage.setItem('key', 'value'); @@ -54,7 +56,9 @@ describe('WeChat', () => { }); it('load WebSocketController', () => { - const socket = require('../Socket.weapp'); + const socket = require('../WebSocketController'); + CoreManager.setWebSocketController(socket); + require('../LiveQueryClient'); const websocket = CoreManager.getWebSocketController(); expect(websocket).toEqual(socket);