mirror of
https://github.com/gristlabs/grist-core.git
synced 2024-10-27 20:44:07 +00:00
205 lines
8.4 KiB
TypeScript
205 lines
8.4 KiB
TypeScript
|
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<void>|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<ApplyUAResult> {
|
||
|
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<void> {
|
||
|
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<void> {
|
||
|
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 extends keyof ActiveDocAPI>(name: Name): ActiveDocAPI[Name] {
|
||
|
return this._callMethod.bind(this, name);
|
||
|
}
|
||
|
|
||
|
private async _callMethod(name: keyof ActiveDocAPI, ...args: any[]): Promise<any> {
|
||
|
return this._notifier.slowNotification(this._doCallMethod(name, ...args), SLOW_NOTIFICATION_TIMEOUT_MS);
|
||
|
}
|
||
|
|
||
|
private async _doCallMethod(name: keyof ActiveDocAPI, ...args: any[]): Promise<any> {
|
||
|
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<any> {
|
||
|
return this._comm._makeRequest(this._clientId, this._docId, name, this._docFD, ...args);
|
||
|
}
|
||
|
|
||
|
private async _doForkDoc(): Promise<void> {
|
||
|
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);
|