From e6be8411376eca8624601b70323ae557cae797ed Mon Sep 17 00:00:00 2001 From: =?utf8?q?J=C3=A9r=C3=B4me=20Benoit?= Date: Thu, 16 Apr 2026 22:39:18 +0200 Subject: [PATCH] refactor(ui-common): generic WebSocket adapter factory with converter injection MIME-Version: 1.0 Content-Type: text/plain; charset=utf8 Content-Transfer-Encoding: 8bit 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 | 85 ++---------------- ui/common/src/client/adapter.ts | 96 +++++++++++++++++++++ ui/common/src/client/browser-adapter.ts | 86 ++----------------- ui/common/src/client/types.ts | 2 + ui/common/src/index.ts | 1 + ui/common/tests/adapter.test.ts | 109 ++++++++++++++++++++++++ 6 files changed, 222 insertions(+), 157 deletions(-) create mode 100644 ui/common/src/client/adapter.ts create mode 100644 ui/common/tests/adapter.test.ts diff --git a/ui/cli/src/client/ws-adapter.ts b/ui/cli/src/client/ws-adapter.ts index 0408153f..2b505057 100644 --- a/ui/cli/src/client/ws-adapter.ts +++ b/ui/cli/src/client/ws-adapter.ts @@ -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[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 index 00000000..c6eb457a --- /dev/null +++ b/ui/common/src/client/adapter.ts @@ -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) + }, + } +} diff --git a/ui/common/src/client/browser-adapter.ts b/ui/common/src/client/browser-adapter.ts index 04258c54..86efbe75 100644 --- a/ui/common/src/client/browser-adapter.ts +++ b/ui/common/src/client/browser-adapter.ts @@ -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', + }) diff --git a/ui/common/src/client/types.ts b/ui/common/src/client/types.ts index d37cd5e4..9dba2c5d 100644 --- a/ui/common/src/client/types.ts +++ b/ui/common/src/client/types.ts @@ -12,6 +12,8 @@ export type AuthenticationConfig = NonNullable +export type DataConverter = (data: unknown) => string + export interface ResponseHandler { reject: (reason?: unknown) => void resolve: (value: ResponsePayload) => void diff --git a/ui/common/src/index.ts b/ui/common/src/index.ts index 17671897..d50d4026 100644 --- a/ui/common/src/index.ts +++ b/ui/common/src/index.ts @@ -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 index 00000000..e0c8cf27 --- /dev/null +++ b/ui/common/tests/adapter.test.ts @@ -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) + }) +}) -- 2.43.0