fix: ws connection duplication + disconnecting during browse

This commit is contained in:
Calum H. (IMB11)
2026-08-04 19:39:37 +01:00
parent 986a40edfc
commit 246362787f
14 changed files with 834 additions and 966 deletions
@@ -9,6 +9,7 @@ export type WebSocketEventHandler<
export interface WebSocketConnection {
serverId: string
socket: WebSocket
authenticated: boolean
reconnectAttempts: number
reconnectTimer?: ReturnType<typeof setTimeout>
isReconnecting: boolean
@@ -31,6 +32,7 @@ export abstract class AbstractWebSocketClient {
protected readonly MAX_RECONNECT_ATTEMPTS = 10
protected readonly RECONNECT_BASE_DELAY = 1000
protected readonly RECONNECT_MAX_DELAY = 30000
protected readonly AUTHENTICATION_TIMEOUT = 30000
constructor(
protected client: {
@@ -58,6 +60,7 @@ export abstract class AbstractWebSocketClient {
}
if (status && !status.connected && !options?.force) {
await this.waitForAuthentication(serverId)
return
}
@@ -69,6 +72,28 @@ export abstract class AbstractWebSocketClient {
await this.connect(serverId, auth)
}
protected async waitForAuthentication(serverId: string): Promise<void> {
await new Promise<void>((resolve, reject) => {
let unsubscribe = () => {}
const timeout = setTimeout(() => {
unsubscribe()
reject(new Error(`WebSocket authentication timed out for server ${serverId}`))
}, this.AUTHENTICATION_TIMEOUT)
unsubscribe = this.on(serverId, 'auth-ok', () => {
clearTimeout(timeout)
unsubscribe()
resolve()
})
if (this.getStatus(serverId)?.connected) {
clearTimeout(timeout)
unsubscribe()
resolve()
}
})
}
on<E extends Archon.Websocket.v0.WSEventType>(
serverId: string,
eventType: E,
@@ -88,7 +113,7 @@ export abstract class AbstractWebSocketClient {
if (!connection) return null
return {
connected: connection.socket.readyState === WebSocket.OPEN,
connected: connection.socket.readyState === WebSocket.OPEN && connection.authenticated,
reconnecting: connection.isReconnecting,
reconnectAttempts: connection.reconnectAttempts,
}
@@ -1152,9 +1152,12 @@ export namespace Archon {
export type InstallProgressFileKey = {
type: 'file'
parent_directory: string
filename: string
install_type: 'install' | 'update'
project_id: string
version_id: string
parent_directory: string
source_filename: string | null
target_filename?: string | null
}
export type InstallProgressModrinthModpackKey = {
@@ -19,12 +19,14 @@ export class GenericWebSocketClient extends AbstractWebSocketClient {
}
return new Promise((resolve, reject) => {
let settled = false
try {
const ws = new WebSocket(getNodeWebSocketUrl(auth.url))
const connection: WebSocketConnection = {
serverId,
socket: ws,
authenticated: false,
reconnectAttempts: 0,
reconnectTimer: undefined,
isReconnecting: false,
@@ -37,18 +39,26 @@ export class GenericWebSocketClient extends AbstractWebSocketClient {
connection.reconnectAttempts = 0
connection.isReconnecting = false
resolve()
}
ws.onmessage = (messageEvent) => {
try {
const data = JSON.parse(messageEvent.data) as Archon.Websocket.v0.WSEvent
if (data.event === 'auth-ok') {
connection.authenticated = true
} else if (data.event === 'auth-incorrect') {
connection.authenticated = false
}
const eventKey = `${serverId}:${data.event}` as keyof WSEventMap
// eslint-disable-next-line @typescript-eslint/no-explicit-any
this.emitter.emit(eventKey, data as any)
if (data.event === 'auth-ok' && !settled) {
settled = true
resolve()
}
if (data.event === 'auth-expiring' || data.event === 'auth-incorrect') {
this.handleAuthExpiring(serverId).catch(console.error)
}
@@ -58,11 +68,20 @@ export class GenericWebSocketClient extends AbstractWebSocketClient {
}
ws.onclose = (event) => {
connection.authenticated = false
console.debug(`[WebSocket] Closed for server ${serverId}:`, {
code: event.code,
reason: event.reason,
wasClean: event.wasClean,
})
if (!settled) {
settled = true
reject(
new Error(
`WebSocket closed before authentication for server ${serverId} (code: ${event.code})`,
),
)
}
if (event.code !== NORMAL_CLOSURE) {
this.scheduleReconnect(serverId, auth)
}
@@ -77,13 +96,17 @@ export class GenericWebSocketClient extends AbstractWebSocketClient {
readyStateLabel: ['CONNECTING', 'OPEN', 'CLOSING', 'CLOSED'][readyState],
type: (event as Event).type,
})
reject(
new Error(
`WebSocket connection failed for server ${serverId} (readyState: ${readyState})`,
),
)
if (!settled) {
settled = true
reject(
new Error(
`WebSocket connection failed for server ${serverId} (readyState: ${readyState})`,
),
)
}
}
} catch (error) {
settled = true
reject(error)
}
})