@sprucelabs/mercury-client
Version:
The simple way to interact with the Spruce Experience Platform
606 lines (605 loc) • 26.3 kB
JavaScript
var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
return new (P || (P = Promise))(function (resolve, reject) {
function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
step((generator = generator.apply(thisArg, _arguments || [])).next());
});
};
var __rest = (this && this.__rest) || function (s, e) {
var t = {};
for (var p in s) if (Object.prototype.hasOwnProperty.call(s, p) && e.indexOf(p) < 0)
t[p] = s[p];
if (s != null && typeof Object.getOwnPropertySymbols === "function")
for (var i = 0, p = Object.getOwnPropertySymbols(s); i < p.length; i++) {
if (e.indexOf(p[i]) < 0 && Object.prototype.propertyIsEnumerable.call(s, p[i]))
t[p[i]] = s[p[i]];
}
return t;
};
import AbstractSpruceError from '@sprucelabs/error';
import { SchemaError, validateSchemaValues, } from '@sprucelabs/schema';
import { eventContractUtil, eventResponseUtil, eventNameUtil, } from '@sprucelabs/spruce-event-utils';
import { buildLog } from '@sprucelabs/spruce-skill-utils';
import { io } from 'socket.io-client';
import SpruceError from '../errors/SpruceError.js';
import socketIoEventUtil from '../utilities/socketIoEventUtil.utility.js';
class MercurySocketIoClient {
get eventContract() {
return this._eventContract;
}
set eventContract(contract) {
this._eventContract = contract;
}
constructor(options) {
this.log = 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 } = options, ioOptions = __rest(options, ["host", "eventContract", "emitTimeoutMs", "reconnectDelayMs", "shouldReconnect", "maxEmitRetries", "connectionRetries"]);
this.host = host;
this.ioOptions = Object.assign(Object.assign({}, ioOptions), { withCredentials: false });
this.eventContract = eventContract;
this.emitTimeoutMs = emitTimeoutMs !== null && emitTimeoutMs !== void 0 ? emitTimeoutMs : 30000;
this.reconnectDelayMs = reconnectDelayMs !== null && reconnectDelayMs !== void 0 ? reconnectDelayMs : 5000;
this.shouldReconnect = shouldReconnect !== null && shouldReconnect !== void 0 ? shouldReconnect : true;
this.id = new Date().getTime().toString();
this.maxEmitRetries = maxEmitRetries;
this.connectionRetriesRemaining = connectionRetries !== null && connectionRetries !== void 0 ? connectionRetries : 5;
this.connectionRetries = connectionRetries !== null && connectionRetries !== void 0 ? connectionRetries : 5;
}
connect() {
return __awaiter(this, void 0, void 0, function* () {
this.socket = MercurySocketIoClient.io(this.host, this.ioOptions);
this.emitStatusChange('connecting');
yield new Promise((resolve, reject) => {
var _a, _b;
(_a = this.socket) === null || _a === void 0 ? void 0 : _a.on('connect', () => {
var _a, _b;
this.connectionRetriesRemaining = this.connectionRetries;
(_a = this.socket) === null || _a === void 0 ? void 0 : _a.removeAllListeners();
if (!this.isReconnecting) {
this.emitStatusChange('connected');
}
if (this.shouldReconnect) {
(_b = this.socket) === null || _b === void 0 ? void 0 : _b.once('disconnect', (opts) => __awaiter(this, void 0, void 0, function* () {
this.log.error('Mercury disconnected, reason:', opts);
yield this.attemptReconnectAfterDelay();
}));
}
this.attachConnectError();
resolve(undefined);
});
(_b = this.socket) === null || _b === void 0 ? void 0 : _b.on('timeout', () => {
reject(new SpruceError({
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) {
var _a;
(_a = this.socket) === null || _a === void 0 ? void 0 : _a.on('connect_error', (err) => __awaiter(this, void 0, void 0, function* () {
var _a;
const error = this.mapSocketErrorToSpruceError(err);
//@ts-ignore
(_a = this.socket) === null || _a === void 0 ? void 0 : _a.removeAllListeners();
this.log.error('Failed to connect to Mercury', error.message);
this.log.error('Connection retries left', `${this.connectionRetriesRemaining}`);
if (this.connectionRetriesRemaining === 0) {
reject === null || reject === void 0 ? void 0 : reject(error);
return;
}
try {
this.isReconnecting = false;
yield this.attemptReconnectAfterDelay();
resolve === null || resolve === void 0 ? void 0 : resolve();
}
catch (err) {
//@ts-ignore
reject === null || reject === void 0 ? void 0 : reject(err);
}
}));
}
attemptReconnectAfterDelay() {
return __awaiter(this, arguments, void 0, function* (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(() => __awaiter(this, void 0, void 0, function* () {
yield this.reconnect(resolve, reject, retriesLeft);
}), this.reconnectDelayMs);
});
return this.reconnectPromise;
});
}
reconnect(resolve, reject, retriesLeft) {
return __awaiter(this, void 0, void 0, function* () {
var _a;
try {
this.connectionRetriesRemaining--;
const key = new Date().getTime();
this.reconnectKey = key;
yield this.connect();
if (this.reconnectKey !== key) {
return;
}
if (this.isManuallyDisconnected) {
this.isReconnecting = false;
return;
}
this.skipWaitIfReconnecting = true;
if (this.lastAuthOptions) {
yield this.authenticate(this.lastAuthOptions);
}
if (this.isManuallyDisconnected) {
this.isReconnecting = false;
return;
}
if (this.shouldRegisterProxyOnReconnect) {
yield this.registerProxyToken();
}
if (this.isManuallyDisconnected) {
this.isReconnecting = false;
return;
}
yield this.reRegisterAllListeners();
this.emitStatusChange('connected');
this.isReAuthing = false;
this.isReconnecting = false;
this.skipWaitIfReconnecting = false;
resolve();
}
catch (err) {
;
((_a = console.error) !== null && _a !== void 0 ? _a : 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) {
yield this.attemptReconnectAfterDelay(retriesLeft)
.then(resolve)
.catch(reject);
}
else {
this.lastAuthOptions = undefined;
reject(err);
}
}
});
}
waitIfReconnecting() {
return __awaiter(this, void 0, void 0, function* () {
yield this.reconnectPromise;
});
}
reRegisterAllListeners() {
return __awaiter(this, void 0, void 0, function* () {
const listeners = this.registeredListeners;
this.registeredListeners = [];
const all = Promise.all(listeners.map((listener) => this.on(listener[0], listener[1])));
yield all;
});
}
mapSocketErrorToSpruceError(err) {
var _a;
const originalError = new Error((_a = err.message) !== null && _a !== void 0 ? _a : err);
if (err.stack) {
originalError.stack = err.stack;
}
//@ts-ignore
originalError.socketError = err;
switch (err.message) {
case 'timeout':
return new SpruceError({
code: 'TIMEOUT',
eventName: 'connect',
timeoutMs: 10000,
});
case 'xhr poll error':
return new SpruceError({
code: 'CONNECTION_FAILED',
host: this.host,
statusCode: +err.description || 503,
originalError,
});
default:
return new SpruceError({
code: 'UNKNOWN_ERROR',
originalError,
friendlyMessage: `Something went wrong when working with socketio`,
});
}
}
emit(eventName, targetAndPayload, cb) {
return __awaiter(this, void 0, void 0, function* () {
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 === null || listener === void 0 ? void 0 : listener[1];
cb === null || cb === void 0 ? void 0 : cb({
//@ts-ignore
payload: targetAndPayload === null || targetAndPayload === void 0 ? void 0 : targetAndPayload.payload,
});
}
return {
responses: [],
totalContracts: 0,
totalErrors: 0,
totalResponses: 0,
};
}
emitAndFlattenResponses(eventName, payload, cb) {
return __awaiter(this, void 0, void 0, function* () {
const results = yield this.emit(eventName, payload, cb);
const { payloads, errors } = eventResponseUtil.getAllResponsePayloadsAndErrors(results, SpruceError);
if (errors === null || errors === void 0 ? void 0 : errors[0]) {
throw errors[0];
}
return payloads;
});
}
_emit(retriesRemaining, eventName, payload, cb) {
return __awaiter(this, void 0, void 0, function* () {
var _a;
if (!this.skipWaitIfReconnecting) {
yield this.waitIfReconnecting();
}
if (!this.allowNextEventToBeAuthenticate &&
eventName === authenticateFqen) {
throw new SchemaError({
code: 'INVALID_PARAMETERS',
parameters: ['eventName'],
friendlyMessage: `You can't emit '${authenticateFqen}' event directly. Use client.authenticate() so all your auth is preserved.`,
});
}
else if (eventName === authenticateFqen) {
this.allowNextEventToBeAuthenticate = false;
}
if (this.isManuallyDisconnected) {
throw new SpruceError({
code: 'NOT_CONNECTED',
action: 'emit',
fqen: eventName,
});
}
this.assertValidEmitTargetAndPayload(eventName, payload);
const responseEventName = eventNameUtil.generateResponseEventName(eventName);
const singleResponsePromises = [];
const singleResponseHandler = (response) => __awaiter(this, void 0, void 0, function* () {
if (cb) {
let resolve;
singleResponsePromises.push(new Promise((r) => {
resolve = r;
}));
yield cb(eventResponseUtil.mutatingMapSingleResonseErrorsToSpruceErrors(response, SpruceError));
//@ts-ignore
resolve();
}
});
if (cb) {
(_a = this.socket) === null || _a === void 0 ? void 0 : _a.on(responseEventName, singleResponseHandler);
}
const args = [];
if (payload || this.proxyToken) {
const p = Object.assign({}, payload);
if (eventName !== authenticateFqen &&
this.proxyToken &&
!p.source) {
p.source = {
proxyToken: this.proxyToken,
};
}
args.push(p);
}
const results = yield new Promise((resolve, reject) => {
var _a;
try {
const emitTimeout = setTimeout(() => __awaiter(this, void 0, void 0, function* () {
var _a;
(_a = this.socket) === null || _a === void 0 ? void 0 : _a.off(responseEventName, singleResponseHandler);
if (retriesRemaining == 0) {
const err = new SpruceError({
code: 'TIMEOUT',
eventName,
timeoutMs: this.emitTimeoutMs,
isConnected: this.isSocketConnected(),
totalRetries: this.maxEmitRetries,
});
reject(err);
return;
}
retriesRemaining--;
try {
if (eventName === authenticateFqen &&
this.authRawResults) {
resolve(this.authRawResults);
return;
}
this.allowNextEventToBeAuthenticate = true;
//@ts-ignore
const results = yield this._emit(retriesRemaining, eventName, payload, cb);
//@ts-ignore
resolve(results);
}
catch (err) {
reject(err);
}
}), this.emitTimeoutMs);
args.push((results) => {
var _a;
clearTimeout(emitTimeout);
this.handleConfirmPinResponse(eventName, results);
(_a = this.socket) === null || _a === void 0 ? void 0 : _a.off(responseEventName, singleResponseHandler);
resolve(results);
});
const ioName = socketIoEventUtil.toSocketName(eventName);
(_a = this.socket) === null || _a === void 0 ? void 0 : _a.emit(ioName, ...args);
}
catch (err) {
reject(err);
}
});
yield Promise.all(singleResponsePromises);
return eventResponseUtil.mutatingMapAggregateResponseErrorsToSpruceErrors(results, SpruceError);
});
}
assertValidEmitTargetAndPayload(eventName, payload) {
const signature = this.getEventSignatureByName(eventName);
if (signature.emitPayloadSchema) {
try {
validateSchemaValues(signature.emitPayloadSchema, payload !== null && payload !== void 0 ? payload : {});
}
catch (err) {
throw new SpruceError({
code: 'INVALID_PAYLOAD',
originalError: err,
eventName,
});
}
}
else if (payload && this.eventContract) {
throw new SpruceError({
code: 'UNEXPECTED_PAYLOAD',
eventName,
});
}
}
handleConfirmPinResponse(eventName, results) {
var _a, _b;
const payload = (_b = (_a = results === null || results === void 0 ? void 0 : results.responses) === null || _a === void 0 ? void 0 : _a[0]) === null || _b === void 0 ? void 0 : _b.payload;
if (eventName.search('confirm-pin') === 0 && (payload === null || payload === void 0 ? void 0 : payload.person)) {
this.lastAuthOptions = { token: payload.token };
this.auth = {
person: payload.person,
};
}
}
getEventSignatureByName(eventName) {
if (!this.eventContract) {
return {};
}
return eventContractUtil.getSignatureByName(this.eventContract, eventName);
}
setShouldAutoRegisterListeners(should) {
this.shouldAutoRegisterListeners = should;
}
on(eventName, cb) {
return __awaiter(this, void 0, void 0, function* () {
var _a, _b, _c;
this.registeredListeners.push([eventName, cb]);
const isLocalEvent = this.isEventLocal(eventName);
if (isLocalEvent) {
return;
}
if (!isLocalEvent && this.shouldAutoRegisterListeners) {
//@ts-ignore
const results = yield this.emit('register-listeners::v2020_12_25', {
payload: { events: [{ eventName }] },
});
if (results.totalErrors > 0) {
const options = (_b = (_a = results.responses[0].errors) === null || _a === void 0 ? void 0 : _a[0]) !== null && _b !== void 0 ? _b : 'UNKNOWN_ERROR';
throw AbstractSpruceError.parse(options, SpruceError);
}
}
const listener = (targetAndPayload, ioCallback) => __awaiter(this, void 0, void 0, function* () {
if (cb) {
try {
const results = yield cb(targetAndPayload);
if (ioCallback) {
ioCallback(results);
}
}
catch (err) {
let thisErr = err;
if (ioCallback) {
if (!(err instanceof AbstractSpruceError)) {
thisErr = new SpruceError({
//@ts-ignore
code: 'LISTENER_ERROR',
fqen: eventName,
friendlyMessage: err.message,
originalError: err,
});
}
ioCallback({ errors: [thisErr.toObject()] });
}
}
}
});
this.listenerMap.set(cb, listener);
(_c = this.socket) === null || _c === void 0 ? void 0 : _c.on(eventName,
//@ts-ignore
listener);
});
}
isEventLocal(eventName) {
return eventName === 'connection-status-change';
}
off(eventName, cb) {
return __awaiter(this, void 0, void 0, function* () {
this.removeLocalListener(cb, eventName);
return new Promise((resolve, reject) => {
var _a;
if (!this.socket || !this.auth || this.isEventLocal(eventName)) {
resolve(0);
return;
}
(_a = this.socket) === null || _a === void 0 ? void 0 : _a.emit('unregister-listeners::v2020_12_25', {
payload: {
fullyQualifiedEventNames: [eventName],
},
}, (results) => {
if (results.totalErrors > 0) {
const err = AbstractSpruceError.parse(results.responses[0].errors[0], SpruceError);
reject(err);
}
else {
resolve(results.responses[0].payload.unregisterCount);
}
});
});
});
}
removeLocalListener(cb, eventName) {
var _a, _b;
const listener = this.listenerMap.get(cb);
if (listener) {
this.listenerMap.delete(cb);
(_a = this.socket) === null || _a === void 0 ? void 0 : _a.off(eventName, listener);
}
else {
(_b = this.socket) === null || _b === void 0 ? void 0 : _b.removeAllListeners(eventName);
}
}
getId() {
return this.id;
}
disconnect() {
return __awaiter(this, void 0, void 0, function* () {
var _a;
this.isManuallyDisconnected = true;
if (this.isSocketConnected()) {
//@ts-ignore
(_a = this.socket) === null || _a === void 0 ? void 0 : _a.removeAllListeners();
yield new Promise((resolve) => {
var _a, _b;
(_a = this.socket) === null || _a === void 0 ? void 0 : _a.once('disconnect', () => {
this.socket = undefined;
resolve(undefined);
});
(_b = this.socket) === null || _b === void 0 ? void 0 : _b.disconnect();
});
}
return;
});
}
authenticate(options) {
return __awaiter(this, void 0, void 0, function* () {
var _a, _b;
const { skillId, apiKey, token } = options;
if (this.authPromise) {
yield this.authPromise;
return {
skill: (_a = this.auth) === null || _a === void 0 ? void 0 : _a.skill,
person: (_b = this.auth) === null || _b === void 0 ? void 0 : _b.person,
};
}
this.lastAuthOptions = options;
this.allowNextEventToBeAuthenticate = true;
//@ts-ignore
this.authPromise = this.emit('authenticate::v2020_12_25', {
payload: {
skillId,
apiKey,
token,
},
});
const results = yield this.authPromise;
//@ts-ignore
const { auth } = 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() {
var _a, _b;
return (_b = (_a = this.socket) === null || _a === void 0 ? void 0 : _a.connected) !== null && _b !== void 0 ? _b : false;
}
getProxyToken() {
return this.proxyToken;
}
setProxyToken(token) {
this.proxyToken = token;
}
registerProxyToken() {
return __awaiter(this, void 0, void 0, function* () {
const results = yield this.emit('register-proxy-token::v2020_12_25');
//@ts-ignore
const { token } = eventResponseUtil.getFirstResponseOrThrow(results);
this.setProxyToken(token);
this.shouldRegisterProxyOnReconnect = true;
return token;
});
}
getIsTestClient() {
return false;
}
}
MercurySocketIoClient.io = io;
export default MercurySocketIoClient;
export const authenticateFqen = 'authenticate::v2020_12_25';