UNPKG

@directus/api

Version:

Directus is a real-time API and App dashboard for managing SQL database content

226 lines (225 loc) 8.53 kB
import { randomUUID } from 'crypto'; import { useEnv } from '@directus/env'; import { isDirectusError } from '@directus/errors'; import { WS_TYPE } from '@directus/types'; import { COLLAB_BUS, } from '@directus/types/collab'; import { useBus } from '../../bus/index.js'; import { useLogger } from '../../logger/index.js'; import { useStore } from './store.js'; const env = useEnv(); const INSTANCE_TIMEOUT = Number(env['WEBSOCKETS_COLLAB_INSTANCE_TIMEOUT']); export class Messenger { uid; store; clients = {}; orders = {}; messenger = useBus(); roomListeners = {}; constructor() { this.uid = randomUUID(); this.store = useStore('registry', { instances: {} }); this.store(async (store) => { const instances = await store.get('instances'); instances[this.uid] = { clients: [], rooms: [] }; await store.set('instances', instances); }).catch((err) => { useLogger().error(err, '[Collab] Failed to register instance in registry'); }); this.messenger.subscribe(COLLAB_BUS, (message) => { if (message.type === 'send') { const client = this.clients[message.client]; if (client) { const order = this.orders[client.uid] ?? 0; this.orders[client.uid] = order + 1; client.send(JSON.stringify({ ...message.message, order })); } } else if (message.type === 'error') { const client = this.clients[message.client]; if (client) { client.send(JSON.stringify(message.message)); } } else if (message.type === 'terminate') { this.clients[message.client]?.close(); } else if (message.type === 'room') { this.roomListeners[message.room]?.(message); } else if (message.type === 'ping' && message.instance === this.uid) { this.messenger.publish(COLLAB_BUS, { type: 'pong', instance: this.uid }); } }); } hasClient(client) { return client in this.clients; } setRoomListener(room, callback) { this.roomListeners[room] = callback; } removeRoomListener(room) { delete this.roomListeners[room]; } addClient(client) { if (client.uid in this.clients) return; this.clients[client.uid] = client; this.orders[client.uid] = 0; this.store(async (store) => { const instances = await store.get('instances'); if (!instances[this.uid]) instances[this.uid] = { clients: [], rooms: [] }; instances[this.uid].clients = [...(instances[this.uid].clients ?? []), client.uid]; await store.set('instances', instances); }).catch((err) => { useLogger().error(err, `[Collab] Failed to add client ${client.uid} to registry`); }); client.on('close', () => { this.removeClient(client.uid); }); } removeClient(uid) { delete this.clients[uid]; delete this.orders[uid]; this.store(async (store) => { const instances = await store.get('instances'); if (instances[this.uid]) { instances[this.uid].clients = (instances[this.uid].clients ?? []).filter((clientId) => clientId !== uid); await store.set('instances', instances); } }).catch((err) => { useLogger().error(err, `[Collab] Failed to remove client ${uid} from registry`); }); } async registerRoom(uid) { await this.store(async (store) => { const instances = await store.get('instances'); if (!instances[this.uid]) instances[this.uid] = { clients: [], rooms: [] }; if (!instances[this.uid].rooms.includes(uid)) { instances[this.uid].rooms.push(uid); await store.set('instances', instances); } }); } async unregisterRoom(uid) { await this.store(async (store) => { const instances = await store.get('instances'); if (instances[this.uid]) { instances[this.uid].rooms = (instances[this.uid].rooms ?? []).filter((roomUid) => roomUid !== uid); await store.set('instances', instances); } }); } async getLocalClients() { return Object.keys(this.clients); } async getGlobalClients() { const instances = await this.store(async (store) => await store.get('instances')); return Object.values(instances) .map((instance) => instance.clients) .flat(); } async pruneDeadInstances() { const instances = await this.store(async (store) => await store.get('instances')); const inactiveInstances = new Set(Object.keys(instances)); inactiveInstances.delete(this.uid); const pongCollector = (message) => { if (message.type === 'pong') { inactiveInstances.delete(message.instance); } }; this.messenger.subscribe(COLLAB_BUS, pongCollector); for (const instance of inactiveInstances) { this.messenger.publish(COLLAB_BUS, { type: 'ping', instance }); } await new Promise((resolve) => { setTimeout(resolve, INSTANCE_TIMEOUT); }); this.messenger.unsubscribe(COLLAB_BUS, pongCollector); const dead = { clients: [], rooms: [] }; if (inactiveInstances.size === 0) { return { inactive: dead, active: Object.values(instances) .map((instance) => instance.clients) .flat(), }; } // Reread state to avoid overwriting updates during the timeout phase const current = await this.store(async (store) => { const current = await store.get('instances'); let changed = false; for (const deadId of inactiveInstances) { if (current[deadId]) { dead.clients.push(...(current[deadId].clients ?? [])); dead.rooms.push(...(current[deadId].rooms ?? [])); delete current[deadId]; changed = true; } } if (changed) { await store.set('instances', current); } return current; }); return { inactive: dead, active: Object.values(current) .map((instance) => instance.clients) .flat(), }; } sendRoom(room, message) { this.messenger.publish(COLLAB_BUS, { type: 'room', room, ...message }); } sendClient(client, message) { const localClient = this.clients[client]; if (localClient) { const order = this.orders[client] ?? 0; this.orders[client] = order + 1; localClient.send(JSON.stringify({ ...message, order })); } else { this.messenger.publish(COLLAB_BUS, { type: 'send', client, message }); } } terminateClient(client) { const localClient = this.clients[client]; if (localClient) { // Allow message to flush before closing setTimeout(() => { localClient.close(); }, 250); } else { this.messenger.publish(COLLAB_BUS, { type: 'terminate', client }); } } sendError(client, error) { const localClient = this.clients[client]; if (localClient) { localClient.send(JSON.stringify(error)); } else { this.messenger.publish(COLLAB_BUS, { type: 'error', client, message: error }); } } handleError(client, error, action) { let message; if (isDirectusError(error)) { message = { action: 'error', type: WS_TYPE.COLLAB, code: error.code, trigger: action, message: error.message, }; } else { useLogger().error(`WebSocket unhandled exception ${JSON.stringify({ type: WS_TYPE.COLLAB, error })}`); return; } this.sendError(client, message); } }