import {
BehaviorSubject,
defer as observableDefer,
EMPTY,
Observable,
of as observableOf,
ReplaySubject,
Subject,
Subscription,
} from "rxjs";
import { map, startWith, switchMap } from "rxjs/operators";
import { Injectable, Injector } from "@angular/core";
import { Configuration, Widget } from "./app.configuration";
import {
AppController,
AppControllerFactory,
AppParams,
ControllerRegistry,
} from "./app.controller";
import { HttpClient } from "@angular/common/http";
import {
AppGlobalChannelsService,
GlobalChannel,
GlobalChannelRegistry,
} from "./app.global-channels.service";
import { ChannelDescriptor, WidgetMetadata } from "../widget.descriptor";
import { WidgetConfig } from "../widget.configuration";
import { Link } from "../../components/hal/hal";
import { NmModuleDescriptor } from "../../../nm.module-descriptor";
const NG_ON_INIT = "ngOnInit";
export function appServiceFactory(
http: HttpClient,
channels: AppGlobalChannelsService,
injector: Injector
): AppService {
return new AppService(http, channels, injector);
}
export function uuid() {
return ("" + 1e7 + -1e3 + -4e3 + -8e3 + -1e11).replace(/[018]/g, (r) => {
let c = Number(r);
return (
c ^
(crypto.getRandomValues(new Uint8Array(1))[0] & (15 >> (c / 4)))
).toString(16);
});
}
export interface DefaultChannelOptions {
createRelay: () => Subject<any>;
}
export interface ChannelOptions {
relay?: Subject<any>;
subscribeTargetsFirst?: boolean;
}
export type SubjectOrOptions =
| ChannelOptions
| Subject<any>
| ReplaySubject<any>
| BehaviorSubject<any>;
export interface WidgetChannel {
widget: string;
channel: string;
}
export interface Supplier {
(): any;
}
export interface Consumer {
(input: any): any;
}
export type SourceChannel =
| Observable<any>
| WidgetChannel
| GlobalChannel
| Supplier
| (Observable<any> | WidgetChannel | GlobalChannel | Supplier)[];
export type TargetChannel =
| Subject<any>
| WidgetChannel
| GlobalChannel
| Consumer
| (Subject<any> | WidgetChannel | GlobalChannel | Consumer)[];
export interface Transformation {
(observable: Observable<any>): Observable<any>;
}
const DEFAULT_TRANSFORMATION: Transformation = (observable) => observable;
export interface ChannelRef {
subscription: Subscription;
widgetId?: string;
}
export interface SourceChannelRef extends ChannelRef {
observable: Observable<any>;
}
export interface TargetChannelRef extends ChannelRef {
subject: Subject<any>;
}
export interface ChannelConfig {
transformation: Transformation;
}
export interface SourceChannelConfig extends ChannelConfig {
channel: SourceChannel;
refs: SourceChannelRef | Array<SourceChannelRef>;
}
export interface TargetChannelConfig extends ChannelConfig {
channel: TargetChannel;
refs: TargetChannelRef | Array<TargetChannelRef>;
}
type ExtractRefs<C> = C extends SourceChannelConfig
? SourceChannelRef
: TargetChannelRef;
export interface Bridge {
name: string;
sources: SourceChannelConfig[];
targets: TargetChannelConfig[];
relay: Subject<any>;
initialized: boolean;
options: ChannelOptions;
}
export interface ChannelBridgeBuilder {
from(
channel: SourceChannel,
transformation?: Transformation
): ChannelBridgeBuilder;
to(
channel: TargetChannel,
transformation?: Transformation
): ChannelBridgeBuilder;
}
export class ChannelBridgeBuilderImpl implements ChannelBridgeBuilder {
readonly bridge: Bridge;
constructor(
name: string,
private config: Configuration,
private options: ChannelOptions
) {
if (!name) {
throw new Error("name is required");
}
this.bridge = {
name: name,
sources: [],
targets: [],
relay: options.relay,
initialized: false,
options,
};
this.bridge.toString = function (): string {
return this.name;
};
}
public from(
channel: SourceChannel,
transformation: Transformation = DEFAULT_TRANSFORMATION
): ChannelBridgeBuilder {
if (!this.canCreateBridge(channel)) {
return this;
}
let channels: Array<SourceChannel> = [];
channels = channels.concat(channel);
channels.forEach((ch) => {
this.bridge.sources.push({
channel: ch,
transformation: transformation,
refs: null,
});
});
return this;
}
public to(
channel: TargetChannel,
transformation: Transformation = DEFAULT_TRANSFORMATION
): ChannelBridgeBuilder {
// If this is a widget-channel try to check if the widget exists. If not dont create a new target-bridge
if (!this.canCreateBridge(channel)) {
return this;
}
let channels: Array<TargetChannel> = [];
channels = channels.concat(channel);
channels.forEach((ch) => {
this.bridge.targets.push({
channel: ch,
transformation: transformation,
refs: null,
});
});
return this;
}
private canCreateBridge(channel: SourceChannel | TargetChannel): boolean {
if (isWidgetChannel(channel)) {
let exists = this.config.widgets.hasOwnProperty(channel.widget);
if (!exists) {
console.debug(
`Unable to find widget ${channel.widget} the bridge to the channel ${channel.channel} will not be created`
);
return false;
}
}
return true;
}
}
export interface WidgetData {
widgetId: string;
id: string;
widget: any;
descriptor: WidgetMetadata;
initializeChannels: () => void;
}
function isChannelOptions(options: any): options is ChannelOptions {
return (
options.hasOwnProperty("relay") ||
options.hasOwnProperty("subscribeTargetsFirst")
);
}
function isGlobalChannel(channel: any): channel is GlobalChannel {
return channel.hasOwnProperty("channel") && !channel.hasOwnProperty("widget");
}
function isWidgetChannel(channel: any): channel is WidgetChannel {
return channel.hasOwnProperty("widget") && channel.hasOwnProperty("channel");
}
function isSubject(channel: any): channel is Subject<any> {
return channel instanceof Subject;
}
function isObservable(channel: any): channel is Observable<any> {
return channel instanceof Observable;
}
function isConsumer(channel: TargetChannel): channel is Consumer {
return typeof channel == "function" && channel.length == 1;
}
function isSupplier(channel: SourceChannel): channel is Supplier {
return typeof channel == "function" && channel.length == 0;
}
export interface ControllerService {
registerController(name: string): void;
}
export interface ChannelService {
connectDefaults(defaults: DefaultChannelOptions): void;
connect(
name: string,
subjectOrOptions?: SubjectOrOptions
): ChannelBridgeBuilder;
}
declare var lload: any;
declare var contextPath: any;
declare var nmLoader: any;
@Injectable()
export class AppService implements ControllerService, ChannelService {
private config: Configuration = null;
private widgetConfigs: Map<string, WidgetConfig>;
private channels: Set<Bridge>;
private controllers: Set<AppController>;
private _detached = new ReplaySubject<any>(1);
private _detachedObs = this._detached.asObservable().pipe(startWith(false));
private _activeContext = new ReplaySubject<any>(1);
private _initialized = new ReplaySubject<any>(1);
private _componentInitialized = new ReplaySubject<any>(1);
private global: GlobalChannelRegistry;
private connectDefaultOpts: DefaultChannelOptions = {
createRelay: () => new ReplaySubject(1),
};
public get configurations() {
return this.config;
}
public constructor(
private http: HttpClient,
globalChannelsService: AppGlobalChannelsService,
private injector: Injector
) {
this.initialize();
// clone the global channel registry, so that we can register page specific
// global channels here
this.global = globalChannelsService.clone();
this.global.registerSource("initialized", this._initialized);
this.global.registerSource(
"component-initialized",
this._componentInitialized
);
this.global.registerSource(
"initialized-active-context",
this._activeContext.pipe(
switchMap((ctx) => this._initialized.pipe(map((_) => ctx)))
),
(params, prop) => (params && prop ? params[prop] : params)
);
this.global.registerSource(
"active-context",
this._activeContext,
(params, prop) => (params && prop ? params[prop] : params)
);
}
/**
* detaches all active channels
*
* incoming events from source channels will be buffered in the relay "ReplaySubject", which
* keeps the latest event from all it's sources.
*/
public detach() {
this._detached.next(true);
}
/**
* re-attaches all channels
*
* all target channels will re-subscribe to the relay "ReplaySubjects", which automatically
* re-emits the latest value from it's source channels
*/
public attach() {
this._detached.next(false);
}
set activeContext(param: any) {
this._activeContext.next(param);
}
public initialized() {
this._initialized.next(true);
this._initialized.complete();
}
public componentInitialized(id: string) {
this._componentInitialized.next(id);
}
/**
* @deprecated use ControllerRegistry.registerController directly
*/
public static registerAppController(
name: string,
factory: AppControllerFactory
) {
ControllerRegistry.registerController(name, factory);
}
public loadPage(params: AppParams): Observable<Configuration> {
return this.http.get(params.href).pipe(
map((response) => <Configuration>response),
map((config) => this.configurationLoaded(<Configuration>config))
);
}
private configurationLoaded(config: Configuration): Configuration {
this.config = config;
if (config.messages) {
console.log(
`CONFIGURATION MESSAGES for ${config.module}:${config.identifier}:${config.profile}`
);
config.messages.forEach((msg) =>
console.log(msg.level, msg.path, msg.message)
);
}
let controllerId = config["root-controller"];
if (!controllerId) {
throw new Error("no root controller configured");
}
let widgetId = config["root-widget"];
if (!widgetId) {
throw new Error("no root widget configured");
}
// initializes the root controller and allows the root controller to register sub-controllers if required
// after any initialization of the controller, the required channel connections can be registered
this.registerController(controllerId);
return config;
}
public registerController(controllerId: string) {
let controllerConfig = this.config.controllers[controllerId];
if (!controllerConfig) {
throw new Error(
`no configuration for controller "${controllerId}" found`
);
}
let controllerName = controllerConfig.controller;
let factory = ControllerRegistry.get(controllerName);
if (!factory) {
throw new Error(
`no factory for controller "${controllerId}:${controllerName}" found`
);
}
let controller = factory.create(this.injector);
this.controllers.add(controller);
controller.initialize(controllerConfig, this);
controller.connect(this);
}
public connectDefaults(defaults: DefaultChannelOptions): void {
this.connectDefaultOpts = defaults;
}
public connect(
name: string,
subjectOrOptions?: SubjectOrOptions
): ChannelBridgeBuilder {
let options: ChannelOptions;
if (subjectOrOptions == null) {
options = { relay: this.connectDefaultOpts.createRelay() };
} else if (isSubject(subjectOrOptions)) {
let subject = subjectOrOptions as Subject<any>;
options = { relay: subject };
} else if (isChannelOptions(subjectOrOptions)) {
options = subjectOrOptions;
if (options.relay == null) {
options.relay = this.connectDefaultOpts.createRelay();
}
if (options.subscribeTargetsFirst == null) {
options.subscribeTargetsFirst = false;
}
}
let builder = new ChannelBridgeBuilderImpl(name, this.config, options);
this.channels.add(builder.bridge);
return builder;
}
public configure(
id: string,
widget: any,
descriptor: WidgetMetadata,
configuration: WidgetConfig,
beforeInitCallback?: (widget) => void
): WidgetData {
let data: WidgetData = {
widgetId: uuid(),
id: id,
widget: widget,
descriptor: descriptor,
initializeChannels: () => {
try {
if (this.channels) {
this.channels.forEach((bridge) =>
this.initializeBridge(bridge, data)
);
}
} catch (err) {
console.error(err);
throw new Error(
"exception during onInit or connectInput/-Output " +
JSON.stringify(err)
);
}
},
};
if (descriptor.propertyConfiguration) {
widget[descriptor.propertyConfiguration] = configuration;
}
if (descriptor.propertyId) {
widget[descriptor.propertyId] = id;
}
if (descriptor.methodConfigure) {
widget[descriptor.methodConfigure](configuration);
}
if (beforeInitCallback) {
beforeInitCallback(widget);
}
return data;
}
private static unsubscribeRef(
channel: SourceChannelConfig,
predicate: (ref: SourceChannelRef) => boolean
): boolean;
private static unsubscribeRef(
channel: TargetChannelConfig,
predicate: (ref: TargetChannelRef) => boolean
): boolean;
private static unsubscribeRef(
channel: any,
predicate: (ref: any) => boolean
): boolean {
if (!channel.refs) {
return;
}
let reset = false;
let refs = Array.isArray(channel.refs) ? channel.refs : [channel.refs];
refs = refs.filter((ref) => {
if (ref == null) {
return false;
}
let unsubscribe = predicate(ref);
if (unsubscribe) {
ref.subscription.unsubscribe();
reset = true;
}
return !unsubscribe;
});
channel.refs = AppService.toRefs(refs);
return reset;
}
public destroyComponent(id: string) {
this.channels.forEach((bridge) => {
let reset = false;
bridge.sources
.filter((source) => isWidgetChannel(source.channel))
.forEach((source) => {
reset = AppService.unsubscribeRef(
source,
(ref) => ref.widgetId && ref.widgetId == id && !!ref.subscription
);
});
bridge.targets
.filter((target) => isWidgetChannel(target.channel))
.forEach((target) => {
reset = AppService.unsubscribeRef(
target,
(ref) => ref.widgetId && ref.widgetId == id && !!ref.subscription
);
});
if (reset) {
bridge.initialized = false;
}
});
}
private initializeBridge(bridge: Bridge, widget: WidgetData) {
if (bridge.initialized) {
return;
}
const subscribeTargetsFirst = bridge.options.subscribeTargetsFirst;
let targetsReady;
// check, if we want to subscribe to targets first
// to ensure the connection is established, before events from any sources
// can be emitted
if (subscribeTargetsFirst) {
targetsReady = this.resolveTargets(bridge, widget);
}
// try to resolve all sources
let sourcesReady = this.resolveSources(bridge, widget);
// .. and targets, if they weren't already resolve above
if (!subscribeTargetsFirst) {
targetsReady = this.resolveTargets(bridge, widget);
}
if (sourcesReady && targetsReady) {
console.log(`bridge "${bridge.name}" fully initialized`);
bridge.initialized = true;
}
}
private resolveTargets(bridge: Bridge, widget: WidgetData) {
let targetsReady = true;
for (let target of bridge.targets) {
let isResolved = this.resolveTarget(target, widget, bridge);
if (!isResolved) {
targetsReady = false;
}
}
return targetsReady;
}
private resolveSources(bridge: Bridge, widget: WidgetData) {
let sourcesReady = true;
for (let source of bridge.sources) {
let isResolved = this.resolveSource(source, widget, bridge);
if (!isResolved) {
sourcesReady = false;
}
}
return sourcesReady;
}
/**
* build a refs property. if the result is only on element, that element will be returned.
* If more than one ref exist, return an array
*
* @param refs array of references
* @param newRef reference to add
*/
static toRefs<T>(refs: T | Array<T>, newRef: T = null): T | Array<T> {
if (refs == null) {
return newRef;
}
if (newRef == null) {
if (Array.isArray(refs)) {
if (refs.length == 0) {
return null;
}
if (refs.length == 1) {
return refs[0];
}
}
return refs;
}
if (Array.isArray(refs)) {
return [].concat(...refs, newRef);
}
return [refs, newRef];
}
private static subscribeSource(
config: SourceChannelConfig,
observable: Observable<any>,
relay: Subject<any>,
widgetId: string = null
) {
if (!observable) {
return;
}
observable = config.transformation(observable);
let newRef = {
observable,
widgetId,
subscription: observable.subscribe(relay),
};
config.refs = AppService.toRefs(config.refs, newRef);
}
private static subscribeTarget(
config: TargetChannelConfig,
subject: Subject<any>,
relay: Subject<any>,
detached,
widgetId: string = null
) {
if (!subject) {
return;
}
let observable = relay.asObservable();
let source = detached.pipe(
switchMap((isDetached) => (isDetached ? EMPTY : observable))
);
let newRef = {
subject,
widgetId,
subscription: config.transformation(source).subscribe(subject),
};
config.refs = AppService.toRefs(config.refs, newRef);
}
private resolveSource(
config: SourceChannelConfig,
data: WidgetData,
bridge: Bridge
): boolean {
let channel = config.channel;
if (isWidgetChannel(channel)) {
let ch = <WidgetChannel>channel;
if (ch.widget !== data.id) {
return false;
}
let descriptor = data.descriptor;
let channelDescriptor: ChannelDescriptor = descriptor.outputs[ch.channel];
if (channelDescriptor) {
let property = channelDescriptor.property;
if (!data.widget[property]) {
data.widget[property] = new Subject<any>();
}
let observable = data.widget[property];
AppService.subscribeSource(
config,
observable,
bridge.relay,
data.widgetId
);
// no return here. we may want to subscribe multiple instances of the same widget
}
return false;
}
// fast return if non-widget channel already has a channel-ref
// the channel was already resolved
if (config.refs) {
return true;
}
let observable: Observable<any>;
if (isObservable(channel)) {
observable = channel;
} else if (isSupplier(channel)) {
observable = observableDefer(() => observableOf((<Supplier>channel)()));
} else if (isGlobalChannel(channel)) {
observable = this.global.getSource(channel);
} else {
throw new Error("unknown channel type");
}
if (observable) {
AppService.subscribeSource(config, observable, bridge.relay);
return true;
}
return false;
}
private resolveTarget(
config: TargetChannelConfig,
data: WidgetData,
bridge: Bridge
): boolean {
let channel = config.channel;
if (isWidgetChannel(channel)) {
let ch = <WidgetChannel>channel;
if (ch.widget !== data.id) {
return false;
}
let descriptor = data.descriptor;
let channelDescriptor: ChannelDescriptor = descriptor.inputs[ch.channel];
if (channelDescriptor) {
let property = channelDescriptor.property;
if (!data.widget[property]) {
data.widget[property] = new Subject<any>();
}
let subject = data.widget[property];
AppService.subscribeTarget(
config,
subject,
bridge.relay,
this._detachedObs,
data.widgetId
);
// no return here. we may want to subscribe multiple instances of the same widget
}
return false;
}
// fast return if non-widget channel already has a channel-ref
// the channel was already resolved
if (config.refs) {
return true;
}
let subject: Subject<any>;
if (isSubject(channel)) {
subject = channel;
} else if (isConsumer(channel)) {
subject = new Subject();
subject.subscribe((data) => (<Consumer>channel)(data));
} else if (isGlobalChannel(channel)) {
subject = this.global.getTarget(channel);
} else {
throw new Error("unknown channel type");
}
if (subject) {
AppService.subscribeTarget(
config,
subject,
bridge.relay,
this._detachedObs,
null
);
return true;
}
return false;
}
public hasWidget(id: string): boolean {
return this.widgetConfigs.has(id);
}
public registerWidget(widget: WidgetConfig) {
this.widgetConfigs.set(widget.id, widget);
}
public toWidgetConfig(id: string): WidgetConfig {
let result: WidgetConfig = this.widgetConfigs.get(id);
if (result) {
return result;
}
let widget: Widget = this.config.widgets[id];
if (widget == undefined) {
// console.debug(`Widget with id ${id} could not be found`);
return undefined;
}
result = {
id: id,
component: widget.component,
configuration: widget.configuration,
hostClass: widget.hostClass,
hostClassModifiers: widget.hostClassModifiers,
elementClass: widget.elementClass,
elementClassModifiers: widget.elementClassModifiers,
};
if (widget.links) {
result._links = {};
for (let link in widget.links) {
result._links[link] = <Link>{
href: widget.links[link],
};
}
}
if (widget.header) {
result.header = this.toWidgetConfig(widget.header);
}
if (widget.actions) {
result.actions = {
alignment: widget.actions.alignment,
elements: widget.actions.elements.map((entry) =>
this.toWidgetConfig(entry)
),
};
}
if (widget.footer) {
result.footer = this.toWidgetConfig(widget.footer);
}
this.widgetConfigs.set(id, result);
return result;
}
private initialize() {
this.widgetConfigs = new Map<string, WidgetConfig>();
this.channels = new Set<Bridge>();
this.controllers = new Set<AppController>();
}
public reset() {
this.disposeChannels();
this.initialize();
}
private disposeChannels() {
if (this.controllers) {
this.controllers.forEach((controller) => controller.dispose());
}
if (this.channels) {
this.channels.forEach((channel) => {
channel.targets
.filter((target) => !!target.refs)
.map((target) => target.refs)
.forEach((refs) => {
refs = Array.isArray(refs) ? refs : [refs];
refs
.filter((ref) => !!ref.subscription)
.forEach((ref) => ref.subscription.unsubscribe());
});
channel.sources
.filter((source) => !!source.refs)
.map((source) => source.refs)
.forEach((refs) => {
refs = Array.isArray(refs) ? refs : [refs];
refs
.filter((ref) => !!ref.subscription)
.forEach((ref) => ref.subscription.unsubscribe());
});
});
}
}
public getWidgetConfig(key: string) {
return this.config.widgets[key];
}
public dispose() {
this._activeContext.unsubscribe();
this._initialized.unsubscribe();
this._componentInitialized.unsubscribe();
this.disposeChannels();
}
}
export function loadController(module: NmModuleDescriptor) {
return Observable.create((observer) => {
let path = `${contextPath}/${module.identifier}/extensions/js/controller.js`;
lload(path, (err) => {
if (err) {
console.warn(`controller.js not found here: ${path}`);
} else {
let loadedModule = nmLoader.registry["custom-controller"];
let result = loadedModule["APP"];
if (result && result.controllers) {
let controllers: { [id: string]: AppControllerFactory } =
result.controllers;
for (let name in controllers) {
ControllerRegistry.registerController(name, controllers[name]);
}
}
}
observer.complete();
});
}).toPromise();
}