Repository: single-spa/single-spa-layout
Pull request #160 (2022-04-13 13:27:56)

feat(server): Add support for nonce
This PR adds a way to specify an [`nonce`](https://developer.mozilla.org/en-US/docs/Web/HTML/Global_attributes/nonce) attribute for the `<script>` tag injected by `sendLayoutHTTPResponse`.

This script tag being crucial to single-spa, it's currently impossible to use it with a strict CSP. We're currently using it in production, but we need to search the whole output and replace `<script>` with `<script nonce="...">`, not an optimal solution.

Another approach to solve this would be to prevent the browser from evaluating this script tag ([by setting a `type` different than a javascript mime type](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/script#attr-type)), and then manually read from it in single-spa's client script.

MarcusResell: Nice, was about to do this one myself as we're also in need of it. Currently we keep that script snippet empty and "live" with the csp violation logged in the browser. Do you have time to add tests for it?

Patch:
diff --git a/package.json b/package.json
index 3c37bc1..b05782d 100644
--- a/package.json
+++ b/package.json
@@ -9,13 +9,15 @@
     ".": {
       "default": {
         "import": "./dist/esm/single-spa-layout.min.js",
-        "require": "./dist/umd/single-spa-layout.min.cjs"
+        "require": "./dist/umd/single-spa-layout.min.cjs",
+        "types": "dist/types/single-spa-layout-interface.d.ts"
       }
     },
     "./server": {
       "node": {
         "import": "./dist/esm/single-spa-layout-server.min.js",
-        "require": "./dist/umd/single-spa-layout-server.min.cjs"
+        "require": "./dist/umd/single-spa-layout-server.min.cjs",
+        "types": "dist/types/single-spa-layout-server.d.ts"
       }
     }
   },
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 04c48f5..ea7d58f 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -1,4 +1,4 @@
-lockfileVersion: 5.3
+lockfileVersion: 5.4
 
 specifiers:
   '@babel/core': ^7.14.6
@@ -49,10 +49,10 @@ dependencies:
 
 devDependencies:
   '@babel/core': 7.15.5
-  '@babel/eslint-parser': 7.15.7_@babel+core@7.15.5+eslint@7.32.0
+  '@babel/eslint-parser': 7.15.7_lnlygpe4mr5r2ivbe575ruxbju
   '@babel/plugin-transform-modules-commonjs': 7.15.4_@babel+core@7.15.5
   '@babel/preset-env': 7.15.6_@babel+core@7.15.5
-  '@rollup/plugin-babel': 5.3.0_@babel+core@7.15.5+rollup@2.57.0
+  '@rollup/plugin-babel': 5.3.0_brt5hmzxtlu35zrky3b5c2eksi
   '@rollup/plugin-commonjs': 19.0.2_rollup@2.57.0
   '@rollup/plugin-node-resolve': 13.0.5_rollup@2.57.0
   '@rollup/plugin-replace': 2.4.2_rollup@2.57.0
@@ -127,7 +127,7 @@ packages:
       - supports-color
     dev: true
 
-  /@babel/eslint-parser/7.15.7_@babel+core@7.15.5+eslint@7.32.0:
+  /@babel/eslint-parser/7.15.7_lnlygpe4mr5r2ivbe575ruxbju:
     resolution: {integrity: sha512-yJkHyomClm6A2Xzb8pdAo4HzYMSXFn1O5zrCYvbFP0yQFvHueLedV8WiEno8yJOKStjUXzBZzJFeWQ7b3YMsqQ==}
     engines: {node: ^10.13.0 || ^12.13.0 || >=14.0.0}
     peerDependencies:
@@ -404,6 +404,8 @@ packages:
     resolution: {integrity: sha512-rycZXvQ+xS9QyIcJ9HXeDWf1uxqlbVFAUq0Rq0dbc50Zb/+wUe/ehyfzGfm9KZZF0kBejYgxltBXocP+gKdL2g==}
     engines: {node: '>=6.0.0'}
     hasBin: true
+    dependencies:
+      '@babel/types': 7.15.6
     dev: true
 
   /@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining/7.15.4_@babel+core@7.15.5:
@@ -1290,11 +1292,13 @@ packages:
       uuid: 3.4.0
     dev: true
 
-  /@cypress/xvfb/1.2.4:
+  /@cypress/xvfb/1.2.4_supports-color@8.1.1:
     resolution: {integrity: sha512-skbBzPggOVYCbnGgV+0dmBdW/s77ZkAOXIC1knS8NagwDjBrNC1LuXtQJeiN6l+m7lzmHtaoUw/ctJKdqkG57Q==}
     dependencies:
-      debug: 3.2.7
+      debug: 3.2.7_supports-color@8.1.1
       lodash.once: 4.1.1
+    transitivePeerDependencies:
+      - supports-color
     dev: true
 
   /@eslint/eslintrc/0.4.3:
@@ -1584,6 +1588,8 @@ packages:
       semver: 7.3.4
       unique-filename: 1.1.1
       which: 2.0.2
+    transitivePeerDependencies:
+      - bluebird
     dev: true
 
   /@npmcli/installed-package-contents/1.0.5:
@@ -1626,7 +1632,7 @@ packages:
       read-package-json-fast: 1.2.1
     dev: true
 
-  /@rollup/plugin-babel/5.3.0_@babel+core@7.15.5+rollup@2.57.0:
+  /@rollup/plugin-babel/5.3.0_brt5hmzxtlu35zrky3b5c2eksi:
     resolution: {integrity: sha512-9uIC8HZOnVLrLHxayq/PTzw+uS25E14KPUBh5ktF+18Mjo5yK0ToMMx6epY0uEgkjwJw0aBW4x2horYXh8juWw==}
     engines: {node: '>= 10.0.0'}
     peerDependencies:
@@ -2370,6 +2376,8 @@ packages:
       ssri: 8.0.0
       tar: 6.1.0
       unique-filename: 1.1.1
+    transitivePeerDependencies:
+      - bluebird
     dev: true
 
   /cachedir/2.3.0:
@@ -2544,6 +2552,7 @@ packages:
   /colors/1.4.0:
     resolution: {integrity: sha512-a+UqTh4kgZg/SlGvfbzDHpgRu7AAQOmmqRHJnxhRZICKFUT91brVhNNt58CMWU9PsBbv3PDCZUHbVxuDiH2mtA==}
     engines: {node: '>=0.1.90'}
+    requiresBuild: true
     dev: true
     optional: true
 
@@ -2668,7 +2677,7 @@ packages:
     requiresBuild: true
     dependencies:
       '@cypress/request': 2.88.5
-      '@cypress/xvfb': 1.2.4
+      '@cypress/xvfb': 1.2.4_supports-color@8.1.1
       '@types/node': 14.17.19
       '@types/sinonjs__fake-timers': 6.0.2
       '@types/sizzle': 2.3.2
@@ -2735,10 +2744,16 @@ packages:
     resolution: {integrity: sha512-P6twpd70BcPK34K26uJ1KT3wlhpuOAPoMwJzpsIWUxHZ7wpmbdZL/hQqBDfz7hGurYSa5PhzdhDHtt319hL3ig==}
     dev: true
 
-  /debug/3.2.7:
+  /debug/3.2.7_supports-color@8.1.1:
     resolution: {integrity: sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==}
+    peerDependencies:
+      supports-color: '*'
+    peerDependenciesMeta:
+      supports-color:
+        optional: true
     dependencies:
       ms: 2.1.3
+      supports-color: 8.1.1
     dev: true
 
   /debug/4.3.1:
@@ -2941,6 +2956,7 @@ packages:
 
   /encoding/0.1.13:
     resolution: {integrity: sha512-ETBauow1T35Y/WZMkio9jiM0Z5xjHHmJ4XmjZOq1l/dXz3lr2sRn87nJy20RupqSh1F2m3HHPSp8ShIPQJrJ3A==}
+    requiresBuild: true
     dependencies:
       iconv-lite: 0.6.2
     dev: true
@@ -4761,6 +4777,7 @@ packages:
       tree-walk: 0.4.0
       yargs: 16.2.0
     transitivePeerDependencies:
+      - bluebird
       - supports-color
     dev: true
 
@@ -4802,6 +4819,7 @@ packages:
       socks-proxy-agent: 5.0.0
       ssri: 8.0.0
     transitivePeerDependencies:
+      - bluebird
       - supports-color
     dev: true
 
@@ -5115,6 +5133,7 @@ packages:
       minizlib: 2.1.2
       npm-package-arg: 8.1.0
     transitivePeerDependencies:
+      - bluebird
       - supports-color
     dev: true
 
@@ -5298,6 +5317,7 @@ packages:
       ssri: 8.0.0
       tar: 6.1.0
     transitivePeerDependencies:
+      - bluebird
       - supports-color
     dev: true
 
@@ -5452,6 +5472,11 @@ packages:
 
   /promise-inflight/1.0.1:
     resolution: {integrity: sha1-mEcocL8igTL8vdhoEputEsPAKeM=}
+    peerDependencies:
+      bluebird: '*'
+    peerDependenciesMeta:
+      bluebird:
+        optional: true
     dev: true
 
   /promise-retry/1.1.1:
@@ -6182,6 +6207,7 @@ packages:
     engines: {node: '>=10'}
     hasBin: true
     dependencies:
+      acorn: 8.5.0
       commander: 2.20.3
       source-map: 0.7.3
       source-map-support: 0.5.19
diff --git a/src/browser/constructLayoutEngine.js b/src/browser/constructLayoutEngine.js
index 8257a02..8ccda22 100644
--- a/src/browser/constructLayoutEngine.js
+++ b/src/browser/constructLayoutEngine.js
@@ -123,6 +123,7 @@ export function constructLayoutEngine({
         parcelConfig,
         {
           domElement: applicationDomContainer,
+          error: err,
         }
       );
     }
diff --git a/src/server/sendLayoutHTTPResponse.js b/src/server/sendLayoutHTTPResponse.js
index 0f3cf72..23df236 100644
--- a/src/server/sendLayoutHTTPResponse.js
+++ b/src/server/sendLayoutHTTPResponse.js
@@ -39,6 +39,7 @@ const GT_REGEX = />/g;
  * renderFragment?(name: string) => RenderResult | Promise<RenderResult>;
  * retrieveProp(name: string) => Promise<any> | any;
  * assembleFinalHeaders(appHeaders: AppHeaders[]) => object;
+ * nonce?: string;
  * }} RenderOptions
  *
  * @typedef {{
@@ -56,9 +57,10 @@ const GT_REGEX = />/g;
  * assetsStream: import('merge2').Merge2Stream;
  * bodyStream: import('merge2').Merge2Stream;
  * renderOptions: RenderOptions;
- * serverLayout: import('./constructServerLayout').serverLayout;
+ * serverLayout: import('./constructServerLayout').ServerLayout;
  * applicationProps: import('single-spa').AppProps;
  * inRouterElement: boolean;
+ * nonce?: string;
  * }} SerializeArgs
  *
  * @param {RenderOptions} renderOptions
@@ -101,6 +103,7 @@ export async function sendLayoutHTTPResponse(renderOptions) {
     applicationPropPromises,
     headerPromises,
     inRouterElement: false,
+    nonce: renderOptions.nonce,
   });
 
   const allAppHeaders = await Promise.all(
@@ -326,7 +329,7 @@ function isAssetsNode(node) {
  *
  * @param {SerializeArgs} args
  */
-function serializeLayoutData({ propPromises, bodyStream }) {
+function serializeLayoutData({ propPromises, bodyStream, nonce }) {
   const getLayoutData = async () => {
     const propEntries = await Promise.all(
       Object.entries(propPromises).map(([propName, propValuePromise]) => {
@@ -340,9 +343,13 @@ function serializeLayoutData({ propPromises, bodyStream }) {
       return acc;
     }, {});
 
-    return `<script>window.singleSpaLayoutData = ${JSON.stringify({
-      props,
-    })}</script>`;
+    const scriptAttributes = nonce ? ` nonce="${nonce}"` : "";
+
+    return `<script${scriptAttributes}>window.singleSpaLayoutData = ${JSON.stringify(
+      {
+        props,
+      }
+    )}</script>`;
   };
 
   try {


Test patch:
diff --git a/test/browser-only/constructLayoutEngine-browser.test.js b/test/browser-only/constructLayoutEngine-browser.test.js
index 7766261..14272ec 100644
--- a/test/browser-only/constructLayoutEngine-browser.test.js
+++ b/test/browser-only/constructLayoutEngine-browser.test.js
@@ -963,7 +963,70 @@ describe(`constructLayoutEngine browser`, () => {
       expect(errorParcelUnmountTime).toBeDefined();
       expect(app2MountTime).toBeDefined();
 
-      expect(errorParcelUnmountTime).toBeLessThan(app2MountTime);
+      expect(errorParcelUnmountTime).toBeLessThanOrEqual(app2MountTime);
+    });
+
+    it(`passes an err prop to error parcels`, async () => {
+      let parcelWasMounted = false,
+        parcelWasUnmounted = false,
+        parcelProps;
+
+      /** @type {import('../../src/constructRoutes').ResolvedRoutesConfig} */
+      const routes = constructRoutes({
+        containerEl: "body",
+        base: "/",
+        mode: "history",
+        routes: [
+          {
+            type: "route",
+            path: "/app1",
+            routes: [
+              {
+                type: "application",
+                name: "app1",
+                error: {
+                  async bootstrap() {},
+                  async mount(props) {
+                    parcelProps = props;
+                    const div = document.createElement("div");
+                    div.textContent = `App 1 is broken (parcel)`;
+                    props.domElement.appendChild(div);
+                    parcelWasMounted = true;
+                  },
+                  async unmount(props) {
+                    parcelWasUnmounted = true;
+                  },
+                },
+              },
+            ],
+          },
+        ],
+      });
+
+      const applications = constructApplications({
+        routes,
+        loadApp: async (name) => {
+          throw Error("failed to load app");
+        },
+      });
+
+      layoutEngine = constructLayoutEngine({
+        routes,
+        applications,
+      });
+      applications.forEach(registerApplication);
+
+      await transition("/app1");
+
+      expect(parcelWasMounted).toBe(true);
+      expect(parcelProps.error).not.toBeFalsy();
+      expect(parcelProps.error.message).toMatch(/failed to load app/);
+      expect(parcelWasUnmounted).toBe(false);
+
+      await transition("/");
+      unloadApplication("app1");
+
+      expect(parcelWasUnmounted).toBe(true);
     });
   });
 
diff --git a/test/node-only/__snapshots__/sendLayoutHTTPResponse-node.test.js.snap b/test/node-only/__snapshots__/sendLayoutHTTPResponse-node.test.js.snap
index 3741268..9fca5cd 100644
--- a/test/node-only/__snapshots__/sendLayoutHTTPResponse-node.test.js.snap
+++ b/test/node-only/__snapshots__/sendLayoutHTTPResponse-node.test.js.snap
@@ -454,6 +454,72 @@ exports[`sendLayoutHTTPResponse response body dom-elements.html fixture renders
 </html>
 `;
 
+exports[`sendLayoutHTTPResponse response body dom-elements.html fixture supports nonce attributes 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>
+    <div id="single-spa-application:header">
+      <button>
+        App header
+      </button>
+    </div>
+    <div class="main-content">
+      <button>
+        Top level content
+      </button>
+      <div id="single-spa-application:app1">
+        <button>
+          App app1
+        </button>
+      </div>
+      Some raw text
+    </div>
+    <span>
+      <footer>
+        <a href="/about">
+          About
+        </a>
+      </footer>
+    </span>
+    <script nonce="sample-nonce">
+      window.singleSpaLayoutData = {"props":{}}
+    </script>
+    <single-spa-router mode="history"
+                       base="/"
+    >
+      <application name="header">
+      </application>
+      <div class="main-content">
+        <route path="app1">
+          <button>
+            Top level content
+          </button>
+          <application name="app1">
+          </application>
+          Some raw text
+        </route>
+      </div>
+      <span>
+        <footer>
+          <a href="/about">
+            About
+          </a>
+        </footer>
+      </span>
+    </single-spa-router>
+  </body>
+</html>
+`;
+
 exports[`sendLayoutHTTPResponse response body fragments.html fixture renders fragments correctly 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 e9505a9..dc389b5 100644
--- a/test/node-only/sendLayoutHTTPResponse-node.test.js
+++ b/test/node-only/sendLayoutHTTPResponse-node.test.js
@@ -67,6 +67,45 @@ describe(`sendLayoutHTTPResponse`, () => {
 
         expect(responseBody).toMatchSnapshot();
       });
+
+      it(`supports nonce attributes`, async () => {
+        const html = fs
+          .readFileSync(
+            path.resolve(process.cwd(), "./test/fixtures/dom-elements.html"),
+            "utf-8"
+          )
+          .toString();
+
+        const serverLayout = constructServerLayout({
+          html,
+        });
+
+        await sendLayoutHTTPResponse({
+          res,
+          serverLayout,
+          urlPath: "/app1",
+          nonce: "sample-nonce",
+          retrieveApplicationHeaders(props) {
+            return {};
+          },
+          assembleFinalHeaders(allHeaders) {
+            return _.assign({}, Object.values(allHeaders));
+          },
+          renderApplication({ appName, propsPromise }) {
+            const appStream = new stream.Readable({
+              read() {
+                appStream.push(`<button>App ${appName}</button>`);
+                appStream.push(null);
+              },
+            });
+            return appStream;
+          },
+        });
+
+        const responseBody = await responseBodyPromise;
+
+        expect(responseBody).toMatchSnapshot();
+      });
     });
 
     describe(`fragments.html fixture`, () => {