homey-api 3.17.3 → 3.17.5

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.
@@ -0,0 +1,341 @@
1
+ 'use strict';
2
+
3
+ const Util = require('../../Util');
4
+
5
+ class SubscriptionRegistry {
6
+ constructor({ homey, session }) {
7
+ this.homey = homey;
8
+ this.session = session;
9
+ this.__entries = new Map();
10
+ this.__destroyed = false;
11
+
12
+ this.__onSessionDisconnect = this.__onSessionDisconnect.bind(this);
13
+ this.__onSessionReconnect = this.__onSessionReconnect.bind(this);
14
+ this.__onSessionReady = this.__onSessionReady.bind(this);
15
+ this.__onSessionReadyError = this.__onSessionReadyError.bind(this);
16
+
17
+ this.session.on('disconnect', this.__onSessionDisconnect);
18
+ this.session.on('reconnect', this.__onSessionReconnect);
19
+ this.session.on('ready', this.__onSessionReady);
20
+ this.session.on('ready_error', this.__onSessionReadyError);
21
+ }
22
+
23
+ async subscribe(
24
+ uri,
25
+ {
26
+ onConnect = () => {},
27
+ onReconnect = () => {},
28
+ onReconnectError = () => {},
29
+ onDisconnect = () => {},
30
+ onEvent = () => {},
31
+ }
32
+ ) {
33
+ if (this.__destroyed) {
34
+ throw new Error('Subscription registry destroyed.');
35
+ }
36
+
37
+ this.homey.__debug('subscribe', uri);
38
+
39
+ const entry = this.__getEntry(uri);
40
+ const consumer = {
41
+ active: true,
42
+ handlers: {
43
+ onConnect,
44
+ onReconnect,
45
+ onReconnectError,
46
+ onDisconnect,
47
+ onEvent,
48
+ },
49
+ };
50
+
51
+ entry.consumers.add(consumer);
52
+
53
+ try {
54
+ await this.__ensureEntrySubscribed(entry);
55
+
56
+ if (consumer.active) {
57
+ consumer.handlers.onConnect();
58
+ }
59
+ } catch (err) {
60
+ entry.consumers.delete(consumer);
61
+ this.__deleteEntryIfUnused(entry);
62
+ throw err;
63
+ }
64
+
65
+ return {
66
+ unsubscribe: () => {
67
+ if (!consumer.active) return;
68
+
69
+ consumer.active = false;
70
+ entry.consumers.delete(consumer);
71
+ this.__deleteEntryIfUnused(entry);
72
+ },
73
+ };
74
+ }
75
+
76
+ destroy() {
77
+ if (this.__destroyed) return;
78
+
79
+ this.__destroyed = true;
80
+
81
+ this.session.removeListener('disconnect', this.__onSessionDisconnect);
82
+ this.session.removeListener('reconnect', this.__onSessionReconnect);
83
+ this.session.removeListener('ready', this.__onSessionReady);
84
+ this.session.removeListener('ready_error', this.__onSessionReadyError);
85
+
86
+ for (const entry of this.__entries.values()) {
87
+ entry.consumers.clear();
88
+ this.__teardownEntry(entry);
89
+ }
90
+
91
+ this.__entries.clear();
92
+ }
93
+
94
+ __getEntry(uri) {
95
+ if (this.__entries.has(uri)) {
96
+ return this.__entries.get(uri);
97
+ }
98
+
99
+ const entry = {
100
+ uri,
101
+ consumers: new Set(),
102
+ eventSocket: null,
103
+ eventHandler: (event, data) => {
104
+ this.__notifyConsumers(entry, 'onEvent', event, data);
105
+ },
106
+ subscribePromise: null,
107
+ subscribedRevision: 0,
108
+ pendingRevision: 0,
109
+ needsReconnect: false,
110
+ hasConnected: false,
111
+ reconnectPromise: null,
112
+ removed: false,
113
+ };
114
+
115
+ this.__entries.set(uri, entry);
116
+ return entry;
117
+ }
118
+
119
+ async __ensureEntrySubscribed(entry) {
120
+ if (entry.removed || entry.consumers.size === 0) return;
121
+
122
+ if (entry.subscribedRevision === this.session.revision && this.session.isConnected()) {
123
+ return;
124
+ }
125
+
126
+ if (entry.subscribePromise) {
127
+ return entry.subscribePromise;
128
+ }
129
+
130
+ const subscribePromise = Promise.resolve().then(async () => {
131
+ const { homeySocket, revision } = await this.session.ensureConnected();
132
+
133
+ if (entry.removed || entry.consumers.size === 0) {
134
+ return;
135
+ }
136
+
137
+ if (entry.subscribedRevision === revision) {
138
+ this.__bindEventSocket(entry, homeySocket);
139
+ return;
140
+ }
141
+
142
+ entry.pendingRevision = revision;
143
+ this.__bindEventSocket(entry, homeySocket);
144
+ await this.__subscribeUri(entry, homeySocket);
145
+
146
+ if (entry.removed || entry.consumers.size === 0) {
147
+ homeySocket.emit('unsubscribe', entry.uri);
148
+ return;
149
+ }
150
+
151
+ entry.subscribedRevision = revision;
152
+ entry.pendingRevision = 0;
153
+ entry.hasConnected = true;
154
+ });
155
+
156
+ entry.subscribePromise = subscribePromise;
157
+
158
+ subscribePromise
159
+ .catch(() => {})
160
+ .finally(() => {
161
+ if (entry.subscribePromise === subscribePromise) {
162
+ entry.subscribePromise = null;
163
+ }
164
+ });
165
+
166
+ return subscribePromise;
167
+ }
168
+
169
+ async __subscribeUri(entry, homeySocket) {
170
+ await Util.timeout(
171
+ new Promise((resolve, reject) => {
172
+ const onDisconnect = (reason) => {
173
+ cleanup();
174
+ reject(new Error(reason));
175
+ };
176
+
177
+ const cleanup = () => {
178
+ homeySocket.removeListener('disconnect', onDisconnect);
179
+ };
180
+
181
+ homeySocket.once('disconnect', onDisconnect);
182
+ this.homey.__debug('subscribing', entry.uri);
183
+ homeySocket.emit('subscribe', entry.uri, (err) => {
184
+ cleanup();
185
+
186
+ if (err) {
187
+ this.homey.__debug('Failed to subscribe', entry.uri, err);
188
+ reject(err);
189
+ return;
190
+ }
191
+
192
+ this.homey.__debug('subscribed', entry.uri);
193
+ resolve();
194
+ });
195
+ }),
196
+ this.homey.constructor.SUBSCRIBE_TIMEOUT,
197
+ `Failed to subscribe to ${entry.uri} (Timeout after ${this.homey.constructor.SUBSCRIBE_TIMEOUT}ms).`
198
+ );
199
+ }
200
+
201
+ __bindEventSocket(entry, homeySocket) {
202
+ if (entry.eventSocket === homeySocket) return;
203
+
204
+ if (entry.eventSocket) {
205
+ entry.eventSocket.removeListener(entry.uri, entry.eventHandler);
206
+ }
207
+
208
+ entry.eventSocket = homeySocket;
209
+ entry.eventSocket.on(entry.uri, entry.eventHandler);
210
+ }
211
+
212
+ __onSessionDisconnect(reason) {
213
+ for (const entry of this.__entries.values()) {
214
+ if (!entry.hasConnected || entry.consumers.size === 0) {
215
+ continue;
216
+ }
217
+
218
+ entry.needsReconnect = true;
219
+ entry.subscribedRevision = 0;
220
+
221
+ if (entry.eventSocket) {
222
+ entry.eventSocket.removeListener(entry.uri, entry.eventHandler);
223
+ entry.eventSocket = null;
224
+ }
225
+
226
+ this.__notifyConsumers(entry, 'onDisconnect', reason);
227
+ }
228
+ }
229
+
230
+ __onSessionReady({ revision, homeySocket, isReconnect }) {
231
+ if (!isReconnect) {
232
+ return;
233
+ }
234
+
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
+ for (const entry of this.__entries.values()) {
240
+ if (entry.consumers.size === 0 || !entry.needsReconnect) {
241
+ continue;
242
+ }
243
+
244
+ this.__bindEventSocket(entry, homeySocket);
245
+ this.__resumeEntry(entry, revision);
246
+ }
247
+ }
248
+
249
+ __onSessionReconnect() {
250
+ for (const entry of this.__entries.values()) {
251
+ if (entry.consumers.size === 0 || !entry.needsReconnect) {
252
+ continue;
253
+ }
254
+
255
+ this.__resumeEntry(entry);
256
+ }
257
+ }
258
+
259
+ __onSessionReadyError(err) {
260
+ for (const entry of this.__entries.values()) {
261
+ if (!entry.needsReconnect || entry.consumers.size === 0 || entry.reconnectPromise) {
262
+ continue;
263
+ }
264
+
265
+ this.__notifyConsumers(entry, 'onReconnectError', err);
266
+ }
267
+ }
268
+
269
+ __resumeEntry(entry, revision = null) {
270
+ if (entry.reconnectPromise) {
271
+ return entry.reconnectPromise;
272
+ }
273
+
274
+ const reconnectPromise = Promise.resolve()
275
+ .then(async () => {
276
+ await this.__ensureEntrySubscribed(entry);
277
+
278
+ if (
279
+ entry.removed
280
+ || entry.consumers.size === 0
281
+ || (revision != null && entry.subscribedRevision !== revision)
282
+ ) {
283
+ return;
284
+ }
285
+
286
+ entry.needsReconnect = false;
287
+ this.__notifyConsumers(entry, 'onReconnect');
288
+ })
289
+ .catch((err) => {
290
+ if (entry.removed || entry.consumers.size === 0) {
291
+ return;
292
+ }
293
+
294
+ this.__notifyConsumers(entry, 'onReconnectError', err);
295
+ })
296
+ .finally(() => {
297
+ if (entry.reconnectPromise === reconnectPromise) {
298
+ entry.reconnectPromise = null;
299
+ }
300
+ });
301
+
302
+ entry.reconnectPromise = reconnectPromise;
303
+
304
+ return reconnectPromise;
305
+ }
306
+
307
+ __notifyConsumers(entry, event, ...args) {
308
+ for (const consumer of entry.consumers) {
309
+ if (!consumer.active) continue;
310
+ consumer.handlers[event](...args);
311
+ }
312
+ }
313
+
314
+ __deleteEntryIfUnused(entry) {
315
+ if (entry.consumers.size > 0) return;
316
+
317
+ entry.removed = true;
318
+ this.__entries.delete(entry.uri);
319
+
320
+ if (entry.eventSocket) {
321
+ entry.eventSocket.emit('unsubscribe', entry.uri);
322
+ } else if (this.session.homeySocket) {
323
+ this.session.homeySocket.emit('unsubscribe', entry.uri);
324
+ }
325
+
326
+ this.__teardownEntry(entry);
327
+ }
328
+
329
+ __teardownEntry(entry) {
330
+ if (entry.eventSocket) {
331
+ entry.eventSocket.removeListener(entry.uri, entry.eventHandler);
332
+ entry.eventSocket = null;
333
+ }
334
+
335
+ entry.pendingRevision = 0;
336
+ entry.subscribedRevision = 0;
337
+ entry.reconnectPromise = null;
338
+ }
339
+ }
340
+
341
+ module.exports = SubscriptionRegistry;