import type { AppModulesInstance, AppScriptModule, AppManifest, AppConfig, AppSettings, ConfigEnvironment, } from '../types'; import { type FlowSubject, Observable } from '@equinor/fusion-observable'; import type { AppModuleProvider } from '../AppModuleProvider'; import { combineLatest, of, type OperatorFunction, Subscription, firstValueFrom, lastValueFrom, } from 'rxjs'; import { defaultIfEmpty, filter, last, map, switchMap } from 'rxjs/operators'; import type { EventModule } from '@equinor/fusion-framework-module-event'; import type { AnyModule, ModuleType } from '@equinor/fusion-framework-module'; import { createState } from './create-state'; import { actions, type Actions } from './actions'; import type { AppBundleState, AppBundleStateInitial } from './types'; import isEqual from 'fast-deep-equal'; import './events'; /** * RxJS operator that filters out `null` and `undefined` emissions. * * @template T - The non-nullable value type. * @returns An operator that only passes through non-nullable values. */ export function filterEmpty(): OperatorFunction { return filter((value): value is T => value !== undefined && value !== null); } /** * Public interface for a single loaded Fusion application. * * Provides reactive observables and imperative methods for accessing the * application's manifest, configuration, per-user settings, script module, * and initialized module instance. * * @template TEnv - Shape of the environment configuration record. * @template TModules - Additional framework modules the app depends on. */ // eslint-disable-next-line @typescript-eslint/no-explicit-any export interface IApp< TEnv extends ConfigEnvironment = ConfigEnvironment, TModules extends Array | unknown = unknown, > { /** * Returns an observable that emits the app manifest. * @returns An observable of type AppManifest. */ get manifest$(): Observable; /** * Observable that emits the configuration of the app. * @returns An Observable that emits the app configuration. */ get config$(): Observable>; /** * Returns an observable stream of the loaded app script instance. * @returns {Observable} The observable stream of app script modules. */ get modules$(): Observable; /** * Returns an observable that emits the instance of the app modules. * @returns An observable that emits the instance of the app modules. */ get instance$(): Observable>; /** * Observable that emits the settings of the app. * @returns An Observable that emits the app settings. */ get settings$(): Observable; /** * Observable that emits the status of the app. * @returns An Observable that emits the app status. */ get status$(): Observable; /** * Gets the current state of the Application. * @returns The current state of the Application. */ get state(): AppBundleState; /** * Gets the app key. * @returns The app key. */ get appKey(): string; /** * Gets the manifest of the app. * @returns The manifest of the app, or undefined if it doesn't exist. */ get manifest(): Readonly | undefined; /** * Retrieves the manifest asynchronously. * @returns A promise that resolves to the AppManifest. */ get manifestAsync(): Promise>; /** * Gets the configuration of the app. * @returns The configuration object or undefined if no configuration is set. */ get config(): AppConfig | undefined; /** * Retrieves the configuration asynchronously. * @returns A promise that resolves to the AppConfig. */ get instance(): AppModulesInstance | undefined; /** * Initializes the application container. * @returns An observable that emits an object containing the manifest, script, and config. * @example * ```typescript * app.initialize().subscribe({ * next: ({ manifest, script, config }) => { * // Use the manifest, script, and config to initialize the application * script.render(el, ...); * }, * error: (err) => console.error('Failed to load application', err), * complete: () => setInitializingApp(false) * }); * ``` */ initialize(): Observable<{ manifest: AppManifest; script: AppScriptModule; config: AppConfig; }>; /** * Loads the app configuration. */ loadConfig(): void; /** * Loads the app manifest. */ loadManifest(): void; /** * Loads the app module. * @param allow_cache Whether to allow loading from cache. */ loadAppModule(allow_cache?: boolean): void; /** * Gets the app configuration. * @param force_refresh Whether to force refreshing the configuration. * @returns An observable that emits the app configuration. */ getConfig(force_refresh?: boolean): Observable; /** * Retrieves the app configuration asynchronously. * @param allow_cache Whether to allow loading from cache. * @returns A promise that resolves to the AppConfig. */ getConfigAsync(allow_cache?: boolean): Promise; /** * Gets the app settings. * @param force_refresh Whether to force refreshing the settings. * @returns An observable that emits the app settings. */ getSettings(force_refresh?: boolean): Observable; /** * Retrieves the app settings asynchronously. * @param allow_cache Whether to allow loading from cache. * @returns A promise that resolves to the AppSettings. */ getSettingsAsync(allow_cache?: boolean): Promise; /** * Sets the app settings. * @param settings The settings object to save. * @returns An observable that emits the app settings. */ updateSettings(settings: T): Observable; /** * Sets the app settings asyncronously. * @param settings The settings object to save. * @returns An Promise that resolves the app settings. */ updateSettingsAsync(settings: T): Promise; /** * Updates a specific setting of the app. * @param property The property to update. * @param value The value to set. * @returns An observable that emits the app settings. */ updateSetting( property: P, value: T[P], ): Observable; /** * Updates a specific setting of the app asynchronously. * @param property The property to update. * @param value The value to set. * @returns A promise that resolves to the AppSettings. */ updateSettingAsync( property: P, value: T[P], ): Promise; /** * Gets the app manifest. * @param force_refresh Whether to force refreshing the manifest. * @returns An observable that emits the app manifest. */ getManifest(force_refresh?: boolean): Observable; /** * Retrieves the app manifest asynchronously. * @param allow_cache Whether to allow loading from cache. * @returns A promise that resolves to the AppManifest. */ getManifestAsync(allow_cache?: boolean): Promise; /** * Gets the app module. * @param force_refresh Whether to force refreshing the app module. * @returns An observable that emits the app module. */ getAppModule(force_refresh?: boolean): Observable; /** * Retrieves the app module asynchronously. * @param allow_cache Whether to allow loading from cache. * @returns A promise that resolves to the AppScriptModule. */ getAppModuleAsync(allow_cache?: boolean): Promise; } /** Default empty settings object used when no settings have been fetched. */ const fallbackSettings: AppSettings = {}; /** * Result emitted by {@link IApp.initialize}, containing the resolved manifest, * imported script module, and runtime configuration. */ export type AppInitializeResult = { manifest: AppManifest; script: AppScriptModule; config: AppConfig; }; /** * Concrete implementation of {@link IApp}. * * Manages an internal reactive state machine ({@link FlowSubject}) that orchestrates * manifest fetching, config loading, settings management, and script import. Dispatches * lifecycle events through the {@link EventModule} when available. * * @template TEnv - Shape of the environment configuration record. * @template TModules - Additional framework modules the app depends on. */ // TODO make streams distinct until changed from state // eslint-disable-next-line @typescript-eslint/no-explicit-any export class App< TEnv extends ConfigEnvironment = ConfigEnvironment, TModules extends Array | unknown = unknown, > implements IApp { #state: FlowSubject; //#region === streams === get manifest$(): Observable { return this.#state.select((state) => state.manifest).pipe(filterEmpty()); } get config$(): Observable> { return this.#state .select((state) => state.config as AppConfig, isEqual) .pipe(filterEmpty()); } get modules$(): Observable { return this.#state.select((state) => state.modules).pipe(filterEmpty()); } get instance$(): Observable> { return this.#state .select((state) => state.instance as AppModulesInstance) .pipe(filterEmpty()); } get settings$(): Observable { return new Observable((subscriber) => { this.#state.next(actions.fetchSettings(this.appKey)); subscriber.add( this.#state .select((state) => state.settings, isEqual) .pipe(filterEmpty(), defaultIfEmpty(fallbackSettings)) .subscribe(subscriber), ); }); } get status$(): Observable { return this.#state.select((state) => state.status); } //#endregion get state(): Readonly { // todo deep-freeze return Object.freeze(this.#state.value) as Readonly; } get appKey(): string { return this.#state.value.appKey; } get tag(): string | undefined { return this.#state.value.tag; } get manifest(): Readonly | undefined { return this.state.manifest; } get manifestAsync(): Promise> { return firstValueFrom(this.manifest$); } get config(): AppConfig | undefined { return this.state.config as AppConfig; } get configAsync(): Promise> { return firstValueFrom(this.config$); } get instance(): AppModulesInstance | undefined { return this.#state.value.instance as AppModulesInstance; } constructor( value: AppBundleStateInitial, args: { provider: AppModuleProvider; event?: ModuleType; }, ) { this.#state = createState(value, args.provider); const { appKey } = value; const { event } = args; // register events if event module is provided event && this.#registerEvents(event); // create a tear down handler for the application const subscriptions = new Subscription(); if (event) { // when app is disposed, dispatch event to notify listeners subscriptions.add(() => { event.dispatchEvent('onAppDispose', { detail: { appKey } }); }); // when disposed, dispose of monitoring of app modules loaded subscriptions.add( event.addEventListener('onAppModulesLoaded', (e) => { // validate that the event is for the current app if (e.detail.appKey === appKey) { // set the instance of the app modules this.#state.next(actions.setInstance(e.detail.modules)); } }), ); } this.dispose = () => { subscriptions?.unsubscribe(); if (this.#state.value.instance) { // tear down modules of application this.#state.value.instance.dispose(); } this.#state.complete(); }; } /** * Registers event listeners for various actions in the app. * @param event - The event module used for dispatching events. */ #registerEvents(event: ModuleType): void { const { appKey } = this; // monitor when application manifest is loading this.#state.addEffect(actions.fetchManifest.type, () => { // dispatch event to notify listeners that the application manifest is being loaded event.dispatchEvent('onAppManifestLoad', { detail: { appKey }, source: this, }); }); // monitor when application manifest is loaded this.#state.addEffect(actions.fetchManifest.success.type, (action) => { // dispatch event to notify listeners that the application manifest has been loaded event.dispatchEvent('onAppManifestLoaded', { detail: { appKey, manifest: action.payload }, source: this, }); }); // monitor when application manifest fails to load this.#state.addEffect(actions.fetchManifest.failure.type, (action) => { // dispatch event to notify listeners that the application manifest failed to load event.dispatchEvent('onAppManifestFailure', { detail: { appKey, error: action.payload }, source: this, }); }); // monitor when application configuration is loading this.#state.addEffect(actions.fetchConfig.type, () => { // dispatch event to notify listeners that the application configuration is being loaded event.dispatchEvent('onAppConfigLoad', { detail: { appKey }, source: this, }); }); // monitor when application configuration is loaded this.#state.addEffect(actions.fetchConfig.success.type, (action) => { // dispatch event to notify listeners that the application configuration has been loaded event.dispatchEvent('onAppConfigLoaded', { detail: { appKey, config: action.payload }, source: this, }); }); // monitor when application configuration fails to load this.#state.addEffect(actions.fetchConfig.failure.type, (action) => { // dispatch event to notify listeners that the application configuration failed to load event.dispatchEvent('onAppConfigFailure', { detail: { appKey, error: action.payload }, source: this, }); }); // monitor when application settings is loading this.#state.addEffect(actions.fetchSettings.type, () => { // dispatch event to notify listeners that the application settings is being loaded event.dispatchEvent('onAppSettingsLoad', { detail: { appKey }, source: this, }); }); // monitor when application settings is loaded this.#state.addEffect(actions.fetchSettings.success.type, (action) => { // dispatch event to notify listeners that the application settings has been loaded event.dispatchEvent('onAppSettingsLoaded', { detail: { appKey, settings: action.payload }, source: this, }); }); // monitor when application settings fails to load this.#state.addEffect(actions.fetchSettings.failure.type, (action) => { // dispatch event to notify listeners that the application settings failed to load event.dispatchEvent('onAppSettingsFailure', { detail: { appKey, error: action.payload }, source: this, }); }); // monitor when application settings is updated this.#state.addEffect(actions.updateSettings.type, (action) => { // dispatch event to notify listeners that the application settings has been loaded event.dispatchEvent('onAppSettingsUpdate', { detail: { appKey, settings: action.payload.settings }, source: this, }); }); // monitor when application settings is updated this.#state.addEffect(actions.updateSettings.success.type, (action) => { // dispatch event to notify listeners that the application settings has been loaded event.dispatchEvent('onAppSettingsUpdated', { detail: { appKey, settings: action.payload.settings }, source: this, }); }); // monitor when application settings fails to updated this.#state.addEffect(actions.updateSettings.failure.type, (action) => { // dispatch event to notify listeners that the application settings has been loaded event.dispatchEvent('onAppSettingsUpdateFailure', { detail: { appKey, settings: action.payload }, source: this, }); }); // monitor when application script is loading this.#state.addEffect(actions.importApp.type, () => { // dispatch event to notify listeners that the application script is being loaded event.dispatchEvent('onAppScriptLoad', { detail: { appKey }, source: this, }); }); // monitor when application script is loaded this.#state.addEffect(actions.importApp.success.type, (action) => { // dispatch event to notify listeners that the application script has been loaded event.dispatchEvent('onAppScriptLoaded', { detail: { appKey, script: action.payload }, source: this, }); }); // monitor when application script fails to load this.#state.addEffect(actions.importApp.failure.type, (action) => { // dispatch event to notify listeners that the application script failed to load event.dispatchEvent('onAppScriptFailure', { detail: { appKey, error: action.payload }, source: this, }); }); // monitor when application is initializing this.#state.addEffect(actions.initialize.type, () => { // dispatch event to notify listeners that the application is initializing event.dispatchEvent('onAppInitialize', { detail: { appKey }, source: this, }); }); // monitor when application has been initialized this.#state.addEffect(actions.initialize.success.type, () => { // dispatch event to notify listeners that the application has been initialized event.dispatchEvent('onAppInitialized', { detail: { appKey }, source: this, }); }); // monitor when application fails to initialize this.#state.addEffect(actions.initialize.failure.type, ({ payload }) => { // dispatch event to notify listeners that the application failed to initialize event.dispatchEvent('onAppInitializeFailure', { detail: { appKey, error: payload }, source: this, }); }); } public initialize(): Observable { return new Observable((subscriber) => { // dispatch initialize action to indicate that the application is initializing this.#state.next(actions.initialize()); subscriber.add( // request latest manifest, application script, and configuration combineLatest([this.getManifest(), this.getAppModule(), this.getConfig()]).subscribe({ next: ([manifest, script, config]) => // emit the manifest, script, and config to the subscriber subscriber.next({ manifest, script, config, }), error: (err) => { // emit error and complete the stream subscriber.error(err); this.#state.next(actions.initialize.failure(err)); }, complete: () => { // dispatch initialize success action to indicate that the application has been initialized this.#state.next(actions.initialize.success()); subscriber.complete(); }, }), ); }); } public loadConfig() { // TODO - shit fix (this.manifest ? of(this.manifest) : this.getManifest()).subscribe({ next: (manifest) => { this.#state.next(actions.fetchConfig(manifest)); }, }); } public loadManifest(update?: boolean) { this.#state.next(actions.fetchManifest(this.appKey, this.tag, update)); } public updateManifest(manifest: AppManifest, replace?: false) { this.#state.next(actions.setManifest(manifest, !replace)); } public async loadAppModule(allow_cache = true) { const manifest = await this.getManifestAsync(allow_cache); if (manifest.build?.entryPoint) { this.#state.next(actions.importApp(manifest.build.entryPoint)); } else { console.warn( `The ${manifest.appKey} is missing entryPoint, please upload a build for the app before continuing`, ); } } public getConfig(force_refresh = false): Observable { return new Observable((subscriber) => { if (this.#state.value.config) { // emit current config to the subscriber subscriber.next(this.#state.value.config); if (!force_refresh) { // since we have the config and no force refresh, complete the stream return subscriber.complete(); } } // when stream closes, dispose of subscription to change of state config subscriber.add( // monitor changes to state changes of config and emit to subscriber this.#state.addEffect('set_config', ({ payload }) => { subscriber.next(payload); }), ); // when stream closes, dispose of subscription to fetch config subscriber.add( // monitor success of fetching config and emit to subscriber this.#state.addEffect('fetch_config::success', ({ payload }) => { // application config loaded, emit to subscriber and complete the stream subscriber.next(payload); subscriber.complete(); }), ); // when stream closes, dispose of subscription to fetch config subscriber.add( // monitor failure of fetching config and emit error to subscriber this.#state.addEffect('fetch_config::failure', ({ payload }) => { // application config failed to load, emit error and complete the stream subscriber.error( Error('failed to load application config', { cause: payload, }), ); }), ); this.loadConfig(); }); } public getConfigAsync(allow_cache = true): Promise { // when allow_cache is true, use first emitted value, otherwise use last emitted value const operator = allow_cache ? firstValueFrom : lastValueFrom; return operator(this.getConfig(!allow_cache)); } public getSettings(force_refresh = false): Observable { return new Observable((subscriber) => { if (this.#state.value.settings) { // emit current settings to the subscriber subscriber.next(this.#state.value.settings as T); if (!force_refresh) { // since we have the settings and no force refresh, complete the stream return subscriber.complete(); } } // when stream closes, dispose of subscription to change of state settings subscriber.add( // monitor changes to state changes of settings and emit to subscriber this.#state.addEffect('set_settings', ({ payload }) => { subscriber.next(payload as T); }), ); // when stream closes, dispose of subscription to fetch settings subscriber.add( // monitor success of fetching settings and emit to subscriber this.#state.addEffect('fetch_settings::success', ({ payload }) => { // application settings loaded, emit to subscriber and complete the stream subscriber.next(payload as T); subscriber.complete(); }), ); // when stream closes, dispose of subscription to fetch settings subscriber.add( // monitor failure of fetching settings and emit error to subscriber this.#state.addEffect('fetch_settings::failure', ({ payload }) => { // application settings failed to load, emit error and complete the stream subscriber.error( Error('failed to load application settings', { cause: payload, }), ); }), ); this.#state.next(actions.fetchSettings(this.appKey)); }); } public getSettingsAsync(allow_cache = true): Promise { // when allow_cache is true, use first emitted value, otherwise use last emitted value const operator = allow_cache ? firstValueFrom : lastValueFrom; return operator(this.getSettings(!allow_cache)); } public updateSettings(settings: T): Observable { return new Observable((subscriber) => { subscriber.add( // monitor failure of updating settings and emit error to subscriber this.#state.addEffect('update_settings::failure', ({ payload }) => { // request to reset settings to source state this.#state.next(actions.fetchSettings(this.appKey)); // application settings failed to save, emit error and complete the stream subscriber.error( Error('failed to update application settings', { cause: payload, }), ); }), ); subscriber.add( // monitor success of updating settings and emit to subscriber this.#state.addEffect('update_settings::success', ({ payload }) => { subscriber.next(payload as T); subscriber.complete(); }), ); // optimistic update of settings this.#state.next(actions.setSettings(settings)); // request to update settings this.#state.next(actions.updateSettings(this.appKey, settings)); }); } public updateSettingsAsync(settings: T): Promise { return lastValueFrom(this.updateSettings(settings)); } public updateSetting( property: P, value: T[P], ): Observable { const currentSettings$ = this.#state.value.settings === undefined ? // if settings are not loaded, fetch settings this.getSettings().pipe(last()) : // if settings are loaded, use current settings of(this.#state.value.settings); return currentSettings$.pipe( // merge current settings with new value map((settings) => ({ ...settings, [property]: value })), // update settings switchMap((settings) => this.updateSettings(settings as T)), // return the updated property map((settings) => settings[property] as T[P]), ); } public updateSettingAsync( property: P, value: T[P], ): Promise { return lastValueFrom(this.updateSetting(property, value)); } public getManifest(force_refresh = false): Observable { return new Observable((subscriber) => { if (this.#state.value.manifest) { // emit current manifest to the subscriber subscriber.next(this.#state.value.manifest); if (!force_refresh) { // since we have the manifest and no force refresh, complete the stream return subscriber.complete(); } } // when stream closes, dispose of subscription to change of state manifest subscriber.add( // monitor changes to state changes of manifest and emit to subscriber this.#state.addEffect('set_manifest', ({ payload }) => { subscriber.next(payload); }), ); // when stream closes, dispose of subscription to fetch manifest subscriber.add( // monitor success of fetching manifest and emit to subscriber this.#state.addEffect('fetch_manifest::success', ({ payload }) => { subscriber.next(payload); // application manifest loaded, complete the stream subscriber.complete(); }), ); subscriber.add( // monitor failure of fetching manifest and emit error to subscriber this.#state.addEffect('fetch_manifest::failure', ({ payload }) => { // application manifest failed to load, emit error and complete the stream subscriber.error( Error('failed to load application manifest', { cause: payload, }), ); }), ); // fetch the application manifest this.loadManifest(); }); } public getManifestAsync(allow_cache = true): Promise { // when allow_cache is true, use first emitted value, otherwise use last emitted value const operator = allow_cache ? firstValueFrom : lastValueFrom; return operator(this.getManifest(!allow_cache)); } public getAppModule(force_refresh = false): Observable { return new Observable((subscriber) => { if (this.#state.value.modules) { // emit current value to the subscriber subscriber.next(this.#state.value.modules); if (!force_refresh) { // complete if no force refresh return subscriber.complete(); } } // when stream closes, dispose of subscription to change of state modules subscriber.add( // monitor changes to state changes of modules and emit to subscriber this.#state.addEffect('set_module', ({ payload }) => { subscriber.next(payload); }), ); // when stream closes, dispose of subscription script load success subscriber.add( // monitor success of loading application script and emit to subscriber this.#state.addEffect('import_app::success', ({ payload }) => { subscriber.next(payload); // application module loaded, complete the stream subscriber.complete(); }), ); // when stream closes, dispose of subscription to script load failure subscriber.add( // monitor failure of loading application script and emit error to subscriber this.#state.addEffect('import_app::failure', ({ payload }) => { // application module failed to load, emit error and complete the stream subscriber.error( Error('failed to load application modules from script', { cause: payload, }), ); }), ); // when stream closes, dispose of subscription to fetch manifest subscriber.add( // fetch application latest manifest and request loading of the application script this.getManifest().subscribe((manifest) => { if (manifest.build?.entryPoint) { // TODO - this should come from backend const assetPath = manifest.build.assetPath ?? [manifest.appKey, manifest.build.version].join('@'); // dispatch import_app action to load the application script this.#state.next(actions.importApp([assetPath, manifest.build.entryPoint].join('/'))); } else { console.error( `The ${manifest.appKey} app is missing a entry in the manifest, upload a build for your app before continuing`, ); } }), ); }); } public getAppModuleAsync(allow_cache = true): Promise { // when allow_cache is true, use first emitted value, otherwise use last emitted value const operator = allow_cache ? firstValueFrom : lastValueFrom; return operator(this.getAppModule(!allow_cache)); } public dispose: VoidFunction; } export default App;