[ Web Proxy ]
URL:
Viewing: https://raw.githubusercontent.com/devforth/adminforth/next/adminforth/modules/socketBroker.ts [Back]  [Original]

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);
      }))
    );
  }
 
}

Web Proxy Viewer  |  New URL  |  Original Page