- Введение
- Запуск фронтенда и страница авторизации
- Вебсокет сервер
- Функционал чата
- HTTP метод для транспортного уровня
- Postman
Уровень будет включать в себя React-приложение для чата и Web-Socket сервер для связи с бекендом
Для разработки понадобится среда исполнения NodeJS и пакетный менеджер npm.
Создадим пустой проект, и в нем - две папки:
- Frontend (здесь будет фронтенд на реакте)
- WebSocketServer (здесь будет веб-сокет сервер)
Перейдем в папку Frontend и выполним в ней npx create-react-app . --template typescript для инициализации
реакт-приложения в
текущей директории.
Получим ошибку:
Cannot create a project named "Frontend" because of npm naming restrictions:
* name can no longer contain capital letters
Please choose a different project name.
Поэтому переименуем папку в frontend с маленькой буквы.
Еще раз запустим команду npx create-react-app . --template typescript. В этот раз должно получиться.
Создалось две папки - public и src, и в них довольно много файлов, нам столько не нужно. Понадобятся файлы:
- public/index.html
- src/App.css
- src/App.tsx
- src/index.css
- src/index.tsx
Остальные файлы в этих папках можно удалять.
Кстати, не забыли сделать npm install в папке frontend? Самое время! И не забудьте добавить node_modules и
package-lock.json в .gitignore)
В нужных нам файлах тоже много чего лишнего, поэтому оставим только то что нужно:
public/index.html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="theme-color" content="#000000" />
<meta
name="description"
content="Web site created using create-react-app"
/>
<title>Messenger</title>
</head>
<body>
<noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root"></div>
</body>
</html>src/App.tsx
import React from 'react';
import './App.css';
function App() {
return (
<div className="App">
<header className="App-header">
<p>
Edit <code>src/App.tsx</code> and save to reload.
</p>
<a
className="App-link"
href="https://reactjs.org"
target="_blank"
rel="noopener noreferrer"
>
Learn React
</a>
</header>
</div>
);
}
export default App;src/App.css
.App {
display: flex;
justify-content: center;
text-align: center;
background-color: #19191A;
color: white;
height: 100%;
}Импортируем стили в src/index.css. По ходу дела будем дописывать стили в разные файлы, поэтому не забывайте их импортировать сюда) Ведь index.html импортит именно этот файл.
И еще пропишем стили для body и root
src/index.css
@import "App.css";
body {
margin: 0;
height: 100vh;
overflow: hidden;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
sans-serif;
}
#root {
height: 100%;
}src/index.tsx
import React from 'react';
import ReactDOM from 'react-dom/client';
import './index.css';
import App from './App';
const root = ReactDOM.createRoot(
document.getElementById('root') as HTMLElement
);
root.render(
<React.StrictMode>
<App />
</React.StrictMode>
);Проверяем, что все запускается npm run start
Приложение будет состоять из страницы авторизации и чата. Для такого небольшого приложения использовать роутинг не будем (хотя в более крупных проектах он необходим - очень улучшает UX и в целом делает код более удобным).
В проекте будем использовать библиотеку Material UI - она позволит импортировать уже стилизованные компоненты, что ускорит разработку нашего проекта (ведь писать стили это довольно долгое занятие).
npm install @mui/material @emotion/react @emotion/styled
Теперь при каждом использовании компонентов необходимо будет просто ипортировать их в топе файла:
import {TextField} from '@mui/material';Определимся со структурой проекта. В папке src будут исходники нашего приложения - стор, страницы, компоненты.
Создадим папку src/components/ - здесь будут находится наши компоненты для верстки. Внутри создадим папки и файлы для компонентов:
- Login/
- Login.css
- Login.tsx
Теперь давайте отрисуем страницу авторизации (Login). Пока без функционала. Просто страница с версткой.
Login.tsx
export const Login = () => {
return (
<>
<div className="login">
<div className="login--card">
<div className="login--header">Вход</div>
<TextField id="outlined-basic" label="Введите имя" variant="outlined"
className="login--input"
/>
<Button variant="contained">
Войти
</Button>
</div>
</div>
</>
);
}И добавим немного стилей. Будем писать стили по методологии БЭМ:
блок--элемент--модификатор (или можно еще блок--элемент__модификатор - как вам удобнее)
Это распросраненная методология определения классов для компонентов верстки. Позволяет довольно удобно выделять логические блоки в верстке. Еще очень помогает, когда необходимо повесить много стилей на какие то компоненты - убирает лишний код из html (jsx) верстки. Улучшает переиспользуемость компонентов.
Login.css
.login {
display: flex;
justify-content: center;
align-items: center;
width: 100%;
height: 100%;
}
.login--card {
background-color: #323232;
width: 25%;
padding: 5em;
border-radius: 20px;
display: flex;
flex-direction: column;
gap: 3em;
}
.login--input {
color: white;
}
.login--header {
font-size: 2em;
font-weight: bold;
}Добавим компонент в наше приложение:
App.tsx
import React from 'react';
function App() {
return (
<>
<div className="App">
<Login/>
</div>
</>
);
}
export default App;Проверим как выглядит наша страница авторизации:
Супер! Начало верстки положено. Теперь надо бы добавить функционал для нашего приложения - перейдем к вебсокет-серверу.
Перейдем в папку WebSocketServer, создадим там файл index.ts
Для запуска typescript файлов понадобится собственно typescript и движок для него ts-node. Установите их командами:
Либо локально для проекта:
npm install -D typescriptnpm install -D ts-node
Либо глобально:
npm install -g typescriptnpm install -g ts-node
В папке WebSocketServer также инициализируем npm проект:
npm init
Перейдем в файл index.ts. Импортируем пакеты для сервера:
import express from 'express'
import axios from 'axios'
import http from 'http'
import ws, {type WebSocket} from 'ws'Не забудьте импортировать пакеты как npm-зависимости через npm install ...
Если сервер не будет запускаться или будут проблемы с зависимостями, можете подтянуть в package.json
разделы dependencies и devDependencies как у меня и сделать npm install.
"dependencies": {
"@reduxjs/toolkit": "^2.2.1",
"arg": "^5.0.2",
"axios": "^1.6.7",
"express": "^4.18.2",
"react-redux": "^9.1.0",
"swagger-autogen": "^2.23.7",
"swagger-ui-express": "^5.0.0",
"ws": "^8.16.0"
},
"devDependencies": {
"@types/express": "^4.17.21",
"@types/swagger-ui-express": "^4.1.6",
"@types/ws": "^8.5.10",
"@typescript-eslint/eslint-plugin": "^6.21.0",
"eslint": "^8.57.0",
"eslint-config-standard-with-typescript": "^43.0.1",
"eslint-plugin-import": "^2.29.1",
"eslint-plugin-n": "^16.6.2",
"eslint-plugin-promise": "^6.1.1",
"ts-node": "^10.9.2",
"typescript": "^5.4.5"
}
В файле index.ts объявим несколько констант:
const port: number = 8001; // порт на котором будет развернут этот (вебсокет) сервер
const hostname = 'localhost'; // адрес вебсокет сервера
const transportLevelPort = 8002; // порт сервера транспортного уровня
const transportLevelHostname = '192.168.12.172'; // адрес сервера транспортного уровняОбъявим тип MessageCard для описания сообщений. В нем будет содержаться id сообщения, имя пользователя, который отправил сообщение, время отправки, текст и признак ошибки (дошло ли сообщение)
interface MessageCard {
id?: number
username: string
data?: string
send_time?: string
error?: string
}В записи о пользователе будет id пользователя и вебсокет-соединение
type Users = Record<string, Array<{
id: number
ws: WebSocket
}>>Теперь создадим сам сервер:
const app = express(); // создание экземпляра приложения express
const server = http.createServer(app); // создание HTTP-сервера
// Используйте express.json() для парсинга JSON тела запроса
app.use(express.json());
// запуск сервера приложения
server.listen(port, hostname, () => {
console.log(`Server started at http://${hostname}:${port}`)
})Создадим вебсокет сервер и юзеров, которые будут к нам подключаться:
const wss = new ws.WebSocketServer({server})
const users: Users = {}Теперь опишем само вебсокет соединение. Линтер может выбрасывать предупреждения, но работать будет, так что игнорируем некоторые строчки.
// обрабочик на коннект для вебсокета
wss.on('connection', (websocketConnection: WebSocket, req: Request) => {
if (req.url.length === 0) {
console.log(`Error: req.url = ${req.url}`)
return
}
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-expect-error
const url = new URL(req?.url, `http://${req.headers.host}`)
const username = url.searchParams.get('username') // берем имя юзера из параметра
if (username !== null) {
console.log(`[open] Connected, username: ${username}`)
if (username in users) {
users[username] = [...users[username], {id: users[username].length, ws: websocketConnection}]
} else {
users[username] = [{id: 1, ws: websocketConnection}]
}
} else {
console.log('[open] Connected')
}
console.log('users collection', users)
// обработчик на закрытие
websocketConnection.on('close', (event: any) => {
console.log(username, '[close] Соединение прервано', event)
delete users.username
})
})На каждый запрос на новое вебсокет соединение будем добавлять нового пользователя в нащ список и подпишем его на событие 'close' когда соединение закрывается.
На событие 'close' будем удалять пользователя из списка.
Если запутались в коде, вот еще раз полный пример файла:
import express from 'express';
import axios from 'axios';
import http from 'http';
import ws, {type WebSocket} from 'ws';
const port: number = 8001; // порт на котором будет развернут этот (вебсокет) сервер
const hostname = 'localhost'; // адрес вебсокет сервера
const transportLevelPort = 8002; // порт сервера транспортного уровня
const transportLevelHostname = '192.168.12.172'; // адрес сервера транспортного уровня
interface MessageCard {
id?: number
username: string
data?: string
send_time?: string
error?: string
}
type Users = Record<string, Array<{
id: number
ws: WebSocket
}>>
const app = express() // создание экземпляра приложения express
const server = http.createServer(app) // создание HTTP-сервера
// Используйте express.json() для парсинга JSON тела запроса
app.use(express.json())
// запуск сервера приложения
server.listen(port, hostname, () => {
console.log(`Server started at http://${hostname}:${port}`)
})
const wss = new ws.WebSocketServer({server})
const users: Users = {}
// обрабочик на коннект для вебсокета
wss.on('connection', (websocketConnection: WebSocket, req: Request) => {
if (req.url.length === 0) {
console.log(`Error: req.url = ${req.url}`)
return
}
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-expect-error
const url = new URL(req?.url, `http://${req.headers.host}`)
const username = url.searchParams.get('username') // берем имя юзера из параметра
if (username !== null) {
console.log(`[open] Connected, username: ${username}`)
if (username in users) {
users[username] = [...users[username], {id: users[username].length, ws: websocketConnection}]
} else {
users[username] = [{id: 1, ws: websocketConnection}]
}
} else {
console.log('[open] Connected')
}
// обработчик на закрытие
websocketConnection.on('close', (event: any) => {
console.log(username, '[close] Соединение прервано', event)
delete users.username
})
})Еще необходимо добавить в проект (в папку WebSocketServer) файл tsconfig.json - в нем будут описаны конфигурации для
сборки проекта:
{
"compilerOptions": {
"target": "ES6",
"module": "CommonJS",
"sourceMap": true,
"esModuleInterop": true,
"strictNullChecks": true
}
}Выполняем ts-node index.ts, сервер должен запуститься и в консоли должна появиться надпись
Server started at http://localhost:8001 - сервер запустился на порту 8001
На фронтенде добавим страницу с чатом. Когда пользователь будет авторизоваться, для него откроется вебсокет-соединение, и его редиректнет на страницу с чатом.
Добавим в папку frontend/src/components/ файлы:
- Chat/
- Chat.css
- Chat.tsx
- Input/
- Input.css
- Input.tsx
Chat - наша основная страница. А компонент Input будет отвечать за инпут-поле, в которое пользователь будет писать сообщения. Считается хорошей практикой делить приложение на компоненты, которые должны отвечать за определенный функционал. Например наш Input будет отвечать за ввод сообщений, а в будущем мы добавим компонент MessageCard, который будет представлять собой карточку с сообщением пользователя.
Пока что наш чат не будет отправлять сообщение. Просто проверим работу вебсокет сервера - при авторизации пользователя должно открыться соедиение.
В компоненте чата будет реализована логика логаута - чистим стор при выходе юзера. Здесь же будет отображаться массив карточек-сообщений, и еще инпут для ввода сообщений.
Важный момент! Перед каждым react-компонетом мы будем объявлять какие параметры он принимает - typescript постоянно следит за нами и не допустит нетипизированных мест, а мы не запутаемся в параметрах компонента.
Chat.tsx
import {useUser} from "../../hooks/useUser";
import {Message} from "../../consts";
import {Input} from "../Input/Input";
import {MessageCard} from "../MessageCard/MessageCard";
import {Button} from "@mui/material";
import React from "react";
type ChatProps = {
ws: WebSocket | undefined;
}
export const Chat: React.FC<ChatProps> = ({ws}) => {
const {login, resetUser} = useUser();
// при логауте закрываем соединение
const handleClickLogoutBtn = () => {
resetUser();
if (ws) {
ws.close(4000, login);
} else {
console.log("ws.close(4000, 'User logout'); don't work");
}
};
return (
<>
<div className="chat">
<div className="chat--header">
{/* имя нашего пользователя */}
Сообщения от {login}
</div>
<div className="chat--body">
{/* здесь в будущем будут находиться карточки с сообщениями */}
</div>
<Input ws={ws} setMessageArray={setMessageArray}/>
</div>
<Button variant="contained"
onClick={handleClickLogoutBtn}
style={{
height: 'fit-content',
margin: '1em'
}}
>
Выход
</Button>
</>
);
}Chat.css
.chat {
background-color: #323232;
height: 100%;
width: 50%;
}
.chat--header {
width: 100%;
padding: 1em;
border-bottom: #19191A solid 1px;
}
.chat--container {
overflow-y: auto;
height: 85vh;
}Input.tsx
import React, {useState} from "react";
import {useUser} from "../../hooks/useUser";
import {Message} from "../../consts";
import {Button, TextField} from "@mui/material";
export const Input = () => {
return (
<>
<div className="chat-input">
<input className="chat--input"
placeholder="Введите сообщение"
style={{width: '100%'}}
/>
<Button variant="contained"
style={{
margin: '0 2em',
padding: '0 2em',
}}
>
Отправить
</Button>
</div>
</>
);
}Input.css
.chat-input {
display: flex;
height: 3em;
position: fixed;
bottom: 1em;
width: 50%;
padding-left: 1em;
}
.chat--input {
background-color: #505051;
border: none;
border-radius: 0.5em;
}в папке src создадим файл consts.ts - здесь будут константы и модельки
export const hostname = 'localhost';
export type Message = {
id?: number;
username?: string;
data?: string;
send_time?: string;
error?: string;
};Для того, чтобы при перезагрузке пользователя не разлогинивало, можем воспользоваться Redux с персистентным хранилищем - данные о пользователе будут храниться в localStorage и не чиститься после перезагрузки.
Установим redux-toolkit: npm install @reduxjs/toolkit react-redux redux-persist
Создадим папку store, и в ней файлы:
- index.ts - файл для экспорта всего необходимого
- store.ts - здесь будет описано хранилище
- types.ts - тут будут объявлены типы для стора
- userSlice.ts - слайс, в котором будет храниться информация о пользователе
Конфигурация файлов довольно стандартная для редакса:
index.ts
export { store } from './store'; // экспортируем экземпляр стора, чтобы потом брать из него что-тоstore.ts
import { configureStore, combineReducers } from '@reduxjs/toolkit';
import userSlice from './userSlice';
import { persistReducer, persistStore } from 'redux-persist';
import storage from 'redux-persist/lib/storage';
// конфиг для нащщего стора
const persistConfig = {
key: 'root',
storage,
};
const rootReducer = combineReducers({
user: userSlice, // стор состоит из одного слайса
});
// создаем редьюсера
const persistedReducer = persistReducer(persistConfig, rootReducer);
// конфигурирем стор
export const store = configureStore({
reducer: persistedReducer,
middleware: (getDefaultMiddleware) =>
getDefaultMiddleware({
serializableCheck: false,
}),
});
export const persistor = persistStore(store); // стор персистентный, чтобы данные сохранялись в localStoragetypes.ts
import type { store } from './store';
export type RootState = ReturnType<typeof store.getState>;
export type AppDispatch = typeof store.dispatch;userSlice.ts
import { createSlice } from '@reduxjs/toolkit';
const initialState = {
login: '', // все что нужно знать о пользователе - его логин
};
const dataSlice = createSlice({
name: 'user',
initialState: {
Data: initialState
},
reducers: {
setData(state, { payload }) {
state.Data = payload.Data; // будем сеттить юзера при логине
},
cleanUser: (state) => {
state.Data.login = ''; // чистим логин юзера когда он выйдет
},
},
});
export const {
setData: setUserDataAction,
cleanUser: cleanUserDataAction
} = dataSlice.actions;
export default dataSlice.reducer;Теперь подумаем, как можно обращаться к хранилищу для получения информации о юзере. Хорошей практикой считается использовать для этого кастомные хуки. Давайте создадим папку hooks и в ней файл useUser.ts - назовем файл как наш хук (а любой хук, как известно, начинается с префикса use).
import { useDispatch, useSelector } from 'react-redux';
import {
setUserDataAction,
cleanUserDataAction,
} from '../store/userSlice';
import { RootState } from '../store/types';
type State = {
userInfo: {
Data: {
login: string;
};
};
};
export const useUser = () => {
const { Data } = useSelector((state: RootState) => state.user);
const { login } = Data; // возьмем у юзера логин
const dispatch = useDispatch();
const setUser = (value: State) => { // метод для загрузки логина юзера в стор
dispatch(setUserDataAction(value.userInfo));
};
const resetUser = () => { // метод для чистки слайса
dispatch(cleanUserDataAction());
};
return {
login,
setUser,
resetUser,
};
};Поправьте index.tsx для работы со стором:
import React from 'react';
import ReactDOM from 'react-dom/client';
import './index.css';
import App from './App';
import {store} from "./store";
import { Provider } from 'react-redux';
const root = ReactDOM.createRoot(
document.getElementById('root') as HTMLElement
);
root.render(
<Provider store={store}>
<App />
</Provider>,
);И обновим файл Login.tsx. Будем открывать вебсокет соединение при логине.
Login.tsx
import React, {useState} from "react";
import {useUser} from "../../hooks/useUser";
import {Button, TextField} from '@mui/material';
import {hostname} from "../../consts";
type LoginProps = {
ws: WebSocket | undefined;
setWs: (ws: WebSocket | undefined) => void;
createWebSocket: (url: string) => WebSocket | undefined;
}
export const Login: React.FC<LoginProps> = ({ws, setWs, createWebSocket}) => {
const {login, setUser} = useUser();
const [userName, setUsername] = useState(login);
const handleChangeLogin = (event: any) => {
setUsername(event.target.value);
};
// при авторизации регистрируем новое вебсокет соединеие
const handleClickSignInBtn = () => {
setUser({
userInfo: {
Data: {
login: userName,
},
},
});
if (ws) {
ws.close(1000, 'User enter userName');
} else {
console.log('ws.close(1000, User enter userName); dont work');
}
setWs(
createWebSocket(
`ws://${hostname}:8001/?username=${encodeURIComponent(userName)}`,
),
);
};
return (
<>
<div className="login">
<div className="login--card">
<div className="login--header">Вход</div>
<TextField id="outlined-basic" label="Введите имя" variant="outlined"
className="login--input"
value={userName}
onChange={handleChangeLogin}
/>
<Button variant="contained"
onClick={handleClickSignInBtn}
>
Войти
</Button>
</div>
</div>
</>
);
}App.tsx
import React, {useEffect, useState} from 'react';
import './App.css';
import {useUser} from "./hooks/useUser";
import {Login} from "./components/Login/Login";
import {hostname, Message} from "./consts";
import {Chat} from "./components/Chat/Chat";
function App() {
const {login} = useUser();
const [ws, setWs] = useState<WebSocket | undefined>(); // весокет
const [messageArray, setMessageArray] = useState<Message[]>([]);
// создание вебсокета должно быть после рендера - поместим в useEffect
useEffect(() => {
if (login) {
setWs(
createWebSocket(
`ws://${hostname}:8001/?username=${encodeURIComponent(login)}`,
),
);
} else {
setWs(new WebSocket(`ws://${hostname}`));
}
}, []);
// создаем вебсокет
const createWebSocket = (url: string) => {
const ws = new WebSocket(url); // создаем новый инстанс
// обработчик на открытие соединения
ws.onopen = function () {
console.log('WebSocket connection opened');
};
// обработчик на получение сообщения
ws.onmessage = function (event) {
const msgString = event.data;
const message = JSON.parse(msgString); // парсим
console.log('MessageCard from server:', message);
// сеттим сообщение в массив
setMessageArray((currentMsgArray: Message[]) => [...currentMsgArray, message]);
};
hooks
// обработчик на закрытие
ws.onclose = function () {
console.log('WebSocket connection closed');
};
// обработчик на ошибку
ws.onerror = function (event) {
console.error('WebSocket error:', event);
};
return ws;
};
// дальше будут две страницы - с чатом и авторизацией
return (
<>
<div className="App">
{login ?
<Chat messages={messageArray} ws={ws} messageArray={messageArray} setMessageArray={setMessageArray}/>
:
<Login ws={ws} setWs={setWs} createWebSocket={createWebSocket}/>
}
</div>
</>
);
}
export default App;Давайте проверим, что при логине у пользователя открывается вебсокет-соединение и он добавляется в коллекцию пользователей на сервере:
Осталось сделать отправку и получение сообщений. Займемся их отрисовкой на фронте
Внутри src/components/ создадим папку и файлы для карточки сообщения:
- MessageCard
- MessageCard.css
- MessageCard.tsx
В приложении будут два компонента, отвечающие за страницы - Login и Chat.
В чате добавим карточки сообщений.
Chat
import {useUser} from "../../hooks/useUser";
import {Message} from "../../consts";
import {Input} from "../Input/Input";
import {MessageCard} from "../MessageCard/MessageCard";
import {Button} from "@mui/material";
import React from "react";
type ChatProps = {
messages: Message[];
ws: WebSocket | undefined;
messageArray: Message[];
setMessageArray: (msg: Message[]) => void;
}
export const Chat: React.FC<ChatProps> = ({messages, ws, messageArray, setMessageArray}) => {
const {login, resetUser} = useUser();
// при логауте закрываем соединение
const handleClickLogoutBtn = () => {
resetUser();
if (ws) {
ws.close(4000, login);
} else {
console.log("ws.close(4000, 'User logout'); don't work");
}
};
return (
<>
<div className="chat">
<div className="chat--header">
Сообщения от {login}
</div>
<div className="chat--body">
{messageArray.length > 0 ?
<div className="chat--container">
{messageArray.map((msg: Message, index: number) => (
<div key={index} className="chat--msg">
<MessageCard msg={msg}/>
</div>
))}
</div>
:
<div className="chat--no-msg">
<div style={{fontSize: '2em', color: 'gray'}}>Здесь будут сообщения</div>
</div>
}
</div>
<Input ws={ws} setMessageArray={setMessageArray}/>
</div>
<Button variant="contained"
onClick={handleClickLogoutBtn}
style={{
height: 'fit-content',
margin: '1em'
}}
>
Выход
</Button>
</>
);
}В карточке будем отображать информацию о сообщении - имя пользователя, дату отправки сообщения и само сообщение. В зависимости от того, отправил ли наш пользователь сообщение или же другой, будем менять стили карточки. Для нашего пользователя будет голубой фон, и сообщение будет находиться справа. Если же это сообщение от другого пользователя, фон будет серым, а сообщение будет находиться слева.
MessageCard.tsx
import React from "react";
import {useUser} from "../../hooks/useUser";
import {Message} from "../../consts";
type MessageProps = {
msg: Message;
}
export const MessageCard: React.FC<MessageProps> = ({msg}) => {
const {login} = useUser();
// функция для форматирования времени, чтобы оно красиво отображалось
function formatTime(isoDateTime: string | number | Date) {
const dateTime = new Date(isoDateTime);
return dateTime.toLocaleString('en-US', {
timeZone: 'UTC',
hour: 'numeric',
minute: 'numeric',
second: 'numeric',
hour12: false
});
}
return (
<>
<div className={`${msg.username === login ? "msg--own" : "msg--alien"} msg--container`}>
<div className={`${msg.username === login ? "msg--own--reverse" : "msg--alien--reverse"} msg`}>
<div className="msg--service">
<div>
{msg.username ?? 'Аноним'}
</div>
<div style={{color: 'gray', marginLeft: '1em'}}>
{formatTime(msg.send_time ?? String(new Date()))}
</div>
</div>
{msg.error ?
<div style={{color: 'gray'}}>Ошибка при отправке: {msg.error}</div>
:
<div className={`msg--text ${msg.username === login ? "msg--bg--own" : "msg--bg--alien"}`}>{msg.data}</div>
}
</div>
</div>
</>
);
}MessageCard.css
.msg--container {
width: 100%;
display: flex;
}
.msg--own {
justify-content: end;
}
.msg--alien {
justify-content: start;
}
.msg {
max-width: 60%;
padding: 1em;
display: flex;
flex-direction: column;
}
.msg--own--reverse {
align-items: end;
}
.msg--alien--reverse {
align-items: start;
}
.msg--service {
display: flex;
width: 100%;
margin-bottom: 0.5em;
}
.msg--text {
width: fit-content;
padding: 0.5em 1em;
border-radius: 0.5em;
display: flex;
justify-content: start;
}
.msg--bg--own {
background-color: #1565c0;
}
.msg--bg--alien {
background-color: gray;
}Для инпута реализуем логику для написания и отправки сообщения.
Input.tsx
import React, {useState} from "react";
import {useUser} from "../../hooks/useUser";
import {Message} from "../../consts";
import {Button, TextField} from "@mui/material";
type InputProps = {
ws: any,
setMessageArray: any,
}
export const Input: React.FC<InputProps> = ({ws, setMessageArray}) => {
const {login} = useUser();
const [message, setMessage] = useState<Message>({data: ''});
// в инпуте делаем обработчик на изменение состояния инпута
const handleChangeMessage = (event: any) => {
const newMsg: Message = {
data: event.target.value,
username: login,
send_time: String(new Date()),
};
setMessage(newMsg);
};
// на кнопку Отправить мы должны посать сообщение по вебсокету
const handleClickSendMessBtn = () => {
if (login && ws) {
message.send_time = '2024-02-23T13:45:41Z';
const msgJSON = JSON.stringify(message);
ws.send(msgJSON);
setMessageArray((currentMsgArray: any) => [...currentMsgArray, message]);
}
};
return (
<>
<div className="chat-input">
<input className="chat--input"
placeholder="Введите сообщение"
value={message.data}
onChange={handleChangeMessage}
style={{width: '100%'}}
/>
<Button variant="contained"
onClick={handleClickSendMessBtn}
style={{
margin: '0 2em',
padding: '0 2em',
}}
>
Отправить
</Button>
</div>
</>
);
}На сервере добавляем методы для отправки сообщений всем пользователям в коллекции.
Метод для отправки сообщений пользователям:
function sendMessageToOtherUsers(username: string, message: MessageCard): void {
const msgString = JSON.stringify(message) // сериализуем сообщение
for (const key in users) { // рассылаем всем юзерам
console.log(`[array] key: ${key}, users[keys]: ${JSON.stringify(users[key])} username: ${username}`)
if (key !== username) { // кроме нашего юзера
users[key].forEach(element => {
element.ws.send(msgString)
})
}
}
}А вот так будет выглядеть файл целиком (вдруг запутались)
import express from 'express';
import axios from 'axios';
import http from 'http';
import ws, { type WebSocket } from 'ws';
const port: number = 8001; // порт на котором будет развернут этот (вебсокет) сервер
const hostname = 'localhost'; // адрес вебсокет сервера
const transportLevelPort = 8002; // порт сервера транспортного уровня
const transportLevelHostname = '192.168.12.172'; // адрес сервера транспортного уровня
interface Message {
id?: number
username: string
data?: string
send_time?: string
error?: string
}
type Users = Record<string, Array<{
id: number
ws: WebSocket
}>>
const app = express() // создание экземпляра приложения express
const server = http.createServer(app) // создание HTTP-сервера
// Используйте express.json() для парсинга JSON тела запроса
app.use(express.json())
app.post('/receive', (req: { body: Message }, res: { sendStatus: (arg0: number) => void }) => {
const message: Message = req.body
sendMessageToOtherUsers(message.username, message)
res.sendStatus(200)
})
// запуск сервера приложения
server.listen(port, hostname, () => {
console.log(`Server started at http://${hostname}:${port}`)
})
const wss = new ws.WebSocketServer({ server })
const users: Users = {}
function sendMessageToOtherUsers (username: string, message: Message): void {
const msgString = JSON.stringify(message)
for (const key in users) {
console.log(`[array] key: ${key}, users[keys]: ${JSON.stringify(users[key])} username: ${username}`)
if (key !== username) {
users[key].forEach(element => {
element.ws.send(msgString)
})
}
}
}
wss.on('connection', (websocketConnection: WebSocket, req: Request) => {
if (req.url.length === 0) {
console.log(`Error: req.url = ${req.url}`)
return
}
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-expect-error
const url = new URL(req?.url, `http://${req.headers.host}`)
const username = url.searchParams.get('username')
if (username !== null) {
console.log(`[open] Connected, username: ${username}`)
if (username in users) {
users[username] = [...users[username], { id: users[username].length, ws: websocketConnection }]
} else {
users[username] = [{ id: 1, ws: websocketConnection }]
}
} else {
console.log('[open] Connected')
}
console.log('users collection', users)
websocketConnection.on('message', (messageString: string) => {
console.log('[message] Received from ' + username + ': ' + messageString)
const message: Message = JSON.parse(messageString)
message.username = message.username ?? username
sendMessageToOtherUsers(message.username, message)
})
websocketConnection.on('close', (event: any) => {
console.log(username, '[close] Соединение прервано', event)
delete users.username
})
})Проверяем, что сообщения получают все пользователи:
Добавим HTTP метод еа сервер, чтобы отправлять сообщение на транспортный уровень:
index.ts
import express from 'express';
import axios from 'axios';
import http from 'http';
import ws, { type WebSocket } from 'ws';
const port: number = 8001; // порт на котором будет развернут этот (вебсокет) сервер
const hostname = 'localhost'; // адрес вебсокет сервера
const transportLevelPort = 8002; // порт сервера транспортного уровня
const transportLevelHostname = '192.168.12.172'; // адрес сервера транспортного уровня
interface Message {
id?: number
username: string
data?: string
send_time?: string
error?: string
}
type Users = Record<string, Array<{
id: number
ws: WebSocket
}>>
const app = express() // создание экземпляра приложения express
const server = http.createServer(app) // создание HTTP-сервера
// Используйте express.json() для парсинга JSON тела запроса
app.use(express.json())
app.post('/receive', (req: { body: Message }, res: { sendStatus: (arg0: number) => void }) => {
const message: Message = req.body
sendMessageToOtherUsers(message.username, message)
res.sendStatus(200)
})
// запуск сервера приложения
server.listen(port, hostname, () => {
console.log(`Server started at http://${hostname}:${port}`)
})
const wss = new ws.WebSocketServer({ server })
const users: Users = {}
const sendMsgToTransportLevel = async (message: Message): Promise<void> => {
const response = await axios.post(`http://${transportLevelHostname}:${transportLevelPort}/send`, message)
if (response.status !== 200) {
message.error = 'Error from transport level by sending message'
users[message.username].forEach(element => {
if (message.id === element.id) {
element.ws.send(JSON.stringify(message))
}
})
}
console.log('Response from transport level: ', response)
}
function sendMessageToOtherUsers (username: string, message: Message): void {
const msgString = JSON.stringify(message)
for (const key in users) {
console.log(`[array] key: ${key}, users[keys]: ${JSON.stringify(users[key])} username: ${username}`)
if (key !== username) {
users[key].forEach(element => {
element.ws.send(msgString)
})
}
}
}
wss.on('connection', (websocketConnection: WebSocket, req: Request) => {
if (req.url.length === 0) {
console.log(`Error: req.url = ${req.url}`)
return
}
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-expect-error
const url = new URL(req?.url, `http://${req.headers.host}`)
const username = url.searchParams.get('username')
if (username !== null) {
console.log(`[open] Connected, username: ${username}`)
if (username in users) {
users[username] = [...users[username], { id: users[username].length, ws: websocketConnection }]
} else {
users[username] = [{ id: 1, ws: websocketConnection }]
}
} else {
console.log('[open] Connected')
}
console.log('users collection', users)
websocketConnection.on('message', (messageString: string) => {
console.log('[message] Received from ' + username + ': ' + messageString)
const message: Message = JSON.parse(messageString)
message.username = message.username ?? username
void sendMsgToTransportLevel(message)
})
websocketConnection.on('close', (event: any) => {
console.log(username, '[close] Соединение прервано', event)
delete users.username
})
})Давайте протестируем наше приложение в постмане. Создаем новый POST запрос с урлом http://localhost:8001/receive
Тело запроса выбираем raw, и вставляем туда json
{
"username": "Alik",
"data": "Привет! Как там с домашкой?",
"send_time": "2024-02-23T13:45:41Z",
"error": ""
}Отправляем, и получаем на странице результат:
Получилось! Поздравляю, мы с вами освоили вебсокеты и сделали простой чат, к которому можно подключаться и отправлять сообщения всем пользователям в чате.
💡 Скорее всего в своем проекте вы будете исполльзовать git для контроля версий своего проекта. Небольшой совет по использованию гита:
Когда вы ставите зависимости в проекте с помощью npm, у вас появляются две очень тяжелые штуки - папка node_modules и файл package-lock.json.
В node_modules подтягиваются все зависимости из npm-реестра, необходимые для вашего проекта.
В package-lock. json файле перечислены зависимости вашего приложения и зависимости всех его зависимостей. Другими словами, он описывает, какую версию каждого отдельного пакета вы установили.
Они очень тяжелые (даже в одном package-lock.json могут быть тысячи строк для небольшого проекта). Но эти файлы создаются автоматически, когда вы подтягиваете зависимости через npm install. Поэтому нет смысла хранить их в гит-репозитории. Их в любой момент можно сгенерировать (npm install), а вся необходимая информация для них будет храниться в package.json.
Поэтому хорошей практикой считается помещать их в .gitignore файл.










