import {RxStomp, RxStompConfig, RxStompState} from '@stomp/rx-stomp'; import {StompHeaders, Versions} from '@stomp/stompjs'; import Axios, {AxiosInstance} from 'axios'; import { encode } from 'base-64'; import {Subscription} from 'rxjs'; import {take} from 'rxjs/operators'; import {v4} from 'uuid'; import {version} from './environment/version'; let TransportFallback: { default: { new(arg: string): unknown } }; import('sockjs-client') .then((sockjs) => { TransportFallback = sockjs; }) .catch((error) => { ErrorMessageTransportFallback.errorMessage = error.message; TransportFallback = {default: ErrorMessageTransportFallback}; }); class ErrorMessageTransportFallback { static errorMessage: string; constructor() { throw new Error( 'Encountered error when attempting to use transport fallback: ' + ErrorMessageTransportFallback.errorMessage ); } } export default class StompX { private readonly host: string; private readonly wsScheme: string; private readonly httpScheme: string; private readonly rxStompConfig: RxStompConfig; private readonly axios: AxiosInstance; private readonly topics: Map<string, Subscription> = new Map(); private readonly pendingActions: Map<string, { types?: string[]; action: (resource: unknown) => void }> = new Map(); private readonly pendingRelayErrors: Map<string, (error: StompXError) => void> = new Map(); private readonly pendingActionErrors: Map<string, (error: StompXError) => void> = new Map(); private readonly eventHandlers: Map<string, Set<StompXEventHandler<unknown>>> = new Map(); private rxStomp: RxStomp = new RxStomp(); public initialized = false; constructor(configuration: StompXConfiguration) { this.host = configuration.host; if (configuration.isSecure) { this.wsScheme = 'wss'; this.httpScheme = 'https'; } else { this.wsScheme = 'ws'; this.httpScheme = 'http'; } this.rxStompConfig = { stompVersions: new Versions(['1.2']), connectionTimeout: 60000, heartbeatIncoming: 10000, heartbeatOutgoing: 60000, debug: (message) => { if (configuration.isDebug) { console.log('StompX Debug:\n' + message); } }, }; if (typeof navigator != 'undefined' && navigator.product == 'ReactNative') { this.rxStompConfig.forceBinaryWSFrames = true; this.rxStompConfig.appendMissingNULLonIncoming = true; } this.axios = Axios.create({ baseURL: this.httpScheme + '://' + this.host, }); } public connect<U>(request: StompXConnectRequest<U>) { const host = this.host; const connectHeaders: StompHeaders = { 'StompX-User': request.username, 'StompX-User-Agent': `ChatKitty-JS/${version}`, }; if (request.authParams) { connectHeaders['StompX-Auth-Params'] = encode(JSON.stringify(request.authParams)); } if (typeof WebSocket === 'function') { this.rxStompConfig.brokerURL = `${ this.wsScheme }://${host}/rtm/websocket?api-key=${encodeURIComponent( request.apiKey )}`; } else { this.rxStompConfig.webSocketFactory = () => { return new TransportFallback.default( `${this.httpScheme}://${host}/rtm?api-key=${encodeURIComponent( request.apiKey )}` ); }; } this.rxStomp.configure({ ...this.rxStompConfig, connectHeaders, }); this.rxStomp.serverHeaders$.subscribe(headers => { this.rxStomp.configure({ ...this.rxStompConfig, connectHeaders: { ...connectHeaders, 'StompX-Auth-Session-ID': headers['session'], }, }); }) this.rxStomp.connected$.subscribe(() => { this.relayResource<U>({ destination: '/application/v1/user.relay', onSuccess: (user) => { if (this.initialized) { request.onConnected(user); } else { this.rxStomp .watch('/user/queue/v1/errors', { id: StompX.generateSubscriptionId(), }) .subscribe((message) => { const error: StompXError = JSON.parse(message.body); const subscription = message.headers['subscription-id']; const receipt = message.headers['receipt-id']; if (subscription) { const handler = this.pendingRelayErrors.get(subscription); if (handler) { handler(error); this.pendingRelayErrors.delete(subscription); } } if (receipt) { const handler = this.pendingActionErrors.get(receipt); if (handler) { handler(error); this.pendingActionErrors.delete(receipt); } } if (!subscription && !receipt) { this.pendingActionErrors.forEach((handler) => { handler(error); }); this.pendingActionErrors.clear(); } }); this.relayResource<{ grant: string }>({ destination: '/application/v1/user.write_file_access_grant.relay', onSuccess: (write) => { this.relayResource<{ grant: string }>({ destination: '/application/v1/user.read_file_access_grant.relay', onSuccess: (read) => { request.onSuccess(user, write.grant, read.grant); request.onConnected(user); this.initialized = true; }, }); }, }); } }, }); }); this.rxStomp.connectionState$.subscribe((state) => { if (state == RxStompState.CLOSED) { request.onConnectionLost(); } if (state == RxStompState.OPEN) { request.onConnectionResumed(); } }); this.rxStomp.stompErrors$.subscribe((frame) => { let error: StompXError; try { error = JSON.parse(frame.body); } catch (e) { error = { error: 'UnknownChatKittyError', message: 'An unknown error occurred.', timestamp: new Date().toISOString(), }; } if (error.error == 'AccessDeniedError') { const onResult = () => request.onError(error); this.disconnect({onSuccess: onResult, onError: onResult}); } else { request.onError(error); } }); this.rxStomp.webSocketErrors$.subscribe((error) => { console.error(error); request.onError({ error: 'ChatKittyConnectionError', message: 'Could not connect to ChatKitty', timestamp: new Date().toISOString(), }); }); this.rxStomp.activate(); } public relayResource<R>(request: StompXRelayResourceRequest<R>) { this.guardConnected(() => { const subscriptionId = StompX.generateSubscriptionId(); if (request.onError) { this.pendingRelayErrors.set(subscriptionId, request.onError); } this.rxStomp.stompClient.subscribe( request.destination, (message) => { request.onSuccess(JSON.parse(message.body).resource); }, { ...request.parameters, id: subscriptionId, } ); }); } public listenToTopic(request: StompXListenToTopicRequest): () => void { let unsubscribe = () => { // Do nothing }; this.guardConnected(() => { const subscriptionReceipt = StompX.generateReceipt(); const onSuccess = request.onSuccess; if (onSuccess) { this.rxStomp.watchForReceipt(subscriptionReceipt, () => { onSuccess(); }); } const subscription = this.rxStomp .watch(request.topic, { id: StompX.generateSubscriptionId(), receipt: subscriptionReceipt, ack: 'client-individual', }) .subscribe((message) => { const event: StompXEvent<unknown> = JSON.parse(message.body); const receipt = message.headers['receipt-id']; if (receipt) { const action = this.pendingActions.get(receipt); if (action && (!action.types || action.types.find(type => type === event.type))) { action.action(event.resource); this.pendingActions.delete(receipt); } } const handlers = this.eventHandlers.get(request.topic); if (handlers) { handlers.forEach((handler) => { if (handler.event === event.type) { handler.onSuccess(event.resource); } }); } message.ack(); }); this.topics.set(request.topic, subscription); unsubscribe = () => { subscription.unsubscribe(); this.topics.delete(request.topic); }; }); return () => unsubscribe(); } public listenForEvent<R>( request: StompXListenForEventRequest<R> ): () => void { let handlers = this.eventHandlers.get(request.topic); if (handlers === undefined) { handlers = new Set<StompXEventHandler<unknown>>(); } const handler = { event: request.event, onSuccess: request.onSuccess as (resource: unknown) => void, }; handlers.add(handler); this.eventHandlers.set(request.topic, handlers); return () => { if (handlers) { handlers.delete(handler); } }; } public sendAction<R>(request: StompXSendActionRequest<R>) { this.guardConnected(() => { const receipt = StompX.generateReceipt(); if (request.onSent) { this.rxStomp.watchForReceipt(receipt, request.onSent); } if (request.onSuccess) { this.pendingActions.set( receipt, { types: request.events, action: request.onSuccess as (resource: unknown) => void } ); } if (request.onError) { this.pendingActionErrors.set(receipt, request.onError); } this.rxStomp.publish({ destination: request.destination, headers: { 'content-type': 'application/json;charset=UTF-8', receipt: receipt, }, body: JSON.stringify(request.body), }); }); } public sendToStream<R>(request: StompXSendToStreamRequest<R>) { const data = new FormData(); let file = request.file; if (!(file instanceof File)) { file = StompX.dataUriToFile(file.uri, file.name); } data.append('file', file); request.properties?.forEach((value, key) => { data.append(key, JSON.stringify(value)); }); request.progressListener?.onStarted?.(); this.axios({ method: 'post', url: request.stream, data: data, headers: {'Content-Type': 'multipart/form-data', Grant: request.grant}, onUploadProgress: (progressEvent) => { request.progressListener?.onProgress?.( progressEvent.loaded / progressEvent.total ); }, }) .then((response) => { request.progressListener?.onCompleted?.(); request.onSuccess?.(response.data); }) .catch((error) => { request.progressListener?.onFailed?.(); request.onError?.(error); }); } public disconnect(request: StompXDisconnectRequest) { this.initialized = false; this.rxStomp.deactivate().then(request.onSuccess).catch(request.onError); this.rxStomp = new RxStomp(); } private guardConnected(action: () => void) { this.rxStomp.connected$.pipe(take(1)).subscribe(() => { action(); }); } private static dataUriToFile(url: string, name: string): File { const arr = url.split(','), mime = arr[0].match(/:(.*?);/)?.[1], bstr = atob(arr[1]); let n = bstr.length; const u8arr = new Uint8Array(n) while(n--){ u8arr[n] = bstr.charCodeAt(n); } return new File([u8arr], name, {type:mime}); } private static generateSubscriptionId(): string { return 'subscription-id-' + v4(); } private static generateReceipt(): string { return 'receipt-' + v4(); } } export declare class StompXConfiguration { public isSecure: boolean; public host: string; public isDebug: boolean; } export declare class StompXConnectRequest<U> { apiKey: string; username: string; authParams?: unknown; onSuccess: (user: U, writeFileGrant: string, readFileGrant: string) => void; onConnected: (user: U) => void; onConnectionLost: () => void; onConnectionResumed: () => void; onError: (error: StompXError) => void; } export declare class StompXDisconnectRequest { onSuccess: () => void; onError: (e: unknown) => void; } export declare class StompXListenForEventRequest<R> { topic: string; event: string; onSuccess: (resource: R) => void; } export declare class StompXListenToTopicRequest { topic: string; onSuccess?: () => void; } export declare class StompXPage { _embedded?: Record<string, unknown>; page: StompXPageMetadata; _relays: StompXPageRelays; } export declare class StompXPageMetadata { size: number; totalElement: number; totalPages: number; number: number; } export declare class StompXPageRelays { first?: string; prev?: string; self: string; next?: string; last?: string; } export declare class StompXRelayParameters { [key: string]: unknown; } export declare class StompXSendActionRequest<R> { destination: string; body: unknown; events?: string[]; onSent?: () => void; onSuccess?: (resource: R) => void; onError?: (error: StompXError) => void; } export declare class StompXRelayResourceRequest<R> { destination: string; parameters?: StompXRelayParameters; onSuccess: (resource: R) => void; onError?: (error: StompXError) => void; } export declare class StompXSendToStreamRequest<R> { stream: string; grant: string; file: File | {uri: string, name: string}; properties?: Map<string, unknown>; onSuccess?: (resource: R) => void; onError?: (error: StompXError) => void; progressListener?: StompXUploadProgressListener; } export declare class StompXEvent<R> { type: string; version: string; resource: R; } export declare class StompXError { error: string; message: string; timestamp: string; } export declare class StompXEventHandler<R> { event: string; onSuccess: (resource: R) => void; } export interface StompXUploadProgressListener { onStarted?: () => void; onProgress?: (progress: number) => void; onCompleted?: () => void; onFailed?: () => void; onCancelled?: () => void; }