Add LiveQuery
This commit is contained in:
@@ -22,7 +22,7 @@ export class Config {
|
||||
this.facebookAppIds = cacheInfo.facebookAppIds;
|
||||
this.allowClientClassCreation = cacheInfo.allowClientClassCreation;
|
||||
this.database = DatabaseAdapter.getDatabaseConnection(applicationId, cacheInfo.collectionPrefix);
|
||||
|
||||
|
||||
this.serverURL = cacheInfo.serverURL;
|
||||
this.publicServerURL = cacheInfo.publicServerURL;
|
||||
this.verifyUserEmails = cacheInfo.verifyUserEmails;
|
||||
@@ -36,14 +36,15 @@ export class Config {
|
||||
this.authDataManager = cacheInfo.authDataManager;
|
||||
this.customPages = cacheInfo.customPages || {};
|
||||
this.mount = mount;
|
||||
this.liveQueryController = cacheInfo.liveQueryController;
|
||||
}
|
||||
|
||||
|
||||
static validate(options) {
|
||||
this.validateEmailConfiguration({verifyUserEmails: options.verifyUserEmails,
|
||||
appName: options.appName,
|
||||
this.validateEmailConfiguration({verifyUserEmails: options.verifyUserEmails,
|
||||
appName: options.appName,
|
||||
publicServerURL: options.publicServerURL})
|
||||
}
|
||||
|
||||
|
||||
static validateEmailConfiguration({verifyUserEmails, appName, publicServerURL}) {
|
||||
if (verifyUserEmails) {
|
||||
if (typeof appName !== 'string') {
|
||||
@@ -58,23 +59,23 @@ export class Config {
|
||||
get invalidLinkURL() {
|
||||
return this.customPages.invalidLink || `${this.publicServerURL}/apps/invalid_link.html`;
|
||||
}
|
||||
|
||||
|
||||
get verifyEmailSuccessURL() {
|
||||
return this.customPages.verifyEmailSuccess || `${this.publicServerURL}/apps/verify_email_success.html`;
|
||||
}
|
||||
|
||||
|
||||
get choosePasswordURL() {
|
||||
return this.customPages.choosePassword || `${this.publicServerURL}/apps/choose_password`;
|
||||
}
|
||||
|
||||
|
||||
get requestResetPasswordURL() {
|
||||
return `${this.publicServerURL}/apps/${this.applicationId}/request_password_reset`;
|
||||
}
|
||||
|
||||
|
||||
get passwordResetSuccessURL() {
|
||||
return this.customPages.passwordResetSuccess || `${this.publicServerURL}/apps/password_reset_success.html`;
|
||||
}
|
||||
|
||||
|
||||
get verifyEmailURL() {
|
||||
return `${this.publicServerURL}/apps/${this.applicationId}/verify_email`;
|
||||
}
|
||||
|
||||
51
src/Controllers/LiveQueryController.js
Normal file
51
src/Controllers/LiveQueryController.js
Normal file
@@ -0,0 +1,51 @@
|
||||
import { ParseCloudCodePublisher } from '../LiveQuery/ParseCloudCodePublisher';
|
||||
|
||||
export class LiveQueryController {
|
||||
classNames: any;
|
||||
liveQueryPublisher: any;
|
||||
|
||||
constructor(config: any) {
|
||||
let classNames;
|
||||
// If config is empty, we just assume no classs needs to be registered as LiveQuery
|
||||
if (!config || !config.classNames) {
|
||||
this.classNames = new Set();
|
||||
} else if (config.classNames instanceof Array) {
|
||||
this.classNames = new Set(config.classNames);
|
||||
} else {
|
||||
throw 'liveQuery.classes should be an array of string'
|
||||
}
|
||||
this.liveQueryPublisher = new ParseCloudCodePublisher(config);
|
||||
}
|
||||
|
||||
onAfterSave(className: string, currentObject: any, originalObject: any) {
|
||||
if (!this.hasLiveQuery(className)) {
|
||||
return;
|
||||
}
|
||||
let req = this._makePublisherRequest(currentObject, originalObject);
|
||||
this.liveQueryPublisher.onCloudCodeAfterSave(req);
|
||||
}
|
||||
|
||||
onAfterDelete(className: string, currentObject: any, originalObject: any) {
|
||||
if (!this.hasLiveQuery(className)) {
|
||||
return;
|
||||
}
|
||||
let req = this._makePublisherRequest(currentObject, originalObject);
|
||||
this.liveQueryPublisher.onCloudCodeAfterDelete(req);
|
||||
}
|
||||
|
||||
hasLiveQuery(className: string): boolean {
|
||||
return this.classNames.has(className);
|
||||
}
|
||||
|
||||
_makePublisherRequest(currentObject: any, originalObject: any): any {
|
||||
let req = {
|
||||
object: currentObject
|
||||
};
|
||||
if (currentObject) {
|
||||
req.original = originalObject;
|
||||
}
|
||||
return req;
|
||||
}
|
||||
}
|
||||
|
||||
export default LiveQueryController;
|
||||
104
src/LiveQuery/Client.js
Normal file
104
src/LiveQuery/Client.js
Normal file
@@ -0,0 +1,104 @@
|
||||
import PLog from './PLog';
|
||||
import Parse from 'parse/node';
|
||||
|
||||
import type { FlattenedObjectData } from './Subscription';
|
||||
export type Message = { [attr: string]: any };
|
||||
|
||||
let dafaultFields = ['className', 'objectId', 'updatedAt', 'createdAt', 'ACL'];
|
||||
|
||||
class Client {
|
||||
id: number;
|
||||
parseWebSocket: any;
|
||||
userId: string;
|
||||
roles: Array<string>;
|
||||
subscriptionInfos: Object;
|
||||
pushConnect: Function;
|
||||
pushSubscribe: Function;
|
||||
pushUnsubscribe: Function;
|
||||
pushCreate: Function;
|
||||
pushEnter: Function;
|
||||
pushUpdate: Function;
|
||||
pushDelete: Function;
|
||||
pushLeave: Function;
|
||||
|
||||
constructor(id: number, parseWebSocket: any) {
|
||||
this.id = id;
|
||||
this.parseWebSocket = parseWebSocket;
|
||||
this.roles = [];
|
||||
this.subscriptionInfos = new Map();
|
||||
this.pushConnect = this._pushEvent('connected');
|
||||
this.pushSubscribe = this._pushEvent('subscribed');
|
||||
this.pushUnsubscribe = this._pushEvent('unsubscribed');
|
||||
this.pushCreate = this._pushEvent('create');
|
||||
this.pushEnter = this._pushEvent('enter');
|
||||
this.pushUpdate = this._pushEvent('update');
|
||||
this.pushDelete = this._pushEvent('delete');
|
||||
this.pushLeave = this._pushEvent('leave');
|
||||
}
|
||||
|
||||
static pushResponse(parseWebSocket: any, message: Message): void {
|
||||
PLog.verbose('Push Response : %j', message);
|
||||
parseWebSocket.send(message);
|
||||
}
|
||||
|
||||
static pushError(parseWebSocket: any, code: number, error: string, reconnect: boolean = true): void {
|
||||
Client.pushResponse(parseWebSocket, JSON.stringify({
|
||||
'op': 'error',
|
||||
'error': error,
|
||||
'code': code,
|
||||
'reconnect': reconnect
|
||||
}));
|
||||
}
|
||||
|
||||
addSubscriptionInfo(requestId: number, subscriptionInfo: any): void {
|
||||
this.subscriptionInfos.set(requestId, subscriptionInfo);
|
||||
}
|
||||
|
||||
getSubscriptionInfo(requestId: numner): any {
|
||||
return this.subscriptionInfos.get(requestId);
|
||||
}
|
||||
|
||||
deleteSubscriptionInfo(requestId: number): void {
|
||||
return this.subscriptionInfos.delete(requestId);
|
||||
}
|
||||
|
||||
_pushEvent(type: string): Function {
|
||||
return function(subscriptionId: number, parseObjectJSON: any): void {
|
||||
let response: Message = {
|
||||
'op' : type,
|
||||
'clientId' : this.id
|
||||
};
|
||||
if (typeof subscriptionId !== 'undefined') {
|
||||
response['requestId'] = subscriptionId;
|
||||
}
|
||||
if (typeof parseObjectJSON !== 'undefined') {
|
||||
let fields;
|
||||
if (this.subscriptionInfos.has(subscriptionId)) {
|
||||
fields = this.subscriptionInfos.get(subscriptionId).fields;
|
||||
}
|
||||
response['object'] = this._toJSONWithFields(parseObjectJSON, fields);
|
||||
}
|
||||
Client.pushResponse(this.parseWebSocket, JSON.stringify(response));
|
||||
}
|
||||
}
|
||||
|
||||
_toJSONWithFields(parseObjectJSON: any, fields: any): FlattenedObjectData {
|
||||
if (!fields) {
|
||||
return parseObjectJSON;
|
||||
}
|
||||
let limitedParseObject = {};
|
||||
for (let field of dafaultFields) {
|
||||
limitedParseObject[field] = parseObjectJSON[field];
|
||||
}
|
||||
for (let field of fields) {
|
||||
if (field in parseObjectJSON) {
|
||||
limitedParseObject[field] = parseObjectJSON[field];
|
||||
}
|
||||
}
|
||||
return limitedParseObject;
|
||||
}
|
||||
}
|
||||
|
||||
export {
|
||||
Client
|
||||
}
|
||||
59
src/LiveQuery/EventEmitterPubSub.js
Normal file
59
src/LiveQuery/EventEmitterPubSub.js
Normal file
@@ -0,0 +1,59 @@
|
||||
import events from 'events';
|
||||
|
||||
let emitter = new events.EventEmitter();
|
||||
|
||||
class Publisher {
|
||||
emitter: any;
|
||||
|
||||
constructor(emitter: any) {
|
||||
this.emitter = emitter;
|
||||
}
|
||||
|
||||
publish(channel: string, message: string): void {
|
||||
this.emitter.emit(channel, message);
|
||||
}
|
||||
}
|
||||
|
||||
class Subscriber extends events.EventEmitter {
|
||||
emitter: any;
|
||||
subscriptions: any;
|
||||
|
||||
constructor(emitter: any) {
|
||||
super();
|
||||
this.emitter = emitter;
|
||||
this.subscriptions = new Map();
|
||||
}
|
||||
|
||||
subscribe(channel: string): void {
|
||||
let handler = (message) => {
|
||||
this.emit('message', channel, message);
|
||||
}
|
||||
this.subscriptions.set(channel, handler);
|
||||
this.emitter.on(channel, handler);
|
||||
}
|
||||
|
||||
unsubscribe(channel: string): void {
|
||||
if (!this.subscriptions.has(channel)) {
|
||||
return;
|
||||
}
|
||||
this.emitter.removeListener(channel, this.subscriptions.get(channel));
|
||||
this.subscriptions.delete(channel);
|
||||
}
|
||||
}
|
||||
|
||||
function createPublisher(): any {
|
||||
return new Publisher(emitter);
|
||||
}
|
||||
|
||||
function createSubscriber(): any {
|
||||
return new Subscriber(emitter);
|
||||
}
|
||||
|
||||
let EventEmitterPubSub = {
|
||||
createPublisher,
|
||||
createSubscriber
|
||||
}
|
||||
|
||||
export {
|
||||
EventEmitterPubSub
|
||||
}
|
||||
22
src/LiveQuery/Id.js
Normal file
22
src/LiveQuery/Id.js
Normal file
@@ -0,0 +1,22 @@
|
||||
class Id {
|
||||
className: string;
|
||||
objectId: string;
|
||||
|
||||
constructor(className: string, objectId: string) {
|
||||
this.className = className;
|
||||
this.objectId = objectId;
|
||||
}
|
||||
toString(): string {
|
||||
return this.className + ':' + this.objectId;
|
||||
}
|
||||
|
||||
static fromString(str: string) {
|
||||
var split = str.split(':');
|
||||
if (split.length !== 2) {
|
||||
throw new TypeError('Cannot create Id object from this string');
|
||||
}
|
||||
return new Id(split[0], split[1]);
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = Id;
|
||||
41
src/LiveQuery/PLog.js
Normal file
41
src/LiveQuery/PLog.js
Normal file
@@ -0,0 +1,41 @@
|
||||
let LogLevel = {
|
||||
'VERBOSE': 0,
|
||||
'DEBUG': 1,
|
||||
'INFO': 2,
|
||||
'ERROR': 3,
|
||||
'NONE': 4
|
||||
}
|
||||
|
||||
function getCurrentLogLevel() {
|
||||
if (PLog.logLevel && PLog.logLevel in LogLevel) {
|
||||
return LogLevel[PLog.logLevel];
|
||||
}
|
||||
return LogLevel['ERROR'];
|
||||
}
|
||||
|
||||
function verbose(): void {
|
||||
if (getCurrentLogLevel() <= LogLevel['VERBOSE']) {
|
||||
console.log.apply(console, arguments)
|
||||
}
|
||||
}
|
||||
|
||||
function log(): void {
|
||||
if (getCurrentLogLevel() <= LogLevel['INFO']) {
|
||||
console.log.apply(console, arguments)
|
||||
}
|
||||
}
|
||||
|
||||
function error(): void {
|
||||
if (getCurrentLogLevel() <= LogLevel['ERROR']) {
|
||||
console.error.apply(console, arguments)
|
||||
}
|
||||
}
|
||||
|
||||
let PLog = {
|
||||
log: log,
|
||||
error: error,
|
||||
verbose: verbose,
|
||||
logLevel: 'INFO'
|
||||
};
|
||||
|
||||
module.exports = PLog;
|
||||
37
src/LiveQuery/ParseCloudCodePublisher.js
Normal file
37
src/LiveQuery/ParseCloudCodePublisher.js
Normal file
@@ -0,0 +1,37 @@
|
||||
import { ParsePubSub } from './ParsePubSub';
|
||||
import PLog from './PLog';
|
||||
|
||||
class ParseCloudCodePublisher {
|
||||
parsePublisher: Object;
|
||||
|
||||
// config object of the publisher, right now it only contains the redisURL,
|
||||
// but we may extend it later.
|
||||
constructor(config: any = {}) {
|
||||
this.parsePublisher = ParsePubSub.createPublisher(config);
|
||||
}
|
||||
|
||||
onCloudCodeAfterSave(request: any): void {
|
||||
this._onCloudCodeMessage('afterSave', request);
|
||||
}
|
||||
|
||||
onCloudCodeAfterDelete(request: any): void {
|
||||
this._onCloudCodeMessage('afterDelete', request);
|
||||
}
|
||||
|
||||
// Request is the request object from cloud code functions. request.object is a ParseObject.
|
||||
_onCloudCodeMessage(type: string, request: any): void {
|
||||
PLog.verbose('Raw request from cloud code current : %j | original : %j', request.object, request.original);
|
||||
// We need the full JSON which includes className
|
||||
let message = {
|
||||
currentParseObject: request.object._toFullJSON()
|
||||
}
|
||||
if (request.original) {
|
||||
message.originalParseObject = request.original._toFullJSON();
|
||||
}
|
||||
this.parsePublisher.publish(type, JSON.stringify(message));
|
||||
}
|
||||
}
|
||||
|
||||
export {
|
||||
ParseCloudCodePublisher
|
||||
}
|
||||
460
src/LiveQuery/ParseLiveQueryServer.js
Normal file
460
src/LiveQuery/ParseLiveQueryServer.js
Normal file
@@ -0,0 +1,460 @@
|
||||
import tv4 from 'tv4';
|
||||
import Parse from 'parse/node';
|
||||
import { Subscription } from './Subscription';
|
||||
import { Client } from './Client';
|
||||
import { ParseWebSocketServer } from './ParseWebSocketServer';
|
||||
import PLog from './PLog';
|
||||
import RequestSchema from './RequestSchema';
|
||||
import { matchesQuery, queryHash } from './QueryTools';
|
||||
import { ParsePubSub } from './ParsePubSub';
|
||||
import { SessionTokenCache } from './SessionTokenCache';
|
||||
|
||||
class ParseLiveQueryServer {
|
||||
clientId: number;
|
||||
clients: Object;
|
||||
// className -> (queryHash -> subscription)
|
||||
subscriptions: Object;
|
||||
parseWebSocketServer: Object;
|
||||
keyPairs : any;
|
||||
// The subscriber we use to get object update from publisher
|
||||
subscriber: Object;
|
||||
|
||||
constructor(server: any, config: any) {
|
||||
this.clientId = 0;
|
||||
this.clients = new Map();
|
||||
this.subscriptions = new Map();
|
||||
|
||||
config = config || {};
|
||||
// Set LogLevel
|
||||
PLog.logLevel = config.logLevel || 'INFO';
|
||||
|
||||
// Store keys, convert obj to map
|
||||
let keyPairs = config.keyPairs || {};
|
||||
this.keyPairs = new Map();
|
||||
for (let key of Object.keys(keyPairs)) {
|
||||
this.keyPairs.set(key, keyPairs[key]);
|
||||
}
|
||||
PLog.verbose('Support key pairs', this.keyPairs);
|
||||
|
||||
// Initialize Parse
|
||||
Parse.Object.disableSingleInstance();
|
||||
Parse.User.enableUnsafeCurrentUser();
|
||||
|
||||
let serverURL = config.serverURL || Parse.serverURL;
|
||||
Parse.serverURL = serverURL;
|
||||
let appId = config.appId || Parse.applicationId;
|
||||
let javascriptKey = Parse.javaScriptKey;
|
||||
let masterKey = config.masterKey || Parse.masterKey;
|
||||
Parse.initialize(appId, javascriptKey, masterKey);
|
||||
|
||||
// Initialize websocket server
|
||||
this.parseWebSocketServer = new ParseWebSocketServer(
|
||||
server,
|
||||
(parseWebsocket) => this._onConnect(parseWebsocket),
|
||||
config.websocketTimeout
|
||||
);
|
||||
|
||||
// Initialize subscriber
|
||||
this.subscriber = ParsePubSub.createSubscriber({
|
||||
redisURL: config.redisURL
|
||||
});
|
||||
this.subscriber.subscribe('afterSave');
|
||||
this.subscriber.subscribe('afterDelete');
|
||||
// Register message handler for subscriber. When publisher get messages, it will publish message
|
||||
// to the subscribers and the handler will be called.
|
||||
this.subscriber.on('message', (channel, messageStr) => {
|
||||
PLog.verbose('Subscribe messsage %j', messageStr);
|
||||
let message = JSON.parse(messageStr);
|
||||
this._inflateParseObject(message);
|
||||
if (channel === 'afterSave') {
|
||||
this._onAfterSave(message);
|
||||
} else if (channel === 'afterDelete') {
|
||||
this._onAfterDelete(message);
|
||||
} else {
|
||||
PLog.error('Get message %s from unknown channel %j', message, channel);
|
||||
}
|
||||
});
|
||||
|
||||
// Initialize sessionToken cache
|
||||
this.sessionTokenCache = new SessionTokenCache(config.cacheTimeout);
|
||||
}
|
||||
|
||||
// Message is the JSON object from publisher. Message.currentParseObject is the ParseObject JSON after changes.
|
||||
// Message.originalParseObject is the original ParseObject JSON.
|
||||
_inflateParseObject(message: any): void {
|
||||
// Inflate merged object
|
||||
let currentParseObject = message.currentParseObject;
|
||||
let className = currentParseObject.className;
|
||||
let parseObject = new Parse.Object(className);
|
||||
parseObject._finishFetch(currentParseObject);
|
||||
message.currentParseObject = parseObject;
|
||||
// Inflate original object
|
||||
let originalParseObject = message.originalParseObject;
|
||||
if (originalParseObject) {
|
||||
className = originalParseObject.className;
|
||||
parseObject = new Parse.Object(className);
|
||||
parseObject._finishFetch(originalParseObject);
|
||||
message.originalParseObject = parseObject;
|
||||
}
|
||||
}
|
||||
|
||||
// Message is the JSON object from publisher after inflated. Message.currentParseObject is the ParseObject after changes.
|
||||
// Message.originalParseObject is the original ParseObject.
|
||||
_onAfterDelete(message: any): void {
|
||||
PLog.verbose('afterDelete is triggered');
|
||||
|
||||
let deletedParseObject = message.currentParseObject.toJSON();
|
||||
let className = deletedParseObject.className;
|
||||
PLog.verbose('ClassName: %j | ObjectId: %s', className, deletedParseObject.id);
|
||||
PLog.verbose('Current client number : %d', this.clients.size);
|
||||
|
||||
let classSubscriptions = this.subscriptions.get(className);
|
||||
if (typeof classSubscriptions === 'undefined') {
|
||||
PLog.error('Can not find subscriptions under this class ' + className);
|
||||
return;
|
||||
}
|
||||
for (let subscription of classSubscriptions.values()) {
|
||||
let isSubscriptionMatched = this._matchesSubscription(deletedParseObject, subscription);
|
||||
if (!isSubscriptionMatched) {
|
||||
continue;
|
||||
}
|
||||
for (let [clientId, requestIds] of subscription.clientRequestIds.entries()) {
|
||||
let client = this.clients.get(clientId);
|
||||
if (typeof client === 'undefined') {
|
||||
continue;
|
||||
}
|
||||
for (let requestId of requestIds) {
|
||||
let acl = message.currentParseObject.getACL();
|
||||
// Check ACL
|
||||
this._matchesACL(acl, client, requestId).then((isMatched) => {
|
||||
if (!isMatched) {
|
||||
return null;
|
||||
}
|
||||
client.pushDelete(requestId, deletedParseObject);
|
||||
}, (error) => {
|
||||
PLog.error('Matching ACL error : ', error);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Message is the JSON object from publisher after inflated. Message.currentParseObject is the ParseObject after changes.
|
||||
// Message.originalParseObject is the original ParseObject.
|
||||
_onAfterSave(message: any): void {
|
||||
PLog.verbose('afterSave is triggered');
|
||||
|
||||
let originalParseObject = null;
|
||||
if (message.originalParseObject) {
|
||||
originalParseObject = message.originalParseObject.toJSON();
|
||||
}
|
||||
let currentParseObject = message.currentParseObject.toJSON();
|
||||
let className = currentParseObject.className;
|
||||
PLog.verbose('ClassName: %s | ObjectId: %s', className, currentParseObject.id);
|
||||
PLog.verbose('Current client number : %d', this.clients.size);
|
||||
|
||||
let classSubscriptions = this.subscriptions.get(className);
|
||||
if (typeof classSubscriptions === 'undefined') {
|
||||
PLog.error('Can not find subscriptions under this class ' + className);
|
||||
return;
|
||||
}
|
||||
for (let subscription of classSubscriptions.values()) {
|
||||
let isOriginalSubscriptionMatched = this._matchesSubscription(originalParseObject, subscription);
|
||||
let isCurrentSubscriptionMatched = this._matchesSubscription(currentParseObject, subscription);
|
||||
for (let [clientId, requestIds] of subscription.clientRequestIds.entries()) {
|
||||
let client = this.clients.get(clientId);
|
||||
if (typeof client === 'undefined') {
|
||||
continue;
|
||||
}
|
||||
for (let requestId of requestIds) {
|
||||
// Set orignal ParseObject ACL checking promise, if the object does not match
|
||||
// subscription, we do not need to check ACL
|
||||
let originalACLCheckingPromise;
|
||||
if (!isOriginalSubscriptionMatched) {
|
||||
originalACLCheckingPromise = Parse.Promise.as(false);
|
||||
} else {
|
||||
let originalACL;
|
||||
if (message.originalParseObject) {
|
||||
originalACL = message.originalParseObject.getACL();
|
||||
}
|
||||
originalACLCheckingPromise = this._matchesACL(originalACL, client, requestId);
|
||||
}
|
||||
// Set current ParseObject ACL checking promise, if the object does not match
|
||||
// subscription, we do not need to check ACL
|
||||
let currentACLCheckingPromise;
|
||||
if (!isCurrentSubscriptionMatched) {
|
||||
currentACLCheckingPromise = Parse.Promise.as(false);
|
||||
} else {
|
||||
let currentACL = message.currentParseObject.getACL();
|
||||
currentACLCheckingPromise = this._matchesACL(currentACL, client, requestId);
|
||||
}
|
||||
|
||||
Parse.Promise.when(
|
||||
originalACLCheckingPromise,
|
||||
currentACLCheckingPromise
|
||||
).then((isOriginalMatched, isCurrentMatched) => {
|
||||
PLog.verbose('Original %j | Current %j | Match: %s, %s, %s, %s | Query: %s',
|
||||
originalParseObject,
|
||||
currentParseObject,
|
||||
isOriginalSubscriptionMatched,
|
||||
isCurrentSubscriptionMatched,
|
||||
isOriginalMatched,
|
||||
isCurrentMatched,
|
||||
subscription.hash
|
||||
);
|
||||
|
||||
// Decide event type
|
||||
let type;
|
||||
if (isOriginalMatched && isCurrentMatched) {
|
||||
type = 'Update';
|
||||
} else if (isOriginalMatched && !isCurrentMatched) {
|
||||
type = 'Leave';
|
||||
} else if (!isOriginalMatched && isCurrentMatched) {
|
||||
if (originalParseObject) {
|
||||
type = 'Enter';
|
||||
} else {
|
||||
type = 'Create';
|
||||
}
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
let functionName = 'push' + type;
|
||||
client[functionName](requestId, currentParseObject);
|
||||
}, (error) => {
|
||||
PLog.error('Matching ACL error : ', error);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_onConnect(parseWebsocket: any): void {
|
||||
parseWebsocket.on('message', (request) => {
|
||||
if (typeof request === 'string') {
|
||||
request = JSON.parse(request);
|
||||
}
|
||||
PLog.verbose('Request: %j', request);
|
||||
|
||||
// Check whether this request is a valid request, return error directly if not
|
||||
if (!tv4.validate(request, RequestSchema['general']) || !tv4.validate(request, RequestSchema[request.op])) {
|
||||
Client.pushError(parseWebsocket, 1, tv4.error.message);
|
||||
PLog.error('Connect message error %s', tv4.error.message);
|
||||
return;
|
||||
}
|
||||
|
||||
switch(request.op) {
|
||||
case 'connect':
|
||||
this._handleConnect(parseWebsocket, request);
|
||||
break;
|
||||
case 'subscribe':
|
||||
this._handleSubscribe(parseWebsocket, request);
|
||||
break;
|
||||
case 'unsubscribe':
|
||||
this._handleUnsubscribe(parseWebsocket, request);
|
||||
break;
|
||||
default:
|
||||
Client.pushError(parseWebsocket, 3, 'Get unknown operation');
|
||||
PLog.error('Get unknown operation', request.op);
|
||||
}
|
||||
});
|
||||
|
||||
parseWebsocket.on('disconnect', () => {
|
||||
PLog.log('Client disconnect: %d', parseWebsocket.clientId);
|
||||
let clientId = parseWebsocket.clientId;
|
||||
if (!this.clients.has(clientId)) {
|
||||
PLog.error('Can not find client %d on disconnect', clientId);
|
||||
return;
|
||||
}
|
||||
|
||||
// Delete client
|
||||
let client = this.clients.get(clientId);
|
||||
this.clients.delete(clientId);
|
||||
|
||||
// Delete client from subscriptions
|
||||
for (let [requestId, subscriptionInfo] of client.subscriptionInfos.entries()) {
|
||||
let subscription = subscriptionInfo.subscription;
|
||||
subscription.deleteClientSubscription(clientId, requestId);
|
||||
|
||||
// If there is no client which is subscribing this subscription, remove it from subscriptions
|
||||
let classSubscriptions = this.subscriptions.get(subscription.className);
|
||||
if (!subscription.hasSubscribingClient()) {
|
||||
classSubscriptions.delete(subscription.hash);
|
||||
}
|
||||
// If there is no subscriptions under this class, remove it from subscriptions
|
||||
if (classSubscriptions.size === 0) {
|
||||
this.subscriptions.delete(subscription.className);
|
||||
}
|
||||
}
|
||||
|
||||
PLog.verbose('Current clients %d', this.clients.size);
|
||||
PLog.verbose('Current subscriptions %d', this.subscriptions.size);
|
||||
});
|
||||
}
|
||||
|
||||
_matchesSubscription(parseObject: any, subscription: any): boolean {
|
||||
// Object is undefined or null, not match
|
||||
if (!parseObject) {
|
||||
return false;
|
||||
}
|
||||
return matchesQuery(parseObject, subscription.query);
|
||||
}
|
||||
|
||||
_matchesACL(acl: any, client: any, requestId: number): any {
|
||||
// If ACL is undefined or null, or ACL has public read access, return true directly
|
||||
if (!acl || acl.getPublicReadAccess()) {
|
||||
return Parse.Promise.as(true);
|
||||
}
|
||||
// Check subscription sessionToken matches ACL first
|
||||
let subscriptionInfo = client.getSubscriptionInfo(requestId);
|
||||
if (typeof subscriptionInfo === 'undefined') {
|
||||
return Parse.Promise.as(false);
|
||||
}
|
||||
|
||||
let subscriptionSessionToken = subscriptionInfo.sessionToken;
|
||||
return this.sessionTokenCache.getUserId(subscriptionSessionToken).then((userId) => {
|
||||
return acl.getReadAccess(userId);
|
||||
}).then((isSubscriptionSessionTokenMatched) => {
|
||||
if (isSubscriptionSessionTokenMatched) {
|
||||
return Parse.Promise.as(true);
|
||||
}
|
||||
// Check client sessionToken matches ACL
|
||||
let clientSessionToken = client.sessionToken;
|
||||
return this.sessionTokenCache.getUserId(clientSessionToken).then((userId) => {
|
||||
return acl.getReadAccess(userId);
|
||||
});
|
||||
}).then((isMatched) => {
|
||||
return Parse.Promise.as(isMatched);
|
||||
}, (error) => {
|
||||
return Parse.Promise.as(false);
|
||||
});
|
||||
}
|
||||
|
||||
_handleConnect(parseWebsocket: any, request: any): any {
|
||||
if (!this._validateKeys(request, this.keyPairs)) {
|
||||
Client.pushError(parseWebsocket, 4, 'Key in request is not valid');
|
||||
PLog.error('Key in request is not valid');
|
||||
return;
|
||||
}
|
||||
let client = new Client(this.clientId, parseWebsocket);
|
||||
parseWebsocket.clientId = this.clientId;
|
||||
this.clientId += 1;
|
||||
this.clients.set(parseWebsocket.clientId, client);
|
||||
PLog.log('Create new client: %d', parseWebsocket.clientId);
|
||||
client.pushConnect();
|
||||
}
|
||||
|
||||
_validateKeys(request: any, validKeyPairs: any): boolean {
|
||||
if (!validKeyPairs || validKeyPairs.size == 0) {
|
||||
return true;
|
||||
}
|
||||
let isValid = false;
|
||||
for (let [key, secret] of validKeyPairs) {
|
||||
if (!request[key] || request[key] !== secret) {
|
||||
continue;
|
||||
}
|
||||
isValid = true;
|
||||
break;
|
||||
}
|
||||
return isValid;
|
||||
}
|
||||
|
||||
_handleSubscribe(parseWebsocket: any, request: any): any {
|
||||
// If we can not find this client, return error to client
|
||||
if (!parseWebsocket.hasOwnProperty('clientId')) {
|
||||
Client.pushError(parseWebsocket, 2, 'Can not find this client, make sure you connect to server before subscribing');
|
||||
PLog.error('Can not find this client, make sure you connect to server before subscribing');
|
||||
return;
|
||||
}
|
||||
let client = this.clients.get(parseWebsocket.clientId);
|
||||
|
||||
// Get subscription from subscriptions, create one if necessary
|
||||
let subscriptionHash = queryHash(request.query);
|
||||
// Add className to subscriptions if necessary
|
||||
let className = request.query.className;
|
||||
if (!this.subscriptions.has(className)) {
|
||||
this.subscriptions.set(className, new Map());
|
||||
}
|
||||
let classSubscriptions = this.subscriptions.get(className);
|
||||
let subscription;
|
||||
if (classSubscriptions.has(subscriptionHash)) {
|
||||
subscription = classSubscriptions.get(subscriptionHash);
|
||||
} else {
|
||||
subscription = new Subscription(className, request.query.where, subscriptionHash);
|
||||
classSubscriptions.set(subscriptionHash, subscription);
|
||||
}
|
||||
|
||||
// Add subscriptionInfo to client
|
||||
let subscriptionInfo = {
|
||||
subscription: subscription
|
||||
};
|
||||
// Add selected fields and sessionToken for this subscription if necessary
|
||||
if (request.query.fields) {
|
||||
subscriptionInfo.fields = request.query.fields;
|
||||
}
|
||||
if (request.sessionToken) {
|
||||
subscriptionInfo.sessionToken = request.sessionToken;
|
||||
}
|
||||
client.addSubscriptionInfo(request.requestId, subscriptionInfo);
|
||||
|
||||
// Add clientId to subscription
|
||||
subscription.addClientSubscription(parseWebsocket.clientId, request.requestId);
|
||||
|
||||
client.pushSubscribe(request.requestId);
|
||||
|
||||
PLog.verbose('Create client %d new subscription: %d', parseWebsocket.clientId, request.requestId);
|
||||
PLog.verbose('Current client number: %d', this.clients.size);
|
||||
}
|
||||
|
||||
_handleUnsubscribe(parseWebsocket: any, request: any): any {
|
||||
// If we can not find this client, return error to client
|
||||
if (!parseWebsocket.hasOwnProperty('clientId')) {
|
||||
Client.pushError(parseWebsocket, 2, 'Can not find this client, make sure you connect to server before unsubscribing');
|
||||
PLog.error('Can not find this client, make sure you connect to server before unsubscribing');
|
||||
return;
|
||||
}
|
||||
let requestId = request.requestId;
|
||||
let client = this.clients.get(parseWebsocket.clientId);
|
||||
if (typeof client === 'undefined') {
|
||||
Client.pushError(parseWebsocket, 2, 'Cannot find client with clientId ' + parseWebsocket.clientId +
|
||||
'. Make sure you connect to live query server before unsubscribing.');
|
||||
PLog.error('Can not find this client ' + parseWebsocket.clientId);
|
||||
return;
|
||||
}
|
||||
|
||||
let subscriptionInfo = client.getSubscriptionInfo(requestId);
|
||||
if (typeof subscriptionInfo === 'undefined') {
|
||||
Client.pushError(parseWebsocket, 2, 'Cannot find subscription with clientId ' + parseWebsocket.clientId +
|
||||
' subscriptionId ' + requestId + '. Make sure you subscribe to live query server before unsubscribing.');
|
||||
PLog.error('Can not find subscription with clientId ' + parseWebsocket.clientId + ' subscriptionId ' + requestId);
|
||||
return;
|
||||
}
|
||||
|
||||
// Remove subscription from client
|
||||
client.deleteSubscriptionInfo(requestId);
|
||||
// Remove client from subscription
|
||||
let subscription = subscriptionInfo.subscription;
|
||||
let className = subscription.className;
|
||||
subscription.deleteClientSubscription(parseWebsocket.clientId, requestId);
|
||||
// If there is no client which is subscribing this subscription, remove it from subscriptions
|
||||
let classSubscriptions = this.subscriptions.get(className);
|
||||
if (!subscription.hasSubscribingClient()) {
|
||||
classSubscriptions.delete(subscription.hash);
|
||||
}
|
||||
// If there is no subscriptions under this class, remove it from subscriptions
|
||||
if (classSubscriptions.size === 0) {
|
||||
this.subscriptions.delete(className);
|
||||
}
|
||||
|
||||
client.pushUnsubscribe(request.requestId);
|
||||
|
||||
PLog.verbose('Delete client: %d | subscription: %d', parseWebsocket.clientId, request.requestId);
|
||||
}
|
||||
}
|
||||
|
||||
ParseLiveQueryServer.setLogLevel = function(logLevel) {
|
||||
PLog.logLevel = logLevel;
|
||||
}
|
||||
|
||||
export {
|
||||
ParseLiveQueryServer
|
||||
}
|
||||
29
src/LiveQuery/ParsePubSub.js
Normal file
29
src/LiveQuery/ParsePubSub.js
Normal file
@@ -0,0 +1,29 @@
|
||||
import { RedisPubSub } from './RedisPubSub';
|
||||
import { EventEmitterPubSub } from './EventEmitterPubSub';
|
||||
|
||||
let ParsePubSub = {};
|
||||
|
||||
function useRedis(config: any): boolean {
|
||||
let redisURL = config.redisURL;
|
||||
return typeof redisURL !== 'undefined' && redisURL !== '';
|
||||
}
|
||||
|
||||
ParsePubSub.createPublisher = function(config: any): any {
|
||||
if (useRedis(config)) {
|
||||
return RedisPubSub.createPublisher(config.redisURL);
|
||||
} else {
|
||||
return EventEmitterPubSub.createPublisher();
|
||||
}
|
||||
}
|
||||
|
||||
ParsePubSub.createSubscriber = function(config: any): void {
|
||||
if (useRedis(config)) {
|
||||
return RedisPubSub.createSubscriber(config.redisURL);
|
||||
} else {
|
||||
return EventEmitterPubSub.createSubscriber();
|
||||
}
|
||||
}
|
||||
|
||||
export {
|
||||
ParsePubSub
|
||||
}
|
||||
44
src/LiveQuery/ParseWebSocketServer.js
Normal file
44
src/LiveQuery/ParseWebSocketServer.js
Normal file
@@ -0,0 +1,44 @@
|
||||
import PLog from './PLog';
|
||||
|
||||
let typeMap = new Map([['disconnect', 'close']]);
|
||||
|
||||
export class ParseWebSocketServer {
|
||||
server: Object;
|
||||
|
||||
constructor(server: any, onConnect: Function, websocketTimeout: number = 10 * 1000) {
|
||||
let WebSocketServer = require('ws').Server;
|
||||
let wss = new WebSocketServer({ server: server });
|
||||
wss.on('listening', () => {
|
||||
PLog.log('Parse LiveQuery Server starts running');
|
||||
});
|
||||
wss.on('connection', (ws) => {
|
||||
onConnect(new ParseWebSocket(ws));
|
||||
// Send ping to client periodically
|
||||
let pingIntervalId = setInterval(() => {
|
||||
if (ws.readyState == ws.OPEN) {
|
||||
ws.ping();
|
||||
} else {
|
||||
clearInterval(pingIntervalId);
|
||||
}
|
||||
}, websocketTimeout);
|
||||
});
|
||||
this.server = wss;
|
||||
}
|
||||
}
|
||||
|
||||
export class ParseWebSocket {
|
||||
ws: any;
|
||||
|
||||
constructor(ws: any) {
|
||||
this.ws = ws;
|
||||
}
|
||||
|
||||
on(type: string, callback): void {
|
||||
let wsType = typeMap.has(type) ? typeMap.get(type) : type;
|
||||
this.ws.on(wsType, callback);
|
||||
}
|
||||
|
||||
send(message: any, channel: string): void {
|
||||
this.ws.send(message);
|
||||
}
|
||||
}
|
||||
280
src/LiveQuery/QueryTools.js
Normal file
280
src/LiveQuery/QueryTools.js
Normal file
@@ -0,0 +1,280 @@
|
||||
var equalObjects = require('./equalObjects');
|
||||
var Id = require('./Id');
|
||||
var Parse = require('parse/node');
|
||||
|
||||
/**
|
||||
* Query Hashes are deterministic hashes for Parse Queries.
|
||||
* Any two queries that have the same set of constraints will produce the same
|
||||
* hash. This lets us reliably group components by the queries they depend upon,
|
||||
* and quickly determine if a query has changed.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Convert $or queries into an array of where conditions
|
||||
*/
|
||||
function flattenOrQueries(where) {
|
||||
if (!where.hasOwnProperty('$or')) {
|
||||
return where;
|
||||
}
|
||||
var accum = [];
|
||||
for (var i = 0; i < where.$or.length; i++) {
|
||||
accum = accum.concat(where.$or[i]);
|
||||
}
|
||||
return accum;
|
||||
}
|
||||
|
||||
/**
|
||||
* Deterministically turns an object into a string. Disregards ordering
|
||||
*/
|
||||
function stringify(object): string {
|
||||
if (typeof object !== 'object' || object === null) {
|
||||
if (typeof object === 'string') {
|
||||
return '"' + object.replace(/\|/g, '%|') + '"';
|
||||
}
|
||||
return object + '';
|
||||
}
|
||||
if (Array.isArray(object)) {
|
||||
var copy = object.map(stringify);
|
||||
copy.sort();
|
||||
return '[' + copy.join(',') + ']';
|
||||
}
|
||||
var sections = [];
|
||||
var keys = Object.keys(object);
|
||||
keys.sort();
|
||||
for (var k = 0; k < keys.length; k++) {
|
||||
sections.push(stringify(keys[k]) + ':' + stringify(object[keys[k]]));
|
||||
}
|
||||
return '{' + sections.join(',') + '}';
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a hash from a query, with unique fields for columns, values, order,
|
||||
* skip, and limit.
|
||||
*/
|
||||
function queryHash(query) {
|
||||
if (query instanceof Parse.Query) {
|
||||
query = {
|
||||
className: query.className,
|
||||
where: query._where
|
||||
}
|
||||
}
|
||||
var where = flattenOrQueries(query.where || {});
|
||||
var columns = [];
|
||||
var values = [];
|
||||
var i;
|
||||
if (Array.isArray(where)) {
|
||||
var uniqueColumns = {};
|
||||
for (i = 0; i < where.length; i++) {
|
||||
var subValues = {};
|
||||
var keys = Object.keys(where[i]);
|
||||
keys.sort();
|
||||
for (var j = 0; j < keys.length; j++) {
|
||||
subValues[keys[j]] = where[i][keys[j]];
|
||||
uniqueColumns[keys[j]] = true;
|
||||
}
|
||||
values.push(subValues);
|
||||
}
|
||||
columns = Object.keys(uniqueColumns);
|
||||
columns.sort();
|
||||
} else {
|
||||
columns = Object.keys(where);
|
||||
columns.sort();
|
||||
for (i = 0; i < columns.length; i++) {
|
||||
values.push(where[columns[i]]);
|
||||
}
|
||||
}
|
||||
|
||||
var sections = [columns.join(','), stringify(values)];
|
||||
|
||||
return query.className + ':' + sections.join('|');
|
||||
}
|
||||
|
||||
/**
|
||||
* matchesQuery -- Determines if an object would be returned by a Parse Query
|
||||
* It's a lightweight, where-clause only implementation of a full query engine.
|
||||
* Since we find queries that match objects, rather than objects that match
|
||||
* queries, we can avoid building a full-blown query tool.
|
||||
*/
|
||||
function matchesQuery(object: any, query: any): boolean {
|
||||
if (query instanceof Parse.Query) {
|
||||
var className =
|
||||
(object.id instanceof Id) ? object.id.className : object.className;
|
||||
if (className !== query.className) {
|
||||
return false;
|
||||
}
|
||||
return matchesQuery(object, query._where);
|
||||
}
|
||||
for (var field in query) {
|
||||
if (!matchesKeyConstraints(object, field, query[field])) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Determines whether an object matches a single key's constraints
|
||||
*/
|
||||
function matchesKeyConstraints(object, key, constraints) {
|
||||
var i;
|
||||
if (key === '$or') {
|
||||
for (i = 0; i < constraints.length; i++) {
|
||||
if (matchesQuery(object, constraints[i])) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
if (key === '$relatedTo') {
|
||||
// Bail! We can't handle relational queries locally
|
||||
return false;
|
||||
}
|
||||
// Equality (or Array contains) cases
|
||||
if (typeof constraints !== 'object') {
|
||||
if (Array.isArray(object[key])) {
|
||||
return object[key].indexOf(constraints) > -1;
|
||||
}
|
||||
return object[key] === constraints;
|
||||
}
|
||||
var compareTo;
|
||||
if (constraints.__type) {
|
||||
if (constraints.__type === 'Pointer') {
|
||||
return (
|
||||
constraints.className === object[key].className &&
|
||||
constraints.objectId === object[key].objectId
|
||||
);
|
||||
}
|
||||
compareTo = Parse._decode(key, constraints);
|
||||
if (Array.isArray(object[key])) {
|
||||
for (i = 0; i < object[key].length; i++) {
|
||||
if (equalObjects(object[key][i], compareTo)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
return equalObjects(object[key], compareTo);
|
||||
}
|
||||
// More complex cases
|
||||
for (var condition in constraints) {
|
||||
compareTo = constraints[condition];
|
||||
if (compareTo.__type) {
|
||||
compareTo = Parse._decode(key, compareTo);
|
||||
}
|
||||
switch (condition) {
|
||||
case '$lt':
|
||||
if (object[key] >= compareTo) {
|
||||
return false;
|
||||
}
|
||||
break;
|
||||
case '$lte':
|
||||
if (object[key] > compareTo) {
|
||||
return false;
|
||||
}
|
||||
break;
|
||||
case '$gt':
|
||||
if (object[key] <= compareTo) {
|
||||
return false;
|
||||
}
|
||||
break;
|
||||
case '$gte':
|
||||
if (object[key] < compareTo) {
|
||||
return false;
|
||||
}
|
||||
break;
|
||||
case '$ne':
|
||||
if (equalObjects(object[key], compareTo)) {
|
||||
return false;
|
||||
}
|
||||
break;
|
||||
case '$in':
|
||||
if (compareTo.indexOf(object[key]) < 0) {
|
||||
return false;
|
||||
}
|
||||
break;
|
||||
case '$nin':
|
||||
if (compareTo.indexOf(object[key]) > -1) {
|
||||
return false;
|
||||
}
|
||||
break;
|
||||
case '$all':
|
||||
for (i = 0; i < compareTo.length; i++) {
|
||||
if (object[key].indexOf(compareTo[i]) < 0) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
break;
|
||||
case '$exists':
|
||||
if (typeof object[key] === 'undefined') {
|
||||
return false;
|
||||
}
|
||||
break;
|
||||
case '$regex':
|
||||
if (typeof compareTo === 'object') {
|
||||
return compareTo.test(object[key]);
|
||||
}
|
||||
// JS doesn't support perl-style escaping
|
||||
var expString = '';
|
||||
var escapeEnd = -2;
|
||||
var escapeStart = compareTo.indexOf('\\Q');
|
||||
while (escapeStart > -1) {
|
||||
// Add the unescaped portion
|
||||
expString += compareTo.substring(escapeEnd + 2, escapeStart);
|
||||
escapeEnd = compareTo.indexOf('\\E', escapeStart);
|
||||
if (escapeEnd > -1) {
|
||||
expString += compareTo.substring(escapeStart + 2, escapeEnd)
|
||||
.replace(/\\\\\\\\E/g, '\\E').replace(/\W/g, '\\$&');
|
||||
}
|
||||
|
||||
escapeStart = compareTo.indexOf('\\Q', escapeEnd);
|
||||
}
|
||||
expString += compareTo.substring(Math.max(escapeStart, escapeEnd + 2));
|
||||
var exp = new RegExp(expString, constraints.$options || '');
|
||||
if (!exp.test(object[key])) {
|
||||
return false;
|
||||
}
|
||||
break;
|
||||
case '$nearSphere':
|
||||
var distance = compareTo.radiansTo(object[key]);
|
||||
var max = constraints.$maxDistance || Infinity;
|
||||
return distance <= max;
|
||||
case '$within':
|
||||
var southWest = compareTo.$box[0];
|
||||
var northEast = compareTo.$box[1];
|
||||
if (southWest.latitude > northEast.latitude ||
|
||||
southWest.longitude > northEast.longitude) {
|
||||
// Invalid box, crosses the date line
|
||||
return false;
|
||||
}
|
||||
return (
|
||||
object[key].latitude > southWest.latitude &&
|
||||
object[key].latitude < northEast.latitude &&
|
||||
object[key].longitude > southWest.longitude &&
|
||||
object[key].longitude < northEast.longitude
|
||||
);
|
||||
case '$options':
|
||||
// Not a query type, but a way to add options to $regex. Ignore and
|
||||
// avoid the default
|
||||
break;
|
||||
case '$maxDistance':
|
||||
// Not a query type, but a way to add a cap to $nearSphere. Ignore and
|
||||
// avoid the default
|
||||
break;
|
||||
case '$select':
|
||||
return false;
|
||||
case '$dontSelect':
|
||||
return false;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
var QueryTools = {
|
||||
queryHash: queryHash,
|
||||
matchesQuery: matchesQuery
|
||||
};
|
||||
|
||||
module.exports = QueryTools;
|
||||
18
src/LiveQuery/RedisPubSub.js
Normal file
18
src/LiveQuery/RedisPubSub.js
Normal file
@@ -0,0 +1,18 @@
|
||||
import redis from 'redis';
|
||||
|
||||
function createPublisher(redisURL: string): any {
|
||||
return redis.createClient(redisURL, { no_ready_check: true });
|
||||
}
|
||||
|
||||
function createSubscriber(redisURL: string): any {
|
||||
return redis.createClient(redisURL, { no_ready_check: true });
|
||||
}
|
||||
|
||||
let RedisPubSub = {
|
||||
createPublisher,
|
||||
createSubscriber
|
||||
}
|
||||
|
||||
export {
|
||||
RedisPubSub
|
||||
}
|
||||
101
src/LiveQuery/RequestSchema.js
Normal file
101
src/LiveQuery/RequestSchema.js
Normal file
@@ -0,0 +1,101 @@
|
||||
let general = {
|
||||
'title': 'General request schema',
|
||||
'type': 'object',
|
||||
'properties': {
|
||||
'op': {
|
||||
'type': 'string',
|
||||
'enum': ['connect', 'subscribe', 'unsubscribe']
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
let connect = {
|
||||
'title': 'Connect operation schema',
|
||||
'type': 'object',
|
||||
'properties': {
|
||||
'op': 'connect',
|
||||
'applicationId': {
|
||||
'type': 'string'
|
||||
},
|
||||
'javascriptKey': {
|
||||
type: 'string'
|
||||
},
|
||||
'masterKey': {
|
||||
type: 'string'
|
||||
},
|
||||
'clientKey': {
|
||||
type: 'string'
|
||||
},
|
||||
'windowsKey': {
|
||||
type: 'string'
|
||||
},
|
||||
'restAPIKey': {
|
||||
'type': 'string'
|
||||
},
|
||||
'sessionToken': {
|
||||
'type': 'string'
|
||||
}
|
||||
},
|
||||
'required': ['op', 'applicationId'],
|
||||
"additionalProperties": false
|
||||
};
|
||||
|
||||
let subscribe = {
|
||||
'title': 'Subscribe operation schema',
|
||||
'type': 'object',
|
||||
'properties': {
|
||||
'op': 'subscribe',
|
||||
'requestId': {
|
||||
'type': 'number'
|
||||
},
|
||||
'query': {
|
||||
'title': 'Query field schema',
|
||||
'type': 'object',
|
||||
'properties': {
|
||||
'className': {
|
||||
'type': 'string'
|
||||
},
|
||||
'where': {
|
||||
'type': 'object'
|
||||
},
|
||||
'fields': {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"minItems": 1,
|
||||
"uniqueItems": true
|
||||
}
|
||||
},
|
||||
'required': ['where', 'className'],
|
||||
'additionalProperties': false
|
||||
},
|
||||
'sessionToken': {
|
||||
'type': 'string'
|
||||
}
|
||||
},
|
||||
'required': ['op', 'requestId', 'query'],
|
||||
'additionalProperties': false
|
||||
};
|
||||
|
||||
let unsubscribe = {
|
||||
'title': 'Unsubscribe operation schema',
|
||||
'type': 'object',
|
||||
'properties': {
|
||||
'op': 'unsubscribe',
|
||||
'requestId': {
|
||||
'type': 'number'
|
||||
}
|
||||
},
|
||||
'required': ['op', 'requestId'],
|
||||
"additionalProperties": false
|
||||
}
|
||||
|
||||
let RequestSchema = {
|
||||
'general': general,
|
||||
'connect': connect,
|
||||
'subscribe': subscribe,
|
||||
'unsubscribe': unsubscribe
|
||||
}
|
||||
|
||||
export default RequestSchema;
|
||||
38
src/LiveQuery/SessionTokenCache.js
Normal file
38
src/LiveQuery/SessionTokenCache.js
Normal file
@@ -0,0 +1,38 @@
|
||||
import Parse from 'parse/node';
|
||||
import LRU from 'lru-cache';
|
||||
import PLog from './PLog';
|
||||
|
||||
class SessionTokenCache {
|
||||
cache: Object;
|
||||
|
||||
constructor(timeout: number = 30 * 24 * 60 *60 * 1000, maxSize: number = 10000) {
|
||||
this.cache = new LRU({
|
||||
max: maxSize,
|
||||
maxAge: timeout
|
||||
});
|
||||
}
|
||||
|
||||
getUserId(sessionToken: string): any {
|
||||
if (!sessionToken) {
|
||||
return Parse.Promise.error('Empty sessionToken');
|
||||
}
|
||||
let userId = this.cache.get(sessionToken);
|
||||
if (userId) {
|
||||
PLog.verbose('Fetch userId %s of sessionToken %s from Cache', userId, sessionToken);
|
||||
return Parse.Promise.as(userId);
|
||||
}
|
||||
return Parse.User.become(sessionToken).then((user) => {
|
||||
PLog.verbose('Fetch userId %s of sessionToken %s from Parse', user.id, sessionToken);
|
||||
let userId = user.id;
|
||||
this.cache.set(sessionToken, userId);
|
||||
return Parse.Promise.as(userId);
|
||||
}, (error) => {
|
||||
PLog.error('Can not fetch userId for sessionToken %j, error %j', sessionToken, error);
|
||||
return Parse.Promise.error(error);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export {
|
||||
SessionTokenCache
|
||||
}
|
||||
55
src/LiveQuery/Subscription.js
Normal file
55
src/LiveQuery/Subscription.js
Normal file
@@ -0,0 +1,55 @@
|
||||
import {matchesQuery, queryHash} from './QueryTools';
|
||||
import PLog from './PLog';
|
||||
|
||||
export type FlattenedObjectData = { [attr: string]: any };
|
||||
export type QueryData = { [attr: string]: any };
|
||||
|
||||
class Subscription {
|
||||
// It is query condition eg query.where
|
||||
query: QueryData;
|
||||
className: string;
|
||||
hash: string;
|
||||
clientRequestIds: Object;
|
||||
|
||||
constructor(className: string, query: QueryData, queryHash: string) {
|
||||
this.className = className;
|
||||
this.query = query;
|
||||
this.hash = queryHash;
|
||||
this.clientRequestIds = new Map();
|
||||
}
|
||||
|
||||
addClientSubscription(clientId: number, requestId: number): void {
|
||||
if (!this.clientRequestIds.has(clientId)) {
|
||||
this.clientRequestIds.set(clientId, []);
|
||||
}
|
||||
let requestIds = this.clientRequestIds.get(clientId);
|
||||
requestIds.push(requestId);
|
||||
}
|
||||
|
||||
deleteClientSubscription(clientId: number, requestId: number): void {
|
||||
let requestIds = this.clientRequestIds.get(clientId);
|
||||
if (typeof requestIds === 'undefined') {
|
||||
PLog.error('Can not find client %d to delete', clientId);
|
||||
return;
|
||||
}
|
||||
|
||||
let index = requestIds.indexOf(requestId);
|
||||
if (index < 0) {
|
||||
PLog.error('Can not find client %d subscription %d to delete', clientId, requestId);
|
||||
return;
|
||||
}
|
||||
requestIds.splice(index, 1);
|
||||
// Delete client reference if it has no subscription
|
||||
if (requestIds.length == 0) {
|
||||
this.clientRequestIds.delete(clientId);
|
||||
}
|
||||
}
|
||||
|
||||
hasSubscribingClient(): boolean {
|
||||
return this.clientRequestIds.size > 0;
|
||||
}
|
||||
}
|
||||
|
||||
export {
|
||||
Subscription
|
||||
}
|
||||
48
src/LiveQuery/equalObjects.js
Normal file
48
src/LiveQuery/equalObjects.js
Normal file
@@ -0,0 +1,48 @@
|
||||
var toString = Object.prototype.toString;
|
||||
|
||||
/**
|
||||
* Determines whether two objects represent the same primitive, special Parse
|
||||
* type, or full Parse Object.
|
||||
*/
|
||||
function equalObjects(a, b) {
|
||||
if (typeof a !== typeof b) {
|
||||
return false;
|
||||
}
|
||||
if (typeof a !== 'object') {
|
||||
return (a === b);
|
||||
}
|
||||
if (a === b) {
|
||||
return true;
|
||||
}
|
||||
if (toString.call(a) === '[object Date]') {
|
||||
if (toString.call(b) === '[object Date]') {
|
||||
return (+a === +b);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
if (Array.isArray(a)) {
|
||||
if (Array.isArray(b)) {
|
||||
if (a.length !== b.length) {
|
||||
return false;
|
||||
}
|
||||
for (var i = 0; i < a.length; i++) {
|
||||
if (!equalObjects(a[i], b[i])) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
if (Object.keys(a).length !== Object.keys(b).length) {
|
||||
return false;
|
||||
}
|
||||
for (var key in a) {
|
||||
if (!equalObjects(a[key], b[key])) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
module.exports = equalObjects;
|
||||
@@ -266,6 +266,7 @@ RestWrite.prototype.findUsersWithAuthData = function(authData) {
|
||||
return findPromise;
|
||||
}
|
||||
|
||||
|
||||
RestWrite.prototype.handleAuthData = function(authData) {
|
||||
let results;
|
||||
return this.handleAuthDataValidation(authData).then(() => {
|
||||
@@ -768,7 +769,9 @@ RestWrite.prototype.runAfterTrigger = function() {
|
||||
}
|
||||
|
||||
// Avoid doing any setup for triggers if there is no 'afterSave' trigger for this class.
|
||||
if (!triggers.triggerExists(this.className, triggers.Types.afterSave, this.config.applicationId)) {
|
||||
let hasAfterSaveHook = triggers.triggerExists(this.className, triggers.Types.afterSave, this.config.applicationId);
|
||||
let hasLiveQuery = this.config.liveQueryController.hasLiveQuery(this.className);
|
||||
if (!hasAfterSaveHook && !hasLiveQuery) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
@@ -789,6 +792,10 @@ RestWrite.prototype.runAfterTrigger = function() {
|
||||
updatedObject.set(this.sanitizedData());
|
||||
updatedObject._handleSaveResponse(this.response.response, this.response.status || 200);
|
||||
|
||||
// Notifiy LiveQueryServer if possible
|
||||
this.config.liveQueryController.onAfterSave(updatedObject.className, updatedObject, originalObject);
|
||||
|
||||
// Run afterSave trigger
|
||||
triggers.maybeRunTrigger(triggers.Types.afterSave, this.auth, updatedObject, originalObject, this.config.applicationId);
|
||||
};
|
||||
|
||||
|
||||
79
src/index.js
79
src/index.js
@@ -11,41 +11,43 @@ var batch = require('./batch'),
|
||||
Parse = require('parse/node').Parse,
|
||||
authDataManager = require('./authDataManager');
|
||||
|
||||
//import passwordReset from './passwordReset';
|
||||
import cache from './cache';
|
||||
import Config from './Config';
|
||||
import parseServerPackage from '../package.json';
|
||||
import ParsePushAdapter from './Adapters/Push/ParsePushAdapter';
|
||||
import PromiseRouter from './PromiseRouter';
|
||||
import requiredParameter from './requiredParameter';
|
||||
import { AnalyticsRouter } from './Routers/AnalyticsRouter';
|
||||
import { ClassesRouter } from './Routers/ClassesRouter';
|
||||
import { FeaturesRouter } from './Routers/FeaturesRouter';
|
||||
import { FileLoggerAdapter } from './Adapters/Logger/FileLoggerAdapter';
|
||||
import { FilesController } from './Controllers/FilesController';
|
||||
import { FilesRouter } from './Routers/FilesRouter';
|
||||
import { FunctionsRouter } from './Routers/FunctionsRouter';
|
||||
import { GCSAdapter } from './Adapters/Files/GCSAdapter';
|
||||
import { GlobalConfigRouter } from './Routers/GlobalConfigRouter';
|
||||
import { GridStoreAdapter } from './Adapters/Files/GridStoreAdapter';
|
||||
import { HooksController } from './Controllers/HooksController';
|
||||
import { HooksRouter } from './Routers/HooksRouter';
|
||||
import { IAPValidationRouter } from './Routers/IAPValidationRouter';
|
||||
import { InstallationsRouter } from './Routers/InstallationsRouter';
|
||||
import { loadAdapter } from './Adapters/AdapterLoader';
|
||||
import { LoggerController } from './Controllers/LoggerController';
|
||||
import { LogsRouter } from './Routers/LogsRouter';
|
||||
import { PublicAPIRouter } from './Routers/PublicAPIRouter';
|
||||
import { PushController } from './Controllers/PushController';
|
||||
import { PushRouter } from './Routers/PushRouter';
|
||||
import { randomString } from './cryptoUtils';
|
||||
import { RolesRouter } from './Routers/RolesRouter';
|
||||
import { S3Adapter } from './Adapters/Files/S3Adapter';
|
||||
import { SchemasRouter } from './Routers/SchemasRouter';
|
||||
import { SessionsRouter } from './Routers/SessionsRouter';
|
||||
import { setFeature } from './features';
|
||||
import { UserController } from './Controllers/UserController';
|
||||
import { UsersRouter } from './Routers/UsersRouter';
|
||||
//import passwordReset from './passwordReset';
|
||||
import cache from './cache';
|
||||
import Config from './Config';
|
||||
import parseServerPackage from '../package.json';
|
||||
import ParsePushAdapter from './Adapters/Push/ParsePushAdapter';
|
||||
import PromiseRouter from './PromiseRouter';
|
||||
import requiredParameter from './requiredParameter';
|
||||
import { AnalyticsRouter } from './Routers/AnalyticsRouter';
|
||||
import { ClassesRouter } from './Routers/ClassesRouter';
|
||||
import { FeaturesRouter } from './Routers/FeaturesRouter';
|
||||
import { FileLoggerAdapter } from './Adapters/Logger/FileLoggerAdapter';
|
||||
import { FilesController } from './Controllers/FilesController';
|
||||
import { FilesRouter } from './Routers/FilesRouter';
|
||||
import { FunctionsRouter } from './Routers/FunctionsRouter';
|
||||
import { GCSAdapter } from './Adapters/Files/GCSAdapter';
|
||||
import { GlobalConfigRouter } from './Routers/GlobalConfigRouter';
|
||||
import { GridStoreAdapter } from './Adapters/Files/GridStoreAdapter';
|
||||
import { HooksController } from './Controllers/HooksController';
|
||||
import { HooksRouter } from './Routers/HooksRouter';
|
||||
import { IAPValidationRouter } from './Routers/IAPValidationRouter';
|
||||
import { InstallationsRouter } from './Routers/InstallationsRouter';
|
||||
import { loadAdapter } from './Adapters/AdapterLoader';
|
||||
import { LiveQueryController } from './Controllers/LiveQueryController';
|
||||
import { LoggerController } from './Controllers/LoggerController';
|
||||
import { LogsRouter } from './Routers/LogsRouter';
|
||||
import { ParseLiveQueryServer } from './LiveQuery/ParseLiveQueryServer';
|
||||
import { PublicAPIRouter } from './Routers/PublicAPIRouter';
|
||||
import { PushController } from './Controllers/PushController';
|
||||
import { PushRouter } from './Routers/PushRouter';
|
||||
import { randomString } from './cryptoUtils';
|
||||
import { RolesRouter } from './Routers/RolesRouter';
|
||||
import { S3Adapter } from './Adapters/Files/S3Adapter';
|
||||
import { SchemasRouter } from './Routers/SchemasRouter';
|
||||
import { SessionsRouter } from './Routers/SessionsRouter';
|
||||
import { setFeature } from './features';
|
||||
import { UserController } from './Controllers/UserController';
|
||||
import { UsersRouter } from './Routers/UsersRouter';
|
||||
|
||||
// Mutate the Parse object to add the Cloud Code handlers
|
||||
addParseCloud();
|
||||
@@ -108,6 +110,7 @@ function ParseServer({
|
||||
choosePassword: undefined,
|
||||
passwordResetSuccess: undefined
|
||||
},
|
||||
liveQuery = {}
|
||||
}) {
|
||||
setFeature('serverVersion', parseServerPackage.version);
|
||||
// Initialize the node client SDK automatically
|
||||
@@ -151,6 +154,7 @@ function ParseServer({
|
||||
const loggerController = new LoggerController(loggerControllerAdapter, appId);
|
||||
const hooksController = new HooksController(appId, collectionPrefix);
|
||||
const userController = new UserController(emailControllerAdapter, appId, { verifyUserEmails });
|
||||
const liveQueryController = new LiveQueryController(liveQuery);
|
||||
|
||||
|
||||
cache.apps.set(appId, {
|
||||
@@ -174,6 +178,7 @@ function ParseServer({
|
||||
appName: appName,
|
||||
publicServerURL: publicServerURL,
|
||||
customPages: customPages,
|
||||
liveQueryController: liveQueryController
|
||||
});
|
||||
|
||||
// To maintain compatibility. TODO: Remove in some version that breaks backwards compatability
|
||||
@@ -262,6 +267,10 @@ function addParseCloud() {
|
||||
global.Parse = Parse;
|
||||
}
|
||||
|
||||
ParseServer.createLiveQueryServer = function(httpServer, config) {
|
||||
return new ParseLiveQueryServer(httpServer, config);
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
ParseServer: ParseServer,
|
||||
S3Adapter: S3Adapter,
|
||||
|
||||
@@ -42,6 +42,7 @@ function del(config, auth, className, objectId) {
|
||||
return Promise.resolve().then(() => {
|
||||
if (triggers.getTrigger(className, triggers.Types.beforeDelete, config.applicationId) ||
|
||||
triggers.getTrigger(className, triggers.Types.afterDelete, config.applicationId) ||
|
||||
(config.liveQueryController && config.liveQueryController.hasLiveQuery(className)) ||
|
||||
className == '_Session') {
|
||||
return find(config, Auth.master(config), className, {objectId: objectId})
|
||||
.then((response) => {
|
||||
@@ -49,6 +50,8 @@ function del(config, auth, className, objectId) {
|
||||
response.results[0].className = className;
|
||||
cache.users.remove(response.results[0].sessionToken);
|
||||
inflatedObject = Parse.Object.fromJSON(response.results[0]);
|
||||
// Notify LiveQuery server if possible
|
||||
config.liveQueryController.onAfterDelete(inflatedObject.className, inflatedObject);
|
||||
return triggers.maybeRunTrigger(triggers.Types.beforeDelete, auth, inflatedObject, null, config.applicationId);
|
||||
}
|
||||
throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND,
|
||||
@@ -97,7 +100,8 @@ function update(config, auth, className, objectId, restObject) {
|
||||
|
||||
return Promise.resolve().then(() => {
|
||||
if (triggers.getTrigger(className, triggers.Types.beforeSave, config.applicationId) ||
|
||||
triggers.getTrigger(className, triggers.Types.afterSave, config.applicationId)) {
|
||||
triggers.getTrigger(className, triggers.Types.afterSave, config.applicationId) ||
|
||||
(config.liveQueryController && config.liveQueryController.hasLiveQuery(className))) {
|
||||
return find(config, Auth.master(config), className, {objectId: objectId});
|
||||
}
|
||||
return Promise.resolve({});
|
||||
|
||||
@@ -3,6 +3,7 @@ import cache from './cache';
|
||||
import * as middlewares from './middlewares';
|
||||
import { ParseServer } from './index';
|
||||
import { Parse } from 'parse/node';
|
||||
|
||||
var express = require('express'),
|
||||
cryptoUtils = require('./cryptoUtils');
|
||||
|
||||
|
||||
Reference in New Issue
Block a user