diff --git a/package.json b/package.json index b089fe9274..cbb5695473 100644 --- a/package.json +++ b/package.json @@ -57,10 +57,10 @@ "dependencies": { "@livekit/mutex": "1.1.1", "@livekit/protocol": "1.42.2", + "@praha/byethrow": "^0.8.1", "events": "^3.3.0", "jose": "^6.1.0", "loglevel": "^1.9.2", - "neverthrow": "^8.2.0", "sdp-transform": "^2.15.0", "ts-debounce": "^4.0.0", "tslib": "2.8.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 228bec32fd..9aa5d083d5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -14,6 +14,9 @@ importers: '@livekit/protocol': specifier: 1.42.2 version: 1.42.2 + '@praha/byethrow': + specifier: ^0.8.1 + version: 0.8.1(typescript@5.8.3) '@types/dom-mediacapture-record': specifier: ^1 version: 1.0.22 @@ -26,9 +29,6 @@ importers: loglevel: specifier: ^1.9.2 version: 1.9.2 - neverthrow: - specifier: ^8.2.0 - version: 8.2.0 sdp-transform: specifier: ^2.15.0 version: 2.15.0 @@ -1133,6 +1133,11 @@ packages: resolution: {integrity: sha512-QNqXyfVS2wm9hweSYD2O7F0G06uurj9kZ96TRQE5Y9hU7+tgdZwIkbAKc5Ocy1HxEY2kuDQa6cQ1WRs/O5LFKA==} engines: {node: ^12.20.0 || ^14.18.0 || >=16.0.0} + '@praha/byethrow@0.8.1': + resolution: {integrity: sha512-THvagF+Fq43PV6NMkPjNt9aN1fP8LZdKBqNNHvdFmIv2oi2gy3ryU6HYTQqOT5LHsbD7Ih2qySvsW5gGPgGoCA==} + peerDependencies: + typescript: '>=5.0.0' + '@rollup/plugin-babel@6.1.0': resolution: {integrity: sha512-dFZNuFD2YRcoomP4oYf+DvQNSUA9ih+A3vUqopQx5EdtPGo3WBnQcI/S8pwpz91UsGfL0HsMSOlaMld8HrbubA==} engines: {node: '>=14.0.0'} @@ -1335,6 +1340,9 @@ packages: peerDependencies: size-limit: 11.2.0 + '@standard-schema/spec@1.0.0': + resolution: {integrity: sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA==} + '@stylistic/eslint-plugin@3.1.0': resolution: {integrity: sha512-pA6VOrOqk0+S8toJYhQGv2MWpQQR0QpeUo9AhNkC49Y26nxBQ/nH1rta9bUU1rPw2fJ1zZEMV5oCX5AazT7J2g==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -2995,10 +3003,6 @@ packages: neo-async@2.6.2: resolution: {integrity: sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==} - neverthrow@8.2.0: - resolution: {integrity: sha512-kOCT/1MCPAxY5iUV3wytNFUMUolzuwd/VF/1KCx7kf6CutrOsTie+84zTGTpgQycjvfLdBBdvBvFLqFD2c0wkQ==} - engines: {node: '>=18'} - node-fetch@2.7.0: resolution: {integrity: sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==} engines: {node: 4.x || >=6.0.0} @@ -3641,8 +3645,8 @@ packages: engines: {node: '>=14.17'} hasBin: true - typescript@6.0.0-dev.20251124: - resolution: {integrity: sha512-3dp4cPGjA35NMKhzreQI41rYQTiLFWUjIoWCBAp/r27Ccf6HLZwniD0f3KSgruR6hhzb+2m5yrEn0RP7IaK1Bg==} + typescript@6.0.0-dev.20251125: + resolution: {integrity: sha512-9Z3Gc/FwO/swCqfmqvha4UkUVlEkBf1lu0u0ELR4TynnXV1lWHc9iomNPfNLzclcKK6xJS9fOgiNi3p8L5imUw==} engines: {node: '>=14.17'} hasBin: true @@ -5069,6 +5073,11 @@ snapshots: '@pkgr/core@0.2.9': {} + '@praha/byethrow@0.8.1(typescript@5.8.3)': + dependencies: + '@standard-schema/spec': 1.0.0 + typescript: 5.8.3 + '@rollup/plugin-babel@6.1.0(@babel/core@7.28.5)(rollup@4.53.2)': dependencies: '@babel/core': 7.28.5 @@ -5232,6 +5241,8 @@ snapshots: - uglify-js - webpack-cli + '@standard-schema/spec@1.0.0': {} + '@stylistic/eslint-plugin@3.1.0(eslint@9.39.1(jiti@2.4.2))(typescript@5.8.3)': dependencies: '@typescript-eslint/utils': 8.47.0(eslint@9.39.1(jiti@2.4.2))(typescript@5.8.3) @@ -6040,7 +6051,7 @@ snapshots: dependencies: semver: 7.6.0 shelljs: 0.8.5 - typescript: 6.0.0-dev.20251124 + typescript: 6.0.0-dev.20251125 dunder-proto@1.0.1: dependencies: @@ -7110,10 +7121,6 @@ snapshots: neo-async@2.6.2: {} - neverthrow@8.2.0: - optionalDependencies: - '@rollup/rollup-linux-x64-gnu': 4.53.2 - node-fetch@2.7.0: dependencies: whatwg-url: 5.0.0 @@ -7815,7 +7822,7 @@ snapshots: typescript@5.8.3: {} - typescript@6.0.0-dev.20251124: {} + typescript@6.0.0-dev.20251125: {} uc.micro@2.1.0: {} diff --git a/src/api/SignalClient.test.ts b/src/api/SignalClient.test.ts index c2e4ad5d24..cb660cb62a 100644 --- a/src/api/SignalClient.test.ts +++ b/src/api/SignalClient.test.ts @@ -6,7 +6,7 @@ import { SignalRequest, SignalResponse, } from '@livekit/protocol'; -import { ResultAsync } from 'neverthrow'; +import { R } from '@praha/byethrow'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import { ConnectionError, ConnectionErrorReason } from '../room/errors'; import { SignalClient, SignalConnectionState } from './SignalClient'; @@ -59,27 +59,33 @@ function createMockConnection(readable: ReadableStream): WebSocketC interface MockWebSocketStreamOptions { connection?: WebSocketConnection; - opened?: ResultAsync, WebSocketError>; - closed?: ResultAsync; + opened?: R.ResultAsync, WebSocketError>; + closed?: R.ResultAsync; readyState?: number; } function mockWebSocketStream(options: MockWebSocketStreamOptions = {}) { const { connection, - // eslint-disable-next-line neverthrow-must-use/must-use-result + opened = connection - ? ResultAsync.fromPromise(Promise.resolve(connection), (error) => ({ - type: 'connection' as const, - error: error as Event, - })) - : // eslint-disable-next-line neverthrow-must-use/must-use-result - ResultAsync.fromPromise(new Promise(() => {}), (error) => ({ - type: 'connection' as const, - error: error as Event, - })), - // eslint-disable-next-line neverthrow-must-use/must-use-result - closed = ResultAsync.fromPromise(new Promise(() => {}), (error) => error as WebSocketError), + ? R.try({ + immediate: true, + try: () => Promise.resolve(connection), + catch: (error) => ({ + type: 'connection' as const, + error: error as Event, + }), + }) + : R.try({ + immediate: true, + try: () => new Promise(() => {}), + catch: (error) => ({ + type: 'connection' as const, + error: error as Event, + }), + }), + closed = R.try({ immediate: true, try: () => new Promise(() => {}), catch: (error) => error }), readyState = 1, } = options; @@ -209,8 +215,16 @@ describe('SignalClient.connect', () => { return { url: 'wss://test.livekit.io', - opened: ResultAsync.fromPromise(new Promise(() => {}), (e) => e as WebSocketError), // Never resolves - closed: ResultAsync.fromPromise(new Promise(() => {}), (e) => e as WebSocketError), + opened: R.try({ + immediate: true, + try: () => new Promise(() => {}), + catch: (e) => e as WebSocketError, + }), // Never resolves + closed: R.try({ + immediate: true, + try: () => new Promise(() => {}), + catch: (e) => e as WebSocketError, + }), close: vi.fn(), readyState: 0, } as any; @@ -265,16 +279,19 @@ describe('SignalClient.connect', () => { }; vi.mocked(WebSocketStream).mockImplementation(() => { - // eslint-disable-next-line neverthrow-must-use/must-use-result - const opened = ResultAsync.fromPromise(Promise.resolve(mockConnection), (error) => ({ - type: 'connection' as const, - error: error as Event, - })); - // eslint-disable-next-line neverthrow-must-use/must-use-result - const closed = ResultAsync.fromPromise( - new Promise(() => {}), - (error) => error as WebSocketError, - ); + const opened = R.try({ + immediate: true, + try: () => Promise.resolve(mockConnection), + catch: (error) => ({ + type: 'connection' as const, + error: error as Event, + }), + }); + const closed = R.try({ + immediate: true, + try: () => new Promise(() => {}), + catch: (error) => error as WebSocketError, + }); return { url: 'wss://test.livekit.io', opened, @@ -323,10 +340,11 @@ describe('SignalClient.connect', () => { describe('Failure Case - WebSocket Connection Errors', () => { it('should reject with NotAllowed error for 4xx HTTP status', async () => { mockWebSocketStream({ - opened: ResultAsync.fromPromise( - Promise.reject(new Error('Connection failed')), - (e) => e as WebSocketError, - ), + opened: R.try({ + immediate: true, + try: () => Promise.reject(new Error('Connection failed')), + catch: (e) => e as WebSocketError, + }), readyState: 3, }); @@ -347,10 +365,11 @@ describe('SignalClient.connect', () => { it('should reject with ServerUnreachable when fetch fails', async () => { mockWebSocketStream({ - opened: ResultAsync.fromPromise( - Promise.reject(new Error('Connection failed')), - (e) => e as WebSocketError, - ), + opened: R.try({ + immediate: true, + try: () => Promise.reject(new Error('Connection failed')), + catch: (e) => e as WebSocketError, + }), readyState: 3, }); @@ -368,7 +387,11 @@ describe('SignalClient.connect', () => { const customError = ConnectionError.websocket('Custom error'); mockWebSocketStream({ - opened: ResultAsync.fromPromise(Promise.reject(customError), (e) => e as WebSocketError), + opened: R.try({ + immediate: true, + try: () => Promise.reject(customError), + catch: (e) => e as WebSocketError, + }), readyState: 3, }); @@ -465,12 +488,19 @@ describe('SignalClient.connect', () => { closedResolve({ closeCode: 1006, reason: 'Connection lost' }); }); - // eslint-disable-next-line neverthrow-must-use/must-use-result - const closed = ResultAsync.fromPromise(closedPromise, (e) => e as WebSocketError); + const closed = R.try({ + immediate: true, + try: () => closedPromise, + catch: (e) => e as WebSocketError, + }); return { url: 'wss://test.livekit.io', - opened: ResultAsync.fromPromise(new Promise(() => {}), (e) => e as WebSocketError), // Never resolves + opened: R.try({ + immediate: true, + try: () => new Promise(() => {}), + catch: (e) => e as WebSocketError, + }), // Never resolves closed: closed, close: vi.fn(), readyState: 2, // CLOSING diff --git a/src/api/SignalClient.ts b/src/api/SignalClient.ts index 9cee74d20b..0444561e4a 100644 --- a/src/api/SignalClient.ts +++ b/src/api/SignalClient.ts @@ -44,13 +44,19 @@ import { WrappedJoinRequest, protoInt64, } from '@livekit/protocol'; +import { R } from '@praha/byethrow'; import log, { LoggerNames, getLogger } from '../logger'; import { ConnectionError } from '../room/errors'; import CriticalTimers from '../room/timers'; import type { LoggerOptions } from '../room/types'; import { getClientInfo, isReactNative, sleep } from '../room/utils'; import { AsyncQueue } from '../utils/AsyncQueue'; -import { type WebSocketConnection, WebSocketStream } from './WebSocketStream'; +import { + type WebSocketCloseInfo, + type WebSocketConnection, + type WebSocketError, + WebSocketStream, +} from './WebSocketStream'; import { createRtcUrl, createValidateUrl, @@ -359,42 +365,48 @@ export class SignalClient { // abortHandler() calls reject we simply return here return; } - this.ws = new WebSocketStream(rtcUrl); + + const ws = new WebSocketStream(rtcUrl); + this.ws = ws; + + const handleWebSocketClose = ( + closeInfo: WebSocketCloseInfo, + ): R.Result => { + if ( + // we only continue here if the current ws connection is still the same, we don't care about closing of older ws connections that have been replaced + ws !== this.ws + ) { + return R.succeed(); + } + if (closeInfo.closeCode !== 1000) { + this.log.warn(`websocket closed`, { + ...this.logContext, + reason: closeInfo.reason, + code: closeInfo.closeCode, + wasClean: closeInfo.closeCode === 1000, + state: this.state, + }); + } + if (this.isEstablishingConnection) { + return R.fail( + ConnectionError.websocket( + `Websocket got closed during a (re)connection attempt: ${closeInfo.reason}`, + ), + ); + } else if (this.state === SignalConnectionState.CONNECTED) { + this.handleOnClose(closeInfo.reason ?? 'Unexpected WS error'); + } + return R.succeed(); + }; try { - this.ws.closed.match( - (closeInfo) => { - if (closeInfo.closeCode !== 1000) { - this.log.warn(`websocket closed`, { - ...this.logContext, - reason: closeInfo.reason, - code: closeInfo.closeCode, - wasClean: closeInfo.closeCode === 1000, - state: this.state, - }); - } - if (this.isEstablishingConnection) { - reject( - ConnectionError.websocket( - `Websocket got closed during a (re)connection attempt: ${closeInfo.reason}`, - ), - ); - } else if (this.state === SignalConnectionState.CONNECTED) { - this.handleOnClose(closeInfo.reason ?? 'Unexpected WS error'); - } - }, - (reason) => { - if (this.isEstablishingConnection) { - reject( - ConnectionError.websocket( - `Websocket error during a (re)connection attempt: ${reason.message}`, - ), - ); - } - }, + R.pipe( + ws.closed, + R.andThen(handleWebSocketClose), + R.inspectError((e) => reject(e)), ); const openResult = await this.ws.opened; - if (openResult.isErr()) { + if (R.isFailure(openResult)) { const reason = openResult.error; if (this.state !== SignalConnectionState.CONNECTED) { this.state = SignalConnectionState.DISCONNECTED; diff --git a/src/api/WebSocketStream.test.ts b/src/api/WebSocketStream.test.ts index 32d1a5d5ad..ef4b51916b 100644 --- a/src/api/WebSocketStream.test.ts +++ b/src/api/WebSocketStream.test.ts @@ -1,4 +1,5 @@ /* eslint-disable @typescript-eslint/no-unused-vars */ +import { R } from '@praha/byethrow'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { ConnectionErrorReason } from '../room/errors'; import { WebSocketStream } from './WebSocketStream'; @@ -126,8 +127,8 @@ vi.mock('../room/utils', () => ({ // Helper function to unwrap Result from opened promise async function getConnectionOrFail(wsStream: WebSocketStream) { const result = await wsStream.opened; - expect(result.isOk()).toBe(true); - if (!result.isOk()) { + expect(R.isSuccess(result)).toBe(true); + if (!R.isSuccess(result)) { throw new Error('Failed to open connection'); } return result.value; @@ -214,8 +215,8 @@ describe('WebSocketStream', () => { mockWebSocket.triggerOpen(); const result = await wsStream.opened; - expect(result.isOk()).toBe(true); - if (result.isOk()) { + expect(R.isSuccess(result)).toBe(true); + if (R.isSuccess(result)) { const connection = result.value; expect(connection.readable).toBeInstanceOf(ReadableStream); expect(connection.writable).toBeInstanceOf(WritableStream); @@ -231,8 +232,8 @@ describe('WebSocketStream', () => { mockWebSocket.triggerError(); const result = await wsStream.opened; - expect(result.isErr()).toBe(true); - if (result.isErr()) { + expect(R.isFailure(result)).toBe(true); + if (R.isFailure(result)) { expect(result.error.reason).toBe(ConnectionErrorReason.WebSocket); } }); @@ -248,8 +249,8 @@ describe('WebSocketStream', () => { const result = await wsStream.closed; - expect(result.isOk()).toBe(true); - if (result.isOk()) { + expect(R.isSuccess(result)).toBe(true); + if (R.isSuccess(result)) { expect(result.value.closeCode).toBe(1001); expect(result.value.reason).toBe('Going away'); } @@ -265,8 +266,8 @@ describe('WebSocketStream', () => { const result = await wsStream.closed; - expect(result.isOk()).toBe(true); - if (result.isOk()) { + expect(R.isSuccess(result)).toBe(true); + if (R.isSuccess(result)) { expect(result.value.closeCode).toBe(1006); expect(result.value.reason).toBe('Connection failed'); } @@ -282,8 +283,8 @@ describe('WebSocketStream', () => { mockWebSocket.triggerError(); const result = await wsStream.closed; - expect(result.isErr()).toBe(true); - if (result.isErr()) { + expect(R.isFailure(result)).toBe(true); + if (R.isFailure(result)) { expect(result.error.reason).toBe(ConnectionErrorReason.WebSocket); expect(result.error.message).toBe( 'Encountered unspecified websocket error without a timely close event', @@ -298,8 +299,8 @@ describe('WebSocketStream', () => { mockWebSocket.triggerOpen(); const result = await wsStream.opened; - expect(result.isOk()).toBe(true); - if (!result.isOk()) return; + expect(R.isSuccess(result)).toBe(true); + if (!R.isSuccess(result)) return; const connection = result.value; const reader = connection.readable.getReader(); @@ -333,7 +334,7 @@ describe('WebSocketStream', () => { const closedResult = await wsStream.closed; await expect(reader.read()).rejects.toBeDefined(); - expect(closedResult.isErr()).toBe(true); + expect(R.isFailure(closedResult)).toBe(true); }); it('should close WebSocket with custom close info when cancelled', async () => { @@ -596,7 +597,7 @@ describe('WebSocketStream', () => { const closedResult = await wsStream.closed; await expect(readPromise).rejects.toBeDefined(); - expect(closedResult.isErr()).toBe(true); + expect(R.isFailure(closedResult)).toBe(true); }); it('should support zero-length and empty messages', async () => { diff --git a/src/api/WebSocketStream.ts b/src/api/WebSocketStream.ts index 6e25c223f7..8d3832cbe4 100644 --- a/src/api/WebSocketStream.ts +++ b/src/api/WebSocketStream.ts @@ -1,5 +1,5 @@ // https://github.com/CarterLi/websocketstream-polyfill -import { ResultAsync } from 'neverthrow'; +import { R } from '@praha/byethrow'; import { ConnectionError } from '../room/errors'; import { sleep } from '../room/utils'; @@ -30,9 +30,9 @@ export type WebSocketError = ReturnType; export class WebSocketStream { readonly url: string; - readonly opened: ResultAsync, WebSocketError>; + readonly opened: R.ResultAsync, WebSocketError>; - readonly closed: ResultAsync; + readonly closed: R.ResultAsync; readonly close!: (closeInfo?: WebSocketCloseInfo) => void; @@ -55,106 +55,110 @@ export class WebSocketStream ws.close(code, reason); - // eslint-disable-next-line neverthrow-must-use/must-use-result - this.opened = ResultAsync.fromPromise, WebSocketError>( - new Promise((resolve, r) => { - const reject = (err: WebSocketError) => r(err); - const errorHandler = (e: Event) => { - console.error(e); - reject( - ConnectionError.websocket('Encountered websocket error while establishing connection'), - ); - ws.removeEventListener('open', openHandler); - }; - - const onCloseDuringOpen = (ev: CloseEvent) => { - reject( - ConnectionError.websocket( - `WS closed during connection establishment: ${ev.reason}`, - ev.code, - ev.reason, - ), - ); - }; - - const openHandler = () => { - resolve({ - readable: new ReadableStream({ - start(controller) { - ws.onmessage = ({ data }) => controller.enqueue(data); - ws.onerror = (e) => controller.error(e); - }, - cancel: closeWithInfo, - }), - writable: new WritableStream({ - write(chunk) { - ws.send(chunk); - }, - abort() { - ws.close(); - }, - close: closeWithInfo, - }), - protocol: ws.protocol, - extensions: ws.extensions, - }); - ws.removeEventListener('error', errorHandler); - ws.removeEventListener('close', onCloseDuringOpen); - }; - - console.log('websocket setup registering event listeners'); - - ws.addEventListener('open', openHandler, { once: true }); - ws.addEventListener('error', errorHandler, { once: true }); - ws.addEventListener('close', onCloseDuringOpen, { once: true }); - }), - (error) => error as WebSocketError, - ); - - // eslint-disable-next-line neverthrow-must-use/must-use-result - this.closed = ResultAsync.fromPromise( - new Promise((resolve, r) => { - const reject = (err: WebSocketError) => r(err); - const errorHandler = async () => { - const closePromise = new Promise((res) => { - if (ws.readyState === WebSocket.CLOSED) return; - else { - ws.addEventListener( - 'close', - (closeEv: CloseEvent) => { - res(closeEv); - }, - { once: true }, - ); - } - }); - const reason = await Promise.race([sleep(250), closePromise]); - if (!reason) { + this.opened = R.try({ + immediate: true, + try: () => + new Promise>((resolve, r) => { + const reject = (err: WebSocketError) => r(err); + const errorHandler = (e: Event) => { + console.error(e); + reject( + ConnectionError.websocket( + 'Encountered websocket error while establishing connection', + ), + ); + ws.removeEventListener('open', openHandler); + }; + + const onCloseDuringOpen = (ev: CloseEvent) => { reject( ConnectionError.websocket( - 'Encountered unspecified websocket error without a timely close event', + `WS closed during connection establishment: ${ev.reason}`, + ev.code, + ev.reason, ), ); - } else { - // if we can infer the close reason from the close event then resolve with ok, we don't need to throw - resolve({ closeCode: reason.code, reason: reason.reason }); + }; + + const openHandler = () => { + resolve({ + readable: new ReadableStream({ + start(controller) { + ws.onmessage = ({ data }) => controller.enqueue(data); + ws.onerror = (e) => controller.error(e); + }, + cancel: closeWithInfo, + }), + writable: new WritableStream({ + write(chunk) { + ws.send(chunk); + }, + abort() { + ws.close(); + }, + close: closeWithInfo, + }), + protocol: ws.protocol, + extensions: ws.extensions, + }); + ws.removeEventListener('error', errorHandler); + ws.removeEventListener('close', onCloseDuringOpen); + }; + + console.log('websocket setup registering event listeners'); + + ws.addEventListener('open', openHandler, { once: true }); + ws.addEventListener('error', errorHandler, { once: true }); + ws.addEventListener('close', onCloseDuringOpen, { once: true }); + }), + catch: (error) => error as WebSocketError, + }); + + this.closed = R.try({ + immediate: true, + try: () => + new Promise((resolve, r) => { + const reject = (err: WebSocketError) => r(err); + const errorHandler = async () => { + const closePromise = new Promise((res) => { + if (ws.readyState === WebSocket.CLOSED) return; + else { + ws.addEventListener( + 'close', + (closeEv: CloseEvent) => { + res(closeEv); + }, + { once: true }, + ); + } + }); + const reason = await Promise.race([sleep(250), closePromise]); + if (!reason) { + reject( + ConnectionError.websocket( + 'Encountered unspecified websocket error without a timely close event', + ), + ); + } else { + // if we can infer the close reason from the close event then resolve with ok, we don't need to throw + resolve({ closeCode: reason.code, reason: reason.reason }); + } + }; + + if (ws.readyState === WebSocket.CLOSED) { + reject(ConnectionError.websocket('Websocket already closed at initialization time')); + return; } - }; - - if (ws.readyState === WebSocket.CLOSED) { - reject(ConnectionError.websocket('Websocket already closed at initialization time')); - return; - } - - ws.onclose = ({ code, reason }) => { - resolve({ closeCode: code, reason }); - ws.removeEventListener('error', errorHandler); - }; - - ws.addEventListener('error', errorHandler); - }), - (error) => error as WebSocketError, - ); + + ws.onclose = ({ code, reason }) => { + resolve({ closeCode: code, reason }); + ws.removeEventListener('error', errorHandler); + }; + + ws.addEventListener('error', errorHandler); + }), + catch: (error) => error as WebSocketError, + }); if (options.signal) { options.signal.onabort = () => ws.close(undefined, 'AbortSignal triggered');