Сегодня я собираюсь поделиться знаниями, которые я получил при разработке своего первого приложения WebSocket с использованием TypeScript и NestJS. Основной целью этой части была разработка простого приложения для веб-чата в реальном времени.

План

Поскольку я никогда раньше не разрабатывал приложение WebSocket, я решил сосредоточиться на том, что я считал наиболее важным: на бэкэнде, и отложил в сторону внешний клиент. Я сосредоточился на разработке сервера, который будет обрабатывать соединения, отключения и сообщения.

Бэкенд

Первое, что я сделал, это создал свой проект NestJS с помощью:

nest new realtime-chat

После этого мне пришлось внести некоторые изменения в файл main.ts. Я решил создать приложение NestExpressApplication, которое использует порт 3002, разрешает клиентский доступ через HTML, и мне также пришлось включить CORS для своего приложения. Вот как выглядел мой файл main.ts после внесения всех этих изменений:

import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
import { NestExpressApplication } from '@nestjs/platform-express';
import { join } from 'path';
import { CustomIoAdapter } from './custom.io.adapter';

async function bootstrap() {
  const app = await NestFactory.create<NestExpressApplication>(AppModule);
  app.enableCors();
  app.useStaticAssets(join(__dirname, '..', 'public'));
  app.setViewEngine('html');
  app.useWebSocketAdapter(new CustomIoAdapter(app));

  await app.listen(3002);
}
bootstrap();

А это мой CustomIoAdapter.ts:

import { IoAdapter } from '@nestjs/platform-socket.io';
import { Server } from 'socket.io';

export class CustomIoAdapter extends IoAdapter {
  createIOServer(port: number, options?: any): Server {
    const server = super.createIOServer(port, options);
    return server;
  }

  bindClientConnect(server: Server, callback: (...args: any[]) => void) {
    server.on('connection', callback);
  }

  bindClientDisconnect(client: Server, callback: (...args: any[]) => void) {
    client.on('disconnect', callback);
  }
}

Этот адаптер был необходим для использования сервера socket.io, потому что при использовании вложенного WsAdapter он не мог правильно взаимодействовать с моим клиентом socket.io. Он всегда имел значение undefined Socket.id. Это решило мою проблему. (Для следующего P.O.C я планирую изучить, будет ли полезен RedisIoAdapter.)

В моем app.module.ts мне нужно было включить ServeStaticModule для папки public, которую я создал в корне проекта, содержащего HTML-клиент. Кроме того, мне пришлось настроить шлюз, о чем я расскажу позже.

import { Module } from '@nestjs/common';
import { ServeStaticModule } from '@nestjs/serve-static';
import { join } from 'path';
import { ChatGateway } from './real-time-chat/chat.gateway';

@Module({
  imports: [
    ServeStaticModule.forRoot({
      rootPath: join(__dirname, '..', 'public'),
    }),
  ],
  controllers: [],
  providers: [ChatGateway],
})
export class AppModule {}

После предоставления и импорта я создал chat.gateway.ts

// src/websocket/game.gateway.ts
import { Logger, UsePipes, ValidationPipe } from '@nestjs/common';
import {
  WebSocketGateway,
  WebSocketServer,
  SubscribeMessage,
  OnGatewayConnection,
  OnGatewayDisconnect,
} from '@nestjs/websockets';
import { Server, Socket } from 'socket.io';

@UsePipes(new ValidationPipe())
@WebSocketGateway({
  cors: {
    origin: '*',
  },
  credentials: true,
})
export class ChatGateway implements OnGatewayConnection, OnGatewayDisconnect {
  @WebSocketServer()
  server: Server;
  private players: Set<Socket> = new Set();
  private chat: Set<{ sender: string; message: string }> = new Set();
  private connectionLogger = new Logger('connectionLogger');
  private eventLogger = new Logger('eventLogger');

  @SubscribeMessage('message')
  handleMessage(client: Socket, payload: string): void {
    this.eventLogger.log(`Client ${client.id} sent: ${payload}`);
    const { sender, message, timeSent } = JSON.parse(payload).data;
    this.chat.add(JSON.parse(payload).data);
    this.players.forEach(async (client) => {
      await client.emit('message', { sender, message, timeSent });
    });
  }

  handleConnection(client: Socket): void {
    this.players.add(client);
    this.connectionLogger.log(`client ${client.id} joined`);
    this.chat.forEach((conversa) => {
      client.send(conversa);
    });
  }

  handleDisconnect(client: Socket): void {
    this.players.delete(client);
    this.connectionLogger.log(`client ${client.id} left`);
  }
}

Подход к обработке подключений следующий: после подключения сокета к серверу WebSocket сервер регистрирует с помощью connectionLogger, а также отправляет событие для получения всех сообщений, которые сохраняются на сервере.

Это было одним из моих вызовов. Мне нужно было понять разницу между Socket.emit и Socket.send. Метод Socket.emit позволяет создавать настраиваемые события и отправлять объекты, а метод Socket.send отправляет сообщения, полученные с помощью message. событие и отправляет строку.

Я решил использовать оба, чтобы продемонстрировать их использование. Идея состоит в том, чтобы отправить событие двумя разными способами и захватить его, используя один и тот же прослушиватель на стороне клиента.

Самая важная часть — это когда клиент отправляет сообщение. Этим занимается

@SubscribeMessage('message')
  handleMessage(client: Socket, payload: string): void {
    this.eventLogger.log(`Client ${client.id} sent: ${payload}`);
    const { sender, message, timeSent } = JSON.parse(payload).data;
    this.chat.add(JSON.parse(payload).data);
    this.players.forEach(async (client) => {
      await client.emit('message', { sender, message, timeSent });
    });
  }

Первое, что делает сервер, — регистрирует с помощью eventLogger, после чего я сохраняю сообщение чата в массиве чата и для каждого клиента, подключенного к серверу, выдаю сообщение мероприятие со всей информацией.

Внешний интерфейс

<!-- index.html -->

<!DOCTYPE html>
<html>
  <head>
    <title>WebSocket chat</title>
    <style>
      textarea::-webkit-scrollbar {
        width: 10px;
        height: 10px;
        border-radius: 10px;
      }

      textarea::-webkit-scrollbar-thumb {
        background-color: #ccc;
        border-radius: 10px;
      }

      body {
        background-image: url('https://source.unsplash.com/random');
        background-repeat: no-repeat;
        background-size: cover;
        font-family: Arial, sans-serif;
        /* margin: 20px; */
        background-color: #f5f5f5;
        height: 100%;
        width: 100%;
        margin: 0;
        padding: 0;
      }

      h1 {
        font-family: sans-serif;
        font-size: 40px;
        font-weight: bold;
        color: #000;
        text-align: center;
        margin-top: 100px;
        /* Add some modern CSS properties */
        text-transform: uppercase;
        letter-spacing: 1px;
        border-bottom: 1px solid #ccc;
      }

      #messages {
        width: 100%;
        height: 200px;
        font-size: 16px;
        outline: none;
        background-color: #f1f1f1;
        border-radius: 20px;
        resize: none;
      }

      .message {
        margin-bottom: 5px;
      }

      .message.sent {
        background-color: #4caf50;
        color: white;
      }

      .message.received {
        background-color: #ccc;
        color: black;
      }

      .chat-input-container {
        display: flex;
        align-items: center;
        max-width: 100%;
        border-radius: 30px;
        background-color: #f1f1f1;
        box-shadow: 0px 2px 5px rgba(0, 0, 0, 0.1);
        overflow: hidden;
      }

      .chat-input {
        flex: 1;
        padding: 12px 20px;
        font-size: 16px;
        border: none;
        outline: none;
        background-color: transparent;
      }

      .send-button {
        padding: 10px 20px;
        background-color: #4caf50;
        color: #ffffff;
        border: none;
        border-radius: 0 30px 30px 0;
        cursor: pointer;
        transition: background-color 0.2s;
      }

      .send-button:hover {
        background-color: #45a049;
      }
    </style>
  </head>
  <body>
    <script
      src="https://cdn.socket.io/4.6.0/socket.io.min.js"
      integrity="sha384-c79GN5VsunZvi+Q/WObgk2in0CbZsHnjEqvFxC5DxHn9lTfNce2WW6h2pH6u/kF+"
      crossorigin="anonymous"
    ></script>
    <h1>WebSocket Chat</h1>
    <textarea
      id="messages"
      readonly
      class="message"
      rows="10"
      cols="50"
    ></textarea>
    <div class="chat-input-container">
      <input
        type="text"
        class="chat-input"
        id="message"
        placeholder="Type your message..."
      />
      <button class="send-button" id="sendButton" onclick="sendMessage()">
        Send
      </button>
    </div>
    <script>
      const socket = io('ws://192.168.210.107:3002');

      let sender;
      sender = sessionStorage.getItem('sender');

      socket.on('connect', () => {
        // Get or generate a unique sender name for this client session
        if (!sender) {
          sender = socket.id;
          sessionStorage.setItem('sender', sender);
        }
      });

      socket.on('message', (event) => {
        console.log(event);
        let data = event;
        const messagesTextarea = document.getElementById('messages');
        messagesTextarea.value += `${data.timeSent} ${data.sender} says:\n${data.message}\n`;
        messagesTextarea.scrollTop = messagesTextarea.scrollHeight; // Scroll to the bottom
      });

      function sendMessage() {
        const messageInput = document.getElementById('message');
        const message = messageInput.value;
        if (message.trim() !== '') {
          const payload = {
            event: 'message',
            data: {
              sender,
              message,
              timeSent: new Date(Date.now()).toLocaleString(),
            },
          };
          socket.send(JSON.stringify(payload));
          messageInput.value = '';
        }
      }
    </script>
  </body>
</html>

Поскольку клиент довольно прост, он подключается к серверу, устанавливает Socket.id в StorageSession (каждая вкладка уникальна) в качестве отправителя и взаимодействует с сервером.

Полный проект можно найти на моем Github. (ссылка в справочниках)

Демо

https://vimeo.com/852853457?share=copy

Открытые вопросы

Как мне вообще это проверить? (Юнит и E2E)

Это лучший способ создания WebSockets?

Должен ли я использовать конечные точки вместо только событий?

Рекомендации

Шлюзы веб-сокетов NestJS:



Адаптеры веб-сокетов NestJS:



Socket.emit x Socket.send:



Полный проект: