Socket.IO
Socket.IO - это библиотека для создания прил ожений, работающих в режиме реального времени, имеющих двунаправленный канал связи и основанных на событиях. Она состоит из:
- сервера на Node.js;
- клиентской библиотеки для браузера (которая также может использоваться на сервере).
Пример чата на React + Express.
Введение
Принцип работы
Клиент пытается установить WebSocket-соединение. Если веб-сокеты оказываются недоступными, тогда используется HTTP Long Polling (длинные опросы).
WebSocket - это коммуникационный протокол, предоставляющий полнодуплексный канал с низкой задержкой между сервером и клиентом.
Возможности
- надежность: соединение устанавливается независимо от присутствия прокси, балансировщиков нагрузки, "файервола" и антивируса
- автоматическое переподключение
- обнаружение обрыва соединения
- отправка бинарных данных
- мультиплексирование
Установка
yarn add socket.io
# или
npm i socket.io
Простой пример
// index.js
const content = require('fs').readFileSync(__dirname + '/index.html', 'utf8')
const httpServer = require('http').createServer((req, res) => {
res.setHeader('Content-Type', 'text/html')
res.setHeader('Content-Length', Buffer.byteLength(content))
res.end(content)
})
const io = require('socket.io')(httpServer)
io.on('connection', socket => {
console.log('Подключение установлено')
let counter = 0
setInterval(() => {
// отправляем данные клиенту
socket.emit('hello', ++counter);
}, 1000)
// получаем данные от клиента
socket.on('hi', data => {
console.log('hi', data)
})
})
httpServer.listen(3000, () => {
console.log('Перейдите на http://localhost:3000')
})
<body>
<ul id="events"></ul>
<script src="/socket.io/socket.io.js"></script>
<script>
const $events = document.getElementById('events')
const newItem = (content) => {
const item = document.createElement('li')
item.innerText = content
return item
}
const socket = io()
socket.on('connect', () => {
$events.appendChild(newItem('Подключение установлено'))
})
// получаем данные от сервера
socket.on('hello', (counter) => {
$events.appendChild(newItem(`Привет - ${counter}`))
})
// отправляе м данные на сервер
let counter = 0
setInterval(() => {
++counter
socket.emit('hi', { counter })
}, 1000)
</script>
</body>
node index.js
# открываем localhost:3000
Сервер
Node
const server = require('http').createServer()
const options = {}
const io = require('socket.io')(server, options)
io.on('connection', socket => {})
server.listen(3000)
Express
const app = require('express')()
const server = require('http').createServer(app)
const io = require('socket.io')(server)
io.on('connection', socket => {})
server.listen(3000)
Koa
const app = require('koa')()
const server = require('http').createServer(app.callback())
const io = require('socket.io')(server)
io.on('connection', socket => {})
server.listen(3000)
Экземпляр сокета
socket.id
- каждый сокет имеет 20-значный идентификатор, одинаковый на сервере и клиенте:
// сервер
io.on('connection', socket => {
console.log(socket.id) // ojIckSD2jqNzOqIrAGzL
})
// клиент
socket.on('connect', () => {
console.log(socket.id) // ojIckSD2jqNzOqIrAGzL
})
-
socket.handshake
- объект, содержащий некоторую информацию о "рукопожатии", происходящем в начале сессии (заголовки, строка запроса, url и т.д.). -
socket.rooms
- ссылка на комнаты:
io.on('connection', socket => {
console.log(socket.rooms) // Set { <socket.id> }
socket.join('room1')
console.log(socket.rooms) // Set { <socket.id>, 'room1' }
})
Добавление атрибутов
К экземпляру можно добавлять любые атрибуты:
// middleware
io.use(async (socket, next) => {
try {
const user = await fetchUser(socket)
socket.user = user
} catch {
next(new Error('Неизвестный пользователь'))
}
})
io.on('connection', (socket) => {
console.log(socket.user)
// обработчик
socket.on('set username', username => {
socket.username = username
})
})
События
disconnect
- происходит при отключении сокета:
io.on('connection', socket => {
socket.on('disconnect', reason = {})
})
disconnecting
- происходит передdisconnect
, может использоваться для регистрации выхода пользователя из непустой комнаты:
io.on('connection', socket => {
socket.on('disconnecting', reason => {
for (const room of socket.rooms) {
if (room !== socket.id) {
socket.to(room).emit('user has left', socket.id)
}
}
})
})
Специальные события не должны использоваться в приложении.
Посредники (middlewares)
Middleware - это функция, которая выполняется при каждом соединении. Посредники могут использоваться для логгирования, аутентификации/авторизации, ограничения скорости подключения и т.д.
Регистрация посредника
Посредник имеет доступ к экземпляру сокета и следующему посреднику:
io.use((socket, next) => {
if (isValid(socket.request)) {
next()
} else {
next(new Error('как-то это все невалидно'))
}
})
Несколько посредников выполняются последовательно.
Отправка полномочий (credentials)
Для отправки полномочий на клиенте используется свойство auth
:
const socket = io({
auth: {
token: 'secret'
}
})
На сервере доступ к полномочиям можно получить через свойство handshake
:
io.use((socket, next) => {
const token = socket.handshake.auth.token
})
Обработка ошибок, возникающих в посреднике
При возникновении ошибки в посреднике происходит отказ в подключении, а клиент получает событие connect_error
:
socket.on('connect_error', (e) => {
console.log(e.message)
})
Совместимость с посредниками Express
Для обеспечения совместимости с Express middlewares требуется функция-обертка:
const wrap = middleware => (socket, next) => middleware(socket.request, {}, next)
Пример использования Passport.js:
const session = require('express-session')
const passport = require('passport')
io.use(wrap(session({ secret: 'secret' })))
io.use(wrap(passport.initialize()))
io.use(wrap(passport.session()))
io.use((socket, next) => {
if (socket.request.user) {
next()
} else {
next(new Error('unauthorized'))
}
})
Обработка CORS
Начиная с Socket.io версии 3, необходимо явно разрешать cross-origin resource sharing (распределение ресурса между разными источниками):
const io = require('socket.io')(server, {
cors: {
origin: 'http://example.com',
methods: ['GET', 'POST']
}
})
// или
const io = require('socket.io')(server, {
cors: {
origin: '*'
}
})
Пример с куками:
// сервер
const io = require('socket.io')(server, {
cors: {
origin: 'http://example.com',
methods: ['GET', 'POST'],
allowedHeaders: ['custom-header'],
credentials: true
}
})
// клиент
const io = require('socket.io-client')
const socket = io('http://api.example.com', {
withCredentials: true,
extraHeaders: {
'custom-header': 'chat-app'
}
})
Структура приложения
Рекомендуемая структура на примере списка задач.
mkdir socket-todo
cd socket-todo
yarn init -yp
yarn add socket.io express lowdb nanoid
// package.json
{
"name": "socket-todo",
"version": "1.0.0",
"main": "server.js",
"license": "MIT",
"private": true,
"dependencies": {
"express": "^4.17.1",
"lowdb": "^1.0.0",
"nanoid": "^3.1.20",
"socket.io": "^3.1.1"
},
"scripts": {
"start": "node server"
}
}
<!-- index.html -->
<body>
<form>
<input
type="text"
placeholder="Enter todo title..."
autocomplete="off"
autofocus
required
/>
<input type="submit" value="Add" />
</form>
<ul></ul>
<!-- подключение socket.io -->
<script src="/socket.io/socket.io.js"></script>
<script src="client.js"></script>
</body>
// server.js
const express = require('express')
const app = express()
const server = require('http').createServer(app)
const io = require('socket.io')(server)
// обработка статических файлов
app.use(express.static(__dirname))
// регистрация обработчиков
const registerTodoHandlers = require('./todoHandlers')
// обработка подключения
const onConnection = (socket) => {
console.log('User connected')
registerTodoHandlers(io, socket)
}
// регистрация подключения
io.on('connection', onConnection)
const PORT = process.env.PORT || 3000
server.listen(PORT, () => {
console.log(`Server ready. Port: ${PORT}`)
})
// todoHandlers.js
const { nanoid } = require('nanoid')
// подключение и настройка lowdb для работы с локальной базой данных в формате json
const low = require('lowdb')
const FileSync = require('lowdb/adapters/FileSync')
const adapter = new FileSync('todos.json')
const db = low(adapter)
// инициализация БД
db.defaults({
todos: [
{ id: '1', title: 'Eat' },
{ id: '2', title: 'Sleep' },
{ id: '3', title: 'Repeat' }
]
}).write()
module.exports = (io, socket) => {
// получение задач
const getTodos = () => {
const todos = db.get('todos').value()
io.emit('todos', todos)
}
// добавление задачи
const addTodo = (title) => {
db.get('todos')
.push({ id: nanoid(4), title })
.write()
getTodos()
}
// удаление задачи по идентификатору
const removeTodo = (id) => {
db.get('todos').remove({ id }).write()
getTodos()
}
socket.on('todo:get', getTodos)
socket.on('todo:add', addTodo)
socket.on('todo:remove', removeTodo)
}
// client.js
const socket = io()
// HTML-элементы
const form = document.querySelector('form')
const input = document.querySelector('input')
const list = document.querySelector('ul')
// инициализация получения задач
socket.emit('todo:get')
// обработка получения задач
socket.on('todos', (todos) => {
list.innerHTML = ''
todos.forEach(addTodo)
})
function addTodo({ id, title }) {
const template = /*html*/ `
<li>
<span>${title}</span>
<button data-id="${id}">Remove</button>
</li>
`
list.innerHTML += template
initBtns()
}
// инициализация кнопок для удаления задач
function initBtns() {
const buttons = document.querySelectorAll('button')
buttons.forEach((btn) => {
btn.addEventListener('click', ({ target }) => {
// удаление задачи по id
const { id } = target.dataset
socket.emit('todo:remove', id)
})
})
}
// добавление задачи
form.onsubmit = (e) => {
e.preventDefault()
const title = input.value.trim()
input.value = ''
socket.emit('todo:add', title)
}
yarn start
# открываем localhost:3000
Клиент
- В процессе установки socket.io (
yarn add socket.io
) формируется клиентский "бандл":
<script src="/socket.io/socket.io.js"></script>
Подключение библиотеки приводит к созданию глобальной переменной io.
- CDN:
<script src="https://cdn.socket.io/3.1.1/socket.io.min.js"></script>
Инициализация
- Если домены клиента и сервера совпадают:
const socket = io()
- Если не совпадают:
const socket = io('https://server-domain.com')
Не забудьте про CORS.
- Имеется возможность выбора определенного пространства имен (namespace):
const socket = io('/admin')
Настройки движка
path
- название пути, должно быть одинаковым на клиенте и сервере, не имеет отношения к пространству имен:
// клиент
import { io } from "socket.io-client"
const socket = io("https://example.com", {
path: "/custom-path/"
})
// сервер
const httpServer = require("http").createServer()
const io = require("socket.io")(httpServer, {
path: "/custom-path/"
})
query
- дополни тельные параметры строки запроса, сохраняются на протяжении сессии (не могут быть изменены):
// клиент
const socket = io({
query: {
x: 42
}
})
// сервер
io.on("connection", (socket) => {
console.log(socket.handshake.query) // { x: "42", EIO: "4", transport: "polling" }
})
extraHeaders
- дополнительные заголовки, сохраняются на протяжении сессии (не могут быть изменены):
// клиент
const socket = io({
extraHeaders: {
"custom-header": "chat-app"
}
})
// сервер
io.on("connection", (socket) => {
console.log(socket.handshake.headers) // { "custom-header": "chat-app" }
})
Также имеются настройки менеджера (reconnection
, reconnectionAttempts
, reconnectionDelay
и т.д.).
Экземпляр сокета
-
socket.id
-
socket.connected
- индикатор подключения к серверу
Жизненный цикл
События
connect
- происходит при подключении / повторном подключении
socket.on('connect', () => {})
connect_error
- происходит при отказе в подключении, требуется ручное переподключение:
socket.on('connect_error', () => {
socket.auth.token = 'secret'
socket.connect()
})
disconnect
- происходит при отключении:
socket.on('disconnect', reason => {})
Офлайн-режим
По умолчанию при отсутствии подключения происходит буферизация данных. В некоторых случаях может потребоваться другое поведение.
- использование атрибута
connect
:
if (socket.connected) {
socket.emit()
} else {}
- использование изменчивых событий (volatile events):
socket.volatile.emit()
- очистка буфера перед повторным подключением:
socket.on('connect', () => {
socket.sendBuffer = []
})
События
Базовая отправка
Socket.io вдохновлен "нодовским" EventEmitter
. Это означает, что мы можем отправлять (emit
) события на одной стороне и регистрировать (on
) их на другой:
// сервер
io.on('connection', socket => {
socket.emit('message', 'Привет, народ!')
})
// клиент
socket.on('message', message => {
console.log(message) // Привет, народ!
})
// сервер
io.on('connection', socket => {
socket.on('message', message => {
consol.log(message) // Привет, народ!
})
})
// клиент
socket.emit('message', 'Привет, народ!')
Можно отправлять любое количество аргументов и любые сериализуемые структуры данных, включая буферы и типизированные массивы. Сериализация объектов (JSON.stringify()
) выполняется автоматически. Map
и Set
должны быть сериализованы вручную.
Подтверждения
События удобны в использовании, но в некоторых случаях более предпочтительны м является использование классического API запрос-ответ. В socket.io такой подход называется подтверждениями (acknowledgements). Мы можем добавить колбек в качестве последнего аргумента в emit()
. Данный колбек будет вызван после получения события другой стороной:
// сервер
io.on('connection', socket => {
socket.on('update item', (arg1, arg2, cb) => {
console.log(arg1) // 1
console.log(arg2) // { name: 'updated' }
cb({
status: 'ok'
})
})
})
// клиент
socket.emit('update item', '1', { name: 'updated' }, response => {
console.log(response.status) // ok
})
Изменчивые события
Volatile events - это такие события, которые не отправляются другой стороне при отсутствии подключения. Это может быть полезным при необходимости отправки только последних данны х (когда получение другой стороной промежуточной информации является несущественным). Другим случаем использования является сброс (discard) событий при отсутствии подключения (по умолчанию события буферизуются до повторного подключения):
// сервер
io.on('connection', socket => {
console.log('connect')
socket.on('ping', count => {
console.log(count)
})
})
// клиент
let count = 0
setInterval(() => {
socket.volatile.emit('ping', ++count)
}, 1000)
При перезапуске сервера получим следующее:
connect
1
2
3
4
# сервер был перезапущен, произошло автоматическое переподключение клиента
connect
9
10
...
Без volatile
мы увидим следующее:
connect
1
2
3
4
# сервер был перезапущен, произошло автоматическое переподключение клиента и отправка ему данных из буфера
connect
5
6
7
8
9
10
...
Прослушивание событий
Методы EventEmitter
socket.on(eventName, listener)
- добавляет обработчик в конец массива обработчиков для события под названием eventNamesocket.once(eventName, listener)
- добавляет одноразовый обработчикsocket.off(eventName, listener)
- удаляет указанный обработчик из массива обработчиков для eventNamesocket.removeAllListeners([eventName])
- удаляет все или указанные обработчики
Обработчики catch-all
socket.onAny(listener)
- добавляет обработчик, вызываемый при возникновении любого из указанных событий:
socket.onAny((eventName, ...args) => {})
socket.prependAny(listener)
- добавляет обработчик, вызываемый при возникновении любог о из указанных событий, обработчик добавляется в начало массиваsocket.offAny([listener])
- удаляет все или указанный обработчик
Валидация
Socket.io не предоставляет инструментов для валидации входных данных.
Пример с Joi:
const Joi = require('joi')
const userSchema = Joi.object({
username: Joi.string().max(30).required(),
email: Joi.string().email().required()
})
io.on('connection', socket => {
socket.on('create user', (pl, cb) => {
if (typeof cb !== 'function') return socket.disconnect()
const { error, value } = userSchema.validate(pl)
if (error) return cb({ status: '!OK', error })
cb({ status: 'OK' })
})
})
Обработка ошибок
Socket.io также не предоставляет средств для обработки ошибок. Это означает, что мы должны перехватывать ошибки в обработчиках:
io.on('connection', socket => {
socket.on('list items', async cb => {
try {
const items = await findItems()
cb({
status: 'OK',
items
})
} catch (error) {
cb({
status: '!OK',
error
})
}
})
})
Распространение (передача) событий (вещание, broadcasting)
Всем подключенным клиентам
io.on('connection', socket => {
io.emit('message', 'Привет, народ!')
})
Всем подключенным клиентам, кроме отправителя
io.on('connection', socket => {
socket.broadcast.emit('message', 'Привет, народ!')
})
Комнаты
Комната - это произвольный канал, к которому сокет может присоединяться (join
) и который он может покидать (leave
). Она может использоваться для передачи события только определенным клиентам.
Список комнат можно получить только на сервере.
Вход в комнату и выход из нее
Для входа в комнату используется join
:
io.on('connection', socket => {
socket.join('some room')
})
Затем для вещания событий используется to
или in
:
io.to('some room').emit('some event')
// можно вещать в несколько комнат
io.to('room1').to('room2').emit('some event')
// можно вещать из сокета
io.on('connection', socket => {
socket.to('some room').emit('some event')
})
Для выхода из комнаты используется leave
.
"Дефолтная" комната
Каждый сокет по умолчанию присоединяется к комнате с его идентификатором.
Это позволяет легко отправлять приватные сообщения:
io.on('connection', socket => {
socket.on('private message', (anotherSocket, message) => {
socket.to(anotherSocket).emit('private message', socket.id, message)
})
})
Простые случаи использования
- передача данных указанному пользователю:
io.on('connection', async socket => {
const userId = await fetchUserId(socket)
socket.join(userId)
io.to(userId).emit('Привет!')
})
- отправка уведомлений о событии:
io.on('connection', async socket => {
const projects = await fetchProjects(socket)
projects.forEach(p => socket.join(`project: ${p.id}`))
io.to('project:1234').emit('project updated')
})
Асинхронный код
Пример с колбеком:
saveProduct(() => {
socket.to('room1').emit('project updated')
})
Пример с async/await
:
const details = await fetchDetails()
io.to('room2').emit('details', details)
Отключение
При отключении сокеты автоматически покидают все комнаты, к которым были присоединены. Список комнат можно получить, прослушивая событие disconnecting
:
io.on('connection', socket => {
socket.on('disconnecting', () => {
console.log(socket.rooms)
})
socket.on('disconnect', () => {
// socket.rooms.size === 0
})
})
Шпагралка по отправке событий
io.on('connection', socket => {
// всем клиентам
socket.emit('hello', 'ты меня слышишь?', 1, 2, 'абя')
// всем клиентам, кроме отправителя
socket.broadcast.emit('broadcast', 'привет, друзья!')
// всем клиентам в комнате "game", кроме отправителя
socket.to('game').emit('nice game', 'сыграем?')
// всем клиентам в комнате "game1" и/или в комнате "game2", кроме отправителя
socket.to('game1').to('game2').emit('nice game', 'когда будем играть?')
// всем клиентам в комнате "game", включая отправителя
io.in('game').emit('big announcement', 'игра скоро начнется')
// всем клиентам в пространстве имен "myNamespace", включая отправителя
io.of('myNamespace').emit('bigger announcement', 'турнир скоро начнется')
// всем клиентам в определенном пространстве имен и в определенной комнате, включая отправителя
io.of('namespace').to('room').emit('event', 'сообщение')
// определенному сокету (приватное сообщение)
io.to(socketId).emit('hi', 'после прочтения сжечь')
// с подтверждением
socket.emit('question', 'ты действительно так думаешь?', answer => {})
// без сжатия
socket.compress(false).emit('uncompessed', 'без сжатия')
// отправка необязательного события
socket.volatile.emit('maybe', 'оно тебе надо?')
// всем клиентам в данном узле
io.local.emit('hi', 'только вам, больше никому')
// всем клиентам
io.emit('данное событие отправлено всем клиентам')
})
Следующие события являются зарезервированными и не должны использоваться в приложении:
connect
connect_error
disconnect
disconnecting
newListener
removeListener
Продвинутые возможности
Пространства имен (namespaces)
Пространство имен - это канал коммуникации, позволяющий разделить логику приложения на несколько частей (это называется "мультиплексирование м").
Введение
Каждое пространство имен имеет собственные:
- обработчики событий
io.of('/orders').on('connection', socket => {
socket.on('order:list', () => {})
socket.on('order:create', () => {})
})
io.of('/users').on('connection', socket => {
socket.on('user:list', () => {})
})
- комнаты
const orderNsp = io.of('/orders')
orderNsp.on('connection', socket => {
socket.join('room1')
orderNsp.to('room1').emit('hello')
})
const userNsp = io.of('/users')
userNsp.on('connection', socket => {
socket.join('room1')
userNsp.to('room1').emit('привет')
})
- посредников
const orderNsp = io.of('/orders')
orderNsp.use((socket, next) => {
// ...
next()
})
const userNsp = io.of('/users')
userNsp.use((socket, next) => {
// ...
next()
})
Случаи использования:
- создание специального пространства имен, к которому имеют доступ только авторизованные пользователи
const adminNsp = io.of('/admin')
adminNsp.use((socket, next) => {
// ...
next()
})
adminNsp.on('connection', socket => {
socket.on('delete user', () => {})
})
- динамическое создание пространств для каждого "арендатора"
const wkss = io.of(/^\/\w+$/)
wkss.on('connection', socket => {
const wks = socket.nsp
wks.emit('hello')
})
Основное пространство имен
Основное пространство имен называется /
. Экземпляр io
наследует все его методы:
io.on("connection", (socket) => {})
io.use((socket, next) => { next() })
io.emit("hello")
// является эквивалентом следующего
io.of("/").on("connection", (socket) => {})
io.of("/").use((socket, next) => { next() })
io.of("/").emit("hello")
io.sockets
является алиасом для io.of('/')
.
"Кастомные" пространства имен
const nsp = io.of('/namespace')
nsp.on('connection', socket => {
console.log('user connected')
})
nsp.emit('привет', 'всем!')
Инициализация клиента
const socket = io()
const orderSocket = io('/orders')
const userSocket = io('/users')
Динамические пространства имен
- С помощью регулярного выражения:
io.of(/^\/dynamic-\d+$/)
- С помощью функции:
io.of((name, auth, next) => {
next(null, true) // или false, если в создании отказано
})
Доступ к новому ПИ можно получить в событии connection
:
io.of(/^\/dynamic-\d+$/).on('connection', socket => {
const namespace = socket.nsp
}) // метод `io()` возвращает родительское ПИ
// регистрация посредника
const parentNsp = io.of(/^\/dynamic-\d+$/)
parentNsp.use((socket, next) => { next() })
// вещание событий
parentNsp.emit('hello')
Пример чата
mkdir socket-chat
cd socket-chat
yarn init -yp
yarn add socket.io express
// package.json
{
"name": "socket-chat",
"version": "1.0.0",
"main": "server.js",
"license": "MIT",
"private": true,
"dependencies": {
"express": "^4.17.1",
"socket.io": "^3.1.1"
},
"scripts": {
"start": "node server"
}
}
<!-- index.html -->
<body>
<ul class="pages">
<li class="chat_page">
<div class="chat_area">
<ul class="messages"></ul>
</div>
<input
type="text"
placeholder="Type here..."
class="message_input"
/>
</li>
<li class="login_page">
<div class="form">
<h3 class="title">What is your name?</h3>
<input type="text" maxlength="14" class="username_input" />
</div>
</li>
</ul>
<script src="/socket.io/socket.io.js"></script>
<script src="client.js"></script>
</body>
/* style.css */
* {
box-sizing: border-box;
}
html {
font-weight: 300;
-webkit-font-smoothing: antialiased;
}
html,
input {
font-family: 'HelveticaNeue-Light', 'Helvetica Neue Light', 'Helvetica Neue', Helvetica, Arial, 'Lucida Grande', sans-serif;
}
html,
body {
height: 100%;
margin: 0;
padding: 0;
}
ul {
list-style: none;
word-wrap: break-word;
}
/* Pages */
.pages {
height: 100%;
margin: 0;
padding: 0;
width: 100%;
}
.login_page,
.chat_page {
height: 100%;
position: absolute;
width: 100%;
}
/* Login Page */
.login_page {
background-color: #000;
}
.login_page .form {
height: 100px;
margin-top: -100px;
position: absolute;
text-align: center;
top: 50%;
width: 100%;
}
.login_page .form .username_input {
background-color: transparent;
border: none;
border-bottom: 2px solid #fff;
outline: none;
padding-bottom: 15px;
text-align: center;
width: 400px;
}
.login_page .title {
font-size: 200%;
}
.login_page .username_input {
font-size: 200%;
letter-spacing: 3px;
}
.login_page .title,
.login_page .username_input {
color: #fff;
font-weight: 100;
}
/* Chat page */
.chat_page {
display: none;
}
/* Font */
.messages {
font-size: 150%;
}
.message_input {
font-size: 100%;
}
.log {
color: gray;
font-size: 70%;
margin: 5px;
text-align: center;
}
/* Messages */
.chat_area {
height: 100%;
padding-bottom: 60px;
}
.messages {
height: 100%;
margin: 0;
overflow-y: scroll;
padding: 10px 20px 10px 20px;
}
.message.typing .message_body {
color: gray;
}
.username {
font-weight: 700;
overflow: hidden;
padding-right: 15px;
text-align: right;
}
/* Input */
.message_input {
border: 10px solid #000;
bottom: 0;
height: 60px;
left: 0;
outline: none;
padding-left: 10px;
position: absolute;
right: 0;
width: 100%;
}
// server.js
const express = require('express')
const app = express()
const server = require('http').createServer(app)
const io = require('socket.io')(server)
const PORT = process.env.PORT || 3000
server.listen(PORT, () => {
console.log(`Server ready. Port: ${PORT}`)
})
app.use(express.static(__dirname))
let users = 0
io.on('connection', (S) => {
let user = false
S.on('new message', (message) => {
S.broadcast.emit('new message', {
username: S.username,
message
})
})
S.on('add user', (username) => {
if (user) return
S.username = username
++users
user = true
S.emit('login', {
users
})
S.broadcast.emit('user joined', {
username: S.username,
users
})
})
S.on('typing', () => {
S.broadcast.emit('typing', {
username: S.username
})
})
S.on('stop typing', () => {
S.broadcast.emit('stop typing', {
username: S.username
})
})
S.on('disconnect', () => {
if (user) {
--users
S.broadcast.emit('user left', {
username: S.username,
users
})
}
})
})
// client.js
const TYPING_TIMER = 800
const COLORS = [
'#e21400',
'#91580f',
'#f8a700',
'#f78b00',
'#58dc00',
'#287b00',
'#a8f07a',
'#4ae8c4',
'#3b88eb',
'#3824aa',
'#a700ff',
'#d300e7'
]
// вспомогательные функции
const findOne = (selector, el = document) => el.querySelector(selector)
const findAll = (selector, el = document) => [...el.querySelectorAll(selector)]
const hide = (el) => (el.style.display = 'none')
const show = (el) => (el.style.display = 'block')
// HTML-элементы
const $usernameInput = findOne('.username_input')
const $messages = findOne('.messages')
const $messageInput = findOne('.message_input')
const $loginPage = findOne('.login_page')
const $chatPage = findOne('.chat_page')
// глобальные переменные
const S = io()
let username
let connected = false
let typing = false
let lastTypingTime
let $currentInput = $usernameInput
$currentInput.focus()
// функции
const addParticipants = ({ users }) => {
let message = ''
if (users === 1) {
message += `there's 1 participant`
} else {
message += `there are ${users} participants`
}
log(message)
}
const setUsername = () => {
username = $usernameInput.value.trim()
if (username) {
hide($loginPage)
show($chatPage)
$loginPage.onclick = () => false
$currentInput = $messageInput
$currentInput.focus()
S.emit('add user', username)
}
}
const sendMessage = () => {
const message = $messageInput.value.trim()
if (message && connected) {
$messageInput.value = ''
addChatMessage({ username, message })
S.emit('new message', message)
}
}
const addChatMessage = (data) => {
removeChatTyping(data)
const typingClass = data.typing ? 'typing' : ''
const html = `
<li
class="message ${typingClass}"
data-username="${data.username}"
>
<span
class="username"
style="color: ${getUsernameColor(data.username)};"
>
${data.username}
</span>
<span class="message_body">
${data.message}
</span>
</li>
`
addMessageEl(html)
}
const addMessageEl = (html) => {
$messages.innerHTML += html
}
const log = (message) => {
const html = `<li class="log">${message}</li>`
addMessageEl(html)
}
const addChatTyping = (data) => {
data.typing = true
data.message = 'is typing...'
addChatMessage(data)
}
const removeChatTyping = (data) => {
const $typingMessages = getTypingMessages(data)
if ($typingMessages.length > 0) {
$typingMessages.forEach((el) => {
el.remove()
})
}
}
const updateTyping = () => {
if (connected) {
if (!typing) {
typing = true
S.emit('typing')
}
lastTypingTime = new Date().getTime()
setTimeout(() => {
const now = new Date().getTime()
const diff = now - lastTypingTime
if (diff >= TYPING_TIMER && typing) {
S.emit('stop typing')
typing = false
}
}, TYPING_TIMER)
}
}
const getTypingMessages = ({ username }) =>
findAll('.message.typing').filter((el) => el.dataset.username === username)
const getUsernameColor = (username) => {
let hash = 7
for (let i = 0; i < username.length; i++) {
hash = username.charCodeAt(i) + (hash << 5) - hash
}
const index = Math.abs(hash % COLORS.length)
return COLORS[index]
}
// обработчики
window.onkeydown = (e) => {
if (!(e.ctrlKey || e.metaKey || e.altKey)) $currentInput.focus()
if (e.which === 13) {
if (username) {
sendMessage()
S.emit('stop typing')
typing = false
} else {
setUsername()
}
}
}
$messageInput.oninput = () => {
updateTyping()
}
$loginPage.onclick = () => {
$currentInput.focus()
}
$chatPage.onclick = () => {
$messageInput.focus()
}
// сокет
S.on('login', (data) => {
connected = true
log(`Welcome to Socket.IO Chat - `)
addParticipants(data)
})
S.on('new message', (data) => {
addChatMessage(data)
})
S.on('user joined', (data) => {
log(`${data.username} joined`)
addParticipants(data)
})
S.on('user left', (data) => {
log(`${data.username} left`)
addParticipants(data)
removeChatTyping(data)
})
S.on('typing', (data) => {
addChatTyping(data)
})
S.on('stop typing', (data) => {
removeChatTyping(data)
})
S.on('disconnect', () => {
log('You have been disconnected')
})
S.on('reconnect', () => {
log('You have been reconnected')
if (username) {
S.emit('add user', username)
}
})
S.on('reconnect_error', () => {
log('Attempt to reconnect has failed')
})
yarn start
# открываем localhost:3000