Skip to main content

Разрабатываем чат с помощью NestJS, React и Postgres

· 16 min read

Привет, друзья!

В данном туториале мы разработаем чат с использованием следующих технологий:

  • TypeScript - статический типизатор;
  • NestJS - сервер;
  • Socket.IO - библиотека для работы в веб-сокетами;
  • React - клиент;
  • TailwindCSS - библиотека для стилизации;
  • PostgreSQL - база данных (далее - БД);
  • Prisma - ORM;
  • Docker - платформа для разработки, доставки и запуска приложений в изолированной среде - контейнере.

Функционал чата будет таким:

  • фейковая регистрация пользователей:
    • хранение имен пользователей в памяти (объекте) на сервере;
    • хранение имен и идентификаторов пользователей в localStorage на клиенте;
  • регистрация подключений и отключений пользователей на сервере и передача этой информации подключенным клиентам;
  • запись, обновление и удаление сообщений из БД в реальном времени на сервере и передача этой информации клиентам.

Репозиторий с кодом проекта.

Материалы для изучения (опционально):

Полезные расширения для VSCode (опционально):

Подготовка и настройка проекта

Обратите внимание: для успешного прохождения туториала на вашей машине должны быть установлены Node.js и Docker.

Для работы с зависимостями будет использоваться Yarn.

Создаем директорию, переходим в нее и инициализируем Node.js-проект:

mkdir react-nest-postgres-chat
cd react-nest-postgres-chat

yarn init -yp

База данных

Создаем файл docker-compose.yml следующего содержания:

services:
# название сервиса
postgres:
# образ
image: postgres
# политика перезапуска
restart: on-failure
# файл с переменными среды окружения
env_file:
- .env
# порты
ports:
- 5432:5432
# тома для постоянного хранения данных
volumes:
- postgres-data:/var/lib/postgresql/data

volumes:
postgres-data:

Создаем файл .env и определяем в нем переменные среды окружения для Postgres:

POSTGRES_USER=postgres
POSTGRES_PASSWORD=postgres
POSTGRES_DB=chat

Запускаем сервер Postgres в контейнере:

docker compose up -d

Это приводит к созданию и запуску контейнера react-nest-postgres-chat-1 с сервером Postgres в сервисе react-nest-postgres-chat.


БД доступна по адресу http://localhost:5432/chat.

ORM

Устанавливаем Prisma в качестве зависимости для разработки и инициализируем ее:

yarn add -D prisma

prisma init

Это приводит к созданию файла prisma/schema.prisma. Определяем в нем модель сообщения:

model Message {
id Int @id @default(autoincrement())
userId String
userName String
text String
createdAt DateTime @default(now())
}

Определяем переменную со строкой для подключения к БД в файле .env:

DATABASE_URL=postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@localhost:5432/${POSTGRES_DB}?schema=public

Выполняем миграцию:

yarn prisma migrate dev --name init

Это приводит к созданию директории migrations с выполненной миграцией в формате SQL.

Устанавливаем клиента Prisma и генерируем типы:

yarn add @prisma/client

yarn prisma generate

Сервер и клиент

Глобально устанавливаем Nest CLI и инициализируем Nest-проект:

yarn global add @nestjs/cli

# выбираем `yarn` для работы с зависимостями
nest new server

Создаем шаблон React + TS приложения с помощью create-vite:

# client - название приложения (и директории)
# react-ts - используемый шаблон
yarn create vite client --template react-ts

Устанавливаем concurrently:

yarn add concurrently

Определяем команду для одновременного запуска сервера и клиента в режиме разработки в файле package.json:

"scripts": {
"dev:client": "yarn --cwd client dev",
"dev:server": "yarn --cwd server start:dev",
"dev": "concurrently \"yarn dev:client\" \"yarn dev:server\""
}

Работоспособность приложения можно проверить, выполнив команду yarn dev.

На этом подготовка и настройка проекта завершены. Переходим к разработке сервера.

Сервер

Переходим в директорию сервера и устанавливаем модули для работы с сокетами:

cd server

yarn add @nestjs/websockets @nestjs/platform-socket.io

Генерируем шлюз (gateway) для модуля App:

# g ga - generate gateway
nest g ga app

Приводим файлы сервера к следующей структуре:

- src
- app.gateway.ts
- app.module.ts
- app.service.ts
- main.ts
- prisma.service.ts
- constants.ts
- types.ts
- ...

Определяем сервис Prisma в файле prisma.service.ts:

import { INestApplication, Injectable, OnModuleInit } from "@nestjs/common";
import { PrismaClient } from "@prisma/client";

@Injectable()
export class PrismaService extends PrismaClient implements OnModuleInit {
async onModuleInit() {
await this.$connect();
}

async enableShutdownHooks(app: INestApplication) {
this.$on("beforeExit", async () => {
await app.close();
});
}
}

Настраиваем данный сервис в файле main.ts:

import { NestFactory } from "@nestjs/core";
import { AppModule } from "./app.module";
// !
import { PrismaService } from "./prisma.service";

async function bootstrap() {
const app = await NestFactory.create(AppModule);

// !
const prismaService = app.get(PrismaService);
await prismaService.enableShutdownHooks(app);

// обратите внимание на порт
await app.listen(3001);
}
bootstrap();

И подключаем его в качестве провайдера в модуле App (app.module.ts):

import { Module } from "@nestjs/common";
import { AppService } from "./app.service";
// !
import { PrismaService } from "./prisma.service";

@Module({
imports: [],
controllers: [],
// !
providers: [PrismaService, AppService]
})
export class AppModule {}

Определяем адрес клиента в файле constants.ts:

export const CLIENT_URI = "http://localhost:3000";

И тип полезной нагрузки для обновления сообщения в файле types.ts:

import { Prisma } from "@prisma/client";

// { id?: number, text?: string }
export type MessageUpdatePayload = Prisma.MessageWhereUniqueInput &
Pick<Prisma.MessageUpdateInput, "text">;

Определяем методы для работы с сообщениями в файле app.service.ts:

import { Injectable } from "@nestjs/common";
import { Message, Prisma } from "@prisma/client";
import { MessageUpdatePayload } from "types";
import { PrismaService } from "./prisma.service";

@Injectable()
export class AppService {
// инициализация сервиса `Prisma`
constructor(private readonly prisma: PrismaService) {}

// получение всех сообщений
async getMessages(): Promise<Message[]> {
return this.prisma.message.findMany();
}

// удаление всех сообщений - для отладки в процессе разработки
async clearMessages(): Promise<Prisma.BatchPayload> {
return this.prisma.message.deleteMany();
}

// создание сообщения
async createMessage(data: Prisma.MessageCreateInput) {
return this.prisma.message.create({ data });
}

// обновление сообщения
async updateMessage(payload: MessageUpdatePayload) {
const { id, text } = payload;
return this.prisma.message.update({ where: { id }, data: { text } });
}

// удаление сообщения
async removeMessage(where: Prisma.MessageWhereUniqueInput) {
return this.prisma.message.delete({ where });
}
}

Осталось реализовать обработку событий сокетов в файле app.gateway.ts.

Импортируем зависимости и прочее, а также определяем переменную для хранения записей "идентификатор сокета - имя пользователя":

import {
MessageBody,
OnGatewayConnection,
OnGatewayDisconnect,
OnGatewayInit,
SubscribeMessage,
WebSocketGateway,
WebSocketServer
} from "@nestjs/websockets";
import { Prisma } from "@prisma/client";
import { Server, Socket } from "Socket.IO";
import { MessageUpdatePayload } from "types";
import { CLIENT_URI } from "../constants";
import { AppService } from "./app.service";

const users: Record<string, string> = {};

Инициализируем сокет-соединение, определяем шлюз App и инициализируем в нем сервис App:

@WebSocketGateway({
cors: {
origin: CLIENT_URI // можно указать `*` для отключения `CORS`
},
serveClient: false,
// название пространства может быть любым, но должно учитываться на клиенте
namespace: "chat"
})
export class AppGateway
implements OnGatewayInit, OnGatewayConnection, OnGatewayDisconnect
{
constructor(private readonly appService: AppService) {}

/**
* @todo
*/
}

Инициализируем сокет-сервер и регистрируем инициализацию:

@WebSocketServer() server: Server;

afterInit(server: Server) {
console.log(server);
}

Обрабатываем подключение и отключение клиентов:

// подключение
handleConnection(client: Socket, ...args: any[]) {
// обратите внимание на структуру объекта `handshake`
const userName = client.handshake.query.userName as string;
const socketId = client.id;
users[socketId] = userName;

// передаем информацию всем клиентам, кроме текущего
client.broadcast.emit("log", `${userName} connected`);
}

// отключение
handleDisconnect(client: Socket) {
const socketId = client.id;
const userName = users[socketId];
delete users[socketId];

client.broadcast.emit("log", `${userName} disconnected`);
}

Наконец, обрабатываем события сокетов:

// получение всех сообщений
@SubscribeMessage("messages:get")
async handleMessagesGet(): Promise<void> {
const messages = await this.appService.getMessages();
this.server.emit("messages", messages);
}

// удаление всех сообщений
@SubscribeMessage("messages:clear")
async handleMessagesClear(): Promise<void> {
await this.appService.clearMessages();
}

// создание сообщения
@SubscribeMessage("message:post")
async handleMessagePost(
@MessageBody()
payload: // { userId: string, userName: string, text: string }
Prisma.MessageCreateInput
): Promise<void> {
const createdMessage = await this.appService.createMessage(payload);
// можно сообщать клиентам о каждой операции по отдельности
this.server.emit("message:post", createdMessage);
// но мы пойдем более простым путем
this.handleMessagesGet();
}

// обновление сообщения
@SubscribeMessage("message:put")
async handleMessagePut(
@MessageBody()
payload: // { id: number, text: string }
MessageUpdatePayload
): Promise<void> {
const updatedMessage = await this.appService.updateMessage(payload);
this.server.emit("message:put", updatedMessage);
this.handleMessagesGet();
}

// удаление сообщения
@SubscribeMessage("message:delete")
async handleMessageDelete(
@MessageBody()
payload: // { id: number }
Prisma.MessageWhereUniqueInput
) {
const removedMessage = await this.appService.removeMessage(payload);
this.server.emit("message:delete", removedMessage);
this.handleMessagesGet();
}
import {
MessageBody,
OnGatewayConnection,
OnGatewayDisconnect,
OnGatewayInit,
SubscribeMessage,
WebSocketGateway,
WebSocketServer
} from "@nestjs/websockets";
import { Prisma } from "@prisma/client";
import { Server, Socket } from "socket.io";
import { MessageUpdatePayload } from "types";
import { CLIENT_URI } from "../constants";
import { AppService } from "./app.service";

const users: Record<string, string> = {};

@WebSocketGateway({
cors: {
origin: CLIENT_URI
},
serveClient: false,
namespace: "chat"
})
export class AppGateway
implements OnGatewayInit, OnGatewayConnection, OnGatewayDisconnect
{
constructor(private readonly appService: AppService) {}

@WebSocketServer() server: Server;

@SubscribeMessage("messages:get")
async handleMessagesGet(): Promise<void> {
const messages = await this.appService.getMessages();
this.server.emit("messages", messages);
}

@SubscribeMessage("messages:clear")
async handleMessagesClear(): Promise<void> {
await this.appService.clearMessages();
}

@SubscribeMessage("message:post")
async handleMessagePost(
@MessageBody()
payload: // { userId: string, userName: string, text: string }
Prisma.MessageCreateInput
): Promise<void> {
const createdMessage = await this.appService.createMessage(payload);
this.server.emit("message:post", createdMessage);
this.handleMessagesGet();
}

@SubscribeMessage("message:put")
async handleMessagePut(
@MessageBody()
payload: // { id: number, text: string }
MessageUpdatePayload
): Promise<void> {
const updatedMessage = await this.appService.updateMessage(payload);
this.server.emit("message:put", updatedMessage);
this.handleMessagesGet();
}

@SubscribeMessage("message:delete")
async handleMessageDelete(
@MessageBody()
payload: // { id: number }
Prisma.MessageWhereUniqueInput
) {
const removedMessage = await this.appService.removeMessage(payload);
this.server.emit("message:delete", removedMessage);
this.handleMessagesGet();
}

afterInit(server: Server) {
console.log(server);
}

handleConnection(client: Socket, ...args: any[]) {
const userName = client.handshake.query.userName as string;
const socketId = client.id;
users[socketId] = userName;

client.broadcast.emit("log", `${userName} connected`);
}

handleDisconnect(client: Socket) {
const socketId = client.id;
const userName = users[socketId];
delete users[socketId];

client.broadcast.emit("log", `${userName} disconnected`);
}
}

Подключаем шлюз в качестве провайдера в модуле App (app.module.ts):

import { Module } from "@nestjs/common";
import { AppService } from "./app.service";
// !
import { AppGateway } from "./app.gateway";
import { PrismaService } from "./prisma.service";

@Module({
imports: [],
controllers: [],
// !
providers: [PrismaService, AppService, AppGateway]
})
export class AppModule {}

На этом разработка сервера завершена. Переходим к разработке клиента.

Клиент

Переходим в директорию клиента и инициализируем Tailwind:

cd client

yarn add -D tailwindcss postcss autoprefixer
yarn tailwindcss init -p

Редактируем файл tailwind.config.js:

/** @type {import('tailwindcss').Config} */
module.exports = {
// !
content: ['./src/**/*.{ts,tsx}'],
theme: {
extend: {}
},
plugins: []
}

Добавляем директивы @tailwind в файл App.css:

@tailwind base;
@tailwind components;
@tailwind utilities;

Переопределяем дефолтный шрифт и добавляем несколько переиспользуемых стилей (reused styles):

@layer base {
html {
font-family: "Montserrat", sans-serif;
}
}

@layer components {
#root {
width: 100vw;
height: 100vh;
overflow: hidden;
}

.title {
@apply mb-4 text-2xl text-center font-bold;
}

.btn {
@apply py-2 px-4 text-white rounded-md shadow-md focus:outline-none focus:ring-2 focus:ring-opacity-75 transition-all duration-150;
}

.btn-primary {
@apply btn bg-blue-500 hover:bg-blue-600 focus:ring-blue-400;
}

.btn-success {
@apply btn bg-green-500 hover:bg-green-600 focus:ring-green-400;
}

.btn-error {
@apply btn bg-red-500 hover:bg-red-600 focus:ring-red-400;
}

.input {
@apply py-2 px-4 border border-blue-500 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-400 transition-all duration-150;
}
}

Подключаем шрифт в файле index.html:

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" href="data:." />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>React Nest Postgres Chat</title>
<!-- ! -->
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=Montserrat&display=swap"
rel="stylesheet"
/>
<!-- ! -->
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

Устанавливаем несколько библиотек:

yarn add socket.io-client react-icons react-timeago react-toastify
  • socket.io-client - клиент Socket.IO;
  • react-icons - иконки в виде компонентов;
  • react-timeago - компонент для форматирования даты и времени с обновлением в реальном времени;
  • react-toastify - библиотека для реализации всплывающих уведомлений.

Приводим файлы клиента к следующей структуре:

- src
- components
- ChatScreen.tsx
- index.ts
- WelcomeScreen.tsx
- hooks
- useChat.ts
- App.css
- App.tsx
- constants.ts
- main.ts
- types.ts
- utils.ts
- postcss.config.js
- tailwind.config.js
- ...

Определяем константы в файле constants.ts:

// ключ для `localStorage`
export const USER_INFO = "user-info";
// адрес шлюза на сервере
export const SERVER_URI = "http://localhost:3001/chat";

Определяем типы в файле types.ts:

import { Prisma } from "@prisma/client";

export type UserInfo = {
userId: string;
userName: string;
};

export type MessageUpdatePayload = Prisma.MessageWhereUniqueInput &
Pick<Prisma.MessageUpdateInput, "text">;

И утилиты в файле utils.ts:

// утилита для генерации идентификатора пользователя
export const getId = () => Math.random().toString(36).slice(2);

// утилита для работы с `localStorage`
export const storage = {
set<T>(key: string, value: T) {
localStorage.setItem(key, JSON.stringify(value));
},
get<T>(key: string): T | null {
return localStorage.getItem(key)
? JSON.parse(localStorage.getItem(key) as string)
: null;
}
};

Начнем, пожалуй, с основного компонента приложения (App.tsx):

import "react-toastify/dist/ReactToastify.css";
import "./App.css";
import { ChatScreen, WelcomeScreen } from "./components";
import { USER_INFO } from "./constants";
import { UserInfo } from "./types";
import { storage } from "./utils";

function App() {
const userInfo = storage.get<UserInfo>(USER_INFO);

return (
<section className="w-[480px] h-full mx-auto flex flex-col py-4">
{userInfo ? <ChatScreen /> : <WelcomeScreen />}
</section>
);
}

export default App;

Если в локальном хранилище содержится информация о пользователе, рендерится экран чата. В противном случае, рендерится экран приветствия.

Экран приветствия (ChatScreen.tsx):

import React, { useState } from "react";
import { FiUser } from "react-icons/fi";
import { USER_INFO } from "../constants";
import { UserInfo } from "../types";
import { getId, storage } from "../utils";

export const WelcomeScreen = () => {
const [userName, setUserName] = useState("");

const changeUserName = (e: React.ChangeEvent<HTMLInputElement>) => {
setUserName(e.target.value);
};

const setUserInfo = (e: React.FormEvent) => {
e.preventDefault();

const trimmed = userName.trim();
if (!trimmed) return;

// генерируем идентификатор пользователя
const userId = getId();
// сохраняем информацию о пользователе в локальном хранилище
storage.set<UserInfo>(USER_INFO, { userName: trimmed, userId });

// и перезагружаем локацию
location.reload();
};

return (
<section>
<h1 className="title">Welcome, friend!</h1>
<form onSubmit={setUserInfo} className="flex flex-col items-center gap-4">
<div className="flex flex-col gap-2">
<label
htmlFor="username"
className="text-lg flex items-center justify-center"
>
<span className="mr-1">
<FiUser />
</span>
<span>What is your name?</span>
</label>
<input
type="text"
id="username"
name="userName"
value={userName}
onChange={changeUserName}
required
autoComplete="off"
className="input"
/>
</div>
<button className="btn-success">Start chat</button>
</form>
</section>
);
};

Инкапсулируем логику обработки событий сокетов в кастомном хуке (useChat.ts):

import { Message, Prisma } from "@prisma/client";
import { useCallback, useEffect, useMemo, useState } from "react";
import { io, Socket } from "Socket.IO-client";
import { SERVER_URI, USER_INFO } from "../constants";
import { MessageUpdatePayload, UserInfo } from "../types";
import { storage } from "../utils";

// экземпляр сокета
let socket: Socket;

export const useChat = () => {
const userInfo = storage.get<UserInfo>(USER_INFO) as UserInfo;

// это важно: один пользователь - один сокет
if (!socket) {
socket = io(SERVER_URI, {
// помните сигнатуру объекта `handshake` на сервере?
query: {
userName: userInfo.userName
}
});
}

const [messages, setMessages] = useState<Message[]>();
const [log, setLog] = useState<string>();

useEffect(() => {
// подключение/отключение пользователя
socket.on("log", (log: string) => {
setLog(log);
});

// получение сообщений
socket.on("messages", (messages: Message[]) => {
setMessages(messages);
});

socket.emit("messages:get");
}, []);

// отправка сообщения
const send = useCallback((payload: Prisma.MessageCreateInput) => {
socket.emit("message:post", payload);
}, []);

// обновление сообщения
const update = useCallback((payload: MessageUpdatePayload) => {
socket.emit("message:put", payload);
}, []);

// удаление сообщения
const remove = useCallback((payload: Prisma.MessageWhereUniqueInput) => {
socket.emit("message:delete", payload);
}, []);

// очистка сообщения - для отладки при разработке
// можно вызывать в консоли браузера, например
window.clearMessages = useCallback(() => {
socket.emit("messages:clear");
location.reload();
}, []);

// операции
const chatActions = useMemo(
() => ({
send,
update,
remove
}),
[]
);

return { messages, log, chatActions };
};

Наконец, экран чата (ChatScreen.tsx):

import React, { useEffect, useState } from "react";
import { FiEdit2, FiSend, FiTrash } from "react-icons/fi";
import { MdOutlineClose } from "react-icons/md";
import TimeAgo from "react-timeago";
import { Slide, toast, ToastContainer } from "react-toastify";
import { USER_INFO } from "../constants";
import { useChat } from "../hooks/useChat";
import { UserInfo } from "../types";
import { storage } from "../utils";

// уведомление о подключении/отключении пользователя
const notify = (message: string) =>
toast.info(message, {
position: "top-left",
autoClose: 1000,
hideProgressBar: true,
transition: Slide
});

export const ChatScreen = () => {
const userInfo = storage.get<UserInfo>(USER_INFO) as UserInfo;
const { userId, userName } = userInfo;

// получаем сообщения, лог и операции
const { messages, log, chatActions } = useChat();

const [text, setText] = useState("");
// индикатор состояния редактирования сообщения
const [editingState, setEditingState] = useState(false);
// идентификатор редактируемого сообщения
const [editingMessageId, setEditingMessageId] = useState(0);

const changeText = (e: React.ChangeEvent<HTMLInputElement>) => {
setText(e.target.value);
};

const sendMessage = (e: React.FormEvent) => {
e.preventDefault();

const trimmed = text.trim();
if (!trimmed) return;

const message = {
userId,
userName,
text
};

// если компонент находится в состоянии редактирования
if (editingState) {
// обновляем сообщение
chatActions.update({ id: editingMessageId, text });
setEditingState(false);
// иначе
} else {
// отправляем сообщение
chatActions.send(message);
}

setText("");
};

const removeMessage = (id: number) => {
chatActions.remove({ id });
};

// эффект для отображения уведомлений при изменении лога
useEffect(() => {
if (!log) return;

notify(log);
}, [log]);

return (
<>
<h1 className="title">Let's Chat</h1>
<div className="flex-1 flex flex-col">
{messages &&
messages.length > 0 &&
messages.map((message) => {
// определяем принадлежность сообщения пользователю
const isMsgBelongsToUser = message.userId === userInfo.userId;

return (
<div
key={message.id}
// цвет фона сообщения зависит от 2 факторов:
// 1) принадлежность пользователю;
// 2) состояние редактирования
className={[
"my-2 p-2 rounded-md text-white w-1/2",
isMsgBelongsToUser
? "self-end bg-green-500"
: "self-start bg-blue-500",
editingState ? "bg-gray-300" : ""
].join(" ")}
>
<div className="flex justify-between text-sm mb-1">
<p>
By <span>{message.userName}</span>
</p>
<TimeAgo date={message.createdAt} />
</div>
<p>{message.text}</p>
{/* пользователь может редактировать и удалять только принадлежащие ему сообщения */}
{isMsgBelongsToUser && (
<div className="flex justify-end gap-2">
<button
disabled={editingState}
className={`${
editingState ? "hidden" : "text-orange-500"
}`}
// редактирование сообщения
onClick={() => {
setEditingState(true);
setEditingMessageId(message.id);
setText(message.text);
}}
>
<FiEdit2 />
</button>
<button
disabled={editingState}
className={`${
editingState ? "hidden" : "text-red-500"
}`}
// удаление сообщения
onClick={() => {
removeMessage(message.id);
}}
>
<FiTrash />
</button>
</div>
)}
</div>
);
})}
</div>
{/* отправка сообщения */}
<form onSubmit={sendMessage} className="flex items-stretch">
<div className="flex-1 flex">
<input
type="text"
id="message"
name="message"
value={text}
onChange={changeText}
required
autoComplete="off"
className="input flex-1"
/>
</div>
{editingState && (
<button
className="btn-error"
type="button"
// отмена редактирования
onClick={() => {
setEditingState(false);
setText("");
}}
>
<MdOutlineClose fontSize={18} />
</button>
)}
<button className="btn-primary">
<FiSend fontSize={18} />
</button>
</form>
{/* контейнер для уведомлений */}
<ToastContainer />
</>
);
};

На этом разработка клиента также завершена. Посмотрим, как выглядит наш чат и убедимся в его работоспособности.

Результат

Находясь в корневой директории проекта, выполняем команду yarn dev и открываем 2 вкладки браузера по адресу http://localhost:3000 (хотя бы одну вкладку необходимо открыть в режиме инкогнито):


Вводим имя пользователя, например, Bob в одной из вкладок и нажимаем Start chat:


Делаем то же самое (только с другим именем, например, Alice) в другой вкладке:


Получаем в первой вкладке сообщение о подключении Alice.

Данные пользователя можно найти в разделе Storage -> Local Storage вкладки Application инструментов разработчика в браузере:


Обмениваемся сообщениями:


Как проверить, что сообщения записываются в БД? С Prisma - сделать это проще простого. Выполняем команду yarn prisma studio и в открывшейся по адресу http://localhost:5555 вкладке выбираем модель Message:


Пробуем редактировать и удалять сообщения - все работает, как ожидается:


Закрываем одну из вкладок:


Получаем во второй вкладке сообщение об отключении Alice.

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


Благодарю за внимание и happy coding!