@ima/core
Version:
IMA.js framework for isomorphic javascript application
498 lines (497 loc) • 17.4 kB
JavaScript
import { autoYield } from '@esmj/task';
import { AbstractRoute } from './AbstractRoute';
import { ActionTypes } from './ActionTypes';
import { RouteNames } from './RouteNames';
import { Router } from './Router';
import { RouterEvents } from './RouterEvents';
import { IMAError } from '../error/Error';
import { GenericError } from '../error/GenericError';
import { HttpStatusCode } from '../http/HttpStatusCode';
/**
* The basic implementation of the {@link Router} interface, providing the
* common or default functionality for parts of the API.
*/ export class AbstractRouter extends Router {
/**
* The page manager handling UI rendering, and transitions between
* pages if at the client side.
*/ _pageManager;
/**
* Factory for routes.
*/ _factory;
/**
* Dispatcher fires events to app.
*/ _dispatcher;
/**
* The current protocol used to access the application, terminated by a
* colon (for example `https:`).
*/ _protocol = '';
/**
* The application's host.
*/ _host = '';
/**
* The URL path pointing to the application's root.
*/ _root = '';
/**
* The URL path fragment used as a suffix to the `_root` field
* that specifies the current language.
*/ _languagePartPath = '';
/**
* Storage of all known routes and middlewares. The key are their names.
*/ _routeHandlers = new Map();
/**
* Middleware ID counter which is used to auto-generate unique middleware
* names when adding them to routeHandlers map.
*/ _currentMiddlewareId = 0;
_currentlyRoutedPath = '';
_middlewareTimeout;
_isSPARouted;
/**
* Initializes the 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.
* @example
* router.link('article', {articleId: 1});
* @example
* router.redirect('http://www.example.com/web');
* @example
* router.add(
* 'home',
* '/',
* ns.app.page.home.Controller,
* ns.app.page.home.View,
* {
* onlyUpdate: false,
* autoScroll: true,
* documentView: null,
* managedRootView: null,
* viewAdapter: null
* }
* );
*/ constructor(pageManager, factory, dispatcher, settings){
super();
this._pageManager = pageManager;
this._factory = factory;
this._dispatcher = dispatcher;
this._middlewareTimeout = 30000;
// ima@20 - Remove in IMA.js 20, this is for backwards compatibility
if (typeof settings === 'number') {
this._middlewareTimeout = settings;
} else {
this._middlewareTimeout = settings?.middlewareTimeout ?? this._middlewareTimeout;
this._isSPARouted = settings?.isSPARouted;
}
}
/**
* @inheritDoc
*/ init(config) {
this._protocol = config.$Protocol || '';
this._root = config.$Root || '';
this._languagePartPath = config.$LanguagePartPath || '';
this._host = config.$Host;
this._currentlyRoutedPath = this.getPath();
}
/**
* @inheritDoc
*/ add(name, pathExpression, controller, view, options) {
if (this._routeHandlers.has(name)) {
throw new GenericError(`ima.core.router.AbstractRouter.add: The route with name ${name} ` + `is already defined`, {
name,
pathExpression,
options
});
}
const factory = this._factory;
const route = factory.createRoute(name, pathExpression, controller, view, options);
this._routeHandlers.set(name, route);
return this;
}
/**
* @inheritDoc
*/ use(middleware) {
this._routeHandlers.set(`middleware-${this._currentMiddlewareId++}`, middleware);
return this;
}
/**
* @inheritDoc
*/ remove(name) {
this._routeHandlers.delete(name);
return this;
}
/**
* @inheritDoc
*/ getRouteHandler(name) {
return this._routeHandlers.get(name);
}
/**
* @inheritDoc
*/ getPath() {
throw new GenericError('The getPath() method is abstract and must be overridden.');
}
/**
* @inheritDoc
*/ getUrl() {
return this.getBaseUrl() + this.getPath();
}
/**
* @inheritDoc
*/ getBaseUrl() {
return this.getDomain() + this._root + this._languagePartPath;
}
/**
* @inheritDoc
*/ getDomain() {
return this._protocol + '//' + this._host;
}
/**
* @inheritDoc
*/ getHost() {
return this._host;
}
/**
* @inheritDoc
*/ getProtocol() {
return this._protocol;
}
/**
* @inheritDoc
*/ getCurrentRouteInfo() {
const path = this.getPath();
let { route } = this.getRouteHandlersByPath(path);
if (!route) {
const notFoundRoute = this._routeHandlers.get(RouteNames.NOT_FOUND);
if (!notFoundRoute || !(notFoundRoute instanceof AbstractRoute)) {
throw new GenericError(`ima.core.router.AbstractRouter.getCurrentRouteInfo: The route ` + `for path ${path} is not defined, or it's not instance of AbstractRoute.`, {
route: notFoundRoute,
path
});
}
route = notFoundRoute;
}
const params = route.extractParameters(path, this.getBaseUrl());
return {
route,
params,
path
};
}
/**
* @inheritDoc
*/ getRouteHandlers() {
return this._routeHandlers;
}
/**
* @inheritDoc
* @abstract
*/ listen() {
throw new GenericError('The listen() method is abstract and must be overridden.');
}
/**
* @inheritDoc
*/ unlisten() {
throw new GenericError('The unlisten() method is abstract and must be overridden.');
}
/**
* @inheritDoc
*/ redirect(url, options, action, locals) {
throw new GenericError('The redirect() method is abstract and must be overridden.', {
url,
options,
action,
locals
});
}
/**
* @inheritDoc
*/ link(routeName, params) {
const route = this._routeHandlers.get(routeName);
if (!route) {
throw new GenericError(`ima.core.router.AbstractRouter:link has undefined route with ` + `name ${routeName}. Add new route with that name.`, {
routeName,
params
});
}
if (!(route instanceof AbstractRoute)) {
throw new GenericError(`ima.core.router.AbstractRouter:link Unable to create link to ${routeName}, ` + `since it's likely a middleware.`, {
routeName,
params,
route
});
}
return this.getBaseUrl() + route.toPath(params);
}
/**
* @inheritDoc
*/ async route(path, options, action, locals) {
this._currentlyRoutedPath = path;
let params = {};
const { route, middlewares } = this.getRouteHandlersByPath(path);
locals = {
...locals,
action,
route
};
if (!route) {
params.error = new GenericError(`Route for path '${path}' is not configured.`, {
status: 404
});
return this.handleNotFound(params, {}, locals);
}
await this._runMiddlewares(middlewares, params, locals);
params = {
...params,
...route.extractParameters(path, this.getBaseUrl())
};
await this._runMiddlewares(route.getOptions().middlewares, params, locals);
return this._handle(route, params, options, action);
}
/**
* @inheritDoc
*/ async handleError(params, options, locals) {
const errorRoute = this._routeHandlers.get(RouteNames.ERROR);
if (!errorRoute) {
throw new GenericError(`ima.core.router.AbstractRouter:handleError cannot process the ` + `error because no error page route has been configured. Add ` + `a new route named '${RouteNames.ERROR}'.`, params);
}
if (!(errorRoute instanceof AbstractRoute)) {
throw new GenericError(`ima.core.router.AbstractRouter:handleError '${RouteNames.ERROR}' is,` + ` not instance of AbstractRoute, please check your configuration.`, {
errorRoute,
params,
options,
locals
});
}
params = this.#addParamsFromOriginalRoute(params);
const action = {
url: this.getUrl(),
type: ActionTypes.ERROR
};
locals = {
...locals,
action,
route: errorRoute
};
await this._runMiddlewares([
...this._getMiddlewaresForRoute(RouteNames.ERROR),
...errorRoute.getOptions().middlewares
], params, locals);
return this._handle(errorRoute, params, options, action);
}
/**
* @inheritDoc
*/ async handleNotFound(params, options, locals) {
const notFoundRoute = this._routeHandlers.get(RouteNames.NOT_FOUND);
if (!notFoundRoute) {
throw new GenericError(`ima.core.router.AbstractRouter:handleNotFound cannot processes ` + `a non-matching route because no not found page route has ` + `been configured. Add new route named ` + `'${RouteNames.NOT_FOUND}'.`, {
...params,
status: HttpStatusCode.TIMEOUT
});
}
if (!(notFoundRoute instanceof AbstractRoute)) {
throw new GenericError(`ima.core.router.AbstractRouter:handleNotFound '${RouteNames.NOT_FOUND}' is,` + ` not instance of AbstractRoute, please check your configuration.`, {
notFoundRoute,
params,
options,
locals
});
}
params = this.#addParamsFromOriginalRoute(params);
const action = {
url: this.getBaseUrl() + this._getCurrentlyRoutedPath(),
type: ActionTypes.ERROR
};
locals = {
...locals,
action,
route: notFoundRoute
};
await this._runMiddlewares([
...this._getMiddlewaresForRoute(RouteNames.NOT_FOUND),
...notFoundRoute.getOptions().middlewares
], params, locals);
return this._handle(notFoundRoute, params, options, action);
}
/**
* @inheritDoc
*/ isClientError(reason) {
return reason instanceof IMAError && reason.isClientError();
}
/**
* @inheritDoc
*/ isRedirection(reason) {
return reason instanceof IMAError && reason.isRedirection();
}
/**
* Strips the URL path part that points to the application's root (base
* URL) from the provided path.
*
* @protected
* @param path Relative or absolute URL path.
* @return URL path relative to the application's base URL.
*/ _extractRoutePath(path) {
return path.replace(this._root + this._languagePartPath, '');
}
/**
* Handles the provided route and parameters by initializing the route's
* controller and rendering its state via the route's view.
*
* The result is then sent to the client if used at the server side, or
* displayed if used as the client side.
*
* @param route The route that should have its
* associated controller rendered via the associated view.
* @param params Parameters extracted from
* the URL path and query.
* @param options The options overrides route options defined in the
* `routes.js` configuration file.
* @param action An action
* object describing what triggered this routing.
* @return A promise that resolves when the
* page is rendered and the result is sent to the client, or
* displayed if used at the client side.
*/ async _handle(route, params, options, action) {
const routeOptions = Object.assign({}, route.getOptions(), options);
const eventData = {
route,
params,
path: this._getCurrentlyRoutedPath(),
options: routeOptions,
action
};
await autoYield();
/**
* Call pre-manage to cancel/property kill previously managed
* route handler.
*/ await this._pageManager.preManage();
this._dispatcher.fire(RouterEvents.BEFORE_HANDLE_ROUTE, eventData);
return this._pageManager.manage({
route,
options: routeOptions,
params,
action
}).then(async (response)=>{
response = response || {};
if (params?.error instanceof Error) {
response.error = params.error;
}
await autoYield();
this._dispatcher.fire(RouterEvents.AFTER_HANDLE_ROUTE, {
...eventData,
response
});
return response;
}).finally(async ()=>{
await autoYield();
return this._pageManager.postManage();
});
}
/**
* Returns the route matching the provided URL path part (the path may
* contain a query) and all middlewares preceding this route definition.
*
* @param path The URL path.
* @return The route
* matching the path and middlewares preceding it or `{}`
* (empty object) if no such route exists.
*/ getRouteHandlersByPath(path) {
const middlewares = [];
for (const routeHandler of this._routeHandlers.values()){
if (!(routeHandler instanceof AbstractRoute)) {
middlewares.push(routeHandler);
continue;
}
if (routeHandler.matches(path)) {
return {
route: routeHandler,
middlewares
};
}
}
return {
middlewares
};
}
/**
* Returns middlewares preceding given route name.
*/ _getMiddlewaresForRoute(routeName) {
const middlewares = [];
for (const routeHandler of this._routeHandlers.values()){
if (!(routeHandler instanceof AbstractRoute)) {
middlewares.push(routeHandler);
continue;
}
if (routeHandler.getName() === routeName) {
return middlewares;
}
}
return middlewares;
}
/**
* Returns path that is stored in private property when a `route`
* method is called.
*/ _getCurrentlyRoutedPath() {
return this._currentlyRoutedPath;
}
/**
* Runs provided middlewares in sequence.
*
* @param middlewares Array of middlewares.
* @param params Router params that can be
* mutated by middlewares.
* @param locals The locals param is used to pass local data
* between middlewares.
*/ async _runMiddlewares(middlewares, params, locals) {
if (!Array.isArray(middlewares)) {
return;
}
// eslint-disable-next-line no-async-promise-executor
return new Promise(async (resolve, reject)=>{
const rejectTimeout = setTimeout(()=>{
reject(new GenericError('Middleware execution timeout, check your middlewares for any unresolved time consuming promises.' + ` All middlewares should finish execution within ${this._middlewareTimeout}ms timeframe.`));
}, this._middlewareTimeout);
for (const middleware of middlewares){
try {
await autoYield();
/**
* When middleware uses next() function we await in indefinitely
* until the function is called. Otherwise we just await the middleware
* async function.
*/ const result = await (middleware.length === 3 ? new Promise((resolve)=>middleware(params, locals, resolve)) : middleware(params, locals));
locals = {
...locals,
...result
};
} catch (error) {
reject(error);
}
}
clearTimeout(rejectTimeout);
resolve();
});
}
/**
* Obtains original route that was handled before not-found / error route
* and assigns its params to current params
*
* @param params Route params for not-found or
* error page
* @returns Provided params merged with params
* from original route
*/ #addParamsFromOriginalRoute(params) {
const originalPath = this._getCurrentlyRoutedPath();
const { route } = this.getRouteHandlersByPath(originalPath);
if (!route) {
// try to at least extract query string params from path
return {
...Object.fromEntries(new URL(this.getUrl()).searchParams),
...params
};
}
return {
...route.extractParameters(originalPath, this.getBaseUrl()),
...params
};
}
}
//# sourceMappingURL=AbstractRouter.js.map