@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 +21 -0
- package/README.md +83 -0
- package/config.js +38 -0
- package/index.js +428 -0
- package/index.test.js +110 -0
- package/manager/spokes.js +31 -0
- package/manager/spokes.test.js +66 -0
- package/manager/subscribers.js +78 -0
- package/manager/subscribers.test.js +78 -0
- package/package.json +43 -0
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
|
+
}
|