You've already forked AstralRinth
forked from didirus/AstralRinth
feat: ws client & new backups frontend (#4813)
* feat: ws client * feat: v1 backups endpoints * feat: migrate backups page to api-client and new DI ctx * feat: switch to ws client via api-client * fix: disgust * fix: stats * fix: console * feat: v0 backups api * feat: migrate backups.vue to page system w/ components to ui pkgs * feat: polish backups frontend * feat: pending refactor for ws handling of backups * fix: vue shit * fix: cancel logic fix * fix: qa issues * fix: alignment issues for backups page * fix: bar positioning * feat: finish QA * fix: icons * fix: lint & i18n * fix: clear comment * lint --------- Co-authored-by: Prospector <6166773+Prospector@users.noreply.github.com>
This commit is contained in:
@@ -2,7 +2,9 @@ import { $fetch, FetchError } from 'ofetch'
|
||||
|
||||
import { AbstractModrinthClient } from '../core/abstract-client'
|
||||
import type { ModrinthApiError } from '../core/errors'
|
||||
import type { ClientConfig } from '../types/client'
|
||||
import type { RequestOptions } from '../types/request'
|
||||
import { GenericWebSocketClient } from './websocket-generic'
|
||||
|
||||
/**
|
||||
* Generic platform client using ofetch
|
||||
@@ -23,6 +25,17 @@ import type { RequestOptions } from '../types/request'
|
||||
* ```
|
||||
*/
|
||||
export class GenericModrinthClient extends AbstractModrinthClient {
|
||||
constructor(config: ClientConfig) {
|
||||
super(config)
|
||||
|
||||
Object.defineProperty(this.archon, 'sockets', {
|
||||
value: new GenericWebSocketClient(this),
|
||||
writable: false,
|
||||
enumerable: true,
|
||||
configurable: false,
|
||||
})
|
||||
}
|
||||
|
||||
protected async executeRequest<T>(url: string, options: RequestOptions): Promise<T> {
|
||||
try {
|
||||
const response = await $fetch<T>(url, {
|
||||
|
||||
@@ -5,6 +5,7 @@ import type { ModrinthApiError } from '../core/errors'
|
||||
import type { CircuitBreakerState, CircuitBreakerStorage } from '../features/circuit-breaker'
|
||||
import type { ClientConfig } from '../types/client'
|
||||
import type { RequestOptions } from '../types/request'
|
||||
import { GenericWebSocketClient } from './websocket-generic'
|
||||
|
||||
/**
|
||||
* Circuit breaker storage using Nuxt's useState
|
||||
@@ -72,6 +73,17 @@ export interface NuxtClientConfig extends ClientConfig {
|
||||
export class NuxtModrinthClient extends AbstractModrinthClient {
|
||||
protected declare config: NuxtClientConfig
|
||||
|
||||
constructor(config: NuxtClientConfig) {
|
||||
super(config)
|
||||
|
||||
Object.defineProperty(this.archon, 'sockets', {
|
||||
value: new GenericWebSocketClient(this),
|
||||
writable: false,
|
||||
enumerable: true,
|
||||
configurable: false,
|
||||
})
|
||||
}
|
||||
|
||||
protected async executeRequest<T>(url: string, options: RequestOptions): Promise<T> {
|
||||
try {
|
||||
// @ts-expect-error - $fetch is provided by Nuxt runtime
|
||||
|
||||
@@ -2,6 +2,7 @@ import { AbstractModrinthClient } from '../core/abstract-client'
|
||||
import type { ModrinthApiError } from '../core/errors'
|
||||
import type { ClientConfig } from '../types/client'
|
||||
import type { RequestOptions } from '../types/request'
|
||||
import { GenericWebSocketClient } from './websocket-generic'
|
||||
|
||||
/**
|
||||
* Tauri-specific configuration
|
||||
@@ -38,6 +39,17 @@ interface HttpError extends Error {
|
||||
export class TauriModrinthClient extends AbstractModrinthClient {
|
||||
protected declare config: TauriClientConfig
|
||||
|
||||
constructor(config: TauriClientConfig) {
|
||||
super(config)
|
||||
|
||||
Object.defineProperty(this.archon, 'sockets', {
|
||||
value: new GenericWebSocketClient(this),
|
||||
writable: false,
|
||||
enumerable: true,
|
||||
configurable: false,
|
||||
})
|
||||
}
|
||||
|
||||
protected async executeRequest<T>(url: string, options: RequestOptions): Promise<T> {
|
||||
try {
|
||||
// Dynamically import Tauri HTTP plugin
|
||||
|
||||
147
packages/api-client/src/platform/websocket-generic.ts
Normal file
147
packages/api-client/src/platform/websocket-generic.ts
Normal file
@@ -0,0 +1,147 @@
|
||||
import mitt from 'mitt'
|
||||
|
||||
import { AbstractWebSocketClient, type WebSocketConnection } from '../core/abstract-websocket'
|
||||
import type { Archon } from '../modules/archon/types'
|
||||
|
||||
type WSEventMap = {
|
||||
[K in Archon.Websocket.v0.WSEvent as `${string}:${K['event']}`]: K
|
||||
}
|
||||
|
||||
export class GenericWebSocketClient extends AbstractWebSocketClient {
|
||||
protected emitter = mitt<WSEventMap>()
|
||||
|
||||
async connect(serverId: string, auth: Archon.Websocket.v0.WSAuth): Promise<void> {
|
||||
if (this.connections.has(serverId)) {
|
||||
this.disconnect(serverId)
|
||||
}
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
try {
|
||||
const ws = new WebSocket(`wss://${auth.url}`)
|
||||
|
||||
const connection: WebSocketConnection = {
|
||||
serverId,
|
||||
socket: ws,
|
||||
reconnectAttempts: 0,
|
||||
reconnectTimer: undefined,
|
||||
isReconnecting: false,
|
||||
}
|
||||
|
||||
this.connections.set(serverId, connection)
|
||||
|
||||
ws.onopen = () => {
|
||||
ws.send(JSON.stringify({ event: 'auth', jwt: auth.token }))
|
||||
|
||||
connection.reconnectAttempts = 0
|
||||
connection.isReconnecting = false
|
||||
|
||||
resolve()
|
||||
}
|
||||
|
||||
ws.onmessage = (messageEvent) => {
|
||||
try {
|
||||
const data = JSON.parse(messageEvent.data) as Archon.Websocket.v0.WSEvent
|
||||
|
||||
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-expiring' || data.event === 'auth-incorrect') {
|
||||
this.handleAuthExpiring(serverId).catch(console.error)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[WebSocket] Failed to parse message:', error)
|
||||
}
|
||||
}
|
||||
|
||||
ws.onclose = (event) => {
|
||||
if (event.code !== 1000) {
|
||||
this.scheduleReconnect(serverId, auth)
|
||||
}
|
||||
}
|
||||
|
||||
ws.onerror = (error) => {
|
||||
console.error(`[WebSocket] Error for server ${serverId}:`, error)
|
||||
reject(new Error(`WebSocket connection failed for server ${serverId}`))
|
||||
}
|
||||
} catch (error) {
|
||||
reject(error)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
disconnect(serverId: string): void {
|
||||
const connection = this.connections.get(serverId)
|
||||
if (!connection) return
|
||||
|
||||
if (connection.reconnectTimer) {
|
||||
clearTimeout(connection.reconnectTimer)
|
||||
connection.reconnectTimer = undefined
|
||||
}
|
||||
|
||||
if (
|
||||
connection.socket.readyState === WebSocket.OPEN ||
|
||||
connection.socket.readyState === WebSocket.CONNECTING
|
||||
) {
|
||||
connection.socket.close(1000, 'Client disconnecting')
|
||||
}
|
||||
|
||||
this.emitter.all.forEach((_handlers, type) => {
|
||||
if (type.toString().startsWith(`${serverId}:`)) {
|
||||
this.emitter.all.delete(type)
|
||||
}
|
||||
})
|
||||
|
||||
this.connections.delete(serverId)
|
||||
}
|
||||
|
||||
disconnectAll(): void {
|
||||
for (const serverId of this.connections.keys()) {
|
||||
this.disconnect(serverId)
|
||||
}
|
||||
}
|
||||
|
||||
send(serverId: string, message: Archon.Websocket.v0.WSOutgoingMessage): void {
|
||||
const connection = this.connections.get(serverId)
|
||||
if (!connection || connection.socket.readyState !== WebSocket.OPEN) {
|
||||
console.warn(`Cannot send message: WebSocket not connected for server ${serverId}`)
|
||||
return
|
||||
}
|
||||
connection.socket.send(JSON.stringify(message))
|
||||
}
|
||||
|
||||
private scheduleReconnect(serverId: string, auth: Archon.Websocket.v0.WSAuth): void {
|
||||
const connection = this.connections.get(serverId)
|
||||
if (!connection) return
|
||||
|
||||
if (connection.reconnectAttempts >= this.MAX_RECONNECT_ATTEMPTS) {
|
||||
this.disconnect(serverId)
|
||||
return
|
||||
}
|
||||
|
||||
connection.isReconnecting = true
|
||||
connection.reconnectAttempts++
|
||||
|
||||
const delay = this.getReconnectDelay(connection.reconnectAttempts)
|
||||
|
||||
connection.reconnectTimer = setTimeout(() => {
|
||||
this.connect(serverId, auth).catch((error) => {
|
||||
console.error(`[WebSocket] Reconnection failed for server ${serverId}:`, error)
|
||||
})
|
||||
}, delay)
|
||||
}
|
||||
|
||||
private async handleAuthExpiring(serverId: string): Promise<void> {
|
||||
try {
|
||||
const newAuth = await this.client.archon.servers_v0.getWebSocketAuth(serverId)
|
||||
|
||||
const connection = this.connections.get(serverId)
|
||||
if (connection && connection.socket.readyState === WebSocket.OPEN) {
|
||||
connection.socket.send(JSON.stringify({ event: 'auth', jwt: newAuth.token }))
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`[WebSocket] Failed to refresh auth for server ${serverId}:`, error)
|
||||
this.disconnect(serverId)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user