The Feature Hub is an opinionated JavaScript implementation of the micro frontends approach to creating scalable web applications with multiple teams and different technologies.
This monorepo contains a collection of packages that can be used together as a full-fledged solution for composing micro frontends. It supports React micro frontends as first-class citizens, but also allows the integration of micro frontends that are built with any other frontend technology (e.g. Vue.js, Angular, Web Components).
In fact, the @feature-hub/core
package is totally independent of
React. It could be used to create an end-to-end solution with any other frontend
technology.
This software is in active development and is subject to potentially breaking changes. It is not yet used in production. The currently planned milestones can be viewed here.
- Feature Hub
The Feature Hub has been created by SinnerSchrader as part of our client work. In order to facilitate collaboration and reusability, we decided to publish the core functionality of our micro frontend solution as open source.
We've seen many teams create front-end monoliths — a single, large and sprawling browser application — on top of their back-end services. Our preferred (and proven) approach is to split the browser-based code into micro frontends. In this approach, the web application is broken down into its features, and each feature is owned, frontend to backend, by a different team. This ensures that every feature is developed, tested and deployed independently from other features. — thoughtworks.com
The Feature Hub was designed with the following specific requirements in mind:
- Multiple teams with different technologies and knowledge should be able to own, develop, and deploy composable features independently.
- Multiple micro frontends need a way to safely interact with singleton browser APIs like the URL/history or localStorage.
- Micro frontends must be able to share state to facilitate a consistent UX.
- Examples for features needing shared state are: a manager for ensuring only one modal is open at a time, or multiple micro frontends that display information about the same product selected in one of the micro frontends.
- For SEO purposes, and to operate existing fat client frontend apps which need
to fetch great amounts of data on boot, server-side rendering must be
supported.
- Because of asynchronous data fetching and shared state changes, the server-side rendering engine needs to be able to determine the point in time at which it can send the fully rendered UI and its corresponding state to the client.
- The server-side rendered UI and its corresponding state must be hydrated on the client without visual impact.
- Micro frontends that are incompatible with the integration environment should fail early, and not just when the user interacts with the specific incompatible feature.
- It should be possible to compose micro frontends without deployment of the integration environment.
In this implementation, a micro frontend is referred to as a Feature App.
A Feature App encapsulates a composable and reusable UI feature. It may have the need to share state with other Feature Apps.
A Feature Service provides shared state and functionality to consumers, e.g. Feature Apps, on the Feature Hub. While simple code sharing should be achieved by creating libraries, there are features that can only, or more easily, be achieved by creating Feature Services:
- Share state across Feature Apps to ensure a consistent user experience.
- Share browser APIs and resources not intended for shared use (e.g. History, LocalStorage).
- Share configuration across Feature Apps, but only maintain it once.
Furthermore, Feature Services provide a versioned API to allow backward compatibility and thus flexible and decoupled deployments of different consumers.
Package | Version | Description | API |
---|---|---|---|
@feature-hub/core | The core functionality of the Feature Hub. | đź“– | |
@feature-hub/react | A Feature Hub integrator for React. | đź“– | |
@feature-hub/module-loader | A universal module loader. | đź“– | |
@feature-hub/history-service | A history facade guaranteeing safe access for multiple consumers. | đź“– | |
@feature-hub/server-renderer | A Feature Service to manage server-side rendering. | đź“– | |
@feature-hub/demos | A collection of demos for the Feature Hub. |
There are three different roles in a Feature Hub environment:
- An integrator instantiates the Feature Hub components and provides the Feature App compositions.
- A provider provides a Feature Service to consumers through the
FeatureServiceRegistry
. Most providers are registered by the integrator but they can also be registered by Feature Apps. - A consumer is everyone who consumes Feature Services. This can be a Feature App, other Feature Services, or even the integrator.
A Feature App must be bundled as a UMD module
and deployed to a publicly available endpoint. The integrator uses the bundle
URL to place the Feature App onto a page using a Feature App loader, e.g. the
React FeatureAppLoader
.
The default export of a Feature App module must be a definition object. It
consists of an id
, an optional dependencies
object, and the create
method:
export default {
id: 'acme:my-feature-app',
dependencies: {
'acme:some-feature-service': '^2.0'
},
create(env) {
// ...
}
};
A Feature App definition must declare an id
. It is recommended to use
namespaces for the Feature App ID to avoid naming conflicts, e.g.:
const id = 'acme:my-feature-app';
This ID is used to look up the config for a Feature App. Furthermore, it is used as a consumer ID for the bindings of the Feature Services that a Feature App depends on.
If there is more than one instance of a Feature App on a single page, the
integrator must set a unique ID specifier for each Feature App with the same ID.
The FeatureServiceRegistry
then uses the ID together with the ID specifier to
create a unique consumer ID.
In dependencies
, required Feature Services are declared with their ID and a
semver version string:
const dependencies = {
'acme:some-feature-service': '^2.0'
};
The create
method takes the single argument env
, which has the following
properties:
config
— a Feature App config object that is provided by the integrator.featureServices
— an object of required Feature Services that are semver-compatible with the declared dependencies in the Feature App definition.idSpecifier
— an optional ID specifier that distinguishes the Feature App instance from other Feature App instances with the same ID.
Assuming the @feature-hub/react
package is used, a Feature App
can be either a "React Feature App" or a "DOM Feature App":
-
A React Feature App definition's
create
method returns a Feature App instance with arender
method that itself returns aReactNode
.export default { id, dependencies, create(env) { return { render: () => <div>Foo</div> }; } };
Note: Since this element is directly rendered by React, the standard React lifecyle methods can be used (if
render
returns an instance of a React component class). -
A DOM Feature App definition's
create
method returns a Feature App instance with anattachTo
method that accepts a DOM container element.export default { id, dependencies, create(env) { return { attachTo(container) { container.innerText = 'Foo'; } }; } };
A Feature App can also register its own Feature Services by declaring
ownFeatureServiceDefinitions
, e.g.:
import {myFeatureServiceDefinition} from './my-feature-service';
export default {
id: 'acme:my-feature-app',
dependencies: {
'acme:my-feature-service': '^1.0'
},
ownFeatureServiceDefinitions: [myFeatureServiceDefinition],
create(env) {
const myFeatureService = env.featureServices['acme:my-feature-service'];
myFeatureService.init(42);
return {
render: () => <div>{myFeatureService.getSomeSharedState()}</div>
};
}
};
This allows teams to quickly get Feature Apps off the ground, without being dependent on the integrator. However, as soon as other teams need to use this Feature Service, it should be published and included in the global set of Feature Services by the integrator.
When multiple Feature Apps coexist on the same page, they shouldn't access the browser history API directly. Otherwise, they would potentially overwrite their respective history and location changes. To enable a save access to the history for multiple consumers, the History Service can be used.
If the integrator has provided externals to Feature
Apps, they should define these externals in their build config. For example,
defining react
as external in a webpack config would look like this:
{
"externals": {
"react": "react"
}
}
A Feature Service module must export a definition object. It consists of an
id
, an optional dependencies
object, and the create
method:
export const myFeatureServiceDefinition = {
id: 'acme:my-feature-service',
dependencies: {
'acme:other-feature-service': '^2.0'
},
create(env) {
// ...
}
};
A Feature Service definition must declare an id
. It is recommended to use
namespaces for the Feature Service ID to avoid naming conflicts, e.g.:
const id = 'acme:my-feature-service';
This ID is used to look up the config for a Feature Service. Furthermore, it is used as a consumer ID for the bindings of the Feature Services that a Feature Service depends on.
In dependencies
, required Feature Services are declared with their ID and a
semver version string:
const dependencies = {
'acme:other-feature-service': '^2.0'
};
The create
method of a Feature Service definition is called exactly once by
the FeatureServiceRegistry
. It should store, and possibly initialize, any
shared state. The method takes the single argument env
, which has the
following properties:
config
— a Feature Service config object that is provided by the integrator.featureServices
— an object of required Feature Services that are semver-compatible with the declared dependencies in the Feature App definition.
A Feature Service provider can support multiple major versions at the same time
which have access to the same underlying shared state. The create
method must
return an object with a so-called Feature Service binder for each supported
major version. The Feature Service binder is a function that is called for each
consumer. It returns a Feature Service binding with a consumer-bound
featureService
and an optional unbind
method. The FeatureServiceRegistry
passes the bound Feature Service to the consumer's create
method.
With this in mind, a simple counter Feature Service could look like this:
function create(env) {
let count = env.config.initialCount || 0;
const v1 = uniqueConsumerId => ({
featureService: {
plus() {
count += 1;
},
minus() {
count -= 1;
}
}
});
return {'1.0': v1};
}
Let's say after the first release of this Feature Service, the Feature Service
provider noticed that there is no way to retrieve the current count. Therefore,
they introduce the getCount
method in version 1.1
:
function create(env) {
let count = env.config.initialCount || 0;
const v1 = uniqueConsumerId => ({
featureService: {
plus() {
count += 1;
},
minus() {
count -= 1;
},
getCount() {
return count;
}
}
});
return {'1.1': v1};
}
The version of a Feature Service needs to be incremented in a semver-compatible manner (without the need for a patch version). In this case, a method is added, leading to a minor version bump.
In general, breaking changes should be avoided. If a Feature Service provider still needs to make breaking changes, a new Feature Service implementation for the next major version should be added. Old major versions should still be supported.
Furthermore, it is possible to add deprecation warnings, and later remove deprecated APIs.
In our example the Feature Service provider decides to rename the plus
/minus
methods to increment
/decrement
and adds deprecation warnings:
function create(env) {
let count = env.config.initialCount || 0;
const getCount = () => count;
const decrement = () => void --count;
const increment = () => void ++count;
const v1 = uniqueConsumerId => ({
featureService: {
getCount,
plus() {
console.warn('Deprecation warning: use increment instead of plus.');
increment();
},
minus() {
console.warn('Deprecation warning: use decrement instead of minus.');
decrement();
}
}
});
const v2 = uniqueConsumerId => ({
featureService: {getCount, increment, decrement}
});
return {'1.1': v1, '2.0': v2};
}
Declaring a Feature Service binder (for each major version) allows Feature Service providers to create and destroy consumer-specific state.
Let's assume our counter Feature Service, instead of handling a global count, is supposed to handle consumer-specific counts, as well as expose a total of all consumer-specific counts.
With our Feature Service binders, this could be implemented like this:
function create(env) {
// Shared state lives here.
let consumerCounts = {};
return {
'1.0': uniqueConsumerId => {
// Consumer state lives here.
consumerCounts[uniqueConsumerId] = 0;
const unbind = () => {
delete consumerCounts[uniqueConsumerId];
};
const featureService = {
increment() {
consumerCounts[uniqueConsumerId] += 1;
},
decrement() {
consumerCounts[uniqueConsumerId] -= 1;
},
get count() {
return consumerCounts[uniqueConsumerId];
},
get totalCount() {
return Object.values(consumerCounts).reduce(
(totalCount, consumerCount) => totalCount + consumerCount,
0
);
}
};
return {featureService, unbind};
}
};
}
The @feature-hub/core
package provides the following two major
building blocks:
- The
FeatureServiceRegistry
: A class for providing Feature Services to dependent consumers. - The
FeatureAppManager
: A class for managing the lifecycle of Feature Apps.
There are a few steps an integrator needs to follow to compose a web page of multiple Feature Apps that share state through Feature Services:
- Instantiate a
FeatureServiceRegistry
singleton instance. - Register a set of Feature Services at the
FeatureServiceRegistry
. - Instantiate a
FeatureAppManager
singleton instance with theFeatureServiceRegistry
.
A typical integrator bootstrap code would look like this:
import {FeatureAppManager, FeatureServiceRegistry} from '@feature-hub/core';
const registry = new FeatureServiceRegistry();
const featureServiceDefinitions = [
sampleFeatureServiceDefinition1, // import definitions from somewhere
sampleFeatureServiceDefinition2
];
registry.registerProviders(featureServiceDefinitions, 'integrator');
const manager = new FeatureAppManager(registry);
A React integrator can then use the FeatureAppLoader
or the
FeatureAppContainer
(both from the @feature-hub/react
package) to place
Feature Apps onto the web page. Each of them need the FeatureAppManager
singleton instance to render their Feature App.
With the React FeatureAppLoader
a Feature App can be loaded and rendered by
defining a src
which is the URL to its JavaScript UMD bundle, e.g.:
import {FeatureAppLoader} from '@feature-hub/react';
<FeatureAppLoader
manager={manager}
src="https://example.com/my-feature-app.js"
/>
Additionally, when a Feature App wants to be rendered on the server, its
nodeSrc
must be specified, which is the URL to its CommonJS bundle (targeted
at Node.js):
<FeatureAppLoader
manager={manager}
src="https://example.com/my-feature-app.js"
nodeSrc="https://example.com/my-feature-app-node.js"
/>
Note: Server-side rendering of Feature Apps is not fully supported yet.
You can also define a css
prop to add stylesheets to the document.
<FeatureAppLoader
manager={manager}
src="https://example.com/my-feature-app.js"
css={[
{href: 'https://example.com/my-feature-app.css'},
{href: 'https://example.com/my-feature-app-print.css', media: 'print'}
]}
/>
If multiple instances of the same Feature App must be placed onto a single page,
a idSpecifier
that is unique for the Feature App id
must be defined by the
integrator, e.g.:
<section>
<div>
<FeatureAppLoader
manager={manager}
src="https://example.com/my-feature-app.js"
idSpecifier="main"
/>
</div>
<aside>
<FeatureAppLoader
manager={manager}
src="https://example.com/my-feature-app.js"
idSpecifier="aside"
/>
</aside>
</section>
With the React FeatureAppContainer
a Feature App can be rendered by directly
providing its Feature App definition:
import {FeatureAppContainer} from '@feature-hub/react';
import {myFeatureAppDefinition} from './my-feature-app';
<FeatureAppContainer
manager={manager}
featureAppDefinition={myFeatureAppDefinition}
/>
This allows the integrator to bundle Feature Apps, instead of loading them from a remote location.
If multiple instances of the same Feature App must be placed onto a single page,
a idSpecifier
that is unique for the Feature App id
must be defined by the
integrator, e.g.:
<section>
<div>
<FeatureAppContainer
manager={manager}
featureAppDefinition={myFeatureAppDefinition}
idSpecifier="main"
/>
</div>
<aside>
<FeatureAppContainer
manager={manager}
featureAppDefinition={myFeatureAppDefinition}
idSpecifier="aside"
/>
</aside>
</section>
The integrator can provide config objects for Feature Services and Feature Apps,
associated by their respective IDs, via the FeatureServiceRegistry
and
FeatureAppManager
:
const featureServiceConfigs = {'acme:my-feature-service': {foo: 'bar'}};
const featureAppConfigs = {'acme:my-feature-app': {baz: 'qux'}};
const registry = new FeatureServiceRegistry({configs: featureServiceConfigs});
const manager = new FeatureAppManager(registry, {configs: featureAppConfigs});
Feature Services and Feature Apps can then use their respective config object as follows:
const myFeatureServiceDefinition = {
id: 'acme:my-feature-service',
create(env) {
const {foo} = env.config; // foo is 'bar'
// ...
}
};
const myFeatureAppDefinition = {
id: 'acme:my-feature-app',
create(env) {
const {baz} = env.config; // baz is 'qux'
// ...
}
};
When using the browser module loader, the integrator can provide shared npm
dependencies to Feature Apps using the defineExternals
function:
import {defineExternals, loadAmdModule} from '@feature-hub/module-loader';
import * as React from 'react';
import Loadable from 'react-loadable';
defineExternals({react: React, 'react-loadable': Loadable});
const manager = new FeatureAppManager(registry, {moduleLoader: loadAmdModule});
The main purpose of this monorepo is to further develop the Feature Hub. It is developed in the open on GitHub, and we are grateful to the community for contributing bugfixes and improvements.
To get started, install the dependencies and run the tests:
yarn && yarn test
Please note that this project is released with a Contributor Code of Conduct. By participating in this project you agree to abide by its terms.
yarn watch:test
: Watches all tests.yarn watch:demo <demo-name>
: Watches the given demo.yarn watch:compile
: Watches all sources.yarn test
: Executes all tests.yarn compile
: Compiles all sources.yarn lint
: Lints all sources.yarn verify
: Verifies non-functional requirements (used on CI).yarn format
: Formats all files.yarn sort-package-jsons
: Sorts allpackage.json
files.yarn generate-docs
: Generates the API documentation.
Instead of letting the CI automatically publish on every master merge, the Feature Hub package releases are triggered manually.
To create a new semantic npm release for all Feature Hub packages, core team
members must trigger a custom Travis CI build on the
master
branch. You should leave the custom config and commit message fields
empty.
Copyright (c) 2018 SinnerSchrader Deutschland GmbH. Released under the terms of the MIT License.