/** * 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 * as log from 'app/client/lib/log'; import {CommRequest, CommResponse, CommResponseBase, 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; 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 = 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 { 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): Promise { 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; log.debug(`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; log.debug(`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 { 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 { const connection = this._connection(docId); if (clientId !== null && clientId !== connection.clientId) { log.warn("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 }; log.debug("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)) { log.warn(`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: CommResponseBase) { 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. log.warn(`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; log.warn(`Comm response #${reqId} ${r.methodName} ERROR:${code} ${message.error}` + (message.shouldFork ? ` (should fork)` : '')); this._reportError?.(err); r.reject(err); } else { log.debug(`Comm response #${reqId} ${r.methodName} OK`); r.resolve(message.data); } } finally { this.pendingRequests.delete(reqId); } } else { log.warn("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)) { log.debug("Comm: Triggering event " + message.type); this.trigger(message.type, message); } else { log.warn("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) { log.warn("Comm: Rejecting req #" + reqId + " " + r.methodName + ": " + error); r.reject(new Error('Comm: ' + error)); this.pendingRequests.delete(reqId); } } private _wrapMethod(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; }