rewrite websocket client with @rails/actioncable
This commit is contained in:
Generated
+7
@@ -8,6 +8,7 @@
|
|||||||
"name": "sea-battle-frontend",
|
"name": "sea-battle-frontend",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@rails/actioncable": "^8.1.400",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-dom": "^18.3.1"
|
"react-dom": "^18.3.1"
|
||||||
},
|
},
|
||||||
@@ -807,6 +808,12 @@
|
|||||||
"node": "^22.20 || ^24.12 || >=25"
|
"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": {
|
"node_modules/@rolldown/pluginutils": {
|
||||||
"version": "1.0.0-beta.27",
|
"version": "1.0.0-beta.27",
|
||||||
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz",
|
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz",
|
||||||
|
|||||||
@@ -9,6 +9,7 @@
|
|||||||
"preview": "vite preview"
|
"preview": "vite preview"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@rails/actioncable": "^8.1.400",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-dom": "^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 } = {}) {
|
async function request(path, { method = 'GET', body, token, formData } = {}) {
|
||||||
const headers = {}
|
const headers = {}
|
||||||
|
|||||||
+34
-2
@@ -1,6 +1,7 @@
|
|||||||
import { createContext, useContext, useEffect, useState, useCallback } from 'react'
|
import { createContext, useContext, useEffect, useState, useCallback } from 'react'
|
||||||
import { api } from './api'
|
import { api } from './api'
|
||||||
import AuthModal from './components/AuthModal'
|
import AuthModal from './components/AuthModal'
|
||||||
|
import { connectCable, disconnectCable, CableContext } from './cable.js'
|
||||||
|
|
||||||
const AuthContext = createContext(null)
|
const AuthContext = createContext(null)
|
||||||
const AuthModalContext = createContext(null)
|
const AuthModalContext = createContext(null)
|
||||||
@@ -27,20 +28,32 @@ export function AuthProvider({ children }) {
|
|||||||
const [user, setUser] = useState(null)
|
const [user, setUser] = useState(null)
|
||||||
const [token, setToken] = useState(() => localStorage.getItem(TOKEN_KEY))
|
const [token, setToken] = useState(() => localStorage.getItem(TOKEN_KEY))
|
||||||
const [loading, setLoading] = 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(() => {
|
useEffect(() => {
|
||||||
if (!token) {
|
if (!token) {
|
||||||
setUser(null)
|
setUser(null)
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
|
disconnectCable()
|
||||||
|
setConsumer(null)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
api
|
api
|
||||||
.me(token)
|
.me(token)
|
||||||
.then((data) => setUser(data.user))
|
.then((data) => {
|
||||||
|
setUser(data.user)
|
||||||
|
const c = connectCable(token)
|
||||||
|
setConsumer(c)
|
||||||
|
})
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
localStorage.removeItem(TOKEN_KEY)
|
localStorage.removeItem(TOKEN_KEY)
|
||||||
|
disconnectCable()
|
||||||
setToken(null)
|
setToken(null)
|
||||||
setUser(null)
|
setUser(null)
|
||||||
|
setConsumer(null)
|
||||||
})
|
})
|
||||||
.finally(() => setLoading(false))
|
.finally(() => setLoading(false))
|
||||||
}, [token])
|
}, [token])
|
||||||
@@ -50,6 +63,8 @@ export function AuthProvider({ children }) {
|
|||||||
localStorage.setItem(TOKEN_KEY, data.token)
|
localStorage.setItem(TOKEN_KEY, data.token)
|
||||||
setToken(data.token)
|
setToken(data.token)
|
||||||
setUser(data.user)
|
setUser(data.user)
|
||||||
|
const c = connectCable(data.token)
|
||||||
|
setConsumer(c)
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const register = useCallback(async (nickname, password, avatarFile) => {
|
const register = useCallback(async (nickname, password, avatarFile) => {
|
||||||
@@ -57,18 +72,35 @@ export function AuthProvider({ children }) {
|
|||||||
localStorage.setItem(TOKEN_KEY, data.token)
|
localStorage.setItem(TOKEN_KEY, data.token)
|
||||||
setToken(data.token)
|
setToken(data.token)
|
||||||
setUser(data.user)
|
setUser(data.user)
|
||||||
|
const c = connectCable(data.token)
|
||||||
|
setConsumer(c)
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const logout = useCallback(() => {
|
const logout = useCallback(() => {
|
||||||
if (token) api.logout(token).catch(() => {})
|
if (token) api.logout(token).catch(() => {})
|
||||||
|
disconnectCable()
|
||||||
localStorage.removeItem(TOKEN_KEY)
|
localStorage.removeItem(TOKEN_KEY)
|
||||||
setToken(null)
|
setToken(null)
|
||||||
setUser(null)
|
setUser(null)
|
||||||
|
setConsumer(null)
|
||||||
}, [token])
|
}, [token])
|
||||||
|
|
||||||
return (
|
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}
|
{children}
|
||||||
|
</CableContext.Provider>
|
||||||
</AuthContext.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 { useEffect, useRef, useState } from 'react'
|
||||||
import { createMatchmaking, MatchmakingError } from '../matchmaking'
|
import { createMatchmaking, MatchmakingError } from '../matchmaking'
|
||||||
|
import { useCable } from '../cable'
|
||||||
|
|
||||||
const STAGE_LABELS = {
|
const STAGE_LABELS = {
|
||||||
connecting: 'Подключение...',
|
connecting: 'Подключение...',
|
||||||
@@ -13,6 +14,7 @@ const ERROR_LABELS = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function SearchScreen({ token, onMatched, onCancel }) {
|
export default function SearchScreen({ token, onMatched, onCancel }) {
|
||||||
|
const { consumer } = useCable()
|
||||||
const [stage, setStage] = useState('connecting') // 'connecting' | 'searching'
|
const [stage, setStage] = useState('connecting') // 'connecting' | 'searching'
|
||||||
const [error, setError] = useState(null)
|
const [error, setError] = useState(null)
|
||||||
const [retryToken, setRetryToken] = useState(0)
|
const [retryToken, setRetryToken] = useState(0)
|
||||||
@@ -26,7 +28,7 @@ export default function SearchScreen({ token, onMatched, onCancel }) {
|
|||||||
setStage('connecting')
|
setStage('connecting')
|
||||||
setError(null)
|
setError(null)
|
||||||
|
|
||||||
matchmaking = createMatchmaking(token, (status) => {
|
matchmaking = createMatchmaking(consumer || token, (status) => {
|
||||||
if (!cancelled && attempt === attemptRef.current) setStage(status)
|
if (!cancelled && attempt === attemptRef.current) setStage(status)
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -43,7 +45,7 @@ export default function SearchScreen({ token, onMatched, onCancel }) {
|
|||||||
cancelled = true
|
cancelled = true
|
||||||
matchmaking.cancel()
|
matchmaking.cancel()
|
||||||
}
|
}
|
||||||
}, [token, onMatched, retryToken])
|
}, [consumer, token, onMatched, retryToken])
|
||||||
|
|
||||||
const handleCancel = () => {
|
const handleCancel = () => {
|
||||||
onCancel()
|
onCancel()
|
||||||
|
|||||||
+201
-81
@@ -1,6 +1,4 @@
|
|||||||
import { wsUrl } from './api'
|
import { getConsumer, connectCable } from './cable.js'
|
||||||
|
|
||||||
const CHANNEL_IDENTIFIER = JSON.stringify({ channel: 'MatchmakingChannel' })
|
|
||||||
|
|
||||||
export class MatchmakingError extends Error {
|
export class MatchmakingError extends Error {
|
||||||
constructor(code, message) {
|
constructor(code, message) {
|
||||||
@@ -9,75 +7,193 @@ export class MatchmakingError extends Error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Текущая активная сессия матчмейкинга на уровне модуля
|
||||||
|
let activeSession = null
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Подключается к main-server по WebSocket (Action Cable) и инициирует
|
* Инициирует поиск соперника через ActionCable MatchmakingChannel.
|
||||||
* поиск соперника. Возвращает объект с promise (резолвится при событии
|
* Возвращает объект с promise (резолвится при событии `matched`)
|
||||||
* `matched`) и функцией отмены `cancel()`.
|
* и функцией отмены `cancel()`.
|
||||||
*
|
*
|
||||||
* @param {string} token JWT пользователя
|
* Соединение WebSocket НЕ закрывается: при завершении или отмене
|
||||||
* @param {(status: 'connecting' | 'searching') => void} onStatus
|
* отменяется только подписка на канал (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) {
|
export function createMatchmaking(consumerOrToken, onStatus) {
|
||||||
let ws = null
|
let consumer = null
|
||||||
let settled = false
|
let statusCallback = onStatus
|
||||||
|
|
||||||
const send = (payload) => {
|
if (typeof consumerOrToken === 'function') {
|
||||||
if (ws && ws.readyState === WebSocket.OPEN) {
|
statusCallback = consumerOrToken
|
||||||
ws.send(JSON.stringify(payload))
|
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) => {
|
// Если уже существует активная сессия подписки (например, от предыдущего быстрого mount в StrictMode)
|
||||||
const fail = (err) => {
|
if (activeSession && activeSession.consumer === consumer && !activeSession.settled) {
|
||||||
if (settled) return
|
activeSession.cancelled = false
|
||||||
settled = true
|
activeSession.statusCallback = statusCallback
|
||||||
cleanup()
|
|
||||||
reject(err)
|
// Если подписка уже успела подтвердиться ранее
|
||||||
|
if (activeSession.isSubscribed) {
|
||||||
|
statusCallback?.('searching')
|
||||||
|
if (!activeSession.started) {
|
||||||
|
activeSession.subscription.perform('start')
|
||||||
|
activeSession.started = true
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
statusCallback?.('connecting')
|
||||||
}
|
}
|
||||||
|
|
||||||
const succeed = (match) => {
|
return {
|
||||||
if (settled) return
|
promise: activeSession.promise,
|
||||||
settled = true
|
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()
|
cleanup()
|
||||||
resolve(match)
|
resolve(match)
|
||||||
}
|
}
|
||||||
|
|
||||||
onStatus('connecting')
|
function fail(err) {
|
||||||
|
if (session.settled) return
|
||||||
ws = new WebSocket(wsUrl(`/cable?token=${encodeURIComponent(token)}`))
|
session.settled = true
|
||||||
let subscribed = false
|
cleanup()
|
||||||
|
reject(err)
|
||||||
ws.onopen = () => {
|
|
||||||
send({ command: 'subscribe', identifier: CHANNEL_IDENTIFIER })
|
|
||||||
}
|
}
|
||||||
|
|
||||||
ws.onmessage = (event) => {
|
session.statusCallback?.('connecting')
|
||||||
let msg
|
|
||||||
|
// Защита от «зависшего» подключения / подписки
|
||||||
|
session.connectTimeoutId = setTimeout(() => {
|
||||||
|
if (!session.settled && !session.started) {
|
||||||
|
fail(new MatchmakingError('connection', 'Не удалось подключиться к серверу'))
|
||||||
|
}
|
||||||
|
}, 10000)
|
||||||
|
|
||||||
try {
|
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 {
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
cleanup()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (msg.type === 'confirm_subscription') {
|
if (session.settled) return
|
||||||
subscribed = true
|
|
||||||
onStatus('searching')
|
if (session.connectTimeoutId) {
|
||||||
send({
|
clearTimeout(session.connectTimeoutId)
|
||||||
command: 'message',
|
session.connectTimeoutId = null
|
||||||
identifier: CHANNEL_IDENTIFIER,
|
}
|
||||||
data: JSON.stringify({ action: 'start' }),
|
if (session.disconnectTimeoutId) {
|
||||||
})
|
clearTimeout(session.disconnectTimeoutId)
|
||||||
return
|
session.disconnectTimeoutId = null
|
||||||
}
|
}
|
||||||
|
|
||||||
if (msg.type === 'reject_subscription') {
|
session.statusCallback?.('searching')
|
||||||
|
this.perform('start')
|
||||||
|
session.started = true
|
||||||
|
},
|
||||||
|
|
||||||
|
rejected() {
|
||||||
fail(new MatchmakingError('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
|
received(message) {
|
||||||
|
if (session.settled || session.cancelled) return
|
||||||
const message = msg.message
|
|
||||||
if (!message || typeof message !== 'object') return
|
if (!message || typeof message !== 'object') return
|
||||||
|
|
||||||
if (message.type === 'matched') {
|
if (message.type === 'matched') {
|
||||||
@@ -90,47 +206,51 @@ export function createMatchmaking(token, onStatus) {
|
|||||||
} else if (message.type === 'timeout') {
|
} else if (message.type === 'timeout') {
|
||||||
fail(new MatchmakingError('timeout', 'Соперник не найден'))
|
fail(new MatchmakingError('timeout', 'Соперник не найден'))
|
||||||
}
|
}
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
)
|
||||||
ws.onerror = () => {
|
} catch (err) {
|
||||||
if (!settled) fail(new MatchmakingError('connection', 'Сетевая ошибка'))
|
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() {
|
session.cancel = function cancel() {
|
||||||
if (ws) {
|
if (session.settled) return
|
||||||
ws.onopen = ws.onmessage = ws.onerror = ws.onclose = null
|
session.cancelled = true
|
||||||
ws.close()
|
|
||||||
ws = null
|
if (session.connectTimeoutId) {
|
||||||
|
clearTimeout(session.connectTimeoutId)
|
||||||
|
session.connectTimeoutId = null
|
||||||
}
|
}
|
||||||
|
if (session.disconnectTimeoutId) {
|
||||||
|
clearTimeout(session.disconnectTimeoutId)
|
||||||
|
session.disconnectTimeoutId = null
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Явная отмена поиска: команда `stop` + закрытие сокета. */
|
// Если сервер уже подтвердил подписку — сразу отправляем stop и отписываемся
|
||||||
function cancel() {
|
if (session.isSubscribed && session.subscription) {
|
||||||
if (settled) return
|
try {
|
||||||
settled = true
|
if (session.started) {
|
||||||
send({
|
session.subscription.perform('stop')
|
||||||
command: 'message',
|
}
|
||||||
identifier: CHANNEL_IDENTIFIER,
|
} catch {
|
||||||
data: JSON.stringify({ action: 'stop' }),
|
// ignore
|
||||||
})
|
}
|
||||||
cleanup()
|
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