homey-api 3.17.5 → 3.17.6

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.
@@ -6,6 +6,16 @@ const Util = require('../../Util');
6
6
  const HomeyAPIError = require('../HomeyAPIError');
7
7
 
8
8
  const SOCKET_SESSION_NOT_READY_CODE = 'ERR_SOCKET_SESSION_NOT_READY';
9
+ const SERVER_DISCONNECT_REASON = 'io server disconnect';
10
+ const NAMESPACE_STABILITY_TIMEOUT = 25;
11
+ const PHASES = {
12
+ IDLE: 'idle',
13
+ CONNECTING: 'connecting',
14
+ HANDSHAKING: 'handshaking',
15
+ READY: 'ready',
16
+ DISCONNECTING: 'disconnecting',
17
+ DESTROYED: 'destroyed',
18
+ };
9
19
 
10
20
  class SocketSession extends EventEmitter {
11
21
  static NOT_READY_CODE = SOCKET_SESSION_NOT_READY_CODE;
@@ -13,8 +23,11 @@ class SocketSession extends EventEmitter {
13
23
  constructor(homey) {
14
24
  super();
15
25
 
26
+ // The root socket owns transport connectivity. Each ready cycle then
27
+ // handshakes a disposable Homey namespace socket, while higher layers
28
+ // restore URI subscriptions against the current namespace revision.
16
29
  this.homey = homey;
17
- this.__phase = 'idle';
30
+ this.__phase = PHASES.IDLE;
18
31
  this.__socket = null;
19
32
  this.__homeySocket = null;
20
33
  this.__boundHomeySocket = null;
@@ -67,7 +80,7 @@ class SocketSession extends EventEmitter {
67
80
  const abortController = new AbortController();
68
81
  this.__readyAbortController = abortController;
69
82
 
70
- const readyPromise = Promise.resolve().then(async () => {
83
+ const readyPromise = (async () => {
71
84
  if (this.__destroyed) {
72
85
  throw this.__createAbortError('Socket session destroyed.');
73
86
  }
@@ -76,7 +89,7 @@ class SocketSession extends EventEmitter {
76
89
  throw this.__createAbortError(abortController.signal.reason);
77
90
  }
78
91
 
79
- this.__setPhase('connecting');
92
+ this.__setPhase(PHASES.CONNECTING);
80
93
 
81
94
  const baseUrl = await this.homey.baseUrl;
82
95
 
@@ -90,12 +103,12 @@ class SocketSession extends EventEmitter {
90
103
 
91
104
  await this.__waitForSocketConnect(abortController.signal);
92
105
 
93
- this.__setPhase('handshaking');
106
+ this.__setPhase(PHASES.HANDSHAKING);
94
107
  const namespace = await this.__handshakeClient(abortController.signal);
95
108
  await this.__connectHomeySocket(namespace, abortController.signal);
96
109
 
97
110
  this.__readyRevision += 1;
98
- this.__setPhase('ready');
111
+ this.__setPhase(PHASES.READY);
99
112
 
100
113
  const readyState = {
101
114
  ...this.__getReadyState(),
@@ -105,7 +118,7 @@ class SocketSession extends EventEmitter {
105
118
  this.emit('ready', readyState);
106
119
 
107
120
  return readyState;
108
- });
121
+ })();
109
122
 
110
123
  this.__readyPromise = readyPromise;
111
124
 
@@ -118,7 +131,7 @@ class SocketSession extends EventEmitter {
118
131
  }
119
132
 
120
133
  if (!this.isConnected() && !this.__destroyed && !this.__closing) {
121
- this.__setPhase('idle');
134
+ this.__setPhase(PHASES.IDLE);
122
135
  }
123
136
  })
124
137
  .finally(() => {
@@ -187,7 +200,7 @@ class SocketSession extends EventEmitter {
187
200
  this.__closing = true;
188
201
  this.__cancelReadyCycle('Socket session disconnected.');
189
202
  const shouldEmitDisconnect = this.isConnected();
190
- this.__setPhase('disconnecting');
203
+ this.__setPhase(PHASES.DISCONNECTING);
191
204
 
192
205
  const homeySocket = this.__homeySocket;
193
206
  if (homeySocket) {
@@ -222,7 +235,7 @@ class SocketSession extends EventEmitter {
222
235
  }
223
236
 
224
237
  this.__closing = false;
225
- this.__setPhase('idle');
238
+ this.__setPhase(PHASES.IDLE);
226
239
  }
227
240
 
228
241
  destroy() {
@@ -246,7 +259,7 @@ class SocketSession extends EventEmitter {
246
259
  this.__socket = null;
247
260
  }
248
261
 
249
- this.__setPhase('destroyed');
262
+ this.__setPhase(PHASES.DESTROYED);
250
263
  this.removeAllListeners();
251
264
  }
252
265
 
@@ -326,14 +339,29 @@ class SocketSession extends EventEmitter {
326
339
  }
327
340
 
328
341
  __handleSessionDisconnect(reason) {
329
- const shouldEmitDisconnect = this.__phase === 'ready' || this.isConnected();
342
+ const shouldEmitDisconnect = this.__phase === PHASES.READY || this.isConnected();
330
343
 
331
344
  this.__cancelReadyCycle('Socket disconnected.');
332
- this.__setPhase(this.__closing ? 'disconnecting' : 'idle');
345
+ this.__setPhase(this.__closing ? PHASES.DISCONNECTING : PHASES.IDLE);
333
346
 
334
347
  if (shouldEmitDisconnect) {
335
348
  this.emit('disconnect', reason);
336
349
  }
350
+
351
+ if (this.__homeySocket && !this.__homeySocket.connected) {
352
+ this.__disposeStaleHomeySocket();
353
+ }
354
+
355
+ if (
356
+ reason === SERVER_DISCONNECT_REASON
357
+ && this.homey.__reconnect
358
+ && !this.__destroyed
359
+ && !this.__closing
360
+ && this.__socket
361
+ && !this.__socket.connected
362
+ ) {
363
+ this.__socket.connect();
364
+ }
337
365
  }
338
366
 
339
367
  async __waitForSocketConnect(signal) {
@@ -427,6 +455,11 @@ class SocketSession extends EventEmitter {
427
455
  this.__homeySocket.open();
428
456
  },
429
457
  });
458
+ // Homey can accept the /api namespace and then tear it down immediately
459
+ // during socket registration. Wait briefly to confirm /api stays connected
460
+ // before treating the session as ready, so that failed cycles reject
461
+ // connect()/reconnect instead of reporting a false success.
462
+ await this.__waitForNamespaceStability(this.__homeySocket, signal);
430
463
 
431
464
  return this.__homeySocket;
432
465
  }
@@ -578,6 +611,67 @@ class SocketSession extends EventEmitter {
578
611
  }
579
612
  }
580
613
 
614
+ // Wait one short grace window after /api connects so the session is only
615
+ // reported as ready once the namespace has stayed connected briefly.
616
+ async __waitForNamespaceStability(homeySocket, signal) {
617
+ await new Promise((resolve, reject) => {
618
+ const onAbort = () => {
619
+ cleanup();
620
+ reject(this.__createAbortError(signal.reason));
621
+ };
622
+
623
+ const onDisconnect = (reason) => {
624
+ cleanup();
625
+ reject(this.__createNotReadyError(reason));
626
+ };
627
+
628
+ const timer = setTimeout(() => {
629
+ cleanup();
630
+
631
+ if (homeySocket.connected) {
632
+ resolve();
633
+ return;
634
+ }
635
+
636
+ reject(this.__createNotReadyError());
637
+ }, NAMESPACE_STABILITY_TIMEOUT);
638
+
639
+ const cleanup = () => {
640
+ clearTimeout(timer);
641
+ homeySocket.removeListener('disconnect', onDisconnect);
642
+
643
+ if (signal) {
644
+ signal.removeEventListener('abort', onAbort);
645
+ }
646
+ };
647
+
648
+ homeySocket.once('disconnect', onDisconnect);
649
+
650
+ if (signal) {
651
+ if (signal.aborted) {
652
+ cleanup();
653
+ reject(this.__createAbortError(signal.reason));
654
+ return;
655
+ }
656
+
657
+ signal.addEventListener('abort', onAbort, { once: true });
658
+ }
659
+ });
660
+ }
661
+
662
+ __disposeStaleHomeySocket() {
663
+ const homeySocket = this.__homeySocket;
664
+ if (!homeySocket) return;
665
+
666
+ this.__homeySocket = null;
667
+ this.__unbindHomeySocket();
668
+
669
+ // Disconnect the stale namespace socket so it cannot auto-open on the next
670
+ // transport connect before ensureConnected() installs its connect/error
671
+ // listeners for the new reconnect attempt.
672
+ homeySocket.disconnect();
673
+ }
674
+
581
675
  __createAbortError(message) {
582
676
  const err = new Error(message || 'Socket session aborted.');
583
677
  err.name = 'AbortError';
@@ -6,6 +6,10 @@ class SubscriptionRegistry {
6
6
  constructor({ homey, session }) {
7
7
  this.homey = homey;
8
8
  this.session = session;
9
+ // Each URI entry represents one wire-level subscription for the current
10
+ // session revision. Multiple local consumers can share that single server
11
+ // subscription, and needsReconnect means the consumer state still exists
12
+ // locally while the server-side subscription must be restored.
9
13
  this.__entries = new Map();
10
14
  this.__destroyed = false;
11
15
 
@@ -127,7 +131,7 @@ class SubscriptionRegistry {
127
131
  return entry.subscribePromise;
128
132
  }
129
133
 
130
- const subscribePromise = Promise.resolve().then(async () => {
134
+ const subscribePromise = (async () => {
131
135
  const { homeySocket, revision } = await this.session.ensureConnected();
132
136
 
133
137
  if (entry.removed || entry.consumers.size === 0) {
@@ -151,7 +155,7 @@ class SubscriptionRegistry {
151
155
  entry.subscribedRevision = revision;
152
156
  entry.pendingRevision = 0;
153
157
  entry.hasConnected = true;
154
- });
158
+ })();
155
159
 
156
160
  entry.subscribePromise = subscribePromise;
157
161
 
@@ -232,10 +236,9 @@ class SubscriptionRegistry {
232
236
  return;
233
237
  }
234
238
 
235
- // Socket.io can report a transport reconnect before the Homey namespace has
236
- // been re-handshaked. We allow both reconnect and ready to resume entries;
237
- // __resumeEntry() deduplicates the work while ready is the point where the
238
- // current namespace socket can be rebound safely.
239
+ // The transport can reconnect before the current Homey namespace is ready.
240
+ // Listen to both reconnect and ready so entries resume promptly, while
241
+ // ready is the point where the active namespace socket can be rebound.
239
242
  for (const entry of this.__entries.values()) {
240
243
  if (entry.consumers.size === 0 || !entry.needsReconnect) {
241
244
  continue;
@@ -271,8 +274,8 @@ class SubscriptionRegistry {
271
274
  return entry.reconnectPromise;
272
275
  }
273
276
 
274
- const reconnectPromise = Promise.resolve()
275
- .then(async () => {
277
+ const reconnectPromise = (async () => {
278
+ try {
276
279
  await this.__ensureEntrySubscribed(entry);
277
280
 
278
281
  if (
@@ -285,22 +288,23 @@ class SubscriptionRegistry {
285
288
 
286
289
  entry.needsReconnect = false;
287
290
  this.__notifyConsumers(entry, 'onReconnect');
288
- })
289
- .catch((err) => {
291
+ } catch (err) {
290
292
  if (entry.removed || entry.consumers.size === 0) {
291
293
  return;
292
294
  }
293
295
 
294
296
  this.__notifyConsumers(entry, 'onReconnectError', err);
295
- })
296
- .finally(() => {
297
- if (entry.reconnectPromise === reconnectPromise) {
298
- entry.reconnectPromise = null;
299
- }
300
- });
297
+ }
298
+ })();
301
299
 
302
300
  entry.reconnectPromise = reconnectPromise;
303
301
 
302
+ reconnectPromise.finally(() => {
303
+ if (entry.reconnectPromise === reconnectPromise) {
304
+ entry.reconnectPromise = null;
305
+ }
306
+ });
307
+
304
308
  return reconnectPromise;
305
309
  }
306
310
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "homey-api",
3
- "version": "3.17.5",
3
+ "version": "3.17.6",
4
4
  "description": "Homey API",
5
5
  "main": "index.js",
6
6
  "license": "SEE LICENSE",