@superhero/eventflow-spoke 4.0.0
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 +233 -0
- package/config.js +36 -0
- package/consume.js +116 -0
- package/index.js +393 -0
- package/index.test.js +147 -0
- package/manager/hubs.js +63 -0
- package/manager/hubs.test.js +78 -0
- package/manager/listeners.js +72 -0
- package/manager/listeners.test.js +118 -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,233 @@
|
|
|
1
|
+
# Eventflow Spoke
|
|
2
|
+
|
|
3
|
+
Eventflow Spoke is the client component in the Eventflow ecosystem. It enables communication with hubs in the Eventflow network, allowing events to be published, consumed, subscribed to, and managed efficiently.
|
|
4
|
+
|
|
5
|
+
## Features
|
|
6
|
+
|
|
7
|
+
- Publish and subscribe to events.
|
|
8
|
+
- Consume events with callback support.
|
|
9
|
+
- Schedule events for future execution.
|
|
10
|
+
- Wait for specific event outcomes.
|
|
11
|
+
- Manage event logs and their states.
|
|
12
|
+
- Communicates with hubs via secure TLS connections.
|
|
13
|
+
|
|
14
|
+
## Installation
|
|
15
|
+
|
|
16
|
+
Install the package using npm:
|
|
17
|
+
|
|
18
|
+
```bash
|
|
19
|
+
npm install @superhero/eventflow-spoke
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
## Usage
|
|
23
|
+
|
|
24
|
+
### Initialization
|
|
25
|
+
|
|
26
|
+
To initialize a Spoke instance:
|
|
27
|
+
|
|
28
|
+
```javascript
|
|
29
|
+
import { locate } from '@superhero/eventflow-spoke'
|
|
30
|
+
|
|
31
|
+
const locator = new Locator()
|
|
32
|
+
locator.set('@superhero/config', config)
|
|
33
|
+
locator.eagerload('@superhero/eventflow-db')
|
|
34
|
+
locator.eagerload(config.find('locator'))
|
|
35
|
+
|
|
36
|
+
const spoke = locate(locator)
|
|
37
|
+
await spoke.bootstrap()
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
### Publishing Events
|
|
41
|
+
|
|
42
|
+
Publish an event to the Eventflow network:
|
|
43
|
+
|
|
44
|
+
```javascript
|
|
45
|
+
await spoke.publish(
|
|
46
|
+
{
|
|
47
|
+
domain : 'example-domain',
|
|
48
|
+
name : 'example-event',
|
|
49
|
+
pid : 'example-pid',
|
|
50
|
+
data : { key: 'value' }
|
|
51
|
+
})
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
### Subscribing to Events
|
|
55
|
+
|
|
56
|
+
Subscribe to specific events:
|
|
57
|
+
|
|
58
|
+
```javascript
|
|
59
|
+
await spoke.subscribe('example-domain', 'example-event', (event) =>
|
|
60
|
+
{
|
|
61
|
+
console.log('Event received:', event)
|
|
62
|
+
})
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
### Consuming Events
|
|
66
|
+
|
|
67
|
+
Consume events with a callback:
|
|
68
|
+
|
|
69
|
+
```javascript
|
|
70
|
+
await spoke.consume('example-domain', 'example-event', (event) =>
|
|
71
|
+
{
|
|
72
|
+
console.log('Consuming event:', event)
|
|
73
|
+
})
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
### Waiting for Events
|
|
77
|
+
|
|
78
|
+
Wait for a specific event outcome:
|
|
79
|
+
|
|
80
|
+
```javascript
|
|
81
|
+
const result = await spoke.wait('example-domain', 'example-pid', ['success', 'failed'], 10000)
|
|
82
|
+
console.log('Event result:', result) // result = "success" or "failed"
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
### Scheduling Events
|
|
86
|
+
|
|
87
|
+
Schedule an event to be executed at a later time:
|
|
88
|
+
|
|
89
|
+
```javascript
|
|
90
|
+
const scheduledTime = new Date(Date.now() + 60e3) // 1 minute from now
|
|
91
|
+
await spoke.schedule(scheduledTime.toISOString(),
|
|
92
|
+
{
|
|
93
|
+
domain : 'example-domain',
|
|
94
|
+
name : 'example-event',
|
|
95
|
+
pid : 'example-pid',
|
|
96
|
+
data : { key: 'value' }
|
|
97
|
+
})
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
### Managing Event Logs
|
|
101
|
+
|
|
102
|
+
#### Deleting Event Logs
|
|
103
|
+
|
|
104
|
+
```javascript
|
|
105
|
+
await spoke.deleteEventlog('example-domain', 'example-pid')
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
#### Reading Event Logs
|
|
109
|
+
|
|
110
|
+
```javascript
|
|
111
|
+
const events = await spoke.readEventlog('example-domain', 'example-pid')
|
|
112
|
+
console.log('Event log:', events)
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
#### Reading Event Log State
|
|
116
|
+
|
|
117
|
+
```javascript
|
|
118
|
+
const state = await spoke.readEventlogState('example-domain', 'example-pid')
|
|
119
|
+
console.log('Event log state:', state)
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
#### Composing Event Log State
|
|
123
|
+
|
|
124
|
+
Manual composition of an eventlog state.
|
|
125
|
+
|
|
126
|
+
```javascript
|
|
127
|
+
const eventlog =
|
|
128
|
+
[
|
|
129
|
+
{ data: { key1: 'value1' } },
|
|
130
|
+
{ data: { key2: 'value2' } }
|
|
131
|
+
]
|
|
132
|
+
const state = spoke.composeEventlogState(eventlog)
|
|
133
|
+
console.log('Composed state:', state)
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
## API Reference
|
|
137
|
+
|
|
138
|
+
### Methods
|
|
139
|
+
|
|
140
|
+
- `bootstrap()` - Initializes the spoke and connects to online hubs.
|
|
141
|
+
- `destroy()` - Cleans up resources and disconnects from hubs.
|
|
142
|
+
- `publish(event)` - Publishes an event.
|
|
143
|
+
- `subscribe(domain, name, callback)` - Subscribes to a specific event.
|
|
144
|
+
- `consume(domain, name, callback)` - Consumes a specific event.
|
|
145
|
+
- `wait(domain, pid, eventNames, timeout)` - Waits for specific event outcomes.
|
|
146
|
+
- `schedule(scheduled, event)` - Schedules an event for future execution.
|
|
147
|
+
- `delete(eventID)` - Deletes an event by ID.
|
|
148
|
+
- `deleteEventlog(domain, pid)` - Deletes an event log by domain and PID.
|
|
149
|
+
- `read(eventID)` - Reads a specific event by ID.
|
|
150
|
+
- `readEventlog(domain, pid)` - Reads all events for a domain and PID.
|
|
151
|
+
- `readEventlogState(domain, pid)` - Reads the state of an event log.
|
|
152
|
+
- `composeEventlogState(eventlog, size)` - Composes the state from a series of event logs.
|
|
153
|
+
|
|
154
|
+
## Testing
|
|
155
|
+
|
|
156
|
+
Run the test suite using:
|
|
157
|
+
|
|
158
|
+
```bash
|
|
159
|
+
npm run test-build
|
|
160
|
+
npm test
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
### Test Coverage
|
|
164
|
+
|
|
165
|
+
```
|
|
166
|
+
▶ @superhero/eventflow-spoke
|
|
167
|
+
▶ Lifecycle
|
|
168
|
+
✔ Can initialize EventflowSpoke correctly (2.456916ms)
|
|
169
|
+
✔ Lifecycle (3.674219ms)
|
|
170
|
+
|
|
171
|
+
▶ Event Management
|
|
172
|
+
✔ Subscribe (25.140172ms)
|
|
173
|
+
✔ Consume (27.952574ms)
|
|
174
|
+
✔ Wait for event (22.296145ms)
|
|
175
|
+
✔ Schedule events (19.80376ms)
|
|
176
|
+
✔ Delete event (13.513563ms)
|
|
177
|
+
✔ Delete event log (18.829043ms)
|
|
178
|
+
✔ Read event (7.612599ms)
|
|
179
|
+
✔ Read event log (12.595032ms)
|
|
180
|
+
✔ Read event log state (16.270482ms)
|
|
181
|
+
✔ Compose event log state (0.206373ms)
|
|
182
|
+
✔ Event Management (165.303115ms)
|
|
183
|
+
✔ @superhero/eventflow-spoke (11673.269989ms)
|
|
184
|
+
|
|
185
|
+
▶ @superhero/eventflow-spoke/manager/hubs
|
|
186
|
+
✔ Add and retrieve sockets (1.926647ms)
|
|
187
|
+
✔ Check size and has methods (0.425031ms)
|
|
188
|
+
✔ Retrieve socket by IP and port (0.242366ms)
|
|
189
|
+
✔ Delete a socket (0.22056ms)
|
|
190
|
+
✔ Handle deleting non-existent socket gracefully (0.322003ms)
|
|
191
|
+
✔ Return empty array if no sockets exist (0.194319ms)
|
|
192
|
+
✔ @superhero/eventflow-spoke/manager/hubs (5.00919ms)
|
|
193
|
+
|
|
194
|
+
▶ ListenersManager
|
|
195
|
+
✔ Add and retrieve listeners by domain (1.716958ms)
|
|
196
|
+
✔ Throw error when overwriting existing domain (1.135766ms)
|
|
197
|
+
✔ Throw error when setting invalid listener instance (0.246827ms)
|
|
198
|
+
✔ Lazy-load listener for non-existent domain (0.365931ms)
|
|
199
|
+
✔ Delete listener by domain (0.345558ms)
|
|
200
|
+
✔ Wildcard event emission (1.018036ms)
|
|
201
|
+
✔ Emit specific event if listeners exist (0.530123ms)
|
|
202
|
+
✔ ListenersManager (7.564682ms)
|
|
203
|
+
|
|
204
|
+
tests 24
|
|
205
|
+
suites 5
|
|
206
|
+
pass 24
|
|
207
|
+
|
|
208
|
+
-------------------------------------------------------------------------------------------------------------------------
|
|
209
|
+
file | line % | branch % | funcs % | uncovered lines
|
|
210
|
+
-------------------------------------------------------------------------------------------------------------------------
|
|
211
|
+
config.js | 100.00 | 100.00 | 100.00 |
|
|
212
|
+
consume.js | 42.24 | 100.00 | 42.86 | 43-55 58-91 94-103 106-115
|
|
213
|
+
index.js | 86.26 | 80.43 | 88.89 | 34-36 44-47 100-104 111-112 148-151 179-183 205-210 219-231 287-29…
|
|
214
|
+
index.test.js | 100.00 | 100.00 | 100.00 |
|
|
215
|
+
manager | | | |
|
|
216
|
+
hubs.js | 100.00 | 100.00 | 100.00 |
|
|
217
|
+
hubs.test.js | 100.00 | 100.00 | 100.00 |
|
|
218
|
+
listeners.js | 100.00 | 100.00 | 88.89 |
|
|
219
|
+
listeners.test.js | 100.00 | 100.00 | 100.00 |
|
|
220
|
+
-------------------------------------------------------------------------------------------------------------------------
|
|
221
|
+
all files | 88.17 | 92.44 | 91.00 |
|
|
222
|
+
-------------------------------------------------------------------------------------------------------------------------
|
|
223
|
+
```
|
|
224
|
+
|
|
225
|
+
---
|
|
226
|
+
|
|
227
|
+
## License
|
|
228
|
+
|
|
229
|
+
This project is licensed under the MIT License.
|
|
230
|
+
|
|
231
|
+
## Contributing
|
|
232
|
+
|
|
233
|
+
Feel free to submit issues or pull requests for improvements or additional features.
|
package/config.js
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @memberof Eventflow.Spoke
|
|
3
|
+
*/
|
|
4
|
+
export default
|
|
5
|
+
{
|
|
6
|
+
dependency:
|
|
7
|
+
{
|
|
8
|
+
'@superhero/eventflow-db' : '@superhero/eventflow-db'
|
|
9
|
+
},
|
|
10
|
+
bootstrap:
|
|
11
|
+
{
|
|
12
|
+
'@superhero/eventflow-spoke' : true,
|
|
13
|
+
'@superhero/eventflow-spoke/consume' : true
|
|
14
|
+
},
|
|
15
|
+
locator:
|
|
16
|
+
{
|
|
17
|
+
'@superhero/eventflow-spoke' : './index.js',
|
|
18
|
+
'@superhero/eventflow-spoke/consume' : './consume.js'
|
|
19
|
+
},
|
|
20
|
+
eventflow:
|
|
21
|
+
{
|
|
22
|
+
spoke:
|
|
23
|
+
{
|
|
24
|
+
NAME : process.env.EVENTFLOW_SPOKE_NAME ?? 'EVENTFLOW-SPOKE',
|
|
25
|
+
CONNECT_TO_HUB_TIMEOUT : process.env.EVENTFLOW_SPOKE_CONNECT_TO_HUB_TIMEOUT ?? 5e3,
|
|
26
|
+
KEEP_ALIVE_INTERVAL : process.env.EVENTFLOW_SPOKE_KEEP_ALIVE_INTERVAL ?? 60e3,
|
|
27
|
+
TCP_SOCKET_CLIENT_OPTIONS : process.env.EVENTFLOW_SPOKE_TCP_SOCKET_CLIENT_OPTIONS,
|
|
28
|
+
|
|
29
|
+
consume:
|
|
30
|
+
{
|
|
31
|
+
// '<event_domain>' : '*',
|
|
32
|
+
// '<event_domain>' : '<event_name>'
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
}
|
package/consume.js
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
export function locate(locator)
|
|
2
|
+
{
|
|
3
|
+
const
|
|
4
|
+
spoke = locator('@superhero/eventflow-spoke'),
|
|
5
|
+
consume = new ConsumeService(locator, spoke)
|
|
6
|
+
|
|
7
|
+
return consume
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Attaches consumers on bootstrap according to a consumer map
|
|
12
|
+
* routed to be handled by an aligned domain service method.
|
|
13
|
+
*
|
|
14
|
+
* The event name is transformed into a camelcase method name
|
|
15
|
+
* by removing any non-alphanumeric characters and capitalizing
|
|
16
|
+
* the first letter of each word.
|
|
17
|
+
*
|
|
18
|
+
* The consumer method is called with the event as an argument.
|
|
19
|
+
*
|
|
20
|
+
* If the consumer method does not exist in the domain service,
|
|
21
|
+
* an error is composed and forwarded to the domain service error
|
|
22
|
+
* handler method, if it exists. Otherwise, the error is thrown.
|
|
23
|
+
*
|
|
24
|
+
* If the domain service error handler method fails to handle the
|
|
25
|
+
* error, a new error describing the failure is composed and
|
|
26
|
+
* thrown.
|
|
27
|
+
*
|
|
28
|
+
* @memberof Eventflow.Spoke
|
|
29
|
+
*/
|
|
30
|
+
export default class ConsumeService
|
|
31
|
+
{
|
|
32
|
+
#locator
|
|
33
|
+
#spoke
|
|
34
|
+
#lookupConsumerMap = new Map
|
|
35
|
+
|
|
36
|
+
constructor(locator, spoke)
|
|
37
|
+
{
|
|
38
|
+
this.#locator = locator
|
|
39
|
+
this.#spoke = spoke
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
async bootstrap(consumerMap)
|
|
43
|
+
{
|
|
44
|
+
for(const domain in consumerMap)
|
|
45
|
+
{
|
|
46
|
+
const
|
|
47
|
+
service = this.#locator.locate(domain),
|
|
48
|
+
consumer = this.#consumer.bind(this, service)
|
|
49
|
+
|
|
50
|
+
for(const name of consumerMap[domain])
|
|
51
|
+
{
|
|
52
|
+
await this.#spoke.consume(domain, name, consumer)
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
async #consumer(service, event)
|
|
58
|
+
{
|
|
59
|
+
const consumer = this.#lazyloadConsumerName(event.name)
|
|
60
|
+
|
|
61
|
+
try
|
|
62
|
+
{
|
|
63
|
+
await service[consumer](event)
|
|
64
|
+
}
|
|
65
|
+
catch(reason)
|
|
66
|
+
{
|
|
67
|
+
const error = new Error('consumer failed to handle event')
|
|
68
|
+
error.code = 'E_EVENTFLOW_SPOKE_CONSUMER_FAILED'
|
|
69
|
+
error.cause = reason
|
|
70
|
+
error.consumer = consumer
|
|
71
|
+
|
|
72
|
+
if('function' === typeof service.onError)
|
|
73
|
+
{
|
|
74
|
+
try
|
|
75
|
+
{
|
|
76
|
+
await service.onError(error, event)
|
|
77
|
+
}
|
|
78
|
+
catch(onErrorReason)
|
|
79
|
+
{
|
|
80
|
+
const onErrorFailed = new Error('error handler failed')
|
|
81
|
+
onErrorFailed.code = 'E_EVENTFLOW_SPOKE_CONSUMER_ERROR_HANDLER_FAILED'
|
|
82
|
+
onErrorFailed.cause = [ onErrorReason, error ]
|
|
83
|
+
throw onErrorFailed
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
else
|
|
87
|
+
{
|
|
88
|
+
throw error
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
#lazyloadConsumerName(eventName)
|
|
94
|
+
{
|
|
95
|
+
if(this.#lookupConsumerMap.has(eventName))
|
|
96
|
+
{
|
|
97
|
+
return this.#lookupConsumerMap.get(eventName)
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const consumer = this.#composeConsumerName(eventName)
|
|
101
|
+
this.#lookupConsumerMap.set(eventName, consumer)
|
|
102
|
+
return consumer
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
#composeConsumerName(eventName)
|
|
106
|
+
{
|
|
107
|
+
const
|
|
108
|
+
observerLowercase = eventName.toLowerCase(),
|
|
109
|
+
observerSeperated = observerLowercase.replace(/\W+/g, ' '),
|
|
110
|
+
observerDivided = observerSeperated.split(' '),
|
|
111
|
+
observerCamelcase = observerDivided.map((s) => s[0].toUpperCase() + s.slice(1)),
|
|
112
|
+
observerName = 'on' + observerCamelcase.join('')
|
|
113
|
+
|
|
114
|
+
return observerName
|
|
115
|
+
}
|
|
116
|
+
}
|
package/index.js
ADDED
|
@@ -0,0 +1,393 @@
|
|
|
1
|
+
import { setTimeout as wait } from 'node:timers/promises'
|
|
2
|
+
import Channel from '@superhero/tcp-record-channel'
|
|
3
|
+
import IdNameGenerator from '@superhero/id-name-generator'
|
|
4
|
+
import Log from '@superhero/log'
|
|
5
|
+
import deepmerge from '@superhero/deep/merge'
|
|
6
|
+
import deepassign from '@superhero/deep/assign'
|
|
7
|
+
import CertificatesManager from '@superhero/eventflow-certificates'
|
|
8
|
+
import HubsManager from '@superhero/eventflow-spoke/manager/hubs'
|
|
9
|
+
import ListenersManager from '@superhero/eventflow-spoke/manager/listeners'
|
|
10
|
+
|
|
11
|
+
export function locate(locator)
|
|
12
|
+
{
|
|
13
|
+
const
|
|
14
|
+
config = locator('@superhero/config').find('eventflow/spoke'),
|
|
15
|
+
db = locator('@superhero/eventflow-db')
|
|
16
|
+
|
|
17
|
+
return new Spoke(config, db)
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* @memberof Eventflow
|
|
22
|
+
*/
|
|
23
|
+
export default class Spoke
|
|
24
|
+
{
|
|
25
|
+
#spokeID
|
|
26
|
+
|
|
27
|
+
abortion = new AbortController()
|
|
28
|
+
channel = new Channel()
|
|
29
|
+
hubs = new HubsManager()
|
|
30
|
+
subscriptions = new ListenersManager()
|
|
31
|
+
consumers = new ListenersManager()
|
|
32
|
+
|
|
33
|
+
get spokeID()
|
|
34
|
+
{
|
|
35
|
+
return this.#spokeID
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
constructor(config, db)
|
|
39
|
+
{
|
|
40
|
+
if('string' !== typeof config.NAME
|
|
41
|
+
|| 0 === config.NAME.length
|
|
42
|
+
|| (/[^a-z0-9\-\.]/i).test(config.NAME))
|
|
43
|
+
{
|
|
44
|
+
const error = new Error(`invalid config.NAME (${config.NAME})`)
|
|
45
|
+
error.code = 'E_EVENTFLOW_HUB_INVALID_CONFIG_NAME'
|
|
46
|
+
throw error
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
this.#spokeID = (new IdNameGenerator().generateId() + '.' + config.NAME).toUpperCase()
|
|
50
|
+
this.config = config
|
|
51
|
+
this.db = db
|
|
52
|
+
this.log = new Log({ label: `[${config.NAME}]` })
|
|
53
|
+
this.certificates = new CertificatesManager(config.NAME, this.#spokeID, config.certificates, db, this.log)
|
|
54
|
+
|
|
55
|
+
this.channel.on('record', this.#onRecord.bind(this))
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
async bootstrap()
|
|
59
|
+
{
|
|
60
|
+
await this.#pollOnlineHubs()
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
async destroy()
|
|
64
|
+
{
|
|
65
|
+
const reason = new Error('hub is destroyed')
|
|
66
|
+
reason.code = 'E_EVENTFLOW_HUB_DESTROYED'
|
|
67
|
+
|
|
68
|
+
this.abortion.abort(reason)
|
|
69
|
+
|
|
70
|
+
for(const socket of this.hubs.all)
|
|
71
|
+
{
|
|
72
|
+
socket.end()
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
this.hubs.destroy()
|
|
76
|
+
this.log.warn`destroyed`
|
|
77
|
+
// setTimeout(() => this.db.close(), 5000)
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
async #pollOnlineHubs()
|
|
81
|
+
{
|
|
82
|
+
if(this.abortion.signal.aborted)
|
|
83
|
+
{
|
|
84
|
+
return
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
this.log.warn`polling for online hubs`
|
|
88
|
+
|
|
89
|
+
const hubs = await this.db.readOnlineHubs()
|
|
90
|
+
|
|
91
|
+
for(const hub of hubs)
|
|
92
|
+
{
|
|
93
|
+
await this.#connectToHub(hub)
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
if(this.hubs.size)
|
|
97
|
+
{
|
|
98
|
+
this.log.info`polling for online hubs completed`
|
|
99
|
+
}
|
|
100
|
+
else
|
|
101
|
+
{
|
|
102
|
+
await wait(3e3)
|
|
103
|
+
await this.#pollOnlineHubs()
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
async #connectToHub({ id:hubID, external_ip:hubIP, external_port:hubPort })
|
|
108
|
+
{
|
|
109
|
+
if(this.abortion.signal.aborted)
|
|
110
|
+
{
|
|
111
|
+
return
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
try
|
|
115
|
+
{
|
|
116
|
+
if(this.hubs.hasSocket(hubIP, hubPort))
|
|
117
|
+
{
|
|
118
|
+
const error = new Error('already connected to hub')
|
|
119
|
+
error.code = 'E_EVENTFLOW_SPOKE_ALREADY_CONNECTED_TO_HUB'
|
|
120
|
+
reject(error)
|
|
121
|
+
}
|
|
122
|
+
else
|
|
123
|
+
{
|
|
124
|
+
this.log.info`connecting to hub ${hubID} › ${hubIP}:${hubPort}`
|
|
125
|
+
|
|
126
|
+
const
|
|
127
|
+
rootCA = await this.certificates.root,
|
|
128
|
+
spokeICA = await this.certificates.intermediate,
|
|
129
|
+
spokeLeaf = await this.certificates.leaf,
|
|
130
|
+
ca = rootCA.cert,
|
|
131
|
+
certChain = spokeLeaf.cert + spokeICA.cert,
|
|
132
|
+
dynamicConfig = { servername:hubID, host:hubIP, port:hubPort, ca, cert:certChain, key:spokeLeaf.key, passphrase:spokeLeaf.pass },
|
|
133
|
+
peerHubConfig = deepmerge(dynamicConfig, this.config.TCP_SOCKET_CLIENT_OPTIONS),
|
|
134
|
+
hub = await this.channel.createTlsClient(peerHubConfig)
|
|
135
|
+
|
|
136
|
+
hub.id = hubID
|
|
137
|
+
this.hubs.add(hubIP, hubPort, hub)
|
|
138
|
+
hub.on('close', this.#onHubDisconnected .bind(this, hub))
|
|
139
|
+
hub.on('error', this.#onHubError .bind(this, hub))
|
|
140
|
+
this.log.info`connected to hub ${hubID} › ${hubIP}:${hubPort}`
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
catch(error)
|
|
144
|
+
{
|
|
145
|
+
switch(error.code)
|
|
146
|
+
{
|
|
147
|
+
case 'E_EVENTFLOW_SPOKE_ALREADY_CONNECTED_TO_HUB':
|
|
148
|
+
{
|
|
149
|
+
this.log.warn`already connected to hub ${hubID} › ${hubIP}:${hubPort}`
|
|
150
|
+
break
|
|
151
|
+
}
|
|
152
|
+
default:
|
|
153
|
+
{
|
|
154
|
+
const message = `failed to connect to hub ${hubID} › ${hubIP}:${hubPort} [${error.code}] ${error.message}`
|
|
155
|
+
this.log.fail`failed to connect to hub ${hubID} › ${hubIP}:${hubPort} [${error.code}] ${error.message}`
|
|
156
|
+
await this.db.persistLog({ agent:this.#spokeID, message, error })
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* @see @superhero/tcp-record-channel
|
|
164
|
+
* @param {String[]} record The unit seperated record
|
|
165
|
+
* @param {node:tls.TLSSocket} hub A hub tls socket
|
|
166
|
+
*/
|
|
167
|
+
async #onRecord([ event, ...args ], hub)
|
|
168
|
+
{
|
|
169
|
+
switch(event)
|
|
170
|
+
{
|
|
171
|
+
case 'online' : return this.#onHubOnlineMessage (hub, ...args)
|
|
172
|
+
case 'publish' : return this.#onHubPublishMessage (hub, ...args)
|
|
173
|
+
// only recognize the above listed events
|
|
174
|
+
default: this.log.fail`observed invalid message ${event} from hub ${hub.id}`
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
async #onHubError(hub, error)
|
|
179
|
+
{
|
|
180
|
+
const message = `hub socket error ${hub.id} [${error.code}] ${error.message}`
|
|
181
|
+
this.log.fail`hub socket error ${hub.id} [${error.code}] ${error.message}`
|
|
182
|
+
await this.db.persistLog({ agent:this.#spokeID, message, error })
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
async #onHubDisconnected(hub)
|
|
186
|
+
{
|
|
187
|
+
this.hubs.delete(hub)
|
|
188
|
+
|
|
189
|
+
this.log.warn`disconnected from hub ${hub.id}`
|
|
190
|
+
|
|
191
|
+
if(this.hubs.size === 0)
|
|
192
|
+
{
|
|
193
|
+
await this.#pollOnlineHubs()
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
async #onHubPublishMessage(_, domain, id, name, pid)
|
|
198
|
+
{
|
|
199
|
+
this.subscriptions[domain].emit(name, { domain, id, name, pid })
|
|
200
|
+
await this.db.updateEventPublishedToConsumedBySpoke(id, this.#spokeID)
|
|
201
|
+
&& this.consumers[domain].emit(name, { domain, id, name, pid })
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
async #onHubOnlineMessage(hub, hubID, hubIP, hubPort)
|
|
205
|
+
{
|
|
206
|
+
const message = `recieved hub online message ${hubID} › ${hubIP}:${hubPort} from hub ${hub.id}`
|
|
207
|
+
this.log.info`recieved hub online message ${hubID} › ${hubIP}:${hubPort} from hub ${hub.id}`
|
|
208
|
+
await this.db.persistLog({ agent:this.#spokeID, message })
|
|
209
|
+
await this.#connectToHub({ id:hubID, external_ip:hubIP, external_port:hubPort })
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
async #onConsumed(callback, event)
|
|
213
|
+
{
|
|
214
|
+
try
|
|
215
|
+
{
|
|
216
|
+
await callback(event)
|
|
217
|
+
}
|
|
218
|
+
catch(reason)
|
|
219
|
+
{
|
|
220
|
+
const error = new Error(`spoke callback failed to consume event ${event.domain} › ${event.name} › ${event.pid} › ${event.id}`)
|
|
221
|
+
error.code = 'E_EVENTFLOW_SPOKE_CONSUME_OBSERVER_ERROR'
|
|
222
|
+
error.cause = reason
|
|
223
|
+
error.event = event
|
|
224
|
+
|
|
225
|
+
await this.db.updateEventPublishedToFailed(event.id)
|
|
226
|
+
const message = `failed to consume event ${event.domain} › ${event.name} › ${event.pid} › ${event.id}`
|
|
227
|
+
this.log.fail`failed to consume event ${event.domain} › ${event.name} › ${event.pid} › ${event.id}`
|
|
228
|
+
await this.db.persistLog({ agent:this.#spokeID, message, error })
|
|
229
|
+
|
|
230
|
+
return
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
await this.db.updateEventPublishedToSuccess(event.id)
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
#broadcast(type, ...args)
|
|
237
|
+
{
|
|
238
|
+
return this.channel.broadcast(this.hubs.all, [ type, ...args ])
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
consume(domain, name, callback)
|
|
242
|
+
{
|
|
243
|
+
this.consumers[domain].on(name, this.#onConsumed.bind(this, callback))
|
|
244
|
+
this.#broadcast('subscribe', domain, name)
|
|
245
|
+
this.log.info`consuming: ${domain} › ${name}`
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
subscribe(domain, name, callback)
|
|
249
|
+
{
|
|
250
|
+
this.subscriptions[domain].on(name, callback)
|
|
251
|
+
this.#broadcast('subscribe', domain, name)
|
|
252
|
+
this.log.info`subscribes to: ${domain} › ${name}`
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
unsubscribe(domain, name, callback)
|
|
256
|
+
{
|
|
257
|
+
this.subscriptions[domain].off(name, callback)
|
|
258
|
+
|
|
259
|
+
// If there are no listeners for the domain and name
|
|
260
|
+
// then broadcast an unsubscribe message.
|
|
261
|
+
if(0 === this.subscriptions[domain].listenerCount(name)
|
|
262
|
+
&& 0 === this.consumers [domain].listenerCount(name))
|
|
263
|
+
{
|
|
264
|
+
this.#broadcast('unsubscribe', domain, name)
|
|
265
|
+
delete this.subscriptions[domain]
|
|
266
|
+
delete this.consumers [domain]
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/**
|
|
271
|
+
* @param {string} domain
|
|
272
|
+
* @param {string} pid
|
|
273
|
+
* @param {string} [eventNames=["success","failed"]] string or array of event names that will be waited for
|
|
274
|
+
* @param {number} [timeout=10e3] milliseconds to wait before throwing a timeout error
|
|
275
|
+
* @throws E_EVENTFLOW_WAIT_TIMEOUT
|
|
276
|
+
*/
|
|
277
|
+
wait(domain, pid, eventNames=['success','failed'], timeout=10e3)
|
|
278
|
+
{
|
|
279
|
+
if(false === Array.isArray(eventNames))
|
|
280
|
+
{
|
|
281
|
+
eventNames = [eventNames]
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
return new Promise(async (accept, reject) =>
|
|
285
|
+
{
|
|
286
|
+
const waitTimeout = setTimeout(() =>
|
|
287
|
+
{
|
|
288
|
+
const error = new Error(`wait timed out (${timeout}) for ${domain} › ${pid} › ${eventNames.join(' | ')}`)
|
|
289
|
+
error.code = 'E_EVENTFLOW_WAIT_TIMEOUT'
|
|
290
|
+
reject(error)
|
|
291
|
+
}, timeout)
|
|
292
|
+
|
|
293
|
+
const subscriber = (event) =>
|
|
294
|
+
{
|
|
295
|
+
if(event.pid === pid)
|
|
296
|
+
{
|
|
297
|
+
Promise.allSettled(eventNames.map((name) => this.unsubscribe(domain, name, subscriber)))
|
|
298
|
+
.then(() =>
|
|
299
|
+
{
|
|
300
|
+
clearTimeout(waitTimeout)
|
|
301
|
+
accept(event)
|
|
302
|
+
})
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
await Promise.allSettled(eventNames.map((name) => this.subscribe(domain, name, subscriber)))
|
|
307
|
+
})
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
async publish(event)
|
|
311
|
+
{
|
|
312
|
+
const eventID = await this.persist(event)
|
|
313
|
+
await this.db.persistEventPublished({ event_id:eventID, publisher:this.#spokeID })
|
|
314
|
+
this.#broadcast('publish', event.domain, eventID, event.name, event.pid)
|
|
315
|
+
this.log.info`published event ${eventID} › ${event.domain} › ${event.name} › ${event.pid}`
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
async schedule(scheduled, event)
|
|
319
|
+
{
|
|
320
|
+
const scheduledDate = new Date(scheduled)
|
|
321
|
+
|
|
322
|
+
if(isNaN(scheduledDate))
|
|
323
|
+
{
|
|
324
|
+
const error = new Error(`invalid scheduled date ${scheduled}`)
|
|
325
|
+
error.code = 'E_EVENTFLOW_SPOKE_SCHEDULE_INVALID_DATE'
|
|
326
|
+
error.event = event
|
|
327
|
+
throw error
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
scheduled = scheduledDate.toJSON().replace('T', ' ').substring(0, 19)
|
|
331
|
+
|
|
332
|
+
const eventID = await this.persist(event)
|
|
333
|
+
await this.db.persistEventPublished({ event_id:eventID, publisher:this.#spokeID })
|
|
334
|
+
await this.db.persistEventScheduled({ event_id:eventID, scheduled })
|
|
335
|
+
this.log.info`scheduled event ${eventID} › ${event.domain} › ${event.name} › ${scheduled}`
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
async persist(event)
|
|
339
|
+
{
|
|
340
|
+
return await this.db.persistEvent(event)
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
async delete(eventID)
|
|
344
|
+
{
|
|
345
|
+
return await this.db.deleteEvent(eventID)
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
async deleteEventlog(domain, pid)
|
|
349
|
+
{
|
|
350
|
+
return await this.db.deleteEventByDomainAndPid(domain, pid)
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
async read(eventID)
|
|
354
|
+
{
|
|
355
|
+
return await this.db.readEvent(eventID)
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
async readEventlog(domain, pid)
|
|
359
|
+
{
|
|
360
|
+
return await this.db.readEventsByDomainAndPid(domain, pid)
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
async readEventlogState(domain, pid)
|
|
364
|
+
{
|
|
365
|
+
const
|
|
366
|
+
eventlog = await this.readEventlog(domain, pid),
|
|
367
|
+
state = this.composeEventlogState(eventlog)
|
|
368
|
+
|
|
369
|
+
return state
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
composeEventlogState(eventlog, size=10)
|
|
373
|
+
{
|
|
374
|
+
const
|
|
375
|
+
merge = (start, end) => deepmerge(...eventlog.slice(start, end).map((event) => event.data)),
|
|
376
|
+
state = {},
|
|
377
|
+
length = eventlog.length
|
|
378
|
+
|
|
379
|
+
for(let i = size; i < length; i += size)
|
|
380
|
+
{
|
|
381
|
+
const segment = merge(i - size, i)
|
|
382
|
+
deepassign(state, segment)
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
const
|
|
386
|
+
spare = length % size,
|
|
387
|
+
segment = merge(length - spare, length)
|
|
388
|
+
|
|
389
|
+
deepassign(state, segment)
|
|
390
|
+
|
|
391
|
+
return state
|
|
392
|
+
}
|
|
393
|
+
}
|
package/index.test.js
ADDED
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import assert from 'node:assert/strict'
|
|
2
|
+
import util from 'node:util'
|
|
3
|
+
import Config from '@superhero/config'
|
|
4
|
+
import Locator from '@superhero/locator'
|
|
5
|
+
import { suite, test, before, after } from 'node:test'
|
|
6
|
+
|
|
7
|
+
util.inspect.defaultOptions.depth = 5
|
|
8
|
+
|
|
9
|
+
suite('@superhero/eventflow-spoke', () =>
|
|
10
|
+
{
|
|
11
|
+
let locator, spoke, hub
|
|
12
|
+
|
|
13
|
+
before(async () =>
|
|
14
|
+
{
|
|
15
|
+
locator = new Locator()
|
|
16
|
+
|
|
17
|
+
const config = new Config()
|
|
18
|
+
await config.add('@superhero/eventflow-db')
|
|
19
|
+
await config.add('@superhero/eventflow-hub')
|
|
20
|
+
await config.add('./config.js')
|
|
21
|
+
config.assign({ eventflow: { spoke: { certificates: { CERT_PASS_ENCRYPTION_KEY: 'encryptionKey123' }}}})
|
|
22
|
+
config.assign({ eventflow: { hub: { certificates: { CERT_PASS_ENCRYPTION_KEY: 'encryptionKey123' }}}})
|
|
23
|
+
|
|
24
|
+
locator.set('@superhero/config', config)
|
|
25
|
+
await locator.eagerload('@superhero/eventflow-db')
|
|
26
|
+
await locator.eagerload(config.find('locator'))
|
|
27
|
+
|
|
28
|
+
spoke = locator('@superhero/eventflow-spoke')
|
|
29
|
+
hub = locator('@superhero/eventflow-hub')
|
|
30
|
+
|
|
31
|
+
await hub.bootstrap()
|
|
32
|
+
await spoke.bootstrap()
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
after(async () =>
|
|
36
|
+
{
|
|
37
|
+
await locator.destroy()
|
|
38
|
+
locator.clear()
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
suite('Lifecycle', () =>
|
|
42
|
+
{
|
|
43
|
+
test('Can initialize EventflowSpoke correctly', () =>
|
|
44
|
+
{
|
|
45
|
+
assert.strictEqual(spoke.config.NAME, 'EVENTFLOW-SPOKE')
|
|
46
|
+
assert.ok(spoke.channel)
|
|
47
|
+
assert.ok(spoke.certificates)
|
|
48
|
+
assert.ok(spoke.hubs)
|
|
49
|
+
assert.ok(spoke.subscriptions)
|
|
50
|
+
assert.ok(spoke.consumers)
|
|
51
|
+
})
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
suite('Event Management', () =>
|
|
55
|
+
{
|
|
56
|
+
test('Subscribe', async () =>
|
|
57
|
+
{
|
|
58
|
+
await spoke.publish({ domain: 'domain1', name: 'event1', pid: 'pid1', data: {} })
|
|
59
|
+
const event = await new Promise((accept) => spoke.subscribe('domain1', 'event1', accept))
|
|
60
|
+
assert.ok(event)
|
|
61
|
+
assert.strictEqual(event.name, 'event1')
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
test('Consume', async () =>
|
|
65
|
+
{
|
|
66
|
+
await spoke.publish({ domain: 'domain1', name: 'event1', pid: 'pid1', data: {} })
|
|
67
|
+
const event = await new Promise((accept) => spoke.consume('domain1', 'event1', accept))
|
|
68
|
+
assert.ok(event)
|
|
69
|
+
assert.strictEqual(event.name, 'event1')
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
test('Wait for event', async () =>
|
|
73
|
+
{
|
|
74
|
+
const event = { domain: 'domain1', name: 'success', pid: 'pid1', data: {} }
|
|
75
|
+
await spoke.publish(event)
|
|
76
|
+
const waitPromise = await spoke.wait('domain1', 'pid1', 'success', 1e3)
|
|
77
|
+
const result = await waitPromise
|
|
78
|
+
assert.strictEqual(result.name, 'success')
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
test('Schedule events', async () =>
|
|
82
|
+
{
|
|
83
|
+
const scheduled = Date.now()
|
|
84
|
+
await spoke.schedule(scheduled, { domain: 'domain1', name: 'event1', pid: 'pid1', data: {} })
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
test('Delete event', async () =>
|
|
88
|
+
{
|
|
89
|
+
const id = await spoke.persist({ domain: 'domain1', name: 'event1', pid: 'pid1', data: {} })
|
|
90
|
+
await spoke.delete(id)
|
|
91
|
+
await assert.rejects(spoke.read(id), { code:'E_EVENTFLOW_DB_EVENT_NOT_FOUND' })
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
test('Delete event log', async () =>
|
|
95
|
+
{
|
|
96
|
+
await spoke.deleteEventlog('domain1', 'pid1')
|
|
97
|
+
await spoke.persist({ domain: 'domain1', name: 'event1', pid: 'pid1', data: {} })
|
|
98
|
+
const preEvents = await spoke.readEventlog('domain1', 'pid1')
|
|
99
|
+
assert.strictEqual(preEvents.length, 1)
|
|
100
|
+
await spoke.deleteEventlog('domain1', 'pid1')
|
|
101
|
+
const postEvents = await spoke.readEventlog('domain1', 'pid1')
|
|
102
|
+
assert.strictEqual(postEvents.length, 0)
|
|
103
|
+
})
|
|
104
|
+
|
|
105
|
+
test('Read event', async () =>
|
|
106
|
+
{
|
|
107
|
+
const
|
|
108
|
+
id = await spoke.persist({ domain: 'domain1', name: 'event1', pid: 'pid1', data: {} }),
|
|
109
|
+
event = await spoke.read(id)
|
|
110
|
+
|
|
111
|
+
assert.strictEqual(event.id, id)
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
test('Read event log', async () =>
|
|
115
|
+
{
|
|
116
|
+
await spoke.deleteEventlog('domain1', 'pid1')
|
|
117
|
+
await spoke.persist({ domain: 'domain1', name: 'event1', pid: 'pid1', data: {} })
|
|
118
|
+
const events = await spoke.readEventlog('domain1', 'pid1')
|
|
119
|
+
assert.strictEqual(events.length, 1)
|
|
120
|
+
assert.strictEqual(events[0].name, 'event1')
|
|
121
|
+
})
|
|
122
|
+
|
|
123
|
+
test('Read event log state', async () =>
|
|
124
|
+
{
|
|
125
|
+
await spoke.deleteEventlog('domain1', 'pid1')
|
|
126
|
+
await spoke.persist({ domain: 'domain1', name: 'event1', pid: 'pid1', data: { key1: 'value1' } })
|
|
127
|
+
await spoke.persist({ domain: 'domain1', name: 'event2', pid: 'pid1', data: { key2: 'value2' } })
|
|
128
|
+
const state = await spoke.readEventlogState('domain1', 'pid1')
|
|
129
|
+
assert.strictEqual(state.key1, 'value1')
|
|
130
|
+
assert.strictEqual(state.key2, 'value2')
|
|
131
|
+
})
|
|
132
|
+
|
|
133
|
+
test('Compose event log state', () =>
|
|
134
|
+
{
|
|
135
|
+
const eventlog =
|
|
136
|
+
[
|
|
137
|
+
{ data: { key1: 'value1' } },
|
|
138
|
+
{ data: { key2: 'value2' } },
|
|
139
|
+
{ data: { key3: 'value3' } }
|
|
140
|
+
]
|
|
141
|
+
const state = spoke.composeEventlogState(eventlog)
|
|
142
|
+
assert.strictEqual(state.key1, 'value1')
|
|
143
|
+
assert.strictEqual(state.key2, 'value2')
|
|
144
|
+
assert.strictEqual(state.key3, 'value3')
|
|
145
|
+
})
|
|
146
|
+
})
|
|
147
|
+
})
|
package/manager/hubs.js
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Manage eventflow hub sockets.
|
|
3
|
+
* @memberof Eventflow.Spoke
|
|
4
|
+
*/
|
|
5
|
+
export default class HubsManager
|
|
6
|
+
{
|
|
7
|
+
#socketMap = new Map
|
|
8
|
+
#ipPortMap = new Map
|
|
9
|
+
|
|
10
|
+
destroy()
|
|
11
|
+
{
|
|
12
|
+
this.#socketMap.clear()
|
|
13
|
+
this.#ipPortMap.clear()
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
#serialize(ip, port)
|
|
17
|
+
{
|
|
18
|
+
return `${ip.replace(/^::ffff:/, '')}:${port}`
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
get size()
|
|
22
|
+
{
|
|
23
|
+
return this.#socketMap.size
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
add(ip, port, socket)
|
|
27
|
+
{
|
|
28
|
+
const ipPort = this.#serialize(ip, port)
|
|
29
|
+
|
|
30
|
+
this.#socketMap.set(socket, ipPort)
|
|
31
|
+
this.#ipPortMap.set(ipPort, socket)
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
hasSocket(ip, port)
|
|
35
|
+
{
|
|
36
|
+
const ipPort = this.#serialize(ip, port)
|
|
37
|
+
return this.#ipPortMap.has(ipPort)
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
has(socket)
|
|
41
|
+
{
|
|
42
|
+
return this.#socketMap.has(socket)
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
get all()
|
|
46
|
+
{
|
|
47
|
+
return [...this.#ipPortMap.values()]
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
getSocket(ip, port)
|
|
51
|
+
{
|
|
52
|
+
const ipPort = this.#serialize(ip, port)
|
|
53
|
+
return this.#ipPortMap.get(ipPort)
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
delete(socket)
|
|
57
|
+
{
|
|
58
|
+
const ipPort = this.#socketMap.get(socket)
|
|
59
|
+
|
|
60
|
+
this.#socketMap.delete(socket)
|
|
61
|
+
this.#ipPortMap.delete(ipPort)
|
|
62
|
+
}
|
|
63
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import assert from 'node:assert/strict'
|
|
2
|
+
import { suite, test } from 'node:test'
|
|
3
|
+
import HubsManager from '@superhero/eventflow-spoke/manager/hubs'
|
|
4
|
+
|
|
5
|
+
suite('@superhero/eventflow-spoke/manager/hubs', () =>
|
|
6
|
+
{
|
|
7
|
+
test('Add and retrieve sockets', async () =>
|
|
8
|
+
{
|
|
9
|
+
const
|
|
10
|
+
hubs = new HubsManager(),
|
|
11
|
+
socket1 = {},
|
|
12
|
+
socket2 = {}
|
|
13
|
+
|
|
14
|
+
hubs.add('127.0.0.1', 8080, socket1)
|
|
15
|
+
hubs.add('127.0.0.1', 8081, socket2)
|
|
16
|
+
|
|
17
|
+
assert.deepStrictEqual(hubs.all, [socket1, socket2])
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
test('Check size and has methods', async () =>
|
|
21
|
+
{
|
|
22
|
+
const
|
|
23
|
+
hubs = new HubsManager(),
|
|
24
|
+
socket = {}
|
|
25
|
+
|
|
26
|
+
hubs.add('192.168.1.1', 9090, socket)
|
|
27
|
+
|
|
28
|
+
assert.strictEqual(hubs.size, 1)
|
|
29
|
+
assert.strictEqual(hubs.has(socket), true)
|
|
30
|
+
assert.strictEqual(hubs.hasSocket('192.168.1.1', 9090), true)
|
|
31
|
+
assert.strictEqual(hubs.hasSocket('192.168.1.2', 9090), false)
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
test('Retrieve socket by IP and port', async () =>
|
|
35
|
+
{
|
|
36
|
+
const
|
|
37
|
+
hubs = new HubsManager(),
|
|
38
|
+
socket = {}
|
|
39
|
+
|
|
40
|
+
hubs.add('10.0.0.1', 8080, socket)
|
|
41
|
+
|
|
42
|
+
const retrievedSocket = hubs.getSocket('10.0.0.1', 8080)
|
|
43
|
+
assert.strictEqual(retrievedSocket, socket)
|
|
44
|
+
|
|
45
|
+
const nonExistentSocket = hubs.getSocket('10.0.0.2', 8080)
|
|
46
|
+
assert.strictEqual(nonExistentSocket, undefined)
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
test('Delete a socket', async () =>
|
|
50
|
+
{
|
|
51
|
+
const
|
|
52
|
+
hubs = new HubsManager(),
|
|
53
|
+
socket1 = {},
|
|
54
|
+
socket2 = {}
|
|
55
|
+
|
|
56
|
+
hubs.add('172.16.0.1', 3000, socket1)
|
|
57
|
+
hubs.add('172.16.0.2', 3001, socket2)
|
|
58
|
+
hubs.delete(socket1)
|
|
59
|
+
|
|
60
|
+
assert.deepStrictEqual(hubs.all, [socket2])
|
|
61
|
+
assert.strictEqual(hubs.size, 1)
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
test('Handle deleting non-existent socket gracefully', async () =>
|
|
65
|
+
{
|
|
66
|
+
const
|
|
67
|
+
hubs = new HubsManager(),
|
|
68
|
+
socket = {}
|
|
69
|
+
|
|
70
|
+
assert.doesNotThrow(() => hubs.delete(socket))
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
test('Return empty array if no sockets exist', async () =>
|
|
74
|
+
{
|
|
75
|
+
const hubs = new HubsManager()
|
|
76
|
+
assert.deepStrictEqual(hubs.all, [])
|
|
77
|
+
})
|
|
78
|
+
})
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import EventEmitter from 'node:events'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* A class that extends EventEmitter and emits events by name. If there are no listeners
|
|
5
|
+
* for the event name, the event will be emitted to the wildcard event name '*'.
|
|
6
|
+
*
|
|
7
|
+
* @memberof Eventflow.Spoke
|
|
8
|
+
*/
|
|
9
|
+
export class Listener extends EventEmitter
|
|
10
|
+
{
|
|
11
|
+
emit(name, ...args)
|
|
12
|
+
{
|
|
13
|
+
return this.listenerCount(name)
|
|
14
|
+
? super.emit(name, ...args)
|
|
15
|
+
: super.emit('*', ...args)
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Manage event emitters for listeners to events by domain and name.
|
|
21
|
+
* Each domain has it's own listener/event-emitter which is expected to emit events by name.
|
|
22
|
+
*
|
|
23
|
+
* @memberof Eventflow.Spoke
|
|
24
|
+
*/
|
|
25
|
+
export default class ListenersManager
|
|
26
|
+
{
|
|
27
|
+
static Listener = Listener
|
|
28
|
+
|
|
29
|
+
#map = new Map
|
|
30
|
+
|
|
31
|
+
constructor()
|
|
32
|
+
{
|
|
33
|
+
return new Proxy(this,
|
|
34
|
+
{
|
|
35
|
+
set: (_, domain, listener) =>
|
|
36
|
+
{
|
|
37
|
+
if(this.#map.has(domain))
|
|
38
|
+
{
|
|
39
|
+
const error = new Error(`cannot overwrite an existing domain listener: ${domain}`)
|
|
40
|
+
error.code = 'E_EVENTFLOW_LISTENERS_DOMAIN_ALREADY_EXISTS'
|
|
41
|
+
throw error
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
if(listener instanceof Listener)
|
|
45
|
+
{
|
|
46
|
+
this.#map.set(domain, listener)
|
|
47
|
+
return true
|
|
48
|
+
}
|
|
49
|
+
else
|
|
50
|
+
{
|
|
51
|
+
const error = new Error('can only set a listener that is of instance Listener')
|
|
52
|
+
error.code = 'E_EVENTFLOW_LISTENERS_INVLAID_INSTANCE_OF_LISTENER'
|
|
53
|
+
throw error
|
|
54
|
+
}
|
|
55
|
+
},
|
|
56
|
+
get : (target, domain) => this.#map.get(domain) ?? target.lazyload(domain),
|
|
57
|
+
has : (_, domain) => this.#map.has(domain),
|
|
58
|
+
deleteProperty : (_, domain) => this.#map.delete(domain)
|
|
59
|
+
})
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
lazyload(domain)
|
|
63
|
+
{
|
|
64
|
+
if(false === this.#map.has(domain))
|
|
65
|
+
{
|
|
66
|
+
const listener = new Listener()
|
|
67
|
+
this.#map.set(domain, listener)
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
return this.#map.get(domain)
|
|
71
|
+
}
|
|
72
|
+
}
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import assert from 'node:assert/strict'
|
|
2
|
+
import { suite, test } from 'node:test'
|
|
3
|
+
import ListenersManager from '@superhero/eventflow-spoke/manager/listeners'
|
|
4
|
+
|
|
5
|
+
suite('ListenersManager', () =>
|
|
6
|
+
{
|
|
7
|
+
test('Add and retrieve listeners by domain', async () =>
|
|
8
|
+
{
|
|
9
|
+
const
|
|
10
|
+
manager = new ListenersManager(),
|
|
11
|
+
domain1 = 'domain1',
|
|
12
|
+
domain2 = 'domain2',
|
|
13
|
+
listener1 = new ListenersManager.Listener(),
|
|
14
|
+
listener2 = new ListenersManager.Listener()
|
|
15
|
+
|
|
16
|
+
manager[domain1] = listener1
|
|
17
|
+
manager[domain2] = listener2
|
|
18
|
+
|
|
19
|
+
assert.strictEqual(manager[domain1], listener1)
|
|
20
|
+
assert.strictEqual(manager[domain2], listener2)
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
test('Throw error when overwriting existing domain', async () =>
|
|
24
|
+
{
|
|
25
|
+
const
|
|
26
|
+
manager = new ListenersManager(),
|
|
27
|
+
domain = 'domain1',
|
|
28
|
+
listener = new ListenersManager.Listener()
|
|
29
|
+
|
|
30
|
+
manager[domain] = listener
|
|
31
|
+
|
|
32
|
+
assert.throws(() =>
|
|
33
|
+
{
|
|
34
|
+
manager[domain] = new ListenersManager.Listener()
|
|
35
|
+
},
|
|
36
|
+
{
|
|
37
|
+
code: 'E_EVENTFLOW_LISTENERS_DOMAIN_ALREADY_EXISTS'
|
|
38
|
+
})
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
test('Throw error when setting invalid listener instance', async () =>
|
|
42
|
+
{
|
|
43
|
+
const
|
|
44
|
+
manager = new ListenersManager(),
|
|
45
|
+
domain = 'domain1'
|
|
46
|
+
|
|
47
|
+
assert.throws(() =>
|
|
48
|
+
{
|
|
49
|
+
manager[domain] = {}
|
|
50
|
+
},
|
|
51
|
+
{
|
|
52
|
+
code: 'E_EVENTFLOW_LISTENERS_INVLAID_INSTANCE_OF_LISTENER'
|
|
53
|
+
})
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
test('Lazy-load listener for non-existent domain', async () =>
|
|
57
|
+
{
|
|
58
|
+
const
|
|
59
|
+
manager = new ListenersManager(),
|
|
60
|
+
domain = 'domain1'
|
|
61
|
+
|
|
62
|
+
const listener = manager[domain]
|
|
63
|
+
assert.ok(listener instanceof ListenersManager.Listener)
|
|
64
|
+
assert.strictEqual(manager[domain], listener)
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
test('Delete listener by domain', async () =>
|
|
68
|
+
{
|
|
69
|
+
const
|
|
70
|
+
manager = new ListenersManager(),
|
|
71
|
+
domain = 'domain1',
|
|
72
|
+
listener = new ListenersManager.Listener()
|
|
73
|
+
|
|
74
|
+
manager[domain] = listener
|
|
75
|
+
assert.strictEqual(manager[domain], listener)
|
|
76
|
+
|
|
77
|
+
delete manager[domain]
|
|
78
|
+
assert.ok(manager[domain] instanceof ListenersManager.Listener)
|
|
79
|
+
|
|
80
|
+
assert.notStrictEqual(manager[domain], listener)
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
test('Wildcard event emission', async () =>
|
|
84
|
+
{
|
|
85
|
+
const
|
|
86
|
+
listener = new ListenersManager.Listener(),
|
|
87
|
+
emittedEvents = []
|
|
88
|
+
|
|
89
|
+
listener.on('*', (...args) => emittedEvents.push({ event: '*', args }))
|
|
90
|
+
listener.emit('nonexistentEvent', 'arg1', 'arg2')
|
|
91
|
+
|
|
92
|
+
assert.deepStrictEqual(emittedEvents,
|
|
93
|
+
[
|
|
94
|
+
{
|
|
95
|
+
event : '*',
|
|
96
|
+
args : ['arg1', 'arg2']
|
|
97
|
+
}
|
|
98
|
+
])
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
test('Emit specific event if listeners exist', async () =>
|
|
102
|
+
{
|
|
103
|
+
const
|
|
104
|
+
listener = new ListenersManager.Listener(),
|
|
105
|
+
emittedEvents = []
|
|
106
|
+
|
|
107
|
+
listener.on('specificEvent', (...args) => emittedEvents.push({ event: 'specificEvent', args }))
|
|
108
|
+
listener.emit('specificEvent', 'arg1', 'arg2')
|
|
109
|
+
|
|
110
|
+
assert.deepStrictEqual(emittedEvents,
|
|
111
|
+
[
|
|
112
|
+
{
|
|
113
|
+
event : 'specificEvent',
|
|
114
|
+
args : ['arg1', 'arg2']
|
|
115
|
+
}
|
|
116
|
+
])
|
|
117
|
+
})
|
|
118
|
+
})
|
package/package.json
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@superhero/eventflow-spoke",
|
|
3
|
+
"version": "4.0.0",
|
|
4
|
+
"description": "Eventflow spoke is the client component in the eventflow ecosystem.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"eventflow",
|
|
7
|
+
"spoke"
|
|
8
|
+
],
|
|
9
|
+
"main": "config.js",
|
|
10
|
+
"license": "MIT",
|
|
11
|
+
"type": "module",
|
|
12
|
+
"exports": {
|
|
13
|
+
".": "./index.js",
|
|
14
|
+
"./consume": "./consume.js",
|
|
15
|
+
"./manager/*": "./manager/*.js"
|
|
16
|
+
},
|
|
17
|
+
"dependencies": {
|
|
18
|
+
"@superhero/deep": "^4.2.0",
|
|
19
|
+
"@superhero/eventflow-certificates": "^4.0.2",
|
|
20
|
+
"@superhero/eventflow-db": "^4.1.0",
|
|
21
|
+
"@superhero/id-name-generator": "^4.0.0",
|
|
22
|
+
"@superhero/log": "^4.0.0",
|
|
23
|
+
"@superhero/tcp-record-channel": "^4.2.1"
|
|
24
|
+
},
|
|
25
|
+
"devDependencies": {
|
|
26
|
+
"@superhero/config": "^4.1.3",
|
|
27
|
+
"@superhero/locator": "^4.2.1",
|
|
28
|
+
"@superhero/eventflow-hub": "^4.0.4"
|
|
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-spoke.git"
|
|
42
|
+
}
|
|
43
|
+
}
|