gristlabs_grist-core/app/server/lib/Comm.js
Paul Fitzpatrick 5ef889addd (core) move home server into core
Summary: This moves enough server material into core to run a home server.  The data engine is not yet incorporated (though in manual testing it works when ported).

Test Plan: existing tests pass

Reviewers: dsagal

Reviewed By: dsagal

Differential Revision: https://phab.getgrist.com/D2552
2020-07-21 20:39:10 -04:00

396 lines
15 KiB
JavaScript

/**
* The server's Comm object implements communication with the client.
*
* The server receives requests, to which it sends a response (or an error). The server can
* also send asynchronous messages to the client. Available methods should be provided via
* comm.registerMethods().
*
* To send async messages, you may call broadcastMessage() or sendDocMessage().
*
* In practice, requests which modify the document are done via UserActions.js, and result in an
* asynchronous message updating the document (which is sent to all clients who have the document
* open), and the response could return some useful value, but does not have to.
*
* See app/client/components/Comm.js for other details of the communication protocol.
*
*
* Currently, this module also implements the concept of a "Client". A Client corresponds to a
* browser window, and should persist across brief disconnects. A Client has a 'clientId'
* property, which uniquely identifies a client within the currently running server. Method
* registered with Comm always receive a Client object as the first argument.
*
* In the future, we may want to have a separate Client.js file with documentation of the various
* properties that may be associated with a client.
*
* Note that users of this module should never use the websocket of a Client, since that's an
* implementation detail of Comm.js.
*/
/**
* Event for DocList changes.
* @event docListAction Emitted when the document list changes in any way.
* @property {Array[String]} [addDocs] Array of names of documents to add to the docList.
* @property {Array[String]} [removeDocs] Array of names of documents that got removed.
* @property {Array[String]} [renameDocs] Array of [oldName, newName] pairs for renamed docs.
* @property {Array[String]} [addInvites] Array of document invite names to add.
* @property {Array[String]} [removeInvites] Array of documents invite names to remove.
*/
var events = require('events');
var url = require('url');
var util = require('util');
var ws = require('ws');
var Promise = require('bluebird');
var log = require('./log');
var gutil = require('app/common/gutil');
const {parseFirstUrlPart} = require('app/common/gristUrls');
const version = require('app/common/version');
const {Client} = require('./Client');
// Bluebird promisification, to be able to use e.g. websocket.sendAsync method.
Promise.promisifyAll(ws.prototype);
/// How long the client state persists after a disconnect.
var clientRemovalTimeoutMsDefault = 300 * 1000; // 300s = 5 minutes.
var clientRemovalTimeoutMs = clientRemovalTimeoutMsDefault;
/**
* Constructs a Comm object.
* @param {Object} server - The HTTP server.
* @param {Object} options.sessions - A collection of sessions
* @param {Object} options.settings - The config object containing instance settings
* including features.
* @param {Object} options.instanceManager - Instance manager, giving access to InstanceStore
* and per-instance objects. If null, HubUserClient will not be created.
* @param {Object} options.hosts - Hosts object from extractOrg.ts. if set, we use
* hosts.getOrgInfo(req) to extract an organization from a (possibly versioned) url.
*/
function Comm(server, options) {
events.EventEmitter.call(this);
this._server = server;
this._httpsServer = options.httpsServer;
this.wss = this._startServer();
// Maps client IDs to websocket objects.
this._clients = {}; // Maps clientIds to Client objects.
this.clientList = []; // List of all active Clients, ordered by clientId.
// Maps sessionIds to LoginSession objects.
this.sessions = options.sessions;
this._settings = options.settings;
this._instanceManager = options.instanceManager;
this._hosts = options.hosts;
// This maps method names to their implementation.
this.methods = {};
// For testing, we need a way to override the server version reported.
// For upgrading, we use this to set the server version for a defunct server
// to "dead" so that a client will know that it needs to periodically recheck
// for a valid server.
this._serverVersion = null;
}
util.inherits(Comm, events.EventEmitter);
/**
* Registers server methods.
* @param {Object[String:Function]} Mapping of method name to their implementations. All methods
* receive the client as the first argument, and the arguments from the request.
*/
Comm.prototype.registerMethods = function(serverMethods) {
// Wrap methods to translate return values and exceptions to promises.
for (var methodName in serverMethods) {
this.methods[methodName] = Promise.method(serverMethods[methodName]);
}
};
/**
* Returns the Client object associated with the given clientId, or throws an Error if not found.
*/
Comm.prototype.getClient = function(clientId) {
const client = this._clients[clientId];
if (!client) { throw new Error('Unrecognized clientId'); }
return client;
};
/**
* Returns a LoginSession object with the given session id from the list of sessions,
* or adds a new one and returns that.
*/
Comm.prototype.getOrCreateSession = function(sid, req) {
// LoginSessions are specific to a session id / org combination.
const org = req.org || "";
return this.sessions.getOrCreateLoginSession(sid, org, this, this._instanceManager);
};
/**
* Returns the sessionId from the signed grist cookie.
*/
Comm.prototype.getSessionIdFromCookie = function(gristCookie) {
return this.sessions.getSessionIdFromCookie(gristCookie);
};
/**
* Broadcasts an app-level message to all clients.
* @param {String} type - Type of message, e.g. 'docListAction'.
* @param {Object} messageData - The data for this type of message.
*/
Comm.prototype.broadcastMessage = function(type, messageData) {
return this._broadcastMessage(type, messageData, this.clientList);
};
Comm.prototype._broadcastMessage = function(type, data, clients) {
clients.forEach(client => client.sendMessage({type, data}));
};
/**
* Sends a per-doc message to the given client.
* @param {Object} client - The client object, as passed to all per-doc methods.
* @param {Number} docFD - The document's file descriptor in the given client.
* @param {String} type - The type of the message, e.g. 'docUserAction'.
* @param {Object} messageData - The data for this type of message.
* @param {Boolean} fromSelf - Whether `client` is the originator of this message.
*/
Comm.sendDocMessage = function(client, docFD, type, data, fromSelf = undefined) {
client.sendMessage({type, docFD, data, fromSelf});
};
/**
* Processes a new websocket connection.
* TODO: Currently it always creates a new client, but in the future the creation of a client
* should possibly be delayed until some hello message, so that a previous client may reconnect
* without losing state.
*/
Comm.prototype._onWebSocketConnection = async function(websocket, req) {
log.info("Comm: Got WebSocket connection: %s", req.url);
if (this._hosts) {
// DocWorker ID (/dw/) and version tag (/v/) may be present in this request but are not
// needed. addOrgInfo assumes req.url starts with /o/ if present.
req.url = parseFirstUrlPart('dw', req.url).path;
req.url = parseFirstUrlPart('v', req.url).path;
await this._hosts.addOrgInfo(req);
}
websocket.on('error', this.onError.bind(this, websocket));
websocket.on('close', this.onClose.bind(this, websocket));
// message handler is added later, after we create a Client but before any async operations
// Parse the cookie in the request to get the sessionId.
var sessionId = this.sessions.getSessionIdFromRequest(req);
var urlObj = url.parse(req.url, true);
var existingClientId = urlObj.query.clientId;
var browserSettings = urlObj.query.browserSettings ? JSON.parse(urlObj.query.browserSettings) : {};
var newClient = (parseInt(urlObj.query.newClient, 10) === 1);
const counter = urlObj.query.counter;
// Associate an ID with each websocket, reusing the supplied one if it's valid.
var client;
if (existingClientId && this._clients.hasOwnProperty(existingClientId) &&
!this._clients[existingClientId]._websocket &&
await this._clients[existingClientId].isAuthorized()) {
client = this._clients[existingClientId];
client.setCounter(counter);
log.info("Comm %s: existing client reconnected (%d missed messages)", client,
client._missedMessages.length);
if (client._destroyTimer) {
log.warn("Comm %s: clearing scheduled destruction", client);
clearTimeout(client._destroyTimer);
client._destroyTimer = null;
}
if (newClient) {
// If this isn't a reconnect, then we assume that the browser client lost its state (e.g.
// reloaded the page), so we treat it as a disconnect followed by a new connection to the
// same state. At the moment, this only means that we close all docs.
if (client._missedMessages.length) {
log.warn("Comm %s: clearing missed messages for new client", client);
}
client._missedMessages.length = 0;
client.closeAllDocs();
}
client.setConnection(websocket, req.headers.host, browserSettings);
} else {
client = new Client(this, this.methods, req.headers.host);
client.setCounter(counter);
client.setConnection(websocket, req.headers.host, browserSettings);
this._clients[client.clientId] = client;
this.clientList.push(client);
log.info("Comm %s: new client", client);
}
websocket._commClient = client;
websocket.clientId = client.clientId;
// Add a Session object to the client.
log.info(`Comm ${client}: using session ${sessionId}`);
const loginSession = this.getOrCreateSession(sessionId, req);
client.setSession(loginSession);
// Delegate message handling to the client
websocket.on('message', client.onMessage.bind(client));
loginSession.getSessionProfile()
.then((profile) => {
log.debug(`Comm ${client}: sending clientConnect with ` +
`${client._missedMessages.length} missed messages`);
// Don't use sendMessage here, since we don't want to queue up this message on failure.
client.setOrg(req.org || "");
client.setProfile(profile);
const clientConnectMsg = {
type: 'clientConnect',
clientId: client.clientId,
serverVersion: this._serverVersion || version.gitcommit,
missedMessages: client._missedMessages.slice(0),
settings: this._settings,
profile,
};
// If reconnecting a client with missed messages, clear them now.
client._missedMessages.length = 0;
return websocket.sendAsync(JSON.stringify(clientConnectMsg))
// A heavy-handed fix to T396, since 'clientConnect' is sometimes not seen in the browser,
// (seemingly when the 'message' event is triggered before 'open' on the native WebSocket.)
// See also my report at https://stackoverflow.com/a/48411315/328565
.delay(250).then(() => {
if (client._destroyed) { return; } // object is already closed - don't show messages
if (websocket.readyState === websocket.OPEN) {
return websocket.sendAsync(JSON.stringify(Object.assign(clientConnectMsg, {dup: true})));
} else {
log.debug(`Comm ${client}: websocket closed right after clientConnect`);
}
});
})
.then(() => {
if (!client._destroyed) { log.debug(`Comm ${client}: clientConnect sent successfully`); }
})
.catch(err => {
log.error(`Comm ${client}: failed to prepare or send clientConnect:`, err);
});
};
/**
* Processes an error on the websocket.
*/
Comm.prototype.onError = function(websocket, err) {
log.warn("Comm cid %s: onError", websocket.clientId, err);
// TODO Make sure that this is followed by onClose when the connection is lost.
};
/**
* Processes the closing of a websocket.
*/
Comm.prototype.onClose = function(websocket) {
log.info("Comm cid %s: onClose", websocket.clientId);
websocket.removeAllListeners();
var client = websocket._commClient;
if (client) {
// Remove all references to the websocket.
client._websocket = null;
// Schedule the client to be destroyed after a timeout. The timer gets cleared if the same
// client reconnects in the interim.
if (client._destroyTimer) {
log.warn("Comm cid %s: clearing previously scheduled destruction", websocket.clientId);
clearTimeout(client._destroyTimer);
}
log.warn("Comm cid %s: will discard client in %s sec",
websocket.clientId, clientRemovalTimeoutMs / 1000);
client._destroyTimer = setTimeout(this._destroyClient.bind(this, client),
clientRemovalTimeoutMs);
}
};
Comm.prototype._startServer = function() {
const servers = [this._server];
if (this._httpsServer) { servers.push(this._httpsServer); }
const wss = [];
for (const server of servers) {
const wssi = new ws.Server({server});
wssi.on('connection', async (websocket, req) => {
try {
await this._onWebSocketConnection(websocket, req);
} catch (e) {
log.error("Comm connection for %s threw exception: %s", req.url, e.message);
websocket.removeAllListeners();
websocket.terminate(); // close() is inadequate when ws routed via loadbalancer
}
});
wss.push(wssi);
}
return wss;
};
Comm.prototype.testServerShutdown = async function() {
if (this.wss) {
for (const wssi of this.wss) {
await Promise.fromCallback((cb) => wssi.close(cb));
}
this.wss = null;
}
};
Comm.prototype.testServerRestart = async function() {
await this.testServerShutdown();
this.wss = this._startServer();
};
/**
* Destroy all clients, forcing reconnections.
*/
Comm.prototype.destroyAllClients = function() {
// Iterate over all clients. Take a copy of the list of clients since it will be changing
// during the loop as we remove them one by one.
for (const client of this.clientList.slice()) {
client.interruptConnection();
this._destroyClient(client);
}
};
/**
* Destroys a client. If the same browser window reconnects later, it will get a new Client
* object and clientId.
*/
Comm.prototype._destroyClient = function(client) {
log.info("Comm %s: client gone", client);
client.closeAllDocs();
if (client._destroyTimer) {
clearTimeout(client._destroyTimer);
}
delete this._clients[client.clientId];
gutil.arrayRemove(this.clientList, client);
client.destroy();
};
/**
* Override the version string Comm will report to clients.
* Call with null to reset the override.
*
*/
Comm.prototype.setServerVersion = function (serverVersion) {
this._serverVersion = serverVersion;
};
/**
* Mark the server as active or inactive. If inactive, any client that manages to
* connect to it will read a server version of "dead".
*/
Comm.prototype.setServerActivation = function (active) {
this._serverVersion = active ? null : 'dead';
};
/**
* Set how long clients persist on the server after disconnection. Call with
* 0 to return to the default.
*/
Comm.prototype.testSetClientPersistence = function (ttlMs) {
clientRemovalTimeoutMs = ttlMs || clientRemovalTimeoutMsDefault;
}
module.exports = Comm;