Add LiveQuery

This commit is contained in:
wangmengyan95
2016-03-10 14:27:00 -08:00
parent cf3606246f
commit 555e25bf33
33 changed files with 3580 additions and 48 deletions

104
src/LiveQuery/Client.js Normal file
View 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
}

View 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
View 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
View 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;

View 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
}

View 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
}

View 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
}

View 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
View 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;

View 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
}

View 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;

View 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
}

View 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
}

View 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;