diff --git a/packages/server-sdk/src/client.ts b/packages/server-sdk/src/client.ts index 5b5e0635f..13e57a6a3 100644 --- a/packages/server-sdk/src/client.ts +++ b/packages/server-sdk/src/client.ts @@ -7,135 +7,114 @@ import { sleep } from '@moeru/std' export interface ClientOptions { url?: string name: string - possibleEvents?: Array<(keyof WebSocketEvents)> + possibleEvents?: Array> token?: string onError?: (error: unknown) => void onClose?: () => void autoConnect?: boolean autoReconnect?: boolean + maxReconnectAttempts?: number } export class Client { private connected = false - private opts: Required, 'token'>> & Pick, 'token'> - private websocket: WebSocket | undefined - private eventListeners: Map, Array<(data: WebSocketBaseEvent) => void | Promise>> = new Map() - - private reconnectAttempts = 0 + private websocket?: WebSocket private shouldClose = false + private readonly opts: Required, 'token'>> & Pick, 'token'> + private readonly eventListeners = new Map< + keyof WebSocketEvents, + Set<(data: WebSocketBaseEvent) => void | Promise> + >() + constructor(options: ClientOptions) { this.opts = { url: 'ws://localhost:6121/ws', possibleEvents: [], - onError: () => { }, - onClose: () => { }, + onError: () => {}, + onClose: () => {}, autoConnect: true, autoReconnect: true, + maxReconnectAttempts: -1, ...options, } + // Authentication listener is registered once only + this.onEvent('module:authenticated', async (event) => { + if (event.data.authenticated) { + this.tryAnnounce() + } + else { + await this.retryWithExponentialBackoff(() => this.tryAuthenticate()) + } + }) + if (this.opts.autoConnect) { + void this.connect() + } + } + + private async retryWithExponentialBackoff(fn: () => void | Promise) { + const { maxReconnectAttempts } = this.opts + let attempts = 0 + + // Loop until attempts exceed maxReconnectAttempts, or unlimited if -1 + while (true) { + if (maxReconnectAttempts !== -1 && attempts >= maxReconnectAttempts) { + console.error(`Maximum retry attempts (${maxReconnectAttempts}) reached`) + return + } + try { - this.connect() + await fn() + return } catch (err) { - console.error(err) + this.opts.onError?.(err) + const delay = Math.min(2 ** attempts * 1000, 30_000) // capped exponential backoff + await sleep(delay) + attempts++ } } } - async retryWithExponentialBackoff(fn: () => void | Promise, attempts = 0, maxAttempts = -1) { - if (maxAttempts !== -1 && attempts >= maxAttempts) { - console.error(`Maximum retry attempts (${maxAttempts}) reached`) + private async tryReconnectWithExponentialBackoff() { + if (this.shouldClose) { return } - - try { - await fn() - } - catch (err) { - console.error('Encountered an error when retrying', err) - await sleep(2 ** attempts * 1000) - await this.retryWithExponentialBackoff(fn, attempts + 1, maxAttempts) - } + await this.retryWithExponentialBackoff(() => this._connect()) } - async tryReconnectWithExponentialBackoff() { - await this.retryWithExponentialBackoff(() => this._connect(), this.reconnectAttempts) - } + private _connect(): Promise { + if (this.shouldClose || this.connected) { + return Promise.resolve() + } - private _connect() { - return new Promise((resolve, reject) => { - if (this.shouldClose) { - resolve() - return - } + return new Promise((resolve, reject) => { + const ws = new WebSocket(this.opts.url) + this.websocket = ws - if (this.connected) { - resolve() - return - } - - this.websocket = new WebSocket(this.opts.url) - - this.onEvent('module:authenticated', async (event) => { - const auth = event.data.authenticated - if (!auth) { - this.retryWithExponentialBackoff(() => this.tryAuthenticate()) - } - else { - this.tryAnnounce() - } - }) - - this.websocket.onerror = (event) => { - this.opts.onError?.(event) - - if ('error' in event && event.error instanceof Error) { - if (event.error.message === 'Received network error or non-101 status code.') { - this.connected = false - - if (!this.opts.autoReconnect) { - this.opts.onError?.(event) - this.opts.onClose?.() - reject(event.error) - return - } - - reject(event.error) - } - } - } - - this.websocket.onclose = () => { - this.opts.onClose?.() + ws.onerror = (event: any) => { this.connected = false + this.opts.onError?.(event) + reject(event?.error ?? new Error('WebSocket error')) + } - if (!this.opts.autoReconnect) { + ws.onclose = () => { + if (this.connected) { + this.connected = false this.opts.onClose?.() } - else { - this.tryReconnectWithExponentialBackoff() + if (this.opts.autoReconnect && !this.shouldClose) { + void this.tryReconnectWithExponentialBackoff() } } - this.websocket.onmessage = (event) => { - this.handleMessage(event) - } - - this.websocket.onopen = () => { - this.reconnectAttempts = 0 - - if (this.opts.token) { - this.tryAuthenticate() - } - else { - this.tryAnnounce() - } + ws.onmessage = this.handleMessageBound + ws.onopen = () => { this.connected = true - + this.opts.token ? this.tryAuthenticate() : this.tryAnnounce() resolve() } }) @@ -157,19 +136,32 @@ export class Client { private tryAuthenticate() { if (this.opts.token) { - this.send({ type: 'module:authenticate', data: { token: this.opts.token || '' } }) + this.send({ + type: 'module:authenticate', + data: { token: this.opts.token }, + }) } } - private async handleMessage(event: any) { - try { - const data = JSON.parse(event.data) as WebSocketEvent - const listeners = this.eventListeners.get(data.type) - if (!listeners) - return + // bound reference avoids new closure allocation on every connect + private readonly handleMessageBound = (event: MessageEvent) => { + void this.handleMessage(event) + } - for (const listener of listeners) - await listener(data) + private async handleMessage(event: MessageEvent) { + try { + const data = JSON.parse(event.data as string) as WebSocketEvent + const listeners = this.eventListeners.get(data.type) + if (!listeners?.size) { + return + } + + // Execute all listeners concurrently + const executions: Promise[] = [] + for (const listener of listeners) { + executions.push(Promise.resolve(listener(data as any))) + } + await Promise.allSettled(executions) } catch (err) { console.error('Failed to parse message:', err) @@ -181,30 +173,49 @@ export class Client { event: E, callback: (data: WebSocketBaseEvent[E]>) => void | Promise, ): void { - if (!this.eventListeners.get(event)) { - this.eventListeners.set(event, []) + let listeners = this.eventListeners.get(event) + if (!listeners) { + listeners = new Set() + this.eventListeners.set(event, listeners) } + listeners.add(callback as any) + } + offEvent>( + event: E, + callback?: (data: WebSocketBaseEvent[E]>) => void, + ): void { const listeners = this.eventListeners.get(event) if (!listeners) { return } - listeners.push(callback as unknown as (data: WebSocketBaseEvent[E]>) => void | Promise) + if (callback) { + listeners.delete(callback as any) + if (!listeners.size) { + this.eventListeners.delete(event) + } + } + else { + this.eventListeners.delete(event) + } } send(data: WebSocketEvent): void { - this.websocket?.send(JSON.stringify(data)) + if (this.websocket && this.connected) { + this.websocket.send(JSON.stringify(data)) + } } sendRaw(data: string | ArrayBufferLike | ArrayBufferView): void { - this.websocket?.send(data) + if (this.websocket && this.connected) { + this.websocket.send(data) + } } close(): void { this.shouldClose = true - - if (this.connected && this.websocket) { + if (this.websocket) { this.websocket.close() this.connected = false }