-
Notifications
You must be signed in to change notification settings - Fork 1
Microfrontends
So, why do we need another microfrontend project? The low-level technology ( e.g. module federation ) is pretty much established and there are lots of GitHub projects that demonstrate the basic features...
Well... the most use-cases you can find hardwired remote configurations and only lately so called "dynamic module federation" examples at least show how to get rid of this restriction. Those example usually end with a sentence "and here you will get a dynamic configuration from somewhere"
This is exactly where we start here. This project is about a completely dynamic setup and infrastructure for microfrontend based applications including administration possibilities that allow you define what a specific user will actually see as an application. The project is split in an Angular part, that defines the basic building bocks for client artifacts, starting with the logic required for the microservice part, but also covering a lot of other areas that are typically touched in any client-side project ( e.g. i18n, authentication, authorization, etc. )
A server-side part is implemented as a Kotlin based Spring-Boot application, that acts as a central portal server that will take care of maintaining and distributing dynamic deployment information to the clients.
The basic idea is to have a central "portal server", that maintains ( either by pull or push-mechanisms ) meta-data of different microfrontends that can be combined in form of one or more applications. The meta-data will cover several aspects
- basic connectivity information ( of course )
- additional meta-data on a microfrontend level ( enabled status, required permissions, etc.)
- full meta-data of all routable components including information on
- required permissions
- required feature flags
- categories and tags that can be interpreted during the runtime
The missing part is the notion of an application, which more or less is a combination of micro-frontends. Both micro-frontends and applications are versioned, so that all different combinations are supported.
With this information, and an appropriate administrative tool, administrators are able to configure appropriate combinations of microservices and features that are visible in a portal. As a client boots, it will get a tailored deployment configuration in a first step, appropriate for an application in combination with a specific user, that will be interpreted by the runtime by
- setting up the microfrontend remoting configuration, and
- setting up the resulting router configuration
Tailored means, that different dynamic aspects - such as the current authorization context, feature flags, etc. ) are used to filter the set of available features / microfrontends. Since we usually have a certain login-logic, this process is executed multiple times, at least for the initial portal and the portal after successful login.
The following picture illustrates the idea and the timing

- micro services register with the portal server
- the shell client requests a deployment configuration, and
- renders the dynamic content
In order to get a feeling for the possibilities, here are some screenshots of the implemented UI.
All microfrontends and shell applications need to be registered, in our case "microfrontend-1", "microfrontend-2" and the shell "shell"
The UI for a microfrontend will show basic meta-data and all top-level features

An "application" - in a specific version - is the combination of different micro-frontends in different versions.

In addition to the micro-frontends we are able to configure environment-variables, in our case the backend url.
Last but not least, we need to configure the related application version given a shell

With this information, the configured shell will request the deployment configuration.
I18N administration
The overall logic is based on meta-data that describes the internal setup of a microfrontend.
Since we don't want to duplicate data ( e.g. typescript code and json configuration files ), a nx plugin has been implemented that is able to parse angular files and extract the corresponding information in form of a manifest.json
The basis for the extraction process are specific decorators, that are associated with feature components.
Example:
Given this component
@Feature({
id: 'public-portal',
visibility: ["public"],
tags: ["portal"]
})
@Component({
selector: 'public-portal',
templateUrl: './public-portal-component.html',
styleUrls: ["./public-portal-component.scss"]
})
export class PublicPortalComponent extends AbstractFeature {
...
}the decorator @Feature is used to extract its properties that end up in the manifest.json
{
"name": "my-microfrontend",
"version": "0.1",
"commitHash": "253c108b3fc289605e96fd654b99c7ab34c348fb",
"module": "RemoteEntryModule",
"features": [
...
{
"id": "public-portal",
"label": "public-portal",
"component": "PublicPortalComponent",
"tags": ["portal"],
"visibility": ["public"],
"module": "PublicPortalModule"
}
]
}This file - as part of the microfrontend assets - is exactly the information read and maintained by the portal server.
Since we have already parsed and extracted meta-data from Angular code we can implement additional generators, that help to avoid writing boilerplate code. As part of the nx plugin, that primarily extracts the manifest.json, it will also
- create a
webpack.config.js - create Angular routes, including the corresponding
- router modules for both the top-level application and all lazy child components.
Especially hardcoded routes where always a pain in my eyes, since they are hard to read and error-prone to write and completely contradict the idea of distributed components. Finally gone, puuu :-)
In order to allow maximum flexibility in providing and supporting completely different clients, it is essential that the main application frame ( or usually in technical terms, the shell ) is a purely technical component without knowing what components it will exactly host. Required dependencies should only relate to mostly cross-functional topics such as authentication, i18n, etc.
As a consequence the ui frame is also a dynamic component, that will be supplied in form of a particular microfrontend. While the particular component will of course include some hardcoded logic, it makes sense to benefit as much as possible by utilizing metadata. For example the display of navigation items can be easily abstracted by filtering the existing features according to some logic ( e.g. features with the tag "navigation" should appear in a navigation component )
I never was a friend of using module federation just in order to allow guys to do whatever they want in a team ( starting with completely different technology stacks ). This is pure anarchy in my eyes and especially a waste of time and money. 😄
But there is more... Even with a single technology stack there are so many cross-functional use-cases that need to be solved in different areas, that it definitely makes sense to offer a framework layer on top of the low-level solutions in order to streamline, simplify and most importantly increase developer productivity and raise code quality.
Part of the ( shared ) "portal" library deals with those use-cases by providing numerous solutions like
- i18n
- configuration values
- command pattern & command interceptors
- shortcut handling
- saving and restoring of states
- online help
- error handling
- communication
- speech recognition
- tracing
- etc.
We will cover some of the topics in the following chapters...
As long as not used and requested, or i can apply the code in a project, none... :-)
What comes to my mind is at least:
- migration to standalone components
- native federation
- esbuild
- adding i18n and a more sophisticed ui ( searching, chapters, etc. ) to the help mechanism
Every microfrontend shell application needs to mark the top-level module with the decorator @Shell in order to give the nx plugin a hint, where to generate the route and routing module file.
Example:
@Shell({
name: 'shell',
})
@NgModule({
...
})
export class ShellModule {
}Every microfrontend application needs to mark the top-level module with the decorator @Microfrontend in order to give the nx plugin a hint, where to generate the route and routing module file.
Example:
@Microfrontend({
name: 'microfrontend',
})
@NgModule({
...
})
export class MicrofrontendModule {
}The decorator @Feature marks components as feature elements. It defines the following properties
-
idthe unique id within the microfrontend -
isDefaultis true, the '' route will redirect to this feature ( if none, the first feature will we picked according to the alphabetical sorting ) -
parentthe optional parent id in case of hierarchical components / routes -
routeradditional hints for the router. The embedded object can override the route path via thepathproperty. -
labeloptional label of the feature ( will defualt to the id if not provided ) -
labelKeyoptional i18n resource key that will be used to retrieve a localized label. -
descriptionoptional description of the feature -
iconoptional icon name tha can be used to render naviagtion entries -
i18noptional array of i18n namespaces that will be loaded before rendering the component -
visibilityoptional array of visibility properties ("private" and "public") that will control, if a feature should be available with or without a session. -
tagsoptional array of tags that can be interpreted by the runtime -
categoriesoptional array of categories that can be interpreted by teh runtime -
permissionsoptional array of permissions that are required to activate this feature -
featureTogglesoptional array of feature toggles that are required to activate this feature
The backend currently doesn't interpret the last three properties, btw.!
The corresponding nx plugin will find the decorators and extract the embedded properties.
We will typically make use of the provided feature meta-data in order to render dynamic navigation components.
If applications get bigger, having all features on one level wouldn't make sense anymore.
For this purpose we can define ( recursive ) folder structures that can be used in the runtime based on the decorator @Folder and the corresponding config interface
export interface FolderConfig {
name : string,
label? : string,
icon? : string,
parent? : string
}The folder property of a feature can now be linked to a folder structure in a second step.
Example:
@Folder({
name: "microfrontends",
label: "Microfrontend Stuff",
icon: "home"
})
@Feature({
id: "microfrontend",
folder: "microfrontends",
labelKey: ["microfrontends.label"],
})
export class MicrofrontendFeature extends AbstractFeature {
...
}A multi-level navigation component is already part of the library, that will render something like that
All features must derive from a base class AbstractFeature. While it adds some lifecycle methods and stores the injector in the injector property, its main purpose is to be able to identify access all active features
during the runtime by linking features with their parents ( and vice versa ) resulting in an overall tree of features with one feature ( the portal itself ) as a root.
parent?: AbstractFeature;
children: AbstractFeature[] = [];The lifecycle methods allow you to add callbacks in the available Angular lifecycle phase.
Example:
class SomeFeature extends AbstractFeature {
constructor() {
this.onDestroy(() => {
...
})
}
...
}Available methods are:
onInit(func: () => void) : voidafterViewInit(func: () => void) : voidafterContentInit(func: () => void) : voidonDestroy(func: () => void) : void
In order to be able to inject the direct parent, the decorator @Feature additionally adds the necessary injector information.
The implementation of features follow regular angular logic. In order to easily access common framework logic but avoid ugly class hierarchies, i have adopted mixins heavily.
A mixin is a typescript mechanism helping to write reusable logic by achieving a kind of multiple inheritance. Mixins are functions that can be included in the extends clause and more or less add a base class that is a result of the function call. The charm is that you can compose mixins by simply chaining the calls.
Example:
export class TranslationEditorComponent extends WithState<TranslationState>()(WithDialogs(WithSpeechCommands(WithCommands(AbstractFeature)))) {
...
}How cool is that! 😄
In the following sections a numebr of mixins will be introduced, that can be applied to features.
The component <feature-outlet> is used to render a named feature by passing the fully qualified name
Example:
<feature-outlet [feature]="portal.path"></feature-outlet>This component will of course load all required lazy modules and remotes recursively...
The singleton FeatureRegistry maintains all registered features during the runtime and can be used to find specific features via
getFeature(id: string) : FeatureDatafindFeature(id: string): FeatureData | undefined
or the most flexible method find() : FeatureFinder that offers a fluent interface to filter features.
Example:
const portals = featureRegistry.finder()
.withTag('portal')
.withVisibility('private')
.find()A number of places in the framework rely on
- decorators and
- type information related to properties or methods
In order to have a common mechanism, where the appropriate information is extracted and retrieved the class TypeDescriptor is implemented, that will analyze types and maintain the gathered information.
- properties including type information
- methods including type information
- type, method, and property decorators
- superclass relationships
It of course relies on the typescript metadata refection api; please make sure that the top-level entry point imports the "reflect-metadata" library.
Example:
TypeDescriptor.forType(SomeType).getMethod("getFoo").returnTypeA a nice add-on, typescript decorators can be implemented, that register "injection" possibilities, allowing for ( Spring-like ) injections.
Example: Injection of a configuration - here the environment property - values
class SomeClass {
@Value("production")
production:boolean = false
}The implementation is straightforward. Let's look at the code.
export function Value(key: string, defaultValue: any = undefined): any {
return function (target: any, propertyKey: string) {
TypeDescriptor.forType(target.constructor)
// register the decorator
.addPropertyDecorator(target, propertyKey, Value as any)
// and add the injection logic
.addInjector(new InjectProperty(propertyKey, (injector: Injector) => injector.get(ConfigurationManager).get(key, defaultValue)))
}
}InjectProperty is responsible to set a property value given the passed function.
The decorator Injected injects an instance of the appropriate type.
The injections are executed by the TypeDescriptor method
inject(target: T, injector: Injector): TThe base class ÀbstractFeature already includes this call!
The module SecurityModule is used to configure three different aspects
-
authentication
takes care of the authentication process to the system -
authorization
answers questions about assigned permissions of the current user -
session management
maintains the state of a session once a user is logged in.
The class Authentication covers the authentication process. It was designed so, that both "traditional" login flows are possible as well as token-based flows.
/**
* an authentication request consisting of - at least - the user and password
*/
export interface AuthenticationRequest {
/**
* the user name
*/
user : string;
/**
* the password
*/
password : string;
/**
* any other parameters
*/
[prop : string] : any;
}
export interface Ticket {
/**
* any ticket properties
*/
[prop : string] : any;
}
export class Authentication<U = any, T extends Ticket = Ticket> {
/**
* return a combination of a user and ticket related to the specified authentication request.
* @param request the authentication request
*/
authenticate(request : AuthenticationRequest) : Observable<Session<U, T>> {
return throwError(new AuthenticationException(request.user, 'no authentication configured'));
}
}A successful login will return a session object containing the user information and a ticket ( e.g. storing tokens ). Subclasses will add the corresponding generic parameters.
In the context of OIDC ( OpenID Connect ) , the class OIDCAuthentication is implemented, that also specifies the user
export interface OIDCUser {
given_name : string
family_name : string
email : string
email_verified : string
name : string
preferred_username : string
sub : string
// did we forget something?
[prop : string] : any;
}and the ticket information
export interface OIDCTicket extends Ticket {
token : string
refreshToken : string
}The class Authorization answers questions related to granted permissions.
/**
* authorization answers questions about granted permissions
*/
export class Authorization {
/**
* return true, if the current session has access to a specific permission, false otherwise.
* @param permission the permission object
*/
hasPermission(permission : string) : boolean {
return true; // that's easy :-)
}
}The default always returns true.
Once a user has logged in, a session is created that captures the relevant data in form of the user information and any other aspects.
export interface Session<U = any, T extends Ticket = Ticket> {
/**
* the user object
*/
user : U;
/**
* the ticket
*/
ticket : T;
/**
* any other properties
*/
[prop : string] : any;
}The singleton SessionManager maintains the corresponding object.
The main methods are
-
start()
execute any startup login ( e.g. required for teh derived OIDC class ) -
login()
trigger the login process -
logout()
trigger the logout process -
hasSession() : boolean
return true, if a session is established -
currentSession() : Session<U, T>
return the current session -
getUser() : U
return the current user
A number of subjects can be subscribed to
-
authenticated$ = new BehaviorSubject<boolean>(false)
emits events the relate to the authentication state -
session$ = new BehaviorSubject<Session<any,Ticket> | undefined>(undefined)
emits events the relate to the current session -
events$ = new Subject<SessionEvent<any,Ticket>>()
emits session events
Session events are
export interface SessionEvent<U=any,T extends Ticket = Ticket> {
type: "opening" | "opened" | "closing" | "closed"
session: Session<U,T>
}The PortalManager is the main component that takes care of the (microfrontend) routing logic and is configured in the main module.
Example:
import { localRoutes } from "./local.routes";
import * as localManifest from "../assets/manifest.json"
...
PortalModule.forRoot({
loader: {
// call the portal server!
server: {}
// this would read the remoteEntry.mjs by hand in case of a missing server :-)!
//local: {
// remotes:["http://localhost:4201", "http://localhost:4202"]]
//}
},
localRoutes: localRoutes,
localManifest: localManifest,
decorateRoutes: (route : Route) => {
route.resolve = {i18n: I18nResolver}
route.canActivate = [CanActivateGuard]
route.canDeactivate = [CanDeactivateGuard]
}
}),Its main task is to load a specific deployment and establish and synchronize the angular routing. The passed decorator is used to add properies to the angualr routes, in this case
- a resolver that preloads i18n namespaces
- a
canActivateGuard, that checks if the feature is enabled acanDeactivateGuard, that calls a possiblecanDeactive()method of the current component.
The interface
export abstract class DeploymentLoader {
abstract load() : Promise<Deployment>
}is used to load a deployment, given the interfaces
/**
* The microfrontend manifest data as stored in the manifest.json
*/
export interface Manifest extends ModuleMetadata {
name : string,
version : string,
enabled? : boolean,
health? : string,
commitHash : string,
remoteEntry? : string,
healthCheck?: string,
module : string,
features : FeatureConfig[],
folders : FolderData[],
}
/**
* a set of microfrontends
*/
export interface Deployment {
modules : { [name : string] : Manifest }
}All lazy routes have to be registered with the portal manager, since it will for example link feature information to the routes ( via the data property )
The static method registerLazyRoutes is called for this purpose.
Example:
@NgModule({
imports: [
RouterModule.forChild(PortalManager.registerLazyRoutes('first-microfront.private-portal', routes)),
],
exports: [RouterModule],
})
export class PrivatePortalRouterModule {}The good news is, that this is not written by hand, since the nx plugin does the job already. :-)
Whenever the session state changes ( eg. login, logout ) the deployment has to be recomputed and routes adjusted accordingly.
Example:
logout() {
this.sessionManager.closeSession().subscribe(
(session) => {
this.portalManager.loadDeployment(true /* merge with existing */).then(result =>
this.router.navigate(["/"])
)
})
}The point of a reasonable error handling concept is to
- capture as much context information as possible, and to
- have a flexible approach how to handle captured errors
We try to solve both problems by having a pluggable or configurable approach how to introduce error handling logic and provide hooks into the angular logic which is the global error handler and interceptors in http and command execution. Let's start with the error handling logic.
An internal class ErrorManager is responsible to maintain different error handlers and to dispatch to them in case of caught errors.
Handlers are methods marked with a decorator @ErrorHandler
Example:
@Injectable({ providedIn: 'root' })
export class CustomErrorHandler {
// handler
@ErrorHandler()
handleString(error: string, context?: ErrorContext) {
// put logic here
}
@ ErrorHandler()
handleError(error: Error, context?: ErrorContext) {
// put logic here
}
...
}The marked methods expect two methods
- the error instance
- an error context object
The error manager will collect all registered handlers and will dispatch to the most applicable handler in the method
handle(error : any, errorContext? : ErrorContext)Most applicable means that it will respect the provided types and the corresponding class hierarchy. If several handlers match ( in case of inherited error classes ) the handler can proceed to the next most applicable handler by calling
ExceptionManager.proceed()Example:
@HandleError()
handleString(error: Error, context?: ErrorContext) {
// put logic here
}
@HandleError()
handleError(error: CommunicationError, context?: ErrorContext) {
// put logic here
ExceptionManager.proceed() // call the first handler as well
}The context object captures additional information . It is defined as
export interface ErrorContext {
/**
* the chained {@link ErrorContext}
*/
$next?: ErrorContext
/**
* the type of context
*/
$type: string
/**
* any possible properties of this context
*/
[prop: string]: unknown
}Two places are covered:
- command execution
type ist set to "command", the property "command" is set to the command name - http call
type is set to "http", the property "request" is set to the http request object
So if a command triggers a http call that fails, we can see the complete chain including the triggering command name and the http request object.
The module ErrorModule needs to configure the classes that contribute handlers
Example:
ErrorModule.forRoot({
handler: [ErrorHandler] // can be any class...
}),Additionaly the command module has to add the interceptor
CommandModule.forRoot({
interceptors: [..., CommandErrorInterceptor]
}),It will additional
- insert an angular error handler that will delete to the error manager, and
- insert the http interceptor that catches errors early
Once we have the possibility to react differently on different error types we can define a number of base error classes, which can be used in error handling logic
Error
FatalError
ServerError
CommunicationError-
FatalError
base class for non expected and non handleable fatal errors - `CommunicationError*
error caused by the communication protocol -
ServerError
error caused by a backend
The http interceptor already transforms to the correct classes.
Since a number of use-case require localization and the existing solutions ( e.g. transloco ) are way too bloated in my mind, a small own solution has been implemented.
The service LocaleManager responsibility is to store the current ( and available ) locales.
It is configured with the module LocaleManager
Example:
LocaleModule.forRoot({
locale: 'en-US',
supportedLocales: ['en-US', 'de-DE'],
}) It offers a getter and setter for the locale
setLocale(locale : string | Intl.Locale)getLocale() : Intl.Locale
Listeners can be informed about changes by subscribing to
subscribe(onLocaleChange : OnLocaleChange, priority = 10) : () => void
where
interface OnLocaleChange {
/**
* called whenever the current locale changes
* @param locale the new locale
*/
onLocaleChange(locale : Intl.Locale) : Observable<any>;
}The priority is used to sort listeners ( smaller number are executed earlier )
The main interface for translation purposes is Translator, that defines the main methods
-
translate(key : string, options?: any) : string
translate the key given optional options for interpolation -
translate$(key : string, options?: any) : Observable<string>
translate the key given optional options for interpolation and return an observable
The internal logic relies on an i18n organization that separates between namespaces and names
A valid key contains a ':' that separates the two items
Example: "portal.commands:ok.label"
As you can see, both namespace and names are '.'-separated paths. In reality ( also supported by an ui editor ) the name is only a pair of a name followed by a type.
Supported types are
- "label"
the label - "tooltip"
tooltips that can be used for example in combination with buttons - "shortcut"
localized shortcuts, e.g. "ctlr+z" - "speech"
associated speech command
A translator needs a loader that is responsible for loading translations which is defined as
export abstract class I18nLoader {
/**
* load the specified namespace
* @param locale the requested locale
* @param namespace the requested namespace
*/
abstract loadNamespace(locale : Intl.Locale, namespace : string) : Observable<any>;
}Two implemenations are available
-
ServerTranslationLoader
a loader that will call a rest service -
AssetTranslationLoader
a loader that will retrieve static i18n json files from the assets
The module I18nModule is used to configure the translator
Example:
I18nModule.forRoot({
loader: { type: ServerTranslationLoader }
}),The pipe translate can be used to integrate i18n in html templates
Example:
{{"portal.commands:ok.label" | translate}}The pipe can process additional arguments for interpolation as a second argument.
{{"some.namespace:price.label" | translate | {price: price}}}where the localized strings include necessary placeholders with optional formatting options
Example:
Hello {me}, today is {today:date()}, and i cost {price:number(style: 'currency', currency: 'EUR')}!"
Supported types for formatting options are "date" and "number" where the attributes are directly passed on to the
Intl.DateFormat and Intl.NumberFormat respectively.
Check the documentation for DateFormat and NumberFormat
Commands is a design pattern, that maps a specific functionality - in typescript: a simple method - to a standalone object that will be responsible for the execution.
Example:
@Command({})
foo() {
console.log("that's not exciting...")
}OK, so far, so good.... executing foo is now part of a command ( which will technically mean, btw. that the original method is replaced by a technical method that does the magic.
The big benefit is, that
- the command is stateful
and we can all of a sudden
- add meta-data to the command object that will influence execution logic
- implemented in form of specific interceptors
Let's look at some use-cases
Shortcuts
passing shortcut: "ctrl-z"
will activate the appropriate keyboard shortcut, that will trigger the command
I18N
adding i18n: portal.commands:ok"
will lookup available translations and fill related properties ("shortcut", "label", "tooltip")
Command Status
Commands are enabled or disabled.
Passing lock: "command" will deactivate the command as long as a previous execution has not returned.
Think of a "Save" button that should be deactivated, if pressed, in order to avoid multiple executions.
UI Interaction
In the context of specific features, command execution can influence the component by
- activating the busy cursor after a small delay, or
- locking the complete view with an overlay and spinner
Other interceptors
Other configured interceptors can be used to add additional logic.
Examples:
- error handling
- performance measurements
Let's look at the overall properties of the passed configuration object
-
command?: string
the command name ( if not passed, the method name is used) -
group?: string
the group of the commands that may be disabled -
label?: string
the label of the command. If not passed, it will be set to the name -
i18n?: string
the i18n key that will be used to translate the other i18n aspects ( "label", "tooltip", "speech", "shortcut") -
shortcut?: string
the shortcut of the command -
tooltip?: string
the tooltip of the command -
icon?: string
the icon name of the command -
enabled?: boolean
the initial enabled status of the command -
speech?: string
the speech input that can trigger a command in context of an activated speech recognition -
lock?: "command" | "view" | "group"
the locking behaviour during command execution.
The module CommandModule is used to configure the top-level interceptors
Example:
CommandModule.forRoot({
interceptors: [interceptor_1, ..., interceptor_n]
})where an interceptor is defined as
/**
* A <code>CommandInterceptor</code> is part of a chain of interceptors and can add execution logic as part of a command execution.
*/
export interface CommandInterceptor {
/**
* called prior to method execution
* @param executionContext {@link ExecutionContext} the current execution context
*/
onCall(executionContext: ExecutionContext): void;
/**
* called after a result has been computed
* @param executionContext {@link ExecutionContext} the current execution context
*/
onResult(executionContext: ExecutionContext): void;
/**
* called after an exception has been caught
* @param executionContext {@link ExecutionContext} the current execution context
*/
onError(executionContext: ExecutionContext): void;
}The integration of commands is done by simply adding the appropriate mixin.
class AppComponent extends WithCommands(AbstractFeature) {
// commands
@Command({})
foo() {
...
}
}It will activate the decorator parsing and add the following methods
-
findCommand(command: string) : CommandDescriptor | undefined
find a named command -
getCommand(command: string) : CommandDescriptor
find a named command. This will throw an exception if the command is not defined -
setCommandEnabled(command: string, value: boolean): CommandManager
sets the enabled state of the command
Typically, the enabled status of commands is executed in a single place.
Example:
updateCommandState() {
this
.setCommandEnabled("save", this.isDirty())
.setCommandEnabled("revert", this.isDirty())
...
}Another mixing WithSpeechCommands ( that requires a base class that supports commands ) is able to interpret the speech property of a command ( manually or retrieved via i18n )
It requires a top-level module configuration
SpeechRecognitionModule.forRoot({
lang: 'de-DE', // the initial locale
continuous: true, // listen continuously
interimResults: false // only deliver final result, if any
}),The property can specify
- combination of words, e.g. "say hello"
- optional words, by parentheses, e.g. "hi (andi)"
- placeholders, prefixed with a colon, e.g. "hi :dude"
In case of placeholders, the command has to define an argument that will hold the named results
Example:
@Command({
speech: "hi :dude"
})
hi(args: any) {
console.log("hi " + args.dude)
}In addition to the support for commands, a component can be used to support entry in input fields.
Example:
<mat-form-field>
<mat-label>Label</mat-label>
<input [(ngModel)]="value" id="id" [name]="id" matInput type="text" [voiceInput]="{'icon': mic}"/>
<!-- a microfone -->
<microfone-icon matSuffix #mic>mic</microfone-icon>
</mat-form-field>The <microfone-icon> is a simple mat-icon with a little animation. I tried to integrate that in the voiceInput but was not successful and gave up after a while 😭
The mixin WithDialogs adds methods that let you open ( convenience ) dialogs easily.
Example:
class AppComponent extends WithDialogs(AbstractFeature) {
...
void checkSave() {
return this.confirmationDialog()
.title("Unsaved Changes")
.message("Still close?")
.okCancel() // it will look for portal.commands:ok/cancel translations
.show()
.subscribe(result => ...)
}
}The new methods are
-
inputDialog() : InputDialogBuilder
returns a fluent interface to configure input dialogs -
confirmationDialog() : ConfirmationDialogBuilder
returns a fluent interface to configure confirmation dialogs -
openDialog<T>(component: ComponentType<T>, configuration: any) : Observable<any>
is used to open a generic dialog
The last method methods must be used instead of the angular method, since it needs add additional logic before openeing and after closing dialogs ( related to the management shortcuts, etc. )
An application typically requires some configuration parameters passed from the outside, that control some internal aspects.
Usually there are the Angular files environment.ts that contain parameters.
Since this is not the only source, and we additionally would like to add some convenience methods for retrieving values, a general configuration logic is implemented.
The singleton ConfigurationManager is implemented that will manage different sources and can retrieve values.
It is configured via
ConfigurationModule.forRoot(...sources: ConfigurationSource[])Example:
ConfigurationModule.forRoot(new ValueConfigurationSource(environment))Here we simply integrate the existing environment.
Retrieval is done by the ConfigurationManager method
/**
* get a configuration value
* @param key the - possibly '.' separated - key
* @param defaultValue possible default value, if the value is not known
*/
get<T>(key: string, defaultValue: T | undefined = undefined): T | undefinedConfiguration sources need to implement the interface
export interface ConfigurationSource {
/**
* return true, if the source is laoded, fals otherwise
*/
isLoaded() : boolean
/**
* return the loaded values
*/
values() : any
/**
* load the configuration values asynchronously and return the resulting tree
*/
load(): Promise<any>
}The only implementation so far is the ValueConfigurationSource which simply digests the passed value
A typical problem in the context of rest services is always, how and where to configure URLs of backend servers. The approach here, is based on two components
- an abstract base class for services
- a service used to compute base URLs
Example:
@Injectable({providedIn: 'root'})
@Service({domain: "admin", prefix: "/administration"}) // prefix is added to the URLs
export class ComponentService extends AbstractHTTPService {
// constructor
constructor(injector : Injector) {
super(injector);
}
// public
public listAll() : Observable<string[]> {
return this.get<string[]>(`/services`);
}
...
}The base class AbstractHTTPService in combination with the decorator offers the low-level method ( get, post, ... )
A second component
export abstract class EndpointLocator {
/**
* return a base url for server calls
* @param domain a domain name
*/
abstract getEndpoint(domain : string) : string
}is used to retrieve base URLs given a domain name.
A typical implementation is based on configuration values based on this implementation:
@Injectable({providedIn: 'root'})
export class ApplicationEndpointLocator extends EndpointLocator {
// constructor
constructor(private configuration : ConfigurationManager) {
super()
}
// implement
getEndpoint(domain : string) : string {
return this.configuration.get<string>("backend." + domain)!
}
}and the corresponding entries
export const environment = {
production: true,
backend: {
admin: 'http://localhost:8083'
}
};The concrete implementation needs to be added in the provider section of the main module, e.g.
providers: [{
provide: EndpointLocator,
useClass: ApplicationEndpointLocator
},
...
]The mixin WithState can be used to persist a component state and restore it when reopened.
Example:
interface TranslationState {
selectedNamespace?: string
selectedMessage?: string
}
...
export class TranslationEditorComponent extends WithState<TranslationState>()(AbstractFeature) {
...
// override Stateful
override applyState(state: TranslationState) : void {
...
}
override writeState(state: TranslationState) : void {
state.selectedNamespace = this.selectedNamespace?.path
state.selectedMessage = this.selectedName
}
}
While every component is responsible for its own state, the framework logic will assemble an overall json object containing the overall state of the root component.
Example:
{
"owner": {
"component": "app"
},
"data": {
"feature": "home"
},
"children": [
{
"owner": {
"component": "translations" // by default the component selector
},
"data": {
"selectedNamespace": "portal.commands",
"selectedMessage": "ok"
},
"children": []
},
...
]
}This object will be persisted via an implementation of
/**
* a <code>StateStorage</code> is responsible to save and load states
*/
export abstract class StateStorage {
/**
* load the state of a portal
* @param application the id of the application
* @param session the current session
*/
abstract load(application: string, session?: Session): State
/**
* save the sate
* @param state the state object
* @param application the application name
* @param session the current session
*/
abstract save(state: State, application: string, session?: Session): void
}which is configured in the module
StateModule.forRoot({
storage: LocalStorageStateStorage
}
),LocalStorageStateStorageis the only current implementation that simply uses the local storage
Since all state objects form a tree, it is essential that all parents of stateful components are also stateful!
Tracing offers a simple logging mechanism for development purposes which will be deactivated in production code.
After configuration of the corresponding module
TracerModule.forRoot({
enabled: environment.production !== true,
trace: new ConsoleTrace('%d [%p]: %m %f\n'), // d(ate), l(evel), p(ath), m(message), f(rame)
paths: {
"": TraceLevel.OFF,
"portal": TraceLevel.FULL,
"session.oidc": TraceLevel.FULL,
...
}
}),individual log messages are created by calling
Example:
if (Tracer.ENABLED)
Tracer.Trace('message-bus', TraceLevel.MEDIUM, 'broadcast topic {0}: {1}', message.topic, message.message);Different tracing outputs are possible that cover the interface Trace.
Currently the class ConsoleTrace simply delegates the output to the console.
The constructor parameter defines the message format. Valid placeholders are
-
%dthe date of the log -
%lthe trace level -
%pthe path of the trace message -
%mthe message itself -
%fthe location of the calling frame
Example:
Wed Feb 21 2024 [session.oidc]: handle event token_expires webpack:///libs/portal/src/lib/security/oidc/oidc-session-manager.ts:30:23
As you can see, integrating the frame will load the appropriate source maps:-)
The mixin WithView is used in combination with a specific component <view> that surrounds the component html.
It will add the following methods
export interface WithView {
/**
* the view component
*/
view : ViewComponent
/**
* set the busy cursor of the view
*/
setBusy(busy: boolean) : void
/**
* show or hide the view overlay
*/
showOverlay(on: boolean): void
/**
* show a message as part of the overlay
*/
showMessage(message: string): void
}As a side effect, specific command interceptors will be included that map the locking logic - busy cursor after 100ms, etc. - to the ui.
The generator
portal-artifact-generator
parses a project for the specific decorators and will generate different files:
-
manifest.jsonthe extracted manifest - the webpack configuration
- files containing routes, and
- router modules ( yea yea...i know, need to switch )
The logic concerning the naming of router modules is based on the original module names by simply adding "Router".
If we are talking about lazy modules, the information is taken from the @Feature itself
Example:
@Feature({
id: 'lazy-child',
parent: "some-parent",
router: {
lazyModule: "LazyChildModule"
}
})will create a module LazyChildRouterModule
feature-generator
is used to generate new features. A number of checkboxes are available that will include different mixins.
Please make sure, to rerun the artifact generator, since new features need to be visible in the other artifacts as well.
shell-generator
generates a minimal shell project.
While the most parameters - and the resulting project code - come from the standard angular application generator, it adds
-
serverURL: stringthe URL of the backend portal server -
generatePublicPortal: booleaniftrue, it will generate a feature the renders the frame of a public portal ( e.g. prior to a login ) -
generatePrivatePortal: booleaniftrue, it will generate a feature the renders the frame of a private portal ( after successful login )
The portals will simply render a toolbar containing feature links ( of features that are tagged with "navigation" ) and a corresponding router-outlet. A right aligned button "Login"/"Logout" will trigger the corresponding actions based on a dummy authentication.
The only content of the shell component is a feature-outlet that references the corresponding portal feature. It should look this

It will throw an exception, if the required features are not available.
microservice-generator
generates a minimal microfrontend project. It adds the same additional parameters as the shell generator.
A mimimal showcase can be setup with a few clicks by using the corresponding plugins
Generate the corresponding projects via the generators
npx nx generate microfrontend-shell-generator
and
npx nx generate microfrontend-generator
As a last step we need to run the portal server and register the microfrontends.
For this purpose we can execute
docker compose -f web-compose.yml up -din the top-level docker folder.
the account/password is "coolsamson" and "geheim"
In the "microfrontends" feature, click the plus button and add the microfrontend URLs ( e.g. "http://localhost:4200" )
Beware: The docker compose already starts two microfrontends and a shell under the ports 4200-4203.
If you check the corresponding Docker files, you will btw. see, how an automatic registration can be already included in the corresponding containers :-)
If you don't like the hustle, replace the line
loader: {
server: {}
}with
loader: {
local: {
remotes: ["http://<host>:<port>", ...]
}
}Additional features can be added with the generator feature-generator.
You can already have a sneak peek by looking at the apps
Have fun!