rewrite websocket client with @rails/actioncable

This commit is contained in:
2026-11-01 10:20:43 +03:00
parent 6392c2ef03
commit 27adf47465
7 changed files with 376 additions and 112 deletions
+1 -1
View File
@@ -1,4 +1,4 @@
const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:3000'
const API_URL = (typeof import.meta !== 'undefined' && import.meta.env?.VITE_API_URL) || 'http://localhost:3000'
async function request(path, { method = 'GET', body, token, formData } = {}) {
const headers = {}
+35 -3
View File
@@ -1,6 +1,7 @@
import { createContext, useContext, useEffect, useState, useCallback } from 'react'
import { api } from './api'
import AuthModal from './components/AuthModal'
import { connectCable, disconnectCable, CableContext } from './cable.js'
const AuthContext = createContext(null)
const AuthModalContext = createContext(null)
@@ -27,20 +28,32 @@ export function AuthProvider({ children }) {
const [user, setUser] = useState(null)
const [token, setToken] = useState(() => localStorage.getItem(TOKEN_KEY))
const [loading, setLoading] = useState(!!localStorage.getItem(TOKEN_KEY))
const [consumer, setConsumer] = useState(() => {
const savedToken = localStorage.getItem(TOKEN_KEY)
return savedToken ? connectCable(savedToken) : null
})
useEffect(() => {
if (!token) {
setUser(null)
setLoading(false)
disconnectCable()
setConsumer(null)
return
}
api
.me(token)
.then((data) => setUser(data.user))
.then((data) => {
setUser(data.user)
const c = connectCable(token)
setConsumer(c)
})
.catch(() => {
localStorage.removeItem(TOKEN_KEY)
disconnectCable()
setToken(null)
setUser(null)
setConsumer(null)
})
.finally(() => setLoading(false))
}, [token])
@@ -50,6 +63,8 @@ export function AuthProvider({ children }) {
localStorage.setItem(TOKEN_KEY, data.token)
setToken(data.token)
setUser(data.user)
const c = connectCable(data.token)
setConsumer(c)
}, [])
const register = useCallback(async (nickname, password, avatarFile) => {
@@ -57,18 +72,35 @@ export function AuthProvider({ children }) {
localStorage.setItem(TOKEN_KEY, data.token)
setToken(data.token)
setUser(data.user)
const c = connectCable(data.token)
setConsumer(c)
}, [])
const logout = useCallback(() => {
if (token) api.logout(token).catch(() => {})
disconnectCable()
localStorage.removeItem(TOKEN_KEY)
setToken(null)
setUser(null)
setConsumer(null)
}, [token])
return (
<AuthContext.Provider value={{ user, token, loading, login, register, logout }}>
{children}
<AuthContext.Provider
value={{
user,
token,
loading,
consumer,
cable: consumer,
login,
register,
logout,
}}
>
<CableContext.Provider value={{ consumer, cable: consumer }}>
{children}
</CableContext.Provider>
</AuthContext.Provider>
)
}
+102
View File
@@ -0,0 +1,102 @@
import { createContext, useContext, useEffect, useState, createElement } from 'react'
import { createConsumer } from '@rails/actioncable'
import { wsUrl } from './api.js'
let consumerInstance = null
let activeToken = null
/**
* Возвращает активный экземпляр ActionCable consumer, если он создан.
*
* @returns {import('@rails/actioncable').Consumer | null}
*/
export function getConsumer() {
return consumerInstance
}
/**
* Подключает (или возвращает существующий) ActionCable consumer для переданного JWT токена.
* ActionCable автоматически обрабатывает переподключения при разрывах связи.
*
* @param {string} token JWT токен пользователя
* @returns {import('@rails/actioncable').Consumer | null}
*/
export function connectCable(token) {
if (!token) return null
if (consumerInstance && activeToken === token) {
consumerInstance.connect()
return consumerInstance
}
if (consumerInstance) {
disconnectCable()
}
activeToken = token
const url = wsUrl(`/cable?token=${encodeURIComponent(token)}`)
consumerInstance = createConsumer(url)
consumerInstance.connect()
return consumerInstance
}
/**
* Отключает ActionCable consumer и закрывает соединение.
* Вызывается при выходе из профиля (logout) или истечении сессии.
*/
export function disconnectCable() {
if (consumerInstance) {
try {
consumerInstance.disconnect()
} catch {
// Игнорируем возможные ошибки при закрытии
}
consumerInstance = null
}
activeToken = null
}
export const CableContext = createContext(null)
/**
* Провайдер ActionCable для React приложения.
* Может принимать token явно или использоваться в составе дерева компонентов.
*/
export function CableProvider({ children, token }) {
const [consumer, setConsumer] = useState(() => (token ? connectCable(token) : getConsumer()))
useEffect(() => {
if (token) {
const c = connectCable(token)
setConsumer(c)
} else if (token === null) {
disconnectCable()
setConsumer(null)
}
}, [token])
return createElement(
CableContext.Provider,
{ value: { consumer, cable: consumer } },
children
)
}
/**
* Хук для доступа к ActionCable consumer из любого компонента.
* Поддерживает деструктуризацию: `const { consumer } = useCable()`, `const { cable } = useCable()`.
*/
export function useCable() {
const context = useContext(CableContext)
if (context && context.consumer !== undefined) return context
const consumer = getConsumer()
return { consumer, cable: consumer }
}
/**
* Хук для получения экземпляра consumer напрямую: `const consumer = useConsumer()`.
*/
export function useConsumer() {
const { consumer } = useCable()
return consumer
}
+4 -2
View File
@@ -1,5 +1,6 @@
import { useEffect, useRef, useState } from 'react'
import { createMatchmaking, MatchmakingError } from '../matchmaking'
import { useCable } from '../cable'
const STAGE_LABELS = {
connecting: 'Подключение...',
@@ -13,6 +14,7 @@ const ERROR_LABELS = {
}
export default function SearchScreen({ token, onMatched, onCancel }) {
const { consumer } = useCable()
const [stage, setStage] = useState('connecting') // 'connecting' | 'searching'
const [error, setError] = useState(null)
const [retryToken, setRetryToken] = useState(0)
@@ -26,7 +28,7 @@ export default function SearchScreen({ token, onMatched, onCancel }) {
setStage('connecting')
setError(null)
matchmaking = createMatchmaking(token, (status) => {
matchmaking = createMatchmaking(consumer || token, (status) => {
if (!cancelled && attempt === attemptRef.current) setStage(status)
})
@@ -43,7 +45,7 @@ export default function SearchScreen({ token, onMatched, onCancel }) {
cancelled = true
matchmaking.cancel()
}
}, [token, onMatched, retryToken])
}, [consumer, token, onMatched, retryToken])
const handleCancel = () => {
onCancel()
+226 -106
View File
@@ -1,6 +1,4 @@
import { wsUrl } from './api'
const CHANNEL_IDENTIFIER = JSON.stringify({ channel: 'MatchmakingChannel' })
import { getConsumer, connectCable } from './cable.js'
export class MatchmakingError extends Error {
constructor(code, message) {
@@ -9,128 +7,250 @@ export class MatchmakingError extends Error {
}
}
/**
* Подключается к main-server по WebSocket (Action Cable) и инициирует
* поиск соперника. Возвращает объект с promise (резолвится при событии
* `matched`) и функцией отмены `cancel()`.
*
* @param {string} token JWT пользователя
* @param {(status: 'connecting' | 'searching') => void} onStatus
*/
export function createMatchmaking(token, onStatus) {
let ws = null
let settled = false
// Текущая активная сессия матчмейкинга на уровне модуля
let activeSession = null
const send = (payload) => {
if (ws && ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify(payload))
/**
* Инициирует поиск соперника через ActionCable MatchmakingChannel.
* Возвращает объект с promise (резолвится при событии `matched`)
* и функцией отмены `cancel()`.
*
* Соединение WebSocket НЕ закрывается: при завершении или отмене
* отменяется только подписка на канал (MatchmakingChannel),
* а сокет остается открытым и доступным для всего приложения.
*
* Если отмена происходит до подтверждения подписки сервером (например,
* при двойном монтировании в React StrictMode или быстром уходе со страницы),
* отписка откладывается до прихода `confirm_subscription`, чтобы не создавать
* гонку непарных subscribe/unsubscribe на сервере. При немедленном повторном
* монтировании уже начатая подписка переиспользуется.
*
* @param {import('@rails/actioncable').Consumer | string} [consumerOrToken]
* Экземпляр ActionCable consumer или JWT токен пользователя
* @param {(status: 'connecting' | 'searching') => void} [onStatus]
* @returns {{ promise: Promise<{ matchId: string, gameToken: string, gameServerUrl: string, players: [number, number] }>, cancel: () => void }}
*/
export function createMatchmaking(consumerOrToken, onStatus) {
let consumer = null
let statusCallback = onStatus
if (typeof consumerOrToken === 'function') {
statusCallback = consumerOrToken
consumer = getConsumer()
} else if (consumerOrToken && typeof consumerOrToken.subscriptions === 'object') {
consumer = consumerOrToken
} else if (typeof consumerOrToken === 'string') {
consumer = getConsumer() || connectCable(consumerOrToken)
} else {
consumer = getConsumer()
}
if (!consumer) {
return {
promise: Promise.reject(new MatchmakingError('connection', 'Нет подключения к серверу')),
cancel: () => {},
}
}
const promise = new Promise((resolve, reject) => {
const fail = (err) => {
if (settled) return
settled = true
cleanup()
reject(err)
// Если уже существует активная сессия подписки (например, от предыдущего быстрого mount в StrictMode)
if (activeSession && activeSession.consumer === consumer && !activeSession.settled) {
activeSession.cancelled = false
activeSession.statusCallback = statusCallback
// Если подписка уже успела подтвердиться ранее
if (activeSession.isSubscribed) {
statusCallback?.('searching')
if (!activeSession.started) {
activeSession.subscription.perform('start')
activeSession.started = true
}
} else {
statusCallback?.('connecting')
}
const succeed = (match) => {
if (settled) return
settled = true
return {
promise: activeSession.promise,
cancel: activeSession.cancel,
}
}
const session = {
consumer,
statusCallback,
isSubscribed: false,
cancelled: false,
settled: false,
started: false,
subscription: null,
connectTimeoutId: null,
disconnectTimeoutId: null,
promise: null,
cancel: null,
}
activeSession = session
session.promise = new Promise((resolve, reject) => {
function cleanup() {
if (session.connectTimeoutId) {
clearTimeout(session.connectTimeoutId)
session.connectTimeoutId = null
}
if (session.disconnectTimeoutId) {
clearTimeout(session.disconnectTimeoutId)
session.disconnectTimeoutId = null
}
if (activeSession === session) {
activeSession = null
}
if (session.subscription) {
try {
session.subscription.unsubscribe()
} catch {
// ignore
}
session.subscription = null
}
}
function succeed(match) {
if (session.settled) return
session.settled = true
cleanup()
resolve(match)
}
onStatus('connecting')
ws = new WebSocket(wsUrl(`/cable?token=${encodeURIComponent(token)}`))
let subscribed = false
ws.onopen = () => {
send({ command: 'subscribe', identifier: CHANNEL_IDENTIFIER })
function fail(err) {
if (session.settled) return
session.settled = true
cleanup()
reject(err)
}
ws.onmessage = (event) => {
let msg
try {
msg = JSON.parse(event.data)
} catch {
return
}
session.statusCallback?.('connecting')
if (msg.type === 'confirm_subscription') {
subscribed = true
onStatus('searching')
send({
command: 'message',
identifier: CHANNEL_IDENTIFIER,
data: JSON.stringify({ action: 'start' }),
})
return
}
if (msg.type === 'reject_subscription') {
fail(new MatchmakingError('rejected', 'Не удалось подписаться на поиск соперника'))
return
}
if (msg.type === 'ping' || msg.type === 'welcome') return
const message = msg.message
if (!message || typeof message !== 'object') return
if (message.type === 'matched') {
succeed({
matchId: message.match_id,
gameToken: message.game_token,
gameServerUrl: message.game_server,
players: message.players,
})
} else if (message.type === 'timeout') {
fail(new MatchmakingError('timeout', 'Соперник не найден'))
}
}
ws.onerror = () => {
if (!settled) fail(new MatchmakingError('connection', 'Сетевая ошибка'))
}
ws.onclose = (event) => {
if (!settled) {
const err = new MatchmakingError('connection', 'Соединение прервано')
err.closeCode = event.code
fail(err)
}
}
// Подписка не подтверждена — защита от «зависшего» соединения
setTimeout(() => {
if (!settled && !subscribed) {
// Защита от «зависшего» подключения / подписки
session.connectTimeoutId = setTimeout(() => {
if (!session.settled && !session.started) {
fail(new MatchmakingError('connection', 'Не удалось подключиться к серверу'))
}
}, 10000)
try {
session.subscription = consumer.subscriptions.create(
{ channel: 'MatchmakingChannel' },
{
connected() {
session.isSubscribed = true
// Если поиск был отменен ДО подтверждения подписки (отложенная отписка)
if (session.cancelled) {
try {
this.perform('stop')
} catch {
// ignore
}
cleanup()
return
}
if (session.settled) return
if (session.connectTimeoutId) {
clearTimeout(session.connectTimeoutId)
session.connectTimeoutId = null
}
if (session.disconnectTimeoutId) {
clearTimeout(session.disconnectTimeoutId)
session.disconnectTimeoutId = null
}
session.statusCallback?.('searching')
this.perform('start')
session.started = true
},
rejected() {
fail(new MatchmakingError('rejected', 'Не удалось подписаться на поиск соперника'))
},
disconnected(data) {
session.isSubscribed = false
if (session.settled) return
// Если ActionCable пытается восстановить связь
if (data?.willAttemptReconnect) {
session.statusCallback?.('connecting')
if (!session.disconnectTimeoutId) {
session.disconnectTimeoutId = setTimeout(() => {
if (!session.settled) {
fail(new MatchmakingError('connection', 'Соединение прервано'))
}
}, 10000)
}
} else {
fail(new MatchmakingError('connection', 'Соединение прервано'))
}
},
received(message) {
if (session.settled || session.cancelled) return
if (!message || typeof message !== 'object') return
if (message.type === 'matched') {
succeed({
matchId: message.match_id,
gameToken: message.game_token,
gameServerUrl: message.game_server,
players: message.players,
})
} else if (message.type === 'timeout') {
fail(new MatchmakingError('timeout', 'Соперник не найден'))
}
},
}
)
} catch (err) {
fail(new MatchmakingError('connection', err.message || 'Ошибка подключения'))
}
})
function cleanup() {
if (ws) {
ws.onopen = ws.onmessage = ws.onerror = ws.onclose = null
ws.close()
ws = null
session.cancel = function cancel() {
if (session.settled) return
session.cancelled = true
if (session.connectTimeoutId) {
clearTimeout(session.connectTimeoutId)
session.connectTimeoutId = null
}
if (session.disconnectTimeoutId) {
clearTimeout(session.disconnectTimeoutId)
session.disconnectTimeoutId = null
}
// Если сервер уже подтвердил подписку — сразу отправляем stop и отписываемся
if (session.isSubscribed && session.subscription) {
try {
if (session.started) {
session.subscription.perform('stop')
}
} catch {
// ignore
}
try {
session.subscription.unsubscribe()
} catch {
// ignore
}
session.subscription = null
if (activeSession === session) {
activeSession = null
}
}
// Если подписка ещё не подтверждена (isSubscribed === false),
// мы НЕ шлем немедленный unsubscribe, предотвращая гонку на сервере.
// Как только сервер пришлет подтверждение, сработает connected()
// и выполнит отложенный unsubscribe.
}
/** Явная отмена поиска: команда `stop` + закрытие сокета. */
function cancel() {
if (settled) return
settled = true
send({
command: 'message',
identifier: CHANNEL_IDENTIFIER,
data: JSON.stringify({ action: 'stop' }),
})
cleanup()
}
return { promise, cancel }
return { promise: session.promise, cancel: session.cancel }
}