@ima/core
Version:
IMA.js framework for isomorphic javascript application
319 lines (318 loc) • 11.7 kB
JavaScript
/* @if server **
export class ClientRouter {};
/* @else */ "use strict";
Object.defineProperty(exports, "__esModule", {
value: true
});
Object.defineProperty(exports, "ClientRouter", {
enumerable: true,
get: function() {
return ClientRouter;
}
});
const _AbstractRouter = require("./AbstractRouter");
const _ActionTypes = require("./ActionTypes");
const _RouteFactory = require("./RouteFactory");
const _Dispatcher = require("../event/Dispatcher");
const _PageManager = require("../page/manager/PageManager");
const _RendererEvents = require("../page/renderer/RendererEvents");
const _Window = require("../window/Window");
/**
* Names of the DOM events the router responds to.
*/ const Events = Object.freeze({
/**
* Name of the event produced when the user clicks the page using the
* mouse, or touches the page and the touch event is not stopped.
*/ CLICK: 'click',
/**
* Name of the event fired when the user navigates back in the history.
*/ POP_STATE: 'popstate'
});
/**
* The number used as the index of the mouse left button in DOM
* `MouseEvent`s.
*/ const MOUSE_LEFT_BUTTON = 0;
class ClientRouter extends _AbstractRouter.AbstractRouter {
_window;
_boundHandleClick = (event)=>this._handleClick(event);
_boundHandlePopState = (event)=>this._handlePopState(event);
/**
* Mounted promise to prevent routing until app is fully mounted.
*/ _mountedPromise = null;
static get $dependencies() {
return [
_PageManager.PageManager,
_RouteFactory.RouteFactory,
_Dispatcher.Dispatcher,
_Window.Window,
'?$Settings.$Router'
];
}
/**
* Initializes the client-side router.
*
* @param pageManager The page manager handling UI rendering,
* and transitions between pages if at the client side.
* @param factory Factory for routes.
* @param dispatcher Dispatcher fires events to app.
* @param window The current global client-side APIs provider.
* @param settings $Router settings.
*/ constructor(pageManager, factory, dispatcher, window1, settings){
super(pageManager, factory, dispatcher, settings);
/**
* Helper for accessing the native client-side APIs.
*/ this._window = window1;
}
/**
* @inheritDoc
*/ init(config) {
super.init(config);
this._host = config.$Host || this._window.getHost();
this._dispatcher.listen(_RendererEvents.RendererEvents.MOUNTED, this.#handleMounted, this);
return this;
}
/**
* @inheritDoc
*/ getUrl() {
return this._window.getUrl();
}
/**
* @inheritDoc
*/ getPath() {
return this._extractRoutePath(this._window.getPath());
}
/**
* @inheritDoc
*/ listen() {
const nativeWindow = this._window.getWindow();
this._window.bindEventListener(nativeWindow, Events.POP_STATE, this._boundHandlePopState);
this._window.bindEventListener(nativeWindow, Events.CLICK, this._boundHandleClick);
return this;
}
/**
* @inheritDoc
*/ unlisten() {
const nativeWindow = this._window.getWindow();
this._window.unbindEventListener(nativeWindow, Events.POP_STATE, this._boundHandlePopState);
this._window.unbindEventListener(nativeWindow, Events.CLICK, this._boundHandleClick);
return this;
}
/**
* @inheritDoc
*/ redirect(url, options, action, locals) {
if (this._isSameDomain(url) && this.#isSPARouted(url, action)) {
let path = url.replace(this.getDomain(), '');
path = this._extractRoutePath(path);
this.route(path, options, {
type: _ActionTypes.ActionTypes.REDIRECT,
event: undefined,
...action,
url
}, locals ?? {});
} else {
this._window.redirect(url);
}
}
/**
* @inheritDoc
*/ async route(path, options, action, locals) {
return super.route(path, options, {
event: undefined,
type: _ActionTypes.ActionTypes.REDIRECT,
...action,
url: this.getBaseUrl() + path
}, locals).catch((error)=>this.handleError({
error
}, {}, locals)).then((params)=>{
// Hide error overlay
if (!params?.error && $Debug && window.__IMA_HMR?.emitter) {
window.__IMA_HMR.emitter.emit('clear');
}
return params;
}).catch((error)=>{
this._handleFatalError(error);
});
}
/**
* @inheritDoc
*/ handleError(params, options, locals) {
options = options ?? {};
const error = params.error;
if ($Debug) {
console.error(error);
// Show error overlay
if (window.__IMA_HMR?.emitter && !this.isRedirection(error)) {
window.__IMA_HMR.emitter.emit('error', {
error: params.error
});
return Promise.reject({
content: null,
status: options.httpStatus || 500,
error: params.error
});
}
}
if (this.isClientError(error)) {
return this.handleNotFound(params, {}, locals);
}
if (this.isRedirection(error)) {
const errorParams = error.getParams();
options.httpStatus = error.getHttpStatus();
const action = {
event: undefined,
type: _ActionTypes.ActionTypes.REDIRECT,
url: errorParams.url
};
this.redirect(errorParams.url, Object.assign(options, errorParams.options), Object.assign(action, errorParams.action), locals);
return Promise.resolve({
content: null,
status: options.httpStatus,
error: params.error
});
}
return super.handleError(params, options, locals).catch((error)=>{
this._handleFatalError(error);
});
}
/**
* @inheritDoc
*/ handleNotFound(params, options = {}, locals = {}) {
return super.handleNotFound(params, options, locals).catch((error)=>{
return this.handleError({
error
}, {}, locals);
});
}
/**
* Handle a fatal error application state. IMA handle fatal error when IMA
* handle error.
*
* @param error
*/ _handleFatalError(error) {
if ($IMA && typeof $IMA.fatalErrorHandler === 'function') {
$IMA.fatalErrorHandler(error);
} else {
if ($Debug) {
console.warn('You must implement $IMA.fatalErrorHandler in ' + 'services.js');
}
}
}
/**
* Handles a popstate event. The method is performed when the active history
* entry changes.
*
* The navigation will be handled by the router if the event state is defined
* and event is not `defaultPrevented`.
*
* @param event The popstate event.
*/ _handlePopState(event) {
if (event.state && !event.defaultPrevented) {
this.route(this.getPath(), {}, {
type: _ActionTypes.ActionTypes.POP_STATE,
event,
url: this.getUrl()
});
}
}
/**
* Handles a click event. The method performs navigation to the target
* location of the anchor (if it has one).
*
* The navigation will be handled by the router if the protocol and domain
* of the anchor's target location (href) is the same as the current,
* otherwise the method results in a hard redirect.
*
* @param event The click event.
*/ _handleClick(event) {
const target = event.target || event.srcElement;
const anchorElement = this._getAnchorElement(target);
if (!anchorElement || typeof anchorElement.href !== 'string') {
return;
}
const targetAttribute = anchorElement.getAttribute('target');
const anchorHref = anchorElement.href;
const isDefinedTargetHref = anchorHref !== undefined && anchorHref !== null;
const isSetTarget = targetAttribute !== null && targetAttribute !== '_self';
const isLeftButton = event.button === MOUSE_LEFT_BUTTON;
const isCtrlPlusLeftButton = event.ctrlKey && isLeftButton;
const isCMDPlusLeftButton = event.metaKey && isLeftButton;
const isSameDomain = this._isSameDomain(anchorHref);
const isHashLink = this._isHashLink(anchorHref);
const isLinkPrevented = event.defaultPrevented;
const routeAction = {
type: _ActionTypes.ActionTypes.CLICK,
event,
url: anchorHref
};
const isSPARouted = this.#isSPARouted(anchorHref, routeAction);
if (!isDefinedTargetHref || isSetTarget || !isLeftButton || !isSameDomain || !isSPARouted || isHashLink || isCtrlPlusLeftButton || isCMDPlusLeftButton || isLinkPrevented) {
return;
}
event.preventDefault();
this.redirect(anchorHref, {}, {
type: _ActionTypes.ActionTypes.CLICK,
event,
url: anchorHref
});
}
/**
* The method determines whether an anchor element or a child of an anchor
* element has been clicked, and if it was, the method returns anchor
* element else null.
*
* @param {Node} target
* @return {?Node}
*/ _getAnchorElement(target) {
// eslint-disable-next-line @typescript-eslint/no-this-alias
const self = this;
while(target && !hasReachedAnchor(target)){
target = target.parentNode;
}
function hasReachedAnchor(nodeElement) {
return nodeElement.parentNode && nodeElement !== self._window.getBody() && nodeElement.href !== undefined && nodeElement.href !== null;
}
return target;
}
/**
* Tests whether the provided target URL contains only an update of the
* hash fragment of the current URL.
*
* @param targetUrl The target URL.
* @return `true` if the navigation to target URL would
* result only in updating the hash fragment of the current URL.
*/ _isHashLink(targetUrl) {
if (targetUrl.indexOf('#') === -1) {
return false;
}
const currentUrl = this._window.getUrl();
const trimmedCurrentUrl = currentUrl.indexOf('#') === -1 ? currentUrl : currentUrl.substring(0, currentUrl.indexOf('#'));
const trimmedTargetUrl = targetUrl.substring(0, targetUrl.indexOf('#'));
return trimmedTargetUrl === trimmedCurrentUrl;
}
/**
* Tests whether the the protocol and domain of the provided URL are the
* same as the current.
*
* @param [url=''] The URL.
* @return `true` if the protocol and domain of the
* provided URL are the same as the current.
*/ _isSameDomain(url = '') {
return new RegExp('^' + this.getBaseUrl()).test(url);
}
/**
* This option allows user to override how certain URLs are handled
* during SPA (client) routing. This adds possibility to opt-out
* of SPA routing for specific URLs and let them be handled by browser
* natively.
*
* @param [url=''] The URL.
* @return `true` if url routing should be handled by IMA.
*/ #isSPARouted(url = '', action) {
return this._isSPARouted?.(url, action) ?? true;
}
#handleMounted() {
this._mountedPromise?.resolve();
this._dispatcher.unlisten(_RendererEvents.RendererEvents.MOUNTED, this.#handleMounted, this);
}
} // @endif
//# sourceMappingURL=ClientRouter.js.map