@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 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
+ })
@@ -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
+ }