diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 57472ce5..7e899533 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -24,9 +24,12 @@ At the same time in parallel terminal you need to run `npm run start:no-apps` To make sure that all ILC components play well together we use E2E tests. We use our Demo applications as test micro frontends so it also gives us ability to make sure that we don't break backward compatibility. -In order to run tests - change your current directory to `./e2e` and launch one of the following commands: - -* Default mode: `npm start` -* Verbose mode: `npm run start:verbose` -* Verbose mode with Browser UI visible: `npm run start:verbose:ui` +In order to run tests: + +* Build ILC & Registry by running `npm run build` +* Change your current directory to `./e2e` +* Launch one of the following commands: + * Default mode: `npm start` + * Verbose mode: `npm run start:verbose` + * Verbose mode with Browser UI visible: `npm run start:verbose:ui` diff --git a/docs/global_errors_handling.md b/docs/global_errors_handling.md index 17f53cfe..1c731dea 100644 --- a/docs/global_errors_handling.md +++ b/docs/global_errors_handling.md @@ -1,10 +1,8 @@ # Global error handling with ILC -**Status codes:** βœ… - implemented; 🎯 - to be implemented soon - Introduction of Micro Frontends architecture brings new challenges to the board that need to be solved. One of those challenges is handling of the 5XX & 4XX error pages. As your web page now is composed from several fragments you can't use the same approach you used to have with monolithic frontend. This is the moment when ILC comes to the stage. -βœ… Types of the apps +Types of the apps ----------------- - **Primary** - app which is "responsible" for the current route, usually this app renders main consumable page content. At a particular route you may have only a single primary app. During SSR this app will supply HTTP response code & headers in the response to the client. @@ -13,7 +11,7 @@ Introduction of Micro Frontends architecture brings new challenges to the board - **Regular** - app which provides supplementary functionality on the page. Page can be effectively consumed by users w/o such apps rendered. Example: footer, ads, promo banners -βœ… "5XX" errors (unexpected errors) +"5XX" errors (unexpected errors) -------------------------------- Handling of the unexpected errors varies between SSR & CSR (as well it depends on the type of the app) due to the natural differences between server & client. So keep an eye on it. @@ -34,18 +32,18 @@ It's also worth saying that there is no such thing as 5XX error at the client si - _CSR:_ any error caught by ILC errorHander or errors during loading/mounting - will be logged w/o any further actions -βœ…/🎯 "404" error (Not found) +"404" error (Not found) ----------------------- This is a very common error in web applications & usually it means that we want to show some message to the user that requested resource was not found on the server. With the introduction of the micro frontends & global ILC router things become a little bit trickier. It means that we may catch this error at 2 different routing layers: -- βœ… **ILC Router** – if there is no route configured in Registry for requested URL - it will trigger an appearance of the special 404 route ([Namecheap example](https://www.namecheap.com/status/404.aspx)). This logic will work seamlessly between SSR & CSR. +- **ILC Router** – if there is no route configured in Registry for requested URL - it will trigger an appearance of the special 404 route ([Namecheap example](https://www.namecheap.com/status/404.aspx)). This logic will work seamlessly between SSR & CSR. Ex: `/nosuchpath` url was requested. Or try -- 🎯 **App Router** – (_only for primary apps_) there also may be cases when we have a route configured in Registry, however the app which is responsible for the page - fails to find the requested resource by it's ID. Imagine that you're trying to open a page of the non-existing product. Here there are 2 ways for the app to handle this case: +- **App Router** – (_only for primary apps_) there also may be cases when we have a route configured in Registry, however the app which is responsible for the page - fails to find the requested resource by it's ID. Imagine that you're trying to open a page of the non-existing product. Here there are 2 ways for the app to handle this case: - _Fallback to global 404 page_ - recommended approach, in this case app's content will be abandoned and users will see content of the special 404 route. To do this at CSR/SSR do the following: - _SSR:_ respond with 404 HTTP code - _CSR:_ trigger `ilc:404` event on window with the following parameters: @@ -57,7 +55,7 @@ With the introduction of the micro frontends & global ILC router things become a -🎯 "401" & "403" errors (Unauthorized / Forbidden) +"401" & "403" errors (Unauthorized / Forbidden) ----------------------- -TBD, currently ILC has no special logic in place \ No newline at end of file +Π‘urrently ILC has no special logic in place. May be reconsidered in the future. \ No newline at end of file diff --git a/e2e/spec/404.spec.e2e.ts b/e2e/spec/404.spec.e2e.ts index f777cde2..5f762e76 100644 --- a/e2e/spec/404.spec.e2e.ts +++ b/e2e/spec/404.spec.e2e.ts @@ -1,6 +1,83 @@ -Feature('404'); +Feature('404 error handling'); -Scenario('should show 404 error page', (I) => { +//region 404 page for non-existing ILC route +Scenario('Renders (SSR) global 404 page for non-existing ILC route', (I) => { I.amOnPage('/nonexistent-path'); I.waitForText('404 not found', 10, 'body > div#body'); }); +Scenario('Renders (CSR) global 404 page for non-existing ILC route', (I, peoplePage) => { + const notFoundPageLink = '#navbar a[href="/nosuchpath"]'; + + I.amOnPage(peoplePage.peopleUrl); + I.waitForElement(notFoundPageLink, 30); + I.click(notFoundPageLink); + I.waitForText('404 not found', 10, 'body > div#body'); +}); +//endregion 404 page for non-existing ILC route + +//region 404 page for non-existing News app route +Scenario('Renders (SSR) global 404 page for non-existing News app route', (I, newsPage: newsPage) => { + I.amOnPage(newsPage.url.nonExistingRoute); + I.waitForText('404 not found', 10, 'body > div#body'); +}); + +Scenario('Renders (CSR) global 404 page for non-existing News app route', (I, newsPage: newsPage) => { + I.amOnPage(newsPage.url.main); + I.waitInUrl(newsPage.url.main, 10); + I.waitForElement(newsPage.linkWithUrl(newsPage.url.nonExistingRoute), 10); + I.click(newsPage.linkWithUrl(newsPage.url.nonExistingRoute)); + I.waitForText('404 not found', 10, 'body > div#body'); + + //After 404 page ILC continues normal operation + I.click(newsPage.linkWithUrl(newsPage.url.main)); + I.waitForElement(newsPage.newsSources, 10); + I.see('Pick a news source', newsPage.bannerHeadline); +}); + +Scenario('Renders (SSR) overridden 404 page for non-existing News app route', (I, newsPage: newsPage) => { + I.amOnPage(newsPage.url.nonExistingRouteWithOverride); + I.waitForText('404 not found component', 10, 'body > div#body'); +}); + +Scenario('Renders (CSR) overridden 404 page for non-existing News app route', (I, newsPage: newsPage) => { + I.amOnPage(newsPage.url.main); + I.waitInUrl(newsPage.url.main, 10); + I.waitForElement(newsPage.linkWithUrl(newsPage.url.nonExistingRouteWithOverride), 10); + I.click(newsPage.linkWithUrl(newsPage.url.nonExistingRouteWithOverride)); + I.waitForText('404 not found component', 10, 'body > div#body'); +}); +//endregion 404 page for non-existing News app route + +//region 404 page for non-existing News resource +Scenario('Renders (SSR) global 404 page for non-existing News resource', (I, newsPage: newsPage) => { + I.amOnPage(newsPage.url.nonExistingResource); + I.waitForText('404 not found', 10, 'body > div#body'); +}); + +Scenario('Renders (CSR) global 404 page for non-existing News resource', (I, newsPage: newsPage) => { + I.amOnPage(newsPage.url.main); + I.waitInUrl(newsPage.url.main, 10); + I.waitForElement(newsPage.linkWithUrl(newsPage.url.nonExistingResource), 10); + I.click(newsPage.linkWithUrl(newsPage.url.nonExistingResource)); + I.waitForText('404 not found', 10, 'body > div#body'); + + //After 404 page ILC continues normal operation + I.wait(5); //Hack to fix issue with the Vue Router + I.click(newsPage.linkWithUrl(newsPage.url.main)); + I.waitForElement(newsPage.newsSources, 10); + I.see('Pick a news source', newsPage.bannerHeadline); +}); + +Scenario('Renders (SSR) overridden 404 page for non-existing News resource', (I, newsPage: newsPage) => { + I.amOnPage(newsPage.url.nonExistingResourceWithOverride); + I.waitForText('404 not found component', 10, 'body > div#body'); +}); + +Scenario('Renders (CSR) overridden 404 page for non-existing News resource', (I, newsPage: newsPage) => { + I.amOnPage(newsPage.url.main); + I.waitInUrl(newsPage.url.main, 10); + I.waitForElement(newsPage.linkWithUrl(newsPage.url.nonExistingResourceWithOverride), 10); + I.click(newsPage.linkWithUrl(newsPage.url.nonExistingResourceWithOverride)); + I.waitForText('404 not found component', 10, 'body > div#body'); +}); +//endregion 404 page for non-existing News resource diff --git a/e2e/spec/navbar.spec.e2e.ts b/e2e/spec/navbar.spec.e2e.ts index bab93404..2f15ef13 100644 --- a/e2e/spec/navbar.spec.e2e.ts +++ b/e2e/spec/navbar.spec.e2e.ts @@ -2,10 +2,10 @@ Feature('navbar ilc demo application'); Scenario('should open every page and show a content only of an opened page', async (I, peoplePage, newsPage, planetsPage) => { I.amOnPage('/'); - I.waitForElement(newsPage.goToNews, 10); - I.click(newsPage.goToNews); - I.waitInUrl(newsPage.newsUrl, 10); - I.seeAttributesOnElements(newsPage.goToNews, { + I.waitForElement(newsPage.linkWithUrl(newsPage.url.main), 10); + I.click(newsPage.linkWithUrl(newsPage.url.main)); + I.waitInUrl(newsPage.url.main, 10); + I.seeAttributesOnElements(newsPage.linkWithUrl(newsPage.url.main), { 'aria-current': 'page', }); I.waitForElement(newsPage.newsSources, 10); @@ -59,9 +59,9 @@ Scenario('should open every page and show a content only of an opened page', asy I.waitForClickable(peoplePage.fetchMorePeople, 10); I.seeNumberOfVisibleElements(peoplePage.personsList, 10); - I.click(newsPage.goToNews); - I.waitInUrl(newsPage.newsUrl, 10); - I.seeAttributesOnElements(newsPage.goToNews, { + I.click(newsPage.linkWithUrl(newsPage.url.main)); + I.waitInUrl(newsPage.url.main, 10); + I.seeAttributesOnElements(newsPage.linkWithUrl(newsPage.url.main), { 'aria-current': 'page', }); I.waitForElement(newsPage.newsSources, 10); diff --git a/e2e/spec/news.spec.e2e.ts b/e2e/spec/news.spec.e2e.ts index 312b8775..c6a4a2b4 100644 --- a/e2e/spec/news.spec.e2e.ts +++ b/e2e/spec/news.spec.e2e.ts @@ -2,10 +2,10 @@ Feature('news ilc demo application'); Scenario('should open a news page and show news sources', async (I, newsPage: newsPage) => { I.amOnPage('/'); - I.waitForElement(newsPage.goToNews, 10); - I.click(newsPage.goToNews); - I.waitInUrl(newsPage.newsUrl, 10); - I.seeAttributesOnElements(newsPage.goToNews, { + I.waitForElement(newsPage.linkWithUrl(newsPage.url.main), 10); + I.click(newsPage.linkWithUrl(newsPage.url.main)); + I.waitInUrl(newsPage.url.main, 10); + I.seeAttributesOnElements(newsPage.linkWithUrl(newsPage.url.main), { 'aria-current': 'page', }); I.waitForElement(newsPage.newsSources, 10); @@ -13,8 +13,8 @@ Scenario('should open a news page and show news sources', async (I, newsPage: ne }); Scenario('should open an article page from a direct link', async (I, newsPage: newsPage) => { - I.amOnPage(newsPage.newsUrl); - I.waitInUrl(newsPage.newsUrl, 10); + I.amOnPage(newsPage.url.main); + I.waitInUrl(newsPage.url.main, 10); I.waitForElement(newsPage.newsSources, 10); I.scrollPageToBottom(); @@ -38,10 +38,10 @@ Scenario('should open an article page from a direct link', async (I, newsPage: n }); Scenario('should open 500 error page when an error happens', async (I, newsPage: newsPage) => { - I.amOnPage(newsPage.newsUrl); - I.waitInUrl(newsPage.newsUrl, 10); + I.amOnPage(newsPage.url.main); + I.waitInUrl(newsPage.url.main, 10); I.waitForElement(newsPage.generateError, 10); I.click(newsPage.generateError); I.waitForElement(newsPage.errorId); - I.seeInCurrentUrl(newsPage.newsUrl); + I.seeInCurrentUrl(newsPage.url.main); }); diff --git a/e2e/spec/pages/news.ts b/e2e/spec/pages/news.ts index 77d1b40c..7c161028 100644 --- a/e2e/spec/pages/news.ts +++ b/e2e/spec/pages/news.ts @@ -1,10 +1,16 @@ const { I } = inject(); -export const newsUrl = '/news/'; +export const url = { + main: '/news/', + nonExistingRoute: '/news/nonExisting', + nonExistingRouteWithOverride: '/news/nonExisting?overrideErrorPage=1', + nonExistingResource: '/news/article/abc-news-au34', + nonExistingResourceWithOverride: '/news/article/abc-news-au34?overrideErrorPage=1', +}; + +export const linkWithUrl = (url:string) => `a[href="${url}"]`; -export const goToNews = `body > div#navbar a[href="${newsUrl}"]`; export const newsView = 'body > div#body > div.single-spa-container.news-app > div.view'; -export const goToNewsSources = `${newsView} > div.container > p.home > a[href="${newsUrl}"]`; export const newsSources = `${newsView} > div.sources > div.container > ol > li.source`; export const bannerHeadline = `${newsView} > div.banner > h1`; export const generateError = `${newsView} > div.banner > a`; diff --git a/ilc/client.js b/ilc/client.js index ff46a6f5..07908242 100644 --- a/ilc/client.js +++ b/ilc/client.js @@ -4,7 +4,8 @@ import Router from './client/ClientRouter'; import setupErrorHandlers from './client/errorHandler/setupErrorHandlers'; import {fragmentErrorHandlerFactory, crashIlc} from './client/errorHandler/fragmentErrorHandlerFactory'; import isActiveFactory from './client/isActiveFactory'; -import initSpaConfig from './client/initSpaConfig'; +import initIlcConfig from './client/initIlcConfig'; +import initIlcState from './client/initIlcState'; import setupPerformanceMonitoring from './client/performance'; import selectSlotsToRegister from './client/selectSlotsToRegister'; import {getSlotElement} from './client/utils'; @@ -16,8 +17,9 @@ if (System === undefined) { throw new Error('ILC: can\'t find SystemJS on a page, crashing everything'); } -const registryConf = initSpaConfig(); -const router = new Router(registryConf, singleSpa.navigateToUrl); +const registryConf = initIlcConfig(); +const state = initIlcState(); +const router = new Router(registryConf, state, singleSpa); const asyncBootUp = new AsyncBootUp(); selectSlotsToRegister([...registryConf.routes, registryConf.specialRoutes['404']]).forEach((slots) => { diff --git a/ilc/client/ClientRouter.js b/ilc/client/ClientRouter.js index d6abf58a..8fe4a500 100644 --- a/ilc/client/ClientRouter.js +++ b/ilc/client/ClientRouter.js @@ -7,23 +7,26 @@ export default class ClientRouter { errors = errors; #currentUrl; - #navigateToUrl; + #singleSpa; #location; #logger; #registryConf; + /** @type Object */ #router; #prevRoute; #currentRoute; + #windowEventHandlers = {}; + #forceSpecialRoute = null; - constructor(registryConf, navigateToUrl, location = window.location, logger = window.console) { - this.#navigateToUrl = navigateToUrl; + constructor(registryConf, state, singleSpa, location = window.location, logger = window.console) { + this.#singleSpa = singleSpa; this.#location = location; this.#logger = logger; this.#registryConf = registryConf; this.#router = new Router(registryConf); - this.#currentUrl = this.#location.pathname + this.#location.search; + this.#currentUrl = this.#getCurrUrl(); - this.#setInitialRoutes(); + this.#setInitialRoutes(state); this.#addEventListeners(); } @@ -48,15 +51,13 @@ export default class ClientRouter { return deepmerge(appProps, routeProps); } - #setInitialRoutes = () => { + #setInitialRoutes = (state) => { // we should respect base tag for cached pages - let path; const base = document.querySelector('base'); - if (base) { const a = document.createElement('a'); a.href = base.getAttribute('href'); - path = a.pathname + a.search; + this.#currentRoute = this.#router.match(a.pathname + a.search); base.remove(); this.#logger.warn( @@ -64,32 +65,58 @@ export default class ClientRouter { 'Currently, ILC does not support it fully.\n' + 'Please open an issue if you need this functionality.' ); + } else if (state.forceSpecialRoute === '404') { + this.#currentRoute = this.#router.matchSpecial(this.#getCurrUrl(), state.forceSpecialRoute); } else { - path = this.#location.pathname + this.#location.search; + this.#currentRoute = this.#router.match(this.#getCurrUrl()); } - this.#currentRoute = this.#router.match(path); this.#prevRoute = this.#currentRoute; }; #addEventListeners = () => { - window.addEventListener('single-spa:before-routing-event', this.#onSingleSpaRoutingEvents); + this.#windowEventHandlers['single-spa:before-routing-event'] = this.#onSingleSpaRoutingEvents; + this.#windowEventHandlers['ilc:404'] = this.#onSpecialRouteTrigger(404); + + for (let key in this.#windowEventHandlers) { + if (!this.#windowEventHandlers.hasOwnProperty(key)) { + continue; + } + + window.addEventListener(key, this.#windowEventHandlers[key]); + } + document.addEventListener('click', this.#onClickLink); }; removeEventListeners() { - window.removeEventListener('single-spa:before-routing-event', this.#onSingleSpaRoutingEvents); + for (let key in this.#windowEventHandlers) { + if (!this.#windowEventHandlers.hasOwnProperty(key)) { + continue; + } + + window.removeEventListener(key, this.#windowEventHandlers[key]); + } + this.#windowEventHandlers = {}; + document.removeEventListener('click', this.#onClickLink); } #onSingleSpaRoutingEvents = () => { this.#prevRoute = this.#currentRoute; + const newUrl = this.#getCurrUrl(); + if (this.#forceSpecialRoute !== null && this.#forceSpecialRoute.url === newUrl) { + this.#currentRoute = this.#router.matchSpecial(newUrl, this.#forceSpecialRoute.id); + } else if (this.#forceSpecialRoute !== null) { + // Reset variable if it was set & now we go to different route + this.#forceSpecialRoute = null; + } + // fix for google cached pages. // if open any cached page and scroll to "#features" section: // only hash will be changed so router.match will return error, since tag has already been removed. // so in this cases we shouldn't regenerate currentRoute - const newUrl = this.#location.pathname + this.#location.search; if (this.#currentUrl !== newUrl) { this.#currentRoute = this.#router.match(this.#location.pathname + this.#location.search); this.#currentUrl = newUrl; @@ -123,8 +150,25 @@ export default class ClientRouter { const {specialRole} = this.#router.match(pathname); if (specialRole === null) { - this.#navigateToUrl(href); + this.#singleSpa.navigateToUrl(href); event.preventDefault(); } }; + + #onSpecialRouteTrigger = (specialRouteId) => (e) => { + const appId = e.detail && e.detail.appId; + const mountedApps = this.#singleSpa.getMountedApps(); + if (!mountedApps.includes(appId)) { + return console.warn( + `ILC: Ignoring special route "${specialRouteId}" trigger which came from not mounted app "${appId}". ` + + `Currently mounted apps: ${mountedApps.join(', ')}.` + ); + } + + console.log(`ILC: Special route "${specialRouteId}" was triggered by "${appId}" app. Performing rerouting...`); + this.#forceSpecialRoute = {id: specialRouteId, url: this.#getCurrUrl()}; + this.#singleSpa.triggerAppChange(); //This call would immediately invoke "single-spa:before-routing-event" and start apps mount/unmount process + }; + + #getCurrUrl = () => this.#location.pathname + this.#location.search; } diff --git a/ilc/client/ClientRouter.spec.js b/ilc/client/ClientRouter.spec.js index a7d1be4e..42272c22 100644 --- a/ilc/client/ClientRouter.spec.js +++ b/ilc/client/ClientRouter.spec.js @@ -5,6 +5,11 @@ import html from 'nanohtml'; import ClientRouter from './ClientRouter'; describe('client router', () => { + const singleSpa = { + navigateToUrl: () => {}, + triggerAppChange: () => {}, + getMountedApps: () => [], + }; const apps = { '@portal/hero': { spaBundle: 'https://somewhere.com/heroSpaBundle.js', @@ -196,7 +201,7 @@ describe('client router', () => { }, }; - router = new ClientRouter(registryConfig, () => {}, location); + router = new ClientRouter(registryConfig, {}, singleSpa, location); chai.expect(router.getCurrentRoute()).to.be.eql(expectedRoute); chai.expect(router.getPrevRoute()).to.be.eql(expectedRoute); @@ -254,7 +259,7 @@ describe('client router', () => { }, }; - router = new ClientRouter(registryConfig, () => {}, location, logger); + router = new ClientRouter(registryConfig, {}, singleSpa, location, logger); chai.expect(mainRef.getElementsByTagName('base')).to.be.empty; @@ -299,7 +304,7 @@ describe('client router', () => { }, }; - router = new ClientRouter(registryConfig, () => {}, location); + router = new ClientRouter(registryConfig, {}, singleSpa, location); location.pathname = registryConfig.routes[2].route; location.search = '?see=you'; @@ -340,7 +345,7 @@ describe('client router', () => { }, }; - router = new ClientRouter(registryConfig, () => {}, location); + router = new ClientRouter(registryConfig, {}, singleSpa, location); window.dispatchEvent(singleSpaBeforeRoutingEvent); @@ -357,7 +362,7 @@ describe('client router', () => { search: '?hi=there', }; - router = new ClientRouter(registryConfig, () => {}, location); + router = new ClientRouter(registryConfig, {}, singleSpa, location); const [eventName, eventListener] = addEventListener.getCall(0).args; @@ -384,7 +389,7 @@ describe('client router', () => { }; beforeEach(() => { - router = new ClientRouter(registryConfig, singleSpa.navigateToUrl); + router = new ClientRouter(registryConfig, {}, singleSpa); clickEvent = new Event('click', { bubbles: true, cancelable: true, @@ -503,7 +508,7 @@ describe('client router', () => { describe('while getting route props', () => { beforeEach(() => { - router = new ClientRouter(registryConfig, () => {}); + router = new ClientRouter(registryConfig, {}, singleSpa); }); it('should throw an error when an app is not defined', () => { diff --git a/ilc/client/initSpaConfig.js b/ilc/client/initIlcConfig.js similarity index 94% rename from ilc/client/initSpaConfig.js rename to ilc/client/initIlcConfig.js index 759d539c..6792267b 100644 --- a/ilc/client/initSpaConfig.js +++ b/ilc/client/initIlcConfig.js @@ -1,5 +1,5 @@ export default function () { - const confScript = document.querySelector('script[type="spa-config"]'); + const confScript = document.querySelector('script[type="ilc-config"]'); if (confScript === null) { throw new Error('Can\'t find single-spa config'); } diff --git a/ilc/client/initIlcState.js b/ilc/client/initIlcState.js new file mode 100644 index 00000000..0bca7500 --- /dev/null +++ b/ilc/client/initIlcState.js @@ -0,0 +1,12 @@ +export default function initIlcState() { + const stateScript = document.querySelector('script[type="ilc-state"]'); + if (stateScript === null) { + return {}; + } + + const state = JSON.parse(stateScript.innerHTML); + + stateScript.parentNode.removeChild(stateScript); + + return state; +} diff --git a/ilc/common/router/Router.js b/ilc/common/router/Router.js index 541dc055..51a70208 100644 --- a/ilc/common/router/Router.js +++ b/ilc/common/router/Router.js @@ -15,7 +15,7 @@ module.exports = class Router { } = registryConfig; this.#compiledRoutes = this.#compiler(routes); - this.#specialRoutes = specialRoutes; + this.#specialRoutes = specialRoutes || {}; } match(reqUrl) { @@ -45,27 +45,51 @@ module.exports = class Router { }); if (next !== true) { - if (res.template === undefined) { - throw new this.errors.NoBaseTemplateMatchError(); - } - res.basePath = match[1]; res.reqUrl = reqUrl; + this.#validateResultingRoute(res); + return res; } } - if (this.#specialRoutes[404] === undefined) { + return this.matchSpecial(reqUrl, 404); + } + + matchSpecial(reqUrl, specialRouteId) { + specialRouteId = parseInt(specialRouteId); + const specialRoute = this.#specialRoutes[specialRouteId]; + + if (specialRoute === undefined) { throw new this.errors.NoRouteMatchError(); } - return deepmerge(res, { - specialRole: 404, - ...this.#specialRoutes[404], + let res = { + basePath: '/', + reqUrl, + }; + + res = deepmerge(res, { + specialRole: specialRouteId, + route: specialRoute.route, + routeId: specialRoute.routeId, + slots: specialRoute.slots, + template: specialRoute.template, }); + + + this.#validateResultingRoute(res); + + return res; } + #validateResultingRoute = (route) => { + if (!route.template) { + throw new this.errors.NoBaseTemplateMatchError(); + } + }; + #compiler = (routes) => { return routes.map(v => { const route = this.#escapeStringRegexp(v.route); @@ -87,9 +111,9 @@ module.exports = class Router { routeExp, }; }); - } + }; #escapeStringRegexp = (str) => { return str.replace(/[|\\{}()[\]^$+*?.-]/g, '\\$&'); - } + }; }; diff --git a/ilc/common/router/Router.spec.js b/ilc/common/router/Router.spec.js index 2d5eb87e..6c8f0c70 100644 --- a/ilc/common/router/Router.spec.js +++ b/ilc/common/router/Router.spec.js @@ -82,74 +82,131 @@ describe('router', () => { route: '/404', next: false, template: 'errorsTemplate', - slots: {}, + slots: { + navbar: { + appName: 'navbar', + kind: 'essential', + }, + footer: { + appName: 'footer', + }, + }, }, }, }; - it('should throw an error when 404 special route does not exist', () => { - const router = new Router({ - routes: registryConfig.routes, - specialRoutes: {}, + describe('.match()', function () { + it('should throw an error when 404 special route does not exist', () => { + const router = new Router({ + routes: registryConfig.routes, + specialRoutes: {}, + }); + const reqUrl = '/nonexistent'; + + chai.expect(router.match.bind(router, reqUrl)).to.throw(router.errors.NoRouteMatchError); }); - const reqUrl = '/nonexistent'; - chai.expect(router.match.bind(router, reqUrl)).to.throw(router.errors.NoRouteMatchError); - }); + it('should throw an error when a route does not have a template', () => { + const routes = [ + { + routeId: 'noTemplateRoute', + route: '/no-template', + next: false, + slots: {} + }, + ]; - it('should throw an error when a route does not have a template', () => { - const routes = [ - { - routeId: 'noTemplateRoute', - route: '/no-template', - next: false, - slots: {} - }, - ]; + const router = new Router({ + routes, + specialRoutes: registryConfig.specialRoutes, + }); + const reqUrl = '/no-template'; - const router = new Router({ - routes, - specialRoutes: registryConfig.specialRoutes, + chai.expect(router.match.bind(router, reqUrl)).to.throw(router.errors.NoBaseTemplateMatchError); }); - const reqUrl = '/no-template'; - chai.expect(router.match.bind(router, reqUrl)).to.throw(router.errors.NoBaseTemplateMatchError); - }); + it('should return matched route by request url', () => { + const router = new Router(registryConfig); + const reqUrl = '/hero/apps?prop=value'; - it('should return matched route by request url', () => { - const router = new Router(registryConfig); - const reqUrl = '/hero/apps?prop=value'; - - chai.expect(router.match(reqUrl)).to.be.eql({ - routeId: 'appsRoute', - route: '/hero/apps', - basePath: '/hero/apps', - reqUrl, - template: 'heroTemplate', - specialRole: null, - slots: { - ...registryConfig.routes[0].slots, - ...registryConfig.routes[1].slots, - ...registryConfig.routes[2].slots, - }, + chai.expect(router.match(reqUrl)).to.be.eql({ + routeId: 'appsRoute', + route: '/hero/apps', + basePath: '/hero/apps', + reqUrl, + template: 'heroTemplate', + specialRole: null, + slots: { + ...registryConfig.routes[0].slots, + ...registryConfig.routes[1].slots, + ...registryConfig.routes[2].slots, + }, + }); + }); + + it('should return 404 route when a router does not match any route', () => { + const router = new Router(registryConfig); + const reqUrl = '/nonexistent?prop=value'; + + chai.expect(router.match(reqUrl)).to.be.eql({ + routeId: 'errorsRoute', + route: '/404', + basePath: '/', + reqUrl, + template: 'errorsTemplate', + specialRole: 404, + slots: { + ...registryConfig.specialRoutes['404'].slots, + }, + }); }); }); - it('should return 404 route when a router does not match any route', () => { - const router = new Router(registryConfig); - const reqUrl = '/nonexistent?prop=value'; - - chai.expect(router.match(reqUrl)).to.be.eql({ - routeId: 'errorsRoute', - route: '/404', - basePath: '/', - reqUrl, - template: 'errorsTemplate', - specialRole: 404, - next: false, - slots: { - ...registryConfig.routes[0].slots, - }, + describe('.matchSpecial()', function () { + it('should return special 404 route', () => { + const router = new Router(registryConfig); + const reqUrl = '/tst'; + + chai.expect(router.matchSpecial(reqUrl, 404)).to.be.eql({ + routeId: 'errorsRoute', + route: '/404', + basePath: '/', + reqUrl, + template: 'errorsTemplate', + specialRole: 404, + slots: { + ...registryConfig.specialRoutes['404'].slots, + }, + }); + }); + + it('should throw an error when asked for non-existing special route', () => { + const router = new Router(registryConfig); + const reqUrl = '/tst'; + + chai.expect(() => router.matchSpecial(reqUrl, 999)).to.throw(router.errors.NoRouteMatchError); + }); + + it('should throw an error when a special route does not have a template', () => { + const router = new Router({ + routes: registryConfig.routes, + specialRoutes: { + '404': { + routeId: 'errorsRoute', + route: '/404', + next: false, + template: '', + slots: { + navbar: { + appName: 'navbar', + }, + }, + }, + }, + }); + const reqUrl = '/no-template'; + + chai.expect(() => router.matchSpecial(reqUrl, '404')).to.throw(router.errors.NoBaseTemplateMatchError); }); }); }); diff --git a/ilc/package.json b/ilc/package.json index 5819d667..9a7322b2 100644 --- a/ilc/package.json +++ b/ilc/package.json @@ -26,49 +26,49 @@ "config": "^3.3.1", "cross-env": "^6.0.3", "deepmerge": "4.2.2", - "fastify": "^2.13.1", + "fastify": "^2.15.2", "http-headers": "^3.0.2", "is-url": "^1.2.4", - "lodash": "^4.17.15", - "newrelic": "^6.5.0", + "lodash": "^4.17.19", + "newrelic": "^6.11.0", "nginx-plus-dynamic-upstream": "^2.2.0", "serve-static": "^1.14.1", "single-spa": "5.4.0", - "systemjs": "^6.3.1", + "systemjs": "^6.4.2", "systemjs-css-extra": "^1.0.2", - "tailorx": "^5.7.0", + "tailorx": "^6.0.0", "url-join": "^4.0.1", "uuid": "^3.4.0" }, "devDependencies": { - "@babel/core": "^7.9.0", - "@babel/plugin-proposal-class-properties": "^7.8.3", - "@babel/plugin-proposal-private-methods": "^7.8.3", + "@babel/core": "^7.11.1", + "@babel/plugin-proposal-class-properties": "^7.10.4", + "@babel/plugin-proposal-private-methods": "^7.10.4", "@babel/plugin-syntax-dynamic-import": "^7.8.3", - "@babel/preset-env": "^7.9.5", - "@babel/register": "^7.9.0", + "@babel/preset-env": "^7.11.0", + "@babel/register": "^7.10.5", "babel-loader": "^8.1.0", "babel-plugin-transform-async-to-promises": "^0.8.15", "chai": "^4.2.0", - "karma": "^5.0.2", + "karma": "^5.1.1", "karma-chai": "^0.1.0", "karma-chrome-launcher": "^3.1.0", - "karma-coverage": "^2.0.2", - "karma-mocha": "^2.0.0", + "karma-coverage": "^2.0.3", + "karma-mocha": "^2.0.1", "karma-mocha-reporter": "^2.2.5", "karma-sinon": "^1.0.5", "karma-sourcemap-loader": "^0.3.7", "karma-webpack": "^4.0.2", - "mocha": "^7.1.1", + "mocha": "^7.2.0", "nanohtml": "^1.9.1", "nock": "^12.0.3", - "nodemon": "^2.0.3", - "nyc": "^15.0.1", + "nodemon": "^2.0.4", + "nyc": "^15.1.0", "raw-loader": "^3.1.0", "sinon": "^8.1.1", "supertest": "^4.0.2", - "webpack": "^4.42.1", - "webpack-cli": "^3.3.11", + "webpack": "^4.44.1", + "webpack-cli": "^3.3.12", "webpack-dev-middleware": "^3.7.2", "wrapper-webpack-plugin": "^2.1.0" } diff --git a/ilc/server/app.js b/ilc/server/app.js index ca466812..df139410 100644 --- a/ilc/server/app.js +++ b/ilc/server/app.js @@ -27,6 +27,7 @@ module.exports = () => { app.all('*', (req, res) => { req.headers['x-request-uri'] = req.raw.url; //TODO: to be removed & replaced with routerProps + req.raw.ilcState = {}; tailor.requestHandler(req.raw, res.res); }); diff --git a/ilc/server/tailor/configs-injector.js b/ilc/server/tailor/configs-injector.js index f384470b..cf04d4ca 100644 --- a/ilc/server/tailor/configs-injector.js +++ b/ilc/server/tailor/configs-injector.js @@ -38,6 +38,7 @@ module.exports = class ConfigsInjector { const ilcJsScripts = this.#wrapWithIgnoreDuringParsing( //...routeAssets.scriptLinks, + this.#getIlcState(request), this.#getSPAConfig(registryConfig), ``, this.#getPolyfill(), @@ -90,7 +91,7 @@ module.exports = class ConfigsInjector { * to avoid duplicate vendors preloads on client side * because apps may have common dependencies but from different sources * - * @see {@path ilc/client/initSpaConfig.js} + * @see {@path ilc/client/initIlcConfig.js} */ const appDependencies = _.reduce(_.keys(appInfo.dependencies), (appDependencies, dependencyName) => { appDependencies[dependencyName] = appsDependencies[dependencyName]; @@ -149,7 +150,16 @@ module.exports = class ConfigsInjector { const apps = _.mapValues(registryConfig.apps, v => _.pick(v, ['spaBundle', 'cssBundle', 'dependencies', 'props', 'kind'])); const spaConfig = JSON.stringify(_.omit({...registryConfig, apps}, ['templates'])); - return ``; + return ``; + }; + + #getIlcState = (request) => { + const state = request.ilcState || {}; + if (Object.keys(state).length === 0) { + return ''; + } + + return ``; }; #wrapWithAsyncScriptTag = (url) => { diff --git a/ilc/server/tailor/configs-injector.spec.js b/ilc/server/tailor/configs-injector.spec.js index 4c060220..80c0a9e2 100644 --- a/ilc/server/tailor/configs-injector.spec.js +++ b/ilc/server/tailor/configs-injector.spec.js @@ -212,7 +212,7 @@ describe('configs injector', () => { }); }; - it('should inject SPA config, polyfills, client js and new relic `); @@ -221,7 +221,7 @@ describe('configs injector', () => { const configsInjector = new ConfigsInjector(newrelic, cdnUrl, nrCustomClientJsWrapper); - const request = {}; + const request = {ilcState: {test: 1}}; const firstTemplateStyleRef = 'https://somewhere.com/firstTemplateStyleRef.css'; const secondTemplateStyleRef = 'https://somewhere.com/secondTemplateStyleRef.css'; @@ -257,7 +257,8 @@ describe('configs injector', () => { '' + '' + - `` + + `` + + `` + '' + ``; newrelic.getBrowserTimingHeader.withArgs().returns(browserTimingHeader); @@ -323,7 +325,7 @@ describe('configs injector', () => { '' + '' + '' + - `` + + `` + '' + `