From: Jérôme Benoit Date: Wed, 15 Apr 2026 23:14:24 +0000 (+0200) Subject: fix(web): prevent ghost events after server switch, fix CloseEvent type, improve... X-Git-Tag: cli@v4.5.0~77 X-Git-Url: https://git.piment-noir.org/?a=commitdiff_plain;h=12c1e9a4cd5cfa456576d672d7d4a681afe1b57a;p=e-mobility-charging-stations-simulator.git fix(web): prevent ghost events after server switch, fix CloseEvent type, improve error extraction --- diff --git a/ui/common/src/client/browser-adapter.ts b/ui/common/src/client/browser-adapter.ts index fad46271..8607b66b 100644 --- a/ui/common/src/client/browser-adapter.ts +++ b/ui/common/src/client/browser-adapter.ts @@ -25,8 +25,9 @@ export const createBrowserWsAdapter = (ws: BrowserWebSocket): WebSocketLike => { ws.onerror = event => { if (onerrorCallback != null) { - const error = new Error('WebSocket error') - const message = 'WebSocket error' + const raw = event as { message?: string } + const message = raw.message ?? 'WebSocket error' + const error = new Error(message) onerrorCallback({ error, message }) } } diff --git a/ui/web/src/composables/UIClient.ts b/ui/web/src/composables/UIClient.ts index 15623b5a..cfc8b8b7 100644 --- a/ui/web/src/composables/UIClient.ts +++ b/ui/web/src/composables/UIClient.ts @@ -19,6 +19,7 @@ import { export class UIClient { private static instance: null | UIClient = null + private abortConnection: () => void private client: WebSocketClient private readonly refreshListeners: Set<() => void> private uiServerConfiguration: UIServerConfigurationSection @@ -28,8 +29,12 @@ export class UIClient { this.uiServerConfiguration = uiServerConfiguration this.refreshListeners = new Set() this.wsEventTarget = new EventTarget() - this.client = this.createClient() - this.client.connect().catch(() => undefined) + const { abort, client } = this.createClientWithAbort() + this.client = client + this.abortConnection = abort + this.client.connect().catch((error: unknown) => { + console.error('WebSocket connect failed', error) + }) } public static getInstance (uiServerConfiguration?: UIServerConfigurationSection): UIClient { @@ -114,10 +119,15 @@ export class UIClient { } public setConfiguration (uiServerConfiguration: UIServerConfigurationSection): void { + this.abortConnection() this.client.disconnect() this.uiServerConfiguration = uiServerConfiguration - this.client = this.createClient() - this.client.connect().catch(() => undefined) + const { abort, client } = this.createClientWithAbort() + this.client = client + this.abortConnection = abort + this.client.connect().catch((error: unknown) => { + console.error('WebSocket connect failed', error) + }) } public async setSupervisionUrl (hashId: string, supervisionUrl: string): Promise { @@ -240,7 +250,8 @@ export class UIClient { this.wsEventTarget.removeEventListener(event, listener as EventListener, options) } - private createClient (): WebSocketClient { + private createClientWithAbort (): { abort: () => void; client: WebSocketClient } { + let aborted = false const config = this.uiServerConfiguration const uiUrl = `${config.secure === true ? ApplicationProtocol.WSS : ApplicationProtocol.WS}://${config.host}:${config.port.toString()}` const uiProtocols = @@ -258,6 +269,9 @@ export class UIClient { const eventTarget = this.wsEventTarget + // Factory builds its own URL/protocols because WebSocketClient.buildProtocols() + // uses Node.js Buffer for base64 encoding, which isn't available in the browser. + // Browser uses btoa() instead. Both produce identical output. const factory: WebSocketFactory = (_url, _protocols) => { const adapter = createBrowserWsAdapter( new WebSocket(uiUrl, uiProtocols) as unknown as Parameters[0] @@ -272,9 +286,12 @@ export class UIClient { }, set onclose (handler) { adapter.onclose = event => { + if (aborted) return handler?.(event) useToast().info('WebSocket to UI server closed') - eventTarget.dispatchEvent(new Event('close')) + eventTarget.dispatchEvent( + new CloseEvent('close', { code: event.code, reason: event.reason }) + ) } }, get onerror () { @@ -282,6 +299,7 @@ export class UIClient { }, set onerror (handler) { adapter.onerror = event => { + if (aborted) return handler?.(event) useToast().error( `Error in WebSocket to UI server '${config.host}:${config.port.toString()}'` @@ -304,6 +322,7 @@ export class UIClient { }, set onopen (handler) { adapter.onopen = () => { + if (aborted) return handler?.() useToast().success( `WebSocket to UI server '${config.host}:${config.port.toString()}' successfully opened` @@ -320,7 +339,7 @@ export class UIClient { } } - return new WebSocketClient( + const client = new WebSocketClient( factory, { authentication: config.authentication, @@ -339,6 +358,13 @@ export class UIClient { } } ) + + return { + abort: () => { + aborted = true + }, + client, + } } private async sendRequest ( diff --git a/ui/web/src/types/ChargingStationType.ts b/ui/web/src/types/ChargingStationType.ts index 066fc2c3..06dbb523 100644 --- a/ui/web/src/types/ChargingStationType.ts +++ b/ui/web/src/types/ChargingStationType.ts @@ -368,6 +368,7 @@ interface CommandsSupport extends JsonObject { outgoingCommands?: Record } +// Local non-recursive JsonObject avoids Vue UnwrapRef infinite instantiation (TS2589) type JsonObject = { [key in string]?: (JsonObject | JsonPrimitive)[] | JsonObject | JsonPrimitive } type JsonPrimitive = boolean | null | number | string diff --git a/ui/web/tests/unit/UIClient.test.ts b/ui/web/tests/unit/UIClient.test.ts index f46f1c43..ea23d1c7 100644 --- a/ui/web/tests/unit/UIClient.test.ts +++ b/ui/web/tests/unit/UIClient.test.ts @@ -133,6 +133,7 @@ describe('UIClient', () => { UIClient.getInstance(createUIServerConfig()) const ws = MockWebSocket.lastInstance! ws.simulateClose() + expect(toastMock.info).toHaveBeenCalledWith(expect.stringContaining('closed')) }) }) @@ -219,6 +220,7 @@ describe('UIClient', () => { const fakeUUID = crypto.randomUUID() ws.simulateMessage([fakeUUID, { status: ResponseStatus.SUCCESS }]) + expect(toastMock.error).not.toHaveBeenCalled() }) it('should silently ignore response with invalid UUID', () => {