Skip to content

Commit 96d7557

Browse files
committed
fix(cli): replace unsafe WebSocket double cast with typed adapter
1 parent fbc4346 commit 96d7557

3 files changed

Lines changed: 462 additions & 2 deletions

File tree

ui/cli/src/client/lifecycle.ts

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,9 @@ export const executeCommand = async (options: ExecuteOptions): Promise<void> =>
5050
activeSpinner = spinner ?? undefined
5151
activeClient = client
5252

53+
const budget = timeoutMs ?? UI_WEBSOCKET_REQUEST_TIMEOUT_MS
54+
const startTime = Date.now()
55+
5356
let connectTimeoutId: ReturnType<typeof setTimeout> | undefined
5457
try {
5558
const connectPromise = client.connect()
@@ -59,7 +62,7 @@ export const executeCommand = async (options: ExecuteOptions): Promise<void> =>
5962
new Promise<never>((_resolve, reject) => {
6063
connectTimeoutId = setTimeout(() => {
6164
reject(new Error(`Connection to ${url} timed out`))
62-
}, timeoutMs ?? UI_WEBSOCKET_REQUEST_TIMEOUT_MS)
65+
}, budget)
6366
}),
6467
])
6568
} catch (error: unknown) {
@@ -70,11 +73,18 @@ export const executeCommand = async (options: ExecuteOptions): Promise<void> =>
7073
clearTimeout(connectTimeoutId)
7174
}
7275

76+
const remaining = budget - (Date.now() - startTime)
77+
if (remaining <= 0) {
78+
spinner?.fail()
79+
client.disconnect()
80+
throw new ConnectionError(url, new Error('Connection consumed entire timeout budget'))
81+
}
82+
7383
try {
7484
if (spinner != null) {
7585
spinner.text = `Sending ${procedureName}...`
7686
}
77-
const response: ResponsePayload = await client.sendRequest(procedureName, payload)
87+
const response: ResponsePayload = await client.sendRequest(procedureName, payload, remaining)
7888
spinner?.stop()
7989
formatter.output(response)
8090
} catch (error: unknown) {

ui/cli/src/client/ws-adapter.ts

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
import { Buffer } from 'node:buffer'
2+
3+
import type { WebSocketLike } from 'ui-common'
4+
import { WebSocketReadyState } from 'ui-common'
5+
import { WebSocket as WsWebSocket } from 'ws'
6+
7+
const toDataString = (data: WsWebSocket.Data): string => {
8+
if (Buffer.isBuffer(data)) {
9+
return data.toString('utf-8')
10+
}
11+
if (data instanceof ArrayBuffer) {
12+
return Buffer.from(data).toString('utf-8')
13+
}
14+
if (Array.isArray(data)) {
15+
return Buffer.concat(data as Buffer[]).toString('utf-8')
16+
}
17+
return data
18+
}
19+
20+
export const createWsAdapter = (ws: WsWebSocket): WebSocketLike => {
21+
let onmessageCallback: ((event: { data: string }) => void) | null = null
22+
let onerrorCallback: ((event: { error: unknown; message: string }) => void) | null = null
23+
let oncloseCallback: ((event: { code: number; reason: string }) => void) | null = null
24+
let onopenCallback: (() => void) | null = null
25+
26+
ws.onmessage = (event) => {
27+
if (onmessageCallback != null) {
28+
const data = toDataString(event.data)
29+
onmessageCallback({ data })
30+
}
31+
}
32+
33+
ws.onerror = (event) => {
34+
if (onerrorCallback != null) {
35+
let error: Error
36+
let message: string
37+
if (event instanceof Error) {
38+
error = event
39+
message = event.message
40+
} else {
41+
message = typeof event === 'string' ? event : 'Unknown error'
42+
error = new Error(message)
43+
}
44+
onerrorCallback({ error, message })
45+
}
46+
}
47+
48+
ws.onclose = (event) => {
49+
if (oncloseCallback != null) {
50+
oncloseCallback({ code: event.code, reason: event.reason })
51+
}
52+
}
53+
54+
ws.onopen = () => {
55+
if (onopenCallback != null) {
56+
onopenCallback()
57+
}
58+
}
59+
60+
return {
61+
get onclose (): ((event: { code: number; reason: string }) => void) | null {
62+
return oncloseCallback
63+
},
64+
set onclose (callback: ((event: { code: number; reason: string }) => void) | null) {
65+
oncloseCallback = callback
66+
},
67+
68+
get onerror (): ((event: { error: unknown; message: string }) => void) | null {
69+
return onerrorCallback
70+
},
71+
set onerror (callback: ((event: { error: unknown; message: string }) => void) | null) {
72+
onerrorCallback = callback
73+
},
74+
75+
get onmessage (): ((event: { data: string }) => void) | null {
76+
return onmessageCallback
77+
},
78+
set onmessage (callback: ((event: { data: string }) => void) | null) {
79+
onmessageCallback = callback
80+
},
81+
82+
get onopen (): (() => void) | null {
83+
return onopenCallback
84+
},
85+
set onopen (callback: (() => void) | null) {
86+
onopenCallback = callback
87+
},
88+
89+
close (code?: number, reason?: string): void {
90+
ws.close(code, reason)
91+
},
92+
93+
get readyState (): WebSocketReadyState {
94+
return ws.readyState as WebSocketReadyState
95+
},
96+
97+
send (data: string): void {
98+
ws.send(data)
99+
},
100+
}
101+
}

0 commit comments

Comments
 (0)