@mega-yfue/eufy-sdk 0.2.0-beta.20 → 0.2.0-beta.21

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.
@@ -5,7 +5,7 @@
5
5
  * `interface EufyMega` (the typed on/once/off/emit overloads) stays in `eufy-mega.ts` next to the
6
6
  * class — TS declaration merging requires both in the same module.
7
7
  */
8
- import type { MegaClientConfig } from "../transport/http/mega-client.js";
8
+ import type { MegaClientConfig, SessionExpiredError } from "../transport/http/mega-client.js";
9
9
  import type { FcmStore } from "../transport/push/store.js";
10
10
  import type { FfmpegLevel } from "../transport/ffmpeg.js";
11
11
  import type { DeviceEventMap } from "../model/capabilities/index.js";
@@ -375,8 +375,12 @@ export type EufyMegaEventMap = {
375
375
  * token expired. The SDK has already cleared the persisted session, so recovery is a fresh `login()`
376
376
  * (which usually needs 2FA). Distinct from `error`: a session error is emitted ONLY here, not also
377
377
  * on `error`.
378
+ *
379
+ * The error carries the rate: `err.retryAfterMs` is how long the next session replacement is barred
380
+ * for, and `err.contended` says this session is being displaced by another client rather than expiring
381
+ * — which a re-login does not answer. A login made before that wait elapses extends it.
378
382
  */
379
- sessionExpired: [err: Error];
383
+ sessionExpired: [err: SessionExpiredError];
380
384
  error: [err: Error];
381
385
  };
382
386
  /** Event names {@link EufyMega} can emit. */
package/dist/index.js CHANGED
@@ -1092,9 +1092,25 @@ var MegaApiError = class extends Error {
1092
1092
  };
1093
1093
  var OWNER_ONLY_CODE = 20004;
1094
1094
  var SessionExpiredError = class extends Error {
1095
- constructor(message) {
1095
+ /**
1096
+ * How long the next session replacement is barred for, in milliseconds; `0` when nothing bars one now.
1097
+ *
1098
+ * The remainder of the client's own hold-off, which doubles per consecutive replacement and is capped —
1099
+ * and which every replacement extends, whether the client spent it or a login made on this error did.
1100
+ */
1101
+ retryAfterMs;
1102
+ /**
1103
+ * Whether this rejection landed inside that bar — a token replaced recently and rejected again since.
1104
+ *
1105
+ * It says the session is being DISPLACED rather than expiring: something else is signing in on this
1106
+ * account, and replacing the token again only trades one login for another.
1107
+ */
1108
+ contended;
1109
+ constructor(message, opts = {}) {
1096
1110
  super(message);
1097
1111
  this.name = "SessionExpiredError";
1112
+ this.retryAfterMs = opts.retryAfterMs ?? 0;
1113
+ this.contended = opts.contended ?? false;
1098
1114
  }
1099
1115
  };
1100
1116
  var EufyCloudErrorCode = {
@@ -1133,7 +1149,7 @@ var LoginStatus = {
1133
1149
  var REAUTH_HOLD_OFF_MS = 6e4;
1134
1150
  var REAUTH_HOLD_OFF_CAP_MS = 30 * 6e4;
1135
1151
  var REAUTH_STABLE_MS = 10 * 6e4;
1136
- var CONTENDED_SESSION_HINT = "another client may be signed in with the same account and device identity, and each login displaces the other's session; give each client its own openudid";
1152
+ var CONTENDED_SESSION_HINT = "another client signed in on this account keeps displacing this session \u2014 the cloud holds about one session per account and device identity, so each login ends the other's; where the other client is another SDK install, give each its own openudid";
1137
1153
  function tokenRejected(code, msg) {
1138
1154
  return code === EufyCloudErrorCode.SESSION_KICKED || /user_id is empty|invalid[^,]*\btoken\b|\btoken\b[^,]*(expired|error|not exist)|kicked|(?:\btoken\b|\bsession\b)[^,]*does not exist|unauthor/i.test(msg ?? "");
1139
1155
  }
@@ -1183,9 +1199,11 @@ var MegaHttpClient = class {
1183
1199
  loggingIn = false;
1184
1200
  /** The one in-flight re-login every call rejected on the same dead token waits on. */
1185
1201
  reauthAttempt;
1186
- /** Replacements since the held session last proved stable, and when the last one ran — see {@link recoveryDue}. */
1202
+ /** Replacements since the held session last proved stable, and when the last one ran — see {@link holdOffRemainingMs}. */
1187
1203
  recoveries = 0;
1188
1204
  lastRecoveryAt = 0;
1205
+ /** A token of ours has been rejected and not yet replaced — see {@link noteTokenReplacement}. */
1206
+ rejectedTokenPending = false;
1189
1207
  constructor(cfg) {
1190
1208
  this.cfg = {
1191
1209
  appName: "eufy_mega",
@@ -1468,12 +1486,17 @@ var MegaHttpClient = class {
1468
1486
  const tokenFinished = identityError && retry.identity || authed && last?.status === 401 && tokenRejected(last.code, last.msg);
1469
1487
  if (tokenFinished) {
1470
1488
  const reason = `${path} failed (${last?.status}/${last?.code}): ${last?.msg}`;
1489
+ this.rejectedTokenPending = true;
1471
1490
  const recovery = retry.reauth ? "unrecoverable" : await this.recoverRejectedSession(sentToken);
1472
1491
  if (recovery === "retry")
1473
1492
  return this.postSigned(host, path, body, authed, { ...retry, reauth: true }, headerOverrides);
1474
1493
  if (!this.sessionReplacedSince(sentToken))
1475
1494
  this.clearSession();
1476
- throw new SessionExpiredError(recovery === "held-off" ? `${reason} \u2014 ${CONTENDED_SESSION_HINT}` : reason);
1495
+ const contended = recovery === "held-off";
1496
+ throw new SessionExpiredError(contended ? `${reason} \u2014 ${CONTENDED_SESSION_HINT}` : reason, {
1497
+ retryAfterMs: this.holdOffRemainingMs(),
1498
+ contended
1499
+ });
1477
1500
  }
1478
1501
  throw new MegaApiError(`${path} failed (${last?.status}/${last?.code}): ${last?.msg}`, last?.code, last?.status);
1479
1502
  }
@@ -1798,8 +1821,7 @@ var MegaHttpClient = class {
1798
1821
  return "unrecoverable";
1799
1822
  if (!this.recoveryDue())
1800
1823
  return "held-off";
1801
- this.recoveries++;
1802
- this.lastRecoveryAt = Date.now();
1824
+ this.noteTokenReplacement();
1803
1825
  this.clearSession();
1804
1826
  return await this.reauthenticate() ? "retry" : "unrecoverable";
1805
1827
  }
@@ -1814,20 +1836,47 @@ var MegaHttpClient = class {
1814
1836
  * and a caller is told the honest reason instead of being served a fight.
1815
1837
  */
1816
1838
  recoveryDue() {
1817
- if (this.recoveries === 0)
1839
+ const remaining = this.holdOffRemainingMs();
1840
+ if (remaining === 0)
1818
1841
  return true;
1819
- const wait = Math.min(REAUTH_HOLD_OFF_MS * 2 ** (this.recoveries - 1), REAUTH_HOLD_OFF_CAP_MS);
1820
- const waited = Date.now() - this.lastRecoveryAt;
1821
- if (waited >= wait)
1822
- return true;
1823
- this.logger.warn(`[mega] token rejected ${Math.round(waited / 1e3)}s after the last replacement \u2014 holding off ${Math.round(wait / 1e3)}s. ${CONTENDED_SESSION_HINT}`);
1842
+ this.logger.warn(`[mega] token rejected ${Math.round((Date.now() - this.lastRecoveryAt) / 1e3)}s after the last replacement \u2014 holding off ${Math.round(remaining / 1e3)}s more. ${CONTENDED_SESSION_HINT}`);
1824
1843
  return false;
1825
1844
  }
1845
+ /**
1846
+ * How much longer a token replacement must wait, in milliseconds; `0` when one may run now.
1847
+ *
1848
+ * The wait doubles per consecutive replacement and is capped, and it is what {@link recoveryDue} gates
1849
+ * this client's own recovery on — and what {@link SessionExpiredError.retryAfterMs} hands a host that
1850
+ * drives its own. One function so the two cannot disagree about the rate, which they would have to for
1851
+ * a host to be told it may retry while this client is still holding off.
1852
+ */
1853
+ holdOffRemainingMs() {
1854
+ if (this.recoveries === 0)
1855
+ return 0;
1856
+ const wait = Math.min(REAUTH_HOLD_OFF_MS * 2 ** (this.recoveries - 1), REAUTH_HOLD_OFF_CAP_MS);
1857
+ return Math.max(0, wait - (Date.now() - this.lastRecoveryAt));
1858
+ }
1859
+ /**
1860
+ * Count one token replacement against the hold-off, and clear the rejection it answered.
1861
+ *
1862
+ * Every replacement passes through here, wherever it was spent from: {@link recoverRejectedSession}, and
1863
+ * a {@link login} that follows a rejection this client surfaced. A hold-off that counted only its own
1864
+ * would be no bound at all — the wait would sit at its first value however many sessions had been spent,
1865
+ * and {@link SessionExpiredError.retryAfterMs} would report a minute while logins ran every few seconds.
1866
+ * Which of the two counted a given replacement is the flag: the recovery path clears it before logging
1867
+ * in, so the login cannot count the same one again.
1868
+ */
1869
+ noteTokenReplacement() {
1870
+ this.recoveries++;
1871
+ this.lastRecoveryAt = Date.now();
1872
+ this.rejectedTokenPending = false;
1873
+ }
1826
1874
  /**
1827
1875
  * Note that the held session is working. A replacement that keeps serving calls for long enough is not
1828
1876
  * contention, so the hold-off is forgotten and the next genuine expiry recovers immediately.
1829
1877
  */
1830
1878
  noteSessionWorking() {
1879
+ this.rejectedTokenPending = false;
1831
1880
  if (this.recoveries > 0 && Date.now() - this.lastRecoveryAt > REAUTH_STABLE_MS)
1832
1881
  this.recoveries = 0;
1833
1882
  }
@@ -1944,6 +1993,8 @@ var MegaHttpClient = class {
1944
1993
  this.sessionKey = void 0;
1945
1994
  await this.ensureSessionKey();
1946
1995
  this.persist();
1996
+ if (this.rejectedTokenPending)
1997
+ this.noteTokenReplacement();
1947
1998
  return { status: LoginStatus.Ok, session: { userId, authToken, geoKey: this.auth_.geoKey, raw: res } };
1948
1999
  }
1949
2000
  /** Save the current token + session key for reuse across runs. */