@directus/api
Version:
Directus is a real-time API and App dashboard for managing SQL database content
226 lines (225 loc) • 8.53 kB
JavaScript
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);
}
}