Предыдущая статья: Кэширование информации в 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
).
Планы
В следующем посте я добавлю обработку серверных валидационных ошибок на фронтенде...
Ссылки
https://nestjs.com - официальный сайт фреймворка
https://nestjs-mod.com - официальный сайт дополнительных утилит
https://fullstack.nestjs-mod.com - сайт из поста
https://github.com/nestjs-mod/nestjs-mod-fullstack - проект из поста
Комментарии (12)
mclander
02.12.2024 10:34Стать прекрасна. Я как сеньор в JS/TS с бэкграундом техлида Ангуляр и node, почти всё понял, кроме того зачем в демо nz-zorro, можно же для демо и нативные элементы, хотя это вопрос вкуса.
А вот новичкам в ангуляр тема будет огонь: смотрите парни как вы сможете, если... разберётесь.
mclander
02.12.2024 10:34ЗЫ.
this.appRestService .appControllerGetData() .pipe( tap((result) => this.serverMessage$.next(result.message)), untilDestroyed(this) ) .subscribe();
А не проще сразу (без сабскрайба) и мар вместо тап?
this.serverMessage$ =
В signOut тоже грязненько, или сейчас так пишут?
jonic
02.12.2024 10:34Map ожидает что будет замена на результат функции. Без сабскрайба не запустится цепочка так как подписантов нет - ей не зачем исполняться.
mclander
02.12.2024 10:34Так на sendMessages$ есть же подписка в шаблоне
jonic
02.12.2024 10:34Ну есть и есть подписка в шаблоне на serverMesaage$, учитывая что это behaviorSubject то подписка вызовется два раза - один для дефолного значения сразу как шаблон подпишется и второй раз уже в новом тике евентлупа когда данные с сервера придут и проставятся через next в tap. Но без пустого subscribe ни map ни tap не вызовутся, потому что никто не слушает subject, а следовательно ему не нужно выполняться. Это я еще молчу о том что пора на сигналы переходить.
kaufmanendy Автор
02.12.2024 10:34Вариантов улучшить там очень много)
Этот пост является частью цикла постов по созданию некого фулстек бойлерплейта https://habr.com/ru/search/?q=[nestjsmod]&target_type=posts&order=date, и я просто основные моменты пишу в коде и в виде постов на хабре по пути оформляю, возможно когда весь проект напишется, пойдут статьи по поводу рефакторинга.
А почему именно написал с сабскрайбом , этот участок кода идет с поста где был простой пример интеграции Ангулар и Нест https://habr.com/ru/articles/835168/
jonic
С одной стороны вроде статья больше про вебсокеты. Но все же, зачем постоянно получать время с сервера, если достаточно знать часовой пояс?
mclander
У пользователя по (куче причин) может быть несинхронизированное время на компе. Время - это вообще постоянный источник багов и хаков в реальной разработке.
jonic
У пользователя сайт по https не откроется если будет время сбито :) вот вообще не встречал кейса что бы время было сбито у клиентского устройства - это первое. А второе, что это вообще за фигня полагаться на данные клиента? А если я часы переведу нарошно? А потом удивляемся низкому качеству веб приложений.
Разверну мысль. Кидаем запрос любой, забираем https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Date заголовок из ответа, строим оффсет/таймзону от времени клиента и времени сервера - показываем время учитывая этот офсет/таймзону. Все!
PrinceKorwin
один раз запросили время с сервера (плюс временная зона), запомнили. рассчитали дельту со временем на клиентской стороне. далее, при отображении, учитываем эту дельту.
зачем постоянно получать время с сервера?
mclander
Я вообще не понимаю почему не можем показывать время локальное. Но будем исходить из того, что дизайн приложения такой из-за чего-то, что я не знаю
kaufmanendy Автор
Нужен был небольшой пример использования веб сокетов и отправка времени это как раз небольшой пример.
Позже будет пост с таймзонами и там будет пример общего кода переключением таймзон для любых данных которые отправляются по веб сокетам и он будет показан на примере как раз этого веб сокета с временем.