UNPKG

@solace-community/angular-solace-message-client

Version:

Message client to communicate with a Solace messaging broker for sending and receiving messages using the native SMF protocol (Solace Message Format) over web socket. The library is designed to be used in Angular applications.

1,045 lines (1,036 loc) 56.6 kB
import * as i0 from '@angular/core'; import { inject, Injectable, makeEnvironmentProviders, InjectionToken, DestroyRef, Injector, NgZone, assertInInjectionContext, runInInjectionContext } from '@angular/core'; import { LogLevel, SolclientFactoryProperties, SolclientFactoryProfiles, SolclientFactory, AuthenticationScheme, SessionProperties, SessionEventCode, QueueDescriptor, QueueType, MessageConsumerEventName, QueueBrowserEventName, Message, SDTField, SDTMapContainer, SDTFieldType, MessageDeliveryModeType, MessageType } from 'solclientjs'; import { Subject, noop, shareReplay, firstValueFrom, skip, Observable, merge, EMPTY, throwError, of, share, ReplaySubject, EmptyError, BehaviorSubject, NEVER } from 'rxjs'; import { mergeMap, catchError, takeUntil, filter, finalize, take, map, distinctUntilChanged, tap } from 'rxjs/operators'; import { UUID } from '@scion/toolkit/uuid'; import { subscribeIn, observeIn } from '@scion/toolkit/operators'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { Observables } from '@scion/toolkit/util'; /** * Logger used by the Angular Solace Message Client. * * The default log level can be changed as follows: * - Change the log level programmatically by providing it under the DI token {@link LogLevel}: * `{provide: LogLevel, useValue: LogLevel.DEBUG}` * - Change the log level at runtime via session storage by adding the following entry and then reloading the application: * key: `angular-solace-message-client#loglevel` * value: `debug` // supported values are: trace | debug | info | warn | error | fatal */ class Logger { logLevel = this.readLogLevelFromSessionStorage(inject(LogLevel)); debug(...data) { if (this.logLevel >= LogLevel.DEBUG) { console.debug(...this.addLogPrefix(data)); } } info(...data) { if (this.logLevel >= LogLevel.INFO) { console.info(...this.addLogPrefix(data)); } } warn(...data) { if (this.logLevel >= LogLevel.WARN) { console.warn(...this.addLogPrefix(data)); } } error(...data) { if (this.logLevel >= LogLevel.ERROR) { console.error(...this.addLogPrefix(data)); } } addLogPrefix(args) { return [`[SolaceMessageClient] ${args[0]}`, ...args.slice(1)]; } readLogLevelFromSessionStorage(defaultLogLevel) { const level = sessionStorage.getItem('angular-solace-message-client#loglevel'); switch (level) { case 'trace': return LogLevel.TRACE; case 'debug': return LogLevel.DEBUG; case 'info': return LogLevel.INFO; case 'warn': return LogLevel.WARN; case 'error': return LogLevel.ERROR; case 'fatal': return LogLevel.FATAL; default: return defaultLogLevel; } } static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.0.2", ngImport: i0, type: Logger, deps: [], target: i0.ɵɵFactoryTarget.Injectable }); static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "20.0.2", ngImport: i0, type: Logger }); } i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.0.2", ngImport: i0, type: Logger, decorators: [{ type: Injectable }] }); /** * Registers a set of DI providers to enable logging in the Angular Solace Message Client. */ function provideLogger(logLevel) { return makeEnvironmentProviders([ Logger, // Provide inherited 'LogLevel' or provide default LogLevel otherwise. { provide: LogLevel, useFactory: () => inject(LogLevel, { skipSelf: true, optional: true }) ?? logLevel }, ]); } /** * Creates a {@link Session} from a given config to connect to the Solace message broker. * * You may override the default session provider in order to customize session construction, e.g., in tests, as following: * `{provide: SolaceSessionProvider, useClass: YourSessionProvider}` * * The default provider does the following: `solace.SolclientFactory.createSession(sessionProperties)`. */ class SolaceSessionProvider { static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.0.2", ngImport: i0, type: SolaceSessionProvider, deps: [], target: i0.ɵɵFactoryTarget.Injectable }); static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "20.0.2", ngImport: i0, type: SolaceSessionProvider }); } i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.0.2", ngImport: i0, type: SolaceSessionProvider, decorators: [{ type: Injectable }] }); /** * @docs-private */ class ɵSolaceSessionProvider { constructor() { const factoryProperties = new SolclientFactoryProperties(); factoryProperties.profile = SolclientFactoryProfiles.version10_5; factoryProperties.logLevel = inject(Logger).logLevel; SolclientFactory.init(factoryProperties); } provide(sessionProperties) { return SolclientFactory.createSession(sessionProperties); } static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.0.2", ngImport: i0, type: ɵSolaceSessionProvider, deps: [], target: i0.ɵɵFactoryTarget.Injectable }); static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "20.0.2", ngImport: i0, type: ɵSolaceSessionProvider }); } i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.0.2", ngImport: i0, type: ɵSolaceSessionProvider, decorators: [{ type: Injectable }], ctorParameters: () => [] }); /** * DI token to inject the config of Angular Solace Message Client. */ const SOLACE_MESSAGE_CLIENT_CONFIG = new InjectionToken('SOLACE_MESSAGE_CLIENT_CONFIG'); /** * Matches exact topics as used when publishing messages against subscription topics. * * This class implements the rules for 'Wildcard Characters in SMF Topic Subscriptions', * as outlined here: https://docs.solace.com/PubSub-Basics/Wildcard-Charaters-Topic-Subs.htm. */ class TopicMatcher { _subscriptionTopic; constructor(subscriptionTopic) { this._subscriptionTopic = parseSubscriptionTopic(subscriptionTopic); } matches(topic) { if (!topic) { return false; } const testeeSegments = topic.split('/'); const subscriptionSegments = this._subscriptionTopic; for (let i = 0; i < subscriptionSegments.length; i++) { const subscriptionTopicSegment = subscriptionSegments[i]; const testee = testeeSegments[i]; const isLastSubscriptionTopicSegment = (i === subscriptionSegments.length - 1); if (testee === undefined) { return false; } if (subscriptionTopicSegment === '>' && isLastSubscriptionTopicSegment) { return true; } if (subscriptionTopicSegment === '*') { continue; } if (subscriptionTopicSegment.endsWith('*') && testee.startsWith(subscriptionTopicSegment.slice(0, -1))) { continue; } if (subscriptionTopicSegment !== testee) { return false; } } return testeeSegments.length === subscriptionSegments.length; } } /** * Parses the subscription topic, removing #noexport and #share segments, if any. */ function parseSubscriptionTopic(topic) { const segments = topic.split('/'); // Remove #noexport segment, if any. See https://docs.solace.com/Messaging/No-Export.htm // Example: #noexport/#share/ShareName/topicFilter, #noexport/topicFilter if (segments[0] === '#noexport') { segments.shift(); } // Remove #share segments, if any. See https://docs.solace.com/Messaging/Direct-Msg/Direct-Messages.htm // Examples: #share/<ShareName>/<topicFilter> if (segments[0] === '#share') { segments.shift(); // removes #share segment segments.shift(); // removes share name segment } return segments; } /** * Maintains the subscription count per topic. */ class TopicSubscriptionCounter { _subscriptionCounts = new Map(); incrementAndGet(topic) { const topicName = coerceTopicName(topic); const count = (this._subscriptionCounts.get(topicName) ?? 0) + 1; this._subscriptionCounts.set(topicName, count); return count; } decrementAndGet(topic) { const topicName = coerceTopicName(topic); const count = Math.max(0, (this._subscriptionCounts.get(topicName) ?? 0) - 1); if (count === 0) { this._subscriptionCounts.delete(topicName); } else { this._subscriptionCounts.set(topicName, count); } return count; } destroy() { this._subscriptionCounts.clear(); } } function coerceTopicName(topic) { if (typeof topic === 'string') { return topic; } return topic.getName(); } /** * Allows the mutual exclusive execution of tasks. * * Scheduled tasks are executed sequentially, one at a time, until all tasks have executed. * A task completed execution when its Promise resolved or rejected. */ class SerialExecutor { _destroy$ = new Subject(); _tasks$ = new Subject(); constructor(logger) { this._tasks$ .pipe(mergeMap(task => task(), 1), // serialize execution catchError((error, caught) => { logger.error('Unexpected: ', error); return caught; }), takeUntil(this._destroy$)) .subscribe(); } /** * Schedules the given task for serial execution. * The task will be executed after all previously scheduled tasks have finished execution. * * @return Promise that resolves to the task's return value when it finished execution, or * that rejects when the taks's Promise rejects. */ scheduleSerial(task) { return new Promise((resolve, reject) => { this._tasks$.next(() => task().then(resolve).catch(reject)); }); } destroy() { this._destroy$.next(); } } /* * Copyright (c) 2018-2023 Swiss Federal Railways * * This program and the accompanying materials are made * available under the terms of the Eclipse Public License 2.0 * which is available at https://www.eclipse.org/legal/epl-2.0/ * * SPDX-License-Identifier: EPL-2.0 */ /** * Implementation of {@link DestroyRef} that can be manually disposed. * * Binds to the {@link DestroyRef} of the current injection context if constructed inside an injection context. */ class ɵDestroyRef { _callbacks = new Set(); _destroyed = false; constructor() { inject(DestroyRef, { optional: true })?.onDestroy(() => this.destroy()); } onDestroy(callback) { if (this._destroyed) { callback(); return noop; } this._callbacks.add(callback); return () => this._callbacks.delete(callback); } destroy() { this._callbacks.forEach(callback => callback()); this._callbacks.clear(); this._destroyed = true; } get destroyed() { return this._destroyed; } } class ɵSolaceMessageClient { connected$; _sessionProvider = inject(SolaceSessionProvider); _injector = inject(Injector); _logger = inject(Logger); _zone = inject(NgZone); _destroyRef = new ɵDestroyRef(); _message$ = new Subject(); _event$ = new Subject(); _session = null; _subscriptionExecutor = null; _subscriptionCounter = null; constructor() { this.disposeWhenSolaceSessionDied(); this.logSolaceSessionEvents(); this.connected$ = this.monitorConnectionState$(); // Connect to the Solace Message Broker. this._session = this.loadConfig() .then(config => { this._logger.debug('Connecting to Solace message broker:', obfuscateSecrets(config)); return this.createSession(config); }) .catch((error) => { this._logger.error('Failed to connect to the Solace message broker.', error); return Promise.reject(error); }); this._destroyRef.onDestroy(() => void this.dispose()); } /** * Creates a Solace session based on the passed configuration. * * @return Promise that resolves to the {@link Session} when connected to the broker, or that rejects if the connection attempt failed. */ createSession(config) { return new Promise((resolve, reject) => void this._zone.runOutsideAngular(async () => { this._subscriptionExecutor = new SerialExecutor(this._logger); this._subscriptionCounter = new TopicSubscriptionCounter(); // If using OAUTH2 authentication scheme, create access token Observable to continuously inject a valid access token into the Solace session. const oAuth2Scheme = config.authenticationScheme === AuthenticationScheme.OAUTH2; const accessToken$ = oAuth2Scheme ? provideOAuthAccessToken$(config, { injector: this._injector }).pipe(shareReplay({ bufferSize: 1, refCount: false })) : null; // If using OAUTH2 authentication scheme, set the initial access token via session properties. if (accessToken$) { config.accessToken = await firstValueFrom(accessToken$.pipe(subscribeIn(fn => this._zone.run(fn)))).catch(mapEmptyError(() => Error('[EmptyAccessTokenError] Access token Observable has completed without emitted an access token.'))); } // Create the Solace session. const session = this._sessionProvider.provide(new SessionProperties(config)); // Provide the session with a valid "OAuth 2.0 Access Token". // The Observable is expected to never complete and continuously emit a renewed token short before expiration of the previously emitted token. accessToken$?.pipe(skip(1), // initial access token is set via session properties takeUntilDestroyed(this._destroyRef)).subscribe({ next: accessToken => { this._logger.debug('Injecting "OAuth 2.0 Access Token" into Solace session.', accessToken); session.updateAuthenticationOnReconnect({ accessToken }); }, complete: () => { if (this._session) { this._logger.warn('[AccessTokenProviderCompletedWarning] Observable providing access token(s) to the Solace session has completed. The Observable should NEVER complete and continuously emit the renewed token short before expiration of the previously emitted token. Otherwise, the connection to the broker would not be re-established in the event of a network interruption.'); } }, error: error => { this._logger.error(error); }, }); // When the Session is ready to send/receive messages and perform control operations. session.on(SessionEventCode.UP_NOTICE, (event) => { this._event$.next(event); this._logger.debug('Connected to Solace message broker.', obfuscateSecrets(config)); resolve(session); }); // When the session has gone down, and an automatic reconnection attempt is in progress. session.on(SessionEventCode.RECONNECTED_NOTICE, (event) => this._event$.next(event)); // Emits when the session was established and then went down. session.on(SessionEventCode.DOWN_ERROR, (event) => this._event$.next(event)); // Emits when the session attempted to connect but was unsuccessful. session.on(SessionEventCode.CONNECT_FAILED_ERROR, (event) => { this._event$.next(event); reject(event); // eslint-disable-line @typescript-eslint/prefer-promise-reject-errors }); // When the session connect operation failed, or the session that was once up, is now disconnected. session.on(SessionEventCode.DISCONNECTED, (event) => this._event$.next(event)); // When the session has gone down, and an automatic reconnection attempt is in progress. session.on(SessionEventCode.RECONNECTING_NOTICE, (event) => this._event$.next(event)); // When a direct message was received on the session. session.on(SessionEventCode.MESSAGE, (message) => this._message$.next(message)); // When a subscribe or unsubscribe operation succeeded. session.on(SessionEventCode.SUBSCRIPTION_OK, (event) => this._event$.next(event)); // When a subscribe or unsubscribe operation was rejected by the broker. session.on(SessionEventCode.SUBSCRIPTION_ERROR, (event) => this._event$.next(event)); // When a message published with a guaranteed message delivery strategy, that is {@link MessageDeliveryModeType.PERSISTENT} or {@link MessageDeliveryModeType.NON_PERSISTENT}, was acknowledged by the router. session.on(SessionEventCode.ACKNOWLEDGED_MESSAGE, (event) => this._event$.next(event)); // When a message published with a guaranteed message delivery strategy, that is {@link MessageDeliveryModeType.PERSISTENT} or {@link MessageDeliveryModeType.NON_PERSISTENT}, was rejected by the router. session.on(SessionEventCode.REJECTED_MESSAGE_ERROR, (event) => this._event$.next(event)); session.connect(); }).catch((error) => reject(error))); } /** * Disposes the Solace session, disconnecting this client from the Solace message broker. Has no effect if already disposed. * * Messages cannot be received or published after disposing the session. * * @param options - Controls disposal of the Solace session. * @param options.force - Controls if to first disconnect from the broker before destroying the Solace session, * enabling graceful shutdown of the connnection for the broker to clean up resources. * Defaults to `false`. * @return A Promise that resolves when disposed the session. */ async dispose(options) { const session = await this._session?.catch(noop); // do not error on dispose if (!session) { return; } this._session = null; // Disconnect from broker. const force = options?.force ?? false; if (!force) { try { const whenDisconnected = this.whenEvent(SessionEventCode.DISCONNECTED); session.disconnect(); await whenDisconnected; } catch (error) { this._logger.warn('Failed to gracefully disconnect from the Solace Message Broker. Disposing the Solace session.', error); } } // Dispose session. try { session.dispose(); } finally { this._subscriptionExecutor?.destroy(); this._subscriptionExecutor = null; this._subscriptionCounter?.destroy(); this._subscriptionCounter = null; this._destroyRef.destroy(); } } observe$(topic, options) { return new Observable((observer) => { const unsubscribe$ = new Subject(); const topicDestination = createSubscriptionTopicDestination(topic); const topicMatcher = new TopicMatcher(topicDestination.getName()); // Wait until initialized the session so that 'subscriptionExecutor' and 'subscriptionCounter' are initialized. this.session .then(() => { const subscribeError$ = new Subject(); let subscriptionErrored = false; // Filter messages sent to the given topic. merge(this._message$, subscribeError$) .pipe(assertNotInAngularZone(), filter(message => topicMatcher.matches(message.getDestination()?.getName())), mapToMessageEnvelope(topic), synchronizeNgZone(this._zone), takeUntilDestroyed(this._destroyRef), takeUntil(unsubscribe$), finalize(() => { // Unsubscribe from the topic on the Solace session, but only if being the last subscription on that topic and if successfully subscribed to the Solace broker. if (this._subscriptionCounter?.decrementAndGet(topicDestination) === 0 && !subscriptionErrored) { void this.unsubscribeFromTopic(topicDestination); } })) .subscribe(observer); // Subscribe to the topic on the Solace session, but only if being the first subscription on that topic. if (this._subscriptionCounter.incrementAndGet(topicDestination) === 1) { void this.subscribeToTopic(topicDestination, options).then(success => { if (success) { options?.onSubscribed?.(); } else { subscriptionErrored = true; subscribeError$.error(`Failed to subscribe to topic ${topicDestination.getName()}.`); } }); } else { options?.onSubscribed?.(); } }) .catch((error) => { observer.error(error); }); return () => unsubscribe$.next(); }); } /** * Subscribes to the given topic on the Solace session. */ subscribeToTopic(topic, observeOptions) { // Calls to `solace.Session.subscribe` and `solace.Session.unsubscribe` must be executed one after the other until the Solace Broker confirms // the operation. Otherwise a previous unsubscribe may cancel a later subscribe on the same topic. return this._subscriptionExecutor.scheduleSerial(async () => { try { // IMPORTANT: Do not subscribe when the session is down, that is, after received a DOWN_ERROR. Otherwise, solclientjs would crash. // When the session is down, the session Promise resolves to `null`. const session = await this._session; if (!session) { return false; } const subscribeCorrelationKey = UUID.randomUUID(); const whenSubscribed = this.whenEvent(SessionEventCode.SUBSCRIPTION_OK, { rejectOnEvent: SessionEventCode.SUBSCRIPTION_ERROR, correlationKey: subscribeCorrelationKey }) .then(() => true) .catch((event) => { this._logger.warn(`Solace event broker rejected subscription on topic ${topic.getName()}.`, event); return false; }); session.subscribe(topic, true, subscribeCorrelationKey, observeOptions?.subscribeTimeout); return await whenSubscribed; } catch { return false; } }); } /** * Unsubscribes from the given topic on the Solace session. */ unsubscribeFromTopic(topic) { // Calls to `solace.Session.subscribe` and `solace.Session.unsubscribe` must be executed one after the other until the Solace Broker confirms // the operation. Otherwise a previous unsubscribe may cancel a later subscribe on the same topic. return this._subscriptionExecutor.scheduleSerial(async () => { try { // IMPORTANT: Do not unsubscribe when the session is down, that is, after received a DOWN_ERROR. Otherwise, solclientjs would crash. // When the session is down, the session Promise resolves to `null`. const session = await this._session; if (!session) { return false; } const unsubscribeCorrelationKey = UUID.randomUUID(); const whenUnsubscribed = this.whenEvent(SessionEventCode.SUBSCRIPTION_OK, { rejectOnEvent: SessionEventCode.SUBSCRIPTION_ERROR, correlationKey: unsubscribeCorrelationKey }) .then(() => true) .catch((error) => { this._logger.warn(`Solace event broker rejected unsubscription on topic ${topic.getName()}.`, error); return false; }); session.unsubscribe(topic, true, unsubscribeCorrelationKey, undefined); return await whenUnsubscribed; } catch { return false; } }); } consume$(topicOrDescriptor) { // If passed a `string` literal, subscribe to a non-durable topic endpoint. if (typeof topicOrDescriptor === 'string') { return this.createMessageConsumer$({ topicEndpointSubscription: SolclientFactory.createTopicDestination(topicOrDescriptor), queueDescriptor: new QueueDescriptor({ type: QueueType.TOPIC_ENDPOINT, durable: false }), }); } return this.createMessageConsumer$(topicOrDescriptor); } createMessageConsumer$(consumerProperties) { const topicEndpointSubscription = consumerProperties.topicEndpointSubscription?.getName(); if (topicEndpointSubscription) { consumerProperties.topicEndpointSubscription = createSubscriptionTopicDestination(consumerProperties.topicEndpointSubscription.getName()); } return new Observable((observer) => { let messageConsumer; this.session .then(session => { messageConsumer = session.createMessageConsumer(consumerProperties); // Define message consumer event listeners messageConsumer.on(MessageConsumerEventName.UP, () => { this._logger.debug(`MessageConsumerEvent: UP`); consumerProperties.onSubscribed?.(messageConsumer); }); messageConsumer.on(MessageConsumerEventName.CONNECT_FAILED_ERROR, (error) => { this._logger.debug(`MessageConsumerEvent: CONNECT_FAILED_ERROR`, error); observer.error(error); }); messageConsumer.on(MessageConsumerEventName.DOWN_ERROR, (error) => { this._logger.debug(`MessageConsumerEvent: DOWN_ERROR`, error); observer.error(error); }); messageConsumer.on(MessageConsumerEventName.DOWN, () => { this._logger.debug(`MessageConsumerEvent: DOWN`); messageConsumer?.dispose(); observer.complete(); }); // Define message event listener messageConsumer.on(MessageConsumerEventName.MESSAGE, (message) => { this._logger.debug(`MessageConsumerEvent: MESSAGE`, message); NgZone.assertNotInAngularZone(); observer.next(message); }); // Connect the message consumer messageConsumer.connect(); }) .catch((error) => { observer.error(error); messageConsumer?.dispose(); }); return () => { // Initiate an orderly disconnection of the consumer. In turn, we will receive a `MessageConsumerEventName#DOWN` event and dispose the consumer. if (messageConsumer && !messageConsumer.disposed) { messageConsumer.disconnect(); } }; }) .pipe(mapToMessageEnvelope(topicEndpointSubscription), synchronizeNgZone(this._zone)); } browse$(queueOrDescriptor) { // If passed a `string` literal, connect to the given queue using default 'browsing' options. if (typeof queueOrDescriptor === 'string') { return this.createQueueBrowser$({ queueDescriptor: new QueueDescriptor({ type: QueueType.QUEUE, name: queueOrDescriptor }), }); } return this.createQueueBrowser$(queueOrDescriptor); } createQueueBrowser$(queueBrowserProperties) { return new Observable((observer) => { let queueBrowser; let disposed = false; this.session .then(session => { queueBrowser = session.createQueueBrowser(queueBrowserProperties); // Define browser event listeners queueBrowser.on(QueueBrowserEventName.UP, () => { this._logger.debug(`QueueBrowserEvent: UP`); queueBrowser.start(); }); queueBrowser.on(QueueBrowserEventName.CONNECT_FAILED_ERROR, (error) => { this._logger.debug(`QueueBrowserEvent: CONNECT_FAILED_ERROR`, error); observer.error(error); }); queueBrowser.on(QueueBrowserEventName.DOWN_ERROR, (error) => { this._logger.debug(`QueueBrowserEvent: DOWN_ERROR`, error); observer.error(error); }); queueBrowser.on(QueueBrowserEventName.DOWN, () => { this._logger.debug(`QueueBrowserEvent: DOWN`); observer.complete(); }); queueBrowser.on(QueueBrowserEventName.DISPOSED, () => { this._logger.debug(`QueueBrowserEvent: DOWN`); disposed = true; observer.complete(); }); // Define browser event listener queueBrowser.on(QueueBrowserEventName.MESSAGE, (message) => { this._logger.debug(`QueueBrowserEvent: MESSAGE`, message); NgZone.assertNotInAngularZone(); observer.next(message); }); // Connect the browser queueBrowser.connect(); }) .catch((error) => { observer.error(error); }); return () => { // Initiate an orderly disconnection of the browser. In turn, we will receive a `QueueBrowserEventName#DOWN` event and dispose the consumer. if (queueBrowser && !disposed) { queueBrowser.stop(); queueBrowser.disconnect(); } }; }) .pipe(mapToMessageEnvelope(), synchronizeNgZone(this._zone)); } publish(destination, data, options) { const solaceDestination = typeof destination === 'string' ? SolclientFactory.createTopicDestination(destination) : destination; const send = (session, message) => session.send(message); return this.sendMessage(solaceDestination, data, options, send); } request$(destination, data, options) { const solaceDestination = typeof destination === 'string' ? SolclientFactory.createTopicDestination(destination) : destination; return new Observable(observer => { const unsubscribe$ = new Subject(); const response$ = new Subject(); response$ .pipe(assertNotInAngularZone(), mapToMessageEnvelope(), synchronizeNgZone(this._zone), takeUntil(unsubscribe$)) .subscribe(observer); const onResponse = (session, message) => { response$.next(message); response$.complete(); }; const onError = (session, error) => { response$.error(error); }; const send = (session, request) => { session.sendRequest(request, options?.requestTimeout, onResponse, onError); }; this.sendMessage(solaceDestination, data, options, send).catch((error) => response$.error(error)); return () => unsubscribe$.next(); }); } reply(request, data, options) { // "solclientjs" marks the message as 'reply' and copies 'replyTo' destination and 'correlationId' from the request. const send = (session, message) => session.sendReply(request, message); return this.sendMessage(null, data, options, send); } async sendMessage(destination, data, options, send) { const message = data instanceof Message ? data : SolclientFactory.createMessage(); // Set the destination. May not be set if replying to a request. if (destination) { message.setDestination(destination); } // Set data, either as unstructured byte data, or as structured container if passed a structured data type (SDT). if (data !== undefined && !(data instanceof Message)) { if (data instanceof SDTField) { message.setSdtContainer(data); } else { message.setBinaryAttachment(data); } } // Apply publish options. if (options) { message.setDeliveryMode(options.deliveryMode ?? message.getDeliveryMode()); message.setCorrelationId(options.correlationId ?? message.getCorrelationId()); message.setPriority(options.priority ?? message.getPriority()); message.setTimeToLive(options.timeToLive ?? message.getTimeToLive()); message.setDMQEligible(options.dmqEligible ?? message.isDMQEligible()); message.setCorrelationKey(options.correlationKey ?? message.getCorrelationKey()); options.replyTo && message.setReplyTo(options.replyTo); message.setAsReplyMessage(options.markAsReply ?? message.isReplyMessage()); } // Add headers. if (options?.headers?.size) { const userPropertyMap = (message.getUserPropertyMap() ?? new SDTMapContainer()); options.headers.forEach((value, key) => { if (value === undefined || value === null) { // eslint-disable-line @typescript-eslint/no-unnecessary-condition return; } if (value instanceof SDTField) { const sdtField = value; userPropertyMap.addField(key, sdtField.getType(), sdtField.getValue()); } else if (typeof value === 'string') { userPropertyMap.addField(key, SDTFieldType.STRING, value); } else if (typeof value === 'boolean') { userPropertyMap.addField(key, SDTFieldType.BOOL, value); } else if (typeof value === 'number') { userPropertyMap.addField(key, SDTFieldType.INT32, value); } else { userPropertyMap.addField(key, SDTFieldType.UNKNOWN, value); } }); message.setUserPropertyMap(userPropertyMap); } // Allow intercepting the message before sending it to the broker. options?.intercept?.(message); const session = await this.session; // Publish the message. if (message.getDeliveryMode() === MessageDeliveryModeType.DIRECT) { send(session, message); } else { const correlationKey = message.getCorrelationKey() ?? UUID.randomUUID(); const whenAcknowledged = this.whenEvent(SessionEventCode.ACKNOWLEDGED_MESSAGE, { rejectOnEvent: SessionEventCode.REJECTED_MESSAGE_ERROR, correlationKey: correlationKey }); message.setCorrelationKey(correlationKey); send(session, message); // Resolve the Promise when acknowledged by the broker, or reject it otherwise. await whenAcknowledged; } } get session() { return this._session ?? Promise.reject(Error('Not connected to the Solace message broker. Did you forget to provide the `SolaceMessageClient` via `provideSolaceMessageClient()` function or to invoke \'connect\'`?')); } /** * Returns a Promise that resolves to the event when the expected event occurs, or that rejects when the specified `rejectOnEvent` event, if specified, occurs. * If a "correlation key" is specified, only events with that correlation key will be evaluated. * * Note that: * - the Promise resolves or rejects outside the Angular zone * - the Promise is bound the current session, i.e., will ony be settled as long as the current session is not disposed. */ whenEvent(resolveOnEvent, options) { return new Promise((resolve, reject) => { this._event$ .pipe(assertNotInAngularZone(), filter(event => !options?.correlationKey || event.correlationKey === options.correlationKey), mergeMap(event => { switch (event.sessionEventCode) { case resolveOnEvent: return of(event); case options?.rejectOnEvent: { return throwError(() => event); } default: return EMPTY; } }), take(1)) .subscribe({ next: (event) => resolve(event), error: error => reject(error), complete: noop, // do not resolve the Promise when the session is disposed }); }); } disposeWhenSolaceSessionDied() { this._event$ .pipe(filter(event => SESSION_DIED_EVENTS.has(event.sessionEventCode)), assertNotInAngularZone(), takeUntilDestroyed(this._destroyRef)) .subscribe(() => { void this.dispose({ force: true }); // no graceful shutdown }); } logSolaceSessionEvents() { const sessionEventCodeMapping = Object.entries(SessionEventCode).reduce((acc, [key, value]) => acc.set(value, key), new Map()); this._event$ .pipe(assertNotInAngularZone(), takeUntilDestroyed(this._destroyRef)) .subscribe((event) => { this._logger.debug(`SessionEvent: ${sessionEventCodeMapping.get(event.sessionEventCode)}`, event); }); } monitorConnectionState$() { const connected$ = this._event$ .pipe(assertNotInAngularZone(), map(event => event.sessionEventCode), filter(event => CONNECTION_ESTABLISHED_EVENTS.has(event) || CONNECTION_LOST_EVENTS.has(event)), map(event => CONNECTION_ESTABLISHED_EVENTS.has(event)), distinctUntilChanged(), observeIn(continueFn => this._zone.run(continueFn)), share({ connector: () => new ReplaySubject(1), resetOnRefCountZero: false, resetOnError: false, resetOnComplete: false, })); // Connect to the source, then unsubscribe immediately (resetOnRefCountZero: false) connected$.subscribe().unsubscribe(); return connected$; } async loadConfig() { assertInInjectionContext(this.loadConfig); // config function must be called inside an injection context const configLike = inject(SOLACE_MESSAGE_CLIENT_CONFIG); const config = typeof configLike === 'function' ? await firstValueFrom(Observables.coerce(configLike())) : configLike; // Apply Solace session defaults. return { reapplySubscriptions: true, // remember subscriptions after a network interruption (default value if not set) reconnectRetries: -1, // Try to restore the connection automatically after a network interruption (default value if not set) ...config, }; } static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.0.2", ngImport: i0, type: ɵSolaceMessageClient, deps: [], target: i0.ɵɵFactoryTarget.Injectable }); static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "20.0.2", ngImport: i0, type: ɵSolaceMessageClient }); } i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.0.2", ngImport: i0, type: ɵSolaceMessageClient, decorators: [{ type: Injectable }], ctorParameters: () => [] }); /** * Maps each {@link Message} to a {@link MessageEnvelope}, and resolves substituted named wildcard segments. */ function mapToMessageEnvelope(subscriptionTopic) { return map((message) => { return { message, params: collectNamedTopicSegmentValues(message, subscriptionTopic), headers: collectHeaders(message), }; }); } /** * Collects message headers from given message. */ function collectHeaders(message) { const userPropertyMap = message.getUserPropertyMap(); return userPropertyMap?.getKeys().reduce((acc, key) => { return acc.set(key, userPropertyMap.getField(key).getValue()); }, new Map()) ?? new Map(); } /** * Parses the effective message destination for named path segments, if any. */ function collectNamedTopicSegmentValues(message, subscriptionTopic) { if (!subscriptionTopic?.length) { return new Map(); } const subscriptionSegments = subscriptionTopic.split('/'); const effectiveDestinationSegments = message.getDestination().getName().split('/'); return subscriptionSegments.reduce((acc, subscriptionSegment, i) => { if (isNamedWildcardSegment(subscriptionSegment)) { return acc.set(subscriptionSegment.substring(1), effectiveDestinationSegments[i]); } return acc; }, new Map()); } /** * Tests whether given segment is a named path segment, i.e., a segment that acts as placeholer for any value, equivalent to the Solace single-level wildcard character (`*`). */ function isNamedWildcardSegment(segment) { return segment.startsWith(':') && segment.length > 1; } /** * Creates a Solace subscription topic with named topic segments replaced by single-level wildcard characters (`*`), if any. */ function createSubscriptionTopicDestination(topic) { const subscriptionTopic = topic.split('/') .map(segment => isNamedWildcardSegment(segment) ? '*' : segment) .join('/'); return SolclientFactory.createTopicDestination(subscriptionTopic); } /** * Set of events indicating final disconnection from the broker with no recovery possible. */ const SESSION_DIED_EVENTS = new Set() .add(SessionEventCode.DOWN_ERROR) // is emitted when reaching the limit of connection retries after a connection interruption .add(SessionEventCode.DISCONNECTED); // is emitted when disconnected from the session /** * Set of events indicating a connection to be established. */ const CONNECTION_ESTABLISHED_EVENTS = new Set() .add(SessionEventCode.UP_NOTICE) .add(SessionEventCode.RECONNECTED_NOTICE); /** * Set of events indicating a connection to be lost. */ const CONNECTION_LOST_EVENTS = new Set() .add(SessionEventCode.DOWN_ERROR) .add(SessionEventCode.CONNECT_FAILED_ERROR) .add(SessionEventCode.DISCONNECTED) .add(SessionEventCode.RECONNECTING_NOTICE); /** * Throws if emitting inside the Angular zone. */ function assertNotInAngularZone() { return tap(() => NgZone.assertNotInAngularZone()); } function obfuscateSecrets(sessionProperties) { const obfuscated = { ...sessionProperties }; if (obfuscated.password) { obfuscated.password = '***'; } if (obfuscated.accessToken) { obfuscated.accessToken = '***'; } return obfuscated; } /** * Maps RxJS {@link EmptyError} to the given error. Other errors are re-thrown unchanged. */ function mapEmptyError(errorFactory) { return (error) => { if (error instanceof EmptyError) { return Promise.reject(errorFactory()); } else { return Promise.reject(error); } }; } /** * Emits in the zone in which subscribed to the Observable. */ function synchronizeNgZone(zone) { return (source$) => { return new Observable(observer => { const insideAngular = NgZone.isInAngularZone(); const subscription = source$ .pipe(observeIn(fn => insideAngular ? runInsideAngular(fn) : runOutsideAngular(fn))) .subscribe(observer); return () => subscription.unsubscribe(); }); }; function runInsideAngular(fn) { NgZone.isInAngularZone() ? fn() : zone.run(fn); } function runOutsideAngular(fn) { !NgZone.isInAngularZone() ? fn() : zone.runOutsideAngular(fn); } } /** * Provides the OAuth 2.0 access token configured in {@link SolaceMessageClientConfig#accessToken} as Observable. * The Observable errors if not using OAUTH2 authentication scheme, or if no token/provider is configured. */ function provideOAuthAccessToken$(config, options) { if (config.authenticationScheme !== AuthenticationScheme.OAUTH2) { return throwError(() => `Expected authentication scheme to be ${AuthenticationScheme.OAUTH2}, but was ${config.authenticationScheme}.`); } return runInInjectionContext(options.injector, () => { const zone = inject(NgZone); const configuredAccessToken = config.accessToken; const accessToken$ = zone.run(() => { switch (typeof configuredAccessToken) { case 'string': { return of(configuredAccessToken); } case 'function': { return Observables.coerce(configuredAccessToken()); } default: { return throwError(() => Error('[NullAccessTokenConfigError] No access token or provider function configured in \'SolaceMessageClientConfig.accessToken\'. An access token is required for OAUTH2 authentication. It is recommended to provide the token via `OAuthAccessTokenFn`.')); } } }); return accessToken$.pipe(mergeMap(accessToken => accessToken ? of(accessToken) : throwError(() => Error('[NullAccessTokenError] Invalid "OAuth 2.0 Access Token". Token must not be `null` or `undefined`.')))); }); } /** * Allows clients to communicate with a Solace messaging broker for sending and receiving messages using the native SMF protocol (Solace Message Format). * * This message client establishes a single connection to the broker regardless of the number of subscriptions. * * See https://docs.solace.com/API-Developer-Online-Ref-Documentation/js/index.html for more information about the Solace Web API. */ class SolaceMessageClient { static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.0.2", ngImport: i0, type: SolaceMessageClient, deps: [], target: i0.ɵɵFactoryTarget.Injectable }); static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "20.0.2", ngImport: i0, type: SolaceMessageClient }); } i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.0.2", ngImport: i0, type: SolaceMessageClient, decorators: [{ type: Injectable }] }); /** * SolaceMessageClient which does nothing, i.e., can be used in tests. */ class NullSolaceMessageClient { connected$ = new BehaviorSubject(true); observe$(topic, options) { return NEVER; } consume$(topicOrDescriptor) { return NEVER; } browse$(queueOrDescriptor) { return NEVER; } publish(destination, data, options) { return Promise.resolve(); } request$(destination, data, options) { return NEVER; } reply(request, data, options) { return Promise.resolve(); } get session() { return new Promise(noop); } static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.0.2", ngImport: i0, type: NullSolaceMessageClient, deps: [], target: i0.ɵɵFactoryTarget.Injectable }); static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "20.0.2", ngImport: i0, type: NullSolaceMessageClient }); } i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.0.2", ngImport: i0, type: NullSolaceMessageClient, decorators: [{ type: Injectable }] }); /** * RxJS operator for mapping a structured text message into its textual representation. * * Each message is mapped to a tuple of three elements: * [<text>, Params, Message]. * * Note: Messages must be published as {@link MessageType.TEXT} messages, otherwise an error is thrown. */ function mapToText() { return map((envelope) => { const message = envelope.message; if (message.getType() !== MessageType.TEXT) { throw Error(`[IllegalMessageTypeError] Expected message type to be ${formatMessageType(MessageType.TEXT)}, but was ${formatMessageType(message.getType())}. Be sure to use a compatible map operator.`); } return [message.getSdtContainer().getValue(), envelope.params, message]; }); } /** * RxJS operator for mapping a message into its binary representation. * * Each message is mapped to a tuple of three elements: * [<binary>, Params, Message]. * * Backward compatibility note: Using the version10 factory profile or older, the binary attachment is returned as a 'latin1' String: * Each character has a code in the range * 0-255 representing the value of a single received byte at that position. * * Note: Messages must be published as {@link MessageType.BINARY} messages, otherwise an error is thrown. */ function mapToBinary() { return map((envelope) => { const message = envelope.message; if (message.getType() !== MessageType.BINARY) { throw Error(`[IllegalMessageTypeError] Expected message type to be ${formatMessageTyp