Предыдущая статья: Кэширование информации в Redis на NestJS

В этом посте я опишу как создать веб-сокетный стрим в бэкенде на NestJS и подписаться на него из фронтенд приложения на Angular.

1. Устанавливаем дополнительные библиотеки

Устанавливаем NestJS-модули для работы с websockets.

Команды

npm install --save @nestjs/websockets @nestjs/platform-socket.io @nestjs/platform-ws
Вывод консоли
$ npm install --save @nestjs/websockets @nestjs/platform-socket.io @nestjs/platform-ws

added 4 packages, removed 2 packages, and audited 2938 packages in 1m

360 packages are looking for funding
  run `npm fund` for details

42 vulnerabilities (21 low, 3 moderate, 18 high)

To address issues that do not require attention, run:
  npm audit fix

To address all issues possible (including breaking changes), run:
  npm audit fix --force

Some issues need review, and may require choosing
a different dependency.

Run `npm audit` for details.

2. Создаем контроллер который отдает серверное время

Контроллер имеет метод выдачи текущего времени и веб-сокет который каждую секунду возвращает текущее время бэкенда.

Создаем файл apps/server/src/app/time.controller.ts

import { Controller, Get } from '@nestjs/common';

import { AllowEmptyUser } from '@nestjs-mod/authorizer';
import { ApiOkResponse } from '@nestjs/swagger';
import { OnGatewayConnection, SubscribeMessage, WebSocketGateway, WsResponse } from '@nestjs/websockets';
import { interval, map, Observable } from 'rxjs';

export const ChangeTimeStream = 'ChangeTimeStream';

@AllowEmptyUser()
@WebSocketGateway({
  cors: {
    origin: '*',
  },
  path: '/ws/time',
  transports: ['websocket'],
})
@Controller()
export class TimeController implements OnGatewayConnection {
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
  handleConnection(client: any, ...args: any[]) {
    client.headers = args[0].headers;
  }

  @Get('/time')
  @ApiOkResponse({ type: Date })
  time() {
    return new Date();
  }

  @SubscribeMessage(ChangeTimeStream)
  onChangeTimeStream(): Observable<WsResponse<Date>> {
    return interval(1000).pipe(
      map(() => ({
        data: new Date(),
        event: ChangeTimeStream,
      }))
    );
  }
}

3. Добавляем контроллер в AppModule

Так как контроллер также включает в себя логику гейтвея, то провайдим контроллер в секции controllers и providers.

Обновляем файл apps/server/src/app/app.module.ts

import { createNestModule, NestModuleCategory } from '@nestjs-mod/common';

import { WebhookModule } from '@nestjs-mod-fullstack/webhook';
import { PrismaModule } from '@nestjs-mod/prisma';
import { ServeStaticModule } from '@nestjs/serve-static';
import { join } from 'path';
import { AppController } from './app.controller';
import { AppService } from './app.service';
import { TimeController } from './time.controller';

export const { AppModule } = createNestModule({
  moduleName: 'AppModule',
  moduleCategory: NestModuleCategory.feature,
  imports: [
    WebhookModule.forFeature({
      featureModuleName: 'app',
    }),
    PrismaModule.forFeature({
      contextName: 'app',
      featureModuleName: 'app',
    }),
    ...(process.env.DISABLE_SERVE_STATIC
      ? []
      : [
          ServeStaticModule.forRoot({
            rootPath: join(__dirname, '..', 'client', 'browser'),
          }),
        ]),
  ],
  controllers: [AppController, TimeController],
  providers: [AppService, TimeController],
});

4. Пересоздаем SDK для фронтенда и тестов

Команды

npm run generate

5. Добавляем утилиту для удобной работы с веб-сокетам из Angular-приложения

Создаем файл libs/common-angular/src/lib/utils/web-socket.ts

import { Observable, finalize } from 'rxjs';

export function webSocket<T>({
  address,
  eventName,
  options,
}: {
  address: string;
  eventName: string;
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
  options?: any;
}) {
  const wss = new WebSocket(address.replace('/api', '').replace('http', 'ws'), options);
  return new Observable<{ data: T; event: string }>((observer) => {
    wss.addEventListener('open', () => {
      wss.addEventListener('message', ({ data }) => {
        observer.next(JSON.parse(data.toString()));
      });
      wss.addEventListener('error', (err) => {
        observer.error(err);
        if (wss?.readyState == WebSocket.OPEN) {
          wss.close();
        }
      });
      wss.send(
        JSON.stringify({
          event: eventName,
          data: true,
        })
      );
    });
  }).pipe(
    finalize(() => {
      if (wss?.readyState == WebSocket.OPEN) {
        wss.close();
      }
    })
  );
}

6. Добавляем получение и отображение текущего серверного времени в футере страницы

Обновляем файл apps/client/src/app/app.component.ts

import { AsyncPipe } from '@angular/common';
import { ChangeDetectionStrategy, Component, OnInit } from '@angular/core';
import { Router, RouterModule } from '@angular/router';
import { User } from '@authorizerdev/authorizer-js';
import { AppRestService, TimeRestService } from '@nestjs-mod-fullstack/app-angular-rest-sdk';
import { AuthService } from '@nestjs-mod-fullstack/auth-angular';
import { webSocket } from '@nestjs-mod-fullstack/common-angular';
import { UntilDestroy, untilDestroyed } from '@ngneat/until-destroy';
import { NzLayoutModule } from 'ng-zorro-antd/layout';

import { NzMenuModule } from 'ng-zorro-antd/menu';
import { NzTypographyModule } from 'ng-zorro-antd/typography';
import { BehaviorSubject, map, merge, Observable, tap } from 'rxjs';

@UntilDestroy()
@Component({
  standalone: true,
  imports: [RouterModule, NzMenuModule, NzLayoutModule, NzTypographyModule, AsyncPipe],
  selector: 'app-root',
  templateUrl: './app.component.html',
  changeDetection: ChangeDetectionStrategy.OnPush,
})
export class AppComponent implements OnInit {
  title = 'client';
  serverMessage$ = new BehaviorSubject('');
  serverTime$ = new BehaviorSubject('');
  authUser$: Observable<User | undefined>;

  constructor(private readonly timeRestService: TimeRestService, private readonly appRestService: AppRestService, private readonly authService: AuthService, private readonly router: Router) {
    this.authUser$ = this.authService.profile$.asObservable();
  }

  ngOnInit() {
    this.appRestService
      .appControllerGetData()
      .pipe(
        tap((result) => this.serverMessage$.next(result.message)),
        untilDestroyed(this)
      )
      .subscribe();

    merge(
      this.timeRestService.timeControllerTime(),
      webSocket<string>({
        address: this.timeRestService.configuration.basePath + '/ws/time',
        eventName: 'ChangeTimeStream',
      }).pipe(map((result) => result.data))
    )
      .pipe(
        tap((result) => this.serverTime$.next(result as string)),
        untilDestroyed(this)
      )
      .subscribe();
  }

  signOut() {
    this.authService
      .signOut()
      .pipe(
        tap(() => this.router.navigate(['/home'])),
        untilDestroyed(this)
      )
      .subscribe();
  }
}

Обновляем файл apps/client/src/app/app.component.html

<nz-layout class="layout">
  <nz-header>
    <div class="logo flex items-center justify-center">{{ title }}</div>
    <ul nz-menu nzTheme="dark" nzMode="horizontal">
      <li nz-menu-item routerLink="/home">Home</li>
      <li nz-menu-item routerLink="/demo">Demo</li>
      @if (authUser$|async; as authUser) {
      <li nz-menu-item routerLink="/webhook">Webhook</li>
      <li nz-submenu [nzTitle]="'You are logged in as ' + authUser.email" [style]="{ float: 'right' }">
        <ul>
          <li nz-menu-item routerLink="/profile">Profile</li>
          <li nz-menu-item (click)="signOut()">Sign-out</li>
        </ul>
      </li>
      } @else {
      <li nz-menu-item routerLink="/sign-up" [style]="{ float: 'right' }">Sign-up</li>
      <li nz-menu-item routerLink="/sign-in" [style]="{ float: 'right' }">Sign-in</li>
      }
    </ul>
  </nz-header>
  <nz-content>
    <router-outlet></router-outlet>
  </nz-content>
  <nz-footer class="flex justify-between">
    <div id="serverMessage">{{ serverMessage$ | async }}</div>
    <div id="serverTime">{{ serverTime$ | async }}</div>
  </nz-footer>
</nz-layout>

7. Создаем E2E-тест для проверки работы логик связанных с временем

Создаем файл apps/server-e2e/src/server/time.spec.ts

import { RestClientHelper } from '@nestjs-mod-fullstack/testing';
import { isDateString } from 'class-validator';
import { lastValueFrom, take, toArray } from 'rxjs';

describe('Get server time from rest api and ws', () => {
  jest.setTimeout(60000);

  const correctStringDateLength = '2024-11-20T11:58:03.338Z'.length;
  const restClientHelper = new RestClientHelper();
  const timeApi = restClientHelper.getTimeApi();

  it('should return time from rest api', async () => {
    const time = await timeApi.timeControllerTime();

    expect(time.status).toBe(200);
    expect(time.data).toHaveLength(correctStringDateLength);
    expect(isDateString(time.data)).toBeTruthy();
  });

  it('should return time from ws', async () => {
    const last3ChangeTimeEvents = await lastValueFrom(
      restClientHelper
        .webSocket<string>({
          path: '/ws/time',
          eventName: 'ChangeTimeStream',
        })
        .pipe(take(3), toArray())
    );

    expect(last3ChangeTimeEvents).toHaveLength(3);
    expect(last3ChangeTimeEvents[0].data).toHaveLength(correctStringDateLength);
    expect(last3ChangeTimeEvents[1].data).toHaveLength(correctStringDateLength);
    expect(last3ChangeTimeEvents[2].data).toHaveLength(correctStringDateLength);
    expect(isDateString(last3ChangeTimeEvents[0].data)).toBeTruthy();
    expect(isDateString(last3ChangeTimeEvents[1].data)).toBeTruthy();
    expect(isDateString(last3ChangeTimeEvents[2].data)).toBeTruthy();
  });
});

8. Запускаем инфраструктуру с приложениями в режиме разработки и проверяем работу через E2E-тесты

Команды

npm run pm2-full:dev:start
npm run pm2-full:dev:test:e2e

Заключение

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

Возможно в следующих постах появится пример с авторизаций, но подготовительный код есть и в текущей версии (ищите: handleConnection).

Планы

В следующем посте я добавлю обработку серверных валидационных ошибок на фронтенде...

Ссылки

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


  1. jonic
    02.12.2024 10:34

    С одной стороны вроде статья больше про вебсокеты. Но все же, зачем постоянно получать время с сервера, если достаточно знать часовой пояс?


    1. mclander
      02.12.2024 10:34

      У пользователя по (куче причин) может быть несинхронизированное время на компе. Время - это вообще постоянный источник багов и хаков в реальной разработке.


      1. jonic
        02.12.2024 10:34

        У пользователя сайт по https не откроется если будет время сбито :) вот вообще не встречал кейса что бы время было сбито у клиентского устройства - это первое. А второе, что это вообще за фигня полагаться на данные клиента? А если я часы переведу нарошно? А потом удивляемся низкому качеству веб приложений.

        Разверну мысль. Кидаем запрос любой, забираем https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Date заголовок из ответа, строим оффсет/таймзону от времени клиента и времени сервера - показываем время учитывая этот офсет/таймзону. Все!


      1. PrinceKorwin
        02.12.2024 10:34

        один раз запросили время с сервера (плюс временная зона), запомнили. рассчитали дельту со временем на клиентской стороне. далее, при отображении, учитываем эту дельту.

        зачем постоянно получать время с сервера?


        1. mclander
          02.12.2024 10:34

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


    1. kaufmanendy Автор
      02.12.2024 10:34

      Нужен был небольшой пример использования веб сокетов и отправка времени это как раз небольшой пример.

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


  1. mclander
    02.12.2024 10:34

    Стать прекрасна. Я как сеньор в JS/TS с бэкграундом техлида Ангуляр и node, почти всё понял, кроме того зачем в демо nz-zorro, можно же для демо и нативные элементы, хотя это вопрос вкуса.

    А вот новичкам в ангуляр тема будет огонь: смотрите парни как вы сможете, если... разберётесь.


    1. mclander
      02.12.2024 10:34

      ЗЫ.

          this.appRestService
            .appControllerGetData()
            .pipe(
              tap((result) => this.serverMessage$.next(result.message)),
              untilDestroyed(this)
            )
            .subscribe();
      

      А не проще сразу (без сабскрайба) и мар вместо тап?

      this.serverMessage$ = 
      

      В signOut тоже грязненько, или сейчас так пишут?


      1. jonic
        02.12.2024 10:34

        Map ожидает что будет замена на результат функции. Без сабскрайба не запустится цепочка так как подписантов нет - ей не зачем исполняться.


        1. mclander
          02.12.2024 10:34

          Так на sendMessages$ есть же подписка в шаблоне


          1. jonic
            02.12.2024 10:34

            Ну есть и есть подписка в шаблоне на serverMesaage$, учитывая что это behaviorSubject то подписка вызовется два раза - один для дефолного значения сразу как шаблон подпишется и второй раз уже в новом тике евентлупа когда данные с сервера придут и проставятся через next в tap. Но без пустого subscribe ни map ни tap не вызовутся, потому что никто не слушает subject, а следовательно ему не нужно выполняться. Это я еще молчу о том что пора на сигналы переходить.


      1. kaufmanendy Автор
        02.12.2024 10:34

        Вариантов улучшить там очень много)

        Этот пост является частью цикла постов по созданию некого фулстек бойлерплейта https://habr.com/ru/search/?q=[nestjsmod]&target_type=posts&order=date, и я просто основные моменты пишу в коде и в виде постов на хабре по пути оформляю, возможно когда весь проект напишется, пойдут статьи по поводу рефакторинга.

        А почему именно написал с сабскрайбом , этот участок кода идет с поста где был простой пример интеграции Ангулар и Нест https://habr.com/ru/articles/835168/