Skip to content
Original file line number Diff line number Diff line change
Expand Up @@ -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 <DesktopSession {...props} client={client} />;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
},
Expand Down Expand Up @@ -183,7 +183,7 @@ export function DesktopSession({
error => {
setTdpConnectionStatus({
status: 'disconnected',
message: error?.message || error?.toString(),
message: error?.message,
});
initialTdpConnectionSucceeded.current = false;
},
Expand Down
27 changes: 22 additions & 5 deletions web/packages/shared/libs/tdp/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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',
Expand Down Expand Up @@ -116,7 +133,7 @@ let wasmReady: Promise<void> | 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<EventMap> {
protected codec: Codec;
protected transport: TdpTransport | undefined;
private transportAbortController: AbortController | undefined;
Expand Down Expand Up @@ -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;
}

Expand All @@ -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();
Expand All @@ -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');
Expand Down
45 changes: 0 additions & 45 deletions web/packages/shared/utils/abortError.test.ts

This file was deleted.

16 changes: 4 additions & 12 deletions web/packages/shared/utils/abortError.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,15 +16,7 @@
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/

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';
132 changes: 132 additions & 0 deletions web/packages/shared/utils/error.test.ts
Original file line number Diff line number Diff line change
@@ -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 <http://www.gnu.org/licenses/>.
*/

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' };
}
74 changes: 74 additions & 0 deletions web/packages/shared/utils/error.ts
Original file line number Diff line number Diff line change
@@ -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 <http://www.gnu.org/licenses/>.
*/

/**
* 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) {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

just curious, i've been using input == null to test for both null and undefined, should i be doing it more explicitly like this?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I use the explicit version because I can never remember the coercion rules for == 😅

It’s probably just personal preference, but I find === a lot easier to reason about.

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';
}
14 changes: 4 additions & 10 deletions web/packages/shared/utils/errorType.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,13 +16,7 @@
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/

// 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';
2 changes: 1 addition & 1 deletion web/packages/teleport/src/Player/DesktopPlayer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand Down
Loading
Loading