UNPKG

@ima/core

Version:

IMA.js framework for isomorphic javascript application

498 lines (497 loc) 17.4 kB
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