@apocaliss92/nodedreame 1.11.10 → 1.11.11

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.cjs CHANGED
@@ -73,6 +73,7 @@ __export(index_exports, {
73
73
  extractMowerConsumableValues: () => extractMowerConsumableValues,
74
74
  getMowerCapabilities: () => getMowerCapabilities,
75
75
  getVacuumCapabilities: () => getVacuumCapabilities,
76
+ isAuthRefusedError: () => isAuthRefusedError,
76
77
  isDreameConsumableKey: () => isDreameConsumableKey,
77
78
  mowerConsumableIndex: () => mowerConsumableIndex,
78
79
  mowerFaultSeverity: () => mowerFaultSeverity,
@@ -717,6 +718,10 @@ var TypedEmitter = class {
717
718
  emit(event, ...args) {
718
719
  return this.#emitter.emit(event, ...args);
719
720
  }
721
+ /** Number of listeners registered for `event`. */
722
+ listenerCount(event) {
723
+ return this.#emitter.listenerCount(event);
724
+ }
720
725
  removeAllListeners() {
721
726
  this.#emitter.removeAllListeners();
722
727
  return this;
@@ -729,6 +734,24 @@ function defaultConnect(url, opts) {
729
734
  const typed = client;
730
735
  return typed;
731
736
  }
737
+ function isAuthRefusedError(err) {
738
+ let cur = err;
739
+ for (let depth = 0; cur != null && depth < 6; depth += 1) {
740
+ if (typeof cur !== "object") {
741
+ break;
742
+ }
743
+ const code = cur.code;
744
+ if (code === 4 || code === 5 || code === 134 || code === 135) {
745
+ return true;
746
+ }
747
+ const message = cur.message;
748
+ if (typeof message === "string" && /not authorized|bad user ?name|bad username or password|bad password/i.test(message)) {
749
+ return true;
750
+ }
751
+ cur = cur.cause;
752
+ }
753
+ return false;
754
+ }
732
755
  var DreamePush = class extends TypedEmitter {
733
756
  #device;
734
757
  #session;
@@ -737,11 +760,15 @@ var DreamePush = class extends TypedEmitter {
737
760
  #backoffMs;
738
761
  #keepaliveSeconds;
739
762
  #rejectUnauthorized;
763
+ #onAuthFailure;
764
+ #maxAuthRetries;
740
765
  #topic;
741
766
  #client = null;
742
767
  #closed = false;
743
768
  #tearingDown = false;
744
769
  #reconnectTimer = null;
770
+ /** Consecutive auth-refused re-auths since the last successful connect. */
771
+ #authFailureCount = 0;
745
772
  /**
746
773
  * Per-instance bound close handler. Stored so we can pass the EXACT same
747
774
  * function reference to both `client.on('close', …)` and
@@ -758,6 +785,8 @@ var DreamePush = class extends TypedEmitter {
758
785
  this.#backoffMs = input.reconnectBackoffMs ?? 5e3;
759
786
  this.#keepaliveSeconds = input.keepaliveSeconds ?? 60;
760
787
  this.#rejectUnauthorized = input.rejectUnauthorized ?? false;
788
+ this.#onAuthFailure = input.onAuthFailure ?? null;
789
+ this.#maxAuthRetries = input.maxAuthRetries ?? 3;
761
790
  this.#topic = buildStatusTopic(this.#device, this.#session.uid, this.#region);
762
791
  this.#onClose = () => {
763
792
  if (this.#tearingDown) {
@@ -794,7 +823,11 @@ var DreamePush = class extends TypedEmitter {
794
823
  this.#reconnectTimer = null;
795
824
  }
796
825
  await this.#teardownClient();
797
- await this.#connectAndSubscribe();
826
+ try {
827
+ await this.#connectAndSubscribe();
828
+ } catch {
829
+ this.#scheduleReconnect();
830
+ }
798
831
  }
799
832
  /** Tear down permanently. Closed subscriptions cannot be reopened. */
800
833
  async close() {
@@ -831,6 +864,7 @@ var DreamePush = class extends TypedEmitter {
831
864
  reject(new DreameTransportError(`mqtt subscribe failed: ${err.message}`, err));
832
865
  return;
833
866
  }
867
+ this.#authFailureCount = 0;
834
868
  this.emit("connect");
835
869
  resolve();
836
870
  });
@@ -864,12 +898,55 @@ var DreamePush = class extends TypedEmitter {
864
898
  if (this.#closed || this.#client) {
865
899
  return;
866
900
  }
867
- void this.#connectAndSubscribe().catch((err) => {
868
- this.emit("error", err instanceof Error ? err : new DreameTransportError(String(err)));
869
- this.#scheduleReconnect();
870
- });
901
+ void this.#attemptReconnect();
871
902
  }, this.#backoffMs);
872
903
  }
904
+ /**
905
+ * One reconnect attempt with reactive auth self-heal. If the broker refuses
906
+ * the CONNECT with an auth error (a stale access token) and an
907
+ * `onAuthFailure` provider is wired and the re-auth budget is not exhausted,
908
+ * mint a fresh session and retry with the new token instead of replaying the
909
+ * old one. Any other failure (or an exhausted budget) falls back to the plain
910
+ * backoff reconnect so bad credentials cannot hot-loop re-login.
911
+ */
912
+ async #attemptReconnect() {
913
+ try {
914
+ await this.#connectAndSubscribe();
915
+ } catch (err) {
916
+ if (this.#closed) {
917
+ return;
918
+ }
919
+ if (this.#onAuthFailure && isAuthRefusedError(err) && this.#authFailureCount < this.#maxAuthRetries) {
920
+ await this.#reauthenticateAndReconnect();
921
+ return;
922
+ }
923
+ this.emit("error", err instanceof Error ? err : new DreameTransportError(String(err)));
924
+ this.#scheduleReconnect();
925
+ }
926
+ }
927
+ /** Mint a fresh session via `onAuthFailure`, adopt its token, and reconnect. */
928
+ async #reauthenticateAndReconnect() {
929
+ this.#authFailureCount += 1;
930
+ let session;
931
+ try {
932
+ session = await this.#onAuthFailure();
933
+ } catch (err) {
934
+ this.emit("error", err instanceof Error ? err : new DreameTransportError(String(err)));
935
+ if (!this.#client) {
936
+ this.#scheduleReconnect();
937
+ }
938
+ return;
939
+ }
940
+ if (this.#closed) {
941
+ return;
942
+ }
943
+ this.#session = session;
944
+ this.#topic = buildStatusTopic(this.#device, this.#session.uid, this.#region);
945
+ if (this.#client) {
946
+ return;
947
+ }
948
+ void this.#attemptReconnect();
949
+ }
873
950
  async #teardownClient() {
874
951
  const client = this.#client;
875
952
  this.#client = null;
@@ -1045,7 +1122,12 @@ function resolveCapabilities(model) {
1045
1122
  // src/device/base-device.ts
1046
1123
  function defaultBaseDeviceDeps() {
1047
1124
  return {
1048
- createPush: (device, session, region) => new DreamePush({ device, session, region }),
1125
+ createPush: (device, session, region, onAuthFailure) => new DreamePush({
1126
+ device,
1127
+ session,
1128
+ region,
1129
+ ...onAuthFailure !== void 0 ? { onAuthFailure } : {}
1130
+ }),
1049
1131
  getProperties: (base, props) => getProperties(base, props),
1050
1132
  getCachedProperties: (base, props) => getCachedProperties(base, props),
1051
1133
  setProperties: (base, writes) => setProperties(base, writes),
@@ -1057,6 +1139,7 @@ var BaseDevice = class extends TypedEmitter {
1057
1139
  #device;
1058
1140
  #region;
1059
1141
  #sessionRef;
1142
+ #onAuthFailure;
1060
1143
  #deps;
1061
1144
  #fetchInitial;
1062
1145
  #initialProps;
@@ -1072,6 +1155,7 @@ var BaseDevice = class extends TypedEmitter {
1072
1155
  this.#device = input.device;
1073
1156
  this.#region = input.region;
1074
1157
  this.#sessionRef = input.sessionRef;
1158
+ this.#onAuthFailure = input.onAuthFailure;
1075
1159
  this.#deps = input.deps ?? defaultBaseDeviceDeps();
1076
1160
  this.#fetchInitial = input.fetchInitialValues ?? true;
1077
1161
  this.#initialProps = input.initialProps ?? [];
@@ -1111,7 +1195,12 @@ var BaseDevice = class extends TypedEmitter {
1111
1195
  }
1112
1196
  /** Open the push, wire events, optionally seed the cache. */
1113
1197
  async start() {
1114
- const push = this.#deps.createPush(this.#device, this.#sessionRef(), this.#region);
1198
+ const push = this.#deps.createPush(
1199
+ this.#device,
1200
+ this.#sessionRef(),
1201
+ this.#region,
1202
+ this.#onAuthFailure
1203
+ );
1115
1204
  this.#push = push;
1116
1205
  push.on("properties", (changes) => this.#onProperties(changes));
1117
1206
  push.on(
@@ -5525,6 +5614,7 @@ function defaultDeps(opts) {
5525
5614
  device: args.device,
5526
5615
  region: args.region,
5527
5616
  sessionRef: args.sessionRef,
5617
+ ...args.onAuthFailure !== void 0 ? { onAuthFailure: args.onAuthFailure } : {},
5528
5618
  ...opts.fetchInitialValues !== void 0 ? { fetchInitialValues: opts.fetchInitialValues } : {},
5529
5619
  ...opts.pollIntervalMs !== void 0 ? { pollIntervalMs: opts.pollIntervalMs } : {}
5530
5620
  });
@@ -5538,6 +5628,15 @@ var Nodreame = class extends TypedEmitter {
5538
5628
  #session = null;
5539
5629
  #devices = [];
5540
5630
  #closed = false;
5631
+ /** Background timer that proactively refreshes before the token expires. */
5632
+ #refreshTimer = null;
5633
+ /** Guard against overlapping proactive-refresh runs. */
5634
+ #refreshInFlight = false;
5635
+ /**
5636
+ * The single in-flight refresh, shared by concurrent callers so the proactive
5637
+ * timer and a manual `ensureSession()`/`reauthenticate()` never double-refresh.
5638
+ */
5639
+ #inFlightRefresh = null;
5541
5640
  constructor(opts, deps) {
5542
5641
  super();
5543
5642
  if (!opts.username || !opts.password) {
@@ -5566,6 +5665,7 @@ var Nodreame = class extends TypedEmitter {
5566
5665
  ...this.#opts.lang !== void 0 ? { lang: this.#opts.lang } : {},
5567
5666
  ...this.#opts.fetchImpl !== void 0 ? { fetchImpl: this.#opts.fetchImpl } : {}
5568
5667
  });
5668
+ this.#scheduleProactiveRefresh();
5569
5669
  return this.#session;
5570
5670
  }
5571
5671
  /** Return a valid session, refreshing proactively within the leeway window. */
@@ -5577,18 +5677,61 @@ var Nodreame = class extends TypedEmitter {
5577
5677
  if (Date.now() < current.expiresAt - this.#leewayMs) {
5578
5678
  return current;
5579
5679
  }
5680
+ return this.#refreshNow(current);
5681
+ }
5682
+ /**
5683
+ * Force a session refresh REGARDLESS of the current expiry, then propagate the
5684
+ * new token to every live device push. Wired as each push's `onAuthFailure`:
5685
+ * when a broker refuses a CONNECT because it considers the token stale (even
5686
+ * though {@link ensureSession} still thinks it valid — e.g. server-side early
5687
+ * revocation or clock skew), this mints a genuinely fresh token instead of
5688
+ * replaying the rejected one.
5689
+ */
5690
+ async reauthenticate() {
5691
+ if (this.#closed) {
5692
+ throw new DreameAuthError("client is closed");
5693
+ }
5694
+ const current = this.#session;
5695
+ if (!current) {
5696
+ return this.login();
5697
+ }
5698
+ return this.#refreshNow(current);
5699
+ }
5700
+ /**
5701
+ * Refresh via the refresh-token (with a full re-login fallback) and propagate
5702
+ * the new token to every device push. Shared by {@link ensureSession} (within
5703
+ * the leeway window) and {@link reauthenticate} (unconditional).
5704
+ */
5705
+ async #refreshNow(current) {
5706
+ const existing = this.#inFlightRefresh;
5707
+ if (existing) {
5708
+ return existing;
5709
+ }
5710
+ const run = this.#doRefresh(current);
5711
+ this.#inFlightRefresh = run;
5712
+ try {
5713
+ return await run;
5714
+ } finally {
5715
+ this.#inFlightRefresh = null;
5716
+ }
5717
+ }
5718
+ async #doRefresh(current) {
5580
5719
  if (current.refreshToken) {
5720
+ let next = null;
5581
5721
  try {
5582
- const next = await this.#deps.refresh({
5722
+ next = await this.#deps.refresh({
5583
5723
  refreshToken: current.refreshToken,
5584
5724
  region: this.#opts.region,
5585
5725
  ...this.#opts.country !== void 0 ? { country: this.#opts.country } : {},
5586
5726
  ...this.#opts.lang !== void 0 ? { lang: this.#opts.lang } : {},
5587
5727
  ...this.#opts.fetchImpl !== void 0 ? { fetchImpl: this.#opts.fetchImpl } : {}
5588
5728
  });
5729
+ } catch {
5730
+ next = null;
5731
+ }
5732
+ if (next) {
5589
5733
  await this.#adoptSession(next);
5590
5734
  return next;
5591
- } catch {
5592
5735
  }
5593
5736
  }
5594
5737
  const fresh = await this.login();
@@ -5610,23 +5753,30 @@ var Nodreame = class extends TypedEmitter {
5610
5753
  (device) => this.#deps.createDevice({
5611
5754
  device,
5612
5755
  region: this.#opts.region,
5613
- sessionRef: () => this.#requireSession()
5756
+ sessionRef: () => this.#requireSession(),
5757
+ onAuthFailure: () => this.reauthenticate()
5614
5758
  })
5615
5759
  );
5616
5760
  for (const h of handles) {
5617
5761
  h.on("stateChanged", (e) => this.emit("stateChanged", e));
5618
5762
  h.on("event", (e) => this.emit("event", e));
5619
- h.on("error", (err) => this.emit("error", err));
5763
+ h.on("error", (err) => {
5764
+ if (this.listenerCount("error") > 0) {
5765
+ this.emit("error", err);
5766
+ }
5767
+ });
5620
5768
  await h.start();
5621
5769
  }
5622
5770
  const previous = this.#devices;
5623
5771
  this.#devices = [...handles];
5624
5772
  await Promise.all(previous.map((d) => d.close()));
5773
+ this.#scheduleProactiveRefresh();
5625
5774
  return this.#devices;
5626
5775
  }
5627
5776
  /** Tear everything down: close every device push and clear timers. */
5628
5777
  async close() {
5629
5778
  this.#closed = true;
5779
+ this.#clearRefreshTimer();
5630
5780
  const devices = this.#devices;
5631
5781
  this.#devices = [];
5632
5782
  await Promise.all(devices.map((d) => d.close()));
@@ -5641,13 +5791,65 @@ var Nodreame = class extends TypedEmitter {
5641
5791
  async #adoptSession(session) {
5642
5792
  this.#session = session;
5643
5793
  await this.#propagateSession(session);
5794
+ this.#scheduleProactiveRefresh();
5795
+ }
5796
+ #clearRefreshTimer() {
5797
+ if (this.#refreshTimer) {
5798
+ clearTimeout(this.#refreshTimer);
5799
+ this.#refreshTimer = null;
5800
+ }
5801
+ }
5802
+ /** Arm the proactive-refresh timer `delayMs` from now (clamped to ≥0). */
5803
+ #armRefreshTimer(delayMs) {
5804
+ this.#clearRefreshTimer();
5805
+ if (this.#closed) {
5806
+ return;
5807
+ }
5808
+ this.#refreshTimer = setTimeout(() => {
5809
+ this.#refreshTimer = null;
5810
+ void this.#runProactiveRefresh();
5811
+ }, Math.max(0, delayMs));
5812
+ }
5813
+ /** (Re)arm the proactive-refresh timer against the current session's expiry. */
5814
+ #scheduleProactiveRefresh() {
5815
+ const session = this.#session;
5816
+ if (!session) {
5817
+ this.#clearRefreshTimer();
5818
+ return;
5819
+ }
5820
+ this.#armRefreshTimer(session.expiresAt - this.#leewayMs - Date.now());
5821
+ }
5822
+ /**
5823
+ * Timer body: refresh the session before it expires, then reschedule against
5824
+ * the new expiry. Because {@link ensureSession} propagates the refreshed token
5825
+ * to every device push, each MQTT connection is re-keyed BEFORE the broker
5826
+ * would reject the old one — the reactive re-auth loop never even starts.
5827
+ */
5828
+ async #runProactiveRefresh() {
5829
+ if (this.#closed || this.#refreshInFlight) {
5830
+ return;
5831
+ }
5832
+ this.#refreshInFlight = true;
5833
+ try {
5834
+ await this.ensureSession();
5835
+ this.#refreshInFlight = false;
5836
+ this.#scheduleProactiveRefresh();
5837
+ } catch (err) {
5838
+ this.#refreshInFlight = false;
5839
+ if (this.listenerCount("error") > 0) {
5840
+ this.emit("error", err instanceof Error ? err : new Error(String(err)));
5841
+ }
5842
+ if (!this.#closed) {
5843
+ this.#armRefreshTimer(this.#leewayMs);
5844
+ }
5845
+ }
5644
5846
  }
5645
5847
  /** Push the refreshed token to every live device push. */
5646
5848
  async #propagateSession(session) {
5647
5849
  if (this.#closed) {
5648
5850
  return;
5649
5851
  }
5650
- await Promise.all(this.#devices.map((d) => d.applySession(session)));
5852
+ await Promise.allSettled(this.#devices.map((d) => d.applySession(session)));
5651
5853
  }
5652
5854
  };
5653
5855
 
@@ -6150,6 +6352,7 @@ function createClientDumper(client, options) {
6150
6352
  extractMowerConsumableValues,
6151
6353
  getMowerCapabilities,
6152
6354
  getVacuumCapabilities,
6355
+ isAuthRefusedError,
6153
6356
  isDreameConsumableKey,
6154
6357
  mowerConsumableIndex,
6155
6358
  mowerFaultSeverity,