@sprucelabs/mercury-client
Version:
The simple way to interact with the Spruce Experience Platform
550 lines (549 loc) • 20.9 kB
JavaScript
;
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';