Webpack Federation SSR

ebey_jacob/July 12th, 2021

Webpack Module Federation has been a game changer in the micro-frontend space allowing multiple SPA's to operate as one cohesive bundle. This has also enabled separately bundled and deployed SSR'd applications to make client side transitioning between them just as seamless as if they were bundled and deployed together.

The missing piece comes when you want to SSR and share at a more granular level than the "page". There are two solutions to this problem, code streaming, and pre-render services. Code streaming has been gaining attention but I would like to focus on how we can solve this with an auto generated prerender service using Webpack.

TLDR;

Example Source Code

  • Auto-generate a pre-render route handler as a virtual module you can include in a server route.
  • Use react 18 lazy or a pre-pass solution to collect federated components you are rendering.
  • Do a POST call to the remote's auto-generated pre-render route to gather the SSR'd HTML.
  • Replace children placeholder with the children the host's usage may have rendered.
  • Add style tags and optionally lazy load scripts for remote components.
  • On hydration consume federated component from remote as usual (might need a hack to preserve HTML while loading if not using react 18).

Webpack Plugin

Note: this is a good workaround when you don't have full control over your webpack runtime and have issues with generating or consuming a server container. The prefered method is to just generate the server remote-entry and consume it through the low level federation API as found here: https://github.com/jacob-ebey/esbuild-federation-example/blob/main/webpack-remote/api/vercel-prerender.js.

To auto generate our pre-render handler we will use a little custom webpack plugin built around webpack-virtual-modules. It will accept the "exposes" option you would pass to the ModuleFederationPlugin and generate a virtual module aliased as "federated-prerender" that you can include in your server.

const VirtualModulesPlugin = require("webpack-virtual-modules");
class FederatedPrerender {
/**
*
* @param {{
* dir?: string;
* exposes: import("webpack").container.ModuleFederationPluginOptions;
* statFile: string;
* }} options
*/
constructor(options) {
this.options = options;
}
/**
*
* @param {import("webpack").Compiler} compiler
*/
apply(compiler) {
const dir = this.options.dir || process.cwd();
const exposes = this.options.exposes || {};
const statFile = this.options.statFile;
// Get the location of the module. This is so the relative imports defined in exposes work.
const virtualMod = path.join(dir, "__federated-prerender.js");
// Assign it to an alias for easier import in the server.
compiler.options.resolve.alias = compiler.options.resolve.alias || {};
compiler.options.resolve.alias["federated-prerender"] = virtualMod;
// Generate the code for the prerender module.
const code = generateVirtualModule(exposes, statFile);
// Use the virutal modules plugin to add it to the build.
new VirtualModulesPlugin({ [virtualMod]: code }).apply(compiler);
}
}

Next up is implementing the "generateVirtualModule" function. The overview of the generated module is:

  • Create a lazy import map of the exposes.
  • Non-webpack-require (i.e force a "require" statement) the federated stats file so the callers can choose to pre-load chunks.
  • Generate the default export that will be the middleware / request handler for your server.

This is a bit more involved and I'm not going to go in-depth, but here is the code:

function generateVirtualModule(exposes, statFile) {
return Template.asString([
`import path from "path";`,
`import { createElement } from "react";`,
`import { renderToStaticMarkup } from "react-dom/server";`,
"",
// Generate the request map
"const requestMap = {",
Template.indent(
Object.entries(exposes).map(
([key, mod]) =>
`[${JSON.stringify(key)}]: () => import(${JSON.stringify(mod)}),`
)
),
"};",
"",
// Get the exposes from the stats file
`const exposes = __non_webpack_require__(path.resolve(__dirname, ${JSON.stringify(statFile)})).federatedModules.find(f => f.remote === ${JSON.stringify(
name
)}).exposes;`,
"",
// Get the chunks for an exposed module
`function getChunksForExposed(exposed) {
return exposes[exposed].reduce((p, c) => {
p.push(...c.chunks);
return p;
}, []);
}`,
"",
// Generate the route handler
"export default async function nextFederatedPrerender(req, res) {",
Template.indent([
// Bail if not a post method
`if (req.method !== "POST") {`,
Template.indent(["res.status(405);", "res.send();", "return;"]),
"}",
"",
"const mod = req.body.module;",
// Bail if not in the request map
`if (!requestMap[mod]) {`,
Template.indent(["res.status(404);", "res.send();", "return;"]),
"}",
"try {",
Template.indent([
// Get the component from the module
"const chunks = getChunksForExposed(mod);",
"let Component = await requestMap[mod]();",
"Component = (Component && Component.default) || Component;",
// Render it to HTML with the props and a placeholder marker for the children.
"const html = renderToStaticMarkup(createElement(Component, req.body.props, `\u200Cchildren\u200C`));",
"res.status(200);",
"res.json({ chunks, html });",
]),
"} catch (err) {",
Template.indent([
"console.error(err);",
"res.status(500);",
"res.send();",
"return;",
]),
"}",
]),
"}",
]);
}

You can now import "federated-prerender" into your server. For Next.js users this can be re-exported from a "/api/federated-prerender.js" file directly. For express users, this can be included as a middleware.

Wrapping React.lazy

On the consuming application side, we are going to use React 18 (alpha at the time of writing) and take advantage of React.lazy on the server to avoid a pre-pass render.

We will also take advantage of html-to-react to allow us to render children inside of the SSR federated components and json-stringify-deterministic to generate IDs.

The flow is going to go something like this:

  • SSR use React.lazy to suspend and do a HTTP post request to our remote pre-render service.
  • Parse HTML using the html-to-react package replacing children marker with actual children.
  • Before hydration lift inline link tags to the head to avoid style flicker.
  • On hydration suspend and load the actual federated module.

Let's start with the higher order component factory that will be used kind of like React.lazy or "dynamic" from your favorite react frameworks:

const Header = federatedComponent("webpackRemote", "./header");

This allows us to use the low level federation API under the hood on the client, while doing the HTTP post calls on SSR to the pre-render service and re-constructing the vDOM.

import React, { createContext, lazy, useContext } from "react";
import { Parser, ProcessNodeDefinitions } from "html-to-react";
import stringify from "json-stringify-deterministic";
import fetch from "node-fetch";
import { initSharing, shareScopes } from "@runtime/federation";
export const context = createContext({});
export default function federatedComponent(
remote,
module,
shareScope = "default"
) {
const FederatedComponent = ({ children, ...props }) => {
const ctx = useContext(context);
let Component;
if (typeof window !== "undefined") {
Component = getClientComponent(ctx, remote, module, shareScope);
}
if (typeof window === "undefined") {
Component = getServerComponent(ctx, remote, module, props);
}
return <Component {...props}>{children}</Component>;
};
return FederatedComponent;
}

We will start with the getClientComponent implementation as it's fairly straight forward. I will be using esbuild-federation-share-scope for the host application, but will include the webpack API in the examples as well:

function getClientComponent(ctx, remote, module, shareScope) {
ctx[remote] = ctx[remote] || {};
let Component = ctx[remote][module];
if (!Component) {
Component = ctx[remote][module] = lazy(() =>
initSharing(shareScope)
.then(() => window[remote].init(shareScopes[shareScope]))
// __webpack_init_sharing__(shareScope)
// .then(() => window[remote].init(__webpack_share_scopes__[shareScope]))
.then(() => window[remote].get(module))
.then((factory) => factory())
);
}
return Component;
}

The breakdown of the above is as follows:

  • Initialize underlying share scope.
  • Initialize the remote with the host share scope.
  • Load the module from the remote.
  • Execute the module factory to get the component.

This should be familiar to anyone who has worked with the low level Webpack federation API before, but now we will dive into the server part that is probably going to be new to most people.

function getServerComponent(ctx, remote, module, props) {
// We cache based on properties. This allows us to only
// do one fetch for multiple references of a remote component.
const id = stringify({ remote, module, props });
let Component = ctx[id];
if (!Component) {
Component = ctx[id] = lazy(() =>
// Do the post request to pre-render the federated component
fetch(`${process.env.REMOTE_HOSTS[remote]}/prerender`, {
method: "post",
headers: {
"content-type": "application/json",
},
body: JSON.stringify({
module,
props,
}),
})
.then((res) => res.json())
.then(({ chunks, html }) => {
// Create an instance of the html->react parser
const processNodeDefinitions = new ProcessNodeDefinitions(React);
const parser = new Parser();
return {
default: ({ children }) => {
const parseInstructions = [
{
shouldProcessNode: (node) => {
// If the pre-rendered component rendered a children placeholder,
// we will process this ourselves.
if (
node?.type === "text" &&
node.data === "\u200Cchildren\u200C"
) {
return true;
}
return false;
},
processNode: (_, __, index) => {
// Instead of retaining the children placeholder, render out
// the children components. This even allows for recursive
// federated components!
return (
<React.Fragment key={index}>{children}</React.Fragment>
);
},
},
{
// Process all other nodes with the lib defaults.
shouldProcessNode: () => true,
processNode: processNodeDefinitions.processDefaultNode,
},
];
// Turn the pre-rendered HTML string into a react element
// while rendering out the children.
const reactElement = parser.parseWithInstructions(
html,
() => true,
parseInstructions
);
return (
<>
{/* Add style chunks and async script tags for the script chunks. */}
{chunks.map((chunk) =>
chunk.endsWith(".css") ? (
<link
key={chunk}
rel="stylesheet"
href={`${process.env.REMOTE_HOSTS[remote]}/build/${chunk}`}
/>
) : (
<script
key={chunk}
async
src={`${process.env.REMOTE_HOSTS[remote]}/build/${chunk}`}
/>
)
)}
{/* Render the re-constructed react element */}
{reactElement}
</>
);
},
};
})
);
}
return Component;
}

The code above is commented pretty well, but here is the breakdown:

  • Create a cache key based on remote, module and instance properties to avoid duplicated requests.
  • Reconstruct the vdom from the pre-rendered HTML and replace the children placeholder with the children to allow them to participate in the react tree as well as recursively render federated components.
  • Render re-constructed vdom alongside link tags for the federated component styles and async script tags to "preload" the federated module.

Avoiding Style Jank

Webpack style loaders such as mini-css-extract-plugin only look for style links in the head, so to avoid a flicker from the hydration removing the link to the time webpack loads the chunk again we can lift these links rendered to the body up to the head with this little snippet before we hydrate our application:

const links = document.body.getElementsByTagName("link");
for (let link of links) {
document.head.appendChild(link);
}
const root = createRoot(document, { hydrate: true });
root.render(<App />);

Summing it up

Using Webpack we can easily generate a pre-render service for our federated components if we don't have the ability to directly consume from a node build of the remote-entry. We can use this combined with React 18 lazy on the server to consume the latest deployed version of a remote at runtime with zero deployments of the host. This is all brought together with html-to-react to allow for recursive rendering and the children of the pre-rendered remote component to participate in the react tree and use things like context.

If your edge provider allows for caching on POST requests, you could even potentially cache the pre-render of the federated components at the edge based on the request body.

Check out the example at https://github.com/jacob-ebey/esbuild-federation-example