diff --git a/web/packages/shared/components/DesktopSession/DesktopSession.story.tsx b/web/packages/shared/components/DesktopSession/DesktopSession.story.tsx index 53b29c246554f..c076d3530577d 100644 --- a/web/packages/shared/components/DesktopSession/DesktopSession.story.tsx +++ b/web/packages/shared/components/DesktopSession/DesktopSession.story.tsx @@ -109,7 +109,7 @@ export const Connected = () => { export const DisconnectedWithNoMessage = () => { const client = fakeClient(); client.connect = async () => { - client.emit(TdpClientEvent.TRANSPORT_CLOSE); + client.emit(TdpClientEvent.TRANSPORT_CLOSE, undefined); }; return ; diff --git a/web/packages/shared/components/DesktopSession/DesktopSession.tsx b/web/packages/shared/components/DesktopSession/DesktopSession.tsx index 251b6803ab393..3799fb716bbaf 100644 --- a/web/packages/shared/components/DesktopSession/DesktopSession.tsx +++ b/web/packages/shared/components/DesktopSession/DesktopSession.tsx @@ -144,7 +144,7 @@ export function DesktopSession({ setTdpConnectionStatus({ status: 'disconnected', fromTdpError: error instanceof TdpError, - message: error.message || error.toString(), + message: error.message, }); initialTdpConnectionSucceeded.current = false; }, @@ -183,7 +183,7 @@ export function DesktopSession({ error => { setTdpConnectionStatus({ status: 'disconnected', - message: error?.message || error?.toString(), + message: error?.message, }); initialTdpConnectionSucceeded.current = false; }, diff --git a/web/packages/shared/libs/tdp/client.ts b/web/packages/shared/libs/tdp/client.ts index f1c6e7fb269e5..f8926ef93f172 100644 --- a/web/packages/shared/libs/tdp/client.ts +++ b/web/packages/shared/libs/tdp/client.ts @@ -25,7 +25,7 @@ import init, { init_wasm_log, } from 'shared/libs/ironrdp/pkg/ironrdp'; import Logger from 'shared/libs/logger'; -import { isAbortError } from 'shared/utils/abortError'; +import { ensureError, isAbortError } from 'shared/utils/error'; import Codec, { FileType, @@ -80,6 +80,23 @@ export enum TdpClientEvent { LATENCY_STATS = 'latency stats', } +type EventMap = { + [TdpClientEvent.TDP_CLIENT_SCREEN_SPEC]: [ClientScreenSpec]; + [TdpClientEvent.TDP_PNG_FRAME]: [PngFrame]; + [TdpClientEvent.TDP_BMP_FRAME]: [BitmapFrame]; + [TdpClientEvent.TDP_CLIPBOARD_DATA]: [ClipboardData]; + [TdpClientEvent.ERROR]: [Error]; + [TdpClientEvent.TDP_WARNING]: [string]; + [TdpClientEvent.CLIENT_WARNING]: [string]; + [TdpClientEvent.TDP_INFO]: [string]; + [TdpClientEvent.TRANSPORT_OPEN]: [void]; + [TdpClientEvent.TRANSPORT_CLOSE]: [Error | undefined]; + [TdpClientEvent.RESET]: [void]; + [TdpClientEvent.POINTER]: [PointerData]; + [TdpClientEvent.LATENCY_STATS]: [LatencyStats]; + 'terminal.webauthn': [string]; +}; + export enum LogType { OFF = 'OFF', ERROR = 'ERROR', @@ -116,7 +133,7 @@ let wasmReady: Promise | undefined; // sending client commands, and receiving and processing server messages. Its creator is responsible for // ensuring the websocket gets closed and all of its event listeners cleaned up when it is no longer in use. // For convenience, this can be done in one fell swoop by calling Client.shutdown(). -export class TdpClient extends EventEmitter { +export class TdpClient extends EventEmitter { protected codec: Codec; protected transport: TdpTransport | undefined; private transportAbortController: AbortController | undefined; @@ -152,7 +169,7 @@ export class TdpClient extends EventEmitter { this.transportAbortController.signal ); } catch (error) { - this.emit(TdpClientEvent.ERROR, error); + this.emit(TdpClientEvent.ERROR, ensureError(error)); return; } @@ -173,7 +190,7 @@ export class TdpClient extends EventEmitter { subscribers.add( this.transport.onMessage(data => { void this.processMessage(data).catch(error => { - processingError = error; + processingError = ensureError(error); unsubscribe(); // All errors are treated as fatal, close the connection. this.transportAbortController.abort(); @@ -197,7 +214,7 @@ export class TdpClient extends EventEmitter { } else if (connectionError && !isAbortError(connectionError)) { this.emit(TdpClientEvent.TRANSPORT_CLOSE, connectionError); } else { - this.emit(TdpClientEvent.TRANSPORT_CLOSE); + this.emit(TdpClientEvent.TRANSPORT_CLOSE, undefined); } this.logger.info('Transport is closed'); diff --git a/web/packages/shared/utils/abortError.test.ts b/web/packages/shared/utils/abortError.test.ts deleted file mode 100644 index 312d328b4a378..0000000000000 --- a/web/packages/shared/utils/abortError.test.ts +++ /dev/null @@ -1,45 +0,0 @@ -/** - * Teleport - * Copyright (C) 2023 Gravitational, Inc. - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see . - */ - -import { isAbortError } from 'shared/utils/abortError'; - -describe.each([ - ['DOMException', newDOMAbortError], - ['ApiError', newApiAbortError], - ['gRPC Error', newGrpcAbortError], -])('for error type %s', (_, ErrorType) => { - it('is abort error', async () => { - expect(isAbortError(ErrorType())).toBe(true); - }); -}); - -function newDOMAbortError() { - return new DOMException('Aborted', 'AbortError'); -} - -// mimics ApiError -function newApiAbortError() { - return new Error('The user aborted a request', { - cause: newDOMAbortError(), - }); -} - -// mimics TshdRpcError -function newGrpcAbortError() { - return { code: 'CANCELLED' }; -} diff --git a/web/packages/shared/utils/abortError.ts b/web/packages/shared/utils/abortError.ts index 2bff7ce9769bc..692f7e83bf692 100644 --- a/web/packages/shared/utils/abortError.ts +++ b/web/packages/shared/utils/abortError.ts @@ -16,15 +16,7 @@ * along with this program. If not, see . */ -export const isAbortError = (err: any): boolean => { - // handles Web UI abort error - if ( - (err instanceof DOMException && err.name === 'AbortError') || - (err.cause && isAbortError(err.cause)) - ) { - return true; - } - - // handles Connect abort error (specifically gRPC cancel error), see TshdRpcError - return err?.code === 'CANCELLED'; -}; +export { + /** @deprecated Import `isAbortError` from 'shared/utils/error.ts' */ + isAbortError, +} from './error'; diff --git a/web/packages/shared/utils/error.test.ts b/web/packages/shared/utils/error.test.ts new file mode 100644 index 0000000000000..71bbc6f363fdc --- /dev/null +++ b/web/packages/shared/utils/error.test.ts @@ -0,0 +1,132 @@ +/** + * Teleport + * Copyright (C) 2025 Gravitational, Inc. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +import { ensureError, isAbortError } from './error'; + +class CustomErrorClass extends Error { + constructor(message: string) { + super(message); + this.name = 'CustomErrorClass'; + } +} + +describe('ensureError', () => { + const cases = [ + { + input: new Error('already error'), + expectedMessage: 'already error', + expectedName: 'Error', + expectedInstance: Error, + }, + { + input: { message: 'custom message' }, + expectedMessage: 'custom message', + expectedName: 'Error', + expectedInstance: Error, + }, + { + input: { message: '', otherField: '123' }, + expectedMessage: '', + expectedName: 'Error', + expectedInstance: Error, + }, + { + input: { name: 'MyError', message: 'fail' }, + expectedMessage: 'fail', + expectedName: 'MyError', + expectedInstance: Error, + }, + { + input: new CustomErrorClass('fail'), + expectedMessage: 'fail', + expectedName: 'CustomErrorClass', + expectedInstance: CustomErrorClass, + }, + { + input: { foo: 'bar' }, + expectedMessage: '{"foo":"bar"}', + expectedName: 'Error', + expectedInstance: Error, + }, + { + input: 'just a string', + expectedMessage: 'just a string', + expectedName: 'Error', + expectedInstance: Error, + }, + { + input: 42, + expectedMessage: '42', + expectedName: 'Error', + expectedInstance: Error, + }, + { + input: null, + expectedMessage: '', + expectedName: 'Error', + expectedInstance: Error, + }, + { + input: undefined, + expectedMessage: '', + expectedName: 'Error', + expectedInstance: Error, + }, + ]; + + test.each(cases)( + 'converts input "$input" to Error with message "$expectedMessage" and name "$expectedName"', + ({ input, expectedMessage, expectedName, expectedInstance }) => { + const error = ensureError(input); + + expect(error).toBeInstanceOf(expectedInstance); + expect(error.message).toBe(expectedMessage); + expect(error.name).toBe(expectedName); + // Non-Error instances should have the original input attached as .cause. + expect(error.cause).toBe(input instanceof Error ? undefined : input); + } + ); +}); + +describe('isAbortError', () => { + describe.each([ + ['DOMException', newDOMAbortError], + ['ApiError', newApiAbortError], + ['gRPC Error', newGrpcAbortError], + ])('for error type %s', (_, ErrorType) => { + it('is abort error', () => { + expect(isAbortError(ErrorType())).toBe(true); + }); + }); +}); + +function newDOMAbortError() { + return new DOMException('Aborted', 'AbortError'); +} + +// mimics ApiError +function newApiAbortError() { + return new Error('The user aborted a request', { + cause: newDOMAbortError(), + }); +} + +// mimics TshdRpcError +function newGrpcAbortError() { + return { code: 'CANCELLED' }; +} diff --git a/web/packages/shared/utils/error.ts b/web/packages/shared/utils/error.ts new file mode 100644 index 0000000000000..c006cbab0bad5 --- /dev/null +++ b/web/packages/shared/utils/error.ts @@ -0,0 +1,74 @@ +/** + * Teleport + * Copyright (C) 2025 Gravitational, Inc. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +/** + * Converts any unknown input into an Error instance. + * Preserves message and name if available. + */ +export function ensureError(input: unknown): Error { + if (input instanceof Error) { + return input; + } + + if (input === null || input === undefined) { + return new Error('', { cause: input }); + } + + if (typeof input !== 'object') { + return new Error(String(input), { cause: input }); + } + + let message = ''; + if ('message' in input) { + message = String(input.message); + } else { + try { + message = JSON.stringify(input); + } catch { + message = '[Unable to stringify the thrown value]'; + } + } + + const error = new Error(message, { cause: input }); + if ('name' in input && typeof input.name === 'string') { + error.name = input.name; + } + return error; +} + +/** Extracts an error message or returns a default one. */ +export function getErrorMessage(err: unknown): string { + const errorInstance = ensureError(err); + if (errorInstance.message === '') { + return 'something went wrong'; + } + return errorInstance.message; +} + +export function isAbortError(err: any): boolean { + // handles Web UI abort error + if ( + (err instanceof DOMException && err.name === 'AbortError') || + (err?.cause && isAbortError(err.cause)) + ) { + return true; + } + + // handles Connect abort error (specifically gRPC cancel error), see TshdRpcError + return err?.code === 'CANCELLED'; +} diff --git a/web/packages/shared/utils/errorType.ts b/web/packages/shared/utils/errorType.ts index 40d531e9a9b79..fa8b38d54a3ff 100644 --- a/web/packages/shared/utils/errorType.ts +++ b/web/packages/shared/utils/errorType.ts @@ -16,13 +16,7 @@ * along with this program. If not, see . */ -// getErrMessage first checks if the error is of type Error -// before attempting to access the error message field. -// Used with try catch blocks, where the error caught -// may not necessary be of type Error. -export function getErrMessage(err: unknown) { - let message = 'something went wrong'; - if (err instanceof Error) message = err.message; - - return message; -} +export { + /** @deprecated Import `getErrorMessage` from 'shared/utils/error.ts' */ + getErrorMessage as getErrMessage, +} from './error'; diff --git a/web/packages/teleport/src/Player/DesktopPlayer.tsx b/web/packages/teleport/src/Player/DesktopPlayer.tsx index ec2adbdb1c388..c16cd502ea61e 100644 --- a/web/packages/teleport/src/Player/DesktopPlayer.tsx +++ b/web/packages/teleport/src/Player/DesktopPlayer.tsx @@ -144,7 +144,7 @@ const useDesktopPlayer = ({ clusterId, sid }) => { const clientOnError = useCallback((error: Error) => { setPlayerStatus(StatusEnum.ERROR); - setStatusText(error.message || error.toString()); + setStatusText(error.message); }, []); const clientOnTdpInfo = useCallback((info: string) => { diff --git a/web/packages/teleport/src/lib/tdp/webSocketTransportAdapter.ts b/web/packages/teleport/src/lib/tdp/webSocketTransportAdapter.ts index 88fd2caa1dd45..5c789cd6d600d 100644 --- a/web/packages/teleport/src/lib/tdp/webSocketTransportAdapter.ts +++ b/web/packages/teleport/src/lib/tdp/webSocketTransportAdapter.ts @@ -95,7 +95,11 @@ async function waitToOpen(socket: WebSocket): Promise { const handleError = (event: Event) => { cleanup(); - reject(event); + reject( + new Error( + `WebSocket error (type=${event.type}, readyState=${socket.readyState})` + ) + ); }; function cleanup() { diff --git a/web/packages/teleterm/src/services/tshd/errors.ts b/web/packages/teleterm/src/services/tshd/errors.ts deleted file mode 100644 index 16660ad9cf2e2..0000000000000 --- a/web/packages/teleterm/src/services/tshd/errors.ts +++ /dev/null @@ -1,24 +0,0 @@ -/** - * Teleport - * Copyright (C) 2023 Gravitational, Inc. - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see . - */ - -import { isTshdRpcError } from './cloneableClient'; - -/** @deprecated Use `isTshdRpcError(error, 'UNIMPLEMENTED')`. */ -export function isUnimplementedError(error: unknown): boolean { - return isTshdRpcError(error, 'UNIMPLEMENTED'); -}