7 Commits

Author SHA1 Message Date
Pascal Bourque
94acdede23 fix: Prevent AWS_ERROR_MQTT_UNEXPECTED_HANGUP connection interruptions (#179)
By using a stable, unique per-process client identifier.

Also:

- Configured MQTT auto-reconnect on interruption
- Reset connection on high MQTT connection interruption rate
2025-11-08 15:12:30 -05:00
dependabot[bot]
d007c2d745 chore(deps-dev): Bump typedoc from 0.28.13 to 0.28.14 (#174) 2025-11-03 11:51:43 +00:00
dependabot[bot]
5d9981f9e0 chore(deps): Bump @aws-sdk/credential-providers from 3.901.0 to 3.922.0 (#173) 2025-11-03 11:49:06 +00:00
dependabot[bot]
2f2cdef0ee chore(deps-dev): Bump typedoc-material-theme from 1.4.0 to 1.4.1 (#175) 2025-11-03 11:48:50 +00:00
dependabot[bot]
193f67226b chore(deps-dev): Bump typescript-eslint from 8.41.0 to 8.46.2 (#176) 2025-11-03 11:48:46 +00:00
dependabot[bot]
ef8d787e05 chore(deps-dev): Bump the dev-dependencies group across 1 directory with 2 updates (#177) 2025-11-03 11:48:39 +00:00
Pascal Bourque
0c71ed95ce chore: Changed dependabot schedule from daily to weekly (#171) 2025-11-02 12:10:05 -05:00
4 changed files with 647 additions and 616 deletions

View File

@@ -8,7 +8,7 @@ updates:
- package-ecosystem: 'npm' # See documentation for possible values - package-ecosystem: 'npm' # See documentation for possible values
directory: '/' # Location of package manifests directory: '/' # Location of package manifests
schedule: schedule:
interval: 'daily' interval: 'weekly'
labels: labels:
- 'dependencies' - 'dependencies'
commit-message: commit-message:

1188
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -51,20 +51,21 @@
}, },
"dependencies": { "dependencies": {
"@aws-sdk/client-iot": "3.920.0", "@aws-sdk/client-iot": "3.920.0",
"@aws-sdk/credential-providers": "3.901.0", "@aws-sdk/credential-providers": "3.922.0",
"amazon-cognito-identity-js": "6.3.15", "amazon-cognito-identity-js": "6.3.15",
"aws-iot-device-sdk-v2": "1.22.0", "aws-iot-device-sdk-v2": "1.22.0",
"dayjs": "1.11.18", "dayjs": "1.11.18",
"lodash": "4.17.21" "lodash": "4.17.21",
"nanoid": "5.1.6"
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "9.38.0", "@eslint/js": "9.38.0",
"@semantic-release/npm": "13.1.1", "@semantic-release/npm": "13.1.1",
"@types/lodash": "4.17.20", "@types/lodash": "4.17.20",
"@types/node": "24.9.2", "@types/node": "24.10.0",
"conventional-changelog-conventionalcommits": "9.1.0", "conventional-changelog-conventionalcommits": "9.1.0",
"dotenv": "17.2.3", "dotenv": "17.2.3",
"eslint": "9.38.0", "eslint": "9.39.0",
"eslint-plugin-jsdoc": "61.1.11", "eslint-plugin-jsdoc": "61.1.11",
"eslint-plugin-tsdoc": "0.4.0", "eslint-plugin-tsdoc": "0.4.0",
"pino": "9.13.0", "pino": "9.13.0",
@@ -75,9 +76,9 @@
"semantic-release": "25.0.1", "semantic-release": "25.0.1",
"tsup": "8.5.0", "tsup": "8.5.0",
"tsx": "4.20.6", "tsx": "4.20.6",
"typedoc": "0.28.13", "typedoc": "0.28.14",
"typedoc-material-theme": "1.4.0", "typedoc-material-theme": "1.4.1",
"typescript": "5.9.3", "typescript": "5.9.3",
"typescript-eslint": "8.41.0" "typescript-eslint": "8.46.2"
} }
} }

View File

@@ -21,6 +21,7 @@ import {
import { iot, mqtt } from 'aws-iot-device-sdk-v2'; import { iot, mqtt } from 'aws-iot-device-sdk-v2';
import dayjs from 'dayjs'; import dayjs from 'dayjs';
import duration from 'dayjs/plugin/duration.js'; import duration from 'dayjs/plugin/duration.js';
import { customAlphabet } from 'nanoid';
import { MqttPublishError, MysaApiError, UnauthenticatedError } from './Errors'; import { MqttPublishError, MysaApiError, UnauthenticatedError } from './Errors';
import { Logger, VoidLogger } from './Logger'; import { Logger, VoidLogger } from './Logger';
import { MysaApiClientEventTypes } from './MysaApiClientEventTypes'; import { MysaApiClientEventTypes } from './MysaApiClientEventTypes';
@@ -29,6 +30,8 @@ import { MysaDeviceMode, MysaFanSpeedMode } from './MysaDeviceMode';
dayjs.extend(duration); dayjs.extend(duration);
const getRandomClientId = customAlphabet('1234567890abcdefghijklmnopqrstuvwxyz', 8);
/** Options for MQTT publish operations. */ /** Options for MQTT publish operations. */
export interface MqttPublishOptions { export interface MqttPublishOptions {
/** Maximum number of publish attempts before failing (default: 5). */ /** Maximum number of publish attempts before failing (default: 5). */
@@ -86,6 +89,12 @@ export class MysaApiClient {
/** A promise that resolves to the MQTT connection used for real-time updates. */ /** A promise that resolves to the MQTT connection used for real-time updates. */
private _mqttConnectionPromise?: Promise<mqtt.MqttClientConnection>; private _mqttConnectionPromise?: Promise<mqtt.MqttClientConnection>;
/** Stable per-process MQTT client id (prevents collisions between multiple processes). */
private _mqttClientId?: string;
/** Interrupt timestamps for storm / collision detection. */
private _mqttInterrupts: number[] = [];
/** The device IDs that are currently being updated in real-time, mapped to their respective timeouts. */ /** The device IDs that are currently being updated in real-time, mapped to their respective timeouts. */
private _realtimeDeviceIds: Map<string, NodeJS.Timeout> = new Map(); private _realtimeDeviceIds: Map<string, NodeJS.Timeout> = new Map();
@@ -173,6 +182,9 @@ export class MysaApiClient {
async login(emailAddress: string, password: string): Promise<void> { async login(emailAddress: string, password: string): Promise<void> {
this._cognitoUser = undefined; this._cognitoUser = undefined;
this._cognitoUserSession = undefined; this._cognitoUserSession = undefined;
this._mqttClientId = undefined;
this._mqttInterrupts = [];
this.emitter.emit('sessionChanged', this.session); this.emitter.emit('sessionChanged', this.session);
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
@@ -601,6 +613,8 @@ export class MysaApiClient {
const transientMarkers = [ const transientMarkers = [
'AWS_ERROR_MQTT_TIMEOUT', 'AWS_ERROR_MQTT_TIMEOUT',
'AWS_ERROR_MQTT_NO_CONNECTION', 'AWS_ERROR_MQTT_NO_CONNECTION',
'AWS_ERROR_MQTT_UNEXPECTED_HANGUP',
'UNEXPECTED_HANGUP',
'Time limit between request and response', 'Time limit between request and response',
'timeout' 'timeout'
]; ];
@@ -691,17 +705,21 @@ export class MysaApiClient {
}); });
const credentials = await credentialsProvider(); const credentials = await credentialsProvider();
// Stable client id + persistent session to retain QoS1 queue & subscriptions across reconnects. // Per-process stable client id. Random suffix avoids collisions with other running processes.
const stableClientId = `mysa-js-sdk-${this.session?.username ?? ''}`; if (!this._mqttClientId) {
this._mqttClientId = `mysa-js-sdk-${this.session?.username ?? 'anon'}-${getRandomClientId()}`;
}
const builder = iot.AwsIotMqttConnectionConfigBuilder.new_with_websockets() const builder = iot.AwsIotMqttConnectionConfigBuilder.new_with_websockets()
.with_credentials(AwsRegion, credentials.accessKeyId, credentials.secretAccessKey, credentials.sessionToken) .with_credentials(AwsRegion, credentials.accessKeyId, credentials.secretAccessKey, credentials.sessionToken)
.with_endpoint(MqttEndpoint) .with_endpoint(MqttEndpoint)
.with_client_id(stableClientId) .with_client_id(this._mqttClientId)
.with_clean_session(false) .with_clean_session(false)
.with_keep_alive_seconds(30) .with_keep_alive_seconds(30)
.with_ping_timeout_ms(3000) .with_ping_timeout_ms(3000)
.with_protocol_operation_timeout_ms(60000); .with_protocol_operation_timeout_ms(60000)
.with_reconnect_min_sec(1)
.with_reconnect_max_sec(30);
const config = builder.build(); const config = builder.build();
const client = new mqtt.MqttClient(); const client = new mqtt.MqttClient();
@@ -719,8 +737,38 @@ export class MysaApiClient {
this._logger.error('MQTT connection_failure', e); this._logger.error('MQTT connection_failure', e);
}); });
connection.on('interrupt', (e) => { connection.on('interrupt', async (e) => {
this._logger.warn('MQTT interrupt', e); this._logger.warn('MQTT interrupt', e);
// Track recent interrupts
const now = Date.now();
// Keep only last 60s
this._mqttInterrupts = this._mqttInterrupts.filter((t) => now - t < 60000);
this._mqttInterrupts.push(now);
if (this._mqttInterrupts.length > 5) {
this._logger.warn(
`High interrupt rate (${this._mqttInterrupts.length}/60s). Possible clientId collision. Regenerating clientId and resetting connection...`
);
// Force new client id to escape collision; close current connection
this._mqttClientId = undefined;
// Clear interrupts
this._mqttInterrupts = [];
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;
}
} catch (error) {
this._logger.error('Failed to disconnect MQTT connection', error);
}
}
}); });
connection.on('resume', async (returnCode, sessionPresent) => { connection.on('resume', async (returnCode, sessionPresent) => {