Skip to main content

Получение серверного времени через WebSockets и отображение его в Angular-приложении

В этом посте я опишу как создать веб-сокетный стрим в бэкенде на 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).

Планы

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

Ссылки

#angular #websockets #nestjsmod #fullstack #2024-11-21