@eclipse-scout/core
Version:
Eclipse Scout runtime
653 lines (585 loc) • 25.6 kB
text/typescript
/*
* 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 {
access, AjaxError, AjaxSettings, AppEventMap, aria, codes, config, Desktop, Device, ErrorHandler, ErrorInfo, Event, EventEmitter, EventHandler, EventListener, EventMapOf, FontDescriptor, fonts, InitModelOf, Locale, locales, logging,
numbers, ObjectFactory, objects, scout, Session, SessionModel, texts, uiPreferences, webstorage, Widget
} from './index';
import $ from 'jquery';
let instance: App = null;
let listeners: EventListener[] = [];
let bootstrappers: (() => JQuery.Promise<void>)[] = [];
export interface AppModel {
/**
* Object to configure the session, see {@link Session.init} for the available options.
*/
session?: SessionModel;
bootstrap?: AppBootstrapOptions;
/**
* True, to check whether the browser fulfills all requirements to run the application. If the check fails, a notification is shown to warn the user about his old browser. Default is true.
*/
checkBrowserCompatibility?: boolean;
version?: string;
}
export type JsonErrorResponse = {
code: number;
message: string;
};
export type JsonErrorResponseContainer = {
url: string;
error: JsonErrorResponse;
};
export interface AppBootstrapOptions {
/**
* Fonts that should be preloaded, which means the initialization will not continue until the fonts are loaded.
* If no fonts are specified, the list of fonts to preload is automatically calculated from the available CSS "@font-face" definitions. This is the default.<br>
* To disable preloading entirely, set fonts to an empty array.
*/
fonts?: FontDescriptor[];
/**
* URL or multiple URLs pointing to a resource providing texts that will be available through {@link texts}.
*/
textsUrl?: string | string[];
/**
* URL pointing to a resource providing locale information processed by {@link locales}.
*/
localesUrl?: string;
/**
* URL pointing to a resource providing codes that will be available through {@link codes}.
*/
codesUrl?: string;
/**
* URL pointing to a resource providing permissions that will be available through {@link access}.
*
* @see PermissionCollectionModel
*/
permissionsUrl?: string;
/**
* URL pointing to a resource providing config properties that will be available through {@link config}.
*/
configUrl?: string | string[];
/**
* Custom functions that needs to be executed while bootstrapping.
* All custom and default bootstrappers need to finish successfully before the app will proceed with the initialization.
*/
bootstrappers?: (() => JQuery.Promise<void>)[];
}
export class App extends EventEmitter {
static addListener<K extends string & keyof EventMapOf<App>>(type: K, handler: EventHandler<(EventMapOf<App>)[K] & Event<App>>): EventListener {
let listener = {
type: type,
func: handler
};
if (instance) {
instance.events.addListener(listener);
} else {
listeners.push(listener);
}
return listener;
}
/**
* Adds a function that needs to be executed while bootstrapping.
* @see AppModel.bootstrappers
*/
static addBootstrapper(bootstrapper: () => JQuery.Promise<void>) {
if (bootstrappers.indexOf(bootstrapper) > -1) {
throw new Error('Bootstrapper is already registered.');
}
bootstrappers.push(bootstrapper);
}
/**
* The response of a successful ajax call with status 200 may contain a {@link JsonErrorResponse}.
* This method detects this case and throws an error containing the error details of the response together with the given request url.
* @throws JsonErrorResponseContainer
* @returns the given data as it is if it does not contain an error
*/
static handleJsonError(url: string, data: any): any {
if (data?.error) {
// The result may contain a json error (e.g. session timeout) -> abort processing
throw {
error: data.error,
url: url
};
}
return data;
}
static get(): App {
return instance;
}
protected static _set(newApp: App) {
if (instance) {
$.log.isWarnEnabled() && $.log.warn('Overwriting already existing App "' + instance + '" with "' + newApp + '".');
}
instance = newApp;
}
declare model: AppModel;
declare eventMap: AppEventMap;
declare self: App;
remote: boolean;
initialized: boolean;
sessions: Session[];
errorHandler: ErrorHandler;
version: string;
bootstrappers: (() => JQuery.Promise<void>)[];
protected _loadingTimeoutId: number;
constructor() {
super();
this.remote = false;
this.initialized = false;
this.sessions = [];
this.bootstrappers = [];
this._loadingTimeoutId = null;
// register the listeners which were added to scout before the app is created
listeners.forEach(listener => {
this.addListener(listener);
});
listeners = [];
App._set(this);
this.errorHandler = this._createErrorHandler();
}
/**
* Main initialization function.
*
* Calls {@link _prepare}, {@link _bootstrap} and {@link _init}.<br>
* At the initial phase the essential objects are initialized, those which are required for the next phases like logging and the object factory.<br>
* During the bootstrap phase additional scripts may get loaded required for a successful session startup.<br>
* The actual initialization does not get started before these bootstrap scripts are loaded.
*/
init(options?: InitModelOf<this>): JQuery.Promise<any> {
options = options || {} as InitModelOf<this>;
return this._prepare(options)
.then(this._bootstrap.bind(this, options.bootstrap))
.then(this._init.bind(this, options))
.then(this._initDone.bind(this, options))
.catch(this._fail.bind(this, options));
}
/**
* Initializes the logging framework and the object factory.
* This happens at the prepare phase because all these things should be available from the beginning.
*/
protected _prepare(options: AppModel): JQuery.Promise<any> {
return this._prepareLogging(options)
.done(() => {
this._prepareEssentials(options);
this._prepareDone(options);
});
}
protected _prepareEssentials(options: AppModel) {
ObjectFactory.get().init();
}
protected _prepareDone(options: AppModel) {
this.trigger('prepare', {
options: options
});
$.log.isDebugEnabled() && $.log.debug('App prepared');
}
protected _prepareLogging(options: AppModel): JQuery.Promise<JQuery> {
return logging.bootstrap();
}
/**
* Executes the bootstrappers.
*
* The actual session startup begins only when all promises of the bootstrappers are completed.
* This gives the possibility to dynamically load additional scripts or files which are mandatory for a successful application startup.
*/
protected _bootstrap(options: AppBootstrapOptions): JQuery.Promise<any> {
options = options || {};
options.bootstrappers = options.bootstrappers || [];
this.bootstrappers = [
...this._defaultBootstrappers(options),
...options.bootstrappers,
...this.bootstrappers,
...bootstrappers
].filter(bootstrapper => !!bootstrapper);
return $.promiseAll(this._doBootstrap())
.catch(this._bootstrapFail.bind(this, options))
.then(this._bootstrapDone.bind(this, options)); // BootstrapDone must only be executed if there are no boostrap errors
}
protected _defaultBootstrappers(options: AppBootstrapOptions): (() => JQuery.Promise<void>)[] {
return [
Device.get().bootstrap.bind(Device.get()),
fonts.bootstrap.bind(fonts, options.fonts),
locales.bootstrap.bind(locales, options.localesUrl),
texts.bootstrap.bind(texts, options.textsUrl),
codes.bootstrap.bind(codes, options.codesUrl),
access.bootstrap.bind(access, options.permissionsUrl),
config.bootstrap.bind(config, options.configUrl),
uiPreferences.bootstrap.bind(uiPreferences)
];
}
protected _doBootstrap(): JQuery.Promise<any>[] {
return this.bootstrappers.map(bootstrapper => bootstrapper());
}
protected _bootstrapDone(options: AppBootstrapOptions) {
webstorage.removeItemFromSessionStorage('scout:bootstrapErrorPageReload');
this.trigger('bootstrap', {
options: options
});
$.log.isDebugEnabled() && $.log.debug('App bootstrapped');
}
/**
* @param vararg may either be
* - an {@link AjaxError} for requests executed with {@link ajax} or {@link AjaxCall}
* - a {@link JQuery.jqXHR} for requests executed with {@link $.ajax}. The parameters `textStatus`, `errorThrown` and `requestOptions` are only set in this case.
* - a {@link JsonErrorResponseContainer} if a successful response contained a {@link JsonErrorResponse} which was transformed to an error (e.g. using {@link App.handleJsonError}).
*/
protected _bootstrapFail(options: AppBootstrapOptions, vararg: AjaxError | JQuery.jqXHR | JsonErrorResponseContainer, textStatus?: JQuery.Ajax.ErrorTextStatus, errorThrown?: string, requestOptions?: AjaxSettings): JQuery.Promise<any> {
$.log.isInfoEnabled() && $.log.info('App bootstrap failed');
// If one of the bootstrap ajax call fails due to a session timeout, the index.html is probably loaded from cache without asking the server for its validity.
// Normally, loading the index.html should already return a session timeout, but if it is loaded from the (back button) cache, no request will be done and therefore no timeout can be returned.
// The browser is allowed to display a page when navigating back without issuing a request even though cache-headers are set to must-revalidate.
// The only way to prevent it would be the no-store header but then pressing back would always reload the page and not only on a session timeout.
// Sometimes the JavaScript and therefore the ajax calls won't be executed in case the page is loaded from that cache, but sometimes they will nevertheless (we don't know the reasons).
// So, if it that happens, the server will either return a session timeout or a status 401 (Unauthorized) and the best thing we can do is to reload the page hoping a request for the index.html
// will be done which eventually will be forwarded to the login page.
// Additionally, requests may fail due to other various reasons, e.g. Chrome may report ERR_NETWORK_CHANGED or ERR_CERT_VERIFER_CHANGED.
// Since a page reload normally solves these issues as well, the reload is done on any error not just session timeouts.
let {url, message} = this._analyzeBootstrapError(vararg, textStatus, errorThrown, requestOptions);
$.log.isInfoEnabled() && $.log.info(`Error for resource ${url}. Reloading page...`);
if (webstorage.getItemFromSessionStorage('scout:bootstrapErrorPageReload')) {
// Prevent loop in case reloading did not solve the problem
$.log.isWarnEnabled() && $.log.warn('Prevented automatic reload, startup will likely fail.');
webstorage.removeItemFromSessionStorage('scout:bootstrapErrorPageReload');
throw new Error(`Bootstrap resource ${url} could not be loaded: ${message}`);
}
webstorage.setItemToSessionStorage('scout:bootstrapErrorPageReload', 'true');
scout.reloadPage();
// Make sure promise will be rejected with all original arguments so that it can be eventually handled by this._fail
// eslint-disable-next-line prefer-rest-params
let args = objects.argumentsToArray(arguments).slice(1);
return $.rejectedPromise(...args);
}
protected _analyzeBootstrapError(vararg: AjaxError | JQuery.jqXHR | JsonErrorResponseContainer, textStatus?: JQuery.Ajax.ErrorTextStatus, errorThrown?: string, requestOptions?: AjaxSettings) {
let ajaxError: AjaxError;
let jsonError: JsonErrorResponseContainer;
if (vararg instanceof AjaxError) {
ajaxError = vararg;
} else if ($.isJqXHR(vararg)) {
ajaxError = new AjaxError({jqXHR: vararg, textStatus: textStatus, errorThrown: errorThrown, requestOptions: requestOptions});
} else if (objects.isObject(vararg) && vararg.error) {
jsonError = vararg;
}
let url;
let message;
if (ajaxError) {
// AJAX error
// If a resource returns 401 (unauthorized) it is likely a session timeout.
// This may happen for REST resources or if a reverse proxy returned the response
url = ajaxError.requestOptions?.url || '';
message = `${ajaxError.errorDo?.message || ''}`;
let httpStatus = `${this.errorHandler.formatAjaxStatus(ajaxError.jqXHR, ajaxError.errorThrown)}`;
if (!message.includes(httpStatus)) {
message = `${message} [${this.errorHandler.formatAjaxStatus(ajaxError.jqXHR, ajaxError.errorThrown)}]`.trim();
}
} else if (jsonError) {
// Json based error
// Json errors (normally processed by Session.js) are returned with http status 200
url = jsonError.url;
message = `${jsonError.error.message} [Code ${jsonError.error.code}]`;
}
return {url, message};
}
/**
* Initializes a session for each html element with class '.scout' and stores them in scout.sessions.
*/
protected _init(options: InitModelOf<this>): JQuery.Promise<any> {
options = options || {} as InitModelOf<this>;
this.setLoading(true);
let compatibilityPromise = this._checkBrowserCompatibility(options);
if (compatibilityPromise) {
this.setLoading(false);
return compatibilityPromise.then(newOptions => this._init(newOptions));
}
this._initVersion(options);
this._prepareDOM();
this._installErrorHandler();
this._installGlobalMouseDownInterceptor();
this._installSyntheticActiveStateHandler();
this._ajaxSetup();
this._installExtensions();
this._triggerInstallExtensions();
return this._load(options)
.then(this._loadSessions.bind(this, options.session));
}
/**
* Maybe implemented to load data from a server before the desktop is created.
* @returns promise which is resolved after the loading is complete
*/
protected _load(options: AppModel): JQuery.Promise<any> {
return $.resolvedPromise();
}
protected _checkBrowserCompatibility(options: AppModel): JQuery.Promise<InitModelOf<this>> | null {
let device = Device.get();
$.log.isInfoEnabled() && $.log.info('Detected browser ' + device.browser + ' version ' + device.browserVersion);
if (!scout.nvl(options.checkBrowserCompatibility, true) || device.isSupportedBrowser()) {
// No check requested or browser is supported
return;
}
let deferred = $.Deferred();
let newOptions = objects.valueCopy(options);
newOptions.checkBrowserCompatibility = false;
$('.scout').each(function() {
let $entryPoint = $(this);
let $box = $entryPoint.appendDiv();
$box.load('unsupported-browser.html', () => {
$box.find('button').on('click', () => {
$box.remove();
deferred.resolve(newOptions);
});
});
});
return deferred.promise();
}
setLoading(loading: boolean) {
if (loading) {
this._loadingTimeoutId = setTimeout(() => {
// Don't start loading if a desktop is already rendered to prevent flickering when the loading will be set to false after app initialization finishes
if (!this.sessions.some(session => session.desktop && session.desktop.rendered)) {
this._renderLoading();
}
}, 200);
} else {
clearTimeout(this._loadingTimeoutId);
this._loadingTimeoutId = null;
this._removeLoading();
}
}
protected _renderLoading() {
let $body = $('body'),
$loadingRoot = $body.children('.application-loading-root');
if (!$loadingRoot.length) {
$loadingRoot = $body.appendDiv('application-loading-root')
.addClass('application-loading-root')
.fadeIn();
}
aria.role($loadingRoot, 'alert');
aria.screenReaderOnly($loadingRoot.appendDiv('text').attr('lang', 'en-US').text('Loading'));
this._renderLoadingElement($loadingRoot, 'application-loading01');
this._renderLoadingElement($loadingRoot, 'application-loading02');
this._renderLoadingElement($loadingRoot, 'application-loading03');
}
protected _renderLoadingElement($loadingRoot: JQuery, cssClass: string) {
if ($loadingRoot.children('.' + cssClass).length) {
return;
}
$loadingRoot.appendDiv(cssClass).hide()
.fadeIn();
}
protected _removeLoading() {
let $loadingRoot = $('body').children('.application-loading-root');
// the fadeout animation only contains a to-value and no from-value
// therefore set the current value to the elements style
$loadingRoot.css('opacity', $loadingRoot.css('opacity'));
// Add animation listener before adding the classes to ensure the listener will always be triggered even while debugging
$loadingRoot.oneAnimationEnd(() => $loadingRoot.remove());
if ($loadingRoot.css('opacity') === '1') {
$loadingRoot.addClass('fadeout and-more');
} else {
$loadingRoot.addClass('fadeout');
}
if (!Device.get().supportsCssAnimation()) {
// fallback for old browsers that do not support the animation-end event
$loadingRoot.remove();
}
}
protected _initVersion(options: AppModel) {
this.version = scout.nvl(
this.version,
options.version,
$('scout-version').data('value'));
}
protected _prepareDOM() {
scout.prepareDOM(document);
}
protected _installGlobalMouseDownInterceptor() {
scout.installGlobalMouseDownInterceptor(document);
}
protected _installSyntheticActiveStateHandler() {
scout.installSyntheticActiveStateHandler(document);
}
/**
* Installs a global error handler.
*
* Note: we do not install an error handler on popup-windows because everything is controlled by the main-window
* so exceptions will also occur in that window. This also means, the fatal message-box will be displayed in the
* main-window, even when a popup-window is opened and active.
*
* Caution: The error.stack doesn't look the same in different browsers. Chrome for instance puts the error message
* on the first line of the stack. Firefox does only contain the stack lines, without the message, but in return
* the stack trace is much longer :)
*/
protected _installErrorHandler() {
window.onerror = this.errorHandler.windowErrorHandler;
// FIXME bsh, cgu: use ErrorHandler to handle unhandled promise rejections. Just replacing jQuery.Deferred.exceptionHook(error, stack) does not work
// because it is called on every exception and not only on unhandled.
// https://developer.mozilla.org/en-US/docs/Web/API/Window/unhandledrejection_event would be exactly what we need, but jQuery does not support it.
// Bluebird has a polyfill -> can it be ported to jQuery?
}
protected _createErrorHandler(opts?: InitModelOf<ErrorHandler>): ErrorHandler {
opts = $.extend({}, opts);
return scout.create(ErrorHandler, opts);
}
/**
* Uses the object returned by {@link _ajaxDefaults} to set up ajax. The values in that object are used as default values for every ajax call.
*/
protected _ajaxSetup() {
let ajaxDefaults = this._ajaxDefaults();
if (ajaxDefaults) {
$.ajaxSetup(ajaxDefaults);
}
}
/**
* Returns the defaults for every ajax call. You may override it to set custom defaults.
* By default {@link _beforeAjaxCall} is assigned to the beforeSend method.
*
* Note: This will affect every ajax call, so use it with care! See also the advice on https://api.jquery.com/jquery.ajaxsetup/.
*/
protected _ajaxDefaults(): AjaxSettings {
return {
beforeSend: this._beforeAjaxCall.bind(this)
};
}
/**
* Called before every ajax call. Sets the header X-Scout-Correlation-Id.
*
* Maybe overridden to set custom headers or to execute other code which should run before an ajax call.
*/
protected _beforeAjaxCall(request: JQuery.jqXHR, settings: AjaxSettings) {
request.setRequestHeader('X-Scout-Correlation-Id', numbers.correlationId());
request.setRequestHeader('X-Requested-With', 'XMLHttpRequest'); // explicitly add here because jQuery only adds it automatically if it is no crossDomain request
if (this.sessions[0] && this.sessions[0].ready) {
request.setRequestHeader('Accept-Language', this.sessions[0].locale.languageTag);
}
}
protected _loadSessions(options: SessionModel): JQuery.Promise<any> {
options = options || {};
let promises = [];
$('.scout').each((i, elem) => {
let $entryPoint = $(elem);
options.portletPartId = options.portletPartId || $entryPoint.data('partid') || '0';
let promise = this._loadSession($entryPoint, options);
promises.push(promise);
});
return $.promiseAll(promises);
}
/**
* @returns promise which is resolved when the session is ready
*/
protected _loadSession($entryPoint: JQuery, model: Omit<SessionModel, '$entryPoint'>): JQuery.Promise<any> {
let sessionModel: InitModelOf<Session> = {$entryPoint: $entryPoint};
let options = $.extend({}, model, sessionModel);
options.locale = options.locale || this._loadLocale();
let session = this._createSession(options);
this.sessions.push(session);
// TODO [7.0] cgu improve this, start must not be executed because it currently does a server request
let desktop = this._createDesktop(session.root);
this._triggerDesktopReady(desktop);
session.render(() => {
session._renderDesktop();
// Ensure layout is valid (explicitly layout immediately and don't wait for setTimeout to run to make layouting invisible to the user)
session.layoutValidator.validate();
session.focusManager.validateFocus();
session.ready = true;
this._triggerSessionReady(session);
$.log.isInfoEnabled() && $.log.info('Session initialized. Detected ' + Device.get());
});
return $.resolvedPromise();
}
/** @internal */
_triggerDesktopReady(desktop: Desktop) {
this.trigger('desktopReady', {
desktop: desktop
});
}
/** @internal */
_triggerSessionReady(session: Session) {
this.trigger('sessionReady', {
session: session
});
}
protected _createSession(options: InitModelOf<Session>): Session {
return scout.create(Session, options);
}
protected _createDesktop(parent: Widget): Desktop {
return scout.create(Desktop, {
parent: parent
});
}
/**
* @returns the locale to be used when no locale is provided as session option. By default, the navigators locale is used.
*/
protected _loadLocale(): Locale {
return locales.getNavigatorLocale();
}
protected _initDone(options: AppModel) {
this.initialized = true;
this.setLoading(false);
this.trigger('init', {
options: options
});
$.log.isInfoEnabled() && $.log.info('App initialized');
}
protected _fail(options: AppModel, error: any, ...args: any[]): JQuery.Promise<any> {
$.log.error('App initialization failed.');
this.setLoading(false);
let promises = [];
if (webstorage.getItemFromSessionStorage('scout:bootstrapErrorPageReload')) {
// Do not append a message, page is about to be reloaded
} else if (this.sessions.length === 0) {
promises.push(this.errorHandler.handle(error, ...args)
.then(errorInfo => {
this._appendStartupError($('body'), errorInfo);
}));
} else {
// Session.js may already display a fatal message box
// -> don't handle the error again and display multiple error messages
this.sessions
.filter(session => !session.ready && !session.isFatalMessageShown())
.forEach(session => {
session.$entryPoint.empty();
const errorHandler = this._createErrorHandler({session: session});
const promise = errorHandler.analyzeError(error).then(info => {
info.showAsFatalError = true;
return errorHandler.handleErrorInfo(info);
});
promises.push(promise);
});
}
this.trigger('fail', {error});
// Reject with original rejection arguments
return $.promiseAll(promises).then(errorInfo => $.rejectedPromise(error, ...args));
}
protected _appendStartupError($parent: JQuery, errorInfo: ErrorInfo) {
let $error = $parent.appendDiv('startup-error');
$error.appendDiv('startup-error-title').text('The application could not be started');
let message = errorInfo.message;
if (errorInfo?.httpStatus) {
message = this.errorHandler.getMessageBodyForHttpStatus(errorInfo?.httpStatus);
}
if (message) {
$error.appendDiv('startup-error-message').text(message);
}
}
/**
* Override this method to install extensions to Scout objects. Since the extension feature replaces functions
* on the prototype of the Scout objects you must apply 'function patches' to Scout framework or other code before
* the extensions are installed.
*
* The default implementation does nothing.
*/
protected _installExtensions() {
// NOP
}
/**
* @see AppEventMap#installExtensions
*/
protected _triggerInstallExtensions() {
this.trigger('installExtensions');
}
}