mirror of
https://github.com/bourquep/mysa-js-sdk.git
synced 2026-02-04 09:41:07 +00:00
Compare commits
12 Commits
145-gracef
...
v2.0.3
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
85c123d2aa | ||
|
|
0524bcea73 | ||
|
|
bb10ba4616 | ||
|
|
5366ea6fc9 | ||
|
|
baa7941cfc | ||
|
|
ef60db37d5 | ||
|
|
f1525cd1f1 | ||
|
|
3b2a020ac7 | ||
|
|
cbac285b1e | ||
|
|
ca127483c1 | ||
|
|
e320d658e8 | ||
|
|
c8dac38563 |
2103
package-lock.json
generated
2103
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
30
package.json
30
package.json
@@ -50,30 +50,30 @@
|
||||
"brace-expansion": "^2.0.2"
|
||||
},
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-iot": "3.920.0",
|
||||
"@aws-sdk/credential-providers": "3.922.0",
|
||||
"amazon-cognito-identity-js": "6.3.15",
|
||||
"aws-iot-device-sdk-v2": "1.22.0",
|
||||
"dayjs": "1.11.18",
|
||||
"@aws-sdk/client-iot": "3.936.0",
|
||||
"@aws-sdk/credential-providers": "3.940.0",
|
||||
"amazon-cognito-identity-js": "6.3.16",
|
||||
"aws-iot-device-sdk-v2": "1.23.1",
|
||||
"dayjs": "1.11.19",
|
||||
"lodash": "4.17.21",
|
||||
"nanoid": "5.1.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "9.38.0",
|
||||
"@semantic-release/npm": "13.1.1",
|
||||
"@types/lodash": "4.17.20",
|
||||
"@types/node": "24.10.0",
|
||||
"@eslint/js": "9.39.1",
|
||||
"@semantic-release/npm": "13.1.2",
|
||||
"@types/lodash": "4.17.21",
|
||||
"@types/node": "24.10.1",
|
||||
"conventional-changelog-conventionalcommits": "9.1.0",
|
||||
"dotenv": "17.2.3",
|
||||
"eslint": "9.39.0",
|
||||
"eslint-plugin-jsdoc": "61.1.11",
|
||||
"eslint-plugin-tsdoc": "0.4.0",
|
||||
"pino": "9.13.0",
|
||||
"pino-pretty": "13.0.0",
|
||||
"eslint": "9.39.1",
|
||||
"eslint-plugin-jsdoc": "61.4.1",
|
||||
"eslint-plugin-tsdoc": "0.5.0",
|
||||
"pino": "10.1.0",
|
||||
"pino-pretty": "13.1.2",
|
||||
"prettier": "3.6.2",
|
||||
"prettier-plugin-jsdoc": "1.5.0",
|
||||
"prettier-plugin-organize-imports": "4.3.0",
|
||||
"semantic-release": "25.0.1",
|
||||
"semantic-release": "25.0.2",
|
||||
"tsup": "8.5.0",
|
||||
"tsx": "4.20.6",
|
||||
"typedoc": "0.28.14",
|
||||
|
||||
@@ -19,7 +19,8 @@ import {
|
||||
CognitoUserSession
|
||||
} from 'amazon-cognito-identity-js';
|
||||
import { iot, mqtt } from 'aws-iot-device-sdk-v2';
|
||||
import dayjs from 'dayjs';
|
||||
import { hash } from 'crypto';
|
||||
import dayjs, { Dayjs } from 'dayjs';
|
||||
import duration from 'dayjs/plugin/duration.js';
|
||||
import { customAlphabet } from 'nanoid';
|
||||
import { MqttPublishError, MysaApiError, UnauthenticatedError } from './Errors';
|
||||
@@ -92,9 +93,15 @@ export class MysaApiClient {
|
||||
/** Stable per-process MQTT client id (prevents collisions between multiple processes). */
|
||||
private _mqttClientId?: string;
|
||||
|
||||
/** Expiration time of the credentials currently in use by the MQTT client. */
|
||||
private _mqttCredentialsExpiration?: Dayjs;
|
||||
|
||||
/** Interrupt timestamps for storm / collision detection. */
|
||||
private _mqttInterrupts: number[] = [];
|
||||
|
||||
/** Whether a forced MQTT reset is currently in progress (guards against re-entrancy). */
|
||||
private _mqttResetInProgress = false;
|
||||
|
||||
/** The device IDs that are currently being updated in real-time, mapped to their respective timeouts. */
|
||||
private _realtimeDeviceIds: Map<string, NodeJS.Timeout> = new Map();
|
||||
|
||||
@@ -493,13 +500,15 @@ export class MysaApiClient {
|
||||
|
||||
const timer = setInterval(async () => {
|
||||
this._logger.debug(`Sending request to keep-alive publishing device status for '${deviceId}'...`);
|
||||
|
||||
const connection = await this._getMqttConnection();
|
||||
const payload = serializeMqttPayload<StartPublishingDeviceStatus>({
|
||||
Device: deviceId,
|
||||
MsgType: InMessageType.START_PUBLISHING_DEVICE_STATUS,
|
||||
Timestamp: dayjs().unix(),
|
||||
Timeout: RealtimeKeepAliveInterval.asSeconds()
|
||||
});
|
||||
await this._publishWithRetry(mqttConnection, `/v1/dev/${deviceId}/in`, payload, mqtt.QoS.AtLeastOnce);
|
||||
await this._publishWithRetry(connection, `/v1/dev/${deviceId}/in`, payload, mqtt.QoS.AtLeastOnce);
|
||||
}, RealtimeKeepAliveInterval.subtract(10, 'seconds').asMilliseconds());
|
||||
|
||||
this._realtimeDeviceIds.set(deviceId, timer);
|
||||
@@ -615,6 +624,7 @@ export class MysaApiClient {
|
||||
'AWS_ERROR_MQTT_NO_CONNECTION',
|
||||
'AWS_ERROR_MQTT_UNEXPECTED_HANGUP',
|
||||
'UNEXPECTED_HANGUP',
|
||||
'AWS_ERROR_MQTT_CONNECTION_DESTROYED',
|
||||
'Time limit between request and response',
|
||||
'timeout'
|
||||
];
|
||||
@@ -705,9 +715,24 @@ export class MysaApiClient {
|
||||
});
|
||||
const credentials = await credentialsProvider();
|
||||
|
||||
if (!credentials.expiration) {
|
||||
throw new Error('MQTT credentials do not have an expiration time.');
|
||||
}
|
||||
|
||||
this._mqttCredentialsExpiration = dayjs(credentials.expiration);
|
||||
|
||||
this._logger.debug(`MQTT credentials expiration: ${this._mqttCredentialsExpiration.format()}`);
|
||||
|
||||
if (!this._mqttCredentialsExpiration.isAfter(dayjs())) {
|
||||
this._mqttCredentialsExpiration = undefined;
|
||||
throw new Error('MQTT credentials are already expired.');
|
||||
}
|
||||
|
||||
// Per-process stable client id. Random suffix avoids collisions with other running processes.
|
||||
if (!this._mqttClientId) {
|
||||
this._mqttClientId = `mysa-js-sdk-${this.session?.username ?? 'anon'}-${getRandomClientId()}`;
|
||||
const username = this.session?.username ?? 'anon';
|
||||
const usernameHash = hash('sha1', username);
|
||||
this._mqttClientId = `mysa-js-sdk-${usernameHash}-${process.pid}-${getRandomClientId()}`;
|
||||
}
|
||||
|
||||
const builder = iot.AwsIotMqttConnectionConfigBuilder.new_with_websockets()
|
||||
@@ -726,19 +751,19 @@ export class MysaApiClient {
|
||||
const connection = client.new_connection(config);
|
||||
|
||||
connection.on('connect', () => {
|
||||
this._logger.debug('MQTT connect');
|
||||
this._logger.debug(`MQTT connect (clientId=${this._mqttClientId})`);
|
||||
});
|
||||
|
||||
connection.on('connection_success', () => {
|
||||
this._logger.debug('MQTT connection_success');
|
||||
this._logger.debug(`MQTT connection_success (clientId=${this._mqttClientId})`);
|
||||
});
|
||||
|
||||
connection.on('connection_failure', (e) => {
|
||||
this._logger.error('MQTT connection_failure', e);
|
||||
this._logger.error(`MQTT connection_failure (clientId=${this._mqttClientId})`, e);
|
||||
});
|
||||
|
||||
connection.on('interrupt', async (e) => {
|
||||
this._logger.warn('MQTT interrupt', e);
|
||||
this._logger.warn(`MQTT interrupt (clientId=${this._mqttClientId})`, e);
|
||||
|
||||
// Track recent interrupts
|
||||
const now = Date.now();
|
||||
@@ -747,32 +772,62 @@ export class MysaApiClient {
|
||||
this._mqttInterrupts = this._mqttInterrupts.filter((t) => now - t < 60000);
|
||||
this._mqttInterrupts.push(now);
|
||||
|
||||
const areCredentialsExpired = !(this._mqttCredentialsExpiration?.isAfter(dayjs()) ?? false);
|
||||
|
||||
if ((this._mqttInterrupts.length > 5 || areCredentialsExpired) && !this._mqttResetInProgress) {
|
||||
this._mqttResetInProgress = true;
|
||||
|
||||
if (this._mqttInterrupts.length > 5) {
|
||||
this._logger.warn(
|
||||
`High interrupt rate (${this._mqttInterrupts.length}/60s). Possible clientId collision. Regenerating clientId and resetting connection...`
|
||||
);
|
||||
} else {
|
||||
this._logger.warn(`Credentials expired. Regenerating clientId and resetting connection...`);
|
||||
}
|
||||
|
||||
// Force new client id to escape collision; close current connection
|
||||
this._mqttClientId = undefined;
|
||||
|
||||
this._mqttCredentialsExpiration = undefined;
|
||||
|
||||
// Clear interrupts
|
||||
this._mqttInterrupts = [];
|
||||
|
||||
// Explicitly clear promise first to prevent reuse while disconnecting
|
||||
// (publishers calling _getMqttConnection() will create a new one)
|
||||
this._mqttConnectionPromise = undefined;
|
||||
|
||||
try {
|
||||
await connection.disconnect();
|
||||
|
||||
if (this._mqttConnectionPromise) {
|
||||
this._logger.warn('MQTT connection promise still defined after disconnect; expected it to be cleared.');
|
||||
this._mqttConnectionPromise = undefined;
|
||||
try {
|
||||
this._logger.debug('Old MQTT connection disconnected; establishing new connection...');
|
||||
const newConnection = await this._getMqttConnection();
|
||||
|
||||
for (const deviceId of Array.from(this._realtimeDeviceIds.keys())) {
|
||||
const topic = `/v1/dev/${deviceId}/out`;
|
||||
this._logger.debug(`Re-subscribing to ${topic}`);
|
||||
await newConnection.subscribe(topic, mqtt.QoS.AtLeastOnce, (_topic, payload) => {
|
||||
this._processMqttMessage(payload);
|
||||
});
|
||||
}
|
||||
|
||||
this._logger.info('MQTT connection rebuilt successfully after interrupt storm or credentials expiration');
|
||||
} catch (err) {
|
||||
this._logger.error('Failed to re-subscribe after interrupt storm or credentials expiration', err);
|
||||
}
|
||||
} catch (error) {
|
||||
this._logger.error('Failed to disconnect MQTT connection', error);
|
||||
this._logger.error('Error during MQTT reset', error);
|
||||
} finally {
|
||||
this._mqttResetInProgress = false;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
connection.on('resume', async (returnCode, sessionPresent) => {
|
||||
this._logger.info(`MQTT resume returnCode=${returnCode} sessionPresent=${sessionPresent}`);
|
||||
this._logger.info(
|
||||
`MQTT resume returnCode=${returnCode} sessionPresent=${sessionPresent} clientId=${this._mqttClientId}`
|
||||
);
|
||||
|
||||
if (!sessionPresent) {
|
||||
this._logger.info('No session present, re-subscribing each device');
|
||||
@@ -791,12 +846,13 @@ export class MysaApiClient {
|
||||
});
|
||||
|
||||
connection.on('error', (e) => {
|
||||
this._logger.error('MQTT error', e);
|
||||
this._logger.error(`MQTT error (clientId=${this._mqttClientId})`, e);
|
||||
});
|
||||
|
||||
connection.on('closed', () => {
|
||||
this._logger.info('MQTT connection closed');
|
||||
this._mqttConnectionPromise = undefined;
|
||||
this._mqttCredentialsExpiration = undefined;
|
||||
});
|
||||
|
||||
await connection.connect();
|
||||
|
||||
Reference in New Issue
Block a user