Repluggable

repository·master·Indexed 41 days ago

https://github.com/wix-incubator/repluggable

An inversion of control library for medium to high-complexity React and Redux applications. It enables micro-frontend architectures by allowing functionality to be composed incrementally from pluggable packages that can be added or removed at runtime via an AppHost.

Tokens
20.3K
Snippets
66
Records
90
Agent score
52%

What's inside Repluggable

  1. What is a pluggable package and an EntryPoint?

    master

    A pluggable package is a collection of one or more entry points. An EntryPoint is an object that contributes specific functionality to the application.

    Key characteristics:

    • An EntryPoint must implement the EntryPoint interface from repluggable.
    • It typically includes a name and an attach() method used to inject functionality.
    • Packages can be separate npm projects or part of the main application.
    • Packages can be plugged in or out at runtime.
    import { EntryPoint } from 'repluggable'
    
    export const Foo : EntryPoint = {
        name: 'FOO',
    
        attach() {
            console.log('FOO is here!')
        }
    }
  2. How the main application and pluggable packages work together

    master

    The repluggable architecture follows a 'lego set' model where a main application is composed of multiple pluggable packages.

    Main Application

    The main application acts as the shell. Its minimal responsibilities are:

    1. Initialize an AppHost object: Pass a list of pluggable packages to the AppHost. The AppHost orchestrates the package lifecycle, handles cross-cutting concerns, and provides dependency injection.
    2. Render AppMainView: Pass the initialized AppHost instance as a prop to the AppMainView component.

    Pluggable Packages

    A package is a standard Node package that exports an array of entry points.

    • Ordering: Packages are plugged in the order they are listed in the AppHost initialization.
    • Execution: Entry points are invoked in the order of the packages, and then in the order of the entry points within each package array.
  3. How package boundaries and entry point contexts work in React

    master

    Every React component rendered under AppMainView is associated with an entry point context. This context links the component to its specific entry point and parent package, providing several automated features:

    • Observability: Performance measurements and errors reported by children are automatically tagged with the entry point and package name.
    • Dependency Injection: The getAPI function is resolved within the context of the entry point.
    • State Scoping: Redux state access is scoped to the reducers contributed by that entry point.
    • Rendering: When rendering an extension slot of contributed React components, each component is rendered within the context of the entry point that contributed it.
  4. How to provide and consume APIs between packages

    master

    APIs are the only allowed way for packages to interact. They allow provider packages to offer services to consumer packages.

    Providing an API

    A provider package must:

    1. Declare and export an API interface and an API key (the key is a constant object exported from the package).
    2. Implement an API object that satisfies the interface.
    3. Contribute that API object under the specific API key.

    Consuming an API

    A consumer package must:

    1. Import the API key and API interface from the provider package.
    2. Declare a dependency on that API within its relevant entry points.
    3. Retrieve the API object by calling getAPI(apiKey) (the exact retrieval mechanism is provided by the AppHost context).

    Note: Because APIs are part of entry points, they may be temporarily unavailable during lazy loading. AppHost handles this by putting dependent entry points 'on hold' until the required API is available.

  5. How progressive and lazy package loading works

    master

    The AppHost allows for flexible, non-blocking package loading to ensure fast application startup.

    Loading Mechanisms

    • Abstraction: Loading is abstracted from the module system. To add a package, you provide a Promise that resolves to the package's default export.
    • Timing: Packages can be added:
      • During AppHost initialization.
      • Immediately after the first render of AppMainView.
      • Lazily at any time during the application lifecycle.

    Entry Point Separation

    Packages can be split into multiple entry points. Some entry points can be added immediately, while others (which might require heavy dependencies or data fetching) can be added later. This allows the application to become functional incrementally.

    Removal

    AppHost supports removing previously added entry points or entire packages at any time. Removing a package automatically removes all its entry points and all contributions made by those entry points.

  6. How API dependencies are resolved

    master

    Because entry points can be loaded lazily, an entry point might attempt to load before its required APIs are available. AppHost manages this via explicit dependency declarations.

    Dependency Lifecycle

    1. Declaration: Every entry point must declare which APIs it depends on.
    2. On Hold (Missing API): If an entry point is added but its required APIs are not yet available, the entry point is put on hold. It will be automatically activated as soon as all required APIs are contributed.
    3. On Hold (API Removal): If an entry point is already running but one of its required APIs becomes unavailable (e.g., due to package removal), the entry point and all its contributions are removed and put on hold. It will be re-added once the required APIs become available again.

    This mechanism guarantees that code depending on an API will only execute when that API is guaranteed to be present.

  7. What are extension slots and how to use them

    master

    An extension slot (ExtensionSlot<T>) is a specialized, "smart" array used by a package to store contributions of a specific type T (e.g., React components, UI commands, or panel items).

    Key Characteristics

    • Metadata: It tracks which package and entry point each contribution originated from, enabling package boundaries and easy removal of packages.
    • Encapsulation: Extension slots are internal implementation details. They should never be exposed directly.

    Implementation Pattern

    Instead of exposing the slot, a package should:

    1. Internally initialize an ExtensionSlot<T> for each group of accepted contributions.
    2. Contribute an API that accepts contributions from external packages and pushes them into the internal extension slot.
  8. How state and Redux reducers are managed

    master

    All application state must be managed in a single Redux store to ensure an event-driven mechanism and consistent React re-renders.

    • Reducers: Packages that require state must contribute one or more Redux reducers. The main application's Redux store is automatically composed from all contributed reducers.
    • APIs and State: If a package contributes APIs, it can also include selectors and action dispatchers within those APIs to allow other packages to interact with its state.
    • State Scoping: In Redux-connected components, the state (via mapStateToProps) and getState() (in thunks) are automatically scoped to the reducers contributed by the specific entry point currently in context.
  9. Create and contribute an API

    master

    APIs allow packages to communicate. Follow these five steps to implement an API:

    1. Define the Interface: Create a standard TypeScript interface for your API methods.
    2. Define the SlotKey: Create a const named after the interface using SlotKey<T>. Set public: true if the API needs to be exported outside your package.
    3. Implement the API: Write a factory function (e.g., createFooAPI(shell: Shell)) that returns the interface implementation.
    4. Contribute via attach: In your EntryPoint.attach(shell) hook, call shell.contributeAPI(YourAPI, () => implementation).
    5. Export the Key: Export the SlotKey from your package's entry point so other packages can reference it.
    // 1. Interface
    export interface FooAPI {
       doSomething(): void
    }
    
    // 2. SlotKey
    import { SlotKey } from 'repluggable'
    export const FooAPI: SlotKey<FooAPI> = {
       name: 'Foo API',
       public: true
    }
    
    // 3. Implementation
    export function createFooAPI(shell: Shell): FooAPI {
       return {
           doSomething() { /* ... */ }
       }
    }
    
    // 4. Contribution in EntryPoint
    attach(shell: Shell) {
       shell.contributeAPI(FooAPI, () => createFooAPI(shell))
    }
    
    // 5. Export
    export { FooAPI } from './fooApi'
  10. Enable HMR for local development

    master

    To enable Hot Module Replacement (HMR) in your package, wrap your default entry point export with the hot utility from repluggable in your package's index.ts.

    import { hot } from "repluggable";
    import { FooEntryPoint } from "./fooEntryPoint";
    import { BarEntryPoint } from "./barEntryPoint";
    
    export default hot(module, [FooEntryPoint, BarEntryPoint]);
  11. Load and add packages dynamically to the AppHost

    master

    Repluggable supports multiple loading strategies for packages:

    • Static bundling: Standard imports.
    • Dynamic import: Using WebPack code splitting (import().then()).
    • External modules: Using AMD/RequireJS (require()).

    To add a package that was not part of the initial createAppHost call, use the host.addShells() method.

    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, add a dynamically loaded package
    void packageTwo().then(p => host.addShells([p]))
  12. Use `connectWithShell` for React components

    master

    When building React components that need to consume APIs or state, use connectWithShell instead of the standard connect.

    connectWithShell provides the Shell object as the first argument to both mapStateToProps and mapDispatchToProps. This allows components to access APIs from other packages without manual prop drilling.

    Pattern:

    1. Define StateProps (from local state) and DispatchProps (from local actions or external APIs).
    2. Create a stateless functional component (SFC).
    3. Wrap the SFC with connectWithShell(mapStateToProps, mapDispatchToProps, boundShell).

    Note: The Shell is extracted from the EntryPointContext automatically.

    // 1. Prop Types
    type FooStateProps = { xyzzy: string; bar: number }
    type FooDispatchProps = { setXyzzy(v: string): void; createNewBar(): void }
    
    // 2. Stateless Component
    const FooSfc: React.SFC<FooStateProps & FooDispatchProps> = (props) => (
      <div>{props.xyzzy}</div>
    )
    
    // 3. Connected Container
    export const createFoo = (boundShell: Shell) => connectWithShell(
      (shell, state) => ({
        xyzzy: state.baz.xyzzy,
        bar: shell.getAPI(BarAPI).getCurrentBar()
      }),
      (shell, dispatch) => ({
        setXyzzy: (val) => dispatch(setXyzzy(val)),
        createNewBar: () => shell.getAPI(BarAPI).createNewBar()
      }),
      boundShell
    )(FooSfc)