From 27adf4746575a94b52cade488c89306e274fb637 Mon Sep 17 00:00:00 2001 From: Slavasil Date: Sun, 1 Nov 2026 10:20:43 +0300 Subject: [PATCH] rewrite websocket client with @rails/actioncable --- frontend/package-lock.json | 7 + frontend/package.json | 1 + frontend/src/api.js | 2 +- frontend/src/auth.jsx | 38 ++- frontend/src/cable.js | 102 +++++++ frontend/src/components/SearchScreen.jsx | 6 +- frontend/src/matchmaking.js | 332 +++++++++++++++-------- 7 files changed, 376 insertions(+), 112 deletions(-) create mode 100644 frontend/src/cable.js diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 010f320..70231f0 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -8,6 +8,7 @@ "name": "sea-battle-frontend", "version": "0.1.0", "dependencies": { + "@rails/actioncable": "^8.1.400", "react": "^18.3.1", "react-dom": "^18.3.1" }, @@ -807,6 +808,12 @@ "node": "^22.20 || ^24.12 || >=25" } }, + "node_modules/@rails/actioncable": { + "version": "8.1.400", + "resolved": "https://registry.npmjs.org/@rails/actioncable/-/actioncable-8.1.400.tgz", + "integrity": "sha512-Qffc3/I9xcpeaal12hmi9EPRV3hz9FvakUHBR7kL0jpZviWwO9dyaG8F1ltcjkHIdFxQ5QqzRyequAWHMq17xQ==", + "license": "MIT" + }, "node_modules/@rolldown/pluginutils": { "version": "1.0.0-beta.27", "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", diff --git a/frontend/package.json b/frontend/package.json index 86a1388..baa03ec 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -9,6 +9,7 @@ "preview": "vite preview" }, "dependencies": { + "@rails/actioncable": "^8.1.400", "react": "^18.3.1", "react-dom": "^18.3.1" }, diff --git a/frontend/src/api.js b/frontend/src/api.js index a5a6786..3678734 100644 --- a/frontend/src/api.js +++ b/frontend/src/api.js @@ -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 = {} diff --git a/frontend/src/auth.jsx b/frontend/src/auth.jsx index fe3aa73..d9349a2 100644 --- a/frontend/src/auth.jsx +++ b/frontend/src/auth.jsx @@ -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 ( - - {children} + + + {children} + ) } diff --git a/frontend/src/cable.js b/frontend/src/cable.js new file mode 100644 index 0000000..4b369be --- /dev/null +++ b/frontend/src/cable.js @@ -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 +} diff --git a/frontend/src/components/SearchScreen.jsx b/frontend/src/components/SearchScreen.jsx index 9a35586..29f9112 100644 --- a/frontend/src/components/SearchScreen.jsx +++ b/frontend/src/components/SearchScreen.jsx @@ -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() diff --git a/frontend/src/matchmaking.js b/frontend/src/matchmaking.js index 3cedfb5..8b2037e 100644 --- a/frontend/src/matchmaking.js +++ b/frontend/src/matchmaking.js @@ -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 } }