gristlabs_grist-core/app/client/components/Comm.ts

414 lines
17 KiB
TypeScript
Raw Normal View History

/**
* The Comm object in this module implements communication with the server. We
* communicate via request-response calls, and also receive async messages from
* the server.
*
* In this implementation, a single WebSocket is used for both purposes.
*
* Calls to the server:
* Call a method of the Comm object. The return value is a promise which will
* be fulfilled with the data object of the response, or rejected with
* an error object.
*
* Async messages from the server:
* Listen to Comm for events documented below.
*
*
* Implementation
* --------------
* Messages are serialized as JSON using types CommRequest, CommResponse, CommResponseError for
* method calls, and CommMessage for async messages from the server. These are all defined in
* app/common/CommTypes. Note that this is a matter between the client's and the server's
* communication libraries, and code outside of them should not rely on these details.
*/
import {GristWSConnection} from 'app/client/components/GristWSConnection';
import * as dispose from 'app/client/lib/dispose';
import {CommMessage, CommRequest, CommResponse, CommResponseError, ValidEvent} from 'app/common/CommTypes';
import {UserAction} from 'app/common/DocActions';
import {DocListAPI, OpenLocalDocResult} from 'app/common/DocListAPI';
import {GristServerAPI} from 'app/common/GristServerAPI';
import {getInitialDocAssignment} from 'app/common/urlUtils';
import {Events as BackboneEvents} from 'backbone';
/**
* A request that is currently being processed.
*/
export interface CommRequestInFlight {
resolve: (result: unknown) => void;
reject: (err: Error) => void;
// clientId is non-null for those requests which should not be re-sent on reconnect if
// the clientId has changed; it is null when it's safe to re-send.
clientId: string|null;
docId: string|null;
methodName: string;
requestMsg: string;
sent: boolean;
}
function isCommResponseError(msg: CommResponse | CommResponseError): msg is CommResponseError {
return Boolean(msg.error);
}
/**
* Comm object provides the interfaces to communicate with the server.
* Each method that calls to the server returns a promise for the response.
*/
export class Comm extends dispose.Disposable implements GristServerAPI, DocListAPI {
// methods defined by GristServerAPI
public logout = this._wrapMethod('logout');
public updateProfile = this._wrapMethod('updateProfile');
public getDocList = this._wrapMethod('getDocList');
public createNewDoc = this._wrapMethod('createNewDoc');
public importSampleDoc = this._wrapMethod('importSampleDoc');
public importDoc = this._wrapMethod('importDoc');
public deleteDoc = this._wrapMethod('deleteDoc');
// openDoc has special definition below
public renameDoc = this._wrapMethod('renameDoc');
public getConfig = this._wrapMethod('getConfig');
public updateConfig = this._wrapMethod('updateConfig');
public lookupEmail = this._wrapMethod('lookupEmail');
public getNewInvites = this._wrapMethod('getNewInvites');
public getLocalInvites = this._wrapMethod('getLocalInvites');
public ignoreLocalInvite = this._wrapMethod('ignoreLocalInvite');
public showItemInFolder = this._wrapMethod('showItemInFolder');
public getBasketTables = this._wrapMethod('getBasketTables');
public embedTable = this._wrapMethod('embedTable');
public reloadPlugins = this._wrapMethod('reloadPlugins');
public pendingRequests: Map<number, CommRequestInFlight>;
public nextRequestNumber: number = 0;
protected listenTo: BackboneEvents["listenTo"]; // set by Backbone
protected trigger: BackboneEvents["trigger"]; // set by Backbone
protected stopListening: BackboneEvents["stopListening"]; // set by Backbone
// This is a map from docId to the connection for the server that manages
// that docId. In classic Grist, which doesn't have fixed docIds or multiple
// servers, the key is always "null".
private _connections: Map<string|null, GristWSConnection> = new Map();
private _collectedUserActions: UserAction[] | null;
private _singleWorkerMode: boolean = getInitialDocAssignment() === null; // is this classic Grist?
private _reportError?: (err: Error) => void; // optional callback for errors
public create(reportError?: (err: Error) => void) {
this._reportError = reportError;
this.autoDisposeCallback(() => {
for (const connection of this._connections.values()) { connection.dispose(); }
this._connections.clear();
});
this.pendingRequests = new Map();
this.nextRequestNumber = 0;
// If collecting is turned on (by tests), this will be a list of UserActions sent to the server.
this._collectedUserActions = null;
}
/**
* Initialize a connection. For classic Grist, with a single server
* and mutable document identifiers, we will only ever have one
* connection, shared for all uses. For hosted Grist, with
* permanent docIds which map to potentially distinct servers, we
* have one connection per document.
*
* For classic grist, the docId passed here has no effect, and can
* be null. For hosted Grist, if the docId is null, the id will be
* read from the configuration object sent by the server. This
* allows the Comm object to be initialized at the same stage as
* it has been classically, eliminating a source of changes in timing
* that could effect old tests.
*/
public initialize(docId: string|null): GristWSConnection {
docId = docId || getInitialDocAssignment();
let connection = this._connections.get(docId);
if (connection) { return connection; }
connection = GristWSConnection.create(null);
this._connections.set(docId, connection);
this.listenTo(connection, 'serverMessage', this._onServerMessage.bind(this, docId));
this.listenTo(connection, 'connectionStatus', (message: any, status: any) => {
this.trigger('connectionStatus', message, status);
});
this.listenTo(connection, 'connectState', () => {
const isConnected = [...this._connections.values()].some(c => c.established);
this.trigger('connectState', isConnected);
});
connection.initialize(docId);
return connection;
}
// Returns a map of docId -> docWorkerUrl for existing connections, for testing.
public listConnections(): Map<string|null, string|null> {
return new Map(Array.from(this._connections, ([docId, conn]) => [docId, conn.getDocWorkerUrlOrNull()]));
}
/**
* The openDoc method is special, in that it is the first point at which
* we commit to a particular document. It is also the only method not
* committed to a document that is called in hosted Grist - all other methods
* are called via DocComm.
*/
public async openDoc(docName: string, mode?: string,
linkParameters?: Record<string, string>): Promise<OpenLocalDocResult> {
return this._makeRequest(null, docName, 'openDoc', docName, mode, linkParameters);
}
/**
* Ensure we have a connection to a docWorker serving docId, and mark it as in use by
* incrementing its useCount. This connection will not be disposed until a corresponding
* releaseDocConnection() is called.
*/
public useDocConnection(docId: string): GristWSConnection {
const connection = this._connection(docId);
connection.useCount += 1;
console.log(`Comm.useDocConnection(${docId}): useCount now ${connection.useCount}`);
return connection;
}
/**
* Remove a connection associated with a particular document. In classic grist, we skip removal,
* since all docs use the same server.
* This should be called in pair with a preceding useDocConnection() call. It decrements the
* connection's useCount, and disposes it when it's no longer in use.
*/
public releaseDocConnection(docId: string): void {
const connection = this._connections.get(docId);
if (connection) {
connection.useCount -= 1;
console.log(`Comm.releaseDocConnection(${docId}): useCount now ${connection.useCount}`);
// Dispose the connection if it is no longer in use (except in "classic grist").
if (!this._singleWorkerMode && connection.useCount <= 0) {
this.stopListening(connection);
connection.dispose();
this._connections.delete(docId);
this._rejectRequests(docId);
}
}
}
/**
* Starts or stops the collection of UserActions.
*/
public userActionsCollect(optYesNo?: boolean): void {
this._collectedUserActions = optYesNo === false ? null : [];
}
/**
* Returns all UserActions collected since collection started or since previous call.
*/
public userActionsFetchAndReset(): UserAction[] {
return this._collectedUserActions ? this._collectedUserActions.splice(0) : [];
}
/**
* Add UserActions to a list, for use in tests. Called by DocComm.
*/
public addUserActions(actions: UserAction[]) {
// Note: collecting user-actions for testing is in Comm mainly for historical reasons.
if (this._collectedUserActions) {
this._collectedUserActions.push(...actions);
}
}
/**
* Returns a url to the worker serving the specified document.
*/
public getDocWorkerUrl(docId: string|null): string {
return this._connection(docId).docWorkerUrl;
}
/**
* Returns true if there is one or more request that has not been fully processed.
*/
public hasActiveRequests(): boolean {
return this.pendingRequests.size !== 0;
}
/**
* Wait for all active requests to complete.
*/
public async waitForActiveRequests(): Promise<void> {
await Promise.all(this.pendingRequests.values());
}
/**
* Internal implementation of all the server methods. They differ only in the name of the server
* method to call, and the arguments that it expects.
*
* This is made public for DocComm's use. Regular code should not call _makeRequest directly.
*
* @param {String} clientId - If non-null, we ensure that it matches the current clientId,
* rejecting the call otherwise. It should be bound to the session's clientId for
* session-specific calls, so that we can't send requests to the wrong session. See openDoc().
* @param {String} methodName - The name of the server method to call.
* @param {...} varArgs - Other method-specific arguments to send to the server.
* @returns {Promise} Promise for the response. The server may fulfill or reject it, or it may be
* rejected in case of a disconnect.
*/
public async _makeRequest(clientId: string|null, docId: string|null,
methodName: string, ...args: any[]): Promise<any> {
const connection = this._connection(docId);
if (clientId !== null && clientId !== connection.clientId) {
console.log("Comm: Rejecting " + methodName + " for outdated clientId %s (current %s)",
clientId, connection.clientId);
return Promise.reject(new Error('Comm: outdated session'));
}
const request: CommRequest = {
reqId: this.nextRequestNumber++,
method: methodName,
args
};
console.log("Comm request #" + request.reqId + " " + methodName, request.args);
return new Promise((resolve, reject) => {
const requestMsg = JSON.stringify(request);
const sent = connection.send(requestMsg);
this.pendingRequests.set(request.reqId, {
resolve,
reject,
clientId,
docId,
methodName,
requestMsg,
sent
});
});
}
/**
* Create a connection to the specified document, or return an already open connection
* that that document. For a docId of null, any open connection will be returned, and
* an error is thrown if no connection is already open.
*/
private _connection(docId: string|null): GristWSConnection {
// for classic Grist, "docIds" are untrustworthy doc names, but on the plus side
// we only need one connections - so just replace docId with a constant.
if (this._singleWorkerMode) { docId = null; }
if (docId === null) {
if (this._connections.size > 0) {
return this._connections.values().next().value;
}
throw new Error('no connection available');
}
const connection = this._connections.get(docId);
if (!connection) {
return this.initialize(docId);
}
return connection;
}
/**
* If GristWSConnection for a docId is disposed, requests that were sent to that doc will never
* resolve. Reject them instead here.
*/
private _rejectRequests(docId: string|null) {
const error = "GristWSConnection disposed";
for (const [reqId, req] of this.pendingRequests) {
if (reqMatchesConnection(req.docId, docId)) {
console.log(`Comm: Rejecting req #${reqId} ${req.methodName}: ${error}`);
this.pendingRequests.delete(reqId);
req.reject(new Error('Comm: ' + error));
}
}
}
/**
*
* This module automatically logs any errors to the console, so callers an provide an empty
* error-handling function if logging is all they need on error.
*
* We should watch timeouts, and log something when there is no response for a while.
* There is probably no need for callers to deal with timeouts.
*/
private _onServerMessage(docId: string|null,
message: CommResponse | CommResponseError | CommMessage) {
if ('reqId' in message) {
const reqId = message.reqId;
const r = this.pendingRequests.get(reqId);
if (r) {
try {
if ('errorCode' in message && message.errorCode === 'AUTH_NO_VIEW') {
// We should only arrive here if the user had view access, and then lost it.
// We should not let the user see the document any more. Let's reload the
// page, reducing this to the problem of arriving at a document the user
// doesn't have access to, which is already handled.
console.log(`Comm response #${reqId} ${r.methodName} issued AUTH_NO_VIEW - closing`);
window.location.reload();
}
if (isCommResponseError(message)) {
const err: any = new Error(message.error);
let code = '';
if (message.errorCode) {
code = ` [${message.errorCode}]`;
err.code = message.errorCode;
}
if (message.details) {
err.details = message.details;
}
err.shouldFork = message.shouldFork;
console.log(`Comm response #${reqId} ${r.methodName} ERROR:${code} ${message.error}`
+ (message.shouldFork ? ` (should fork)` : ''));
this._reportError?.(err);
r.reject(err);
} else {
console.log(`Comm response #${reqId} ${r.methodName} OK`);
r.resolve(message.data);
}
} finally {
this.pendingRequests.delete(reqId);
}
} else {
console.log("Comm: Response to unknown reqId " + reqId);
}
} else {
if (message.type === 'clientConnect') {
// Reject or re-send any pending requests as appropriate in the order in which they were
// added to the pendingRequests map.
for (const [id, req] of this.pendingRequests) {
if (reqMatchesConnection(req.docId, docId)) {
this._resendPendingRequest(id, req);
}
}
}
// Another asynchronous message that's not a response. Broadcast it as an event.
if (ValidEvent.guard(message.type)) {
console.log("Comm: Triggering event " + message.type);
this.trigger(message.type, message);
} else {
console.log("Comm: Server message of unknown type " + message.type);
}
}
}
private _resendPendingRequest(reqId: number, r: CommRequestInFlight) {
let error = null;
const connection = this._connection(r.docId);
if (r.sent) {
// If we sent a request, and reconnected before getting a response, we don't know what
// happened. The safer choice is to reject the request.
error = "interrupted by reconnect";
} else if (r.clientId !== null && r.clientId !== connection.clientId) {
// If we are waiting to send this request for a particular clientId, but clientId changed.
error = "pending with outdated clientId";
} else {
// Waiting to send the request, and clientId is fine: go ahead and send it.
r.sent = connection.send(r.requestMsg);
}
if (error) {
console.log("Comm: Rejecting req #" + reqId + " " + r.methodName + ": " + error);
r.reject(new Error('Comm: ' + error));
this.pendingRequests.delete(reqId);
}
}
private _wrapMethod<Name extends keyof GristServerAPI>(name: Name): GristServerAPI[Name] {
return this._makeRequest.bind(this, null, null, name);
}
}
Object.assign(Comm.prototype, BackboneEvents);
function reqMatchesConnection(reqDocId: string|null, connDocId: string|null) {
return reqDocId === connDocId || !reqDocId || !connDocId;
}