Repository: single-spa/single-spa-layout (js)

Issue:
Add <redirect> component
### I have 3 micro front end , Login,banner and footer. Now I am giving login as a default route coz I want to open login page first when hit the url in browser. So How can I provide 404 page for unregistered routes ? In single spa documentation it is mentioned that for 404 pages we need to give it as a default route, but I want login page as default.  

### 
`<single-spa-router>
    <div class="main-content">
        <route path="login">
          <application name="login"></application>
        </route>
        <route path="main">
          <application name="banner"></application>
          <application name="footerApp"></application>
        </route>
        <route default>
           <application name="login"></application>
         </route>
    </div>
</single-spa-router>`

<pre><code>
Libs:
- @angular/core version: ~9.0.0
- single-spa-angular version: ^4.3.1
<!-- Check whether this is still an issue in the most recent Angular version -->



Pull request description:
Implementing <redirect>. Resolves #73.
See #73. In addition to the automated tests in this PR, I tested client side redirects with the react-microfrontends example and server-side redirects with the isomorphic-microfrontends example.

Patch:
diff --git a/src/browser/constructLayoutEngine.js b/src/browser/constructLayoutEngine.js
index 5f71ec8..0d3c571 100644
--- a/src/browser/constructLayoutEngine.js
+++ b/src/browser/constructLayoutEngine.js
@@ -6,6 +6,7 @@ import {
   getAppStatus,
   SKIP_BECAUSE_BROKEN,
   LOAD_ERROR,
+  navigateToUrl,
 } from "single-spa";
 import { htmlToParcelConfig } from "../utils/parcel-utils";
 
@@ -49,10 +50,7 @@ export function constructLayoutEngine({
       }
 
       if (inBrowser) {
-        window.addEventListener(
-          "single-spa:before-routing-event",
-          unmountErrorParcels
-        );
+        window.addEventListener("single-spa:before-routing-event", beforeRoute);
 
         window.addEventListener(
           "single-spa:before-mount-routing-event",
@@ -76,7 +74,7 @@ export function constructLayoutEngine({
       if (inBrowser) {
         window.removeEventListener(
           "single-spa:before-routing-event",
-          unmountErrorParcels
+          beforeRoute
         );
 
         window.removeEventListener(
@@ -129,7 +127,24 @@ export function constructLayoutEngine({
     }
   }
 
-  function unmountErrorParcels({ detail: { newAppStatuses } }) {
+  function beforeRoute({ detail: { newAppStatuses, cancelNavigation } }) {
+    const path = getPath(resolvedRoutes);
+
+    for (let from in resolvedRoutes.redirects) {
+      const to = resolvedRoutes.redirects[from];
+
+      if (from === path) {
+        if (!cancelNavigation) {
+          throw Error(
+            `single-spa-layout: <redirect> requires single-spa@>=5.7.0`
+          );
+        }
+        cancelNavigation();
+        navigateToUrl(to);
+        return;
+      }
+    }
+
     for (let appName in newAppStatuses) {
       if (
         errorParcelByAppName[appName] &&
@@ -143,7 +158,7 @@ export function constructLayoutEngine({
   }
 
   function arrangeDomElements() {
-    const path = location[resolvedRoutes.mode === "hash" ? "hash" : "pathname"];
+    const path = getPath(resolvedRoutes);
 
     if (path.indexOf(baseWithoutSlash) !== 0) {
       // Base URL doesn't match, no need to recurse routes
@@ -366,3 +381,7 @@ function jsonToDom(obj) {
 function brokenStatus(status) {
   return status === SKIP_BECAUSE_BROKEN || status === LOAD_ERROR;
 }
+
+function getPath(resolvedRoutes) {
+  return location[resolvedRoutes.mode === "hash" ? "hash" : "pathname"];
+}
diff --git a/src/isomorphic/constructRoutes.js b/src/isomorphic/constructRoutes.js
index cd1e208..8067000 100644
--- a/src/isomorphic/constructRoutes.js
+++ b/src/isomorphic/constructRoutes.js
@@ -3,6 +3,7 @@ import {
   validateEnum,
   validateKeys,
   validateString,
+  validateFullPath,
   validateObject,
   validateContainerEl,
   validateBoolean,
@@ -34,6 +35,7 @@ export const MISSING_PROP = typeof Symbol !== "undefined" ? Symbol() : "@";
  * containerEl?: ContainerEl;
  * disableWarnings?: boolean;
  * routes: Array<Route>;
+ * redirects?: Redirects;
  * }} InputRoutesConfigObject
  *
  * @typedef {{
@@ -41,8 +43,13 @@ export const MISSING_PROP = typeof Symbol !== "undefined" ? Symbol() : "@";
  * base: string;
  * containerEl: ContainerEl;
  * routes: Array<ResolvedRouteChild>;
+ * redirects: Redirects;
  * }} ResolvedRoutesConfig
  *
+ * @typedef {{
+ *   [from: string]: string;
+ * }} Redirects
+ *
  * @typedef {UrlRoute | Application | Node} RouteChild
  *
  * @typedef {ResolvedUrlRoute | Application | Node} ResolvedRouteChild
@@ -129,6 +136,7 @@ function domToRoutesConfig(domElement, htmlLayoutData = {}) {
 
   const result = {
     routes: [],
+    redirects: {},
   };
 
   if (getAttribute(domElement, "mode")) {
@@ -145,7 +153,7 @@ function domToRoutesConfig(domElement, htmlLayoutData = {}) {
 
   for (let i = 0; i < domElement.childNodes.length; i++) {
     result.routes.push(
-      ...elementToJson(domElement.childNodes[i], htmlLayoutData)
+      ...elementToJson(domElement.childNodes[i], htmlLayoutData, result)
     );
   }
 
@@ -178,9 +186,10 @@ function hasAttribute(element, attrName) {
 /**
  * @param {HTMLElement} element
  * @param {HTMLLayoutData} htmlLayoutData
+ * @param {ResolvedRoutesConfig} resolvedRoutesConfig
  * @returns {Array<Route>}
  */
-function elementToJson(element, htmlLayoutData) {
+function elementToJson(element, htmlLayoutData, resolvedRoutesConfig) {
   if (element.nodeName.toLowerCase() === "application") {
     if (element.childNodes.length > 0) {
       throw Error(
@@ -236,10 +245,19 @@ function elementToJson(element, htmlLayoutData) {
     setProps(element, route, htmlLayoutData);
     for (let i = 0; i < element.childNodes.length; i++) {
       route.routes.push(
-        ...elementToJson(element.childNodes[i], htmlLayoutData)
+        ...elementToJson(
+          element.childNodes[i],
+          htmlLayoutData,
+          resolvedRoutesConfig
+        )
       );
     }
     return [route];
+  } else if (element.nodeName.toLowerCase() === "redirect") {
+    resolvedRoutesConfig.redirects[
+      resolvePath("/", getAttribute(element, "from"))
+    ] = resolvePath("/", getAttribute(element, "to"));
+    return [];
   } else if (typeof Node !== "undefined" && element instanceof Node) {
     if (
       element.nodeType === Node.TEXT_NODE &&
@@ -251,7 +269,11 @@ function elementToJson(element, htmlLayoutData) {
         element.routes = [];
         for (let i = 0; i < element.childNodes.length; i++) {
           element.routes.push(
-            ...elementToJson(element.childNodes[i], htmlLayoutData)
+            ...elementToJson(
+              element.childNodes[i],
+              htmlLayoutData,
+              resolvedRoutesConfig
+            )
           );
         }
       }
@@ -265,7 +287,11 @@ function elementToJson(element, htmlLayoutData) {
     };
     for (let i = 0; i < element.childNodes.length; i++) {
       result.routes.push(
-        ...elementToJson(element.childNodes[i], htmlLayoutData)
+        ...elementToJson(
+          element.childNodes[i],
+          htmlLayoutData,
+          resolvedRoutesConfig
+        )
       );
     }
     return [result];
@@ -325,7 +351,7 @@ function validateAndSanitize(routesConfig) {
   validateKeys(
     "routesConfig",
     routesConfig,
-    ["mode", "base", "containerEl", "routes", "disableWarnings"],
+    ["mode", "base", "containerEl", "routes", "disableWarnings", "redirects"],
     disableWarnings
   );
 
@@ -347,6 +373,16 @@ function validateAndSanitize(routesConfig) {
     routesConfig.base = "/";
   }
 
+  if (routesConfig.hasOwnProperty("redirects")) {
+    validateObject(`routesConfig.redirects`, routesConfig.redirects);
+
+    for (let from in routesConfig.redirects) {
+      const to = routesConfig.redirects[from];
+      validateFullPath(`routesConfig.redirects key`, from);
+      validateFullPath(`routesConfig.redirects['${from}']`, to);
+    }
+  }
+
   const pathname = inBrowser ? window.location.pathname : "/";
   const hashPrefix = routesConfig.mode === "hash" ? pathname + "#" : "";
 
diff --git a/src/isomorphic/matchRoute.js b/src/isomorphic/matchRoute.js
index 66ade44..e55cc84 100644
--- a/src/isomorphic/matchRoute.js
+++ b/src/isomorphic/matchRoute.js
@@ -74,7 +74,7 @@ export function resolvePath(prefix, path) {
     result = prefix + "/" + path;
   }
 
-  if (result.substr(-1) === "/") {
+  if (result.substr(-1) === "/" && result.length > 1) {
     result = result.slice(0, result.length - 1);
   }
 
diff --git a/src/server/sendLayoutHTTPResponse.js b/src/server/sendLayoutHTTPResponse.js
index 85b3c02..c7ac66a 100644
--- a/src/server/sendLayoutHTTPResponse.js
+++ b/src/server/sendLayoutHTTPResponse.js
@@ -69,6 +69,17 @@ export async function sendLayoutHTTPResponse(renderOptions) {
     throw Error(`single-spa-layout (server): must provide renderOptions`);
   }
 
+  const redirects = renderOptions.serverLayout.resolvedRoutes.redirects;
+
+  for (let from in redirects) {
+    const to = redirects[from];
+
+    if (renderOptions.urlPath === from) {
+      renderOptions.res.redirect(to);
+      return;
+    }
+  }
+
   const propPromises = {},
     applicationPropPromises = {},
     headerPromises = {};
diff --git a/src/utils/validation-helpers.js b/src/utils/validation-helpers.js
index f8762a2..ac14518 100644
--- a/src/utils/validation-helpers.js
+++ b/src/utils/validation-helpers.js
@@ -62,6 +62,15 @@ export function validateString(propertyName, val, strict = true) {
   }
 }
 
+export function validateFullPath(propertyName, val) {
+  validateString(propertyName, val);
+  if (val.indexOf("/") < 0) {
+    throw Error(
+      `Invalid ${propertyName}: received '${val}', but expected an absolute path that starts with /`
+    );
+  }
+}
+
 export function validateArray(propertyName, arr, cbk, ...extraArgs) {
   if (
     !Array.isArray(arr) &&


Test patch:
diff --git a/test/browser-only/constructLayoutEngine-browser.test.js b/test/browser-only/constructLayoutEngine-browser.test.js
index d5e61c7..9ee6bdd 100644
--- a/test/browser-only/constructLayoutEngine-browser.test.js
+++ b/test/browser-only/constructLayoutEngine-browser.test.js
@@ -3,9 +3,8 @@ import {
   constructRoutes,
   constructApplications,
 } from "../../src/single-spa-layout.js";
-import { screen } from "@testing-library/dom";
 import { parseFixture } from "../html-utils.js";
-import { addErrorHandler, getAppStatus } from "single-spa";
+import { addErrorHandler, getAppStatus, navigateToUrl } from "single-spa";
 
 jest.mock("single-spa", () => {
   const actualSingleSpa = jest.requireActual("single-spa");
@@ -13,6 +12,7 @@ jest.mock("single-spa", () => {
     ...actualSingleSpa,
     addErrorHandler: jest.fn(),
     getAppStatus: jest.fn(),
+    navigateToUrl: jest.fn(),
   };
 });
 
@@ -20,6 +20,7 @@ describe(`constructLayoutEngine browser`, () => {
   beforeEach(() => {
     addErrorHandler.mockReset();
     getAppStatus.mockReset();
+    navigateToUrl.mockReset();
   });
 
   /** @type {import('../../src/constructLayoutEngine').LayoutEngine} */
@@ -1007,6 +1008,100 @@ describe(`constructLayoutEngine browser`, () => {
       expect(parcelWasUnmounted).toBe(true);
     });
   });
+
+  describe(`redirects`, () => {
+    it(`calls navigateToUrl() for redirects`, async () => {
+      history.pushState(history.state, "some title", "/something-random");
+
+      expect(navigateToUrl).not.toHaveBeenCalled();
+      const { document, routerElement } = parseFixture("redirects.html");
+      const routes = constructRoutes(routerElement);
+      const applications = constructApplications({
+        routes,
+        loadApp: async (name) => {
+          return {
+            async bootstrap() {},
+            async mount() {},
+            async unmount() {},
+          };
+        },
+      });
+      layoutEngine = constructLayoutEngine({
+        routes,
+        applications,
+      });
+
+      // trigger redirect to login
+      history.pushState(history.state, document.title, "/");
+      const cancelNavigation = jest.fn();
+      window.dispatchEvent(
+        new CustomEvent("single-spa:before-routing-event", {
+          detail: {
+            cancelNavigation,
+          },
+        })
+      );
+
+      await tick();
+
+      expect(cancelNavigation).toHaveBeenCalled();
+      expect(navigateToUrl).toHaveBeenCalledWith("/login");
+
+      // trigger redirect to new settings page
+      history.pushState(history.state, document.title, "/old-settings");
+      cancelNavigation.mockReset();
+      window.dispatchEvent(
+        new CustomEvent("single-spa:before-routing-event", {
+          detail: {
+            cancelNavigation,
+          },
+        })
+      );
+
+      await tick();
+
+      expect(cancelNavigation).toHaveBeenCalled();
+      expect(navigateToUrl).toHaveBeenCalledWith("/settings");
+    });
+
+    it(`doesn't call navigateToUrl() for non-redirects`, async () => {
+      history.pushState(history.state, "some title", "/something-random");
+
+      expect(navigateToUrl).not.toHaveBeenCalled();
+      const { document, routerElement } = parseFixture("redirects.html");
+      const routes = constructRoutes(routerElement);
+      const applications = constructApplications({
+        routes,
+        loadApp: async (name) => {
+          return {
+            async bootstrap() {},
+            async mount() {},
+            async unmount() {},
+          };
+        },
+      });
+      layoutEngine = constructLayoutEngine({
+        routes,
+        applications,
+      });
+
+      // trigger redirect to login
+      history.pushState(history.state, document.title, "/something-else");
+      const cancelNavigation = jest.fn();
+      window.dispatchEvent(
+        new CustomEvent("single-spa:before-routing-event", {
+          detail: {
+            cancelNavigation,
+          },
+        })
+      );
+
+      await tick();
+
+      expect(cancelNavigation).not.toHaveBeenCalled();
+      expect(navigateToUrl).not.toHaveBeenCalled();
+    });
+  });
 });
 
 function tick() {
diff --git a/test/constructRoutes.test.js b/test/constructRoutes.test.js
index 43c78ee..c3dacbb 100644
--- a/test/constructRoutes.test.js
+++ b/test/constructRoutes.test.js
@@ -85,7 +85,7 @@ describe("constructRoutes", () => {
       });
       expect(console.warn).toHaveBeenCalled();
       expect(console.warn.mock.calls[0][0].message).toEqual(
-        `Invalid routesConfig: received invalid properties 'irrelevantProperty', but valid properties are mode, base, containerEl, routes, disableWarnings`
+        `Invalid routesConfig: received invalid properties 'irrelevantProperty', but valid properties are mode, base, containerEl, routes, disableWarnings, redirects`
       );
     });
 
@@ -139,6 +139,51 @@ describe("constructRoutes", () => {
         });
       }).toThrowError("non-blank string");
     });
+
+    it(`throws an error with invalid redirects`, () => {
+      expect(() => {
+        constructRoutes({
+          routes: [],
+          redirects: 123,
+        });
+      }).toThrowError("plain object");
+
+      expect(() => {
+        constructRoutes({
+          routes: [],
+          redirects: {
+            1: "/login",
+          },
+        });
+      }).toThrowError("absolute path");
+
+      expect(() => {
+        constructRoutes({
+          routes: [],
+          redirects: {
+            "/": 1,
+          },
+        });
+      }).toThrowError("non-blank string");
+
+      expect(() => {
+        constructRoutes({
+          routes: [],
+          redirects: {
+            home: "/login",
+          },
+        });
+      }).toThrowError("absolute path");
+
+      expect(() => {
+        constructRoutes({
+          routes: [],
+          redirects: {
+            "/home": "login",
+          },
+        });
+      }).toThrowError("absolute path");
+    });
   });
 
   describe("validates routes", () => {
@@ -769,6 +814,17 @@ describe("constructRoutes", () => {
       expect(routes.containerEl).toBe("#spa-container");
     });
   });
+
+  describe(`redirects`, () => {
+    it(`can parse redirects`, () => {
+      const { document, routerElement } = parseFixture("redirects.html");
+      const routes = constructRoutes(routerElement);
+      expect(routes.redirects).toEqual({
+        "/": "/login",
+        "/old-settings": "/settings",
+      });
+    });
+  });
 });
 
 function findApplication(routes, name) {
diff --git a/test/fixtures/redirects.html b/test/fixtures/redirects.html
new file mode 100644
index 0000000..67829c8
--- /dev/null
+++ b/test/fixtures/redirects.html
@@ -0,0 +1,25 @@
+<!DOCTYPE html>
+<html lang="en">
+  <head>
+    <meta charset="UTF-8" />
+    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
+    <title>Document</title>
+  </head>
+  <body>
+    <single-spa-router base="/custom">
+      <redirect from="/" to="login"></redirect>
+      <application name="nav"></application>
+      <route path="clients">
+        <application name="clients"></application>
+      </route>
+      <route path="settings">
+        <!--
+          I put this redirect here to test that single-spa-layout can handle
+          redirects that are nested within other elements
+        -->
+        <redirect from="/old-settings" to="settings"></redirect>
+        <application name="settings"></application>
+      </route>
+    </single-spa-router>
+  </body>
+</html>
diff --git a/test/node-only/__snapshots__/sendLayoutHTTPResponse-node.test.js.snap b/test/node-only/__snapshots__/sendLayoutHTTPResponse-node.test.js.snap
index f73ceef..2c8afb0 100644
--- a/test/node-only/__snapshots__/sendLayoutHTTPResponse-node.test.js.snap
+++ b/test/node-only/__snapshots__/sendLayoutHTTPResponse-node.test.js.snap
@@ -1,5 +1,45 @@
 // Jest Snapshot v1, https://goo.gl/fbAQLP
 
+exports[`sendLayoutHTTPResponse redirects correctly serializes a layout definition that includes redirects 1`] = `
+<!DOCTYPE html>
+<html lang="en">
+  <head>
+    <meta charset="UTF-8">
+    <meta name="viewport"
+          content="width=device-width, initial-scale=1.0"
+    >
+    <title>
+      Document
+    </title>
+  </head>
+  <body>
+    <script>
+      window.singleSpaLayoutData = {"props":{}}
+    </script>
+    <single-spa-router base="/custom">
+      <redirect from="/"
+                to="login"
+      >
+      </redirect>
+      <application name="nav">
+      </application>
+      <route path="clients">
+        <application name="clients">
+        </application>
+      </route>
+      <route path="settings">
+        <redirect from="/old-settings"
+                  to="settings"
+        >
+        </redirect>
+        <application name="settings">
+        </application>
+      </route>
+    </single-spa-router>
+  </body>
+</html>
+`;
+
 exports[`sendLayoutHTTPResponse response body allows for promises that resolve with streams to be returned from renderApplication 1`] = `
 <!DOCTYPE html>
 <html lang="en">
diff --git a/test/node-only/sendLayoutHTTPResponse-node.test.js b/test/node-only/sendLayoutHTTPResponse-node.test.js
index db6e69f..5fb2a0a 100644
--- a/test/node-only/sendLayoutHTTPResponse-node.test.js
+++ b/test/node-only/sendLayoutHTTPResponse-node.test.js
@@ -25,6 +25,7 @@ describe(`sendLayoutHTTPResponse`, () => {
       bodyResolve(responseBodyStr);
     });
     res.setHeader = jest.fn();
+    res.redirect = jest.fn();
   });
 
   describe("response body", () => {
@@ -774,4 +775,87 @@ describe(`sendLayoutHTTPResponse`, () => {
       });
     });
   });
+
+  describe("redirects", () => {
+    it(`redirects from / to /login`, async () => {
+      const html = fs
+        .readFileSync(
+          path.resolve(process.cwd(), "./test/fixtures/redirects.html"),
+          "utf-8"
+        )
+        .toString();
+
+      const serverLayout = constructServerLayout({
+        html,
+      });
+
+      await sendLayoutHTTPResponse({
+        res,
+        serverLayout,
+        urlPath: "/",
+        retrieveApplicationHeaders({ appName, propsPromise }) {
+          return { [appName]: true };
+        },
+        assembleFinalHeaders(allHeaders) {
+          return {};
+        },
+        renderApplication({ appName, propsPromise }) {
+          return appName;
+        },
+      });
+
+      expect(res.redirect).toHaveBeenCalledWith("/login");
+
+      res.redirect.mockReset();
+
+      await sendLayoutHTTPResponse({
+        res,
+        serverLayout,
+        urlPath: "/old-settings",
+        retrieveApplicationHeaders({ appName, propsPromise }) {
+          return { [appName]: true };
+        },
+        assembleFinalHeaders(allHeaders) {
+          return {};
+        },
+        renderApplication({ appName, propsPromise }) {
+          return appName;
+        },
+      });
+
+      expect(res.redirect).toHaveBeenCalledWith("/settings");
+    });
+
+    it(`correctly serializes a layout definition that includes redirects`, async () => {
+      const html = fs
+        .readFileSync(
+          path.resolve(process.cwd(), "./test/fixtures/redirects.html"),
+          "utf-8"
+        )
+        .toString();
+
+      const serverLayout = constructServerLayout({
+        html,
+      });
+
+      await sendLayoutHTTPResponse({
+        res,
+        serverLayout,
+        urlPath: "/settings",
+        retrieveApplicationHeaders({ appName, propsPromise }) {
+          return { [appName]: true };
+        },
+        assembleFinalHeaders(allHeaders) {
+          return {};
+        },
+        renderApplication({ appName, propsPromise }) {
+          return appName;
+        },
+      });
+
+      expect(res.redirect).not.toHaveBeenCalled();
+
+      expect(await responseBodyPromise).toMatchSnapshot();
+    });
+  });
 });
diff --git a/test/single-spa-layout.test-d.ts b/test/single-spa-layout.test-d.ts
index 2b148f8..c9dadf2 100644
--- a/test/single-spa-layout.test-d.ts
+++ b/test/single-spa-layout.test-d.ts
@@ -86,6 +86,9 @@ const routes = constructRoutes({
       ],
     },
   ],
+  redirects: {
+    "/": "/login",
+  },
 });
 
 (routes.routes[0] as ResolvedUrlRoute).activeWhen(window.location);
@@ -96,6 +99,8 @@ expectType<string | singleSpa.ParcelConfig | undefined>(
     .loader
 );
 
+expectType<string>(routes.redirects["/"]);
+
 const parse5Doc = parse(
   `<single-spa-router></single-spa-router>`
 ) as DefaultTreeDocument;