Compare commits

...

13 Commits

Author SHA1 Message Date
dependabot[bot]
cf46609210 chore(deps-dev): Bump tsup from 8.5.0 to 8.5.1
Bumps [tsup](https://github.com/egoist/tsup) from 8.5.0 to 8.5.1.
- [Release notes](https://github.com/egoist/tsup/releases)
- [Commits](https://github.com/egoist/tsup/compare/v8.5.0...v8.5.1)

---
updated-dependencies:
- dependency-name: tsup
  dependency-version: 8.5.1
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-12-01 04:55:11 +00:00
dependabot[bot]
85c123d2aa chore(deps): Bump @aws-sdk/credential-providers from 3.927.0 to 3.936.0 (#189) 2025-11-28 14:16:33 +00:00
dependabot[bot]
0524bcea73 chore(deps-dev): Bump pino-pretty from 13.0.0 to 13.1.2 (#190) 2025-11-28 14:15:48 +00:00
dependabot[bot]
bb10ba4616 chore(deps-dev): Bump pino from 9.13.0 to 10.1.0 (#191) 2025-11-28 14:13:00 +00:00
dependabot[bot]
5366ea6fc9 chore(deps): Bump @aws-sdk/client-iot from 3.920.0 to 3.936.0 (#192) 2025-11-28 14:12:56 +00:00
dependabot[bot]
baa7941cfc chore(deps): Bump amazon-cognito-identity-js from 6.3.15 to 6.3.16 (#193) 2025-11-28 14:12:49 +00:00
Pascal Bourque
ef60db37d5 fix: Unable to automatically reconnect when credentials have expired (#194) 2025-11-28 09:06:54 -05:00
dependabot[bot]
f1525cd1f1 chore(deps-dev): Bump the dev-dependencies group across 1 directory with 7 updates (#188) 2025-11-23 15:34:54 +00:00
dependabot[bot]
3b2a020ac7 chore(deps): Bump aws-iot-device-sdk-v2 from 1.22.0 to 1.23.1 (#182) 2025-11-23 14:59:39 +00:00
dependabot[bot]
cbac285b1e chore(deps-dev): Bump @eslint/js from 9.38.0 to 9.39.1 (#183) 2025-11-23 14:57:01 +00:00
dependabot[bot]
ca127483c1 chore(deps): Bump @aws-sdk/credential-providers from 3.922.0 to 3.927.0 (#184) 2025-11-23 14:56:56 +00:00
dependabot[bot]
e320d658e8 chore(deps): Bump dayjs from 1.11.18 to 1.11.19 (#185) 2025-11-23 14:56:52 +00:00
dependabot[bot]
c8dac38563 chore(deps-dev): Bump js-yaml from 4.1.0 to 4.1.1 (#187) 2025-11-23 14:56:24 +00:00
3 changed files with 1791 additions and 977 deletions

2646
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -50,31 +50,31 @@
"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",
"tsup": "8.5.0",
"semantic-release": "25.0.2",
"tsup": "8.5.1",
"tsx": "4.20.6",
"typedoc": "0.28.14",
"typedoc-material-theme": "1.4.1",

View File

@@ -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();