]> Piment Noir Git Repositories - e-mobility-charging-stations-simulator.git/commitdiff
fix(web): prevent ghost events after server switch, fix CloseEvent type, improve...
authorJérôme Benoit <jerome.benoit@sap.com>
Wed, 15 Apr 2026 23:14:24 +0000 (01:14 +0200)
committerJérôme Benoit <jerome.benoit@sap.com>
Wed, 15 Apr 2026 23:15:02 +0000 (01:15 +0200)
ui/common/src/client/browser-adapter.ts
ui/web/src/composables/UIClient.ts
ui/web/src/types/ChargingStationType.ts
ui/web/tests/unit/UIClient.test.ts

index fad46271dc7cdc6955f061eec5023cb5e951adb1..8607b66bc3936feea0cd339e9d997fdecf94e070 100644 (file)
@@ -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 })
     }
   }
index 15623b5af69c338280bbe7e8b8085f1f80874ec4..cfc8b8b732ac026cbc977082eb029e10ea21ba98 100644 (file)
@@ -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<ResponsePayload> {
@@ -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<typeof createBrowserWsAdapter>[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 (
index 066fc2c3f3bc4c9dff546177ca28248d262e0dae..06dbb52367aeed82e71a9aeda795e2bb67cbdf54 100644 (file)
@@ -368,6 +368,7 @@ interface CommandsSupport extends JsonObject {
   outgoingCommands?: Record<RequestCommand, boolean>
 }
 
+// Local non-recursive JsonObject avoids Vue UnwrapRef<T> infinite instantiation (TS2589)
 type JsonObject = { [key in string]?: (JsonObject | JsonPrimitive)[] | JsonObject | JsonPrimitive }
 
 type JsonPrimitive = boolean | null | number | string
index f46f1c4361ebcc484da3ce22ec661029d70ede3f..ea23d1c78c02fb08fd734566369f0d6da14a121c 100644 (file)
@@ -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', () => {