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');
-}