import {Comm, CommMessage} from 'app/client/components/Comm'; import {reportError, UserError} from 'app/client/models/errors'; import {Notifier} from 'app/client/models/NotifyModel'; import {ActionGroup} from 'app/common/ActionGroup'; import {ActiveDocAPI, ApplyUAOptions, ApplyUAResult} from 'app/common/ActiveDocAPI'; import {DocAction, UserAction} from 'app/common/DocActions'; import {OpenLocalDocResult} from 'app/common/DocListAPI'; import {docUrl} from 'app/common/urlUtils'; import {Events as BackboneEvents} from 'backbone'; import {Disposable, Emitter} from 'grainjs'; // tslint:disable:no-console export interface DocUserAction extends CommMessage { docFD: number; fromSelf?: boolean; data: { docActions: DocAction[]; actionGroup: ActionGroup; }; } const SLOW_NOTIFICATION_TIMEOUT_MS = 1000; // applies to user actions only /** * The type of data.methods object created by openDoc() in app/client/components/Comm.js. * This is used in much of client-side code, and exposed firstly as GristDoc.docComm. */ export class DocComm extends Disposable implements ActiveDocAPI { // These are all the methods of ActiveDocAPI. Listing them explicitly lets typescript verify // that we haven't missed any. // closeDoc has a special implementation below. public fetchTable = this._wrapMethod("fetchTable"); public fetchTableSchema = this._wrapMethod("fetchTableSchema"); public useQuerySet = this._wrapMethod("useQuerySet"); public disposeQuerySet = this._wrapMethod("disposeQuerySet"); // applyUserActions has a special implementation below. public applyUserActionsById = this._wrapMethod("applyUserActionsById"); public importFiles = this._wrapMethod("importFiles"); public finishImportFiles = this._wrapMethod("finishImportFiles"); public cancelImportFiles = this._wrapMethod("cancelImportFiles"); public addAttachments = this._wrapMethod("addAttachments"); public findColFromValues = this._wrapMethod("findColFromValues"); public getFormulaError = this._wrapMethod("getFormulaError"); public fetchURL = this._wrapMethod("fetchURL"); public autocomplete = this._wrapMethod("autocomplete"); public shareDoc = this._wrapMethod("shareDoc"); public removeInstanceFromDoc = this._wrapMethod("removeInstanceFromDoc"); public getActionSummaries = this._wrapMethod("getActionSummaries"); public startBundleUserActions = this._wrapMethod("startBundleUserActions"); public stopBundleUserActions = this._wrapMethod("stopBundleUserActions"); public forwardPluginRpc = this._wrapMethod("forwardPluginRpc"); public reloadPlugins = this._wrapMethod("reloadPlugins"); public reloadDoc = this._wrapMethod("reloadDoc"); public fork = this._wrapMethod("fork"); public changeUrlIdEmitter = this.autoDispose(new Emitter()); // We save the clientId that was used when opening the doc. If it changes (e.g. reconnecting to // another server), it would be incorrect to use the new clientId without re-opening the doc // (which is handled by App.ts). This way, Comm can protect against mismatched clientIds. private _clientId: string; private _docFD: number; private _forkPromise: Promise|null = null; private _isClosed: boolean = false; private listenTo: BackboneEvents['listenTo']; // set by Backbone constructor(private _comm: Comm, openResponse: OpenLocalDocResult, private _docId: string, private _notifier: Notifier) { super(); this._setOpenResponse(openResponse); // If *this* doc is shutdown forcibly (e.g. via reloadDoc call), mark it as closed, so we // don't attempt to close it again. this.listenTo(_comm, 'docShutdown', (m: CommMessage) => { if (this.isActionFromThisDoc(m)) { this._isClosed = true; } }); this.onDispose(() => this._shutdown()); } // Returns the URL params that identifying this open document to the DocWorker // (used e.g. in attachment and download URLs). public getUrlParams(): {clientId: string, docFD: number} { return { clientId: this._clientId, docFD: this._docFD }; } // Completes a path by adding the correct worker host and prefix for this document. // E.g. "/uploads" becomes "https://host.name/v/ver/o/org/uploads" public docUrl(path: string) { return docUrl(this.docWorkerUrl, path); } // Returns a base url to the worker serving the current document, e.g. // "https://host.name/v/ver/" public get docWorkerUrl() { return this._comm.getDocWorkerUrl(this._docId); } // Returns whether a message received by this Comm object is for the current doc. public isActionFromThisDoc(message: CommMessage): boolean { return message.docFD === this._docFD; } /** * Overrides applyUserActions() method to also add the UserActions to a list, for use in tests. */ public applyUserActions(actions: UserAction[], options?: ApplyUAOptions): Promise { this._comm.addUserActions(actions); return this._callMethod('applyUserActions', actions, options); } /** * Overrides closeDoc() method to call to Comm directly, without triggering forking logic. * This is important in particular since it may be called while forking. */ public closeDoc(): Promise { return this._callDocMethod('closeDoc'); } /** * Forks the document, making sure the url gets updated, and holding any actions * until the fork is complete. If a fork has already been started/completed, this * does nothing. */ public async forkAndUpdateUrl(): Promise { await (this._forkPromise || (this._forkPromise = this._doForkDoc())); } // Clean up connection after closing doc. private async _shutdown() { console.log(`DocComm: shutdown clientId ${this._clientId} docFD ${this._docFD}`); try { // Close the document to unsubscribe from further updates on it. if (!this._isClosed) { await this.closeDoc(); } } catch (err) { console.warn(`DocComm: closeDoc failed: ${err}`); } finally { if (!this._comm.isDisposed()) { this._comm.releaseDocConnection(this._docId); } } } /** * Store important information from the response to openDoc, and * ensure we have a connection to a docWorker for the document * identified by the current docId. the caller of _setOpenResponse * should call _releaseDocConnection for any previous docId. */ private _setOpenResponse(openResponse: OpenLocalDocResult) { this._docFD = openResponse.docFD; this._clientId = openResponse.clientId; this._comm.useDocConnection(this._docId); } private _wrapMethod(name: Name): ActiveDocAPI[Name] { return this._callMethod.bind(this, name); } private async _callMethod(name: keyof ActiveDocAPI, ...args: any[]): Promise { return this._notifier.slowNotification(this._doCallMethod(name, ...args), SLOW_NOTIFICATION_TIMEOUT_MS); } private async _doCallMethod(name: keyof ActiveDocAPI, ...args: any[]): Promise { if (this._forkPromise) { // If a fork is pending or has finished, call the method after waiting for it. // (If we've gone through a fork, we will not consider forking again.) await this._forkPromise; return this._callDocMethod(name, ...args); } try { return await this._callDocMethod(name, ...args); } catch (err) { // TODO should be the suggested fork id and fork user. if (err.shouldFork) { // If the server suggests to fork, do it now, or wait for the fork already pending. await this.forkAndUpdateUrl(); return this._callDocMethod(name, ...args); } throw err; } } private _callDocMethod(name: keyof ActiveDocAPI, ...args: any[]): Promise { return this._comm._makeRequest(this._clientId, this._docId, name, this._docFD, ...args); } private async _doForkDoc(): Promise { reportError(new UserError('Preparing your copy...', {key: 'forking'})); const {urlId, docId} = await this.fork(); const openResponse = await this._comm.openDoc(docId); // Close the old doc and release the old connection. Note that the closeDoc call is expected // to fail, since we close the websocket immediately after it. So let it fail silently. this.closeDoc().catch(() => null); this._comm.releaseDocConnection(this._docId); this._docId = docId; this._setOpenResponse(openResponse); this.changeUrlIdEmitter.emit(urlId); reportError(new UserError('You are now editing your own copy', {key: 'forking'})); } } Object.assign(DocComm.prototype, BackboneEvents);