UNPKG

@only-chat/client

Version:
887 lines 36.2 kB
export var TransportState; (function (TransportState) { /** The connection is not yet open. */ TransportState[TransportState["CONNECTING"] = 0] = "CONNECTING"; /** The connection is open and ready to communicate. */ TransportState[TransportState["OPEN"] = 1] = "OPEN"; /** The connection is in the process of closing. */ TransportState[TransportState["CLOSING"] = 2] = "CLOSING"; /** The connection is closed. */ TransportState[TransportState["CLOSED"] = 3] = "CLOSED"; })(TransportState || (TransportState = {})); var StopStatus; (function (StopStatus) { StopStatus["Deleted"] = "Deleted"; StopStatus["FailedConnect"] = "Failed connect"; StopStatus["FailedJoin"] = "Failed join"; StopStatus["FailedProcessConversationRequest"] = "Failed processing conversation request"; StopStatus["FailedProcessMessage"] = "Failed processing message"; StopStatus["FailedProcessRequest"] = "Failed processing request"; StopStatus["FailedWatch"] = "Failed watch"; StopStatus["Removed"] = "Removed"; StopStatus["RemovedByPrticipant"] = "Removed by new participant"; StopStatus["Stopped"] = "Stopped"; })(StopStatus || (StopStatus = {})); export var WsClientState; (function (WsClientState) { WsClientState[WsClientState["None"] = 0] = "None"; WsClientState[WsClientState["Authenticated"] = 1] = "Authenticated"; WsClientState[WsClientState["Connected"] = 2] = "Connected"; WsClientState[WsClientState["Session"] = 3] = "Session"; WsClientState[WsClientState["WatchSession"] = 4] = "WatchSession"; WsClientState[WsClientState["Disconnected"] = 255] = "Disconnected"; })(WsClientState || (WsClientState = {})); const defaultSize = 100; const sendStates = [WsClientState.Connected, WsClientState.Session, WsClientState.WatchSession]; const connectedRequestTypes = ['close', 'delete', 'find', 'load', 'update']; const types = ['file', 'text']; let instanceId = undefined; let logger; let queue; let store = undefined; let userStore = undefined; export class WsClient { // These members are public for testing purposes only static connectedClients = new Set(); static watchers = new Map(); static conversations = new Map(); static joinedParticipants = new Map(); static conversationsCache = new Map(); connectionId; id; state = WsClientState.None; transport; conversation; lastError; constructor(t) { this.transport = t; t.on('message', this.onMessage.bind(this)); t.once('close', this.onClose.bind(this)); t.send(JSON.stringify({ type: 'hello', instanceId }), { binary: false, fin: true }); } static async addClient(conversation, wc) { let info = WsClient.conversations.get(conversation.id); if (!info) { info = { participants: new Set(conversation.participants), clients: [wc] }; WsClient.conversations.set(conversation.id, info); WsClient.conversationsCache.delete(conversation.id); } else { info.clients.push(wc); } for (const c of info.clients) { if (c.id && !info.participants.has(c.id)) { await c.stop(StopStatus.RemovedByPrticipant); } } } static removeClient(conversationId, wc) { const info = WsClient.conversations.get(conversationId); if (!info) { return false; } const index = info.clients.indexOf(wc); if (index < 0) { return false; } info.clients.splice(index, 1); if (!info.clients.length) { WsClient.conversations.delete(conversationId); for (const id of info.participants) { if (WsClient.watchers.has(id)) { WsClient.conversationsCache.set(conversationId, info.participants); break; } } } return true; } static addWatchClient(wc) { WsClient.watchers.set(wc.id, wc); } static removeWatchClient(wc) { const result = WsClient.watchers.delete(wc.id); if (result) { const toRemove = []; WsClient.conversationsCache.forEach((v, k) => { if (v.has(wc.id)) { for (const id of v) { if (WsClient.watchers.has(id)) { return; } } toRemove.push(k); } }); toRemove.forEach(id => WsClient.conversationsCache.delete(id)); } return result; } static publishToWatchList(userId, action) { if (!WsClient.watchers.size) { return; } const clients = new Set([userId]); WsClient.conversations.forEach(info => { if (info.participants.has(userId)) { info.participants.forEach(p => clients.add(p)); } }); WsClient.conversationsCache.forEach(participants => { if (participants.has(userId)) { participants.forEach(p => clients.add(p)); } }); clients.forEach(c => { const client = WsClient.watchers.get(c); client && action(client); }); } static async getConversationParticipants(conversationId) { const info = WsClient.conversations.get(conversationId); if (info) { return info.participants; } let participants = WsClient.conversationsCache.get(conversationId); if (!participants) { const conversation = await store.getParticipantConversationById(undefined, conversationId); if (!conversation) { return; } participants = new Set(conversation.participants); WsClient.conversationsCache.set(conversationId, participants); } return participants; } static async publishToWsList(conversationId, action) { const tasks = []; const info = WsClient.conversations.get(conversationId); if (info) { info.clients.forEach(client => { tasks.push(action(client, info.participants)); }); } if (WsClient.watchers?.size) { const participants = await WsClient.getConversationParticipants(conversationId); participants?.forEach(id => { const client = WsClient.watchers.get(id); if (client) { tasks.push(action(client)); } }); } return Promise.all(tasks); } static async syncConversation(conversationId) { const conversation = await store.getParticipantConversationById(undefined, conversationId); if (!conversation) { return false; } WsClient.conversationsCache.delete(conversationId); const info = WsClient.conversations.get(conversationId); if (info) { const eqSet = (a, b) => { return a.size === b.length && b.every(a.has.bind(a)); }; if (eqSet(info.participants, conversation.participants)) { return false; } info.participants = new Set(conversation.participants); } return true; } static async translateQueueMessage(qm) { logger?.debug('Queue message received: ' + JSON.stringify(qm)); if (['connected', 'disconnected'].includes(qm.type)) { if (qm.type === 'disconnected') { WsClient.connectedClients.delete(qm.fromId); } else { WsClient.connectedClients.add(qm.fromId); } WsClient.publishToWatchList(qm.fromId, wc => { if (qm.connectionId !== wc.connectionId || qm.instanceId !== instanceId) { wc.send({ type: qm.type, id: qm.id, connectionId: qm.connectionId, fromId: qm.fromId, createdAt: qm.createdAt, }); } }); return; } if (['closed', 'deleted'].includes(qm.type)) { const conversationId = qm.data.conversationId ?? qm.conversationId; if (conversationId) { await WsClient.publishToWsList(conversationId, async (wc, _) => { if (qm.connectionId !== wc.connectionId || qm.instanceId !== instanceId) { wc.send(qm); } if (wc.conversation?.id === conversationId && qm.type === 'deleted') { await wc.stop(StopStatus.Deleted); } }); if (qm.type === 'deleted') { WsClient.conversationsCache.delete(conversationId); } } return; } switch (qm.type) { case 'text': case 'file': if (null == qm.data) { break; } /* FALLTHROUGH */ case 'joined': case 'left': case 'message-updated': case 'message-deleted': if (qm.conversationId) { switch (qm.type) { case 'joined': { const participants = WsClient.joinedParticipants.get(qm.conversationId); if (participants) { participants.add(qm.fromId); } else { WsClient.joinedParticipants.set(qm.conversationId, new Set([qm.fromId])); } } break; case 'left': { const participants = WsClient.joinedParticipants.get(qm.conversationId); if (participants) { participants.delete(qm.fromId); if (!participants.size) { WsClient.joinedParticipants.delete(qm.conversationId); } } } break; } await WsClient.publishToWsList(qm.conversationId, async (wc, _) => wc.send(qm)); } break; case 'updated': { const conversationId = qm.data.conversationId ?? qm.conversationId; if (conversationId) { const updated = await WsClient.syncConversation(conversationId); await WsClient.publishToWsList(conversationId, async (wc, participants) => { wc.send(qm); if (updated && false === participants?.has(wc.id)) { await wc.stop(StopStatus.Removed); } }); } } break; } } async publishMessage(type, clientMessageId, data, save) { let id = undefined; const createdAt = new Date(); if (save) { const m = { type: type, conversationId: this.conversation?.id, participants: this.conversation?.participants, connectionId: this.connectionId, fromId: this.id, clientMessageId: clientMessageId, createdAt, data: data, }; const response = await store.saveMessage(m); if (response.result !== 'created') { const err = 'Index message failed'; if (!this.lastError) { this.lastError = err; } logger?.error(err); return false; } id = response?._id; } const message = { type, id, instanceId, conversationId: this.conversation?.id, participants: this.conversation?.participants, connectionId: this.connectionId, fromId: this.id, clientMessageId: clientMessageId, createdAt, data: data, }; if (queue && (!Array.isArray(queue.acceptTypes) || queue.acceptTypes.includes(type))) { return await queue.publish(message); } await WsClient.translateQueueMessage(message); return true; } send(msg) { if (this.transport && this.transport.readyState === TransportState.OPEN && sendStates.includes(this.state)) { return this.transport.send(JSON.stringify(msg), { binary: false, fin: true }); } } async stop(status, err) { if (logger && err?.message) { logger.error(err.message); } if (this.state === WsClientState.Disconnected) { return; } this.state = WsClientState.Disconnected; const maxStatusLen = 123; const dotSpace = '. '; let statusDescription = status; [this.lastError, err?.message].forEach(v => { if (v && statusDescription.length + dotSpace.length < maxStatusLen) { statusDescription += dotSpace + v.substring(0, maxStatusLen - statusDescription.length - dotSpace.length); } }); if (this.connectionId && this.id) { if (this.conversation) { await this.publishMessage('left', undefined, null, true); } await this.publishMessage('disconnected', undefined, null, false); } if ([TransportState.CLOSING, TransportState.CLOSED].includes(this.transport.readyState)) { return; } this.transport.close(err ? 1011 : 1000, statusDescription); this.transport.removeAllListeners(); } async watch() { this.state = WsClientState.WatchSession; const conversations = await this.getConversations(0, 0); WsClient.addWatchClient(this); this.send({ type: 'watching', conversations }); logger?.debug(`Watch client with id ${this.id} added successfully`); } async join(request) { let conversation; let created = false; const data = request.data; if (data.conversationId) { conversation = await store.getParticipantConversationById(this.id, data.conversationId); if (!conversation?.id) { this.lastError = 'Wrong conversation'; return false; } } else { const participants = new Set(data.participants?.map(p => p.trim()).filter(s => s.length)); participants.add(this.id); if (participants.size < 2) { this.lastError = 'Less than 2 participants'; return false; } let conversationId; const participantsArray = Array.from(participants); if (!data.title && participants.size == 2) { const conversationIdResult = await store.getPeerToPeerConversationId(participantsArray[0], participantsArray[1]); if (!conversationIdResult?.id) { this.lastError = 'Unable to get peer to peer conversation identifier'; logger?.error(this.lastError); return false; } created = conversationIdResult.result === 'created'; conversationId = conversationIdResult?.id; conversation = await store.getParticipantConversationById(undefined, conversationId); } else if (!data.title) { this.lastError = 'Conversation title required'; return false; } const conversationsParticipans = conversation ? new Set(conversation.participants) : undefined; if (!conversationsParticipans || participants.size !== conversationsParticipans.size || participantsArray.some(p => !conversationsParticipans.has(p))) { const now = new Date(); conversation = { id: conversationId, participants: participantsArray, title: data.title, createdBy: conversation?.createdBy ?? this.id, createdAt: conversation?.createdAt ?? now, updatedAt: conversation?.createdAt ? now : undefined, }; const response = await store.saveConversation(conversation); if (!created) { created = response.result === 'created'; } if (!created && response.result !== 'updated') { logger?.error(`Save conversation with id ${conversation.id} failed`); this.lastError = 'Save conversation failed'; return false; } conversation.id = response._id; } } this.state = WsClientState.Session; this.conversation = conversation; await WsClient.addClient(conversation, this); const size = data.messagesSize ?? defaultSize; let lastMessage = undefined; let messages = undefined; if (!created) { const fr = { size, conversationIds: [conversation.id], types, sort: 'createdAt', sortDesc: true, }; messages = await store.findMessages(fr); lastMessage = await store.getParticipantLastMessage(this.id, conversation.id); } const connected = conversation.participants.filter(p => p === this.id || WsClient.joinedParticipants.get(conversation.id)?.has(p)); const joined = { type: 'conversation', clientMessageId: request.clientMessageId, conversation, connected, messages, leftAt: lastMessage?.createdAt, }; this.send(joined); logger?.debug(`Client with id ${this.id} added successfully`); return this.publishMessage('joined', request.clientMessageId, null, true); } onMessage(data, isBinary) { try { if (!(data instanceof Buffer)) { throw new Error('Wrong transport'); } if (isBinary) { logger?.error('Binary message received'); throw new Error('Binary message'); } const msg = JSON.parse(data?.toString()); if (msg && sendStates.includes(this.state)) { const request = msg; if (request && connectedRequestTypes.includes(request.type)) { this.processRequest(request).then(result => { if (!result) { this.stop(StopStatus.FailedProcessRequest); } }).catch(e => { this.stop(StopStatus.FailedProcessRequest, e); }); return; } } switch (this.state) { case WsClientState.None: { const request = msg; if (request) { this.connect(request).then(response => { if (!response) { this.stop(StopStatus.FailedConnect); } }).catch(e => { this.stop(StopStatus.FailedConnect, e); }); } else { this.stop(StopStatus.FailedConnect); } } break; case WsClientState.Connected: { const request = msg; switch (request?.type) { case 'join': this.join(request).then(response => { if (!response) { this.stop(StopStatus.FailedJoin); } }).catch(e => { this.stop(StopStatus.FailedJoin, e); }); break; case 'watch': this.watch().catch(e => { this.stop(StopStatus.FailedWatch, e); }); break; default: throw new Error('Wrong request type'); } } break; case WsClientState.Session: { const request = msg; if (request) { this.processConversationRequest(request).then(result => { if (!result) { this.stop(StopStatus.FailedProcessConversationRequest); } }).catch(e => { this.stop(StopStatus.FailedProcessConversationRequest, e); }); } else { throw new Error('Wrong message'); } } break; } } catch (e) { this.stop(StopStatus.FailedProcessMessage, e); } } onClose() { this.stop(StopStatus.Stopped).finally(() => { if (this.conversation?.id) { WsClient.removeClient(this.conversation.id, this); logger?.debug(`Client with id ${this.id} removed successfully`); } else if (WsClient.removeWatchClient(this)) { logger?.debug(`Watch client with id ${this.id} removed successfully`); } delete this.conversation; }); } async deleteMessage(request) { const findResult = await store.findMessages({ ids: [request.messageId], conversationIds: [this.conversation.id], types, }); const message = findResult.messages?.[0]; if (!message) { this.lastError = 'Wrong message'; return false; } if (message.fromId != this.id) { this.lastError = 'User is not allowed to delete message'; return false; } message.deletedAt = request.deletedAt; const response = await store.saveMessage(message); if (response.result !== 'updated') { logger?.error(`Delete message with id ${message.id} failed`); this.lastError = 'Delete message failed'; return false; } logger?.debug(`Message with id ${message.id} was deleted successfully`); return true; } async updateMessage(request) { const findResult = await store.findMessages({ ids: [request.messageId], conversationIds: [this.conversation.id], types, }); const message = findResult.messages?.[0]; if (!message) { this.lastError = 'Wrong message'; return false; } if (message.fromId !== this.id) { this.lastError = 'User is not allowed to update message'; return false; } switch (message.type) { case 'file': { const { link, name, type, size } = request; if (!name) { this.lastError = 'Wrong file name'; return false; } message.data = { link, name, type, size }; } break; case 'text': { const { text } = request; message.data = { text }; } break; } message.updatedAt = request.updatedAt; const response = await store.saveMessage(message); if (response.result !== 'updated') { logger?.error(`Update message with id ${message.id} failed`); this.lastError = 'Update message failed'; return false; } logger?.debug(`Message with id ${message.id} was updated successfully`); return true; } async updateConversation(data) { const conversation = data.conversationId ? await store.getParticipantConversationById(this.id, data.conversationId) : this.conversation; if (!conversation || this.id !== conversation.createdBy) { //Only creator can update conversation this.lastError = 'User is not allowed to update conversation'; return false; } conversation.title = data.title; conversation.updatedAt = data.updatedAt; conversation.participants = data.participants; const response = await store.saveConversation(conversation); if (response.result !== 'updated') { logger?.error(`Update conversation with id ${conversation.id} failed`); this.lastError = 'Update conversation failed'; return false; } logger?.debug(`Conversation with id ${conversation.id} was updated successfully`); return true; } async closeDeleteConversation(data, del) { const id = data.conversationId ?? this.conversation?.id; if (!id) { this.lastError = 'Wrong conversation identifier'; return null; } const conversation = await store.getParticipantConversationById(this.id, id); if (!conversation) { //Only creator can close or delete conversation this.lastError = 'Conversation not found'; return null; } if (!del && conversation.closedAt) { this.lastError = 'Conversation already closed'; return null; } let type = 'updated'; if (this.id === conversation.createdBy) { //Only creator can close or delete conversation conversation.closedAt = data.closedAt; if (del) { conversation.deletedAt = data.deletedAt; type = 'deleted'; } else { type = 'closed'; } } else if (del) { //leave conversation conversation.participants = conversation.participants.filter(p => p !== this.id); conversation.updatedAt = data.deletedAt; data.participants = conversation.participants; } else { this.lastError = 'User is not allowed to close conversation'; return null; } const response = await store.saveConversation(conversation); if (response.result !== 'updated') { logger?.error(`Close conversation with id ${conversation.id} failed`); this.lastError = 'Close conversation failed'; return null; } logger?.debug(`Conversation with id ${conversation.id} was updated successfully`); return type; } async processRequest(request) { if (!request.data) { this.lastError = 'Wrong message'; return false; } const clientMessageId = request.clientMessageId; switch (request.type) { case 'close': case 'delete': { const { conversationId } = request.data; const now = new Date(); const data = { conversationId, closedAt: now, }; const del = request.type === 'delete'; if (del) { data.deletedAt = now; } const type = await this.closeDeleteConversation(data, del); if (type) { if (type === "updated") { delete data.closedAt; delete data.deletedAt; data.updatedAt = now; } this.send({ type, clientMessageId, data }); return this.publishMessage(type, clientMessageId, data, true); } } break; case 'find': await this.findMessages(request.data, clientMessageId); return true; case 'load': await this.loadConversations(request.data, clientMessageId); return true; case 'update': { const { conversationId, title, participants } = request.data; const participantsSet = new Set([this.id]); participants?.forEach(p => participantsSet.add(p.trim())); const data = { conversationId, title, participants: Array.from(participantsSet), updatedAt: new Date(), }; if (await this.updateConversation(data)) { const type = 'updated'; this.send({ type, clientMessageId, data }); return this.publishMessage(type, clientMessageId, data, true); } } break; } return false; } async processConversationRequest(request) { if (!request.data) { this.lastError = 'Wrong message'; return false; } const verifyConversation = () => { if (this.conversation.closedAt) { this.lastError = 'Conversation closed'; return false; } return true; }; let broadcastType = request.type; switch (request.type) { case 'text': if (!verifyConversation()) { return false; } break; case 'file': if (!verifyConversation()) { return false; } if (!request.data.name) { this.lastError = 'Wrong file name'; return false; } break; case 'message-update': request.data.updatedAt = new Date(); if (!await this.updateMessage(request.data)) { return false; } broadcastType = 'message-updated'; break; case 'message-delete': request.data.deletedAt = new Date(); if (!await this.deleteMessage(request.data)) { return false; } broadcastType = 'message-deleted'; break; case 'load-messages': await this.loadMessages(request.data, request.clientMessageId); return true; default: this.lastError = 'Wrong message type'; return false; } return this.publishMessage(broadcastType, request.clientMessageId, request.data, true); } async connect(request) { this.id = request?.authInfo && await userStore.authenticate(request.authInfo); if (!this.id) { this.lastError = 'Authentication failed'; return false; } this.state = WsClientState.Authenticated; const response = await store.saveConnection(this.id, instanceId); if (response.result !== 'created') { logger?.debug(`Save connection with id ${response._id} failed`); return false; } this.state = WsClientState.Connected; this.connectionId = response._id; logger?.debug(`Save connection with id ${this.connectionId} succeeded`); const conversations = await this.getConversations(0, request.conversationsSize); this.transport.send(JSON.stringify({ type: 'connection', connectionId: this.connectionId, id: this.id, conversations, }), { binary: false, fin: true }); return this.publishMessage('connected', undefined, null, false); } async getConversations(from = 0, conversationsSize, ids, excludeIds) { const size = conversationsSize != null && conversationsSize >= 0 ? conversationsSize : defaultSize; const result = await store.getParticipantConversations(this.id, ids, excludeIds, from, size); if (!result.conversations?.length) { return { conversations: [], from, size, total: result.total, }; } const conversationIds = result.conversations.map(c => c.id); const messagesInfo = await store.getLastMessagesTimestamps(this.id, conversationIds); const conversations = result.conversations.map(c => ({ conversation: c, leftAt: c.id in messagesInfo ? messagesInfo[c.id].left : undefined, latestMessage: c.id in messagesInfo ? messagesInfo[c.id].latest : undefined, connected: c.participants.filter(p => WsClient.joinedParticipants.get(c.id)?.has(p)), })); return { conversations, from, size, total: result.total, }; } async findMessages(request, clientMessageId) { const result = await store.getParticipantConversations(this.id, request.conversationIds, undefined, request.from ?? 0, request.size ?? defaultSize); request.conversationIds = result.conversations.map(c => c.id); const findResult = await store.findMessages(request); this.send({ type: 'find', clientMessageId, messages: findResult.messages, from: findResult.from, size: findResult.size, total: findResult.total }); } async loadMessages(request, clientMessageId) { const findRequest = { from: request.from, size: request.size, sort: 'createdAt', sortDesc: true, conversationIds: [this.conversation.id], createdTo: request.before, types, excludeIds: request.excludeIds, }; const result = await store.findMessages(findRequest); result.messages.reverse(); this.send({ type: 'loaded-messages', clientMessageId, messages: result.messages, count: result.total }); } async loadConversations(request, clientMessageId) { const result = await this.getConversations(request.from, request.size, request.ids, request.excludeIds); this.send({ type: 'loaded', clientMessageId, conversations: result.conversations, count: result.total }); } } export function initialize(config, log) { instanceId = config.instanceId; logger = log; queue = config.queue; store = config.store; userStore = config.userStore; queue?.subscribe(WsClient.translateQueueMessage); } //# sourceMappingURL=index.js.map