Skip to content
Andreas Ernst edited this page Mar 8, 2026 · 26 revisions

Introduction

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.

Basic Solution Idea

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 )
  • roles and permissions
  • feature flags
  • 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

portal_local drawio

Microfrontend application reading remote manifests on the client side

portal_reading_manifest drawio

Microfrontend application with a server side configuration

portal_server drawio

Lets' look at the details

Feature Declaration

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;
}

There are use-cases where the same application needs to adapt to to different client characteristics, starting with the screen resolution. While there are some media query related mechanisms that could be used, there are of course limits and often lead to unmaintainable and scattered code. The approach taken here is completely flexible by defining different features that match certain aspects and that are picked dynamically during the runtime.

The field clients defines constraints that must be fulfilled and include

  • Screen size (xs–xl)
  • Orientation (portrait / landscape)
  • Platform (ios, android, web, macos, etc.)
  • Capabilities (touch, multitouch, stylus)
  • Device type (phone, tablet, desktop)

Example:

@Feature({
    id: "navigation",
    label: "iOS IPad Navigation",
    path: "",
    tags: [],
    permissions: [],
    features: [],
    visibility: ["public"],
    clients: {
        platforms: ["ios"], // Native mobile platforms
        screenSizes: ["sm", "md", "lg", "xl"],  // All except xs (phones portrait)
    },
})

Module Declaration

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.

Feature Extraction

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 public folder

The tool not only generate a manifest.json but will also update the exposes section of the webpack.config.js to reflect the exposed components.

Example:

  exposes: {
        "./MicrofrontendFeature": "./apps/microfrontend/src/feature",
        './Module': './apps/microfrontend/src/Module',
      },

Shell Module

The shell module is required to setup a number of technical classes and initializes the boot mechanism. The required elements are:

Locale Manager

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"),
    })
}

Check the corresponding Wiki

Translator

The Translator is responsible to load i18n values and to execute translations and interpolations:

Example:

@create()
createTranslator(localeManager: LocaleManager) : Translator {
     return new TranslatorBuilder()
      .loader(new AssetTranslationLoader({ path: '/i18n/' }))
      .localeManager(localeManager)
      .build()
}

Check the corresponding Wiki

Session Manager

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 DummyAuthentication()); // just a dummy
  }

The session manager is based on a Authentication implementation that is responsible to create a Session.

/**
 * A `Session` captures the data of a logged in user and corresponding technical information
 * in the form of a ticket ( e.g. tokens, expire date, ... )
 * @params U the user type
 * @params T the ticket type
 */
export interface Session<U = any, T = any> {
  user: U;
  ticket: T;
  expiry?: number;
  locale?: string;

  sessionLocals: Record<string,any>
}

/**
 *  `Authentication` is responsible to establish and delete a user session.
 * @param R any information requires to trigger the login
 * @params U the user type
 * @params T the ticket type
 */
export interface Authentication<R = any, U = any, T = any> {
  /**
   * setup the authentication and possibly restore a valid session.
   */
  start(): Promise<Session<U, T> | null>;

  /**
   * request a session
   * @param request any request information.
   * @returns a valid session
   */
  login(request: R): Promise<Session<U, T>>;

  /**
   * logout
   */
  logout(): Promise<void>;
}

The SessionManager is responsible for the session state and is created given a Authentication. After construction the async method start() needs to be called, that gives the authentication to startup and also to return any valid session object.

It offers the methods:

  • hasSession(): boolean
  • currentSession(): Session<U, T>
  • async openSession(request : R): Promise<Session<U, T>> opens a session given request parameters
  • async closeSession(): Promise<void>? closes the active session

Session can capture session locals with

  • setSessionLocal(key: string, value: any)
  • getSessionLocal(key: string) return a session local

The session manager emits events that signal session changes via the observable events$. The corresponding type is

export interface SessionEvent<U=any,T=any> {
    type: "opening" | "opened" | "closing" | "closed"
    session: Session<U,T>
}

OIDCAuthentication

The class OIDCAuthentication implements a OpenID Connect authentication and is constructed with the corresponding parameters

Example:

new OIDCAuthentication({
       url: "http://localhost:8080",
       realm: "service",
       clientId: "service-browser"
    }))

The corresponding interface for user information is available as OIDCUser with the sepcified properties.

Login Flow

Both OIDC - will redirect to an external page - and SPA login - redirect to internal page - flows are supported. The framework will distinguish the two possibilities by identifying a feature with the tag "login" which will serve as an automatic internal redirect (for example in the route guards for private routes )

Deployment Manager

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
        loader: ...
        processor: new ManifestProcessor({
          hasFeature: (feature) => true,
          hasPermission: (permission) => true
        })
      });
  }

The property processor is an optional argument, that can be provided when manifests are processed on the client side in contrast to a server side mechanism. The optional hasFeature and hasPermission can be set to pass the corresponding logic in order to determine if features or permissions are present.

Different loading strategies are implemented:

  • RemoteDeploymentLoader created with an array of {name: <name>, url: <url>} will load the manifests.
  • ServiceDeploymentLoader will use the PortalService to receive a property configuration

If not specified, no additional manifests will be fetched.

Boot logic

The boot logic is embedded in the @onRunning lifecycle method

 @onRunning()
  async onRunning(featureRegistry: FeatureRegistry, deploymentManager: DeploymentManager, sessionManager: SessionManager<any,any>, routerManager: RouterManager) {
     // load deployment

      await deploymentManager.loadDeployment({
          application: "portal",
          client: deploymentManager.clientInfo(), // pass the current client infor in order to filter features
      });

      // let the routing manager create dynamic routes given a "root" page!

      routerManager.setRoot(() => (featureRegistry.finder()
        .withTag('portal')
        .withVisibility(sessionManager.hasSession() ? 'private' : 'public')
        .findOne()
      ))
  }

After computing an overall configuration given a set of manifests, it will setup the react routes, given a specific root page. In this case a feature with tag portal is used that has a visibility value matching the current session state. Without a session, he is looking for visibility including "public", with a session "private" giving us the chance to have different layouts depending on the state.

All other registered features with a defined path ( which are not lazy child features ) will be added automatically.

Application setup

Booting an application requires several steps:

Environment Creation

The DI environment has to be started given the root module.

Example: Make sure to import all files that include @injectable classes!

export const createEnvironment = async () : Promise<Environment> => {
    const environment = new Environment({module: ApplicationModule})

    await environment.start() // will run all @onRunning

    return environment
}

React root

const environment = await createEnvironment();

const root = createRoot(document.getElementById('root')!);
root.render(
    <EnvironmentContext.Provider value={environment}>
      <App/>
    </EnvironmentContext.Provider>
    );

The context is required so that components can access the environment.

A corresponding Application

The essential part is to give the router the chance to render

*Example:

export function App() {
    const context = useContext(EnvironmentContext);
    const routerManagerRef = useRef<RouterManager | null>(null);

    useEffect(() => {
        routerManagerRef.current = context.get(RouterManager);
    }, [context]);

    if (!routerManagerRef.current) {
        return null;
    }

    return routerManagerRef.current.renderRouter();
}

Microfrontend Module

Every microfrontend needs to

  • expose ist manifest.json as a public asset
  • define a microfrontend module in a Module.ts file.

The file name is essential as the remote logic expects to load it exactly under this name!

Navigation

The top-level page which will serve as the root of the router typically contains a header, footer and some ways how to navigate, typically with some kind of menu structure.

The interesting part is that, since we already have a dynamic routing setup, the navigation entries should also be dynamic, which is easy doable by simply introspecting the existing features and simply adding <Link>s to s subset of them.

In the provided showcase shell the logic is to iterate over all features found with:

const features = featureRegistry
    .finder()
    .withPath()
    .withoutParent()
    .withVisibility(sessionManager.hasSession()) // only show features that should be visible!
    .withTag('menu') // !
    .find();

Isn't that awesome?

Accessing Environment

As showed a context is already inserted that gives us the chance to access it. Two hooks are implemented:

useEnvironment()

will retrieve the current environment.

Example:

const environment = useEnvironment()
const service = environment.get(SomeService)

useLocalEnvironment()

is a flavor that sets up a child environment ( in the current context ). This child environment will inherit all providers of the parent ( and will reuse existing singleton instances ) but will recreate all instances with scope environment. This respective component - and all of its children - will this have isolated values!

If child components need to access the environment as well, make sure the establish the corresponding context!

Example:

const environment = useLocalEnvironment()

<EnvironmentContext.Provider value={environment}>
   ...
</EnvironmentContext.Provider>

Reactive Classes

So we already solved the problem how to have distinct instances per component hierarchy, but we also need to solve the problem of reactivity. We will borrow some of the MobX ideas and integrate that with the existing mechanisms.

Example:

@injectable({scope: "environment"})
@reactive
class Counter extends Controller {
  @observable count = 0

  @computed
  get double() { return this.count * 2 }

  @command()
  async increment() {
    await new Promise(r => setTimeout(r, 1200))
    this.count++
  }

  @command()
  async decrement() {
    await new Promise(r => setTimeout(r, 1200))
    this.count--
  }

  @command()
  reset() { this.count = 0 }
}

Any injectable can be marked as reactive which will respect the observable, computed, action similar to MobX.

command is a new concept as it both is an action and creates a stateful command object that is accessible in the base class Controller that offers methods

  • execute(name: string, ...args: any[]): any
  • enable(name: string, state = true)
  • isEnabled(name: string): boolean

The enabled status is observable as well!

Let's look at an example:

export const CounterView = () => {
  useObserver()
  
  const counter = useLocalEnvironment().get(Counter)
  const busy = !counter.isEnabled("increment")

  return (
        <div>
          <div>{counter.count}</div>
          <div>×2 = {counter.double}</div>

          <div>
            {busy ? "executing…" : "ready"}
          </div>

          <div>
            <button disabled={busy} onClick={counter.decrement}></button>
            <button disabled={busy} onClick={counter.increment}>+</button>
          </div>
     </div>
  )
}

useObserver() will make sure that the renderer will retrigger whenever changes occur!

The enabled status will change automatically while the command is executing!

Additionally, if we already apply AOP mechanisms ( the methods are internally replaced by around methods ) we could add additional logic easily.

Example:

  @around(methods().decoratedWith(command as any).thatAreSync())
  around(invocation: Invocation): any {
     const ctrl = invocation.target as Controller
     const name = invocation.method().name

     const start = performance.now()

     try {
       return invocation.proceed()
     }
     finally {
       const duration = performance.now() - start
       console.log(`< ${name} finished in ${duration.toFixed(2)} ms`)
       ctrl.enable(name)
     }
  }

:-)

Showcase

A showcase app shows a shell and a microfrontend.

image

API Docs

API docs are available here