delta-component
Version:
embeddable react component
356 lines (321 loc) • 14.2 kB
JavaScript
const controller = require('./controllers/incomingMessages');
const document = require('./document');
const log = require('./../../common/logger').log;
const confTransport = require('../../common/logger').confTransport;
const deserialize = require('../../common/crdt/serialize').deserialize;
const io = require('socket.io-client');
const cursors = require('./logic/cursors');
const documentId = require('../../common/logic/documentId');
const UserParams = require('./../../common/messages/user/params').UserParams;
const uniqid = require('uniqid');
const generateSiteId = require('./logic/siteId').generateSiteId;
const opLogic = require('../../common/logic/operation');
// WebsocketTransport is singleton
let instance = null;
class WebsocketTransport {
/**
* Точка входа для UI в приложение:
* let transport = new Transport('i_am_epad'); // началась установка соединения
* .... где-то в UI:
* transport.getDocument('epad/page/10').then(
* document => { подписаться на события, отрендерить },
* error => { нарисовать ошибку }
* )
* .... где-то в UI:
* transport.createDocument('epad/page/10', 'bazinga!').then(
* document => { подписаться на события, отрендерить },
* error => { нарисовать ошибку }
* )
*/
constructor(serviceSlug, host, docId) {
/**
* @param serviceSlug просто чтобы в логах различать кто сейчас подключился. На логику не влияет
* @param host: "https://delta.yandex-team.ru" - некорректно указание аргумент приведет к странным ошибкам
* `${window.location.protocol}//${global.location.host}` - можно указывать так
*/
this.serviceSlug = serviceSlug;
this.log = log;
this.websocket = null;
this.handledDocuments = {};
this.incomingMessages = null; // обработчик входящих сообщений
this._socketHost = host;
this._siteId = null;
this.currentPerson = null;
// TODO: Поддержать работу нескольких документов через одно соединение
//
// 1) Сейчас в нужный инстанс с документом мы проксируем через nginx,
// поэтому на каждый документ придется держать отдельное соединение.
//
// 2) Кроме того, временно documentString явно передается в transport,
// хотя transport поддерживает работу с несколькими документами.
this._docId = docId;
this.standalone = this._standalone();
this.establishConnection();
confTransport(this);
}
static getInstance(serviceSlug, host, docId) {
if(instance === null) {
instance = new WebsocketTransport(serviceSlug, host, docId);
}
return instance;
}
establishConnection() {
let that = this;
const wsParams = {
//reconnectionAttempts: 10,
query: {
'iam': this.serviceSlug
},
// transports: ['websocket']
};
if (!this.standalone && this._docId != null) {
wsParams.path = `/get/${this._docId}`;
}
let websocket = io.connect(this._socketHost, wsParams);
websocket.on('connect', () => {
// TODO: разрешить изменения в документах, перезапросить существующие документы, сообщить об этом в UI
const params = new UserParams();
if (this.currentPerson !== null) {
params.name = this.currentPerson.name;
}
websocket.emit('whoami', this.mySiteId(), params.toClientFormat(), (me) => {
// TODO: siteId выдавать на инстанс ноды, к которой подключен, а не на вкладку браузера
that._authenticated(new cursors.Participant(me.siteId, me.uid, me.name))
});
});
websocket.on('disconnect', () => {
// TODO: блокировать контроллер и транспорт до успешного ответа на whoami
const docsTotal = Object.keys(this.handledDocuments).length;
this.log.warn(`I lost connection, locking ${docsTotal} documents`);
for(let documentString in this.handledDocuments) {
let document = this.handledDocuments[documentString];
if(!document.isLocked()) document.lock();
}
});
this.websocket = websocket;
}
getHandledDocument(documentString) {
return this.handledDocuments[documentString];
}
mySiteId() {
if (this._siteId === null) {
this._siteId = generateSiteId(uniqid());
}
return this._siteId;
}
_authenticated(me) {
this.currentPerson = me;
this.log.info(`connected, i am ${me}`);
if(this.incomingMessages === null) {
this.incomingMessages = controller.controllerProducer(
this.websocket, me, this
);
}
// Восстановить все заблокированные документы
for(let documentString in this.handledDocuments) {
this._refreshDocument(this.handledDocuments[documentString], me);
}
}
_refreshDocument(document) {
const log = this.log;
const currentPerson = this.currentPerson;
log.debug(`Restoring ${document}`);
this._getTreedocFromNetwork(document.documentString).then(
treedoc => {
// TODO: поменять siteId
document.generateTreedocClient(treedoc, currentPerson);
document.somewhenLoadCurrentParticipants(currentPerson);
document.textChanged(document.getText());
document.unlock();
log.info(`${document} is restored`);
},
error => {
log.error(`could not restore document ${document.toString()} after network failure`, error)
}
);
}
getParticipants(documentString) {
/**
* Список участников документа
*/
// бросить исключение, если documentString невалиден
this._validateDocumentString(documentString);
let that = this;
return new Promise(function (resolve, reject) {
that.websocket.emit('GetParticipants', documentString, (participantsOrError) => {
if(participantsOrError.error) {
reject(participantsOrError);
} else {
resolve(participantsOrError.people.map((person) => {
return new cursors.Participant(person.siteId, person.uid, person.name);
}));
}
})
})
}
_validateDocumentString(documentString) {
return documentId.validateDocumentString(documentString);
}
getDocument(documentString) {
/**
* Подключиться к существующему документу.
*
* @type {WebsocketTransport}
*/
// бросить исключение, если documentString невалиден
this._validateDocumentString(documentString);
// убедиться что вебсокет подключился
let that = this;
return new Promise(function (resolve, reject) {
that.log.debug(`getting ${documentString}`);
if(documentString in that.handledDocuments) {
resolve(that.handledDocuments[documentString]);
return;
}
that._getTreedocFromNetwork(documentString).then(
treedoc => {
let doc = new document.Document(
documentString,
treedoc,
that,
that.currentPerson
);
that.handledDocuments[documentString] = doc;
resolve(doc);
},
error => reject(error)
)
})
}
_getTreedocFromNetwork(documentString) {
let that = this;
return new Promise(function (resolve, reject) {
that.websocket.emit('JoinDocument', documentString, (documentOrError) => {
if(documentOrError.error) {
reject(documentOrError);
} else {
resolve(deserialize(documentOrError.treedoc));
}
});
});
}
_standalone() {
return typeof window !== 'undefined' && window.document.cookie.indexOf('delta-standalone=1') > -1;
}
createDocument(documentString, initialText) {
/**
* Создать документ.
* Если документ уже создан - будет ошибка.
* Вы сами должны отслеживать что документа не существует.
*
* @type {WebsocketTransport}
*/
// бросить исключение, если documentString невалиден
this._validateDocumentString(documentString);
// убедиться что вебсокет подключился
let that = this;
return new Promise(function (resolve, reject) {
that.log.debug(`creating document "${documentString}"`);
// if(documentString in that.handledDocuments) {
// reject( сериализовать ExistsError );
// return;
// }
// TODO: вернуть ошибку
that.websocket.emit('CreateDocument', documentString, initialText, (treedocOrError) => {
if(treedocOrError.error) {
reject(treedocOrError);
} else {
let doc = new document.Document(
documentString,
deserialize(treedocOrError.treedoc),
that,
that.currentPerson
);
that.handledDocuments[documentString] = doc;
that.log.info(`created document "${documentString}"`);
resolve(doc);
}
});
})
}
leaveDocument(documentString){
// убедиться что вебсокет подключился
let that = this;
return new Promise(function (resolve) {
that.websocket.emit('LeaveDocument', documentString);
delete that.handledDocuments[documentString];
log.info(`Left document ${documentString}`);
resolve(true);
})
}
viewerJoined(documentString, siteId, uid, displayName) {
siteId = parseInt(siteId);
if (documentString in this.handledDocuments) {
const person = new cursors.Participant(siteId, uid, displayName);
log.info(`${person} joined document ${documentString}`);
this.handledDocuments[documentString].joined(person);
} else {
this.log.error(`Unhandled document "${documentString}"`)
}
}
viewerLeft(documentString, siteId, uid, displayName) {
siteId = parseInt(siteId);
if (documentString in this.handledDocuments) {
this.handledDocuments[documentString].left(siteId);
} else {
this.log.error(`Unhandled document "${documentString}"`)
}
}
changeUserParams({displayName=null}) {
const params = new UserParams();
params.name = displayName;
params.validate();
this.currentPerson.name = displayName;
this.websocket.emit('ChangeParams', params.toClientFormat());
}
sendOperations(documentString, ops) {
/**
* Отправить локальные операции.
*/
this.websocket.emit('ApplyOps', documentString, ops);
}
getTimelineOfChanges(documentString) {
let that = this;
return new Promise(function (resolve, reject) {
that.websocket.emit('TimelineOfChanges', documentString, (timelineOrError) => {
if (timelineOrError.error) {
reject(timelineOrError);
} else {
resolve(timelineOrError.epochs.map(
([treedocJson, ops]) => [
treedocJson,
ops.map(({op, meta}) => {
meta.created_at = new Date(meta.created_at);
return {op: opLogic.deserialize(op), meta: meta}
})
]
));
}
});
});
}
userChangedParams(siteId, newParams) {
for (const documentString of Object.keys(this.handledDocuments)) {
const document = this.handledDocuments[documentString];
const participant = document.viewersMap.getBySiteId(siteId);
if (participant !== null) {
participant.displayName = newParams.name;
document.participantsChanged();
}
}
}
clientLog(documentString, message, context) {
this.websocket.emit('ClientLog', documentString, message, context);
}
}
const transportFabric = WebsocketTransport.getInstance;
module.exports = {
transportFabric,
WebsocketTransport,
};
;