diff --git a/client/package-lock.json b/client/package-lock.json index 37f90c3..1ab4509 100644 --- a/client/package-lock.json +++ b/client/package-lock.json @@ -13066,6 +13066,11 @@ "warning": "^3.0.0" } }, + "react-use-websocket": { + "version": "2.7.1", + "resolved": "https://registry.npmjs.org/react-use-websocket/-/react-use-websocket-2.7.1.tgz", + "integrity": "sha512-n0H429jQGaZAnn8BJljCcAgeSezXYzTLO0tb5QvdZnNjhAA8Br3A3GjU5ziNMlxant++Iv/IRQRNMKCfWnsHrQ==" + }, "read-pkg": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/read-pkg/-/read-pkg-2.0.0.tgz", diff --git a/client/package.json b/client/package.json index 13395a9..d340ae3 100644 --- a/client/package.json +++ b/client/package.json @@ -16,6 +16,7 @@ "react-scripts": "4.0.3", "react-stack-grid": "^0.7.1", "react-textarea-autosize": "^8.3.3", + "react-use-websocket": "^2.7.1", "remark-gfm": "^1.0.0", "web-vitals": "^0.2.4", "workbox-background-sync": "^5.1.4", diff --git a/client/src/Hooks/useDebouncedEffect.hook.js b/client/src/Hooks/useDebouncedEffect.hook.js deleted file mode 100644 index 1d61219..0000000 --- a/client/src/Hooks/useDebouncedEffect.hook.js +++ /dev/null @@ -1,37 +0,0 @@ -/** @file useDebouncedEffect.hook.js */ -import { useState, useEffect } from 'react'; - -/** - * Хук debounce - * @param {*} value - * @param {*} delay - */ -function useDebounce(value, delay) { - const [debouncedValue, setDebouncedValue] = useState(value); - useEffect( - () => { - const handler = setTimeout(() => { - setDebouncedValue(value); - }, delay); - return () => { - clearTimeout(handler); - }; - }, - [delay, value] - ); - return debouncedValue; -} - -/** - * Хук debounced effect - * @param {*} func - * @param {*} deps - * @param {*} delay - */ -function useDebouncedEffect(func, deps, delay) { - const debounced = useDebounce(deps, delay || 0) - const debDepsList = Array.isArray(debounced) ? debounced : debounced ? [debounced] : undefined - useEffect(func, debDepsList) // eslint-disable-line react-hooks/exhaustive-deps -} - -export default useDebouncedEffect \ No newline at end of file diff --git a/client/src/Hooks/useDebouncedFunction.hook.js b/client/src/Hooks/useDebouncedFunction.hook.js new file mode 100644 index 0000000..c4771f5 --- /dev/null +++ b/client/src/Hooks/useDebouncedFunction.hook.js @@ -0,0 +1,23 @@ +/** @file useDebouncedFunction.hook.js */ +import { useEffect, useReducer } from 'react'; + +/** + * Хук Debounced Function + * @param {(arg)=>void} callback + * @param {number} delayms + */ +function useDebouncedFunction(callback, delayms) { + const [state, debounced] = useReducer((state, arg) => { + return { count: state.count + 1, arg: arg } + }, { count: 0, arg: undefined }) + useEffect(() => { + const handler = setTimeout(() => { + if (state.count) callback(state.arg) + }, delayms) + return () => clearTimeout(handler) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [state]) + return debounced +} + +export default useDebouncedFunction \ No newline at end of file diff --git a/client/src/Hooks/useUpdaterSocket.hook.js b/client/src/Hooks/useUpdaterSocket.hook.js new file mode 100644 index 0000000..97f68c8 --- /dev/null +++ b/client/src/Hooks/useUpdaterSocket.hook.js @@ -0,0 +1,81 @@ +/**@file useUpdaterSocket.hook.js */ +import { useCallback } from "react" +import useWebSocket from 'react-use-websocket' +import useDebouncedFunction from "./useDebouncedFunction.hook" +import { useHttp } from "./http.hook" + +const WS_PORT = process.env.WS_PORT || 3030 + +/** + * Хук веб-сокета обновления данных + * @param {void} updateData + * @param {*} auth + */ +function useUpdaterSocket(updateData, auth) { + const { request } = useHttp() + + /**дебонсированный обработчик входящего обновления */ + const debouncedUpdate = useDebouncedFunction(updateData, 200) + + /**колбек для получения url сокета */ + const getSocketUrl = useCallback(async () => { + return new Promise(resolve => { + request("/getIp") + .then((data) => { + const ip = data.ip + const socketAddress = "ws://" + (ip || "localhost") + ":" + WS_PORT + console.log("socketAddress", socketAddress) + resolve(socketAddress) + }) + .catch((err) => console.log(err)) + }) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []) + + /**опции обработки событий сокета */ + const socketOptions = { + onOpen: (e) => { + sendRegisterMsg() + console.log("ws open") + }, + onClose: (e) => { + console.error("ws close") + }, + onMessage: (e) => { + console.log("ws update message") + debouncedUpdate() + }, + onError: (e) => { + console.error("ws error", e) + }, + } + + /**подключение WebSocket */ + const { sendMessage } = useWebSocket(getSocketUrl, socketOptions) + + /**дебонсированный обработчик отсылаемого обновления */ + const sendUpdateMsgDebounced = useDebouncedFunction(sendUpdateMsg, 200) + + /** отпрака сообщения сокета */ + function sendMsg(target) { + const msg = JSON.stringify({ + userId: auth.userId, + target + }) + sendMessage(msg) + } + + /**отправка отсылаемого обновления */ + function sendUpdateMsg() { + sendMsg("update") + } + + /**отпрака сообщения регистрации сокета */ + function sendRegisterMsg() { + sendMsg("register") + } + + return [sendUpdateMsgDebounced] +} + +export default useUpdaterSocket \ No newline at end of file diff --git a/client/src/NoteComponents/ModalNoteEdit.js b/client/src/NoteComponents/ModalNoteEdit.js index f810759..42fcfe9 100644 --- a/client/src/NoteComponents/ModalNoteEdit.js +++ b/client/src/NoteComponents/ModalNoteEdit.js @@ -25,10 +25,10 @@ function calcMaxRows() { */ function ModalNoteEdit() { /**получение контекста */ - const { removeNote, changeNoteColor, unsetEditNoteId, editNoteContent, getNoteByIndex, editNoteId } = React.useContext(NotesContext) + const { removeNote, changeNoteColor, unsetEditNoteId, editNoteContent, getNoteById, editNoteId } = React.useContext(NotesContext) /** обьект заметки */ - const note = getNoteByIndex(editNoteId) + const note = getNoteById(editNoteId) React.useEffect(() => { if (note !== null) open() }, [note]) /**хук состояния формы */ diff --git a/client/src/NoteComponents/NoteItem.js b/client/src/NoteComponents/NoteItem.js index ec836dd..1e9adcb 100644 --- a/client/src/NoteComponents/NoteItem.js +++ b/client/src/NoteComponents/NoteItem.js @@ -2,7 +2,6 @@ * @file NoteItem.js */ import React, { useContext } from 'react' -import PropTypes from 'prop-types' import NotesContext from '../Context/NotesContext' import Note, { PropTypeNote } from '../Shared/noteType/Note' import ReactMarkdown from 'react-markdown' @@ -18,7 +17,7 @@ function fixLineBreaks(mdStr) { * @param {*} param0 * */ -function NoteItem({ note = new Note(), index }) { +function NoteItem({ note = new Note() }) { /**Подключение контекста */ const { setEditNoteId, editNoteOrder } = useContext(NotesContext) @@ -39,7 +38,7 @@ function NoteItem({ note = new Note(), index }) {
{/**Заголовок и текст заметки с обработчиками отображения markdown*/} -
setEditNoteId(index)} > +
setEditNoteId(note.id)} >
@@ -56,14 +55,14 @@ function NoteItem({ note = new Note(), index }) { @@ -75,8 +74,7 @@ function NoteItem({ note = new Note(), index }) { // Валидация NoteItem.propTypes = { - note: PropTypeNote.isRequired, - index: PropTypes.number + note: PropTypeNote.isRequired } export default NoteItem diff --git a/client/src/NoteComponents/NoteList.js b/client/src/NoteComponents/NoteList.js index 1c6e3f5..d90b2f1 100644 --- a/client/src/NoteComponents/NoteList.js +++ b/client/src/NoteComponents/NoteList.js @@ -54,9 +54,9 @@ function NoteList(props) { {/**Отзывчивая сетка карточек */} {/**Рендер каждой карточки из массива */} - {Array.isArray(props.notes) ? (props.notes.sort(sortByOrder).reverse().map((note, index) => { + {Array.isArray(props.notes) ? (props.notes.sort(sortByOrder).reverse().map((note) => { return ( - + ) })) : null} diff --git a/client/src/Pages/NotesPage.js b/client/src/Pages/NotesPage.js index 5ef4b04..3e0b425 100644 --- a/client/src/Pages/NotesPage.js +++ b/client/src/Pages/NotesPage.js @@ -17,6 +17,7 @@ import useFetchNotes from '../Hooks/useFetchNotes.hook' import useEditNoteId from '../Hooks/useEditNoteId.hook' import useNotesArr from '../Hooks/useNotesArr.hook' import { calcOrder, fixOrders } from '../Shared/order' +import useUpdaterSocket from '../Hooks/useUpdaterSocket.hook' /** * Страница с заметками @@ -52,6 +53,9 @@ function NotesPage() { /** подключение контроллера обновления данных */ const [updateData] = useDataLoadingController(loadDataFromServer, setLoadedNotes, auth, 60) + /** подключение сокета обновления данных */ + const [sendUpdateMsg] = useUpdaterSocket(updateData, auth) + /////////// /** @@ -67,14 +71,14 @@ function NotesPage() { * @param {string} target */ function loadDataToServer(note = new Note(), target = 'set') { - fetchNotes(target, "POST", { note }) + fetchNotes(target, "POST", { note }, sendUpdateMsg) } /////////// /** * Внесение в полученных данных в массив - * @param {*} notes + * @param {Array<{}>} notes */ function setLoadedNotes(notes) { setNotesArr([...notes]) @@ -82,9 +86,10 @@ function NotesPage() { /** * удаление карточки - * @param {*} index + * @param {string} id */ - function removeNote(index) { + function removeNote(id) { + const index = getNoteIndexById(id) const toDelete = notesArr.splice(index, 1)[0] setNotesArr([...notesArr]) loadDataToServer(toDelete, "delete") @@ -92,7 +97,7 @@ function NotesPage() { /** * добавление карточки - * @param {*} noteData + * @param {{}} noteData */ function addNote(noteData = {}) { const newId = String(auth.email) + String(Date.now()) + String(Math.random()) @@ -103,21 +108,20 @@ function NotesPage() { text: noteData.text, order: calcOrder(notesArr) }) - //console.log(newId, newNote.id); - const newIndex = (notesArr != null) ? notesArr.length : 0 setNotesArr( (notesArr != null) ? notesArr.concat([newNote]) : [newNote] ) loadDataToServer(newNote, "set") - setEditNoteId(newIndex) + setEditNoteId(newId) } /** * Изменение цвета карточки - * @param {*} index - * @param {*} color + * @param {string} id + * @param {string} color */ - function changeNoteColor(index, color) { + function changeNoteColor(id, color) { + const index = getNoteIndexById(id) notesArr[index].color = color setNotesArr([...notesArr]) loadDataToServer(notesArr[index], "set") @@ -125,12 +129,13 @@ function NotesPage() { /** * Изменение текстового содержания карточки - * @param {*} index - * @param {*} name - * @param {*} text + * @param {string} id + * @param {string} name + * @param {string} text */ - function editNoteContent(index, name, text) { - if (notesArr[index]) { + function editNoteContent(id, name, text) { + const index = getNoteIndexById(id) + if (index !== null) { let note = new Note(notesArr[index]) note.name = name note.text = text @@ -142,11 +147,12 @@ function NotesPage() { /** * Изменение порядка заметки - * @param {number} index + * @param {string} id * @param {boolean} orderOperationFlag */ - function editNoteOrder(index, orderOperationFlag) { - if (notesArr[index]) { + function editNoteOrder(id, orderOperationFlag) { + const index = getNoteIndexById(id) + if (index !== null) { notesArr[index].order += orderOperationFlag ? 1 : -1 let fixedArr = fixOrders(notesArr) setNotesArr(fixedArr) @@ -156,9 +162,40 @@ function NotesPage() { } } - /**функция получения карточки по id */ - function getNoteByIndex(index) { - return index !== null ? notesArr[index] : null + /////////// + + /** + * функция получения карточки по id + * @param {string} id + */ + function getNoteById(id) { + const byId = () => { + let note = null + if (Array.isArray(notesArr)) { + notesArr.forEach((val, index) => { + if (val.id === id) note = val + }) + } + return note + } + return id !== null ? byId() : null + } + + /** + * функция получения индекса карточки по id + * @param {string} id + */ + function getNoteIndexById(id) { + const byId = () => { + let index = null + if (Array.isArray(notesArr)) { + notesArr.forEach((val, ind) => { + if (val.id === id) index = ind + }) + } + return index + } + return id !== null ? byId() : null } /////////// @@ -182,7 +219,7 @@ function NotesPage() { /**рендер */ return ( /**Здесь отрисовываются меню добавления и редактирования заметок и сам перечнь заметок в виде динамичной отзывчивой сетки */ - +
{/**Компонент добавления карточки и модальное окно редактирования */} diff --git a/jsdoc.json b/jsdoc.json index ace4c21..65af500 100644 --- a/jsdoc.json +++ b/jsdoc.json @@ -6,6 +6,7 @@ "server/routes", "server/models", "server/middleware", + "server/socket", "client", "client/src", "client/src/Shared", diff --git a/package.json b/package.json index 7f61485..8109031 100644 --- a/package.json +++ b/package.json @@ -20,6 +20,7 @@ "dev": "cross-env NODE_ENV=dev concurrently \"npm run server:start-dev\" \"npm run client:start-dev\"", "prod": "npm run server:start-prod", "prod:predeploy": "npm run deploy && npm run prod", + "prod:prebuild": "npm run client:build && npm run prod", "test": "echo \"Error: no test specified\" && exit 1", "heroku-postbuild": "npm run deploy", "mongo-ubuntu:update": "(sudo apt update && sudo apt upgrade)", diff --git a/server/app.js b/server/app.js index c369222..94b155e 100644 --- a/server/app.js +++ b/server/app.js @@ -5,30 +5,36 @@ const express = require('express') const path = require('path') -const dns = require('dns') const os = require('os') const mongoose = require('mongoose') const https = require('./middleware/https.middleware') +const startWSS = require('./socket/wss') require('dotenv').config() -/** - * подключение переменных среды - */ +//подключение переменных среды const devMode = process.env.NODE_ENV === "dev" const PORT = process.env.PORT || 5000 const mongoUri = process.env.mongoUri const httpsRedirect = process.env.httpsRedirect || false +const WS_PORT = process.env.WS_PORT || 3030 const app = express() app.use(express.json({ extended: true })) +app.get('/getIp', (req, res) => { + getIp() + .then((ip) => res.status(200).json({ ip })) + .catch(() => res.status(500)) +}) + /** * подключение роутов */ app.use('/api/auth', require('./routes/auth.routes')) app.use('/api/notes', require('./routes/notes.routes')) + if (httpsRedirect) app.use(https) /** @@ -52,6 +58,7 @@ if (!devMode) { async function start() { try { connectMongo(mongoUri) + startWSS(WS_PORT) app.listen(PORT, logServerStart) } catch (e) { console.log('Server Error', e.message) @@ -80,12 +87,22 @@ async function connectMongo(mongoUri) { /** * Вывод информации о сервере */ -function logServerStart() { - dns.lookup(os.hostname(), (err, address) => { - const [logName, sBef, sAft] = devMode ? ['Express server', ' ', ':'] : ['React Notes App', '-', ''] - console.log(`\n${logName} has been started`) - console.log(`${sBef} Local${sAft} http://localhost:${PORT}`) - console.log(`${sBef} On Your Network${sAft} http://${address}:${PORT}`) - if (err) console.log(err) +async function logServerStart() { + const [logName, sBef, sAft] = devMode ? ['Express server', ' ', ':'] : ['React Notes App', '-', ''] + const ip = await getIp() + console.log(`\n${logName} has been started`) + console.log(`${sBef} Local${sAft} http://localhost:${PORT}`) + console.log(`${sBef} On Your Network${sAft} http://${ip}:${PORT}\n`) +} + +/** + * Получение ip сервера + */ +function getIp() { + return new Promise((res, rej) => { + require('dns').lookup(os.hostname(), (err, addr) => { + addr ? res(addr) : rej() + err && console.log(err) + }) }) } diff --git a/server/package-lock.json b/server/package-lock.json index 9fba14c..77cf659 100644 --- a/server/package-lock.json +++ b/server/package-lock.json @@ -1669,6 +1669,11 @@ "typedarray-to-buffer": "^3.1.5" } }, + "ws": { + "version": "7.4.6", + "resolved": "https://registry.npmjs.org/ws/-/ws-7.4.6.tgz", + "integrity": "sha512-YmhHDO4MzaDLB+M9ym/mDA5z0naX8j7SIlT8f8z+I0VtzsRbekxEutHSme7NPS2qE8StCYQNUnfWdXta/Yu85A==" + }, "xdg-basedir": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/xdg-basedir/-/xdg-basedir-4.0.0.tgz", diff --git a/server/package.json b/server/package.json index fa21c55..f167922 100644 --- a/server/package.json +++ b/server/package.json @@ -16,9 +16,10 @@ "express": "^4.17.1", "express-validator": "^6.11.1", "jsonwebtoken": "^8.5.1", - "mongoose": "^5.12.13" + "mongoose": "^5.12.13", + "ws": "^7.4.6" }, "devDependencies": { "nodemon": "^2.0.7" } -} \ No newline at end of file +} diff --git a/server/socket/wss.js b/server/socket/wss.js new file mode 100644 index 0000000..8bfe20a --- /dev/null +++ b/server/socket/wss.js @@ -0,0 +1,79 @@ +/**@file wss.js */ +const WebSocket = require('ws') + +/** + * Подключение WebSocket сервера + * Он принимает сигналы об обновлении от клиента и рассылает остальным для синхронизации + * @param {*} port + */ +function startWSS(port) { + /**сервер ws */ + const wsServer = new WebSocket.Server({ port }) + /**коллекция ws клиентов разделенная по id пользователя */ + const wsCollection = {} + // обработка событий сервера + wsServer.on('connection', (wsClient) => { + // уникальный номер клиента + const clientNum = Date.now() + // проверка регистрации + setTimeout(() => { + if (!wsClient.userId) wsClient.close() + }, 60 * 1000) + // обработка сообщения клиента + wsClient.on("message", (data) => { + try { + // данные с клиента + const { userId, target } = JSON.parse(data) + // обработка сообщения регистрации + if (target == "register") { + wsClient.userId = userId + wsClient.num = clientNum + let clients = getClients(wsClient.userId) + let match = false + clients.forEach(val => { + if (val.num == wsClient.num) match = true + }) + if (!match && wsClient.userId) { + clients.push({ + num: wsClient.num, + send: msg => wsClient.send(msg) + }) + wsCollection[wsClient.userId] = clients + } else throw new Error("ошибка регистрации сокета") + } + // обработка сообщения об обновлении + if (target == "update" && wsClient.userId) { + getClients(wsClient.userId).forEach((wsc) => { + if ((wsClient.num !== undefined) && (wsClient.num !== wsc.num)) { + wsc.send(data) + } + }) + } + } catch (e) { wsClient.close() } + }) + // обработка закрытия клиента + wsClient.on("close", () => { + if (wsClient.userId) wsCollection[wsClient.userId] = getClients(wsClient.userId).filter((wsc) => { + return (wsClient.num === undefined) || (wsClient.num !== wsc.num) + }) + }) + // обработка ошибки клиента + wsClient.on("error", () => { + wsClient.close() + }) + }) + wsServer.on("close", () => console.log("WSS closed")) + wsServer.on("error", () => console.log("WSS error")) + wsServer.on("listening", () => console.log("WSS listening")) + + /** + * Получение колекции соединений по id + * @param {string} userId + * @returns {Array} + */ + function getClients(userId) { + return wsCollection[userId] || [] + } +} + +module.exports = startWSS \ No newline at end of file