@only-chat/client
Version:
Client for only-chat
887 lines • 36.2 kB
JavaScript
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