rewrite websocket client with @rails/actioncable
This commit is contained in:
Generated
+7
@@ -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",
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@rails/actioncable": "^8.1.400",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1"
|
||||
},
|
||||
|
||||
+1
-1
@@ -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 = {}
|
||||
|
||||
+34
-2
@@ -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 }}>
|
||||
<AuthContext.Provider
|
||||
value={{
|
||||
user,
|
||||
token,
|
||||
loading,
|
||||
consumer,
|
||||
cable: consumer,
|
||||
login,
|
||||
register,
|
||||
logout,
|
||||
}}
|
||||
>
|
||||
<CableContext.Provider value={{ consumer, cable: consumer }}>
|
||||
{children}
|
||||
</CableContext.Provider>
|
||||
</AuthContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
+201
-81
@@ -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,75 +7,193 @@ export class MatchmakingError extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
// Текущая активная сессия матчмейкинга на уровне модуля
|
||||
let activeSession = null
|
||||
|
||||
/**
|
||||
* Подключается к main-server по WebSocket (Action Cable) и инициирует
|
||||
* поиск соперника. Возвращает объект с promise (резолвится при событии
|
||||
* `matched`) и функцией отмены `cancel()`.
|
||||
* Инициирует поиск соперника через ActionCable MatchmakingChannel.
|
||||
* Возвращает объект с promise (резолвится при событии `matched`)
|
||||
* и функцией отмены `cancel()`.
|
||||
*
|
||||
* @param {string} token JWT пользователя
|
||||
* @param {(status: 'connecting' | 'searching') => void} onStatus
|
||||
* Соединение 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(token, onStatus) {
|
||||
let ws = null
|
||||
let settled = false
|
||||
export function createMatchmaking(consumerOrToken, onStatus) {
|
||||
let consumer = null
|
||||
let statusCallback = onStatus
|
||||
|
||||
const send = (payload) => {
|
||||
if (ws && ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(JSON.stringify(payload))
|
||||
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
|
||||
session.statusCallback?.('connecting')
|
||||
|
||||
// Защита от «зависшего» подключения / подписки
|
||||
session.connectTimeoutId = setTimeout(() => {
|
||||
if (!session.settled && !session.started) {
|
||||
fail(new MatchmakingError('connection', 'Не удалось подключиться к серверу'))
|
||||
}
|
||||
}, 10000)
|
||||
|
||||
try {
|
||||
msg = JSON.parse(event.data)
|
||||
session.subscription = consumer.subscriptions.create(
|
||||
{ channel: 'MatchmakingChannel' },
|
||||
{
|
||||
connected() {
|
||||
session.isSubscribed = true
|
||||
|
||||
// Если поиск был отменен ДО подтверждения подписки (отложенная отписка)
|
||||
if (session.cancelled) {
|
||||
try {
|
||||
this.perform('stop')
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
cleanup()
|
||||
return
|
||||
}
|
||||
|
||||
if (msg.type === 'confirm_subscription') {
|
||||
subscribed = true
|
||||
onStatus('searching')
|
||||
send({
|
||||
command: 'message',
|
||||
identifier: CHANNEL_IDENTIFIER,
|
||||
data: JSON.stringify({ action: 'start' }),
|
||||
})
|
||||
return
|
||||
if (session.settled) return
|
||||
|
||||
if (session.connectTimeoutId) {
|
||||
clearTimeout(session.connectTimeoutId)
|
||||
session.connectTimeoutId = null
|
||||
}
|
||||
if (session.disconnectTimeoutId) {
|
||||
clearTimeout(session.disconnectTimeoutId)
|
||||
session.disconnectTimeoutId = null
|
||||
}
|
||||
|
||||
if (msg.type === 'reject_subscription') {
|
||||
session.statusCallback?.('searching')
|
||||
this.perform('start')
|
||||
session.started = true
|
||||
},
|
||||
|
||||
rejected() {
|
||||
fail(new MatchmakingError('rejected', 'Не удалось подписаться на поиск соперника'))
|
||||
return
|
||||
},
|
||||
|
||||
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', 'Соединение прервано'))
|
||||
}
|
||||
},
|
||||
|
||||
if (msg.type === 'ping' || msg.type === 'welcome') return
|
||||
|
||||
const message = msg.message
|
||||
received(message) {
|
||||
if (session.settled || session.cancelled) return
|
||||
if (!message || typeof message !== 'object') return
|
||||
|
||||
if (message.type === 'matched') {
|
||||
@@ -90,47 +206,51 @@ export function createMatchmaking(token, onStatus) {
|
||||
} else if (message.type === 'timeout') {
|
||||
fail(new MatchmakingError('timeout', 'Соперник не найден'))
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
ws.onerror = () => {
|
||||
if (!settled) fail(new MatchmakingError('connection', 'Сетевая ошибка'))
|
||||
)
|
||||
} catch (err) {
|
||||
fail(new MatchmakingError('connection', err.message || 'Ошибка подключения'))
|
||||
}
|
||||
|
||||
ws.onclose = (event) => {
|
||||
if (!settled) {
|
||||
const err = new MatchmakingError('connection', 'Соединение прервано')
|
||||
err.closeCode = event.code
|
||||
fail(err)
|
||||
}
|
||||
}
|
||||
|
||||
// Подписка не подтверждена — защита от «зависшего» соединения
|
||||
setTimeout(() => {
|
||||
if (!settled && !subscribed) {
|
||||
fail(new MatchmakingError('connection', 'Не удалось подключиться к серверу'))
|
||||
}
|
||||
}, 10000)
|
||||
})
|
||||
|
||||
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` + закрытие сокета. */
|
||||
function cancel() {
|
||||
if (settled) return
|
||||
settled = true
|
||||
send({
|
||||
command: 'message',
|
||||
identifier: CHANNEL_IDENTIFIER,
|
||||
data: JSON.stringify({ action: 'stop' }),
|
||||
})
|
||||
cleanup()
|
||||
// Если сервер уже подтвердил подписку — сразу отправляем 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.
|
||||
}
|
||||
|
||||
return { promise, cancel }
|
||||
return { promise: session.promise, cancel: session.cancel }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user