UNPKG

@sprucelabs/mercury-client

Version:

The simple way to interact with the Spruce Experience Platform

550 lines (549 loc) • 20.9 kB
"use strict"; var __importDefault = (this && this.__importDefault) || function (mod) { return (mod && mod.__esModule) ? mod : { "default": mod }; }; Object.defineProperty(exports, "__esModule", { value: true }); exports.authenticateFqen = void 0; const error_1 = __importDefault(require("@sprucelabs/error")); const schema_1 = require("@sprucelabs/schema"); const spruce_event_utils_1 = require("@sprucelabs/spruce-event-utils"); const spruce_skill_utils_1 = require("@sprucelabs/spruce-skill-utils"); const socket_io_client_1 = require("socket.io-client"); const SpruceError_1 = __importDefault(require("../errors/SpruceError")); const socketIoEventUtil_utility_1 = __importDefault(require("../utilities/socketIoEventUtil.utility")); class MercurySocketIoClient { get eventContract() { return this._eventContract; } set eventContract(contract) { this._eventContract = contract; } constructor(options) { this.log = (0, spruce_skill_utils_1.buildLog)('MercurySocketIoClient'); this.proxyToken = null; this.listenerMap = new WeakMap(); this.isReAuthing = false; this.reconnectPromise = null; this.connectionRetriesRemaining = 5; this.registeredListeners = []; this.allowNextEventToBeAuthenticate = false; this.shouldAutoRegisterListeners = true; this.isManuallyDisconnected = false; this.isReconnecting = false; this.skipWaitIfReconnecting = false; this.shouldRegisterProxyOnReconnect = false; const { host, eventContract, emitTimeoutMs, reconnectDelayMs, shouldReconnect, maxEmitRetries = 5, connectionRetries, ...ioOptions } = options; this.host = host; this.ioOptions = { ...ioOptions, withCredentials: false }; this.eventContract = eventContract; this.emitTimeoutMs = emitTimeoutMs ?? 30000; this.reconnectDelayMs = reconnectDelayMs ?? 5000; this.shouldReconnect = shouldReconnect ?? true; this.id = new Date().getTime().toString(); this.maxEmitRetries = maxEmitRetries; this.connectionRetriesRemaining = connectionRetries ?? 5; this.connectionRetries = connectionRetries ?? 5; } async connect() { this.socket = MercurySocketIoClient.io(this.host, this.ioOptions); this.emitStatusChange('connecting'); await new Promise((resolve, reject) => { this.socket?.on('connect', () => { this.connectionRetriesRemaining = this.connectionRetries; this.socket?.removeAllListeners(); if (!this.isReconnecting) { this.emitStatusChange('connected'); } if (this.shouldReconnect) { this.socket?.once('disconnect', async (opts) => { this.log.error('Mercury disconnected, reason:', opts); await this.attemptReconnectAfterDelay(); }); } this.attachConnectError(); resolve(undefined); }); this.socket?.on('timeout', () => { reject(new SpruceError_1.default({ code: 'TIMEOUT', eventName: 'connect', timeoutMs: 20000, friendlyMessage: `Uh Oh! I'm having trouble reaching HQ! Double check you have good internet and try again. In the meantime I'll try some things on my side and see what I can do. 🤞`, })); }); this.attachConnectError(reject, resolve); }); } emitStatusChange(status) { //@ts-ignore void this.emit('connection-status-change', { payload: { status, }, }); } attachConnectError(reject, resolve) { this.socket?.on('connect_error', async (err) => { const error = this.mapSocketErrorToSpruceError(err); //@ts-ignore this.socket?.removeAllListeners(); this.log.error('Failed to connect to Mercury', error.message); this.log.error('Connection retries left', `${this.connectionRetriesRemaining}`); if (this.connectionRetriesRemaining === 0) { reject?.(error); return; } try { this.isReconnecting = false; await this.attemptReconnectAfterDelay(); resolve?.(); } catch (err) { //@ts-ignore reject?.(err); } }); } async attemptReconnectAfterDelay(retriesLeft = this.maxEmitRetries) { if (this.isManuallyDisconnected) { this.isReconnecting = false; return; } if (this.isReconnecting) { return; } this.emitStatusChange('disconnected'); delete this.authPromise; this.isReconnecting = true; this.proxyToken = null; this.reconnectPromise = new Promise((resolve, reject) => { if (this.lastAuthOptions) { this.isReAuthing = true; } setTimeout(async () => { await this.reconnect(resolve, reject, retriesLeft); }, this.reconnectDelayMs); }); return this.reconnectPromise; } async reconnect(resolve, reject, retriesLeft) { try { this.connectionRetriesRemaining--; const key = new Date().getTime(); this.reconnectKey = key; await this.connect(); if (this.reconnectKey !== key) { return; } if (this.isManuallyDisconnected) { this.isReconnecting = false; return; } this.skipWaitIfReconnecting = true; if (this.lastAuthOptions) { await this.authenticate(this.lastAuthOptions); } if (this.isManuallyDisconnected) { this.isReconnecting = false; return; } if (this.shouldRegisterProxyOnReconnect) { await this.registerProxyToken(); } if (this.isManuallyDisconnected) { this.isReconnecting = false; return; } await this.reRegisterAllListeners(); this.emitStatusChange('connected'); this.isReAuthing = false; this.isReconnecting = false; this.skipWaitIfReconnecting = false; resolve(); } catch (err) { ; (console.error ?? console.log)(err.message); this.isReconnecting = false; this.skipWaitIfReconnecting = false; retriesLeft = retriesLeft - 1; if ((err.options.code === 'TIMEOUT' || err.options.code === 'CONNECTION_FAILED') && retriesLeft > 0) { await this.attemptReconnectAfterDelay(retriesLeft) .then(resolve) .catch(reject); } else { this.lastAuthOptions = undefined; reject(err); } } } async waitIfReconnecting() { await this.reconnectPromise; } async reRegisterAllListeners() { const listeners = this.registeredListeners; this.registeredListeners = []; const all = Promise.all(listeners.map((listener) => this.on(listener[0], listener[1]))); await all; } mapSocketErrorToSpruceError(err) { const originalError = new Error(err.message ?? err); if (err.stack) { originalError.stack = err.stack; } //@ts-ignore originalError.socketError = err; switch (err.message) { case 'timeout': return new SpruceError_1.default({ code: 'TIMEOUT', eventName: 'connect', timeoutMs: 10000, }); case 'xhr poll error': return new SpruceError_1.default({ code: 'CONNECTION_FAILED', host: this.host, statusCode: +err.description || 503, originalError, }); default: return new SpruceError_1.default({ code: 'UNKNOWN_ERROR', originalError, friendlyMessage: `Something went wrong when working with socketio`, }); } } async emit(eventName, targetAndPayload, cb) { const isLocalEvent = this.isEventLocal(eventName); if (isLocalEvent) { return this.handleLocalEmit(eventName, targetAndPayload); } return this._emit(this.maxEmitRetries, eventName, targetAndPayload, cb); } handleLocalEmit(eventName, targetAndPayload) { const listeners = this.registeredListeners.filter((r) => r[0] === eventName); for (const listener of listeners) { const cb = listener?.[1]; cb?.({ //@ts-ignore payload: targetAndPayload?.payload, }); } return { responses: [], totalContracts: 0, totalErrors: 0, totalResponses: 0, }; } async emitAndFlattenResponses(eventName, payload, cb) { const results = await this.emit(eventName, payload, cb); const { payloads, errors } = spruce_event_utils_1.eventResponseUtil.getAllResponsePayloadsAndErrors(results, SpruceError_1.default); if (errors?.[0]) { throw errors[0]; } return payloads; } async _emit(retriesRemaining, eventName, payload, cb) { if (!this.skipWaitIfReconnecting) { await this.waitIfReconnecting(); } if (!this.allowNextEventToBeAuthenticate && eventName === exports.authenticateFqen) { throw new schema_1.SchemaError({ code: 'INVALID_PARAMETERS', parameters: ['eventName'], friendlyMessage: `You can't emit '${exports.authenticateFqen}' event directly. Use client.authenticate() so all your auth is preserved.`, }); } else if (eventName === exports.authenticateFqen) { this.allowNextEventToBeAuthenticate = false; } if (this.isManuallyDisconnected) { throw new SpruceError_1.default({ code: 'NOT_CONNECTED', action: 'emit', fqen: eventName, }); } this.assertValidEmitTargetAndPayload(eventName, payload); const responseEventName = spruce_event_utils_1.eventNameUtil.generateResponseEventName(eventName); const singleResponsePromises = []; const singleResponseHandler = async (response) => { if (cb) { let resolve; singleResponsePromises.push(new Promise((r) => { resolve = r; })); await cb(spruce_event_utils_1.eventResponseUtil.mutatingMapSingleResonseErrorsToSpruceErrors(response, SpruceError_1.default)); //@ts-ignore resolve(); } }; if (cb) { this.socket?.on(responseEventName, singleResponseHandler); } const args = []; if (payload || this.proxyToken) { const p = { ...payload, }; if (eventName !== exports.authenticateFqen && this.proxyToken && !p.source) { p.source = { proxyToken: this.proxyToken, }; } args.push(p); } const results = await new Promise((resolve, reject) => { try { const emitTimeout = setTimeout(async () => { this.socket?.off(responseEventName, singleResponseHandler); if (retriesRemaining == 0) { const err = new SpruceError_1.default({ code: 'TIMEOUT', eventName, timeoutMs: this.emitTimeoutMs, isConnected: this.isSocketConnected(), totalRetries: this.maxEmitRetries, }); reject(err); return; } retriesRemaining--; try { if (eventName === exports.authenticateFqen && this.authRawResults) { resolve(this.authRawResults); return; } this.allowNextEventToBeAuthenticate = true; //@ts-ignore const results = await this._emit(retriesRemaining, eventName, payload, cb); //@ts-ignore resolve(results); } catch (err) { reject(err); } }, this.emitTimeoutMs); args.push((results) => { clearTimeout(emitTimeout); this.handleConfirmPinResponse(eventName, results); this.socket?.off(responseEventName, singleResponseHandler); resolve(results); }); const ioName = socketIoEventUtil_utility_1.default.toSocketName(eventName); this.socket?.emit(ioName, ...args); } catch (err) { reject(err); } }); await Promise.all(singleResponsePromises); return spruce_event_utils_1.eventResponseUtil.mutatingMapAggregateResponseErrorsToSpruceErrors(results, SpruceError_1.default); } assertValidEmitTargetAndPayload(eventName, payload) { const signature = this.getEventSignatureByName(eventName); if (signature.emitPayloadSchema) { try { (0, schema_1.validateSchemaValues)(signature.emitPayloadSchema, payload ?? {}); } catch (err) { throw new SpruceError_1.default({ code: 'INVALID_PAYLOAD', originalError: err, eventName, }); } } else if (payload && this.eventContract) { throw new SpruceError_1.default({ code: 'UNEXPECTED_PAYLOAD', eventName, }); } } handleConfirmPinResponse(eventName, results) { const payload = results?.responses?.[0]?.payload; if (eventName.search('confirm-pin') === 0 && payload?.person) { this.lastAuthOptions = { token: payload.token }; this.auth = { person: payload.person, }; } } getEventSignatureByName(eventName) { if (!this.eventContract) { return {}; } return spruce_event_utils_1.eventContractUtil.getSignatureByName(this.eventContract, eventName); } setShouldAutoRegisterListeners(should) { this.shouldAutoRegisterListeners = should; } async on(eventName, cb) { this.registeredListeners.push([eventName, cb]); const isLocalEvent = this.isEventLocal(eventName); if (isLocalEvent) { return; } if (!isLocalEvent && this.shouldAutoRegisterListeners) { //@ts-ignore const results = await this.emit('register-listeners::v2020_12_25', { payload: { events: [{ eventName }] }, }); if (results.totalErrors > 0) { const options = results.responses[0].errors?.[0] ?? 'UNKNOWN_ERROR'; throw error_1.default.parse(options, SpruceError_1.default); } } const listener = async (targetAndPayload, ioCallback) => { if (cb) { try { const results = await cb(targetAndPayload); if (ioCallback) { ioCallback(results); } } catch (err) { let thisErr = err; if (ioCallback) { if (!(err instanceof error_1.default)) { thisErr = new SpruceError_1.default({ //@ts-ignore code: 'LISTENER_ERROR', fqen: eventName, friendlyMessage: err.message, originalError: err, }); } ioCallback({ errors: [thisErr.toObject()] }); } } } }; this.listenerMap.set(cb, listener); this.socket?.on(eventName, //@ts-ignore listener); } isEventLocal(eventName) { return eventName === 'connection-status-change'; } async off(eventName, cb) { this.removeLocalListener(cb, eventName); return new Promise((resolve, reject) => { if (!this.socket || !this.auth || this.isEventLocal(eventName)) { resolve(0); return; } this.socket?.emit('unregister-listeners::v2020_12_25', { payload: { fullyQualifiedEventNames: [eventName], }, }, (results) => { if (results.totalErrors > 0) { const err = error_1.default.parse(results.responses[0].errors[0], SpruceError_1.default); reject(err); } else { resolve(results.responses[0].payload.unregisterCount); } }); }); } removeLocalListener(cb, eventName) { const listener = this.listenerMap.get(cb); if (listener) { this.listenerMap.delete(cb); this.socket?.off(eventName, listener); } else { this.socket?.removeAllListeners(eventName); } } getId() { return this.id; } async disconnect() { this.isManuallyDisconnected = true; if (this.isSocketConnected()) { //@ts-ignore this.socket?.removeAllListeners(); await new Promise((resolve) => { this.socket?.once('disconnect', () => { this.socket = undefined; resolve(undefined); }); this.socket?.disconnect(); }); } return; } async authenticate(options) { const { skillId, apiKey, token } = options; if (this.authPromise) { await this.authPromise; return { skill: this.auth?.skill, person: this.auth?.person, }; } this.lastAuthOptions = options; this.allowNextEventToBeAuthenticate = true; //@ts-ignore this.authPromise = this.emit('authenticate::v2020_12_25', { payload: { skillId, apiKey, token, }, }); const results = await this.authPromise; //@ts-ignore const { auth } = spruce_event_utils_1.eventResponseUtil.getFirstResponseOrThrow(results); this.authRawResults = results; this.auth = auth; return { skill: auth.skill, person: auth.person, }; } isAuthenticated() { return !!this.auth; } isConnected() { return !this.isReAuthing && this.isSocketConnected(); } isSocketConnected() { return this.socket?.connected ?? false; } getProxyToken() { return this.proxyToken; } setProxyToken(token) { this.proxyToken = token; } async registerProxyToken() { const results = await this.emit('register-proxy-token::v2020_12_25'); //@ts-ignore const { token } = spruce_event_utils_1.eventResponseUtil.getFirstResponseOrThrow(results); this.setProxyToken(token); this.shouldRegisterProxyOnReconnect = true; return token; } getIsTestClient() { return false; } } MercurySocketIoClient.io = socket_io_client_1.io; exports.default = MercurySocketIoClient; exports.authenticateFqen = 'authenticate::v2020_12_25';