-
Notifications
You must be signed in to change notification settings - Fork 0
Portal
Portal implements a react portal framework supporting microfrontends vastly simplifying implementation efforts since a number of technical challenges every application has to solve are already part of the framework.
- integration of a DI solution
- centralized error handling ( including error boundaries )
- session handling
- optional i18n solution
- meta-data based approach that allows for
- filtering of available features according to authentication, authorization or other aspects ( e.g. feature flags )
- automatic router configuration according to the metadata
- dynamic navigation features that are based on the meta-data and custom rules
- feature outlets that cover both local and federated components and allow for custom async preloading logic ( e.g. i18n loading )
- custom application configurations with support for both client and server side logic
While the framework supports enterprise portals with dynamic microfrontends - and server side configuration mechanisms - as one extreme it also covers small local only applications without significant coding and rampup overhead, making it a one-size-fits-all framework.
The main idea for most of the mechanisms is that modules expose meta-data of "what is inside", by annotating available "features" ( named components used internally or part of the routing ) with special decorators.
@Feature({
id: 'public-navigation',
label: 'Navigation',
visibility: ["public"], // visible without a session
features: [],
permissions: []
tags: ['portal'], // needed to identify this special feature
path: '/'
})
export class PublicNavigationFeature extends React.Component {
...
}A parser - as part of the build - will locate those features and generate a manifest.json which can be processed by different mechanisms.
{
"id": "shell",
"label": "Shell",
"version": "1.0.0",
"moduleName": "ApplicationModule",
"sourceFile": "apps/shell/src/main.tsx",
"description": "Shell",
"features": [
{
"id": "public-navigation",
"label": "Navigation",
"path": "",
"visibility": [
"public"
],
"tags": [
"portal"
],
"features": [
],
"permissions": [
],
"component": "PublicNavigationFeature",
"sourceFile": "apps/app/src/navigation/PublicNavigation.tsx",
},
...
],
...
}If we think of a setup including a shell and federated microfrontends, those manifests are the basis for an application configuration by merging different manifests according to custom rules and setups and booting the application with a tailored configuration. Possible rules - that are typically executed on the server side - are rules
- regarding static configurations ( enabling/disabling microfrontends or features per admin ui )
- regarding roles and permissions
- regarding feature flags
- regarding client characteristics ( e.g. screen resolution )
As already stated, an enterprise portal will have the corresponding data management, services and respective administrative interfaces to compute custom configurations on the server side. As the framework should also cover small apps, a standalone purely client driven approach is also possible. So different scenarios are possible:
Standalone application
Microfrontend application reading remote manifests
Microfrontend application with a server side configuration
Lets' look at the details
In order to extract the meta data, react components need to be decorated with the decorator Feature that accepts the config values
interface FeatureOptions {
id: string;
icon?: string;
permissions?: string[];
description?: string;
tags?: string[];
features?: string[];
visibility?: ('public' | 'private')[];
i18n?: string;
label?: string;
path?: string;
parent?: string;
clients?: ClientConstraints;
}Every project adds top-level meta-data by including a module class inheriting from AbstractModule and decorated with @Module given the options:
@Module({ id: "microfrontend", label: "microfrontend module", version: "1.0.0", description: "Micro Frontend Module", }) export class MicrofrontendModule extends AbstractModule { // override
async setup(): Promise<void> {
await super.setup()
...
}
}
This class is also the basis for the corresponding DI container and may include any @create functions.
A builtin tool is used to extract the data by adding the corresponding target in the corresponding project.json
"targets": {
"scan-metadata": {
"executor": "nx:run-commands",
"options": {
"command": "nx run metadata:scan --moduleFolder=apps/app --outFile=apps/app/src/manifest.json"
}
}
}Depending on the type of project the manifest.json will be placed differently:
- the shell will place it under
src - microfrontends need it as an asset, so typically under the
publicfolder
The shell module is required to setup a number of technical classes and initializes the boot mechanism. The required elements are:
The locale manager defines the current locale and the number of supported locales:
Example:
@create()
createLocaleManager() : LocaleManager {
return new LocaleManager({
locale: "de-DE",
supportedLocales: ["de-DE", "en-US"],
backingStore: new LocalStorageLocaleBackingStore("language"),
})
}The Translator is responsible to load i18n values:
Example:
@create()
createTranslator(localeManager: LocaleManager) : Translator {
return new TranslatorBuilder()
.loader(new AssetTranslationLoader({ path: '/i18n/' }))
.localeManager(localeManager)
.build()
}The session manager is responsible for the corresponding authentication mechanism and the organization of a session object
Example:
@create()
createSessionManager() : SessionManager<any,any> {
return new SessionManager(new NoAuthenticationService()); // just a dummy
}The deployment manager is responsible to load a tailored ( merged ) configuration for the current session.
Example:
@create()
createDeploymentManager(featureRegistry: FeatureRegistry) : DeploymentManager {
return new DeploymentManager({
featureRegistry: featureRegistry,
localManifest: manifest as Manifest
});
}The corresponding lifecycle will initiate the boot phase:
@onRunning()
async onRunning(featureRegistry: FeatureRegistry, deploymentManager: DeploymentManager, sessionManager: SessionManager<any,any>, routerManager: RouterManager) {
// load deployment
await deploymentManager.loadDeployment({
application: "portal",
client: deploymentManager.clientInfo(),
});
// set root
routerManager.setRoot(() => (featureRegistry.finder()
.withTag('portal')
.matchesSession(sessionManager.hasSession())
.findOne()
))
}Booting the shell requires a
Every microfrontend needs to
- expose ist
manifest.jsonas a public asset - define a mcirofrontend module in a
Module.tsfile.
Let's see how to boot an application. First thing we need to do is to setup the di container and add a couple of instances inside of the main "module"
@Module({
id: 'shell',
label: 'Shell',
version: '1.0.0',
description: 'Shell',
name: '',
})
export class ApplicationModule extends AbstractModule {
@create()
createSessionManager() : SessionManager<any,any> {
return new SessionManager(new DummyAuthenticationService()); // for now, would be OIDC in reality
}
@create()
createDeploymentLoader(portalService: PortalService) : DeploymentLoader {
return new EmptyDeploymentLoader() // only local, so far
}
@create()
createDeploymentManager(loader: DeploymentLoader, featureRegistry: FeatureRegistry) : DeploymentManager {
return new DeploymentManager(
featureRegistry,
loader,
manifest as Manifest // that's the local genaretd manifest.json
);
}
// lifecycle
@onRunning()
async onRunning(featureRegistry: FeatureRegistry, deploymentManager: DeploymentManager, sessionManager: SessionManager<any,any>, routerManager: RouterManager) {
// load deployment
await deploymentManager.loadDeployment({
application: "portal",
client: deploymentManager.clientInfo(),
});
// the root if the router will be a feature with tag "portal" and the correct visibility
routerManager.setRoot(featureRegistry.finder()
.withTag('portal')
.withVisibility(sessionManager.hasSession())
.findOne());
await sessionManager.init();
}
}
// create environment
export const createEnvironment = async () : Promise<Environment> => {
const environment = new Environment({module: ApplicationModule})
await environment.start()
return environment
}The crucial parts are
The DeploymentManager which is responsible to compute a merged manifest.json. Since we are still completely local,
it will only return the local manifest.json
A FeatureRegistry collects all features and will be filled with the gathered information of the deployment manager. Since it knows about all registered components - local or remote -
a <FeatureOutlet> component is now available that renders any registered feature by name, which is the basis for a number of mechanisms.
The RoutingManager will compute dynamic routes based on the provided features and a handpicked "root" feature
The routing logic will simply pick all features that have a defined "path" and add them as children - inserting a feature outlet - to the desired
root feature, which in this case has a defined tag "portal" and has a visibility property that matches the current session state.
Launching the application is now just a couple lines of code.
const environment = await createEnvironment();
const root = createRoot(document.getElementById('root')!);
root.render(
<EnvironmentContext.Provider value={environment}>
<App />
</EnvironmentContext.Provider>
);while the application
export class App extends React.Component {
routerManager!: RouterManager;
static contextType = EnvironmentContext
declare context: Environment
async componentDidMount() {
this.routerManager = this.context.get(RouterManager);
}
override render() {
return (
this.routerManager.renderRouter()
);
}
}simply delegates to rendering to the defined routes. So what is the root? Well, a specific feature which acts as the main page typically offering navigation possibilities ( as a side bar ).
The interesting part, is that since we already have the complete meta-data available, we dont need to hardcode the navigation entries anymore, but can rely on a couple of conventions to list the available routes.
Example:
const features = featureRegistry
.finder()
.withPath()
.withoutParent()
.withVisibility(sessionManager.hasSession())
.withTag('menu')
.find();In this case all features, that have the corresponding visibility status matching the session state and have a tag "menu", will be listed
as corresponding <Link>s.
Isn't that awsome?
Ok, but we promised microfrontends as well, were are they?
Federated modules will take a similar approach, by defining a root module, exposing a manifest.json, etc.
The main application will only need to change the corresponding deployment loader to integrate it.
return new RemoteDeploymentLoader([
{ name: 'microfrontend', url: 'http://localhost:3001' },
]);In this case, the manifest is fetched dynamically from the known url and merged with the local manifest.
This is good enough for a local environment used for development purposes, in production the logic would be handed over to a server component that is aware of different microfrontends and configurations also including more sophisticated logic to filter features according to feature flags, etc.
A showcase app shows a shell and a microfreontend.
API docs are available here