Skip to content

Commit

Permalink
refactor(platform): remove gateway iframe in client-broker communication
Browse files Browse the repository at this point in the history
The rationale for this extra gateway-iframe was to create a separate namespace for the platform and the host application in terms of communication and capabilities, and to allow multiple connections from a microfrontend.
But, in libraries such as the SCION Workbench, we cannot hook into the platform's internal namespace. Further, multiple connections from a microfrontend are a very hypothetical construct. Therefore, we decided to remove this extra iframe, which halves the number of iframes, eliminates a communication hop, and drastically reduces the complexity in the client-broker communication.

closes #14

BREAKING CHANGE: Removing the gateway communication iframe introduced a breaking change in the host/client communication protocol.

You need to upgrade the version of SCION Microfrontend Platform in host and client applications at the same time.
The breaking change refers only to the communication protocol, the API of the SCION Microfrontend Platform has not changed.

To migrate, upgrade to the newest version of `@scion/microfrontend-platform` in the host and client applications.
  • Loading branch information
danielwiehl authored and mofogasy committed Jan 31, 2022
1 parent 756125d commit 0a4b4b0
Show file tree
Hide file tree
Showing 25 changed files with 551 additions and 847 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -107,7 +107,7 @@ export class DevToolsManifestService {
new QualifierMatcher(intention.qualifier, {evalAsterisk: true, evalOptional: true}).matches(capability.qualifier) ||
new QualifierMatcher(capability.qualifier, {evalAsterisk: true, evalOptional: true}).matches(intention.qualifier)
)),
filterArray(intention => isCapabilityVisibleToApplication(capability, intention.metadata.appSymbolicName)),
filterArray(intention => this.isCapabilityVisibleToApplication(capability, intention.metadata.appSymbolicName)),
);
}

Expand All @@ -118,13 +118,13 @@ export class DevToolsManifestService {
new QualifierMatcher(intention.qualifier, {evalAsterisk: true, evalOptional: true}).matches(capability.qualifier) ||
new QualifierMatcher(capability.qualifier, {evalAsterisk: true, evalOptional: true}).matches(intention.qualifier)
)),
filterArray(capability => isCapabilityVisibleToApplication(capability, intention.metadata.appSymbolicName)),
filterArray(capability => this.isCapabilityVisibleToApplication(capability, intention.metadata.appSymbolicName)),
);
}
}

function isCapabilityVisibleToApplication(capability: Capability, appSymbolicName: string): boolean {
return !capability.private || this._appsBySymbolicName.get(appSymbolicName).scopeCheckDisabled || capability.metadata.appSymbolicName === appSymbolicName;
private isCapabilityVisibleToApplication(capability: Capability, appSymbolicName: string): boolean {
return !capability.private || this._appsBySymbolicName.get(appSymbolicName).scopeCheckDisabled || capability.metadata.appSymbolicName === appSymbolicName;
}
}

const byType = (a: ManifestObject, b: ManifestObject): number => a.type.localeCompare(b.type);
Expand Down
22 changes: 11 additions & 11 deletions apps/microfrontend-platform-devtools/src/app/ng-zone-decorators.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,28 +23,28 @@ export class NgZoneMessageClientDecorator implements BeanDecorator<MessageClient
constructor(private _zone: NgZone) {
}

public decorate(messageClient: MessageClient): MessageClient {
public decorate(delegate: MessageClient): MessageClient {
const zone = this._zone;
return new class implements MessageClient {

public publish<T = any>(topic: string, message?: T, options?: PublishOptions): Promise<void> {
return messageClient.publish(topic, message, options);
return delegate.publish(topic, message, options);
}

public request$<T>(topic: string, request?: any, options?: RequestOptions): Observable<TopicMessage<T>> {
return messageClient.request$<T>(topic, request, options).pipe(synchronizeWithAngular(zone));
return delegate.request$<T>(topic, request, options).pipe(synchronizeWithAngular(zone));
}

public observe$<T>(topic: string): Observable<TopicMessage<T>> {
return messageClient.observe$<T>(topic).pipe(synchronizeWithAngular(zone));
return delegate.observe$<T>(topic).pipe(synchronizeWithAngular(zone));
}

public onMessage<IN = any, OUT = any>(topic: string, callback: (message: TopicMessage<IN>) => Observable<OUT> | Promise<OUT> | OUT | void): Subscription {
return messageClient.onMessage(topic, callback);
return delegate.onMessage(topic, callback);
}

public subscriberCount$(topic: string): Observable<number> {
return messageClient.subscriberCount$(topic).pipe(synchronizeWithAngular(zone));
return delegate.subscriberCount$(topic).pipe(synchronizeWithAngular(zone));
}
};
}
Expand All @@ -59,24 +59,24 @@ export class NgZoneIntentClientDecorator implements BeanDecorator<IntentClient>
constructor(private _zone: NgZone) {
}

public decorate(intentClient: IntentClient): IntentClient {
public decorate(delegate: IntentClient): IntentClient {
const zone = this._zone;
return new class implements IntentClient {

public publish<T = any>(intent: Intent, body?: T, options?: IntentOptions): Promise<void> {
return intentClient.publish(intent, body, options);
return delegate.publish(intent, body, options);
}

public request$<T>(intent: Intent, body?: any, options?: IntentOptions): Observable<TopicMessage<T>> {
return intentClient.request$<T>(intent, body, options).pipe(synchronizeWithAngular(zone));
return delegate.request$<T>(intent, body, options).pipe(synchronizeWithAngular(zone));
}

public observe$<T>(selector?: Intent): Observable<IntentMessage<T>> {
return intentClient.observe$<T>(selector).pipe(synchronizeWithAngular(zone));
return delegate.observe$<T>(selector).pipe(synchronizeWithAngular(zone));
}

public onIntent<IN = any, OUT = any>(selector: IntentSelector, callback: (intentMessage: IntentMessage<IN>) => Observable<OUT> | Promise<OUT> | OUT | void): Subscription {
return intentClient.onIntent(selector, callback);
return delegate.onIntent(selector, callback);
}
};
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,10 @@ This part of the documentation is for developers who want to integrate the SCION
[.chapter-title]
In this Chapter
- <<chapter:angular-integration-guide:configuring-hash-based-routing>>
- <<chapter:angular-integration-guide:starting-platform-in-app-initializer>>
- <<chapter:angular-integration-guide:connecting-to-host-in-app-initializer>>
- <<chapter:angular-integration-guide:using-route-resolver-instead-app-initializer>>
- <<chapter:angular-integration-guide:configuring-hash-based-routing>>
- <<chapter:angular-integration-guide:activate-custom-elements-schema>>
- <<chapter:angular-integration-guide:providing-activator-services>>
- <<chapter:angular-integration-guide:providing-platform-beans-for-dependency-injection>>
Expand All @@ -22,27 +22,15 @@ In this Chapter
****
'''

[[chapter:angular-integration-guide:configuring-hash-based-routing]]
[discrete]
=== Configuring Hash-Based Routing
We recommend using hash-based routing over HTML 5 push-state routing in micro applications. To enable hash-based routing in an Angular application, pass `{useHash: true}` when configuring the router in `AppRoutingModule`, as follows.

[source,typescript]
----
include::angular-integration-guide.snippets.ts[tags=configure-hash-based-routing]
----

====
TIP: Read chapter <<chapter:miscellaneous:routing-in-micro-applications>> to learn more about why to prefer hash-based routing.
====

[[chapter:angular-integration-guide:starting-platform-in-app-initializer]]
[discrete]
=== Starting the Platform Host in an Angular App Initializer

The platform should be started during the bootstrapping of the Angular application, that is, before displaying content to the user. Hence, we recommend starting the platform in an app initializer. See chapter <<chapter:angular-integration-guide:using-route-resolver-instead-app-initializer>> for an alternative approach.

NOTE: Angular allows hooking into the process of initialization by providing an initializer to the `APP_INITIALIZER` injection token. Angular will wait until all initializers resolved to start the application, making it the ideal place for starting the SCION Microfrontend Platform.
Angular allows hooking into the process of initialization by providing an initializer to Angular's `APP_INITIALIZER` injection token. Angular will wait until all initializers resolved to start the application, making it the ideal place for starting the SCION Microfrontend Platform.

NOTE: We recommend starting the platform outside of the Angular zone in order to avoid excessive change detection cycles of platform-internal subscriptions to global DOM events.

The following code snippet configures an Angular `APP_INITIALIZER` to start the platform. In the provider definition, we reference a higher order factory function and instruct Angular to inject the `PlatformInitializer` as function argument.

Expand All @@ -64,7 +52,7 @@ include::start-platform-via-initializer.snippets.ts[tags=host-app:initializer]
----
<1> Injects the Angular `NgZone`.
<2> Declares or loads the platform config, e.g., using `HttpClient`.
<3> Starts the platform host. It is super important to start the platform outside the Angular zone to avoid excessive change detection cycles.
<3> Starts the platform host. We recommend starting it outside of the Angular zone in order to avoid excessive change detection cycles.


====
Expand All @@ -76,20 +64,22 @@ TIP: Refer to chapter <<chapter:configuration:starting-the-platform-in-host-appl
=== Connecting to the Host in an Angular App Initializer
A micro application should connect to the platform host during the bootstrapping of the Angular application, that is, before displaying content to the user. Hence, we recommend connecting to the platform host in an app initializer. See chapter <<chapter:angular-integration-guide:using-route-resolver-instead-app-initializer>> for an alternative approach.

NOTE: We recommend connecting to the platform host outside of the Angular zone in order to avoid excessive change detection cycles of platform-internal subscriptions to global DOM events.

[source,typescript]
----
include::start-platform-via-initializer.snippets.ts[tags=micro-app:initializer]
----
<1> Provides an initializer to the `APP_INITIALIZER` injection token.
<2> Instructs Angular to pass the `NgZone` to the higher order function.
<3> Returns the initializer function, which connects to the host. It is super important to start the platform outside the Angular zone to avoid excessive change detection cycles.
<3> Returns the initializer function, which connects to the host. We recommend connecting to the host outside of the Angular zone in order to avoid excessive change detection cycles.

[[chapter:angular-integration-guide:using-route-resolver-instead-app-initializer]]
[discrete]
=== Using a Route Resolver instead of an App Initializer
If you cannot use an app initializer for starting the platform or connecting to the platform host, an alternative would be to use a route resolver.
If you cannot use an app initializer for starting the platform or connecting to the platform host, an alternative would be to use a route resolver. Angular allows installing resolvers on a route, allowing data to be resolved asynchronously before the route is finally activated.

NOTE: Angular allows installing resolvers on a route, allowing data to be resolved asynchronously before the route is finally activated.
NOTE: We recommend connecting to the platform host outside of the Angular zone in order to avoid excessive change detection cycles of platform-internal subscriptions to global DOM events.

The following code snippet illustrates how to connect to the platform host using an Angular resolver. Similarly, you could start the platform in the host application.

Expand All @@ -100,7 +90,7 @@ For a micro application, the resolver implementation could look as following:
include::start-platform-via-resolver.snippets.ts[tags=resolver]
----
<1> Injects the Angular `NgZone`.
<2> Connects to the platform host. It is super important to start the platform outside the Angular zone to avoid excessive change detection cycles.
<2> Connects to the platform host. We recommend connecting to the host outside of the Angular zone in order to avoid excessive change detection cycles.

Ensure that a micro application instance connects to the host only once. Therefore, it is recommended to install the resolver in a parent route common to all microfrontend routes. When loading a microfrontend for the first time, Angular will wait activating the child route until the platform finished starting. When navigating to another microfrontend of the micro application, the resolver would not resolve anew.

Expand All @@ -111,6 +101,20 @@ include::start-platform-via-resolver.snippets.ts[tags=resolver-registration]
<1> Installs the resolver on a component-less, empty-path route, which is parent to the microfrontend routes.
<2> Registers microfrontend routes as child routes.

[[chapter:angular-integration-guide:configuring-hash-based-routing]]
[discrete]
=== Configuring Hash-Based Routing
We recommend using hash-based routing over HTML 5 push-state routing in micro applications. To enable hash-based routing in an Angular application, pass `{useHash: true}` when configuring the router in `AppRoutingModule`, as follows.

[source,typescript]
----
include::angular-integration-guide.snippets.ts[tags=configure-hash-based-routing]
----

====
TIP: Read chapter <<chapter:miscellaneous:routing-in-micro-applications>> to learn more about why to prefer hash-based routing.
====

[[chapter:angular-integration-guide:activate-custom-elements-schema]]
[discrete]
=== Instruct Angular to allow Custom Elements in Templates
Expand Down Expand Up @@ -213,9 +217,28 @@ include::lazy-loaded-modules.snippets.ts[tags=microfrontend-1-module]

[[chapter:angular-integration-guide:preparing-messaging-for-use-with-angular]]
[discrete]
=== Preparing the MessageClient and IntentClient for use with Angular
=== Synchronizing MessageClient and IntentClient with the Angular Zone

If you start or connect to the platform outside of the Angular zone, which we strongly recommend, messages and intents will be received outside of the Angular zone. In consequence, Angular does not trigger a change detection cycle, which may not update the UI as expected.

Consequently, when receiving messages or intents, you have to synchronize with the Angular zone. For example, as follows.

[source,typescript]
----
include::angular-integration-guide.snippets.ts[tags=synchronize-with-angular-zone-subscription]
----
<1> Runs the passed lambda inside the Angular zone.

You can also use the `observeInside` RxJS operator of `@scion/toolkit` to run downstream operators and the subscription handler inside the Angular zone.

[source,typescript]
----
include::angular-integration-guide.snippets.ts[tags=synchronize-with-angular-zone-observeInside-operator]
----
<1> Runs downstream operators and the subscription handler inside the Angular zone.


Messages and intents are published and received via a separate browsing context, preventing Angular (or more precisely zone.js) from triggering a change detection cycle, causing the UI not to update as expected. Therefore, we recommend to decorate the `MessageClient` and `IntentClient` with a bean decorator and pipe its Observables to emit inside the Angular zone.
Alternatively, to not have to synchronize each subscription with the Angular zone, we recommend decorating the two beans `MessageClient` and `IntentClient` with a bean decorator to pipe Observable emissions into the Angular zone in a central place.

TIP: Decorators allow intercepting bean method invocations. For more information about decorators, refer to the link:https://github.com/SchweizerischeBundesbahnen/scion-toolkit/blob/master/docs/site/tools/bean-manager.md[documentation of the bean manager, window=\"_blank\"].

Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import { ContextService, IntentClient, ManifestService, MessageClient, OutletRouter } from '@scion/microfrontend-platform';
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
import { RouterModule, Routes } from '@angular/router';
import { Beans } from '@scion/toolkit/bean-manager';
import {ContextService, IntentClient, ManifestService, MessageClient, OutletRouter} from '@scion/microfrontend-platform';
import {NgZone} from '@angular/core';
import {RouterModule, Routes} from '@angular/router';
import {Beans} from '@scion/toolkit/bean-manager';
import {observeInside} from '@scion/toolkit/operators';

// tag::provide-platform-beans-for-dependency-injection[]
@NgModule({
Expand Down Expand Up @@ -35,3 +36,24 @@ import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
export class AppModule {
}
// end::add-custom-elements-schema[]

const zone: NgZone = undefined;

// tag::synchronize-with-angular-zone-subscription[]
Beans.get(MessageClient).observe$('topic').subscribe(message => {
console.log(NgZone.isInAngularZone()); // Evaluates to `false`

zone.run(() => { // <1>
console.log(NgZone.isInAngularZone()); // Evaluates to `true`
});
});
// end::synchronize-with-angular-zone-subscription[]

// tag::synchronize-with-angular-zone-observeInside-operator[]
Beans.get(MessageClient).observe$('topic')
.pipe(observeInside(continueFn => zone.run(continueFn))) // <1>
.subscribe(message => {
console.log(NgZone.isInAngularZone()); // Evaluates to `true`
});
// end::synchronize-with-angular-zone-observeInside-operator[]

Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,7 @@ export namespace Contexts {
*/
export function newContextValueLookupRequest(name: string, replyTo: string, options?: ContextLookupOptions, values?: any[]): MessageEnvelope<TopicMessage<any[]>> {
return {
transport: MessagingTransport.EmbeddedOutletContentToOutlet,
transport: MessagingTransport.MicrofrontendToOutlet,
channel: MessagingChannel.Topic,
message: {
topic: contextValueLookupTopic(encodeURIComponent(name)), // Encode in order to support names containing forward slashes or starting with a colon.
Expand All @@ -98,7 +98,7 @@ export namespace Contexts {
*/
export function newContextTreeNamesLookupRequest(replyTo: string, names?: Set<string>): MessageEnvelope<TopicMessage<Set<string>>> {
return {
transport: MessagingTransport.EmbeddedOutletContentToOutlet,
transport: MessagingTransport.MicrofrontendToOutlet,
channel: MessagingChannel.Topic,
message: {
topic: Contexts.contextTreeNamesLookupTopic(),
Expand All @@ -117,7 +117,7 @@ export namespace Contexts {
*/
export function newContextTreeObserveRequest(replyTo: string): MessageEnvelope<TopicMessage<void>> {
return {
transport: MessagingTransport.EmbeddedOutletContentToOutlet,
transport: MessagingTransport.MicrofrontendToOutlet,
channel: MessagingChannel.Topic,
message: {
topic: Contexts.contextTreeChangeTopic(),
Expand Down
Loading

0 comments on commit 0a4b4b0

Please sign in to comment.