feat(auth): 集成认证授权功能并优化API客户端
- 实现了完整的登录注册认证流程,包括密码验证和用户资料获取 - 集成了JWT令牌管理和自动刷新机制,支持设备ID生成和管理 - 添加了WebSocket连接配置和API基础URL环境变量设置 - 实现了API客户端的请求拦截器,包括令牌验证和错误处理逻辑 - 集成了MD5加密和认证令牌缓存机制,提升安全性 - 添加了多语言国际化支持,包括英语、中文、马来语和印尼语 - 实现了认证状态管理和本地存储持久化功能 - 添加了表单验证schema和错误处理机制,增强用户体验
This commit is contained in:
@@ -4,14 +4,15 @@ import {
|
||||
DEFAULT_REQUEST_ACCEPT_HEADER,
|
||||
DEFAULT_REQUEST_TIMEOUT_MS,
|
||||
} from '@/constants'
|
||||
import type { AuthTokenDto } from '@/features/auth/api/types'
|
||||
import { ApiError } from '@/lib/api/api-error.ts'
|
||||
import {
|
||||
handleUnauthorizedSession,
|
||||
tryRefreshAuthSession,
|
||||
} from '@/lib/auth/auth-session'
|
||||
import { useAuthStore } from '@/store/auth'
|
||||
|
||||
import { ApiError } from './api-error'
|
||||
import type { ApiResponse } from './types'
|
||||
import { md5 } from '@/lib/crypto/md5'
|
||||
import { getAuthDeviceId, useAuthStore } from '@/store/auth'
|
||||
import type { ApiResponse } from '@/type'
|
||||
|
||||
type RequestOptions = Omit<Options, 'json'>
|
||||
type JsonRequestOptions<TBody> = RequestOptions & {
|
||||
@@ -20,7 +21,12 @@ type JsonRequestOptions<TBody> = RequestOptions & {
|
||||
|
||||
const AUTH_REFRESH_ATTEMPTED_CONTEXT_KEY = 'authRefreshAttempted'
|
||||
const AUTH_SKIP_REFRESH_CONTEXT_KEY = 'skipAuthRefresh'
|
||||
const AUTH_TOKEN_ENDPOINT = 'api/v1/authToken'
|
||||
const AUTH_REFRESH_ENDPOINT = 'api/user/refreshToken'
|
||||
const ACCESS_TOKEN_REFRESH_SKEW_MS = 60_000
|
||||
const AUTH_TOKEN_CACHE_SKEW_MS = 30_000
|
||||
const appEnv = import.meta.env.VITE_APP_ENV
|
||||
const authSecret = import.meta.env.VITE_AUTH_TOKEN_SECRET?.trim()
|
||||
const shouldLogRequests = import.meta.env.VITE_ENABLE_REQUEST_LOG === 'true'
|
||||
|
||||
function normalizeApiBaseUrl(baseUrl: string | undefined) {
|
||||
@@ -96,6 +102,15 @@ async function toApiError(error: unknown) {
|
||||
|
||||
export const apiBaseUrl = normalizeApiBaseUrl(import.meta.env.VITE_API_BASE_URL)
|
||||
|
||||
const authTokenClient = ky.create({
|
||||
prefix: apiBaseUrl,
|
||||
retry: 0,
|
||||
timeout: DEFAULT_REQUEST_TIMEOUT_MS,
|
||||
headers: {
|
||||
Accept: DEFAULT_REQUEST_ACCEPT_HEADER,
|
||||
},
|
||||
})
|
||||
|
||||
const apiClient = ky.create({
|
||||
prefix: apiBaseUrl,
|
||||
retry: 0,
|
||||
@@ -109,6 +124,7 @@ const apiClient = ky.create({
|
||||
|
||||
if (token) {
|
||||
request.headers.set('Authorization', `Bearer ${token}`)
|
||||
request.headers.set('user-token', token)
|
||||
}
|
||||
|
||||
if (shouldLogRequests) {
|
||||
@@ -128,9 +144,153 @@ const apiClient = ky.create({
|
||||
},
|
||||
})
|
||||
|
||||
function shouldAttachAuthToken(input: string) {
|
||||
return input !== AUTH_TOKEN_ENDPOINT
|
||||
}
|
||||
|
||||
function shouldTryRefreshAccessToken(input: string, options?: Options) {
|
||||
if (
|
||||
input === AUTH_REFRESH_ENDPOINT ||
|
||||
options?.context?.[AUTH_SKIP_REFRESH_CONTEXT_KEY] === true
|
||||
) {
|
||||
return false
|
||||
}
|
||||
|
||||
const authState = useAuthStore.getState()
|
||||
|
||||
return Boolean(
|
||||
authState.accessToken &&
|
||||
authState.accessTokenExpiresAt &&
|
||||
authState.accessTokenExpiresAt <=
|
||||
Date.now() + ACCESS_TOKEN_REFRESH_SKEW_MS,
|
||||
)
|
||||
}
|
||||
|
||||
function unwrapEnvelopeData<T>(response: ApiResponse<T>) {
|
||||
if (response.code === 1) {
|
||||
return response.data
|
||||
}
|
||||
|
||||
throw new ApiError({
|
||||
data: response,
|
||||
message:
|
||||
'msg' in response && typeof response.msg === 'string'
|
||||
? response.msg
|
||||
: 'message' in response && typeof response.message === 'string'
|
||||
? response.message
|
||||
: API_ERROR_MESSAGES.unexpected,
|
||||
})
|
||||
}
|
||||
|
||||
async function fetchAuthToken() {
|
||||
try {
|
||||
const authState = useAuthStore.getState()
|
||||
|
||||
if (
|
||||
authState.apiAuthToken &&
|
||||
authState.apiAuthTokenExpiresAt &&
|
||||
authState.apiAuthTokenExpiresAt > Date.now() + AUTH_TOKEN_CACHE_SKEW_MS
|
||||
) {
|
||||
return authState.apiAuthToken
|
||||
}
|
||||
|
||||
if (!authSecret) {
|
||||
throw new ApiError({
|
||||
message: 'auth.errors.authTokenConfigMissing',
|
||||
})
|
||||
}
|
||||
|
||||
const deviceId = getAuthDeviceId()
|
||||
const timestamp = Math.floor(Date.now() / 1000)
|
||||
const signature = md5(
|
||||
`device_id=${deviceId}&secret=${authSecret}×tamp=${timestamp}`,
|
||||
).toUpperCase()
|
||||
|
||||
const response = await authTokenClient
|
||||
.get(AUTH_TOKEN_ENDPOINT, {
|
||||
searchParams: {
|
||||
device_id: deviceId,
|
||||
secret: authSecret,
|
||||
signature,
|
||||
timestamp: String(timestamp),
|
||||
},
|
||||
})
|
||||
.json<ApiResponse<AuthTokenDto>>()
|
||||
|
||||
const data = unwrapEnvelopeData(response)
|
||||
const expiresAt = Date.now() + data.expires_in * 1000
|
||||
|
||||
useAuthStore.getState().setApiAuthToken({
|
||||
expiresAt,
|
||||
serverTime: data.server_time,
|
||||
value: data.auth_token,
|
||||
})
|
||||
|
||||
return data.auth_token
|
||||
} catch (error) {
|
||||
throw await toApiError(error)
|
||||
}
|
||||
}
|
||||
|
||||
export async function prefetchAuthToken() {
|
||||
await fetchAuthToken()
|
||||
}
|
||||
|
||||
function createHeaders(headersInit?: Options['headers']) {
|
||||
const headers = new Headers()
|
||||
|
||||
if (!headersInit) {
|
||||
return headers
|
||||
}
|
||||
|
||||
if (headersInit instanceof Headers) {
|
||||
headersInit.forEach((value, key) => {
|
||||
headers.set(key, value)
|
||||
})
|
||||
|
||||
return headers
|
||||
}
|
||||
|
||||
if (Array.isArray(headersInit)) {
|
||||
for (const [key, value] of headersInit) {
|
||||
headers.set(key, value)
|
||||
}
|
||||
|
||||
return headers
|
||||
}
|
||||
|
||||
for (const [key, value] of Object.entries(headersInit)) {
|
||||
if (typeof value === 'string') {
|
||||
headers.set(key, value)
|
||||
}
|
||||
}
|
||||
|
||||
return headers
|
||||
}
|
||||
|
||||
async function buildRequestOptions(input: string, options?: Options) {
|
||||
const headers = createHeaders(options?.headers)
|
||||
|
||||
if (shouldAttachAuthToken(input) && !headers.has('auth-token')) {
|
||||
headers.set('auth-token', await fetchAuthToken())
|
||||
}
|
||||
|
||||
return {
|
||||
...options,
|
||||
headers,
|
||||
} satisfies Options
|
||||
}
|
||||
|
||||
async function request<TResponse>(input: string, options?: Options) {
|
||||
try {
|
||||
const response = await apiClient(input, options)
|
||||
if (shouldTryRefreshAccessToken(input, options)) {
|
||||
await tryRefreshAuthSession()
|
||||
}
|
||||
|
||||
const response = await apiClient(
|
||||
input,
|
||||
await buildRequestOptions(input, options),
|
||||
)
|
||||
const data = await parseResponseBody(response)
|
||||
|
||||
return data as TResponse
|
||||
@@ -138,6 +298,7 @@ async function request<TResponse>(input: string, options?: Options) {
|
||||
if (
|
||||
error instanceof HTTPError &&
|
||||
error.response.status === 401 &&
|
||||
input !== AUTH_REFRESH_ENDPOINT &&
|
||||
options?.context?.[AUTH_SKIP_REFRESH_CONTEXT_KEY] !== true &&
|
||||
options?.context?.[AUTH_REFRESH_ATTEMPTED_CONTEXT_KEY] !== true
|
||||
) {
|
||||
|
||||
@@ -1,9 +1,4 @@
|
||||
interface ApiErrorOptions {
|
||||
message: string
|
||||
status?: number
|
||||
data?: unknown
|
||||
url?: string
|
||||
}
|
||||
import type { ApiErrorOptions } from '@/type'
|
||||
|
||||
export class ApiError extends Error {
|
||||
status: number | null
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
/** @description 后端统一响应体结构。 */
|
||||
export interface ApiResponse<T> {
|
||||
code: number
|
||||
msg: string
|
||||
data: T
|
||||
}
|
||||
@@ -61,6 +61,14 @@ export async function initializeAuthSession() {
|
||||
return authInitializationPromise
|
||||
}
|
||||
|
||||
export async function hydrateCurrentUser(initializer: CurrentUserInitializer) {
|
||||
const currentUser = await initializer()
|
||||
|
||||
useAuthStore.getState().setCurrentUser(currentUser)
|
||||
|
||||
return currentUser
|
||||
}
|
||||
|
||||
export async function tryRefreshAuthSession() {
|
||||
if (refreshSessionPromise) {
|
||||
return refreshSessionPromise
|
||||
@@ -86,6 +94,8 @@ export async function tryRefreshAuthSession() {
|
||||
|
||||
useAuthStore.getState().startSession({
|
||||
accessToken: nextSession.accessToken,
|
||||
accessTokenExpiresAt:
|
||||
nextSession.accessTokenExpiresAt ?? snapshot.accessTokenExpiresAt,
|
||||
currentUser: nextSession.currentUser ?? snapshot.currentUser,
|
||||
refreshToken: nextSession.refreshToken ?? snapshot.refreshToken,
|
||||
})
|
||||
|
||||
5
src/lib/crypto/md5.ts
Normal file
5
src/lib/crypto/md5.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import md5Hash from 'md5'
|
||||
|
||||
export function md5(value: string) {
|
||||
return md5Hash(value)
|
||||
}
|
||||
113
src/lib/notify.ts
Normal file
113
src/lib/notify.ts
Normal file
@@ -0,0 +1,113 @@
|
||||
import { create } from 'zustand'
|
||||
|
||||
const DEFAULT_TOAST_DURATION_MS = 3200
|
||||
|
||||
type NotificationType = 'success' | 'error' | 'warning' | 'info' | 'loading'
|
||||
|
||||
export interface NotifyOptions {
|
||||
description?: string
|
||||
duration?: number
|
||||
}
|
||||
|
||||
interface NotificationToast {
|
||||
description?: string
|
||||
duration: number
|
||||
id: string
|
||||
message: string
|
||||
type: NotificationType
|
||||
}
|
||||
|
||||
interface NotificationStoreState {
|
||||
dismissToast: (id: string) => void
|
||||
pushToast: (toast: NotificationToast) => void
|
||||
toasts: NotificationToast[]
|
||||
}
|
||||
|
||||
const toastTimers = new Map<string, number>()
|
||||
|
||||
export const useNotificationStore = create<NotificationStoreState>()((set) => ({
|
||||
dismissToast: (id) => {
|
||||
const timerId = toastTimers.get(id)
|
||||
|
||||
if (timerId) {
|
||||
window.clearTimeout(timerId)
|
||||
toastTimers.delete(id)
|
||||
}
|
||||
|
||||
set((state) => ({
|
||||
toasts: state.toasts.filter((toast) => toast.id !== id),
|
||||
}))
|
||||
},
|
||||
pushToast: (toast) => {
|
||||
set((state) => ({
|
||||
toasts: [...state.toasts.filter((item) => item.id !== toast.id), toast],
|
||||
}))
|
||||
},
|
||||
toasts: [],
|
||||
}))
|
||||
|
||||
function createToastId() {
|
||||
return `toast-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`
|
||||
}
|
||||
|
||||
function showToast(
|
||||
type: NotificationType,
|
||||
message: string,
|
||||
options?: NotifyOptions,
|
||||
) {
|
||||
const id = createToastId()
|
||||
const duration = options?.duration ?? DEFAULT_TOAST_DURATION_MS
|
||||
|
||||
useNotificationStore.getState().pushToast({
|
||||
description: options?.description,
|
||||
duration,
|
||||
id,
|
||||
message,
|
||||
type,
|
||||
})
|
||||
|
||||
if (duration > 0) {
|
||||
const timerId = window.setTimeout(() => {
|
||||
useNotificationStore.getState().dismissToast(id)
|
||||
}, duration)
|
||||
|
||||
toastTimers.set(id, timerId)
|
||||
}
|
||||
|
||||
return id
|
||||
}
|
||||
|
||||
export const notify = {
|
||||
dismiss(id?: string) {
|
||||
if (id) {
|
||||
useNotificationStore.getState().dismissToast(id)
|
||||
return id
|
||||
}
|
||||
|
||||
const { toasts } = useNotificationStore.getState()
|
||||
|
||||
for (const toast of toasts) {
|
||||
useNotificationStore.getState().dismissToast(toast.id)
|
||||
}
|
||||
|
||||
return null
|
||||
},
|
||||
error(message: string, options?: NotifyOptions) {
|
||||
return showToast('error', message, options)
|
||||
},
|
||||
info(message: string, options?: NotifyOptions) {
|
||||
return showToast('info', message, options)
|
||||
},
|
||||
loading(message: string, options?: NotifyOptions) {
|
||||
return showToast('loading', message, options)
|
||||
},
|
||||
message(message: string, options?: NotifyOptions) {
|
||||
return showToast('info', message, options)
|
||||
},
|
||||
success(message: string, options?: NotifyOptions) {
|
||||
return showToast('success', message, options)
|
||||
},
|
||||
warning(message: string, options?: NotifyOptions) {
|
||||
return showToast('warning', message, options)
|
||||
},
|
||||
}
|
||||
103
src/lib/utils.ts
103
src/lib/utils.ts
@@ -4,3 +4,106 @@ import { twMerge } from 'tailwind-merge'
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs))
|
||||
}
|
||||
|
||||
type FullscreenCapableElement = HTMLElement & {
|
||||
mozRequestFullScreen?: () => Promise<void> | void
|
||||
msRequestFullscreen?: () => Promise<void> | void
|
||||
webkitRequestFullscreen?: () => Promise<void> | void
|
||||
}
|
||||
|
||||
type FullscreenCapableDocument = Document & {
|
||||
mozCancelFullScreen?: () => Promise<void> | void
|
||||
mozFullScreenElement?: Element | null
|
||||
msExitFullscreen?: () => Promise<void> | void
|
||||
msFullscreenElement?: Element | null
|
||||
webkitExitFullscreen?: () => Promise<void> | void
|
||||
webkitFullscreenElement?: Element | null
|
||||
}
|
||||
|
||||
const FULLSCREEN_CHANGE_EVENTS = [
|
||||
'fullscreenchange',
|
||||
'webkitfullscreenchange',
|
||||
'mozfullscreenchange',
|
||||
'MSFullscreenChange',
|
||||
] as const
|
||||
|
||||
export function isDesktopFullscreen() {
|
||||
if (typeof document === 'undefined') {
|
||||
return false
|
||||
}
|
||||
|
||||
const fullscreenDocument = document as FullscreenCapableDocument
|
||||
|
||||
return Boolean(
|
||||
document.fullscreenElement ||
|
||||
fullscreenDocument.webkitFullscreenElement ||
|
||||
fullscreenDocument.mozFullScreenElement ||
|
||||
fullscreenDocument.msFullscreenElement,
|
||||
)
|
||||
}
|
||||
|
||||
export async function exitDesktopFullscreen() {
|
||||
if (typeof document === 'undefined') {
|
||||
return false
|
||||
}
|
||||
|
||||
const fullscreenDocument = document as FullscreenCapableDocument
|
||||
|
||||
await Promise.resolve(
|
||||
document.exitFullscreen?.() ??
|
||||
fullscreenDocument.webkitExitFullscreen?.() ??
|
||||
fullscreenDocument.mozCancelFullScreen?.() ??
|
||||
fullscreenDocument.msExitFullscreen?.(),
|
||||
)
|
||||
|
||||
return !isDesktopFullscreen()
|
||||
}
|
||||
|
||||
export function subscribeDesktopFullscreenChange(listener: () => void) {
|
||||
if (typeof document === 'undefined') {
|
||||
return () => {}
|
||||
}
|
||||
|
||||
for (const eventName of FULLSCREEN_CHANGE_EVENTS) {
|
||||
document.addEventListener(eventName, listener)
|
||||
}
|
||||
|
||||
return () => {
|
||||
for (const eventName of FULLSCREEN_CHANGE_EVENTS) {
|
||||
document.removeEventListener(eventName, listener)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function requestDesktopFullscreen(
|
||||
target: HTMLElement = document.documentElement,
|
||||
) {
|
||||
if (typeof window === 'undefined' || typeof document === 'undefined') {
|
||||
return false
|
||||
}
|
||||
|
||||
if (isDesktopFullscreen()) {
|
||||
return true
|
||||
}
|
||||
|
||||
const fullscreenTarget = target as FullscreenCapableElement
|
||||
|
||||
await Promise.resolve(
|
||||
fullscreenTarget.requestFullscreen?.() ??
|
||||
fullscreenTarget.webkitRequestFullscreen?.() ??
|
||||
fullscreenTarget.mozRequestFullScreen?.() ??
|
||||
fullscreenTarget.msRequestFullscreen?.(),
|
||||
)
|
||||
|
||||
return isDesktopFullscreen()
|
||||
}
|
||||
|
||||
export async function toggleDesktopFullscreen(
|
||||
target: HTMLElement = document.documentElement,
|
||||
) {
|
||||
if (isDesktopFullscreen()) {
|
||||
return exitDesktopFullscreen()
|
||||
}
|
||||
|
||||
return requestDesktopFullscreen(target)
|
||||
}
|
||||
|
||||
297
src/lib/ws/game-socket-client.ts
Normal file
297
src/lib/ws/game-socket-client.ts
Normal file
@@ -0,0 +1,297 @@
|
||||
type GameSocketContext = {
|
||||
authToken: string
|
||||
deviceId: string
|
||||
lang: string
|
||||
token: string
|
||||
}
|
||||
|
||||
type GameSocketConnectedMessage = {
|
||||
connection_id?: string
|
||||
event: 'ws.connected'
|
||||
heartbeat_interval?: number
|
||||
server_time?: number
|
||||
}
|
||||
|
||||
type GameSocketErrorMessage = {
|
||||
code?: number
|
||||
event: 'ws.error'
|
||||
message?: string
|
||||
}
|
||||
|
||||
type GameSocketPongMessage = {
|
||||
action?: 'pong'
|
||||
event?: 'pong'
|
||||
server_time?: number
|
||||
topic?: 'pong'
|
||||
}
|
||||
|
||||
export type GameSocketMessage =
|
||||
| GameSocketConnectedMessage
|
||||
| GameSocketErrorMessage
|
||||
| GameSocketPongMessage
|
||||
| ({ event?: string } & Record<string, unknown>)
|
||||
|
||||
type GameSocketStatus =
|
||||
| 'idle'
|
||||
| 'connecting'
|
||||
| 'connected'
|
||||
| 'reconnecting'
|
||||
| 'disconnected'
|
||||
|
||||
type GameSocketClientOptions = {
|
||||
getContext: () => Promise<GameSocketContext | null>
|
||||
getUrl: () => string | null
|
||||
onError?: (message: GameSocketErrorMessage | Error) => void
|
||||
onLatencyChange?: (latencyMs: number | null) => void
|
||||
onMessage?: (message: GameSocketMessage) => void
|
||||
onStatusChange?: (status: GameSocketStatus, reconnectAttempt: number) => void
|
||||
}
|
||||
|
||||
const MAX_RECONNECT_DELAY_MS = 10_000
|
||||
const LATENCY_PROBE_INTERVAL_MS = 3_000
|
||||
const LATENCY_PROBE_TIMEOUT_MS = 10_000
|
||||
|
||||
function toQueryString(context: GameSocketContext) {
|
||||
const params = new URLSearchParams({
|
||||
token: context.token,
|
||||
auth_token: context.authToken,
|
||||
device_id: context.deviceId,
|
||||
lang: context.lang,
|
||||
})
|
||||
|
||||
return params.toString()
|
||||
}
|
||||
|
||||
export class GameSocketClient {
|
||||
private heartbeatTimerId: number | null = null
|
||||
private latencyProbeTimerId: number | null = null
|
||||
private manualClose = false
|
||||
private readonly options: GameSocketClientOptions
|
||||
private pendingPingSentAt: number | null = null
|
||||
private reconnectAttempt = 0
|
||||
private reconnectTimerId: number | null = null
|
||||
private socket: WebSocket | null = null
|
||||
private readonly subscribedTopics = new Set<string>()
|
||||
|
||||
constructor(options: GameSocketClientOptions) {
|
||||
this.options = options
|
||||
}
|
||||
|
||||
async connect() {
|
||||
if (this.socket && this.socket.readyState === WebSocket.OPEN) {
|
||||
return
|
||||
}
|
||||
|
||||
this.clearReconnectTimer()
|
||||
this.clearHeartbeatTimer()
|
||||
this.clearLatencyProbeTimer()
|
||||
|
||||
const url = this.options.getUrl()
|
||||
const context = await this.options.getContext()
|
||||
|
||||
if (!url || !context) {
|
||||
this.setStatus('disconnected')
|
||||
return
|
||||
}
|
||||
|
||||
this.manualClose = false
|
||||
this.setStatus(this.reconnectAttempt > 0 ? 'reconnecting' : 'connecting')
|
||||
|
||||
const socketUrl = new URL(url)
|
||||
|
||||
socketUrl.search = toQueryString(context)
|
||||
|
||||
const socket = new WebSocket(socketUrl.toString())
|
||||
|
||||
this.socket = socket
|
||||
|
||||
socket.addEventListener('open', () => {
|
||||
this.flushSubscriptions()
|
||||
})
|
||||
|
||||
socket.addEventListener('message', (event) => {
|
||||
this.handleMessage(event.data)
|
||||
})
|
||||
|
||||
socket.addEventListener('error', () => {
|
||||
this.options.onError?.(new Error('WebSocket connection error'))
|
||||
})
|
||||
|
||||
socket.addEventListener('close', () => {
|
||||
this.socket = null
|
||||
this.clearHeartbeatTimer()
|
||||
this.clearLatencyProbeTimer()
|
||||
this.pendingPingSentAt = null
|
||||
this.options.onLatencyChange?.(null)
|
||||
|
||||
if (this.manualClose) {
|
||||
this.setStatus('disconnected')
|
||||
return
|
||||
}
|
||||
|
||||
this.scheduleReconnect()
|
||||
})
|
||||
}
|
||||
|
||||
disconnect() {
|
||||
this.manualClose = true
|
||||
this.clearReconnectTimer()
|
||||
this.clearHeartbeatTimer()
|
||||
this.clearLatencyProbeTimer()
|
||||
this.pendingPingSentAt = null
|
||||
this.options.onLatencyChange?.(null)
|
||||
this.socket?.close()
|
||||
this.socket = null
|
||||
this.setStatus('disconnected')
|
||||
}
|
||||
|
||||
// Topics are de-duplicated locally and re-sent automatically after reconnect.
|
||||
subscribe(topics: string[]) {
|
||||
for (const topic of topics) {
|
||||
this.subscribedTopics.add(topic)
|
||||
}
|
||||
|
||||
this.send({
|
||||
action: 'subscribe',
|
||||
topics: [...this.subscribedTopics],
|
||||
})
|
||||
}
|
||||
|
||||
send(payload: Record<string, unknown>) {
|
||||
if (!this.socket || this.socket.readyState !== WebSocket.OPEN) {
|
||||
return
|
||||
}
|
||||
|
||||
this.socket.send(JSON.stringify(payload))
|
||||
}
|
||||
|
||||
private clearHeartbeatTimer() {
|
||||
if (this.heartbeatTimerId !== null) {
|
||||
window.clearInterval(this.heartbeatTimerId)
|
||||
this.heartbeatTimerId = null
|
||||
}
|
||||
}
|
||||
|
||||
private clearReconnectTimer() {
|
||||
if (this.reconnectTimerId !== null) {
|
||||
window.clearTimeout(this.reconnectTimerId)
|
||||
this.reconnectTimerId = null
|
||||
}
|
||||
}
|
||||
|
||||
private clearLatencyProbeTimer() {
|
||||
if (this.latencyProbeTimerId !== null) {
|
||||
window.clearInterval(this.latencyProbeTimerId)
|
||||
this.latencyProbeTimerId = null
|
||||
}
|
||||
}
|
||||
|
||||
private flushSubscriptions() {
|
||||
if (this.subscribedTopics.size === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
this.send({
|
||||
action: 'subscribe',
|
||||
topics: [...this.subscribedTopics],
|
||||
})
|
||||
}
|
||||
|
||||
private sendPing() {
|
||||
const now = performance.now()
|
||||
|
||||
if (
|
||||
this.pendingPingSentAt !== null &&
|
||||
now - this.pendingPingSentAt < LATENCY_PROBE_TIMEOUT_MS
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
this.pendingPingSentAt = performance.now()
|
||||
this.send({ action: 'ping' })
|
||||
}
|
||||
|
||||
private handlePongMessage() {
|
||||
if (this.pendingPingSentAt === null) {
|
||||
return
|
||||
}
|
||||
|
||||
const latencyMs = Math.max(
|
||||
0,
|
||||
Math.round(performance.now() - this.pendingPingSentAt),
|
||||
)
|
||||
|
||||
this.pendingPingSentAt = null
|
||||
this.options.onLatencyChange?.(latencyMs)
|
||||
}
|
||||
|
||||
private handleConnectedMessage(message: GameSocketConnectedMessage) {
|
||||
this.reconnectAttempt = 0
|
||||
this.setStatus('connected')
|
||||
this.options.onLatencyChange?.(null)
|
||||
this.clearLatencyProbeTimer()
|
||||
|
||||
if (message.heartbeat_interval && message.heartbeat_interval > 0) {
|
||||
this.clearHeartbeatTimer()
|
||||
this.heartbeatTimerId = window.setInterval(() => {
|
||||
this.sendPing()
|
||||
}, message.heartbeat_interval * 1000)
|
||||
}
|
||||
|
||||
this.latencyProbeTimerId = window.setInterval(() => {
|
||||
this.sendPing()
|
||||
}, LATENCY_PROBE_INTERVAL_MS)
|
||||
|
||||
this.flushSubscriptions()
|
||||
this.sendPing()
|
||||
}
|
||||
|
||||
private handleMessage(raw: string) {
|
||||
if (raw.trim() === 'pong') {
|
||||
this.handlePongMessage()
|
||||
return
|
||||
}
|
||||
|
||||
let message: GameSocketMessage
|
||||
|
||||
try {
|
||||
message = JSON.parse(raw) as GameSocketMessage
|
||||
} catch {
|
||||
this.options.onError?.(new Error('WebSocket message parse failed'))
|
||||
return
|
||||
}
|
||||
|
||||
if (message.event === 'ws.connected') {
|
||||
this.handleConnectedMessage(message as GameSocketConnectedMessage)
|
||||
} else if (message.event === 'ws.error') {
|
||||
this.options.onError?.(message as GameSocketErrorMessage)
|
||||
} else if (
|
||||
message.event === 'pong' ||
|
||||
('action' in message && message.action === 'pong') ||
|
||||
('topic' in message && message.topic === 'pong')
|
||||
) {
|
||||
this.handlePongMessage()
|
||||
}
|
||||
|
||||
this.options.onMessage?.(message)
|
||||
}
|
||||
|
||||
private scheduleReconnect() {
|
||||
this.reconnectAttempt += 1
|
||||
this.setStatus('reconnecting')
|
||||
|
||||
const delay = Math.min(
|
||||
1000 * 2 ** Math.max(0, this.reconnectAttempt - 1),
|
||||
MAX_RECONNECT_DELAY_MS,
|
||||
)
|
||||
|
||||
this.clearReconnectTimer()
|
||||
this.reconnectTimerId = window.setTimeout(() => {
|
||||
void this.connect()
|
||||
}, delay)
|
||||
}
|
||||
|
||||
private setStatus(status: GameSocketStatus) {
|
||||
this.options.onStatusChange?.(status, this.reconnectAttempt)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user