Repluggable is a library that's implementing inversion of control for front end applications and makes development of medium or high-complexity projects much easier. Currently Repluggable implements micro-frontends in a React+Redux app, with plans to make it framework-independent in the future.
Functionality of a Repluggable app is composed incrementally from a list of pluggable packages. Every package extends the already loaded ones by contributing new functionality into them. Sections of UI, contributed by a certain package, can be rendered anywhere and are not limited to dedicated subtree of DOM. All packages privately manage their state in a modular Redux store, which plays the role of common event mechanism. Packages interact with each other by contributing and consuming APIs, which are objects that implement declared interfaces. Packages can be plugged in and out at runtime without the need to reload a page. Check out the architecture section of the docs to learn more about the design decisions behind Repluggable.
Quick docs links: How-to | Architecture and core concepts
All code in this README is in TypeScript.
To add Repluggable to an existing React+Redux application:
$ npm install repluggable
Run the following commands:
npx create-react-app your-app-name --template typescript
cd your-app-name
yarn add react@16.14.0 react-dom@16.14.0 @types/react@16.14.0 @types/react-dom@16.9.0 repluggable
rm src/App*
rm src/logo*
cp -R node_modules/repluggable/examples/helloWorld/src/ ./src
yarn start
A pluggable package is basically an array of entry points. An entry point is an object which contributes certain functionality to the application. Below is an example of a simple entry point.
foo.ts
import { EntryPoint } from 'repluggable'
export const Foo : EntryPoint = {
name: 'FOO',
attach() {
console.log('FOO is here!')
}
}
Usually, a pluggable package will be a separate npm project, which exports an array of entry points. But it isn't required - entry points can also be part of the main app.
Main application is the React+Redux app that's being composed from packages. Suppose we also have bar.ts
implemented similarly to Foo
above.
App.tsx
import { createAppHost, AppMainView } from 'repluggable'
import { Foo } from './foo'
import { Bar } from './bar'
const host = createAppHost([
// the list of initially loaded packages
Foo,
Bar
])
ReactDOM.render(
<AppMainView host={host} />,
document.getElementById('root')
)
When run, the application will print two messages to console, first 'FOO is here!', then 'BAR is here!'.
The main application is a React application, which uses the repluggable
package.
The index.ts
of the application must perform the following steps.
-
Import from
repluggable
import { createAppHost, AppMainView } from 'repluggable'
-
Provide loaders of pluggable packages. Below is an example of three packages, each loaded in a different way:
package-foo
is statically bundled with the main apppackage-bar
is in a separate chunk (WebPack code splitting). We'll load it with dynamic import.package-baz
is in an AMD module, deployed separately. We'll load it with RequireJS.
This is how the three packages are loaded:
import foo from 'package-foo' const bar = () => import('package-bar').then(m => m.default) const baz = require('package-baz')
-
Initialize
AppHost
with the packages:const host = createAppHost([ foo, baz ]) // Later void bar().then(p => host.addShells([p]))
-
Render
AppMainView
component, passing it the host:ReactDOM.render( <AppMainView host={host} />, document.getElementById('root') )
import ReactDOM from 'react-dom'
import { createAppHost, AppMainView } from 'repluggable'
import packageOne from 'package-one'
const packageTwo = () => import('package-two').then(m => m.default)
const packageThree = require('package-three')
const host = createAppHost([
packageOne,
packageThree
])
ReactDOM.render(
<AppMainView host={host} />,
document.getElementById('root')
)
// Sometime later
void packageTwo().then(p => host.addShells([p]))
A package project is a regular Node project.
Typically, it is set up with TypeScript, React, and Redux. Such a project must include a repluggable
dependency.
The rest of the configuration (Babel, WebPack, Jest, etc) heavily depends on the organization of your codebase and release pipeline, and is outside the scope of this README.
As we mentioned before, each package must export one or more entry points in order to be loaded by the main app.
An entry point is an object which implements an EntryPoint
interface:
import { EntryPoint } from 'repluggable'
const FooEntryPoint: EntryPoint = {
// required: specify unique name of the entry point
name: 'FOO',
// optional
getDependencyAPIs() {
return [
// DO list required API keys
BarAPI
]
},
// optional
declareAPIs() {
// DO list API keys that will be contributed
return [
FooAPI
]
},
// optional
attach(shell: Shell) {
// DO contribute APIs
// DO contribute reducers
// DO NOT consume APIs
// DO NOT access store
shell.contributeAPI(FooAPI, () => createFooAPI(shell))
},
// optional
extend(shell: Shell) {
// DO access store if necessary
shell.getStore().dispatch(....)
// DO consume APIs and contribute to other packages
shell.getAPI(BarAPI).contributeBarItem(() => <FooItem />)
},
// optional
detach(shell: Shell) {
// DO perform any necessary cleanup
}
}
The EntryPoint
interface consists of:
- declarations:
name
,getDependencies()
- lifecycle hooks:
attach()
,extend()
,detach()
The lifecycle hooks receive a Shell
object, which represents the AppHost
for this specific entry point.
The default export of a package must be an array of its entry points. For example, in package root index.ts
:
import { FooEntryPoint } from './fooEntryPoint'
import { BarEntryPoint } from './barEntryPoint'
export default [
FooEntryPoint,
BarEntryPoint
]
To create an API, perform these steps:
-
Declare an API interface. For example:
export interface FooAPI { doSomething(): void doSomethingElse(what: string): Promise<number> }
-
Declare an API key, which is a const named after the interface, as follows:
import { SlotKey } from 'repluggable' export const FooAPI: SlotKey<FooAPI> = { name: 'Foo API', public: true }
Note that
public: true
is required if you plan to export your API outside of your package. The key must be declared in the same.ts
file with the interface. -
Implement your API. For example:
export function createFooAPI(shell: Shell): FooAPI { return { doSomething(): void { // ... }, doSomethingElse(what: string): Promise<number> { // ... } } }
-
Contribute your API from an entry point
attach
function:import { FooAPI, createFooAPI } from './fooApi' const FooEntryPoint: EntryPoint = { ... attach(shell: Shell) { shell.contributeAPI(FooAPI, () => createFooAPI(shell)) } ... }
-
Export your API from the package. For example, in the
index.ts
of your package:export { FooAPI } from './fooApi'
In order to manage state in a package, you need to contribute one or more reducers.
In the example below, FooEntryPoint
will contribute two reducers, bazReducer
and quxReducer
.
To contribute the reducers, perform these steps:
-
Declare types that represent the state for each reducer:
// state managed by bazReducer export interface BazState { ... xyzzy: number // for example } // state managed by quxReducer export interface QuxState { ... }
-
Wrap these state types in a root state type. This root type determines the shape of the state in the entry point.
// the root type on entry point level export interface FooState { baz: BazState qux: QuxState }
-
Write the two reducers. For example, they can look like this:
function bazReducer( state: BazState = { /* initial values */ }, action: Action) { ... } function quxReducer( state: QuxState = { /* initial values */ }, action: Action) { ... }
-
Contribute state in the entry point:
attach(shell: Shell) { shell.contributeState<FooState>(() => ({ baz: bazReducer, qux: quxReducer })) }
Here, an argument passed to
contributeState()
is a reducer map object. This object contains all the same keys ofFooState
(thebaz
andqux
), but this time the keys are assigned their respective reducers. Such derivation of reducers' map shape is enforced by the typings. -
Expose selectors and action dispatchers through APIs:
export interface FooAPI { ... getBazXyzzy(): number setBazXyzzy(value: number): void ... }
The above API allows to read and change the value of
xyzzy
in theBazState
.Note that neither of these two functions are passed the state or the
Store
object. This is because their implementations are already bound to the store of theAppHost
:const createFooAPI = (shell: Shell): FooAPI => { // this returns a scoped wrapper over the full // store of the main application // IMPORTANT! the generic parameter (FooState) // must match the one specified when contributing state! // In our example, we did contributeState<FooState>(...) const entryPointStore = shell.getStore<FooState>() const getState = () => entryPointStore.getState() return { ... // example of selector getBazXyzzy(): number { const state: FooState = getState() return state.baz.xyzzy }, // example of action dispatcher setBazXyzzy(value: number): void { entryPointStore.dispatch(BazActionCreators.setXyzzy(value)) } ... } }
When creating a React component, we strongly recommend to follow the React-Redux pattern, and separate your component into a stateless render and a connect
container.
In repluggable
, components often need to consume APIs. Although APIs can be obtained through Shell
, when it is passed to lifecycle hooks in your entry point, propagating them down the component hierarchy would be cumbersome.
A more elegant solution is to use connectWithShell()
function instead of the regular connect()
. This provides the connector with the ability to obtain APIs.
The example below illustrates how connectWithShell()
is used. Suppose we want to create a component <Foo />
, which would render like this:
(props) => (
<div className="foo">
<div>
<label>XYZZY</label>
<input
type="text"
defaultValue={props.xyzzy}
onChange={e => props.setXyzzy(e.target.value)} />
</div>
<div>
Current BAR = {props.bar}
<button onClick={() => props.createNewBar()}>
Create new BAR
</button>
</div>
</div>
)
In order to implement such a component, follow these steps:
-
Declare the type of state props, which is the object you return from
mapStateToProps
:type FooStateProps = { // retrieved from own package state xyzzy: string // retrieved from another package API bar: number }
-
Declare type of dispatch props, which is the object you return from
mapDispatchToProps
:type FooDispatchProps = { setXyzzy(newValue: string): void createNewBar(): void }
-
Write the stateless function component. Note that its props type is specified as
FooStateProps & FooDispatchProps
:const FooSfc: React.SFC<FooStateProps & FooDispatchProps> = (props) => ( <div className="foo"> ... </div> )
-
Write the connected container using
connectWithShell
. The latter differs fromconnect
in that it passesShell
as the first parameter tomapStateToProps
andmapDispatchToProps
. The new parameter is followed by the regular parameters passed byconnect
. For example:export const createFoo = (boundShell: Shell) => connectWithShell( // mapStateToProps // - shell: represents the associated entry point // - the rest are regular parameters of mapStateToProps (shell, state) => { return { // some properties can map from your own state xyzzy: state.baz.xyzzy, // some properties may come from other packages' APIs bar: shell.getAPI(BarAPI).getCurrentBar() } }, // mapDispatchToProps // - shell: represents the associated entry point // - the rest are regular parameters of mapDispatchToProps (shell, dispatch) => { return { // some actions may alter your own state setXyzzy(newValue: string): void { dispatch(FooActionCreators.setXyzzy(newValue)) }, // others may request actions from other packages' APIs createNewBar() { shell.getAPI(BarAPI).createNewBar() } } }, boundShell )(FooSfc)
The
Shell
parameter is extracted from React contextEntryPointContext
, which represents current package boundary for the component.
TBD (advanced topic)
TBD
For a smooth local development experience, it's recommended to enable HMR, allowing packages' code to update immediately without the need to reload the entire page. (For more information on HMR, see webpack's docs)
To enable HMR, use Repluggable's hot
util to wrap the export of your repluggable package. For example, in the package root index.ts
:
import { hot } from 'repluggable'
import { FooEntryPoint } from './fooEntryPoint'
import { BarEntryPoint } from './barEntryPoint'
export default hot(module, [
FooEntryPoint,
BarEntryPoint
])
repluggable
allows composition of a React+Redux application entirely from a list of pluggable packages.
Think of a package as a box of lego pieces - such as UI, state, and logic. When a package is plugged in, it contributes its pieces by connecting them to other pieces added earlier. In this way, the entire application is built up from connected pieces, much like a lego set.
For two pieces to connect, one piece defines a connection point (an extension slot) for another specific type of a piece. In order to connect, the other piece has to match the type of the slot. One slot can contain many pieces.
Packages can be plugged in and out at runtime. Contributed pieces are added and removed from the application on the fly, without the need for a page to reload.
This is the application being composed, much like a lego set. We refer to it as main application.
The main application can be as small as an empty shell. Its functionality can be completely composed by the packages, where each plugged package contributes its pieces to the whole.
The minimal responsibilities of the main application are:
-
Initialize an
AppHost
object with a list of pluggable packages.The
AppHost
object orchestrates lifecycle of the packages, handles cross-cutting concerns at package boundaries, and provides dependency injection to Redux-connected components. -
Render
AppMainView
component, passing it the initializedAppHost
in props.
Pluggable package (or simply package) is a regular Node package, which exports an array of entry points.
The packages are plugged in the order they are listed when passed to AppHost
. Entry points are invoked in the list order of the packages, in the array order within the package.
Every entry point contributes one or more pieces to the whole lego set of the application.
Examples of contributed pieces include React components, panel item descriptors, UI command descriptors, etc, etc. They can be anything, provided that they are expected by the "lego set". Here expected means that some package provides an API, through which it accepts contributions of this specific type.
There are also two kinds of contributions supported directly by repluggable
: APIs and reducers.
Besides contributing lego pieces, entry points may contain additional lifecycle hooks.
Some packages (providers) provide services to other packages (consumers). The services are provided through APIs. An API is an object, which implements a TypeScript interface, and is identified by an API key. An API key is another object declared as a const TODO: link to example, and exported from the package.
In general, APIs allow packages to extend other packages (consumers call APIs, which let them pass contributions to the provider), and otherwise interact. Moreover, APIs are the only allowed way of interaction between packages.
In order to provide an API, a provider package:
- declares and exports an API interface and an API key
- implements an API object according to the interface
- contributes an API object under the key
In order to consume an API, a consumer package:
- imports an API key and an API interface from the provider package
- declares dependency on the API in relevant entry points
- retrieves an API object by calling
getAPI
and passing it the API key TODO: link to example.
repluggable
requires that all state of the application is managed in a Redux store. This ensures that all pieces are connected to a single event-driven mechanism. In turn, this guarantees that pure React components mapped to values returned by APIs will re-render once these values change.
A package that has state must contribute one or more reducers responsible for managing that state. If such a package contributes APIs, it can also include selectors and action dispatchers in the APIs.
The Redux store of the main application is combined from reducers, contributed by stateful packages.
When a package accepts contributions from other packages, it must store contributed pieces in some kind of an array.
repluggable
provides a "smart" array for this purpose, named extension slot. Extension slot is a generic object ExtensionSlot<T>
, which accepts contributions of type T
.
Its additional responsibility is remembering which package and entry point each contribution was received from. This allows applying package boundaries and easily handling other cross-cutting concerns.
Extension slots are implementation details of a package, and they should never be directly exposed outside of the package. Instead, a package:
- internally initializes an extension slot for every kind or group of accepted contributions.
- contributes an API that receives contributions from the outside and pushes them to an internal extension slot.
With that, the AppHost
also tracks all existing extension slots. This approach allows for an easy implementation of application-wide aspects. For example, removal of a package with all of its contributions across an application.
Every React component rendered under the AppMainView
is associated with an entry point context.
The entry point context is a React context, which associates its children with a specific entry point, and thus the package that contains it.
Such association provides several aspects to the children:
-
performance measurements and errors reported by the children are automatically tagged with the entry point and the package
-
in Redux-connected components (TODO: link to details):
-
dependency injection (the
getAPI
function): all dependencies are resolved in the context of the entry point -
state scoping (the
state
inmapStateToProps
, andgetState()
in thunks): returned state object is scoped to reducers contributed by the entry point.
TODO: verify that getState() in thunks is actually scoped
- when rendering an extension slot of contributed React components: each component is rendered within the context of the entry point it was contributed by.
-
To make application loading reliable and fast, repluggable
allows flexible control over package loading process.
The loading process is abstracted from any concrete module system or loader. Packages can be in a monolith bundle, or loaded with dynamic imports, or with loaders like RequireJS. To add a package to an AppHost
, all that's needed is a Promise
of a package default export.
Packages can be added to an AppHost
at different stages:
- During initialization of the
AppHost
- Right after the
AppMainView
was rendered for the first time - Lazily at any later time
Moreover, AppHost
allows separating the whole package into multiple entry points. Some of the entry points are added right as the package is added to the AppHost
, while others can be added later.
Such separation allows incremental contribution of functional parts as they become ready. Some parts may need to dynamically load additional dependencies or request data from backends. Without the separation approach, a user won't be able to interact with any functionality of the package, until the entire package is initialized -- which would hurt the experience.
In addition, AppHost
supports removal of previously added entry points or entire packages, at any time. Removal of a package means removal of all its entry points. When an entry point is removed, all contributions made from that entry point are removed too.
Since APIs are contributed though entry points, their availability depends on the loading time of the provider package and a specific entry point within it. From a consumer package perspective, this creates a situation in which one or more of the APIs the package depends on may be temporarily unavailable.
AppHost
resolves that with the help of explicit dependency declarations. Every entry point must declare APIs which it's dependent on (including dependencies of all pieces contributed by the entry point). If any of the required APIs is unavailable, the entry point is put on hold. There are two possible cases:
- Attempted to add an entry point, but some of required APIs weren't available: the entry point is put on hold, and will be added as soon as all required APIs are contributed.
- An entry point was added, but then some of its required APIs became unavailable: the entry point will be removed together with all of its contributions, and put on hold. It will be added once again as soon as all required APIs are available.
Such approach guarantees that code dependent on an API from another package will not run unless that API is available.
3-rd party licenses are listed in docs/3rd-party-licenses.md