Skip to content

Commit 7fca174

Browse files
committed
refactor: webSocketWrapper implementation
Signed-off-by: Pedro Lamas <pedrolamas@gmail.com>
1 parent 210f289 commit 7fca174

10 files changed

Lines changed: 140 additions & 91 deletions

File tree

src/components/common/AddInstanceDialog.vue

Lines changed: 9 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -130,26 +130,19 @@ export default class AddInstanceDialog extends Mixins(StateMixin) {
130130
this.abortController = new AbortController()
131131
const { signal } = this.abortController
132132
133-
try {
134-
await webSocketWrapper(socketUrl, signal)
133+
const result = await webSocketWrapper(socketUrl, {
134+
timeout: 3000,
135+
signal
136+
})
135137
138+
if (result.ok) {
136139
this.verified = true
137-
} catch (e) {
138-
if (signal.aborted) return
139-
140-
if (
141-
e != null &&
142-
typeof e === 'object' &&
143-
'code' in e &&
144-
e.code != null
145-
) {
146-
this.error = e.code.toString()
147-
}
148-
140+
} else if (result.reason !== 'cancelled') {
141+
this.error = result.message
149142
this.note = this.$t('app.endpoint.error.cant_connect').toString()
150-
} finally {
151-
this.verifying = false
152143
}
144+
145+
this.verifying = false
153146
}
154147
}
155148

src/components/widgets/camera/services/WebrtcMediamtxCamera.vue

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@ export default class WebrtcMediamtxCamera extends Mixins(CameraMixin) {
4242
whepUrl: string = ''
4343
sessionUrl: string = ''
4444
pc: RTCPeerConnection | null = null
45-
restartTimeout: number | null = null
45+
restartTimeout: ReturnType<typeof setTimeout> | null = null
4646
offerData: MediamtxOffer | null = null
4747
queuedCandidates: RTCIceCandidate[] = []
4848
@@ -192,7 +192,7 @@ export default class WebrtcMediamtxCamera extends Mixins(CameraMixin) {
192192
this.pc = null
193193
}
194194
195-
this.restartTimeout = window.setTimeout(() => {
195+
this.restartTimeout = setTimeout(() => {
196196
this.restartTimeout = null
197197
this.loadStream()
198198
}, 2000)

src/components/widgets/mmu/MmuUnit.vue

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -205,7 +205,7 @@ export default class MmuUnit extends Mixins(BrowserMixin, StateMixin, MmuMixin)
205205
206206
gateMenuVisible: Record<number, boolean> = {}
207207
208-
closeTimeout: number | null = null
208+
closeTimeout: ReturnType<typeof setTimeout> | null = null
209209
menuX = 0
210210
menuY = 0
211211
@@ -402,7 +402,7 @@ export default class MmuUnit extends Mixins(BrowserMixin, StateMixin, MmuMixin)
402402
this.closeContextMenu()
403403
404404
this.$set(this.gateMenuVisible, gate, true)
405-
this.closeTimeout = window.setTimeout(() => {
405+
this.closeTimeout = setTimeout(() => {
406406
this.closeContextMenu()
407407
}, 6000)
408408
}

src/init.ts

Lines changed: 19 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,6 @@ import type { ApiConfig, HostConfig, InstanceConfig } from './store/config/types
66
import sanitizeEndpoint from './util/sanitize-endpoint'
77
import webSocketWrapper from './util/web-socket-wrapper'
88
import promiseAny from './util/promise-any'
9-
import sleep from './util/sleep'
109
import md5 from 'md5'
1110

1211
// Load API configuration
@@ -93,25 +92,28 @@ const getApiConfig = async (hostConfig: HostConfig, apiUrlHash?: string | null):
9392
try {
9493
const { signal } = abortController
9594

96-
const defaultOnTimeout = async () => {
97-
await sleep(5000, signal)
95+
return await promiseAny(
96+
endpoints
97+
.map(async (endpoint) => {
98+
const apiEndpoints = Vue.$filters.getApiUrls(endpoint)
9899

99-
return {
100-
apiUrl: '',
101-
socketUrl: ''
102-
} satisfies ApiConfig
103-
}
104-
105-
return await promiseAny([
106-
...endpoints.map(async (endpoint) => {
107-
const apiEndpoints = Vue.$filters.getApiUrls(endpoint)
100+
const result = await webSocketWrapper(apiEndpoints.socketUrl, {
101+
timeout: 1200,
102+
signal
103+
})
108104

109-
await webSocketWrapper(apiEndpoints.socketUrl, signal)
105+
if (!result.ok) {
106+
throw new Error(result.message)
107+
}
110108

111-
return apiEndpoints
112-
}),
113-
defaultOnTimeout()
114-
])
109+
return apiEndpoints
110+
})
111+
)
112+
} catch {
113+
return {
114+
apiUrl: '',
115+
socketUrl: ''
116+
} satisfies ApiConfig
115117
} finally {
116118
abortController.abort()
117119
}

src/plugins/sandboxedEval.ts

Lines changed: 1 addition & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,3 @@
1-
import abortControllerWithTimeout from '@/util/abort-controller-with-timeout'
21
import type { SandboxedEvalWorkerResponseMessage, SandboxedEvalWorkerRequestMessage } from '@/workers/sandboxedEval.worker'
32

43
import SandboxedEvalWorker from '@/workers/sandboxedEval.worker?ts?worker'
@@ -9,8 +8,7 @@ const sandboxedEval = async<T>(code: string, feature?: string, timeout = 800): P
98
const id = Date.now()
109
const worker = getWorker(feature)
1110

12-
const abortController = abortControllerWithTimeout(timeout)
13-
const { signal } = abortController
11+
const signal = AbortSignal.timeout(timeout)
1412

1513
const workerPromise = new Promise<unknown>((resolve, reject) => {
1614
const cleanup = () => {
@@ -63,8 +61,6 @@ const sandboxedEval = async<T>(code: string, feature?: string, timeout = 800): P
6361

6462
return result as T
6563
} finally {
66-
abortController.clear()
67-
6864
if (feature && signal.aborted) {
6965
worker.terminate()
7066
delete workers[feature]

src/plugins/socketClient.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ export class WebSocketClient {
1919
private store: TypedStore
2020
private cache: CachedParams | null = null
2121
private retryCount = 0
22-
private reconnectTimeout: number | null = null
22+
private reconnectTimeout: ReturnType<typeof setTimeout> | null = null
2323

2424
constructor (options: SocketPluginOptions) {
2525
this.store = options.store
@@ -190,7 +190,7 @@ export class WebSocketClient {
190190
}
191191

192192
this.store.typedDispatch('socket/onSetStatus', 'connecting')
193-
this.reconnectTimeout = window.setTimeout(() => {
193+
this.reconnectTimeout = setTimeout(() => {
194194
this.reconnectTimeout = null
195195
this.openSocket()
196196
}, EXPONENTIAL_BACKOFF ** this.retryCount * 1000)

src/store/server/actions.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ import i18n from '@/plugins/i18n'
1010
import { gte, valid } from 'semver'
1111
import type { ObjectWithRequest } from '@/plugins/socketClient'
1212

13-
let retryTimeout: number
13+
let retryTimeout: ReturnType<typeof setTimeout>
1414

1515
export const actions = {
1616
/**
@@ -94,7 +94,7 @@ export const actions = {
9494
dispatch('checkMoonrakerMinVersion')
9595

9696
if (payload.klippy_state !== 'ready') {
97-
retryTimeout = window.setTimeout(() => {
97+
retryTimeout = setTimeout(() => {
9898
SocketActions.serverInfo()
9999
}, Globals.KLIPPY_RETRY_DELAY)
100100
}

src/store/socket/actions.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ const MODULES_TO_RESET_ON_DROP = [
1818
'gcodePreview'
1919
] as const
2020

21-
let retryTimeout: number
21+
let retryTimeout: ReturnType<typeof setTimeout>
2222

2323
// State machine edges. Self-transitions (same → same) are accepted as no-ops.
2424
const VALID_TRANSITIONS: Record<SocketStatus, readonly SocketStatus[]> = {
@@ -276,7 +276,7 @@ export const actions = {
276276
// Klippy non-responsive or config error. Retry serverInfo after a delay.
277277
commit('printer/setPrinterInfo', { state: 'error', state_message: payload.message }, { root: true })
278278
clearTimeout(retryTimeout)
279-
retryTimeout = window.setTimeout(() => {
279+
retryTimeout = setTimeout(() => {
280280
SocketActions.serverInfo()
281281
}, Globals.KLIPPY_RETRY_DELAY)
282282
}

src/util/abort-controller-with-timeout.ts

Lines changed: 0 additions & 21 deletions
This file was deleted.

src/util/web-socket-wrapper.ts

Lines changed: 101 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,48 +1,127 @@
1-
import { consola } from 'consola'
1+
import consola from 'consola'
22

3-
const webSocketWrapper = (url: string, signal?: AbortSignal) => {
3+
export type WebSocketCheckResult =
4+
| { ok: true }
5+
| { ok: false; reason: 'timeout' | 'cancelled' | 'error'; message: string }
6+
7+
export interface CheckWebSocketOptions {
8+
timeout?: number;
9+
signal?: AbortSignal;
10+
protocols?: string | string[];
11+
}
12+
13+
const webSocketWrapper = (url: string, options: CheckWebSocketOptions = {}): Promise<WebSocketCheckResult> => {
414
const debug = (message: string, ...args: unknown[]) => consola.debug(`[webSocketWrapper] ${url} ${message}`, ...args)
515

6-
return new Promise((resolve, reject) => {
7-
debug('opening...')
16+
const { timeout, signal, protocols } = options
17+
18+
const combinedSignal = AbortSignal.any([
19+
...(timeout !== undefined ? [AbortSignal.timeout(timeout)] : []),
20+
...(signal !== undefined ? [signal] : []),
21+
])
22+
23+
const getAbortResult = (): WebSocketCheckResult => {
24+
if (
25+
timeout !== undefined &&
26+
combinedSignal.reason instanceof DOMException &&
27+
combinedSignal.reason.name === 'TimeoutError'
28+
) {
29+
debug('timed out')
30+
31+
return {
32+
ok: false,
33+
reason: 'timeout',
34+
message: combinedSignal.reason.message
35+
}
36+
}
837

9-
const dispose = () => {
10-
signal?.removeEventListener('abort', abortHandler)
38+
debug('aborted')
1139

12-
connection.close()
40+
return {
41+
ok: false,
42+
reason: 'cancelled',
43+
message: combinedSignal.reason instanceof Error
44+
? combinedSignal.reason.message
45+
: 'Check was cancelled via AbortSignal.',
1346
}
47+
}
48+
49+
if (combinedSignal?.aborted) {
50+
return Promise.resolve(getAbortResult())
51+
}
1452

15-
const abortHandler = () => {
16-
debug('aborted')
53+
return new Promise<WebSocketCheckResult>((resolve) => {
54+
let settled = false
1755

18-
dispose()
56+
const settle = (result: WebSocketCheckResult) => {
57+
if (settled) return
1958

20-
reject(new Error('AbortError'))
59+
settled = true
60+
61+
combinedSignal?.removeEventListener('abort', onAbort)
62+
63+
if (
64+
ws.readyState === WebSocket.CONNECTING ||
65+
ws.readyState === WebSocket.OPEN
66+
) {
67+
// Use code 1000 (Normal Closure) so servers don't log warnings.
68+
try {
69+
ws.close(1000, 'probe complete')
70+
} catch {
71+
/* ignore */
72+
}
73+
}
74+
75+
resolve(result)
2176
}
2277

23-
signal?.addEventListener('abort', abortHandler)
78+
let ws: WebSocket
2479

25-
const connection = new WebSocket(url)
80+
try {
81+
debug('opening...')
2682

27-
connection.onopen = (event) => {
28-
debug('opened', event)
83+
ws = new WebSocket(url, protocols)
84+
} catch (err) {
85+
// `new WebSocket()` throws synchronously for an invalid URL scheme.
86+
resolve({
87+
ok: false,
88+
reason: 'error',
89+
message: err instanceof Error ? err.message : String(err),
90+
})
2991

30-
dispose()
92+
return
93+
}
94+
95+
ws.onopen = (event) => {
96+
debug('opened', event)
3197

32-
resolve(null)
98+
settle({ ok: true })
3399
}
34100

35-
connection.onerror = (event) => {
101+
ws.onerror = (event) => {
36102
debug('error', event)
37103

38-
dispose()
39-
40-
reject(event)
104+
// The browser hides error details for security reasons; the close event
105+
// that always follows carries a (limited) code and reason.
41106
}
42107

43-
connection.onclose = (event) => {
108+
ws.onclose = (event) => {
44109
debug('closed', event)
110+
111+
settle({
112+
ok: false,
113+
reason: 'error',
114+
message: event.reason
115+
? `Connection closed before opening: ${event.reason} (code ${event.code})`
116+
: `Connection closed before opening (code ${event.code}).`,
117+
})
118+
}
119+
120+
const onAbort = (): void => {
121+
settle(getAbortResult())
45122
}
123+
124+
combinedSignal?.addEventListener('abort', onAbort, { once: true })
46125
})
47126
}
48127

0 commit comments

Comments
 (0)