import pLimit from 'p-limit';
import { IAdminForth, IWebSocketBroker, IWebSocketClient } from "../types/Back.js";
import { AdminUser } from "../types/Common.js";
import { afLogger } from '../modules/logger.js';
const PUBLISH_FILTER_CONCURRENCY = 10;
const REAUTHORIZE_CONCURRENCY = 10;
export default class SocketBroker implements IWebSocketBroker {
clients = new Map();
topics: { [key: string]: IWebSocketClient[] } = {};
adminforth: IAdminForth;
deadCheckerRunning = false;
constructor(adminforth: IAdminForth) {
this.adminforth = adminforth;
}
async startChecker() {
if (this.deadCheckerRunning) {
return;
}
this.deadCheckerRunning = true;
while (true) {
await this.checkDeadClients();
await this.reauthorizeClients();
await new Promise((resolve) => setTimeout(resolve, 10_000));
}
}
async checkDeadClients() {
const now = Date.now();
for (const client of this.clients.values()) {
if (now - client.lastPing > 30_000) {
client.close();
this.clients.delete(client.id);
}
}
}
/**
* Closes connections of users who lost access after the handshake (revoked session, deactivated or deleted user,
* expired jwt), so they stop receiving topic messages. Frontend reconnects and passes handshake as anonymous.
*/
async reauthorizeClients() {
const limit = pLimit(REAUTHORIZE_CONCURRENCY);
await Promise.all(
[...this.clients.values()]
.filter((client) => client.adminUser)
.map((client) => limit(async () => {
try {
const result = await client.authorize();
if (result.status === 'ok') {
// keeps dbUser fresh for websocketTopicAuth and publish filters
client.adminUser = result.adminUser;
} else if (result.status === 'verifyFailed') {
// server side problem, e.g. database is not available, so keep the connection and retry on next check
afLogger.error(`Failed to verify websocket client ${client.id}: ${result.error}`);
} else {
client.close();
}
} catch (e) {
afLogger.error(`Failed to authorize websocket client ${client.id}: ${e}`);
}
}))
);
}
deleteClientFromTopic(client: IWebSocketClient, topic: string) {
if (!this.topics[topic]) {
return;
}
this.topics[topic] = this.topics[topic].filter(c => c !== client);
}
cleanupTopicIfEmpty(topic: string) {
if (!this.topics[topic]) {
return;
}
if (this.topics[topic].length === 0) {
delete this.topics[topic];
}
}
registerWsClient(client: IWebSocketClient): void {
afLogger.info(`Registering new WebSocket client ${client.id}`);
this.startChecker();
this.clients.set(client.id, client);
const pendingSubscriptions = new Map();
client.onMessage(async (message) => {
if (this.clients.get(client.id) !== client) {
return;
}
const messageText = message.toString();
if (!messageText.trim()) {
return;
}
if (messageText === 'ping') {
client.send('pong');
client.lastPing = Date.now();
return;
}
let data: unknown;
try {
data = JSON.parse(messageText);
} catch (e) {
client.send(JSON.stringify({ type: 'error', message: 'Invalid websocket message JSON' }));
return;
}
if (!data || typeof data !== 'object' || Array.isArray(data)) {
client.send(JSON.stringify({ type: 'error', message: 'Invalid websocket message format' }));
return;
}
const payload = data as { type?: unknown; topic?: unknown };
if (payload.type !== 'subscribe' && payload.type !== 'unsubscribe') {
client.send(JSON.stringify({ type: 'error', message: 'Unknown websocket message type' }));
return;
}
if (typeof payload.topic !== 'string' || !payload.topic) {
client.send(JSON.stringify({ type: 'error', message: 'No topic provided' }));
return;
}
const topic = payload.topic;
if (payload.type === 'subscribe') {
// Only the latest subscribe intent may commit after asynchronous authorization.
const subscription = {};
pendingSubscriptions.set(topic, subscription);
let authResult = true;
if (!topic.startsWith('/opentopic/')) {
if (this.adminforth.config.auth.websocketTopicAuth) {
authResult = false;
try {
authResult = await this.adminforth.config.auth.websocketTopicAuth(topic, client.adminUser);
} catch (e) {
afLogger.error(`Error in websocketTopicAuth, assuming connection not allowed ${e}`);
}
}
}
if (this.clients.get(client.id) !== client || pendingSubscriptions.get(topic) !== subscription) {
return;
}
pendingSubscriptions.delete(topic);
if (!authResult) {
client.send(JSON.stringify({ type: 'error', message: 'Unauthorized' }));
return;
}
if (!this.topics[topic]) {
this.topics[topic] = [];
}
if (!this.topics[topic].includes(client)) {
this.topics[topic].push(client);
}
client.topics.add(topic);
if (this.adminforth.config.auth.websocketSubscribed) {
(async () => {
try {
await this.adminforth.config.auth.websocketSubscribed(topic, client.adminUser);
} catch (e) {
afLogger.error(`Error in websocketSubscribed for topic ${topic}, ${e}`);
}
})(); // run in background
}
return;
}
pendingSubscriptions.delete(topic);
this.deleteClientFromTopic(client, topic);
this.cleanupTopicIfEmpty(topic);
client.topics.delete(topic);
});
client.onClose(() => {
pendingSubscriptions.clear();
for (const topic of client.topics) {
this.deleteClientFromTopic(client, topic);
this.cleanupTopicIfEmpty(topic);
}
client.topics.clear();
this.clients.delete(client.id);
});
// send ready message
client.send(JSON.stringify({ type: 'ready' }));
}
async publish(topic: string, data: any, filterUsers?: (adminUser: AdminUser) => Promise): Promise {
if (!this.topics[topic]) {
afLogger.trace(`No clients subscribed to topic ${topic}`);
return;
}
const message = JSON.stringify({ type: 'message', topic, data });
if (!filterUsers) {
for (const client of this.topics[topic]) {
afLogger.trace(`Sending data to socket ${topic} ${JSON.stringify(data)}`);
client.send(message);
}
return;
}
const limit = pLimit(PUBLISH_FILTER_CONCURRENCY);
await Promise.all(
this.topics[topic].map((client) => limit(async () => {
if (! (await filterUsers(client.adminUser)) ) {
afLogger.trace(`Client not authorized to receive message ${topic} ${client.adminUser}`);
return;
}
afLogger.trace(`Sending data to socket ${topic} ${JSON.stringify(data)}`);
client.send(message);
}))
);
}
}