Repository: single-spa/single-spa-layout
Pull request #75 (2020-10-01 22:13:18)

Server refactor
This refactors the `renderServerResponseBody` and `setResponseHeaders` apis to be a single API called `sendLayoutHTTPResponse`. The reason for combining them is that this makes it much easier to allow for asynchronous prop retrieval.


Patch:
diff --git a/src/isomorphic/constructRoutes.js b/src/isomorphic/constructRoutes.js
index 7c91634..e8d0969 100644
--- a/src/isomorphic/constructRoutes.js
+++ b/src/isomorphic/constructRoutes.js
@@ -20,6 +20,8 @@ const pathToActiveWhen = singleSpa[defaultStr]
   ? singleSpa[defaultStr][pathToActiveWhenStr]
   : singleSpa[pathToActiveWhenStr];
 
+export const MISSING_PROP = typeof Symbol !== "undefined" ? Symbol() : "@";
+
 /**
  * @typedef {InputRoutesConfigObject | Element | import('parse5').DefaultTreeDocument} RoutesConfig
  *
@@ -81,6 +83,9 @@ const pathToActiveWhen = singleSpa[defaultStr]
  */
 export function constructRoutes(routesConfig, htmlLayoutData) {
   if (routesConfig && routesConfig.nodeName) {
+    if (inBrowser && !htmlLayoutData && window.singleSpaLayoutData) {
+      htmlLayoutData = window.singleSpaLayoutData;
+    }
     routesConfig = domToRoutesConfig(routesConfig, htmlLayoutData);
   } else if (htmlLayoutData) {
     throw Error(
@@ -303,6 +308,8 @@ function setProps(element, route, htmlLayoutData) {
       throw Error(
         `Prop '${propName}' was not defined in the htmlLayoutData. Either remove this attribute from the HTML element or provide the prop's value`
       );
+    } else {
+      route.props[propName] = MISSING_PROP;
     }
   }
 }
diff --git a/src/server/renderServerResponseBody.js b/src/server/sendLayoutHTTPResponse.js
similarity index 64%
rename from src/server/renderServerResponseBody.js
rename to src/server/sendLayoutHTTPResponse.js
index 6e4965a..0ae4964 100644
--- a/src/server/renderServerResponseBody.js
+++ b/src/server/sendLayoutHTTPResponse.js
@@ -4,6 +4,7 @@ import HTML from "parse5/lib/common/html.js";
 import { Readable } from "stream";
 import { matchRoute } from "../isomorphic/matchRoute.js";
 import merge2 from "merge2";
+import { MISSING_PROP } from "../isomorphic/constructRoutes.js";
 
 // Serialization algorithm is heavily based on the Serializer class in
 // https://github.com/inikulin/parse5/blob/master/packages/parse5/lib/serializer/index.js
@@ -22,52 +23,77 @@ const GT_REGEX = />/g;
 
 /**
  * @typedef {{
+ * res: import('http').ServerResponse;
  * serverLayout: import('./constructServerLayout').ServerLayout;
  * urlPath: string;
- * renderApplication(props: import('single-spa').AppProps) => import('stream').Readable | string;
- * renderFragment?(name: string) => import('stream').Readable | string;
+ * renderApplication(appToRender: AppToRender) => import('stream').Readable | string | Promise<string> | Promise<import('stream').Readable>;
+ * retrieveApplicationHeaders(appToRender AppToRender) => object;
+ * renderFragment?(name: string) => import('stream').Readable | string | Promise<string> | Promise<import('stream').Readable>;
+ * retrieveProp(name: string) => Promise<any> | any;
+ * assembleFinalHeaders(appHeaders: AppHeaders[]) => object;
  * }} RenderOptions
  *
  * @typedef {{
+ * appName: string;
+ * propsPromise: Promise<import('single-spa').AppProps>;
+ * }} AppToRender
+ *
+ * @typedef {{
+ * appProps: import('single-spa').AppProps;
+ * appHeaders: object;
+ * }} AppHeaders
+ *
+ * @typedef {{
  * node: import('parse5').Element;
- * output: import('parse5').output;
+ * bodyStream: import('merge2').Merge2Stream;
  * renderOptions: RenderOptions;
  * serverLayout: import('./constructServerLayout').serverLayout;
  * applicationProps: import('single-spa').AppProps;
  * inRouterElement: boolean;
  * }} SerializeArgs
  *
- * @typedef {{
- * bodyStream: import('stream').Readable;
- * applicationProps: Array<import('single-spa').AppProps>;
- * }} ServerResponseBodyResult
- *
  * @param {RenderOptions} renderOptions
- * @returns {import('stream').Readable}
+ * @returns {Promise}
  */
-export function renderServerResponseBody(renderOptions) {
+export async function sendLayoutHTTPResponse(renderOptions) {
   if (!renderOptions) {
     throw Error(`single-spa-layout (server): must provide renderOptions`);
   }
 
-  const applicationProps = [];
+  const propPromises = {},
+    applicationPropPromises = {},
+    headerPromises = {};
 
-  const output = merge2({
+  const bodyStream = merge2({
     pipeError: true,
   });
 
   serializeChildNodes({
     node: renderOptions.serverLayout.parsedDocument,
-    output,
+    bodyStream,
     renderOptions,
-    applicationProps,
+    propPromises,
+    applicationPropPromises,
+    headerPromises,
     inRouterElement: false,
   });
 
-  return {
-    bodyStream: output,
-    applicationProps,
-  };
+  const allAppHeaders = await Promise.all(
+    Object.keys(headerPromises).map(async (appName) => {
+      const [appHeaders, appProps] = await Promise.all([
+        headerPromises[appName],
+        applicationPropPromises[appName],
+      ]);
+      return { appHeaders, appProps };
+    })
+  );
+
+  const finalHeaders = renderOptions.assembleFinalHeaders(allAppHeaders);
+  for (let headerName in finalHeaders) {
+    renderOptions.res.setHeader(headerName, finalHeaders[headerName]);
+  }
+
+  bodyStream.pipe(renderOptions.res);
 }
 
 /**
@@ -143,27 +169,57 @@ function serializeRoute(args) {
  */
 function serializeApplication({
   node,
-  output,
+  bodyStream,
   renderOptions,
-  applicationProps,
+  applicationPropPromises,
+  propPromises,
+  headerPromises,
 }) {
-  const props = {
-    name: node.name,
-    ...(node.props || {}),
-  };
+  const appName = node.name;
+
+  const propsConfig = node.props || {};
+  const propsPromise = Promise.all(
+    Object.keys(propsConfig).map((propName) => {
+      let value;
+      if (propsConfig[propName] === MISSING_PROP) {
+        value =
+          propPromises[propName] ||
+          (propPromises[propName] = renderOptions.retrieveProp(propName));
+      } else {
+        value = propsConfig[propName];
+      }
+
+      return Promise.resolve(value).then((rawValue) => [propName, rawValue]);
+    })
+  ).then((propEntries) => {
+    const props = {};
+
+    propEntries.forEach(([propName, value]) => {
+      props[propName] = value;
+    });
+
+    props.name = appName;
+
+    return props;
+  });
+
+  applicationPropPromises[appName] = propsPromise;
 
-  applicationProps.push(props);
+  headerPromises[appName] = renderOptions.retrieveApplicationHeaders({
+    appName,
+    propsPromise,
+  });
 
   const appStream = renderResultToStream(
-    () => renderOptions.renderApplication(props),
-    `Application ${props.name}`
+    () => renderOptions.renderApplication({ appName, propsPromise }),
+    `Application ${appName}`
   );
 
-  output.add(stringStream(`<div id="single-spa-application:${props.name}">`));
+  bodyStream.add(stringStream(`<div id="single-spa-application:${appName}">`));
 
-  output.add(appStream);
+  bodyStream.add(appStream);
 
-  output.add(stringStream(`</div>`));
+  bodyStream.add(stringStream(`</div>`));
 }
 
 function isRouterContent(node) {
@@ -187,6 +243,7 @@ function serializeRouterContent(args) {
     ...args,
     node: { childNodes: matchedRoutes.routes },
   });
+  serializeLayoutData(args);
 }
 
 function isFragmentNode(node) {
@@ -196,11 +253,37 @@ function isFragmentNode(node) {
   );
 }
 
+/**
+ *
+ * @param {SerializeArgs} args
+ */
+function serializeLayoutData({ propPromises, bodyStream }) {
+  bodyStream.add(
+    renderResultToStream(async () => {
+      const propEntries = await Promise.all(
+        Object.entries(propPromises).map(([propName, propValuePromise]) => {
+          return propValuePromise.then((value) => {
+            return [propName, value];
+          });
+        })
+      );
+      const props = propEntries.reduce((acc, [propName, value]) => {
+        acc[propName] = value;
+        return acc;
+      }, {});
+
+      return `<script>window.singleSpaLayoutData = ${JSON.stringify({
+        props,
+      })}</script>`;
+    }, `Serialize layout props`)
+  );
+}
+
 /**
  *
  * @param {SerializeArgs} serializeArgs
  */
-function serializeFragment({ node, output, renderOptions }) {
+function serializeFragment({ node, bodyStream, renderOptions }) {
   const attr = treeAdapter
     .getAttrList(node)
     .find((attr) => attr.name === "name");
@@ -208,7 +291,7 @@ function serializeFragment({ node, output, renderOptions }) {
     throw Error(`<fragment> has unknown name`);
   }
 
-  output.add(
+  bodyStream.add(
     renderResultToStream(
       () => renderOptions.renderFragment(attr.value),
       `Fragment ${attr.value}`
@@ -221,13 +304,13 @@ function serializeFragment({ node, output, renderOptions }) {
  * @param {SerializeArgs} serializeArgs
  */
 function serializeElement(args) {
-  const { node, output } = args;
+  const { node, bodyStream } = args;
   const tn = treeAdapter.getTagName(node);
   const ns = treeAdapter.getNamespaceURI(node);
 
-  output.add(stringStream(`<${tn}`));
+  bodyStream.add(stringStream(`<${tn}`));
   serializeAttributes(args);
-  output.add(stringStream(`>`));
+  bodyStream.add(stringStream(`>`));
 
   if (
     tn !== $.AREA &&
@@ -257,7 +340,7 @@ function serializeElement(args) {
     const inRouterElement = args.inRouterElement || tn === "single-spa-router";
     const newArgs = { ...args, node: childNodesHolder, inRouterElement };
     serializeChildNodes(newArgs);
-    output.add(stringStream(`</${tn}>`));
+    bodyStream.add(stringStream(`</${tn}>`));
   }
 }
 
@@ -265,7 +348,7 @@ function serializeElement(args) {
  *
  * @param {SerializeArgs} serializeArgs
  */
-function serializeAttributes({ node, output }) {
+function serializeAttributes({ node, bodyStream }) {
   const attrs = treeAdapter.getAttrList(node) || [];
 
   for (let i = 0, attrsLength = attrs.length; i < attrsLength; i++) {
@@ -289,16 +372,11 @@ function serializeAttributes({ node, output }) {
       attrName = attr.prefix + ":" + attr.name;
     }
 
-    output.add(stringStream(` ${attrName}="${value}"`));
+    bodyStream.add(stringStream(` ${attrName}="${value}"`));
   }
 }
 
-/**
- *
- * @param {import('parse5').Element} node
- * @param {import('stream').Readable} output
- */
-function serializeTextNode({ node, output }) {
+function serializeTextNode({ node, bodyStream }) {
   const content = treeAdapter.getTextNodeContent(node);
   const parent = treeAdapter.getParentNode(node);
   let parentTn = null;
@@ -317,9 +395,9 @@ function serializeTextNode({ node, output }) {
     parentTn === $.PLAINTEXT ||
     parentTn === $.NOSCRIPT
   ) {
-    output.add(stringStream(content));
+    bodyStream.add(stringStream(content));
   } else {
-    output.add(stringStream(escapeString(content, false)));
+    bodyStream.add(stringStream(escapeString(content, false)));
   }
 }
 
@@ -339,17 +417,21 @@ function escapeString(str, attrMode) {
  *
  * @param {SerializeArgs} serializeArgs
  */
-function serializeCommentNode({ node, output }) {
-  output.add(stringStream(`<!--${treeAdapter.getCommentNodeContent(node)}-->`));
+function serializeCommentNode({ node, bodyStream }) {
+  bodyStream.add(
+    stringStream(`<!--${treeAdapter.getCommentNodeContent(node)}-->`)
+  );
 }
 
 /**
  *
  * @param {SerializeArgs} serializeArgs
  */
-function serializeDocumentTypeNode({ node, output }) {
+function serializeDocumentTypeNode({ node, bodyStream }) {
   const name = treeAdapter.getDocumentTypeNodeName(node);
-  output.add(stringStream(`<${doctype.serializeContent(name, null, null)}>`));
+  bodyStream.add(
+    stringStream(`<${doctype.serializeContent(name, null, null)}>`)
+  );
 }
 
 export function stringStream(str) {
diff --git a/src/server/setResponseHeaders.js b/src/server/setResponseHeaders.js
deleted file mode 100644
index 53d92a4..0000000
--- a/src/server/setResponseHeaders.js
+++ /dev/null
@@ -1,55 +0,0 @@
-import {
-  validateObject,
-  validateKeys,
-  validateArray,
-  validateFunction,
-} from "../utils/validation-helpers";
-
-/**
- *
- * @typedef {{
- * applicationProps: Array<import('single-spa').AppProps>;
- * res: import('http').ServerResponse;
- * retrieveApplicationHeaders(props: import('single-spa').AppProps) => Promise<import('http').OutgoingHttpHeaders>;
- * mergeHeaders(allHeaders: import('http').OutgoingHttpHeaders) => import('http').OutgoingHttpHeaders;
- * }}
- *
- * @param {ResponseHeaderOptions} options
- * @returns {Promise}
- */
-export async function setResponseHeaders(options) {
-  validateObject("options", options);
-
-  validateKeys("options", options, [
-    "res",
-    "applicationProps",
-    "retrieveApplicationHeaders",
-    "mergeHeaders",
-  ]);
-
-  validateArray(
-    "applicationProps",
-    options.applicationProps,
-    (props, propertyName) => validateObject(propertyName, props)
-  );
-
-  validateFunction(
-    "retrieveApplicationHeaders",
-    options.retrieveApplicationHeaders
-  );
-
-  validateFunction("mergeHeaders", options.mergeHeaders);
-
-  validateObject("res", options.res);
-
-  const allHeaders = await Promise.all(
-    options.applicationProps.map(options.retrieveApplicationHeaders)
-  );
-
-  const finalHeaders = options.mergeHeaders(allHeaders);
-
-  options.res.status(200);
-  Object.entries(finalHeaders).forEach(([name, value]) => {
-    options.res.setHeader(name, value);
-  });
-}
diff --git a/src/single-spa-layout-server.js b/src/single-spa-layout-server.js
index 1ac65ea..58a33b5 100644
--- a/src/single-spa-layout-server.js
+++ b/src/single-spa-layout-server.js
@@ -1,3 +1,2 @@
 export { constructServerLayout } from "./server/constructServerLayout.js";
-export { renderServerResponseBody } from "./server/renderServerResponseBody.js";
-export { setResponseHeaders } from "./server/setResponseHeaders.js";
+export { sendLayoutHTTPResponse } from "./server/sendLayoutHTTPResponse.js";


Test patch:
diff --git a/test/node-only/__snapshots__/renderServerResponseBody-node.test.js.snap b/test/node-only/__snapshots__/sendLayoutHTTPResponse-node.test.js.snap
similarity index 74%
rename from test/node-only/__snapshots__/renderServerResponseBody-node.test.js.snap
rename to test/node-only/__snapshots__/sendLayoutHTTPResponse-node.test.js.snap
index 120b813..6716b63 100644
--- a/test/node-only/__snapshots__/renderServerResponseBody-node.test.js.snap
+++ b/test/node-only/__snapshots__/sendLayoutHTTPResponse-node.test.js.snap
@@ -1,6 +1,6 @@
 // Jest Snapshot v1, https://goo.gl/fbAQLP
 
-exports[`renderServerResponseBody allows for promises that resolve with streams to be returned from renderApplication 1`] = `
+exports[`sendLayoutHTTPResponse response body allows for promises that resolve with streams to be returned from renderApplication 1`] = `
 <!DOCTYPE html>
 <html lang="en">
   <head>
@@ -36,6 +36,9 @@ exports[`renderServerResponseBody allows for promises that resolve with streams
         </a>
       </footer>
     </span>
+    <script>
+      window.singleSpaLayoutData = {"props":{}}
+    </script>
     <single-spa-router mode="history"
                        base="/"
     >
@@ -61,7 +64,7 @@ exports[`renderServerResponseBody allows for promises that resolve with streams
 </html>
 `;
 
-exports[`renderServerResponseBody allows for promises to be returned from renderApplication 1`] = `
+exports[`sendLayoutHTTPResponse response body allows for promises to be returned from renderApplication 1`] = `
 <!DOCTYPE html>
 <html lang="en">
   <head>
@@ -97,6 +100,9 @@ exports[`renderServerResponseBody allows for promises to be returned from render
         </a>
       </footer>
     </span>
+    <script>
+      window.singleSpaLayoutData = {"props":{}}
+    </script>
     <single-spa-router mode="history"
                        base="/"
     >
@@ -122,7 +128,7 @@ exports[`renderServerResponseBody allows for promises to be returned from render
 </html>
 `;
 
-exports[`renderServerResponseBody allows for strings to be returned from renderApplication 1`] = `
+exports[`sendLayoutHTTPResponse response body allows for strings to be returned from renderApplication 1`] = `
 <!DOCTYPE html>
 <html lang="en">
   <head>
@@ -158,6 +164,9 @@ exports[`renderServerResponseBody allows for strings to be returned from renderA
         </a>
       </footer>
     </span>
+    <script>
+      window.singleSpaLayoutData = {"props":{}}
+    </script>
     <single-spa-router mode="history"
                        base="/"
     >
@@ -183,7 +192,7 @@ exports[`renderServerResponseBody allows for strings to be returned from renderA
 </html>
 `;
 
-exports[`renderServerResponseBody dom-elements.html fixture renders the app1 route correctly 1`] = `
+exports[`sendLayoutHTTPResponse response body dom-elements.html fixture renders the app1 route correctly 1`] = `
 <!DOCTYPE html>
 <html lang="en">
   <head>
@@ -219,6 +228,9 @@ exports[`renderServerResponseBody dom-elements.html fixture renders the app1 rou
         </a>
       </footer>
     </span>
+    <script>
+      window.singleSpaLayoutData = {"props":{}}
+    </script>
     <single-spa-router mode="history"
                        base="/"
     >
@@ -244,7 +256,7 @@ exports[`renderServerResponseBody dom-elements.html fixture renders the app1 rou
 </html>
 `;
 
-exports[`renderServerResponseBody fragments.html fixture renders fragments correctly 1`] = `
+exports[`sendLayoutHTTPResponse response body fragments.html fixture renders fragments correctly 1`] = `
 <!DOCTYPE html>
 <html lang="en">
   <head>
@@ -257,6 +269,9 @@ exports[`renderServerResponseBody fragments.html fixture renders fragments corre
     </title>
   </head>
   <body>
+    <script>
+      window.singleSpaLayoutData = {"props":{}}
+    </script>
     <single-spa-router mode="history"
                        base="/"
     >
@@ -270,7 +285,7 @@ exports[`renderServerResponseBody fragments.html fixture renders fragments corre
 </html>
 `;
 
-exports[`renderServerResponseBody multiple-fragments.html fixture allows renderFragment to return a string 1`] = `
+exports[`sendLayoutHTTPResponse response body multiple-fragments.html fixture allows renderFragment to return a string 1`] = `
 <!DOCTYPE html>
 <html lang="en">
   <head>
@@ -283,6 +298,9 @@ exports[`renderServerResponseBody multiple-fragments.html fixture allows renderF
     </title>
   </head>
   <body>
+    <script>
+      window.singleSpaLayoutData = {"props":{}}
+    </script>
     <single-spa-router mode="history"
                        base="/"
     >
@@ -296,7 +314,33 @@ exports[`renderServerResponseBody multiple-fragments.html fixture allows renderF
 </html>
 `;
 
-exports[`renderServerResponseBody multiple-fragments.html fixture preserves correct order of bytes when both fragments are slow 1`] = `
+exports[`sendLayoutHTTPResponse response body multiple-fragments.html fixture doesn't wait for the first fragment stream to finish before starting to read the second fragment 1`] = `
+<!DOCTYPE html>
+<html lang="en">
+  <head>
+    <meta charset="utf-8">
+    <title>
+      Document
+    </title>
+    <script type="systemjs-importmap">
+      {
+                  "imports": {}
+                }
+    </script>
+  </head>
+  <body>
+    <script>
+      window.singleSpaLayoutData = {"props":{}}
+    </script>
+    <single-spa-router mode="history"
+                       base="/"
+    >
+    </single-spa-router>
+  </body>
+</html>
+`;
+
+exports[`sendLayoutHTTPResponse response body multiple-fragments.html fixture preserves correct order of bytes when both fragments are slow 1`] = `
 <!DOCTYPE html>
 <html lang="en">
   <head>
@@ -311,6 +355,9 @@ exports[`renderServerResponseBody multiple-fragments.html fixture preserves corr
     </script>
   </head>
   <body>
+    <script>
+      window.singleSpaLayoutData = {"props":{}}
+    </script>
     <single-spa-router mode="history"
                        base="/"
     >
@@ -319,7 +366,7 @@ exports[`renderServerResponseBody multiple-fragments.html fixture preserves corr
 </html>
 `;
 
-exports[`renderServerResponseBody multiple-fragments.html fixture preserves correct order of bytes when fragments are synchronous 1`] = `
+exports[`sendLayoutHTTPResponse response body multiple-fragments.html fixture preserves correct order of bytes when fragments are synchronous 1`] = `
 <!DOCTYPE html>
 <html lang="en">
   <head>
@@ -334,6 +381,9 @@ exports[`renderServerResponseBody multiple-fragments.html fixture preserves corr
     </script>
   </head>
   <body>
+    <script>
+      window.singleSpaLayoutData = {"props":{}}
+    </script>
     <single-spa-router mode="history"
                        base="/"
     >
@@ -342,7 +392,7 @@ exports[`renderServerResponseBody multiple-fragments.html fixture preserves corr
 </html>
 `;
 
-exports[`renderServerResponseBody multiple-fragments.html fixture preserves correct order of bytes when head-metadata is slow 1`] = `
+exports[`sendLayoutHTTPResponse response body multiple-fragments.html fixture preserves correct order of bytes when head-metadata is slow 1`] = `
 <!DOCTYPE html>
 <html lang="en">
   <head>
@@ -357,6 +407,9 @@ exports[`renderServerResponseBody multiple-fragments.html fixture preserves corr
     </script>
   </head>
   <body>
+    <script>
+      window.singleSpaLayoutData = {"props":{}}
+    </script>
     <single-spa-router mode="history"
                        base="/"
     >
@@ -365,7 +418,7 @@ exports[`renderServerResponseBody multiple-fragments.html fixture preserves corr
 </html>
 `;
 
-exports[`renderServerResponseBody multiple-fragments.html fixture preserves correct order of bytes when import map is slow 1`] = `
+exports[`sendLayoutHTTPResponse response body multiple-fragments.html fixture preserves correct order of bytes when import map is slow 1`] = `
 <!DOCTYPE html>
 <html lang="en">
   <head>
@@ -380,6 +433,9 @@ exports[`renderServerResponseBody multiple-fragments.html fixture preserves corr
     </script>
   </head>
   <body>
+    <script>
+      window.singleSpaLayoutData = {"props":{}}
+    </script>
     <single-spa-router mode="history"
                        base="/"
     >
@@ -388,7 +444,7 @@ exports[`renderServerResponseBody multiple-fragments.html fixture preserves corr
 </html>
 `;
 
-exports[`renderServerResponseBody renders an empty string when renderApplication throws an error 1`] = `
+exports[`sendLayoutHTTPResponse response body renders an empty string when renderApplication throws an error 1`] = `
 <!DOCTYPE html>
 <html lang="en">
   <head>
@@ -418,6 +474,9 @@ exports[`renderServerResponseBody renders an empty string when renderApplication
         </a>
       </footer>
     </span>
+    <script>
+      window.singleSpaLayoutData = {"props":{}}
+    </script>
     <single-spa-router mode="history"
                        base="/"
     >
@@ -443,7 +502,7 @@ exports[`renderServerResponseBody renders an empty string when renderApplication
 </html>
 `;
 
-exports[`renderServerResponseBody renders an empty string when returned promise from renderApplication rejects 1`] = `
+exports[`sendLayoutHTTPResponse response body renders an empty string when returned promise from renderApplication rejects 1`] = `
 <!DOCTYPE html>
 <html lang="en">
   <head>
@@ -473,6 +532,9 @@ exports[`renderServerResponseBody renders an empty string when returned promise
         </a>
       </footer>
     </span>
+    <script>
+      window.singleSpaLayoutData = {"props":{}}
+    </script>
     <single-spa-router mode="history"
                        base="/"
     >
diff --git a/test/node-only/renderServerResponseBody-node.test.js b/test/node-only/renderServerResponseBody-node.test.js
deleted file mode 100644
index cd904c7..0000000
--- a/test/node-only/renderServerResponseBody-node.test.js
+++ /dev/null
@@ -1,593 +0,0 @@
-import { constructServerLayout } from "../../src/single-spa-layout-server.js";
-import { renderServerResponseBody } from "../../src/single-spa-layout-server.js";
-import { stringStream } from "../../src/server/renderServerResponseBody.js";
-import fs from "fs";
-import path from "path";
-import stream, { Readable } from "stream";
-
-describe(`renderServerResponseBody`, () => {
-  describe(`dom-elements.html fixture`, () => {
-    it(`renders the app1 route correctly`, (done) => {
-      const html = fs
-        .readFileSync(
-          path.resolve(process.cwd(), "./test/fixtures/dom-elements.html"),
-          "utf-8"
-        )
-        .toString();
-
-      const serverLayout = constructServerLayout({
-        html,
-      });
-
-      const {
-        bodyStream: readable,
-        applicationProps,
-      } = renderServerResponseBody({
-        serverLayout,
-        urlPath: "/app1",
-        renderApplication(props) {
-          const { name } = props;
-          const appStream = new stream.Readable({
-            read() {
-              appStream.push(`<button>App ${name}</button>`);
-              appStream.push(null);
-            },
-          });
-          return appStream;
-        },
-      });
-
-      // Pending a fix
-      // expect(applicationProps).toEqual([{name: "app1"}])
-
-      let finalHtml = "";
-
-      readable.on("data", (chunk) => {
-        finalHtml += chunk;
-      });
-
-      readable.on("end", () => {
-        expect(finalHtml).toMatchSnapshot();
-        done();
-      });
-
-      readable.read();
-    });
-  });
-
-  describe(`fragments.html fixture`, () => {
-    it(`renders fragments correctly`, (done) => {
-      const html = fs
-        .readFileSync(
-          path.resolve(process.cwd(), "./test/fixtures/fragments.html"),
-          "utf-8"
-        )
-        .toString();
-
-      const serverLayout = constructServerLayout({
-        html,
-      });
-
-      const { bodyStream: readable } = renderServerResponseBody({
-        serverLayout,
-        urlPath: "/app1",
-        renderFragment(name) {
-          const fragStream = new stream.Readable({
-            read() {
-              this.push(
-                `
-                <script type="systemjs-importmap">
-                  {
-                    "imports": {}
-                  }
-                </script>
-              `.trim()
-              );
-              this.push(null);
-            },
-          });
-          return fragStream;
-        },
-      });
-
-      let finalHtml = "";
-
-      readable.on("data", (chunk) => {
-        finalHtml += chunk;
-      });
-
-      readable.on("end", () => {
-        expect(finalHtml).toMatchSnapshot();
-        done();
-      });
-
-      readable.read();
-    });
-  });
-
-  describe(`multiple-fragments.html fixture`, () => {
-    it(`preserves correct order of bytes when fragments are synchronous`, (done) => {
-      const html = fs
-        .readFileSync(
-          path.resolve(
-            process.cwd(),
-            "./test/fixtures/multiple-fragments.html"
-          ),
-          "utf-8"
-        )
-        .toString();
-
-      const serverLayout = constructServerLayout({
-        html,
-      });
-
-      const { bodyStream: readable } = renderServerResponseBody({
-        serverLayout,
-        urlPath: "/app1",
-        renderFragment(name) {
-          if (name === "importmap") {
-            return stringStream(
-              `
-              <script type="systemjs-importmap">
-                {
-                  "imports": {}
-                }
-              </script>
-            `.trim()
-            );
-          } else if (name === "head-metadata") {
-            return stringStream(`<meta charset="utf-8">`);
-          } else {
-            throw Error(`Unknown fragment ${name}`);
-          }
-        },
-      });
-
-      const chunks = [];
-      readable.on("data", (d) => chunks.push(d));
-      readable.on("end", () => {
-        expect(Buffer.concat(chunks).toString("utf-8")).toMatchSnapshot();
-        done();
-      });
-      readable.read();
-    });
-
-    it(`preserves correct order of bytes when import map is slow`, (done) => {
-      const html = fs
-        .readFileSync(
-          path.resolve(
-            process.cwd(),
-            "./test/fixtures/multiple-fragments.html"
-          ),
-          "utf-8"
-        )
-        .toString();
-
-      const serverLayout = constructServerLayout({
-        html,
-      });
-
-      const { bodyStream: readable } = renderServerResponseBody({
-        serverLayout,
-        urlPath: "/app1",
-        renderFragment(name) {
-          if (name === "importmap") {
-            const readable = new Readable({ read() {} });
-            setTimeout(() => {
-              readable.push(
-                `
-                <script type="systemjs-importmap">
-                  {
-                    "imports": {}
-                  }
-                </script>
-              `.trim()
-              );
-              readable.push(null);
-            }, 40);
-            return readable;
-          } else if (name === "head-metadata") {
-            return stringStream(`<meta charset="utf-8">`);
-          } else {
-            throw Error(`Unknown fragment ${name}`);
-          }
-        },
-      });
-
-      const chunks = [];
-      readable.on("data", (d) => chunks.push(d));
-      readable.on("end", () => {
-        expect(Buffer.concat(chunks).toString("utf-8")).toMatchSnapshot();
-        done();
-      });
-      readable.read();
-    });
-
-    it(`preserves correct order of bytes when head-metadata is slow`, (done) => {
-      const html = fs
-        .readFileSync(
-          path.resolve(
-            process.cwd(),
-            "./test/fixtures/multiple-fragments.html"
-          ),
-          "utf-8"
-        )
-        .toString();
-
-      const serverLayout = constructServerLayout({
-        html,
-      });
-
-      const { bodyStream: readable } = renderServerResponseBody({
-        serverLayout,
-        urlPath: "/app1",
-        renderFragment(name) {
-          if (name === "importmap") {
-            return stringStream(
-              `
-              <script type="systemjs-importmap">
-                {
-                  "imports": {}
-                }
-              </script>
-            `.trim()
-            );
-          } else if (name === "head-metadata") {
-            const readable = new Readable({ read() {} });
-            setTimeout(() => {
-              readable.push(`<meta charset="utf-8">`);
-              readable.push(null);
-            }, 40);
-            return readable;
-          } else {
-            throw Error(`Unknown fragment ${name}`);
-          }
-        },
-      });
-
-      const chunks = [];
-      readable.on("data", (d) => chunks.push(d));
-      readable.on("end", () => {
-        expect(Buffer.concat(chunks).toString("utf-8")).toMatchSnapshot();
-        done();
-      });
-      readable.read();
-    });
-
-    it(`preserves correct order of bytes when both fragments are slow`, (done) => {
-      const html = fs
-        .readFileSync(
-          path.resolve(
-            process.cwd(),
-            "./test/fixtures/multiple-fragments.html"
-          ),
-          "utf-8"
-        )
-        .toString();
-
-      const serverLayout = constructServerLayout({
-        html,
-      });
-
-      const { bodyStream: readable } = renderServerResponseBody({
-        serverLayout,
-        urlPath: "/app1",
-        renderFragment(name) {
-          if (name === "importmap") {
-            const readable = new Readable({ read() {} });
-            setTimeout(() => {
-              readable.push(
-                `
-                <script type="systemjs-importmap">
-                  {
-                    "imports": {}
-                  }
-                </script>
-              `.trim()
-              );
-              readable.push(null);
-            }, 70);
-            return readable;
-          } else if (name === "head-metadata") {
-            const readable = new Readable({ read() {} });
-            setTimeout(() => {
-              readable.push(`<meta charset="utf-8">`);
-              readable.push(null);
-            }, 40);
-            return readable;
-          } else {
-            throw Error(`Unknown fragment ${name}`);
-          }
-        },
-      });
-
-      const chunks = [];
-      readable.on("data", (d) => chunks.push(d));
-      readable.on("end", () => {
-        expect(Buffer.concat(chunks).toString("utf-8")).toMatchSnapshot();
-        done();
-      });
-      readable.read();
-    });
-
-    it(`doesn't wait for the first fragment stream to finish before starting to read the second fragment`, () => {
-      const html = fs
-        .readFileSync(
-          path.resolve(
-            process.cwd(),
-            "./test/fixtures/multiple-fragments.html"
-          ),
-          "utf-8"
-        )
-        .toString();
-
-      const serverLayout = constructServerLayout({
-        html,
-      });
-
-      let renderedFragments = [];
-
-      const { bodyStream: readable } = renderServerResponseBody({
-        serverLayout,
-        urlPath: "/app1",
-        renderFragment(name) {
-          renderedFragments.push(name);
-          if (name === "importmap") {
-            const readable = new Readable({ read() {} });
-            readable.push(
-              `
-              <script type="systemjs-importmap">
-                {
-                  "imports": {}
-                }
-              </script>
-            `.trim()
-            );
-            readable.push(null);
-            return readable;
-          } else if (name === "head-metadata") {
-            const readable = new Readable({ read() {} });
-            setTimeout(() => {
-              // renderFragment should have been called for the import map even though
-              // we haven't finished the head-metadata's stream yet.
-              expect(renderedFragments).toEqual(["head-metadata", "importmap"]);
-
-              readable.push(`<meta charset="utf-8">`);
-              readable.push(null);
-            }, 40);
-            return readable;
-          } else {
-            throw Error(`Unknown fragment ${name}`);
-          }
-        },
-      });
-    });
-
-    it(`allows renderFragment to return a string`, (done) => {
-      const html = fs
-        .readFileSync(
-          path.resolve(process.cwd(), "./test/fixtures/fragments.html"),
-          "utf-8"
-        )
-        .toString();
-
-      const serverLayout = constructServerLayout({
-        html,
-      });
-
-      const { bodyStream: readable } = renderServerResponseBody({
-        serverLayout,
-        urlPath: "/app1",
-        renderFragment(name) {
-          return `
-            <script type="systemjs-importmap">
-              {
-                "imports": {}
-              }
-            </script>
-          `.trim();
-        },
-      });
-
-      let finalHtml = "";
-
-      readable.on("data", (chunk) => {
-        finalHtml += chunk;
-      });
-
-      readable.on("end", () => {
-        expect(finalHtml).toMatchSnapshot();
-        done();
-      });
-
-      readable.read();
-    });
-  });
-
-  it(`allows for strings to be returned from renderApplication`, (done) => {
-    const html = fs
-      .readFileSync(
-        path.resolve(process.cwd(), "./test/fixtures/dom-elements.html"),
-        "utf-8"
-      )
-      .toString();
-
-    const serverLayout = constructServerLayout({
-      html,
-    });
-
-    const { bodyStream: readable, applicationProps } = renderServerResponseBody(
-      {
-        serverLayout,
-        urlPath: "/app1",
-        renderApplication(props) {
-          const { name } = props;
-          return `<button>App ${name}</button>`;
-        },
-      }
-    );
-
-    let finalHtml = "";
-
-    readable.on("data", (chunk) => {
-      finalHtml += chunk;
-    });
-
-    readable.on("end", () => {
-      expect(finalHtml).toMatchSnapshot();
-      done();
-    });
-
-    readable.read();
-  });
-
-  it(`allows for promises to be returned from renderApplication`, (done) => {
-    const html = fs
-      .readFileSync(
-        path.resolve(process.cwd(), "./test/fixtures/dom-elements.html"),
-        "utf-8"
-      )
-      .toString();
-
-    const serverLayout = constructServerLayout({
-      html,
-    });
-
-    const { bodyStream: readable, applicationProps } = renderServerResponseBody(
-      {
-        serverLayout,
-        urlPath: "/app1",
-        renderApplication(props) {
-          const { name } = props;
-          return Promise.resolve(`<button>App ${name} from promise</button>`);
-        },
-      }
-    );
-
-    let finalHtml = "";
-
-    readable.on("data", (chunk) => {
-      finalHtml += chunk;
-    });
-
-    readable.on("end", () => {
-      expect(finalHtml).toMatchSnapshot();
-      done();
-    });
-
-    readable.read();
-  });
-
-  it(`allows for promises that resolve with streams to be returned from renderApplication`, (done) => {
-    const html = fs
-      .readFileSync(
-        path.resolve(process.cwd(), "./test/fixtures/dom-elements.html"),
-        "utf-8"
-      )
-      .toString();
-
-    const serverLayout = constructServerLayout({
-      html,
-    });
-
-    const { bodyStream: readable, applicationProps } = renderServerResponseBody(
-      {
-        serverLayout,
-        urlPath: "/app1",
-        renderApplication(props) {
-          const { name } = props;
-          return Promise.resolve().then(() => {
-            return Readable.from(`<button>App ${name} from stream</button>`);
-          });
-        },
-      }
-    );
-
-    let finalHtml = "";
-
-    readable.on("data", (chunk) => {
-      finalHtml += chunk;
-    });
-
-    readable.on("end", () => {
-      expect(finalHtml).toMatchSnapshot();
-      done();
-    });
-
-    readable.read();
-  });
-
-  it(`renders an empty string when returned promise from renderApplication rejects`, (done) => {
-    const html = fs
-      .readFileSync(
-        path.resolve(process.cwd(), "./test/fixtures/dom-elements.html"),
-        "utf-8"
-      )
-      .toString();
-
-    const serverLayout = constructServerLayout({
-      html,
-    });
-
-    const { bodyStream: readable, applicationProps } = renderServerResponseBody(
-      {
-        serverLayout,
-        urlPath: "/app1",
-        renderApplication(props) {
-          const { name } = props;
-          return Promise.reject(Error(`render failed`));
-        },
-      }
-    );
-
-    let finalHtml = "";
-
-    readable.on("data", (chunk) => {
-      finalHtml += chunk;
-    });
-
-    readable.on("end", () => {
-      expect(finalHtml).toMatchSnapshot();
-      done();
-    });
-
-    readable.read();
-  });
-
-  it(`renders an empty string when renderApplication throws an error`, (done) => {
-    const html = fs
-      .readFileSync(
-        path.resolve(process.cwd(), "./test/fixtures/dom-elements.html"),
-        "utf-8"
-      )
-      .toString();
-
-    const serverLayout = constructServerLayout({
-      html,
-    });
-
-    const { bodyStream: readable, applicationProps } = renderServerResponseBody(
-      {
-        serverLayout,
-        urlPath: "/app1",
-        renderApplication(props) {
-          const { name } = props;
-          throw Error(`render failed`);
-        },
-      }
-    );
-
-    let finalHtml = "";
-
-    readable.on("data", (chunk) => {
-      finalHtml += chunk;
-    });
-
-    readable.on("end", () => {
-      expect(finalHtml).toMatchSnapshot();
-      done();
-    });
-
-    readable.read();
-  });
-});
diff --git a/test/node-only/sendLayoutHTTPResponse-node.test.js b/test/node-only/sendLayoutHTTPResponse-node.test.js
new file mode 100644
index 0000000..3a5043a
--- /dev/null
+++ b/test/node-only/sendLayoutHTTPResponse-node.test.js
@@ -0,0 +1,665 @@
+import { constructServerLayout } from "../../src/single-spa-layout-server.js";
+import { sendLayoutHTTPResponse } from "../../src/single-spa-layout-server.js";
+import { stringStream } from "../../src/server/sendLayoutHTTPResponse.js";
+import fs from "fs";
+import path, { resolve } from "path";
+import stream, { Readable, Writable } from "stream";
+import _ from "lodash";
+
+describe(`sendLayoutHTTPResponse`, () => {
+  let res, responseBodyPromise;
+
+  beforeEach(() => {
+    let responseBodyStr = "",
+      bodyResolve;
+
+    responseBodyPromise = new Promise((resolve) => (bodyResolve = resolve));
+
+    res = new Writable({
+      write(chunk, encoding, cb) {
+        responseBodyStr += chunk;
+        cb();
+      },
+    });
+    res.on("finish", () => {
+      bodyResolve(responseBodyStr);
+    });
+    res.setHeader = jest.fn();
+  });
+
+  describe("response body", () => {
+    describe(`dom-elements.html fixture`, () => {
+      it(`renders the app1 route correctly`, 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",
+          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`, () => {
+      it(`renders fragments correctly`, async () => {
+        const html = fs
+          .readFileSync(
+            path.resolve(process.cwd(), "./test/fixtures/fragments.html"),
+            "utf-8"
+          )
+          .toString();
+
+        const serverLayout = constructServerLayout({
+          html,
+        });
+
+        await sendLayoutHTTPResponse({
+          res,
+          serverLayout,
+          urlPath: "/app1",
+          renderFragment(name) {
+            const fragStream = new stream.Readable({
+              read() {
+                this.push(
+                  `
+                <script type="systemjs-importmap">
+                  {
+                    "imports": {}
+                  }
+                </script>
+              `.trim()
+                );
+                this.push(null);
+              },
+            });
+            return fragStream;
+          },
+          assembleFinalHeaders() {
+            return {};
+          },
+        });
+
+        expect(await responseBodyPromise).toMatchSnapshot();
+      });
+    });
+
+    describe(`multiple-fragments.html fixture`, () => {
+      it(`preserves correct order of bytes when fragments are synchronous`, async () => {
+        const html = fs
+          .readFileSync(
+            path.resolve(
+              process.cwd(),
+              "./test/fixtures/multiple-fragments.html"
+            ),
+            "utf-8"
+          )
+          .toString();
+
+        const serverLayout = constructServerLayout({
+          html,
+        });
+
+        await sendLayoutHTTPResponse({
+          res,
+          serverLayout,
+          urlPath: "/app1",
+          renderFragment(name) {
+            if (name === "importmap") {
+              return stringStream(
+                `
+              <script type="systemjs-importmap">
+                {
+                  "imports": {}
+                }
+              </script>
+            `.trim()
+              );
+            } else if (name === "head-metadata") {
+              return stringStream(`<meta charset="utf-8">`);
+            } else {
+              throw Error(`Unknown fragment ${name}`);
+            }
+          },
+          assembleFinalHeaders() {
+            return {};
+          },
+        });
+
+        expect(await responseBodyPromise).toMatchSnapshot();
+      });
+
+      it(`preserves correct order of bytes when import map is slow`, async () => {
+        const html = fs
+          .readFileSync(
+            path.resolve(
+              process.cwd(),
+              "./test/fixtures/multiple-fragments.html"
+            ),
+            "utf-8"
+          )
+          .toString();
+
+        const serverLayout = constructServerLayout({
+          html,
+        });
+
+        await sendLayoutHTTPResponse({
+          res,
+          serverLayout,
+          urlPath: "/app1",
+          renderFragment(name) {
+            if (name === "importmap") {
+              const readable = new Readable({ read() {} });
+              setTimeout(() => {
+                readable.push(
+                  `
+                <script type="systemjs-importmap">
+                  {
+                    "imports": {}
+                  }
+                </script>
+              `.trim()
+                );
+                readable.push(null);
+              }, 40);
+              return readable;
+            } else if (name === "head-metadata") {
+              return stringStream(`<meta charset="utf-8">`);
+            } else {
+              throw Error(`Unknown fragment ${name}`);
+            }
+          },
+          assembleFinalHeaders() {
+            return {};
+          },
+        });
+
+        expect(await responseBodyPromise).toMatchSnapshot();
+      });
+
+      it(`preserves correct order of bytes when head-metadata is slow`, async () => {
+        const html = fs
+          .readFileSync(
+            path.resolve(
+              process.cwd(),
+              "./test/fixtures/multiple-fragments.html"
+            ),
+            "utf-8"
+          )
+          .toString();
+
+        const serverLayout = constructServerLayout({
+          html,
+        });
+
+        await sendLayoutHTTPResponse({
+          res,
+          serverLayout,
+          urlPath: "/app1",
+          assembleFinalHeaders() {
+            return {};
+          },
+          renderFragment(name) {
+            if (name === "importmap") {
+              return stringStream(
+                `
+              <script type="systemjs-importmap">
+                {
+                  "imports": {}
+                }
+              </script>
+            `.trim()
+              );
+            } else if (name === "head-metadata") {
+              const readable = new Readable({ read() {} });
+              setTimeout(() => {
+                readable.push(`<meta charset="utf-8">`);
+                readable.push(null);
+              }, 40);
+              return readable;
+            } else {
+              throw Error(`Unknown fragment ${name}`);
+            }
+          },
+        });
+
+        expect(await responseBodyPromise).toMatchSnapshot();
+      });
+
+      it(`preserves correct order of bytes when both fragments are slow`, async () => {
+        const html = fs
+          .readFileSync(
+            path.resolve(
+              process.cwd(),
+              "./test/fixtures/multiple-fragments.html"
+            ),
+            "utf-8"
+          )
+          .toString();
+
+        const serverLayout = constructServerLayout({
+          html,
+        });
+
+        await sendLayoutHTTPResponse({
+          res,
+          serverLayout,
+          urlPath: "/app1",
+          assembleFinalHeaders() {
+            return {};
+          },
+          renderFragment(name) {
+            if (name === "importmap") {
+              const readable = new Readable({ read() {} });
+              setTimeout(() => {
+                readable.push(
+                  `
+                <script type="systemjs-importmap">
+                  {
+                    "imports": {}
+                  }
+                </script>
+              `.trim()
+                );
+                readable.push(null);
+              }, 70);
+              return readable;
+            } else if (name === "head-metadata") {
+              const readable = new Readable({ read() {} });
+              setTimeout(() => {
+                readable.push(`<meta charset="utf-8">`);
+                readable.push(null);
+              }, 40);
+              return readable;
+            } else {
+              throw Error(`Unknown fragment ${name}`);
+            }
+          },
+        });
+
+        expect(await responseBodyPromise).toMatchSnapshot();
+      });
+
+      it(`doesn't wait for the first fragment stream to finish before starting to read the second fragment`, async () => {
+        const html = fs
+          .readFileSync(
+            path.resolve(
+              process.cwd(),
+              "./test/fixtures/multiple-fragments.html"
+            ),
+            "utf-8"
+          )
+          .toString();
+
+        const serverLayout = constructServerLayout({
+          html,
+        });
+
+        let renderedFragments = [];
+
+        await sendLayoutHTTPResponse({
+          res,
+          serverLayout,
+          urlPath: "/app1",
+          assembleFinalHeaders() {},
+          renderFragment(name) {
+            renderedFragments.push(name);
+            if (name === "importmap") {
+              const readable = new Readable({ read() {} });
+              readable.push(
+                `
+              <script type="systemjs-importmap">
+                {
+                  "imports": {}
+                }
+              </script>
+            `.trim()
+              );
+              readable.push(null);
+              return readable;
+            } else if (name === "head-metadata") {
+              const readable = new Readable({ read() {} });
+              setTimeout(() => {
+                // renderFragment should have been called for the import map even though
+                // we haven't finished the head-metadata's stream yet.
+                expect(renderedFragments).toEqual([
+                  "head-metadata",
+                  "importmap",
+                ]);
+
+                readable.push(`<meta charset="utf-8">`);
+                readable.push(null);
+              }, 40);
+              return readable;
+            } else {
+              throw Error(`Unknown fragment ${name}`);
+            }
+          },
+        });
+
+        expect(await responseBodyPromise).toMatchSnapshot();
+      });
+
+      it(`allows renderFragment to return a string`, async () => {
+        const html = fs
+          .readFileSync(
+            path.resolve(process.cwd(), "./test/fixtures/fragments.html"),
+            "utf-8"
+          )
+          .toString();
+
+        const serverLayout = constructServerLayout({
+          html,
+        });
+
+        await sendLayoutHTTPResponse({
+          res,
+          assembleFinalHeaders() {
+            return {};
+          },
+          serverLayout,
+          urlPath: "/app1",
+          renderFragment(name) {
+            return `
+            <script type="systemjs-importmap">
+              {
+                "imports": {}
+              }
+            </script>
+          `.trim();
+          },
+        });
+
+        expect(await responseBodyPromise).toMatchSnapshot();
+      });
+    });
+
+    it(`allows for strings to be returned from renderApplication`, 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",
+        assembleFinalHeaders() {
+          return {};
+        },
+        renderApplication({ appName, propsPromise }) {
+          return `<button>App ${appName}</button>`;
+        },
+        retrieveApplicationHeaders({ appName, propsPromise }) {
+          return {};
+        },
+      });
+
+      expect(await responseBodyPromise).toMatchSnapshot();
+    });
+
+    it(`allows for promises to be returned from renderApplication`, 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",
+        assembleFinalHeaders() {
+          return {};
+        },
+        renderApplication({ appName, propsPromise }) {
+          return Promise.resolve(
+            `<button>App ${appName} from promise</button>`
+          );
+        },
+        retrieveApplicationHeaders({ appName }) {
+          return {};
+        },
+      });
+
+      expect(await responseBodyPromise).toMatchSnapshot();
+    });
+
+    it(`allows for promises that resolve with streams to be returned from renderApplication`, 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",
+        assembleFinalHeaders() {
+          return {};
+        },
+        renderApplication({ appName }) {
+          return Promise.resolve().then(() => {
+            return Readable.from(`<button>App ${appName} from stream</button>`);
+          });
+        },
+        retrieveApplicationHeaders() {
+          return {};
+        },
+      });
+
+      expect(await responseBodyPromise).toMatchSnapshot();
+    });
+
+    it(`renders an empty string when returned promise from renderApplication rejects`, 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",
+        assembleFinalHeaders() {
+          return {};
+        },
+        renderApplication({ appName }) {
+          return Promise.reject(Error(`render failed`));
+        },
+        retrieveApplicationHeaders({ appName }) {
+          return {};
+        },
+      });
+
+      expect(await responseBodyPromise).toMatchSnapshot();
+    });
+
+    it(`renders an empty string when renderApplication throws an error`, 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",
+        assembleFinalHeaders() {
+          return {};
+        },
+        renderApplication({ appName }) {
+          throw Error(`render failed`);
+        },
+        retrieveApplicationHeaders({ appName }) {
+          return {};
+        },
+      });
+
+      expect(await responseBodyPromise).toMatchSnapshot();
+    });
+  });
+
+  describe("response headers", () => {
+    it(`uses the headers from assembleFinalHeaders`, 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",
+        retrieveApplicationHeaders(props) {
+          return { should: "ignore" };
+        },
+        assembleFinalHeaders(allHeaders) {
+          return { final: "headers", "content-type": "text/html" };
+        },
+        renderApplication({ appName, propsPromise }) {
+          return appName;
+        },
+      });
+
+      expect(res.setHeader).toHaveBeenCalledTimes(2);
+      expect(res.setHeader.mock.calls[0]).toEqual(["final", "headers"]);
+      expect(res.setHeader.mock.calls[1]).toEqual([
+        "content-type",
+        "text/html",
+      ]);
+    });
+
+    it(`should call retrieveApplicationHeaders with correct arguments`, 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",
+        retrieveApplicationHeaders({ appName, propsPromise }) {
+          expect(typeof appName).toBe("string");
+          expect(propsPromise instanceof Promise).toBe(true);
+          return { should: "ignore" };
+        },
+        assembleFinalHeaders(allHeaders) {
+          return { final: "headers", "content-type": "text/html" };
+        },
+        renderApplication({ appName, propsPromise }) {
+          return appName;
+        },
+      });
+
+      expect(res.setHeader).toHaveBeenCalledTimes(2);
+      expect(res.setHeader.mock.calls[0]).toEqual(["final", "headers"]);
+      expect(res.setHeader.mock.calls[1]).toEqual([
+        "content-type",
+        "text/html",
+      ]);
+    });
+
+    it(`should call assembleFinalHeaders with correct arguments`, 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",
+        retrieveApplicationHeaders({ appName, propsPromise }) {
+          return { [appName]: true };
+        },
+        assembleFinalHeaders(allHeaders) {
+          expect(allHeaders).toEqual([
+            { appHeaders: { header: true }, appProps: { name: "header" } },
+            { appHeaders: { app1: true }, appProps: { name: "app1" } },
+          ]);
+          return {};
+        },
+        renderApplication({ appName, propsPromise }) {
+          return appName;
+        },
+      });
+    });
+  });
+});
diff --git a/test/node-only/setResponseHeaders-node.test.js b/test/node-only/setResponseHeaders-node.test.js
deleted file mode 100644
index da5a132..0000000
--- a/test/node-only/setResponseHeaders-node.test.js
+++ /dev/null
@@ -1,89 +0,0 @@
-import { setResponseHeaders } from "../../src/server/setResponseHeaders";
-
-describe(`setResponseHeaders`, () => {
-  it(`correctly validates input`, async () => {
-    const fakeRes = createFakeRes();
-    await expect(
-      setResponseHeaders({
-        applicationProps: [{ name: "app1" }],
-        res: fakeRes,
-        retrieveApplicationHeaders() {
-          return {};
-        },
-        mergeHeaders() {
-          return {};
-        },
-      })
-    ).resolves.toBeUndefined();
-
-    await expect(setResponseHeaders).rejects.toThrow();
-    await expect(() => setResponseHeaders({})).rejects.toThrow();
-    await expect(() =>
-      setResponseHeaders({
-        applicationProps: null,
-        res: fakeRes,
-        retrieveApplicationHeaders() {},
-        mergeHeaders() {},
-      })
-    ).rejects.toThrow();
-  });
-
-  it(`calls merge headers on all the returned application headers`, async () => {
-    const fakeRes = createFakeRes();
-
-    const retrievedHeaders = [];
-
-    await setResponseHeaders({
-      applicationProps: [{ name: "app1" }, { name: "app2" }],
-      res: fakeRes,
-      retrieveApplicationHeaders(props) {
-        retrievedHeaders.push(props.name);
-        return { name: props.name };
-      },
-      mergeHeaders(headers) {
-        expect(headers.map((h) => h.name)).toEqual(retrievedHeaders);
-        return {};
-      },
-    });
-  });
-
-  it(`calls res.setHeader with all the final headers`, async () => {
-    const res = createFakeRes();
-
-    const retrievedHeaders = [];
-
-    await setResponseHeaders({
-      applicationProps: [{ name: "app1" }, { name: "app2" }],
-      res,
-      retrieveApplicationHeaders(props) {
-        retrievedHeaders.push(props.name);
-        return {};
-      },
-      mergeHeaders(headers) {
-        return {
-          "content-type": "application/javascript",
-          "content-encoding": "gzip",
-        };
-      },
-    });
-
-    expect(res.setHeader).toHaveBeenCalledTimes(2);
-    expect(res.setHeader).toHaveBeenNthCalledWith(
-      1,
-      "content-type",
-      "application/javascript"
-    );
-    expect(res.setHeader).toHaveBeenNthCalledWith(
-      2,
-      "content-encoding",
-      "gzip"
-    );
-  });
-});
-
-function createFakeRes() {
-  return {
-    status: jest.fn(),
-    setHeader: jest.fn(),
-  };
-}
diff --git a/test/single-spa-layout.test-d.ts b/test/single-spa-layout.test-d.ts
index c927c24..4416780 100644
--- a/test/single-spa-layout.test-d.ts
+++ b/test/single-spa-layout.test-d.ts
@@ -16,14 +16,14 @@ import {
 } from "../src/single-spa-layout-interface";
 import {
   constructServerLayout,
-  renderServerResponseBody,
-  setResponseHeaders,
+  sendLayoutHTTPResponse,
 } from "../src/server/index";
 import { parse, Element, DefaultTreeDocument } from "parse5";
 import { ResolvedUrlRoute } from "../src/isomorphic/constructRoutes";
 import { JSDOM } from "jsdom";
 import stream from "stream";
-import http, { OutgoingHttpHeaders } from "http";
+import http, { ClientRequest, IncomingMessage, ServerResponse } from "http";
+import { Socket } from "net";
 
 const { window } = new JSDOM(`
 <!DOCTYPE html>
@@ -142,36 +142,28 @@ constructServerLayout({
 });
 expectError(constructServerLayout());
 
-expectType<stream.Readable>(
-  renderServerResponseBody({
+let res: ServerResponse = new ServerResponse(new IncomingMessage(new Socket()));
+
+expectType<Promise<any>>(
+  sendLayoutHTTPResponse({
+    res,
     serverLayout,
     urlPath: "/app1",
-    renderApplication(props) {
+    assembleFinalHeaders() {
+      return {};
+    },
+    renderApplication(arg) {
+      expectType<string>(arg.appName);
       return new stream.Readable();
     },
+    retrieveApplicationHeaders(arg) {
+      return { hi: "there" };
+    },
+    retrieveProp(name) {
+      return "hi";
+    },
+    renderFragment(name) {
+      return "hi";
+    },
   })
 );
-
-renderServerResponseBody({
-  serverLayout,
-  urlPath: "/app1",
-  renderApplication(props) {
-    return new stream.Readable();
-  },
-  renderFragment(name) {
-    return new stream.Readable();
-  },
-});
-
-const res = http.request("/fake.js");
-
-setResponseHeaders({
-  applicationProps: [{ name: "app1" }, { name: "app2" }],
-  retrieveApplicationHeaders(props: AppProps) {
-    return { "content-type": "application/javascript" };
-  },
-  mergeHeaders(headers: OutgoingHttpHeaders) {
-    return headers[0];
-  },
-  res,
-});