mysa-js-sdk 2.1.1 → 3.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -24,14 +24,24 @@ SOFTWARE.
24
24
 
25
25
  // src/api/Errors.ts
26
26
  var UnauthenticatedError = class extends Error {
27
+ /**
28
+ * The underlying failure that prevented authentication.
29
+ *
30
+ * Callers need it to tell a credential rejection apart from a transport or service failure, since both surface as
31
+ * this error. Declared explicitly rather than passed to `super` so the SDK keeps compiling against its current lib,
32
+ * which predates the two-argument `Error` constructor.
33
+ */
34
+ cause;
27
35
  /**
28
36
  * Creates a new UnauthenticatedError instance.
29
37
  *
30
38
  * @param message - The error message
39
+ * @param cause - The underlying failure that prevented authentication, if any.
31
40
  */
32
- constructor(message) {
41
+ constructor(message, cause) {
33
42
  super(message);
34
43
  this.name = "UnauthenticatedError";
44
+ this.cause = cause;
35
45
  }
36
46
  };
37
47
  var MysaApiError = class extends Error {
@@ -53,6 +63,19 @@ var MysaApiError = class extends Error {
53
63
  this.statusText = apiResponse.statusText;
54
64
  }
55
65
  };
66
+ var UnknownDeviceError = class extends Error {
67
+ /**
68
+ * Creates a new UnknownDeviceError instance.
69
+ *
70
+ * @param deviceId - The device id that could not be resolved
71
+ */
72
+ constructor(deviceId) {
73
+ super(`Unknown device id '${deviceId}': no such device on this account.`);
74
+ this.deviceId = deviceId;
75
+ this.name = "UnknownDeviceError";
76
+ }
77
+ deviceId;
78
+ };
56
79
  var MqttPublishError = class extends Error {
57
80
  /**
58
81
  * Creates a new MqttPublishError instance.
@@ -150,8 +173,9 @@ function parseMqttPayload(payload) {
150
173
  const jsonString = decoder.decode(payload);
151
174
  return JSON.parse(jsonString);
152
175
  } catch (error) {
153
- console.error("Error parsing MQTT payload:", error);
154
- throw new Error("Failed to parse MQTT payload");
176
+ const parseError = new Error("Failed to parse MQTT payload");
177
+ parseError.cause = error;
178
+ throw parseError;
155
179
  }
156
180
  }
157
181
  function serializeMqttPayload(payload) {
@@ -191,15 +215,7 @@ var OutMessageType = /* @__PURE__ */ ((OutMessageType2) => {
191
215
  // src/api/MysaApiClient.ts
192
216
  import { DescribeThingCommand, IoTClient } from "@aws-sdk/client-iot";
193
217
  import { fromCognitoIdentityPool } from "@aws-sdk/credential-providers";
194
- import {
195
- AuthenticationDetails,
196
- CognitoAccessToken,
197
- CognitoIdToken,
198
- CognitoRefreshToken,
199
- CognitoUser,
200
- CognitoUserPool,
201
- CognitoUserSession
202
- } from "amazon-cognito-identity-js";
218
+ import { AuthenticationDetails, CognitoUser, CognitoUserPool } from "amazon-cognito-identity-js";
203
219
  import { auth, iot, mqtt } from "aws-iot-device-sdk-v2";
204
220
  import { hash } from "crypto";
205
221
  import dayjs from "dayjs";
@@ -215,11 +231,16 @@ var CognitoLoginKey = `cognito-idp.${AwsRegion}.amazonaws.com/${CognitoUserPoolI
215
231
  var MqttEndpoint = "a3q27gia9qg3zy-ats.iot.us-east-1.amazonaws.com";
216
232
  var MysaApiBaseUrl = "https://app-prod.mysa.cloud";
217
233
  var RealtimeKeepAliveInterval = dayjs.duration(5, "minutes");
234
+ var PublishAckTimeout = dayjs.duration(30, "seconds");
218
235
  var MysaApiClient = class {
236
+ /** The credentials of the Mysa account this client authenticates as. */
237
+ _credentials;
219
238
  /** The current session object, if any. */
220
239
  _cognitoUserSession;
221
240
  /** The current user object, if any. */
222
241
  _cognitoUser;
242
+ /** The in-flight session acquisition, if any, so that concurrent callers share a single refresh or login. */
243
+ _freshSessionPromise;
223
244
  /** The logger instance used by the client. */
224
245
  _logger;
225
246
  /** The fetcher function used by the client. */
@@ -244,89 +265,62 @@ var MysaApiClient = class {
244
265
  * @see {@link MysaApiClientEventTypes} for the possible events and their payloads.
245
266
  */
246
267
  emitter = new EventEmitter();
247
- /**
248
- * Gets the persistable session object.
249
- *
250
- * @returns The current persistable session object, if any.
251
- */
252
- get session() {
253
- if (!this._cognitoUserSession || !this._cognitoUser) {
254
- return void 0;
255
- }
256
- return {
257
- username: this._cognitoUser.getUsername(),
258
- idToken: this._cognitoUserSession.getIdToken().getJwtToken(),
259
- accessToken: this._cognitoUserSession.getAccessToken().getJwtToken(),
260
- refreshToken: this._cognitoUserSession.getRefreshToken().getToken()
261
- };
262
- }
263
- /**
264
- * Returns whether the client currently has an active session.
265
- *
266
- * @returns True if the client has an active session, false otherwise.
267
- */
268
- get isAuthenticated() {
269
- return !!this.session;
270
- }
271
268
  /**
272
269
  * Constructs a new instance of the MysaApiClient.
273
270
  *
274
- * @param session - The persistable session object, if any.
271
+ * @param credentials - The credentials of the Mysa account to authenticate as.
275
272
  * @param options - The options for the client.
276
273
  */
277
- constructor(session, options) {
274
+ constructor(credentials, options) {
275
+ this._credentials = credentials;
278
276
  this._logger = (options == null ? void 0 : options.logger) || new VoidLogger();
279
277
  this._fetcher = (options == null ? void 0 : options.fetcher) || fetch;
280
- if (session) {
281
- this._cognitoUser = new CognitoUser({
282
- Username: session.username,
283
- Pool: new CognitoUserPool({ UserPoolId: CognitoUserPoolId, ClientId: CognitoClientId })
284
- });
285
- this._cognitoUserSession = new CognitoUserSession({
286
- IdToken: new CognitoIdToken({ IdToken: session.idToken }),
287
- AccessToken: new CognitoAccessToken({ AccessToken: session.accessToken }),
288
- RefreshToken: new CognitoRefreshToken({ RefreshToken: session.refreshToken })
289
- });
290
- }
291
278
  }
292
279
  /**
293
- * Logs in the user with the given email address and password.
280
+ * Ensures the client has a usable session, logging in with the credentials it was constructed with if needed.
294
281
  *
295
- * This method authenticates the user with Mysa's Cognito user pool and establishes a session that can be used for
296
- * subsequent API calls. Upon successful login, a 'sessionChanged' event is emitted.
282
+ * Calling this method is optional: the client authenticates on demand before its first API call, and re-authenticates
283
+ * on its own whenever its session can no longer be refreshed. Call it explicitly at startup to fail fast on invalid
284
+ * credentials instead of on the first API call. It is a no-op when the current session is still usable.
297
285
  *
298
286
  * @example
299
287
  *
300
288
  * ```typescript
301
289
  * try {
302
- * await client.login('user@example.com', 'password123');
290
+ * await client.login();
303
291
  * console.log('Login successful!');
304
292
  * } catch (error) {
305
293
  * console.error('Login failed:', error.message);
306
294
  * }
307
295
  * ```
308
296
  *
309
- * @param emailAddress - The email address of the user.
310
- * @param password - The password of the user.
297
+ * @throws {@link UnauthenticatedError} When authentication fails due to invalid credentials or network issues.
298
+ */
299
+ async login() {
300
+ await this._getFreshSession();
301
+ }
302
+ /**
303
+ * Authenticates with Mysa's Cognito user pool and replaces the current session, if any.
304
+ *
305
+ * @returns A promise that resolves to the newly established session.
311
306
  * @throws {@link Error} When authentication fails due to invalid credentials or network issues.
312
307
  */
313
- async login(emailAddress, password) {
308
+ _login() {
314
309
  this._cognitoUser = void 0;
315
310
  this._cognitoUserSession = void 0;
316
311
  this._mqttClientId = void 0;
317
312
  this._mqttInterrupts = [];
318
- this.emitter.emit("sessionChanged", this.session);
313
+ const { username, password } = this._credentials;
319
314
  return new Promise((resolve, reject) => {
320
315
  const user = new CognitoUser({
321
- Username: emailAddress,
316
+ Username: username,
322
317
  Pool: new CognitoUserPool({ UserPoolId: CognitoUserPoolId, ClientId: CognitoClientId })
323
318
  });
324
- user.authenticateUser(new AuthenticationDetails({ Username: emailAddress, Password: password }), {
319
+ user.authenticateUser(new AuthenticationDetails({ Username: username, Password: password }), {
325
320
  onSuccess: (session) => {
326
321
  this._cognitoUser = user;
327
322
  this._cognitoUserSession = session;
328
- this.emitter.emit("sessionChanged", this.session);
329
- resolve();
323
+ resolve(session);
330
324
  },
331
325
  onFailure: (err) => {
332
326
  reject(err);
@@ -351,7 +345,7 @@ var MysaApiClient = class {
351
345
  *
352
346
  * @returns A promise that resolves to the list of devices.
353
347
  * @throws {@link MysaApiError} When the API request fails.
354
- * @throws {@link UnauthenticatedError} When the user is not authenticated.
348
+ * @throws {@link UnauthenticatedError} When the client cannot authenticate with its credentials.
355
349
  */
356
350
  async getDevices() {
357
351
  this._logger.debug(`Fetching devices...`);
@@ -385,7 +379,7 @@ var MysaApiClient = class {
385
379
  *
386
380
  * @param deviceId - The ID of the device to get the serial number for.
387
381
  * @returns A promise that resolves to the serial number, or undefined if not found.
388
- * @throws {@link UnauthenticatedError} When the user is not authenticated.
382
+ * @throws {@link UnauthenticatedError} When the client cannot authenticate with its credentials.
389
383
  */
390
384
  async getDeviceSerialNumber(deviceId) {
391
385
  var _a;
@@ -423,7 +417,7 @@ var MysaApiClient = class {
423
417
  *
424
418
  * @returns A promise that resolves to the firmware information for all devices.
425
419
  * @throws {@link MysaApiError} When the API request fails.
426
- * @throws {@link UnauthenticatedError} When the user is not authenticated.
420
+ * @throws {@link UnauthenticatedError} When the client cannot authenticate with its credentials.
427
421
  */
428
422
  async getDeviceFirmwares() {
429
423
  this._logger.debug(`Fetching device firmwares...`);
@@ -443,7 +437,7 @@ var MysaApiClient = class {
443
437
  *
444
438
  * @returns A promise that resolves to the current state of all devices.
445
439
  * @throws {@link MysaApiError} When the API request fails.
446
- * @throws {@link UnauthenticatedError} When the user is not authenticated.
440
+ * @throws {@link UnauthenticatedError} When the client cannot authenticate with its credentials.
447
441
  */
448
442
  async getDeviceStates() {
449
443
  this._logger.debug(`Fetching device states...`);
@@ -463,7 +457,7 @@ var MysaApiClient = class {
463
457
  *
464
458
  * @returns A promise that resolves to the homes information.
465
459
  * @throws {@link MysaApiError} When the API request fails.
466
- * @throws {@link UnauthenticatedError} When the user is not authenticated.
460
+ * @throws {@link UnauthenticatedError} When the client cannot authenticate with its credentials.
467
461
  */
468
462
  async getHomes() {
469
463
  this._logger.debug(`Fetching homes...`);
@@ -505,16 +499,20 @@ var MysaApiClient = class {
505
499
  * @param mode - The operating mode to set (one of MysaDeviceMode values, or undefined to leave unchanged).
506
500
  * @param fanSpeed - The fan speed mode to set ('low', 'medium', 'high', 'max', 'auto', or undefined to leave
507
501
  * unchanged).
508
- * @throws {@link UnauthenticatedError} When the user is not authenticated.
502
+ * @throws {@link UnauthenticatedError} When the client cannot authenticate with its credentials.
503
+ * @throws {@link UnknownDeviceError} When the device id does not match any device on the account.
509
504
  * @throws {@link Error} When MQTT connection or command sending fails.
510
505
  */
511
506
  async setDeviceState(deviceId, setPoint, mode, fanSpeed) {
512
- var _a;
513
507
  this._logger.debug(`Setting device state for '${deviceId}'`);
514
508
  if (!this._cachedDevices) {
515
509
  this._cachedDevices = await this.getDevices();
516
510
  }
511
+ if (!Object.prototype.hasOwnProperty.call(this._cachedDevices.DevicesObj, deviceId)) {
512
+ throw new UnknownDeviceError(deviceId);
513
+ }
517
514
  const device = this._cachedDevices.DevicesObj[deviceId];
515
+ await this._getFreshSession();
518
516
  this._logger.debug(`Initializing MQTT connection...`);
519
517
  const mqttConnection = await this._getMqttConnection();
520
518
  const now = dayjs();
@@ -527,7 +525,7 @@ var MysaApiClient = class {
527
525
  time: now.unix(),
528
526
  ver: "1.0",
529
527
  src: {
530
- ref: ((_a = this.session) == null ? void 0 : _a.username) ?? "",
528
+ ref: this._cognitoUser.getUsername(),
531
529
  type: 100
532
530
  },
533
531
  dest: {
@@ -596,17 +594,25 @@ var MysaApiClient = class {
596
594
  Timestamp: dayjs().unix(),
597
595
  Timeout: RealtimeKeepAliveInterval.asSeconds()
598
596
  });
599
- await this._publishWithRetry(mqttConnection, `/v1/dev/${deviceId}/in`, payload, mqtt.QoS.AtLeastOnce);
597
+ try {
598
+ await this._publishWithRetry(mqttConnection, `/v1/dev/${deviceId}/in`, payload, mqtt.QoS.AtLeastOnce);
599
+ } catch (error) {
600
+ this._logger.warn(`Failed to request status publishing for '${deviceId}'; relying on periodic reports`, error);
601
+ }
600
602
  const timer = setInterval(async () => {
601
603
  this._logger.debug(`Sending request to keep-alive publishing device status for '${deviceId}'...`);
602
- const connection = await this._getMqttConnection();
603
- const payload2 = serializeMqttPayload({
604
- Device: deviceId,
605
- MsgType: 11 /* START_PUBLISHING_DEVICE_STATUS */,
606
- Timestamp: dayjs().unix(),
607
- Timeout: RealtimeKeepAliveInterval.asSeconds()
608
- });
609
- await this._publishWithRetry(connection, `/v1/dev/${deviceId}/in`, payload2, mqtt.QoS.AtLeastOnce);
604
+ try {
605
+ const connection = await this._getMqttConnection();
606
+ const payload2 = serializeMqttPayload({
607
+ Device: deviceId,
608
+ MsgType: 11 /* START_PUBLISHING_DEVICE_STATUS */,
609
+ Timestamp: dayjs().unix(),
610
+ Timeout: RealtimeKeepAliveInterval.asSeconds()
611
+ });
612
+ await this._publishWithRetry(connection, `/v1/dev/${deviceId}/in`, payload2, mqtt.QoS.AtLeastOnce);
613
+ } catch (error) {
614
+ this._logger.warn(`Failed to keep-alive status publishing for '${deviceId}'`, error);
615
+ }
610
616
  }, RealtimeKeepAliveInterval.subtract(10, "seconds").asMilliseconds());
611
617
  this._realtimeDeviceIds.set(deviceId, timer);
612
618
  }
@@ -637,33 +643,58 @@ var MysaApiClient = class {
637
643
  * Ensures a valid, non-expired session is available.
638
644
  *
639
645
  * This method checks if the current session is valid and not expired. If the session is expired, it automatically
640
- * refreshes it using the refresh token.
646
+ * refreshes it using the refresh token. If there is no session yet, or if the refresh token has itself expired or
647
+ * been revoked, the client logs back in with its credentials.
648
+ *
649
+ * Concurrent callers share a single in-flight acquisition, so a burst of API calls never triggers more than one
650
+ * refresh or login.
641
651
  *
642
652
  * @returns A promise that resolves to a valid CognitoUserSession.
643
- * @throws {@link UnauthenticatedError} When no session exists or refresh fails.
653
+ * @throws {@link UnauthenticatedError} When neither refreshing nor logging back in succeeds.
644
654
  */
645
655
  async _getFreshSession() {
646
- if (!this._cognitoUser || !this._cognitoUserSession) {
647
- throw new UnauthenticatedError("An attempt was made to access a resource without a valid session.");
648
- }
649
- if (this._cognitoUserSession.isValid() && dayjs.unix(this._cognitoUserSession.getIdToken().getExpiration()).isAfter()) {
656
+ var _a;
657
+ if (this._cognitoUser && ((_a = this._cognitoUserSession) == null ? void 0 : _a.isValid()) && dayjs.unix(this._cognitoUserSession.getIdToken().getExpiration()).isAfter()) {
650
658
  this._logger.debug("Session is valid, no need to refresh");
651
- return Promise.resolve(this._cognitoUserSession);
659
+ return this._cognitoUserSession;
652
660
  }
653
- this._logger.debug("Session is not valid or expired, refreshing...");
654
- return new Promise((resolve, reject) => {
655
- this._cognitoUser.refreshSession(this._cognitoUserSession.getRefreshToken(), (error, session) => {
656
- if (error) {
657
- this._logger.error("Failed to refresh session:", error);
658
- reject(new UnauthenticatedError("Unable to refresh the authentication session."));
659
- } else {
660
- this._logger.debug("Session refreshed successfully");
661
- this._cognitoUserSession = session;
662
- this.emitter.emit("sessionChanged", this.session);
663
- resolve(session);
664
- }
665
- });
661
+ this._freshSessionPromise ??= this._acquireFreshSession().finally(() => {
662
+ this._freshSessionPromise = void 0;
666
663
  });
664
+ return this._freshSessionPromise;
665
+ }
666
+ /**
667
+ * Refreshes the current session, falling back to a full login when it cannot be refreshed.
668
+ *
669
+ * @returns A promise that resolves to a valid CognitoUserSession.
670
+ * @throws {@link UnauthenticatedError} When logging back in fails.
671
+ */
672
+ async _acquireFreshSession() {
673
+ if (this._cognitoUser && this._cognitoUserSession) {
674
+ this._logger.debug("Session is not valid or expired, refreshing...");
675
+ try {
676
+ return await new Promise((resolve, reject) => {
677
+ this._cognitoUser.refreshSession(this._cognitoUserSession.getRefreshToken(), (error, session) => {
678
+ if (error) {
679
+ reject(error);
680
+ } else {
681
+ this._logger.debug("Session refreshed successfully");
682
+ this._cognitoUserSession = session;
683
+ resolve(session);
684
+ }
685
+ });
686
+ });
687
+ } catch (error) {
688
+ this._logger.warn("Failed to refresh session, logging back in:", error);
689
+ }
690
+ }
691
+ try {
692
+ this._logger.info("Logging in...");
693
+ return await this._login();
694
+ } catch (error) {
695
+ this._logger.error("Failed to log in:", error);
696
+ throw new UnauthenticatedError("Unable to establish an authentication session.", error);
697
+ }
667
698
  }
668
699
  /**
669
700
  * Establishes and returns an MQTT connection for real-time communication.
@@ -741,7 +772,22 @@ var MysaApiClient = class {
741
772
  while (true) {
742
773
  attempt++;
743
774
  try {
744
- await connection.publish(topic, payload, qos);
775
+ await new Promise((resolve, reject) => {
776
+ const timer = setTimeout(
777
+ () => reject(new Error(`MQTT publish timeout: no acknowledgement within ${PublishAckTimeout.asSeconds()}s`)),
778
+ PublishAckTimeout.asMilliseconds()
779
+ );
780
+ connection.publish(topic, payload, qos).then(
781
+ () => {
782
+ clearTimeout(timer);
783
+ resolve();
784
+ },
785
+ (err) => {
786
+ clearTimeout(timer);
787
+ reject(err);
788
+ }
789
+ );
790
+ });
745
791
  return;
746
792
  } catch (err) {
747
793
  const isTransient = this._isTransientMqttError(err);
@@ -790,7 +836,7 @@ var MysaApiClient = class {
790
836
  throw new Error("MQTT credentials are already expired.");
791
837
  }
792
838
  if (!this._mqttClientId) {
793
- const username = ((_a = this.session) == null ? void 0 : _a.username) ?? "anon";
839
+ const username = ((_a = this._cognitoUser) == null ? void 0 : _a.getUsername()) ?? "anon";
794
840
  const usernameHash = hash("sha1", username);
795
841
  this._mqttClientId = `mysa-js-sdk-${usernameHash}-${process.pid}-${getRandomClientId()}`;
796
842
  }
@@ -1002,6 +1048,7 @@ export {
1002
1048
  MysaApiError,
1003
1049
  OutMessageType,
1004
1050
  UnauthenticatedError,
1051
+ UnknownDeviceError,
1005
1052
  VoidLogger
1006
1053
  };
1007
1054
  //# sourceMappingURL=index.js.map