В этой статье я расскажу как написал модуль под Nest.js позволяющий писать классическое RESTFull API со swagger'ом но клиент будет полностью на WebSocket'ах, звучит странно не так ли? Но зато очень быстро и удобно по итогу).

Идея состоит в том, что вы пишите классическое документированное апи, со всеми типами и плюшками.

  @Get(':id')
  @WsAction('user')
  getUser(@Param('id', ParseIntPipe) id: number): UserDto {
    return this.appService.getUser(id);
  }

Так же при настройка модуля в nest.js можно добавить валидацию сокетов, чтобы всё было под авторизацией. Вот небольшой пример из проекта:

@Module({
    imports: [
      ConfigModule.forRoot(),
      WsModule.registerAsync({
        useFactory: (): IWsApiConfig => {
          return {
            // тут указывается та же валидация что была
            // использована для app.useGlobalPipes(new ValidationPipe(validationConfig));
            validationConfig,
            async validate(socket: Socket) {
              try {
                const authGuard = new (AuthGuard('jwt'))();
                const isAuth = await (authGuard.canActivate(new ExecutionContextHost([socket])) as Promise<boolean>);

                if (!isAuth) {
                  return HttpStatus.UNAUTHORIZED;
                }
              } catch (e) {
                return HttpStatus.UNAUTHORIZED;
              }
              return HttpStatus.OK;
            },
          };
        },
      }),
    ],
  })

Далее на клиенте с помощью пакета swagger-typescript-api к примеру автоматически генерите полноценного HTTP клиента под ts. В котором описаны все типы и методы для http запросов. Но нам от него нужны только типы, впрочем вы можете сделать еще и fallback в случае проблем с сокетами.

Плюс можно сделать логику что при SSR делать запрос по HTTP и далее уже на WS.

export interface UserDto {
  id: number;
  firstName: string;
  email: string;
}

Идем дальше :-)

Что происходит на сервере:

  1. Собирается приложение, при компиляции декоратора WsAction он собирает всю инфу о методе контроллера, и кладет в свою коллекцию.

  2. Далее приложение запускается и собранная коллекция ожидает подключения по сокетам клиента, далее подписывается на евенты, названия которых совпадает с названием метода.

  3. Клиенту отправляется информация с конфигом с сервера если надо + статус подключения, когда можно уже дергать АПИ.

  4. И все это гуляет по одному каналу, что увеличивает скорость в ~10 раз.

[Nest] LOG [InstanceLoader] AppModule dependencies initialized +0ms
[Nest] LOG [WsGateway] Add WS action: AppController => app:getUser
[Nest] LOG [WsGateway] Add WS action: BusinessController => business:createUser
[Nest] LOG [RoutesResolver] AppController {/api}: +2ms
[Nest] LOG [RouterExplorer] Mapped {/api/:id, GET} route +3ms
[Nest] LOG [RoutesResolver] BusinessController {/api/business}: +1ms
[Nest] LOG [RouterExplorer] Mapped {/api/business/create, POST} route +0ms
[Nest] LOG [NestApplication] Nest application successfully started +6ms

Можно заметить что сгенерились экшены на основе неймпейска указанного в контроллере, и названия метода к которому применили декоратор business:createUser и app:getUser

По сути это и есть название евента которое отправляется с клиента:

ws.emit('business:createUser', { payload });

Я бы на этом закончил, но мне не было достаточно асинхронного WS, я хотел чтобы было всё синхронно как все привыкли:

await fetch('api/create-user', { payload })

Для этого я написал небольшой клиент который промисофицирует асинхронные сокеты и получается по итогу:

const result = await webSocket.emit<ResultDto>('business:createUser', { payload });

Не хочется много кода сюда писать)

Модуль я опубликовал в npm, репа https://github.com/gustoase/nestjs-ws-api
Демо проекта чтобы запустить потыкать накидал на коленке https://github.com/gustoase/nestjs-ws-api-demo

Как запустить:

  1. npm i

  2. npm run start:dev

  3. cd client

  4. npm i

  5. npm run dev

  6. http://localhost:3001/

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

Так же за счет своевременного оповещения с сервера о состоянии АПИ можно показывать красивые уведомления :-)

Комментарии (11)


  1. anba8005
    30.10.2022 00:18

    Хотел поинтересоваться, как Вы решили вопрос с авторизацией, а именно с передачей jwt токена на с клиента на сервер ? Установка header'a с вебсокетами не работает ...


    1. kpmy
      30.10.2022 00:43

      Предположу, что отношение к веб-сокету в данном случае как к обычному сокету и весь протокол строится вручную, как в старые-добрые (для кого-то) времена.


    1. PaulIsh
      30.10.2022 07:37

      А что именно не работает?

      Вот у нас фрагмент на react:

      import { useCallback, useEffect, useState } from "react";
      import io, { Socket } from "socket.io-client";
      
      ...
        const [socket, setSocket] = useState<Socket | null>(null);
      
        const connectSocket = useCallback(() => {
          const socketTemp = io(url, {
            path,
            reconnection: true,
            extraHeaders: {
              Authorization: `Bearer ${accessToken}`,
            },
          });
          setSocket(socketTemp);
        }, [url, path, accessToken]);
      
        const disconnectSocket = useCallback(() => {
          socket?.disconnect();
          setSocket(null);
        }, [socket]);
      

      И на сервере всё принимается

      import {
          MessageBody,
          SubscribeMessage,
          WebSocketGateway,
          WebSocketServer,
          ConnectedSocket,
          OnGatewayConnection,
          OnGatewayDisconnect
      } from '@nestjs/websockets'
      import { ExtractJwt } from 'passport-jwt'
      
      @WebSocketGateway({ cors: true, ... })
      export class ChatGateway implements OnGatewayConnection, OnGatewayDisconnect {
          
          extractUser(request: IncomingMessage): { token: string, tokenData: TokenData } {
              try {
                  const extrator = ExtractJwt.fromAuthHeaderAsBearerToken();
                  const token = extrator(request);
                  const tokenData = this.authService.decodeUser(token);
                  return { token, tokenData };
              } catch {
                  return null;
              }
          }
      
          async handleConnection(client: SocketWithUser): Promise<string> {
              const { token, tokenData } = this.extractUser(client.request);
              ...
          }
      }


      1. anba8005
        30.10.2022 18:51

        Понятно. Повезло вам что используете socket.io, на голом websocket такое не сработает ;(

        With extraHeaders

        This only works if polling transport is enabled (which is the default). Custom headers will not be appended when using websocket as the transport. This happens because the WebSocket handshake does not honor custom headers. (For background see theWebSocket protocol RFC)

        Я пока-что не придумал ничего лучше как сделать токен короткоживущим и передавать как часть URL


  1. PaulIsh
    30.10.2022 08:06

    Для документирования websocket можно использовать пакет nestjs-asyncapi


    1. bagzon Автор
      30.10.2022 08:17

      Можно) Но тут идея про другое чуть, избавления от дублирования двух АПИ, оно одно для всех разом


  1. antq
    30.10.2022 10:17
    -2

    К сведению, websoket, server sent events, XHR это уже давно все Легаси, поскольку все можно реализовать через fetch api. Почему здесь все сидят в криокамере не ясно....


    1. bagzon Автор
      30.10.2022 10:17

      так получилось, извините


    1. shai_hulud
      30.10.2022 11:10
      +1

      Эмм, как фетч позволяет организовать двухстороннюю связь с сервером?


      1. antq
        30.10.2022 12:11

        https://developer.mozilla.org/en-US/docs/Web/API/Streams_API/Using_readable_streams
        Можно отправлять запросы от клиента обычным fetch, а читать через потоки fetch (Chunked transfer encoding).
        Соединение HTTP2 поддерживает Мультиплексирование, расходов на открытие нового соединения для каждого нового fetch запроса от клиента быть не должно.
        Вот ссылка на обсуждение, правда там тема про SSE, но сказано и про ws
        https://github.com/whatwg/html/issues/2177#issuecomment-404249432


        1. shai_hulud
          30.10.2022 20:10

          Ну вот когда реализация http2 будет хотябы в 90% веб серверов машин и веб-серверов фреймворков/библиотек можно будет сказать, что оно заменяет. К примеру nginx не проксирует http2 и не планирует, пара пара пам, фьюх. На сколько я помню ws воскрешают в http2 т.к. стримы его не заменили.