@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 +215 -12
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +33 -2
- package/dist/index.d.ts +33 -2
- package/dist/index.js +214 -12
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
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
|
-
|
|
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.#
|
|
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({
|
|
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(
|
|
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
|
-
|
|
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) =>
|
|
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.
|
|
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,
|