UNPKG

@eclipse-scout/core

Version:
652 lines (600 loc) 23.1 kB
/* * Copyright (c) 2010, 2025 BSI Business Systems Integration AG * * This program and the accompanying materials are made * available under the terms of the Eclipse Public License 2.0 * which is available at https://www.eclipse.org/legal/epl-2.0/ * * SPDX-License-Identifier: EPL-2.0 */ import { AjaxError, AjaxSettings, App, arrays, DoEntity, icons, InitModelOf, LogLevel, MessageBox, MessageBoxActionEvent, ModelOf, NullLogger, NullWidget, numbers, ObjectModel, objects, ObjectWithType, scout, Session, Status, StatusSeverity, strings, texts } from './index'; import $ from 'jquery'; import * as sourcemappedStacktrace from 'sourcemapped-stacktrace'; export interface ErrorHandlerModel extends ObjectModel<ErrorHandler> { /** * Default is true */ logError?: boolean; /** * Default is true */ displayError?: boolean; /** * Default is false */ sendError?: boolean; session?: Session; } export interface ErrorInfo { /** * The original error. May be Error, AjaxError or any other type like string, number etc. since any object can be thrown. */ error?: any; /** * Specifies if the error should be shown as fatal error or not. * If true, a fatal error is shown which forces the user to reload the page (unless in dev mode where it can be ignored). * If false, a normal messagebox with an ok button is shown and after confirmation the user is allowed to use the app again without reload. * The default is false. */ showAsFatalError?: boolean; /** * The message of the error */ message?: string; /** * The error code */ code?: string; /** * The HTTP status code if the error comes from an HTTP request */ httpStatus?: number; /** * If the error contains an ErrorDo (see ErrorDo.java) */ errorDo?: ErrorDo; /** * The original stack trace */ stack?: string; /** * Sourcemapped stack trace */ mappedStack?: string; /** * If there stacktrace mapping to source-maps fails this field contains the cause. */ mappingError?: string; /** * The full message to log. Typically consists of the message and e.g. a stack trace. */ log?: string; /** * Specifies the level errors are logged to the console (and sent to the backend for logging). Default is {@link LogLevel.ERROR}. */ level?: LogLevel; /** * Additional custom debug info. May come from an extra field on an Error thrown (Scout extension) or the response text from an ajax call. */ debugInfo?: string; } /** * See org.eclipse.scout.rt.rest.error.ErrorDo */ export interface ErrorDo extends DoEntity { httpStatus?: number; errorCode?: string; title?: string; message?: string; correlationId?: string; severity?: string; } export class ErrorHandler implements ErrorHandlerModel, ObjectWithType { declare model: ErrorHandlerModel; objectType: string; logError: boolean; displayError: boolean; sendError: boolean; session: Session; windowErrorHandler: OnErrorEventHandlerNonNull; constructor() { this.logError = true; this.displayError = true; this.sendError = false; this.windowErrorHandler = this._onWindowError.bind(this); this.session = null; } /** * Use this constant to configure whether all instances of the ErrorHandler should write * to the console. When you've installed a console appender to log4javascript you can set the * value to false, because the ErrorHandler also calls $.log.error and thus the appender has * already written the message to the console. We don't want to see it twice. */ static CONSOLE_OUTPUT = true; init(options?: InitModelOf<this>) { $.extend(this, options); } // Signature matches the "window.onerror" event handler // https://developer.mozilla.org/en-US/docs/Web/API/GlobalEventHandlers/onerror protected _onWindowError(errorMessage: string, fileName?: string, lineNumber?: number, columnNumber?: number, error?: Error) { try { if (this._isIgnorableScriptError(errorMessage, fileName, lineNumber, columnNumber, error)) { this.handleErrorInfo({ log: `Ignoring error. Message: ${errorMessage}`, showAsFatalError: true, level: LogLevel.INFO }); return; } if (error instanceof Error) { this.analyzeError(error) .then(info => { info.showAsFatalError = true; return this.handleErrorInfo(info); }) .catch(error => console.error('Error in global JavaScript error handler', error)); return; } let code = 'J00'; let log = errorMessage + ' at ' + fileName + ':' + lineNumber + '\n(' + 'Code ' + code + ')'; this.handleErrorInfo({ message: errorMessage, showAsFatalError: true, code: code, log: log }); } catch (err) { throw new Error('Error in global JavaScript error handler: ' + err.message + ' (original error: ' + errorMessage + ' at ' + fileName + ':' + lineNumber + ')'); } } protected _isIgnorableScriptError(message: string, fileName?: string, lineNumber?: number, columnNumber?: number, error?: Error): boolean { // Ignore errors caused by scripts from a different origin. // Example: Firefox on iOS throws an error, probably caused by an internal Firefox script. // The error does not affect the application and cannot be prevented by the app either since we don't know what script it is and what it does. // In that case the error must not be shown to the user, instead just log it silently. // https://developer.mozilla.org/en-US/docs/Web/API/GlobalEventHandlers/onerror return message && message.toLowerCase().indexOf('script error') > -1 && !fileName && !lineNumber && !columnNumber && !error; } /** * Handles unexpected JavaScript errors. The arguments are first analyzed and then handled. * * This method may be called by passing the arguments individually or as an array (or array-like object) * in the first argument. * Examples: * 1. try { ... } catch (err) { handler.handle(err); } * 2. $.get().fail(function(jqXHR, textStatus, errorThrown) { handler.handle(jqXHR, textStatus, errorThrown); } * 3. $.get().fail(function(jqXHR, textStatus, errorThrown) { handler.handle(arguments); } // <-- recommended * * @param errorOrArgs error or array or array-like object containing the error and other arguments * @returns the analyzed errorInfo */ handle(errorOrArgs: any | IArguments | any[], ...args: any[]): JQuery.Promise<ErrorInfo> { let error = errorOrArgs; if (errorOrArgs && args.length === 0) { if ((String(errorOrArgs) === '[object Arguments]')) { error = errorOrArgs[0]; args = [...errorOrArgs].slice(1); } else if (Array.isArray(errorOrArgs)) { error = errorOrArgs[0]; args = errorOrArgs.slice(1); } } return this.analyzeError(error, ...args) .then(this.handleErrorInfo.bind(this)); } /** * Returns an "errorInfo" object for the given arguments. The following cases are handled: * 1. Error objects (code: computed by getJsErrorCode()) * 2. jQuery AJAX errors (code: 'X' + HTTP status code) * 3. Nothing (code: 'P3') * 4. Everything else (code: 'P4') */ analyzeError(error?: any, ...args: any[]): JQuery.Promise<ErrorInfo> { let errorInfo: ErrorInfo = { error: error, message: null, code: null, httpStatus: null, errorDo: null, stack: null, mappedStack: null, mappingError: null, log: null, debugInfo: null }; return this._analyzeError(errorInfo, ...args); } protected _analyzeError(errorInfo: ErrorInfo, ...args: any[]): JQuery.Promise<ErrorInfo> { let error = errorInfo.error; // 1. Regular errors if (error instanceof Error) { // Map stack first before analyzing the error return this.mapStack(error.stack) .catch(result => { errorInfo.mappingError = result.message + '\n' + result.error.message + '\n' + result.error.stack; return null; }) .then(mappedStack => { errorInfo.mappedStack = mappedStack; this._analyzeRegularError(errorInfo); return errorInfo; }); } // 2. Ajax errors if ($.isJqXHR(error) || (Array.isArray(error) && $.isJqXHR(error[0])) || error instanceof AjaxError) { this._analyzeAjaxError(errorInfo, ...args); return $.resolvedPromise(errorInfo); } // 3. No reason provided if (!error) { this._analyzeNoError(errorInfo); return $.resolvedPromise(errorInfo); } // 4. Everything else (e.g. when strings are thrown) this._analyzeOtherError(errorInfo); return $.resolvedPromise(errorInfo); } protected _analyzeRegularError(errorInfo: ErrorInfo) { let error = errorInfo.error as Error; errorInfo.code = this.getJsErrorCode(error); errorInfo.showAsFatalError = true; errorInfo.message = String(error.message || error); if (error.stack) { errorInfo.stack = String(error.stack); } if (error['debugInfo']) { // scout extension errorInfo.debugInfo = error['debugInfo']; } let stack = errorInfo.mappedStack || errorInfo.stack; let log = []; if (!stack || stack.indexOf(errorInfo.message) === -1) { // Only log message if not already included in stack log.push(errorInfo.message); } if (stack) { log.push(stack); } if (errorInfo.mappingError) { log.push(errorInfo.mappingError); } if (errorInfo.debugInfo) { // Error throwers may put a "debugInfo" string on the error object that is then added to the log string (this is a scout extension). log.push('----- Additional debug information: -----\n' + errorInfo.debugInfo); } errorInfo.log = arrays.format(log, '\n'); } protected _analyzeAjaxError(errorInfo: ErrorInfo, ...args: any[]) { let error = errorInfo.error; let jqXHR: JQuery.jqXHR, errorThrown: string, requestOptions: AjaxSettings, errorDo: ErrorDo = null; if (error instanceof AjaxError) { // Scout Ajax Error jqXHR = error.jqXHR; errorThrown = error.errorThrown; requestOptions = error.requestOptions; // scout extension errorDo = error.errorDo; } else { // jQuery $.ajax() error (arguments of the fail handler are: jqXHR, textStatus, errorThrown, requestOptions) // The first argument (jqXHR) is stored in errorInfo.error (may even be an array) -> create args array again and extract the parameters args = arrays.ensure(error).concat(args); jqXHR = args[0]; errorThrown = args[2]; requestOptions = args[3]; // scout extension let errorDoCandidate = jqXHR?.responseJSON?.error; if (AjaxError.isErrorDo(errorDoCandidate)) { errorDo = errorDoCandidate; } } let ajaxRequest = this.formatAjaxRequest(requestOptions); let ajaxStatus = this.formatAjaxStatus(jqXHR, errorThrown); errorInfo.httpStatus = jqXHR.status || 0; errorInfo.code = 'X' + errorInfo.httpStatus; errorInfo.errorDo = errorDo; if (errorDo) { errorInfo.message = errorDo.message; errorInfo.level = this._severityToLogLevel(errorDo.severity); } // logging info let req = 'AJAX call' + strings.box(' "', ajaxRequest, '"') + ' failed' + strings.box(' [', ajaxStatus, ']'); let log = []; if (errorInfo.message) { log.push(errorInfo.message); } else { errorInfo.message = req; } log.push(req); if (jqXHR.responseText) { errorInfo.debugInfo = 'Response text:\n' + jqXHR.responseText; log.push(errorInfo.debugInfo); } errorInfo.log = arrays.format(log, '\n'); } formatAjaxStatus(jqXHR: JQuery.jqXHR, errorThrown: string): string { return jqXHR.status ? strings.join(' ', jqXHR.status + '', errorThrown) : 'Connection error'; } formatAjaxRequest(requestOptions: AjaxSettings): string { return requestOptions ? strings.join(' ', requestOptions.method, requestOptions.url) : ''; } protected _severityToLogLevel(severity: string): LogLevel { switch (severity) { case 'ok': return LogLevel.DEBUG; case 'info': return LogLevel.INFO; case 'warning': return LogLevel.WARN; default: return LogLevel.ERROR; } } protected _analyzeOtherError(errorInfo: ErrorInfo) { let error = errorInfo.error; // Everything else (e.g. when strings are thrown) let s = (typeof error === 'string' || typeof error === 'number') ? String(error) : null; errorInfo.code = 'P4'; errorInfo.message = s || 'Unexpected error'; if (!s) { try { s = JSON.stringify(error); // may throw "cyclic object value" error } catch (err) { s = String(error); } } errorInfo.log = 'Unexpected error: ' + s; } protected _analyzeNoError(errorInfo: ErrorInfo) { errorInfo.code = 'P3'; errorInfo.message = 'Unknown error'; errorInfo.log = 'Unexpected error (no reason provided)'; } mapStack(stack: string): JQuery.Promise<string, { message: string; error: Error }> { let deferred = $.Deferred(); try { sourcemappedStacktrace.mapStackTrace(stack, mappedStack => { deferred.resolve(arrays.format(mappedStack, '\n')); }); } catch (e) { return $.rejectedPromise({message: 'stacktrace mapping failed', error: e}); } return deferred.promise(); } /** * Expects an object as returned by {@link analyzeError} and handles it: * - If the flag "logError" is set, the log message is printed to the console * - If there is a scout session and the flag "displayError" is set, the error is shown in a message box. * - If there is a scout session and the flag "sendError" is set, the error is sent to the UI server. */ handleErrorInfo(errorInfo: ErrorInfo): JQuery.Promise<ErrorInfo> { errorInfo.level = scout.nvl(errorInfo.level, LogLevel.ERROR); if (this.logError && errorInfo.log) { this._logErrorInfo(errorInfo); } // Note: The error handler is installed globally, and we cannot tell in which scout session the error happened. // We simply use the first scout session to display the message box and log the error. This is not ideal in the // multi-session-case (portlet), but currently there is no other way. Besides, this feature is not in use yet. let session = this.session || App.get().sessions[0]; if (session) { if (this.sendError) { this._sendErrorMessage(session, errorInfo.log, errorInfo.level); } if (this.displayError) { if (errorInfo.showAsFatalError) { if (errorInfo.level === LogLevel.ERROR) { return this._showInternalUiErrorMessageBox(session, errorInfo.message, errorInfo.code, errorInfo.log) .then(() => errorInfo); } } else { return this._showErrorMessageBox(session, errorInfo) .then(() => errorInfo); } } } return $.resolvedPromise(errorInfo); } protected _logErrorInfo(errorInfo: ErrorInfo) { switch (errorInfo.level) { case LogLevel.TRACE: $.log.trace(errorInfo.log); break; case LogLevel.DEBUG: $.log.debug(errorInfo.log); break; case LogLevel.INFO: $.log.info(errorInfo.log); break; case LogLevel.WARN: $.log.warn(errorInfo.log); break; default: $.log.error(errorInfo.log); } // Note: when the null-logger is active it has already written the error to the console // when the $.log.error function has been called above, so we don't have to log again here. let writeToConsole = ErrorHandler.CONSOLE_OUTPUT; if ($.log instanceof NullLogger) { writeToConsole = false; } if (writeToConsole && window && window.console) { if (errorInfo.level === LogLevel.ERROR && window.console.error) { window.console.error(errorInfo.log); } else if (errorInfo.level === LogLevel.WARN && window.console.warn) { window.console.warn(errorInfo.log); } else if (window.console.log) { window.console.log(errorInfo.log); } } } /** * Generate a "cool looking" error code from the JS error object, that * does not reveal too much technical information, but at least indicates * that a JS runtime error has occurred. (In contrast, fatal errors from * the server have numeric error codes.) */ getJsErrorCode(error?: Error): string { if (error) { if (error.name === 'EvalError') { return 'E1'; } if (error.name === 'InternalError') { return 'I2'; } if (error.name === 'RangeError') { return 'A3'; } if (error.name === 'ReferenceError') { return 'R4'; } if (error.name === 'SyntaxError') { return 'S5'; } if (error.name === 'TypeError') { return 'T6'; } if (error.name === 'URIError') { return 'U7'; } } return 'J0'; } protected _showErrorMessageBox(session: Session, errorInfo: ErrorInfo): JQuery.Promise<MessageBoxActionEvent> { const parent = session.desktop || new NullWidget(); const msgBoxModel: InitModelOf<MessageBox> = { parent, session, ...this._buildErrorMessageBoxModel(session, errorInfo) }; const messageBox = scout.create(MessageBox, msgBoxModel); messageBox.on('action', event => messageBox.close()); messageBox.render(session.$entryPoint); // do not use messageBox.open() as the Desktop might not yet be ready (e.g. if there is an error in the App startup) return messageBox.when('action'); } protected _buildErrorMessageBoxModel(session: Session, errorInfo: ErrorInfo): ModelOf<MessageBox> { const status = this.errorInfoToStatus(session, errorInfo); let code: string = null; if (errorInfo.error instanceof Status && errorInfo.error.code !== 0) { code = errorInfo.error.code + ''; } if (errorInfo?.errorDo?.errorCode && errorInfo.errorDo.errorCode !== '0') { code = errorInfo.errorDo.errorCode; } let body = status.message || session.optText('ui.UnexpectedProblem', 'Unexpected problem'); if (code) { body += ' (' + session.optText('ui.ErrorCodeX', 'Code ' + code, code) + ').'; } let html = null; if (errorInfo?.errorDo?.correlationId) { let $container = session.desktop?.$container || session.$entryPoint; let correlationIdText = session.optText('CorrelationId', 'Correlation ID'); html = $container.makeDiv('error-popup-correlation-id', correlationIdText + ': ' + errorInfo.errorDo.correlationId)[0].outerHTML; } return { header: errorInfo.errorDo?.title, body: body, html: html, iconId: status.iconId, severity: status.severity, hiddenText: errorInfo.log, yesButtonText: session.optText('Ok', 'Ok') }; } protected _showInternalUiErrorMessageBox(session: Session, errorMessage: string, errorCode: string, logMessage: string): JQuery.Promise<void> { let options = { header: session.optText('ui.UnexpectedProblem', 'Internal UI Error'), body: strings.join('\n\n', session.optText('ui.InternalUiErrorMsg', errorMessage, ' (' + session.optText('ui.ErrorCodeX', 'Code ' + errorCode, errorCode) + ')'), session.optText('ui.UiInconsistentMsg', '')), yesButtonText: session.optText('ui.Reload', 'Reload'), yesButtonAction: scout.reloadPage, noButtonText: undefined, hiddenText: logMessage, iconId: icons.SLIPPERY }; if (session.inDevelopmentMode) { options.noButtonText = session.optText('ui.Ignore', 'Ignore'); } return session.showFatalMessage(options, errorCode); } protected _sendErrorMessage(session: Session, logMessage: string, logLevel: LogLevel) { session.sendLogRequest(logMessage, logLevel); } errorInfoToStatus(session: Session, errorInfo: ErrorInfo): Status { if (errorInfo.error instanceof Status) { // if a Status is thrown return errorInfo.error; } return Status.ensure({ message: this._errorInfoToStatusMessage(session, errorInfo), severity: this._errorInfoToStatusSeverity(errorInfo), code: this._errorInfoToStatusCode(errorInfo) }); } protected _errorInfoToStatusCode(errorInfo: ErrorInfo): number { let errorCode = errorInfo?.errorDo?.errorCode; if (errorCode && errorCode !== '0' && objects.isNumber(errorCode)) { return numbers.ensure(errorCode); } return errorInfo.httpStatus; } protected _errorInfoToStatusSeverity(errorInfo: ErrorInfo): StatusSeverity { switch (errorInfo?.errorDo?.severity) { case 'ok': return Status.Severity.OK; case 'info': return Status.Severity.INFO; case 'warning': return Status.Severity.WARNING; default: return Status.Severity.ERROR; } } protected _errorInfoToStatusMessage(session: Session, errorInfo: ErrorInfo): string { if (typeof errorInfo.error === 'string') { return errorInfo.error; } const errorDo = errorInfo.errorDo; if (errorDo) { let body = errorDo.message; if (body && body !== 'undefined') { // ProcessingException has default text 'undefined' which is not very helpful -> don't use it return body; } } if (errorInfo.error?.message) { return errorInfo.error.message; } if (errorInfo.message) { return errorInfo.message; } return this.getMessageBodyForHttpStatus(errorInfo?.httpStatus, session); } /** * Gets the default error message body for the given HTTP status error code. * @param httpStatus The HTTP status error code. E.g. 503 (Service Unavailable) * @param session An optional Session for a message in the language of the user. The default language is used if omitted. * @returns The error message for the given error HTTP status. */ getMessageBodyForHttpStatus(httpStatus: number, session?: Session): string { let body: string; let textMap = session ? session.textMap : texts.get('default'); switch (httpStatus) { case 404: // Not Found body = textMap.optGet('TheRequestedResourceCouldNotBeFound', 'The requested resource could not be found.'); break; case 502: // Bad Gateway case 503: // Service Unavailable case 504: // Gateway Timeout body = textMap.optGet('NetSystemsNotAvailable', 'The system is partially unavailable at the moment.') + '\n\n' + textMap.optGet('PleaseTryAgainLater', 'Please try again later.'); break; case 403: // Forbidden body = textMap.optGet('YouAreNotAuthorizedToPerformThisAction', 'You are not authorized to perform this action.'); break; default: body = textMap.optGet('ui.UnexpectedProblem', 'Unexpected problem'); } return body; } }