@superhero/eventflow-hub 4.0.2

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/LICENCE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2024 Erik Landvall
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,83 @@
1
+ # Eventflow Hub
2
+
3
+ Eventflow Hub is a central component of the Eventflow system that manages mTLS-secured connections between spokes and peer hubs, facilitating message publishing, subscription handling, and event scheduling.
4
+
5
+ > [!NOTE]
6
+ > This module is still under development and in activing testing.
7
+
8
+ ## Features
9
+
10
+ - **TLS-Secured Communication:** Ensures secure connections using certificates for both server and client communication.
11
+ - **Event Management:** Publishes and broadcasts events to subscribed spokes and peer hubs.
12
+ - **Subscriber Management:** Tracks spokes subscribing to specific events.
13
+ - **Scheduled Events:** Executes scheduled tasks with error handling and persistence.
14
+ - **Scalable Architecture:** Supports multi-hub communication for distributed systems.
15
+
16
+ ## Testing
17
+
18
+ Run the test suite using:
19
+
20
+ ```bash
21
+ npm run test-build
22
+ npm test
23
+ ```
24
+
25
+ ### Test Coverage
26
+
27
+ ```
28
+ ▶ @superhero/eventflow-hub
29
+ ▶ Lifecycle
30
+ ✔ Can initialize EventflowHub correctly (239.24859ms)
31
+ ✔ Lifecycle (240.434564ms)
32
+
33
+ ▶ Connections and Communication
34
+ ▶ Handles spoke connections
35
+ ✔ Broadcasts peer hub online event (6422.456966ms)
36
+ ✔ Handles spoke connections (12880.167996ms)
37
+ ✔ Connections and Communication (12880.634724ms)
38
+ ✔ @superhero/eventflow-hub/hub (13122.085188ms)
39
+
40
+ ▶ @superhero/eventflow-hub/manager/spokes
41
+ ✔ Add and retrieve all sockets (3.451128ms)
42
+ ✔ Delete a socket (0.227951ms)
43
+ ✔ Destroy all sockets (0.352411ms)
44
+ ✔ Handle deleting non-existent socket gracefully (0.275818ms)
45
+ ✔ Return empty array if no sockets exist (0.165647ms)
46
+ ✔ @superhero/eventflow-hub/manager/spokes (6.103143ms)
47
+
48
+ ▶ @superhero/eventflow-hub/manager/subscribers
49
+ ✔ Add and retrieve subscribers (2.384233ms)
50
+ ✔ Handle wildcard subscribers (0.308446ms)
51
+ ✔ Return empty array if no subscribers exist (0.152296ms)
52
+ ✔ Delete a subscriber and clean up empty structures (0.243432ms)
53
+ ✔ Clean up domain and event mappings after deletion (0.226052ms)
54
+ ✔ Not throw errors when deleting non-existent subscribers (0.362547ms)
55
+ ✔ @superhero/eventflow-hub/manager/subscribers (5.36466ms)
56
+
57
+ tests 14
58
+ suites 5
59
+ pass 14
60
+
61
+ -------------------------------------------------------------------------------------------------------------------------
62
+ file | line % | branch % | funcs % | uncovered lines
63
+ -------------------------------------------------------------------------------------------------------------------------
64
+ config.js | 100.00 | 100.00 | 100.00 |
65
+ index.js | 73.36 | 74.42 | 74.07 | 40-43 134-136 140-144 147-151 173-175 244-247 253-256 259-262 26…
66
+ index.test.js | 100.00 | 100.00 | 100.00 |
67
+ manager | | | |
68
+ spokes.js | 100.00 | 100.00 | 100.00 |
69
+ spokes.test.js | 100.00 | 100.00 | 100.00 |
70
+ subscribers.js | 78.21 | 100.00 | 83.33 | 61-77
71
+ subscribers.test.js | 100.00 | 100.00 | 100.00 |
72
+ -------------------------------------------------------------------------------------------------------------------------
73
+ all files | 84.20 | 88.89 | 87.88 |
74
+ -------------------------------------------------------------------------------------------------------------------------
75
+ ```
76
+
77
+ ## License
78
+
79
+ This project is licensed under the MIT License.
80
+
81
+ ## Contributing
82
+
83
+ Feel free to submit issues or pull requests for improvements or additional features.
package/config.js ADDED
@@ -0,0 +1,38 @@
1
+ /**
2
+ * @memberof Eventflow.Hub
3
+ */
4
+ export default
5
+ {
6
+ dependency : { '@superhero/eventflow-db' : '@superhero/eventflow-db' },
7
+ bootstrap : { '@superhero/eventflow-hub' : 'eventflow/hub' },
8
+ locator : { '@superhero/eventflow-hub' : './index.js' },
9
+ eventflow :
10
+ {
11
+ hub:
12
+ {
13
+ NAME : process.env.EVENTFLOW_HUB_NAME ?? 'EVENTFLOW-HUB',
14
+ SIGNAL_QUIT : process.env.EVENTFLOW_HUB_SIGNAL_QUIT ?? 'SIGTERM',
15
+ INTERNAL_IP : process.env.EVENTFLOW_HUB_INTERNAL_IP ?? '127.0.0.1',
16
+ INTERNAL_PORT : process.env.EVENTFLOW_HUB_INTERNAL_PORT ?? 50001,
17
+ EXTERNAL_IP : process.env.EVENTFLOW_HUB_EXTERNAL_IP ?? '127.0.0.1',
18
+ EXTERNAL_PORT : process.env.EVENTFLOW_HUB_EXTERNAL_PORT ?? 50001,
19
+ TCP_SOCKET_SERVER_OPTIONS : process.env.EVENTFLOW_HUB_TCP_SOCKET_SERVER_OPTIONS ?? {},
20
+ TCP_SOCKET_CLIENT_OPTIONS : process.env.EVENTFLOW_HUB_TCP_SOCKET_CLIENT_OPTIONS ?? {},
21
+ KEEP_ALIVE_INTERVAL : process.env.EVENTFLOW_HUB_KEEP_ALIVE_INTERVAL ?? 60e3,
22
+ PEER_HUB_ONLINE_TIMEOUT : process.env.EVENTFLOW_HUB_PEER_HUB_ONLINE_TIMEOUT ?? 5e3,
23
+ SPOKE_ID_MESSAGE_TIMEOUT : process.env.EVENTFLOW_HUB_SPOKE_ID_MESSAGE_TIMEOUT ?? 5e3,
24
+ SHEDULED_INTERVAL_DELAY : process.env.EVENTFLOW_HUB_SHEDULED_INTERVAL_DELAY ?? 1e3,
25
+
26
+ certificates:
27
+ {
28
+ CERT_ALGORITHM : process.env.EVENTFLOW_HUB_CERT_ALGORITHM,
29
+ CERT_HASH : process.env.EVENTFLOW_HUB_CERT_HASH,
30
+ CERT_PASS_CIPHER : process.env.EVENTFLOW_HUB_CERT_PASS_CIPHER,
31
+ CERT_PASS_ENCRYPTION_KEY : process.env.EVENTFLOW_HUB_CERT_PASS_ENCRYPTION_KEY,
32
+ CERT_PASS_PBKDF2_HASH : process.env.EVENTFLOW_HUB_CERT_PASS_PBKDF2_HASH,
33
+ CERT_PASS_PBKDF2_BYTES : process.env.EVENTFLOW_HUB_CERT_PASS_PBKDF2_BYTES,
34
+ CERT_PASS_PBKDF2_ITERATIONS : process.env.EVENTFLOW_HUB_CERT_PASS_PBKDF2_ITERATIONS,
35
+ }
36
+ }
37
+ }
38
+ }
package/index.js ADDED
@@ -0,0 +1,428 @@
1
+ import tls from 'node:tls'
2
+ import deepmerge from '@superhero/deep/merge'
3
+ import Channel from '@superhero/tcp-record-channel'
4
+ import IdNameGenerator from '@superhero/id-name-generator'
5
+ import Log from '@superhero/log'
6
+ import SpokesManager from '@superhero/eventflow-hub/manager/spokes'
7
+ import SubscribersManager from '@superhero/eventflow-hub/manager/subscribers'
8
+ import CertificatesManager from '@superhero/eventflow-certificates'
9
+ import { setInterval as asyncInterval } from 'node:timers/promises'
10
+
11
+ export function locate(locator)
12
+ {
13
+ const
14
+ config = locator('@superhero/config').find('eventflow/hub', {}),
15
+ db = locator('@superhero/eventflow-db'),
16
+ hub = new Hub(config, db)
17
+
18
+ return hub
19
+ }
20
+
21
+ /**
22
+ * @memberof Eventflow
23
+ */
24
+ export default class Hub
25
+ {
26
+ #hubID
27
+ channel = new Channel()
28
+
29
+ get hubID()
30
+ {
31
+ return this.#hubID
32
+ }
33
+
34
+ constructor(config, db)
35
+ {
36
+ if('string' !== typeof config.NAME
37
+ || 0 === config.NAME.length
38
+ || (/[^a-z0-9\-\.]/i).test(config.NAME))
39
+ {
40
+ const error = new Error(`invalid config.NAME (${config.NAME})`)
41
+ error.code = 'E_EVENTFLOW_HUB_INVALID_CONFIG_NAME'
42
+ throw error
43
+ }
44
+
45
+ this.#hubID = (new IdNameGenerator().generateId() + '.' + config.NAME).toUpperCase()
46
+ this.config = config
47
+ this.db = db
48
+ this.abortion = new AbortController()
49
+ this.log = new Log({ label: `[${config.NAME}]` })
50
+ this.certificates = new CertificatesManager(config.NAME, this.#hubID, config.certificates, db, this.log)
51
+ this.spokes = new SpokesManager()
52
+ this.subscribers = new SubscribersManager()
53
+
54
+ this.channel.on('record', this.#onRecord.bind(this))
55
+ }
56
+
57
+ async bootstrap()
58
+ {
59
+ await this.db.setupTableSchemas()
60
+ await this.#bootstrapServer()
61
+ setImmediate(this.#sheduledInterval.bind(this, this.config.SHEDULED_INTERVAL_DELAY))
62
+ }
63
+
64
+ async destroy()
65
+ {
66
+ this.abortion.abort()
67
+ this.server?.close()
68
+ this.spokes.destroy()
69
+ this.subscribers.destroy()
70
+ this.log.warn`hub has quit`
71
+ await this.db.updateHubToQuit(this.#hubID)
72
+ setImmediate(() => this.db.close())
73
+ }
74
+
75
+ #bootstrapServer()
76
+ {
77
+ return new Promise(async (accept, reject) =>
78
+ {
79
+ const conf = Object.assign({}, this.config.TCP_SOCKET_SERVER_OPTIONS)
80
+
81
+ conf.requestCert = true
82
+ conf.pauseOnConnect = true
83
+ conf.SNICallback = this.#serverSNICallback.bind(this)
84
+
85
+ this.server = this.channel.createTlsServer(conf, this.#onClientConnection.bind(this))
86
+ this.server.on('error', reject)
87
+ this.server.on('close', reject)
88
+ this.server.listen(this.config.INTERNAL_PORT, this.config.INTERNAL_IP, async () =>
89
+ {
90
+ this.server.off('error', reject)
91
+ this.server.off('close', reject)
92
+
93
+ this.server.on('error', this.#onServerError.bind(this))
94
+ this.server.on('close', this.#onServerClose.bind(this))
95
+
96
+ await this.#persistHubOnline()
97
+ await this.#broadcastHubOnlineToPeerHubs()
98
+
99
+ this.log.info`listen on ${this.config.INTERNAL_IP}:${this.config.INTERNAL_PORT}`
100
+
101
+ accept()
102
+ })
103
+ })
104
+ }
105
+
106
+ async #serverSNICallback(hostname, cb)
107
+ {
108
+ if(hostname !== this.#hubID)
109
+ {
110
+ const error = new Error('invalid hostname')
111
+ error.code = 'E_EVENTFLOW_INVALID_HOSTNAME'
112
+ error.cause = `hostname ${hostname} mismatch with hub id ${this.#hubID}`
113
+ return cb(error)
114
+ }
115
+
116
+ try
117
+ {
118
+ const
119
+ root = await this.certificates.root,
120
+ ica = await this.certificates.intermediate,
121
+ leaf = await this.certificates.leaf
122
+
123
+ const ctx = tls.createSecureContext(
124
+ {
125
+ ca : root.cert,
126
+ cert : leaf.cert + ica.cert,
127
+ key : leaf.key,
128
+ passphrase : leaf.pass
129
+ })
130
+
131
+ cb(null, ctx)
132
+ }
133
+ catch(error)
134
+ {
135
+ return cb(error)
136
+ }
137
+ }
138
+
139
+ async #onServerError(error)
140
+ {
141
+ this.log.fail`server error [${error.code}] ${error.message}`
142
+ const message = `server error [${error.code}] ${error.message}`
143
+ await this.db.persistLog({ agent:this.#hubID, message, error })
144
+ }
145
+
146
+ async #onClientError(client, error)
147
+ {
148
+ this.log.fail`observed client error [${error.code}] ${error.message} in ${client.id}`
149
+ const message = `observed client error [${error.code}] ${error.message} in ${client.id}`
150
+ await this.db.persistLog({ agent:this.#hubID, message, error })
151
+ }
152
+
153
+ async #onServerClose()
154
+ {
155
+ this.log.warn`server closed`
156
+ }
157
+
158
+ async #persistHubOnline()
159
+ {
160
+ const
161
+ internal_ip = this.config.INTERNAL_IP,
162
+ internal_port = this.config.INTERNAL_PORT,
163
+ external_ip = this.config.EXTERNAL_IP,
164
+ external_port = this.config.EXTERNAL_PORT
165
+
166
+ await this.db.persistHub({ id:this.#hubID, internal_ip, internal_port, external_ip, external_port })
167
+ }
168
+
169
+ async #onClientConnection(client)
170
+ {
171
+ if(false === client.authorized)
172
+ {
173
+ this.log.warn`unauthorized client connection rejected`
174
+ return client.authorized = false
175
+ }
176
+
177
+ this.spokes.add(client)
178
+
179
+ client.id = client.getPeerCertificate().subject.UID
180
+ client.authorized = true
181
+
182
+ client.on('close', this.#onClientDisconnected.bind(this, client))
183
+ client.on('error', this.#onClientError.bind(this, client))
184
+
185
+ client.setKeepAlive(true, this.config.KEEP_ALIVE_INTERVAL)
186
+ client.resume()
187
+
188
+ this.log.info`connected ${client.id}`
189
+ const message = `connected ${client.id}`
190
+ await this.db.persistLog({ agent:this.#hubID, message })
191
+ }
192
+
193
+ async #onClientDisconnected(client)
194
+ {
195
+ this.spokes.delete(client)
196
+ this.subscribers.deleteBySocket(client)
197
+ this.log.info`disconnected ${client.id}`
198
+ const message = `disconnected ${client.id}`
199
+ await this.db.persistLog({ agent:this.#hubID, message })
200
+ }
201
+
202
+ /**
203
+ * @see @superhero/tcp-record-channel
204
+ * @param {String[]} record The unit seperated record
205
+ * @param {node:tls.TLSSocket} client A spoke or peer hub client
206
+ */
207
+ async #onRecord([ event, ...args ], client)
208
+ {
209
+ switch(event)
210
+ {
211
+ case 'online' : return this.#onPeerHubOnlineMessage (client, ...args)
212
+ case 'publish' : return this.#onSpokePublishMessage (client, ...args)
213
+ case 'subscribe' : return this.#onSpokeSubscribeMessage (client, ...args)
214
+ case 'unsubscribe' : return this.#onSpokeUnsubscribeMessage (client, ...args)
215
+ // only recognize the above listed events
216
+ default: this.log.fail`observed invalid message ${event} from spoke ${client.id}`
217
+ }
218
+ }
219
+
220
+ /**
221
+ * Broadcasts the online status of a peer hub to all connected spokes of this hub.
222
+ * The intent is to inform all connected spokes that a peer hub is online so that
223
+ * they can connect to that hub as well. Spokes are not expected to poll for online
224
+ * hubs, but rather rely on a reactional architecture.
225
+ *
226
+ * @param {node:tls.TLSSocket} peerHub
227
+ * @param {string} peerHubID
228
+ * @param {string} peerHubIP
229
+ * @param {string} peerHubPort
230
+ */
231
+ async #onPeerHubOnlineMessage(peerHub, peerHubID, peerHubIP, peerHubPort)
232
+ {
233
+ // prevent possible loop
234
+ if(peerHubID === this.#hubID)
235
+ {
236
+ return
237
+ }
238
+
239
+ try
240
+ {
241
+ this.channel.broadcast(this.spokes.all, [ 'online', peerHubID, peerHubIP, peerHubPort ])
242
+ }
243
+ catch(error)
244
+ {
245
+ this.log.fail`failed to broadcast peer hub ${peerHubID} online event [${error.code}] ${error.message}`
246
+ return
247
+ }
248
+
249
+ this.log.info`broadcasted peer hub ${peerHubID} › ${peerHubIP}:${peerHubPort} online event`
250
+ }
251
+
252
+ #onSpokeSubscribeMessage(spoke, domain, name)
253
+ {
254
+ this.subscribers.add(spoke, domain, name)
255
+ this.log.info`spoke ${spoke.id} subscribes to events: ${domain} › ${name}`
256
+ }
257
+
258
+ #onSpokeUnsubscribeMessage(spoke, domain, name)
259
+ {
260
+ this.subscribers.deleteBySocketAndDomainAndName(spoke, domain, name)
261
+ this.log.info`spoke ${spoke.id} unsubscribed to events: ${domain} › ${name}`
262
+ }
263
+
264
+ async #onSpokePublishMessage(spoke, domain, id, name, pid)
265
+ {
266
+ this.log.info`spoke ${spoke.id} published event ${id}: ${domain} › ${name} › ${pid}`
267
+ await this.#attemptToConsumeAndBroadcastPublishedMessage(domain, id, name, pid)
268
+ }
269
+
270
+ async #attemptToConsumeAndBroadcastPublishedMessage(domain, id, name, pid)
271
+ {
272
+ const consumed = await this.db.updateEventPublishedToConsumedByHub(domain, id, this.#hubID)
273
+
274
+ if(consumed)
275
+ {
276
+ this.log.info`consumed event ${id}: ${domain} › ${name} › ${pid}`
277
+ await this.#broadcastPublishedMessage(domain, id, name, pid)
278
+ }
279
+
280
+ return consumed
281
+ }
282
+
283
+ async #broadcastPublishedMessage(domain, id, name, pid)
284
+ {
285
+ const sockets = this.subscribers.get(domain, name)
286
+
287
+ if(sockets.length === 0)
288
+ {
289
+ this.log.warn`observed an orphan event ${id}: ${domain} › ${name} › ${pid}`
290
+ await this.db.updateEventPublishedToOrphan(domain, id)
291
+ }
292
+ else
293
+ {
294
+ try
295
+ {
296
+ this.channel.broadcast(sockets, [ 'publish', domain, id, name, pid ])
297
+ }
298
+ catch(error)
299
+ {
300
+ this.log.fail`failed to broadcast event ${id}: ${domain} › ${name} › ${pid} [${error.code}] ${error.message}`
301
+ return
302
+ }
303
+
304
+ this.log.info`broadcasted event ${id}: ${domain} › ${name} › ${pid}`
305
+ }
306
+ }
307
+
308
+ async #broadcastHubOnlineToPeerHubs()
309
+ {
310
+ for(const { id:hubID, external_ip:ip, external_port:port } of await this.db.readOnlineHubs())
311
+ {
312
+ // prevent possible loop
313
+ if(hubID === this.#hubID
314
+ || this.abortion.signal.aborted)
315
+ {
316
+ continue
317
+ }
318
+
319
+ try
320
+ {
321
+ await this.#transmitHubOnlineToPeerHub(hubID, ip, port)
322
+ }
323
+ catch(error)
324
+ {
325
+ this.log.fail`failed to connect to peer hub ${hubID} [${error.code}] ${error.message}`
326
+ const message = `failed to connect to peer hub ${hubID} [${error.code}] ${error.message}`
327
+ await this.db.persistLog({ agent:this.#hubID, message, error })
328
+ }
329
+ }
330
+ }
331
+
332
+ async #transmitHubOnlineToPeerHub(hubID, ip, port)
333
+ {
334
+ const
335
+ rootCA = await this.certificates.root,
336
+ hubICA = await this.certificates.intermediate,
337
+ hubLeaf = await this.certificates.leaf,
338
+ ca = rootCA.cert,
339
+ certChain = hubLeaf.cert + hubICA.cert,
340
+ dynamicConfig = { servername:hubID, host:ip, port, ca, cert:certChain, key:hubLeaf.key, passphrase:hubLeaf.pass },
341
+ peerHubConfig = deepmerge(dynamicConfig, this.config.TCP_SOCKET_CLIENT_OPTIONS),
342
+ peerHub = await this.channel.createTlsClient(peerHubConfig)
343
+
344
+ peerHub.id = peerHub.getPeerCertificate().subject.UID
345
+ this.channel.transmit(peerHub, [ 'online', this.#hubID, this.config.EXTERNAL_IP, this.config.EXTERNAL_PORT ])
346
+ this.log.info`broadcasted online status to peer hub ${peerHub.id}`
347
+ const message = `broadcasted online status to peer hub ${peerHub.id}`
348
+ await this.db.persistLog({ agent:this.#hubID, message })
349
+
350
+ peerHub.end()
351
+ }
352
+
353
+ /**
354
+ * Polls the database for scheduled events and attempts to execute them.
355
+ * TODO: This method should be refactored to use a more reactive approach.
356
+ * @param {number} delay interval in milliseconds
357
+ */
358
+ async #sheduledInterval(delay)
359
+ {
360
+ try
361
+ {
362
+ const
363
+ readScheduledEvents = this.db.readEventsScheduled.bind(this.db),
364
+ signal = this.abortion.signal,
365
+ asyncIterator = asyncInterval(delay, readScheduledEvents, { signal })
366
+
367
+ for await (const sheduledEvents of asyncIterator)
368
+ {
369
+ for(const scheduledEvent of await sheduledEvents())
370
+ {
371
+ if(signal.aborted)
372
+ {
373
+ return
374
+ }
375
+
376
+ const { domain, id, name } = scheduledEvent
377
+ const executed = await this.db.updateEventScheduledExecuted(domain, id)
378
+
379
+ if(executed === false)
380
+ {
381
+ continue
382
+ }
383
+
384
+ let consumed
385
+
386
+ try
387
+ {
388
+ this.log.info`executed scheduled event ${id}: ${domain} › ${name}`
389
+ consumed = await this.#attemptToConsumeAndBroadcastPublishedMessage(domain, id, name)
390
+ }
391
+ catch (error)
392
+ {
393
+ this.log.fail`failed to execute scheduled event ${id}: ${domain} › ${name} [${error.code}] ${error.message}`
394
+ const message = `failed to execute scheduled event ${id}: ${domain} › ${name} [${error.code}] ${error.message}`
395
+ await this.db.updateEventScheduledFailed(domain, id)
396
+ await this.db.persistLog({ agent:this.#hubID, message, error })
397
+ throw error
398
+ }
399
+
400
+ if(consumed)
401
+ {
402
+ await this.db.updateEventScheduledSuccess(domain, id)
403
+ }
404
+ else
405
+ {
406
+ this.log.fail`failed to execute already consumed scheduled event ${id}: ${domain} › ${name}`
407
+ const message = `failed to execute already consumed scheduled event ${id}: ${domain} › ${name}`
408
+ await this.db.updateEventScheduledFailed(domain, id)
409
+ await this.db.persistLog({ agent:this.#hubID, message, error })
410
+ }
411
+ }
412
+ }
413
+ }
414
+ catch(error)
415
+ {
416
+ this.log.fail`failed to execute scheduled interval ${error.message} [${error.code}]`
417
+ const message = `failed to execute scheduled interval ${error.message} [${error.code}]`
418
+ await this.db.persistLog({ agent:this.#hubID, message, error })
419
+ }
420
+ finally
421
+ {
422
+ if(this.abortion.signal.aborted === false)
423
+ {
424
+ setImmediate(this.#sheduledInterval.bind(this, delay))
425
+ }
426
+ }
427
+ }
428
+ }
package/index.test.js ADDED
@@ -0,0 +1,110 @@
1
+ import assert from 'node:assert'
2
+ import util from 'node:util'
3
+ import Config from '@superhero/config'
4
+ import Locator from '@superhero/locator'
5
+ import Channel from '@superhero/tcp-record-channel'
6
+ import { suite, test, beforeEach, afterEach } from 'node:test'
7
+
8
+ util.inspect.defaultOptions.depth = 5
9
+
10
+ suite('@superhero/eventflow-hub', () =>
11
+ {
12
+ let locator, hub
13
+
14
+ beforeEach(async () =>
15
+ {
16
+ if(beforeEach.skip) return
17
+ locator = new Locator()
18
+ locator.log.config.mute = true
19
+ const config = new Config()
20
+ await config.add('@superhero/eventflow-db')
21
+ await config.add('./config.js')
22
+ config.assign({ eventflow: { hub: { certificates: { CERT_PASS_ENCRYPTION_KEY: 'encryptionKey123' }}}})
23
+ locator.set('@superhero/config', config)
24
+ await locator.eagerload('@superhero/eventflow-db')
25
+ await locator.eagerload(config.find('locator'))
26
+ hub = locator('@superhero/eventflow-hub')
27
+ hub.log.config.mute = true
28
+ await hub.bootstrap()
29
+ })
30
+
31
+ afterEach(async () =>
32
+ {
33
+ if(afterEach.skip) return
34
+ await locator.destroy()
35
+ locator.clear()
36
+ })
37
+
38
+ suite('Lifecycle', () =>
39
+ {
40
+ test('Can initialize EventflowHub correctly', () =>
41
+ {
42
+ assert.strictEqual(hub.config.NAME, 'EVENTFLOW-HUB')
43
+ assert.ok(hub.channel)
44
+ assert.ok(hub.certificates)
45
+ assert.ok(hub.spokes)
46
+ assert.ok(hub.subscribers)
47
+ assert.strictEqual(hub.config.INTERNAL_IP, '127.0.0.1')
48
+ assert.strictEqual(hub.config.INTERNAL_PORT, 50001)
49
+ })
50
+ })
51
+
52
+ suite('Connections and Communication', () =>
53
+ {
54
+ test('Handles spoke connections', async (sub) =>
55
+ {
56
+ assert.equal(hub.spokes.all.length, 0)
57
+
58
+ const
59
+ root = await hub.certificates.root,
60
+ ica = await hub.certificates.intermediate,
61
+ leaf = await hub.certificates.leaf,
62
+ chain = leaf.cert + ica.cert,
63
+ host = hub.config.INTERNAL_IP,
64
+ port = hub.config.INTERNAL_PORT,
65
+ config = { servername:hub.hubID, host, port, cert:chain, key:leaf.key, ca:root.cert, passphrase:leaf.pass },
66
+ channel = new Channel(),
67
+ spoke = await channel.createTlsClient(config)
68
+
69
+ assert.ok(hub.spokes.all.length)
70
+
71
+ beforeEach.skip = true
72
+ afterEach.skip = true
73
+
74
+ await sub.test('Broadcasts peer hub online event', async () => await new Promise(async (accept) =>
75
+ {
76
+ channel.on('record', ([ type, id ,, port ]) => 'online' === type
77
+ && hub2.hubID === id
78
+ && '50002' === port
79
+ && hub2.destroy().then(accept))
80
+
81
+ const
82
+ locator2 = new Locator(),
83
+ config2 = new Config()
84
+
85
+ locator2.log.config.mute = true
86
+ await config2.add('@superhero/eventflow-db')
87
+ await config2.add('./config.js')
88
+ config2.assign(
89
+ { eventflow:
90
+ { hub:
91
+ { INTERNAL_PORT:50002,
92
+ EXTERNAL_PORT:50002,
93
+ certificates:
94
+ { CERT_PASS_ENCRYPTION_KEY: 'encryptionKey123' }}}})
95
+
96
+ locator2.set('@superhero/config', config2)
97
+ await locator2.eagerload('@superhero/eventflow-db')
98
+ await locator2.eagerload(config2.find('locator'))
99
+ const hub2 = locator2('@superhero/eventflow-hub')
100
+ hub2.log.config.mute = true
101
+ await hub2.bootstrap()
102
+ }))
103
+
104
+ beforeEach.skip = false
105
+ afterEach.skip = false
106
+
107
+ await new Promise((accept) => spoke.end(accept))
108
+ })
109
+ })
110
+ })
@@ -0,0 +1,31 @@
1
+ /**
2
+ * Manage spoke sockets.
3
+ * @memberof Eventflow.Hub
4
+ */
5
+ export default class SpokesManager
6
+ {
7
+ #set = new Set
8
+
9
+ destroy()
10
+ {
11
+ for(const socket of this.#set.values())
12
+ {
13
+ socket.end()
14
+ }
15
+ }
16
+
17
+ add(socket)
18
+ {
19
+ this.#set.add(socket)
20
+ }
21
+
22
+ get all()
23
+ {
24
+ return [...this.#set]
25
+ }
26
+
27
+ delete(socket)
28
+ {
29
+ this.#set.delete(socket)
30
+ }
31
+ }
@@ -0,0 +1,66 @@
1
+ import assert from 'node:assert/strict'
2
+ import { suite, test } from 'node:test'
3
+ import SpokesManager from '@superhero/eventflow-hub/manager/spokes'
4
+
5
+ suite('@superhero/eventflow-hub/manager/spokes', () =>
6
+ {
7
+ test('Add and retrieve all sockets', async () =>
8
+ {
9
+ const manager = new SpokesManager()
10
+ const socket1 = { id: 'socket1' }
11
+ const socket2 = { id: 'socket2' }
12
+
13
+ manager.add(socket1)
14
+ manager.add(socket2)
15
+
16
+ const allSockets = manager.all
17
+ assert.deepStrictEqual(allSockets, [socket1, socket2])
18
+ })
19
+
20
+ test('Delete a socket', async () =>
21
+ {
22
+ const manager = new SpokesManager()
23
+ const socket1 = { id: 'socket1' }
24
+ const socket2 = { id: 'socket2' }
25
+
26
+ manager.add(socket1)
27
+ manager.add(socket2)
28
+ manager.delete(socket1)
29
+
30
+ const allSockets = manager.all
31
+ assert.deepStrictEqual(allSockets, [socket2])
32
+ })
33
+
34
+ test('Destroy all sockets', async () =>
35
+ {
36
+ const manager = new SpokesManager()
37
+ const socket1 = { id: 'socket1', end: () => socket1.destroyed = true }
38
+ const socket2 = { id: 'socket2', end: () => socket2.destroyed = true }
39
+
40
+ manager.add(socket1)
41
+ manager.add(socket2)
42
+ manager.destroy()
43
+
44
+ assert.strictEqual(socket1.destroyed, true)
45
+ assert.strictEqual(socket2.destroyed, true)
46
+ })
47
+
48
+ test('Handle deleting non-existent socket gracefully', async () =>
49
+ {
50
+ const manager = new SpokesManager()
51
+ const socket = { id: 'socket1' }
52
+
53
+ assert.doesNotThrow(() =>
54
+ {
55
+ manager.delete(socket)
56
+ })
57
+ })
58
+
59
+ test('Return empty array if no sockets exist', async () =>
60
+ {
61
+ const manager = new SpokesManager()
62
+
63
+ const allSockets = manager.all
64
+ assert.deepStrictEqual(allSockets, [])
65
+ })
66
+ })
@@ -0,0 +1,78 @@
1
+ /**
2
+ * Manage subscribers to events by event domain and event name
3
+ * @memberof Eventflow.Hub
4
+ */
5
+ export default class SubscribersManager
6
+ {
7
+ #map = new Map
8
+
9
+ destroy()
10
+ {
11
+ this.#map.clear()
12
+ }
13
+
14
+ add(socket, domain, name)
15
+ {
16
+ if(false === this.#map.has(domain))
17
+ {
18
+ this.#map.set(domain, new Map)
19
+ }
20
+
21
+ if(false === this.#map.get(domain).has(name))
22
+ {
23
+ this.#map.get(domain).set(name, new Set)
24
+ }
25
+
26
+ this.#map.get(domain).get(name).add(socket)
27
+ }
28
+
29
+ get(domain, name)
30
+ {
31
+ const
32
+ domainMap = this.#map.get(domain) || new Map,
33
+ namedSet = domainMap.get(name) || new Set,
34
+ wildcardSet = domainMap.get('*') || new Set
35
+
36
+ return [...new Set([...namedSet, ...wildcardSet])]
37
+ }
38
+
39
+ deleteBySocket(socket)
40
+ {
41
+ for(const domain of this.#map.keys())
42
+ {
43
+ for(const name of this.#map.get(domain).keys())
44
+ {
45
+ this.#map.get(domain).get(name).delete(socket)
46
+
47
+ if(0 === this.#map.get(domain).get(name).size)
48
+ {
49
+ this.#map.get(domain).delete(name)
50
+
51
+ if(0 === this.#map.get(domain).size)
52
+ {
53
+ this.#map.delete(domain)
54
+ }
55
+ }
56
+ }
57
+ }
58
+ }
59
+
60
+ deleteBySocketAndDomainAndName(socket, domain, name)
61
+ {
62
+ const
63
+ domainMap = this.#map.get(domain) || new Map,
64
+ namedSet = domainMap.get(name) || new Set
65
+
66
+ namedSet.delete(socket)
67
+
68
+ if(0 === namedSet.size)
69
+ {
70
+ domainMap.delete(name)
71
+
72
+ if(0 === domainMap.size)
73
+ {
74
+ this.#map.delete(domain)
75
+ }
76
+ }
77
+ }
78
+ }
@@ -0,0 +1,78 @@
1
+ import assert from 'node:assert/strict'
2
+ import { suite, test } from 'node:test'
3
+ import SubscribersManager from '@superhero/eventflow-hub/manager/subscribers'
4
+
5
+ suite('@superhero/eventflow-hub/manager/subscribers', () =>
6
+ {
7
+ test('Add and retrieve subscribers', async () =>
8
+ {
9
+ const manager = new SubscribersManager()
10
+ const socket1 = { id: 'socket1' }
11
+ const socket2 = { id: 'socket2' }
12
+
13
+ manager.add(socket1, 'domain1', 'event1')
14
+ manager.add(socket2, 'domain1', 'event1')
15
+
16
+ const subscribers = manager.get('domain1', 'event1')
17
+ assert.deepStrictEqual(subscribers, [socket1, socket2])
18
+ })
19
+
20
+ test('Handle wildcard subscribers', async () =>
21
+ {
22
+ const manager = new SubscribersManager()
23
+ const socket1 = { id: 'socket1' }
24
+ const socket2 = { id: 'socket2' }
25
+
26
+ manager.add(socket1, 'domain1', '*')
27
+ manager.add(socket2, 'domain1', 'event1')
28
+
29
+ const subscribers = manager.get('domain1', 'event1')
30
+ assert.deepStrictEqual(subscribers, [socket2, socket1])
31
+ })
32
+
33
+ test('Return empty array if no subscribers exist', async () =>
34
+ {
35
+ const manager = new SubscribersManager()
36
+ const subscribers = manager.get('domain1', 'event1')
37
+ assert.deepStrictEqual(subscribers, [])
38
+ })
39
+
40
+ test('Delete a subscriber and clean up empty structures', async () =>
41
+ {
42
+ const manager = new SubscribersManager()
43
+ const socket1 = { id: 'socket1' }
44
+ const socket2 = { id: 'socket2' }
45
+
46
+ manager.add(socket1, 'domain1', 'event1')
47
+ manager.add(socket2, 'domain1', 'event1')
48
+
49
+ manager.deleteBySocket(socket1)
50
+
51
+ let subscribers = manager.get('domain1', 'event1')
52
+ assert.deepStrictEqual(subscribers, [socket2])
53
+
54
+ manager.deleteBySocket(socket2)
55
+ subscribers = manager.get('domain1', 'event1')
56
+ assert.deepStrictEqual(subscribers, [])
57
+ })
58
+
59
+ test('Clean up domain and event mappings after deletion', async () =>
60
+ {
61
+ const manager = new SubscribersManager()
62
+ const socket = { id: 'socket1' }
63
+
64
+ manager.add(socket, 'domain1', 'event1')
65
+ manager.deleteBySocket(socket)
66
+
67
+ assert.strictEqual(manager.get('domain1', 'event1').length, 0)
68
+ assert.strictEqual(manager.get('domain1', '*').length, 0)
69
+ })
70
+
71
+ test('Not throw errors when deleting non-existent subscribers', async () =>
72
+ {
73
+ const manager = new SubscribersManager()
74
+ const socket = { id: 'socket1' }
75
+
76
+ assert.doesNotThrow(() => manager.deleteBySocket(socket))
77
+ })
78
+ })
package/package.json ADDED
@@ -0,0 +1,43 @@
1
+ {
2
+ "name": "@superhero/eventflow-hub",
3
+ "version": "4.0.2",
4
+ "description": "Eventflow hub is the central server component in the eventflow ecosystem.",
5
+ "keywords": [
6
+ "eventflow",
7
+ "hub",
8
+ "bus"
9
+ ],
10
+ "main": "config.js",
11
+ "license": "MIT",
12
+ "type": "module",
13
+ "exports": {
14
+ ".": "./index.js",
15
+ "./manager/*": "./manager/*.js"
16
+ },
17
+ "dependencies": {
18
+ "@superhero/eventflow-db": "^4.1.1",
19
+ "@superhero/id-name-generator": "^4.0.0",
20
+ "@superhero/log": "^4.0.0",
21
+ "@superhero/eventflow-certificates": "^4.0.2",
22
+ "@superhero/openssl": "^4.0.2",
23
+ "@superhero/tcp-record-channel": "^4.2.1",
24
+ "@superhero/deep": "^4.2.0"
25
+ },
26
+ "devDependencies": {
27
+ "@superhero/config": "^4.1.2",
28
+ "@superhero/locator": "^4.2.0"
29
+ },
30
+ "scripts": {
31
+ "test-build": "npm explore @superhero/eventflow-db -- npm run test-build",
32
+ "test-only": "node --test-only --trace-warnings --test --experimental-test-coverage",
33
+ "test": "node --test --experimental-test-coverage"
34
+ },
35
+ "author": {
36
+ "name": "Erik Landvall",
37
+ "email": "erik@landvall.se"
38
+ },
39
+ "repository": {
40
+ "type": "git",
41
+ "url": "git+https://github.com/superhero/eventflow-hub.git"
42
+ }
43
+ }