]> Piment Noir Git Repositories - e-mobility-charging-stations-simulator.git/commitdiff
refactor(ui-common): generic WebSocket adapter factory with converter injection
authorJérôme Benoit <jerome.benoit@sap.com>
Thu, 16 Apr 2026 20:39:18 +0000 (22:39 +0200)
committerJérôme Benoit <jerome.benoit@sap.com>
Thu, 16 Apr 2026 20:39:18 +0000 (22:39 +0200)
Create ui/common/src/client/adapter.ts — a generic createWsAdapter(ws, options)
factory that implements all WebSocketLike callback boilerplate once. Both adapters
become thin wrappers specifying only their platform-specific data converter:

- createBrowserWsAdapter: data => data as string, errorDefault: 'WebSocket error'
- createWsAdapter (CLI): toDataString() for Buffer/ArrayBuffer, errorDefault: 'Unknown error'

Net reduction: ~143 lines of duplicated boilerplate eliminated.
Add DataConverter type and tests for the generic factory.

ui/cli/src/client/ws-adapter.ts
ui/common/src/client/adapter.ts [new file with mode: 0644]
ui/common/src/client/browser-adapter.ts
ui/common/src/client/types.ts
ui/common/src/index.ts
ui/common/tests/adapter.test.ts [new file with mode: 0644]

index 0408153f55b1d65048a76e6512ec08a287c6ec28..2b505057c2983e8426b5dd7101a4442c2b7a0a28 100644 (file)
@@ -1,7 +1,8 @@
-import type { WebSocketLike, WebSocketReadyState } from 'ui-common'
+import type { WebSocketLike } from 'ui-common'
 import type { WebSocket as WsWebSocket } from 'ws'
 
 import { Buffer } from 'node:buffer'
+import { createWsAdapter as createWsAdapterBase } from 'ui-common'
 
 const toDataString = (data: WsWebSocket.Data): string => {
   if (Buffer.isBuffer(data)) {
@@ -16,80 +17,8 @@ const toDataString = (data: WsWebSocket.Data): string => {
   return data
 }
 
-export const createWsAdapter = (ws: WsWebSocket): WebSocketLike => {
-  let onmessageCallback: WebSocketLike['onmessage'] = null
-  let onerrorCallback: WebSocketLike['onerror'] = null
-  let oncloseCallback: WebSocketLike['onclose'] = null
-  let onopenCallback: WebSocketLike['onopen'] = null
-
-  ws.onmessage = event => {
-    if (onmessageCallback != null) {
-      const data = toDataString(event.data)
-      onmessageCallback({ data })
-    }
-  }
-
-  ws.onerror = event => {
-    if (onerrorCallback != null) {
-      const raw = event as { error?: unknown; message?: string }
-      const error =
-        raw.error instanceof Error ? raw.error : new Error(raw.message ?? 'Unknown error')
-      const message = raw.message ?? error.message
-      onerrorCallback({ error, message })
-    }
-  }
-
-  ws.onclose = event => {
-    if (oncloseCallback != null) {
-      oncloseCallback({ code: event.code, reason: event.reason })
-    }
-  }
-
-  ws.onopen = () => {
-    if (onopenCallback != null) {
-      onopenCallback()
-    }
-  }
-
-  return {
-    close (code?: number, reason?: string): void {
-      ws.close(code, reason)
-    },
-    get onclose () {
-      return oncloseCallback
-    },
-
-    set onclose (callback) {
-      oncloseCallback = callback
-    },
-    get onerror () {
-      return onerrorCallback
-    },
-
-    set onerror (callback) {
-      onerrorCallback = callback
-    },
-    get onmessage () {
-      return onmessageCallback
-    },
-
-    set onmessage (callback) {
-      onmessageCallback = callback
-    },
-    get onopen () {
-      return onopenCallback
-    },
-
-    set onopen (callback) {
-      onopenCallback = callback
-    },
-
-    get readyState (): WebSocketReadyState {
-      return ws.readyState as WebSocketReadyState
-    },
-
-    send (data: string): void {
-      ws.send(data)
-    },
-  }
-}
+export const createWsAdapter = (ws: WsWebSocket): WebSocketLike =>
+  createWsAdapterBase(ws as unknown as Parameters<typeof createWsAdapterBase>[0], {
+    dataConverter: data => toDataString(data as WsWebSocket.Data),
+    errorDefault: 'Unknown error',
+  })
diff --git a/ui/common/src/client/adapter.ts b/ui/common/src/client/adapter.ts
new file mode 100644 (file)
index 0000000..c6eb457
--- /dev/null
@@ -0,0 +1,96 @@
+import type { DataConverter, WebSocketReadyState } from './types.js'
+
+import { type WebSocketLike } from './types.js'
+
+interface RawWebSocket {
+  close(code?: number, reason?: string): void
+  set onclose(handler: ((event: { code: number; reason: string }) => void) | null)
+  set onerror(handler: ((event: unknown) => void) | null)
+  set onmessage(handler: ((event: { data: unknown }) => void) | null)
+  set onopen(handler: (() => void) | null)
+  readonly readyState: number
+  send(data: string): void
+}
+
+interface WsAdapterOptions {
+  dataConverter: DataConverter
+  errorDefault?: string
+}
+
+export const createWsAdapter = (ws: RawWebSocket, options: WsAdapterOptions): WebSocketLike => {
+  const { dataConverter, errorDefault = 'WebSocket error' } = options
+  let onmessageCallback: WebSocketLike['onmessage'] = null
+  let onerrorCallback: WebSocketLike['onerror'] = null
+  let oncloseCallback: WebSocketLike['onclose'] = null
+  let onopenCallback: WebSocketLike['onopen'] = null
+
+  ws.onmessage = event => {
+    if (onmessageCallback != null) {
+      const data = dataConverter(event.data)
+      onmessageCallback({ data })
+    }
+  }
+
+  ws.onerror = event => {
+    if (onerrorCallback != null) {
+      const raw = event as { error?: unknown; message?: string }
+      const error = raw.error instanceof Error ? raw.error : new Error(raw.message ?? errorDefault)
+      const message = raw.message ?? error.message
+      onerrorCallback({ error, message })
+    }
+  }
+
+  ws.onclose = event => {
+    if (oncloseCallback != null) {
+      oncloseCallback({ code: event.code, reason: event.reason })
+    }
+  }
+
+  ws.onopen = () => {
+    if (onopenCallback != null) {
+      onopenCallback()
+    }
+  }
+
+  return {
+    close (code?: number, reason?: string): void {
+      ws.close(code, reason)
+    },
+    get onclose () {
+      return oncloseCallback
+    },
+
+    set onclose (callback) {
+      oncloseCallback = callback
+    },
+    get onerror () {
+      return onerrorCallback
+    },
+
+    set onerror (callback) {
+      onerrorCallback = callback
+    },
+    get onmessage () {
+      return onmessageCallback
+    },
+
+    set onmessage (callback) {
+      onmessageCallback = callback
+    },
+    get onopen () {
+      return onopenCallback
+    },
+
+    set onopen (callback) {
+      onopenCallback = callback
+    },
+
+    get readyState (): WebSocketReadyState {
+      return ws.readyState as WebSocketReadyState
+    },
+
+    send (data: string): void {
+      ws.send(data)
+    },
+  }
+}
index 04258c54dc9d7964bef77ee8def8984b3cf05130..86efbe756911c97076637dc63d6c1f2f79256452 100644 (file)
@@ -1,6 +1,6 @@
-import type { WebSocketReadyState } from './types.js'
+import type { WebSocketLike } from './types.js'
 
-import { type WebSocketLike } from './types.js'
+import { createWsAdapter } from './adapter.js'
 
 interface BrowserWebSocket {
   close(code?: number, reason?: string): void
@@ -12,80 +12,8 @@ interface BrowserWebSocket {
   send(data: string): void
 }
 
-export const createBrowserWsAdapter = (ws: BrowserWebSocket): WebSocketLike => {
-  let onmessageCallback: WebSocketLike['onmessage'] = null
-  let onerrorCallback: WebSocketLike['onerror'] = null
-  let oncloseCallback: WebSocketLike['onclose'] = null
-  let onopenCallback: WebSocketLike['onopen'] = null
-
-  ws.onmessage = event => {
-    if (onmessageCallback != null) {
-      const data = event.data as string
-      onmessageCallback({ data })
-    }
-  }
-
-  ws.onerror = event => {
-    if (onerrorCallback != null) {
-      const raw = event as { error?: unknown; message?: string }
-      const error =
-        raw.error instanceof Error ? raw.error : new Error(raw.message ?? 'WebSocket error')
-      const message = raw.message ?? error.message
-      onerrorCallback({ error, message })
-    }
-  }
-
-  ws.onclose = event => {
-    if (oncloseCallback != null) {
-      oncloseCallback({ code: event.code, reason: event.reason })
-    }
-  }
-
-  ws.onopen = () => {
-    if (onopenCallback != null) {
-      onopenCallback()
-    }
-  }
-
-  return {
-    close (code?: number, reason?: string): void {
-      ws.close(code, reason)
-    },
-    get onclose () {
-      return oncloseCallback
-    },
-
-    set onclose (callback) {
-      oncloseCallback = callback
-    },
-    get onerror () {
-      return onerrorCallback
-    },
-
-    set onerror (callback) {
-      onerrorCallback = callback
-    },
-    get onmessage () {
-      return onmessageCallback
-    },
-
-    set onmessage (callback) {
-      onmessageCallback = callback
-    },
-    get onopen () {
-      return onopenCallback
-    },
-
-    set onopen (callback) {
-      onopenCallback = callback
-    },
-
-    get readyState (): WebSocketReadyState {
-      return ws.readyState as WebSocketReadyState
-    },
-
-    send (data: string): void {
-      ws.send(data)
-    },
-  }
-}
+export const createBrowserWsAdapter = (ws: BrowserWebSocket): WebSocketLike =>
+  createWsAdapter(ws, {
+    dataConverter: data => data as string,
+    errorDefault: 'WebSocket error',
+  })
index d37cd5e46b6372d2130e8d13823641bc07b5c4d3..9dba2c5d858f49bc4adb47246169aac3323c3eeb 100644 (file)
@@ -12,6 +12,8 @@ export type AuthenticationConfig = NonNullable<UIServerConfigurationSection['aut
 
 export type ClientConfig = Omit<UIServerConfigurationSection, 'name'>
 
+export type DataConverter = (data: unknown) => string
+
 export interface ResponseHandler {
   reject: (reason?: unknown) => void
   resolve: (value: ResponsePayload) => void
index 176718977089ae6e7728ca3bb2cdf352ee410234..d50d402625b1932c020169720bfb8403fa07a785 100644 (file)
@@ -1,3 +1,4 @@
+export * from './client/adapter.js'
 export * from './client/browser-adapter.js'
 export * from './client/types.js'
 export * from './client/WebSocketClient.js'
diff --git a/ui/common/tests/adapter.test.ts b/ui/common/tests/adapter.test.ts
new file mode 100644 (file)
index 0000000..e0c8cf2
--- /dev/null
@@ -0,0 +1,109 @@
+/** @file Unit tests for the generic WebSocket adapter factory */
+
+import assert from 'node:assert'
+import { describe, it } from 'node:test'
+
+import { createWsAdapter } from '../src/client/adapter.js'
+import { WebSocketReadyState } from '../src/client/types.js'
+
+interface MockRawWs {
+  close: (code?: number, reason?: string) => void
+  onclose: ((event: { code: number; reason: string }) => void) | null
+  onerror: ((event: unknown) => void) | null
+  onmessage: ((event: { data: unknown }) => void) | null
+  onopen: (() => void) | null
+  readyState: number
+  send: (data: string) => void
+}
+
+const createMockRawWs = (): MockRawWs => ({
+  close: () => undefined,
+  onclose: null,
+  onerror: null,
+  onmessage: null,
+  onopen: null,
+  readyState: WebSocketReadyState.OPEN,
+  send: () => undefined,
+})
+
+await describe('generic WebSocket adapter factory', async () => {
+  await it('should apply dataConverter to message data', () => {
+    const mockWs = createMockRawWs()
+    const adapter = createWsAdapter(mockWs as never, {
+      dataConverter: data => `converted:${String(data)}`,
+    })
+    let receivedData: string | undefined
+    adapter.onmessage = event => {
+      receivedData = event.data
+    }
+    mockWs.onmessage?.({ data: 'raw' })
+    assert.strictEqual(receivedData, 'converted:raw')
+  })
+
+  await it('should use custom errorDefault in onerror fallback', () => {
+    const mockWs = createMockRawWs()
+    const adapter = createWsAdapter(mockWs as never, {
+      dataConverter: data => data as string,
+      errorDefault: 'Custom fallback',
+    })
+    let receivedMessage: string | undefined
+    adapter.onerror = event => {
+      receivedMessage = event.message
+    }
+    mockWs.onerror?.({})
+    assert.strictEqual(receivedMessage, 'Custom fallback')
+  })
+
+  await it('should default errorDefault to WebSocket error', () => {
+    const mockWs = createMockRawWs()
+    const adapter = createWsAdapter(mockWs as never, {
+      dataConverter: data => data as string,
+    })
+    let receivedMessage: string | undefined
+    adapter.onerror = event => {
+      receivedMessage = event.message
+    }
+    mockWs.onerror?.({})
+    assert.strictEqual(receivedMessage, 'WebSocket error')
+  })
+
+  await it('should delegate close, send, and readyState', () => {
+    const mockWs = createMockRawWs()
+    let closedCode: number | undefined
+    let sentData: string | undefined
+    mockWs.close = (code?: number) => {
+      closedCode = code
+    }
+    mockWs.send = (data: string) => {
+      sentData = data
+    }
+    mockWs.readyState = WebSocketReadyState.CONNECTING
+    const adapter = createWsAdapter(mockWs as never, {
+      dataConverter: data => data as string,
+    })
+    adapter.close(1000)
+    adapter.send('test')
+    assert.strictEqual(closedCode, 1000)
+    assert.strictEqual(sentData, 'test')
+    assert.strictEqual(adapter.readyState, WebSocketReadyState.CONNECTING)
+  })
+
+  await it('should forward onclose and onopen events', () => {
+    const mockWs = createMockRawWs()
+    const adapter = createWsAdapter(mockWs as never, {
+      dataConverter: data => data as string,
+    })
+    let closeCode: number | undefined
+    let opened = false
+    adapter.onclose = event => {
+      closeCode = event.code
+    }
+    adapter.onopen = () => {
+      opened = true
+    }
+    mockWs.onclose?.({ code: 1000, reason: 'normal' })
+    mockWs.onopen?.()
+    assert.strictEqual(closeCode, 1000)
+    assert.strictEqual(opened, true)
+  })
+})