import log from 'loglevel' import { computed, makeObservable, observable } from 'mobx' import { Client, MQTTError } from 'paho-mqtt' import { v4 as uuidv4 } from 'uuid' import { IotCredentials } from '../interfaces' import { startSession } from '../mqtt' import { MainStore } from './mainStore' type Subscription = { id: string // eslint-disable-next-line @typescript-eslint/no-explicit-any handler: (data: any) => void } const RECONNECT_TIMEOUT_FLOOR = 4 // Without a ceiling, reconnectAttempts (which only resets on a successful // connect) makes the backoff grow unbounded for a device stuck failing to // connect - e.g. attempt 15 is already ~36h, attempt 20 ~48 days. A device // that hits a rough patch (flaky network, momentarily bad credentials) would // then take days to notice conditions have improved. Cap it so it always // retries at a sane interval instead of being backed off into silence. const RECONNECT_TIMEOUT_CEILING_MS = 5 * 60 * 1000 export class NotificationStore { main: MainStore connected = false mqttClient?: Client listeners: Map = new Map() subscribeQueue: { topic: string handler: Subscription['handler'] referenceId: string }[] = [] subscribedTopics: { topic: string handler: Subscription['handler'] referenceId: string }[] = [] reconnectAttempts = 0 reconnectHandler?: () => void get topicStage(): 'prod' | 'stage' { return this.main.api.config.stage === 'production' ? 'prod' : 'stage' } constructor(main: MainStore) { makeObservable(this, { connected: observable, topicStage: computed, }) this.main = main } public isConnected() { if (!this.mqttClient) { return false } return this.mqttClient.isConnected() } public setReconnectHandler(handler: () => void) { this.reconnectHandler = handler } async getCredentials(): Promise { const credentialsResponse = await this.main.notifications.getCredentials() if (!credentialsResponse?.credentials) { log.warn('Unable to get IoT credentials') return } return credentialsResponse } async connect(credentials?: IotCredentials): Promise { if (this.mqttClient?.isConnected()) { this.mqttClient.disconnect() } if (!credentials) { const credentialsResponse = await this.getCredentials() if (!credentialsResponse) { return } credentials = credentialsResponse } this.reconnectAttempts += 1 this.connectWithCredentials(credentials) } connectWithCredentials(data: IotCredentials) { const clientId = `eDocuSDK-${this.main.userStore.user?.id}-${uuidv4()}` const onConnect = () => { log.debug('connected IoT') this.connected = true this.reconnectAttempts = 0 this.resolveSubscriptionQueue() this.resolveSubscriptions() } const onFailure = (e: MQTTError) => { this.connected = false this.listeners.clear() if (e.errorCode === 0) { // this is a manual disconnect, dont reconnect return } // Connection drops/failed reconnects are expected in the field // (network transitions, momentarily expired credentials, backgrounded // app) and are retried automatically below - log, don't error (RN // funnels console.error into the global exception handler, which // Sentry captures as an issue for every occurrence of what is usually // just normal connectivity flakiness). log.warn('MQTT connection error', e) const reconnectTimeWithJitter = randomIntFromInterval( 0, Math.min( RECONNECT_TIMEOUT_FLOOR * 2 ** this.reconnectAttempts * 1000, RECONNECT_TIMEOUT_CEILING_MS, ), ) log.info( `Reconnecting in ${reconnectTimeWithJitter}ms, attempt ${this.reconnectAttempts}`, ) setTimeout(() => this.reconnectHandler?.(), reconnectTimeWithJitter) } const onMessage = (topic: string, payload: string) => { try { const message = JSON.parse(payload) log.trace( `received message on ${topic}`, JSON.stringify(message, null, 2), ) let wildcardFound = false if (this.listeners.has(topic)) { const subscriptions = this.listeners.get(topic) if (subscriptions) { log.trace(`executing EXACT MATCH listeners for ${topic}`) this.executeListeners(subscriptions, message) return } } // could be wildcard topic, lets try to find it with regex. this.listeners.forEach((subscriptions, wildcardTopic) => { const keyRegex = wildcardToRegExp(wildcardTopic) if (keyRegex.test(topic)) { log.trace(`executing WILDCARD MATCH listeners for ${topic}`) this.executeListeners(subscriptions, message) wildcardFound = true } }) if (wildcardFound) { return } // didnt find message handler in specific nor wildcard topics log.warn(`unhandled message in topic ${topic}`) } catch (e) { console.error(e) } } const { credentials, endpoints } = data const client = startSession( { clientId, endpoint: endpoints.edocu, region: credentials.region, accessKeyId: credentials.accessKey, secretAccessKey: credentials.secretKey, sessionToken: credentials.sessionToken, }, { onConnect, onFailure, onMessageArrived: (m) => { onMessage(m.destinationName, m.payloadString) }, onConnectionLost: onFailure, }, ) this.mqttClient = client } executeListeners(subscriptions: Subscription[], message: unknown) { log.trace('executing listeners', subscriptions) try { subscriptions.forEach((subscription) => subscription.handler(message)) } catch (e) { log.error('error executing listeners', e) } } resolveSubscriptions() { this.subscribedTopics.forEach(({ topic, handler, referenceId }) => { this.subscribe(topic, handler, referenceId) }) } resolveSubscriptionQueue() { if (!this.mqttClient) { return } this.subscribeQueue.forEach(({ topic, handler, referenceId }) => { this.subscribe(topic, handler, referenceId) log.trace(`resolving subscription queue ${topic} ${referenceId}`) }) this.subscribeQueue = [] } subscribe( topicPostfix: string, onMessage: Subscription['handler'], reference?: string, ): string | undefined { if (!this.mqttClient) { log.warn(`No connection handler ${topicPostfix}`) return } const subscribeReference: string = reference ?? uuidv4() if (!this.isConnected()) { log.warn( `Tried to subscribe to ${topicPostfix} before connected, adding to queue`, ) this.subscribeQueue.push({ topic: topicPostfix, handler: onMessage, referenceId: subscribeReference, }) return subscribeReference } const topic = `${this.topicStage}/${topicPostfix}` const newSubscription: Subscription = { id: subscribeReference, handler: onMessage, } if ( !this.subscribedTopics.find((s) => s.referenceId === subscribeReference) ) { this.subscribedTopics.push({ topic: topicPostfix, handler: onMessage, referenceId: subscribeReference, }) } if (this.listeners.has(topic)) { const subscriptions = this.listeners.get(topic) if (!subscriptions || subscriptions.find((s) => s.id === reference)) { return } log.trace(`adding subscription to existing topic ${topic}`) this.listeners.set(topic, [...subscriptions, newSubscription]) return subscribeReference } else { log.trace(`subscribing to ${topic}`) this.listeners.set(topic, [newSubscription]) this.mqttClient.subscribe(topic, { qos: 1, onSuccess: () => { log.trace(`subscribed to ${topic}`) }, onFailure: (e) => { // A subscribe can fail (e.g. a topic-level ACL rejection) while // the connection itself stays up, so the connection-level // `this.listeners.clear()` in onFailure/onConnectionLost never // runs to clean this up. Without removing it here, this topic is // optimistically marked subscribed forever and nothing - not even // a later resubscribe attempt for the same topic - will ever // retry it for the lifetime of this connection. this.listeners.delete(topic) log.warn(`failed to subscribe to ${topic}`, e) }, }) return subscribeReference } } unsubscribe(topicPostfix: string, reference: string) { const topic = `${this.topicStage}/${topicPostfix}` if (this.listeners.has(topic)) { const subscriptions = this.listeners.get(topic) if (!subscriptions) { return } const subIdx = subscriptions.findIndex((s) => s.id === reference) if (subIdx !== -1) { log.trace(`splicing listener ${topic} ref:${reference}`) subscriptions.splice(subIdx, 1) } if (subscriptions.length === 0) { this.listeners.delete(topic) log.trace(`unsubscribing from ${topic}, 0 active listeners`) if (this.mqttClient && this.mqttClient.isConnected()) { this.mqttClient.unsubscribe(topic) } } this.subscribedTopics = this.subscribedTopics.filter( (s) => s.referenceId !== reference, ) } } } // Utility functions to convert wildcard topics into regex expressions function wildcardToRegExp(s: string) { return new RegExp('^' + s.split(/\++/).map(regExpEscape).join('.*') + '$') } function regExpEscape(s: string) { return s.replace(/[|\\{}()[\]^$+*?.]/g, '\\$&') } function randomIntFromInterval(min: number, max: number) { // min and max included return Math.floor(Math.random() * (max - min + 1) + min) }