@toa.io/extensions.realtime 0.0.0-alpha.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/.readme/overview-dark.jpg +0 -0
- package/.readme/overview-light.jpg +0 -0
- package/LICENSE +22 -0
- package/components/context.toa.yaml +11 -0
- package/components/streams/manifest.toa.yaml +20 -0
- package/components/streams/source/create.ts +16 -0
- package/components/streams/source/lib/streams.ts +45 -0
- package/components/streams/source/push.ts +5 -0
- package/components/streams/source/types.ts +11 -0
- package/components/streams/tsconfig.json +9 -0
- package/cucumber.js +9 -0
- package/features/static.feature +32 -0
- package/features/steps/Components.ts +71 -0
- package/features/steps/Realtime.ts +43 -0
- package/features/steps/Streams.ts +50 -0
- package/features/steps/components/messages/manifest.toa.yaml +16 -0
- package/features/steps/config.ts +1 -0
- package/features/steps/tsconfig.json +9 -0
- package/package.json +31 -0
- package/readme.md +99 -0
- package/source/Composition.ts +40 -0
- package/source/Factory.ts +33 -0
- package/source/Realtime.ts +38 -0
- package/source/Routes.ts +84 -0
- package/source/index.ts +1 -0
- package/stage/streams.test.ts +128 -0
- package/tsconfig.json +12 -0
|
Binary file
|
|
Binary file
|
package/LICENSE
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
Copyright (c) 2020-present Artem Gurtovoi
|
|
2
|
+
|
|
3
|
+
MIT License
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining
|
|
6
|
+
a copy of this software and associated documentation files (the
|
|
7
|
+
"Software"), to deal in the Software without restriction, including
|
|
8
|
+
without limitation the rights to use, copy, modify, merge, publish,
|
|
9
|
+
distribute, sublicense, and/or sell copies of the Software, and to
|
|
10
|
+
permit persons to whom the Software is furnished to do so, subject to
|
|
11
|
+
the following conditions:
|
|
12
|
+
|
|
13
|
+
The above copyright notice and this permission notice shall be
|
|
14
|
+
included in all copies or substantial portions of the Software.
|
|
15
|
+
|
|
16
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
|
17
|
+
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
|
18
|
+
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
|
|
19
|
+
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
|
|
20
|
+
LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
|
|
21
|
+
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
|
22
|
+
WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
namespace: realtime
|
|
2
|
+
name: streams
|
|
3
|
+
|
|
4
|
+
operations:
|
|
5
|
+
create:
|
|
6
|
+
input:
|
|
7
|
+
key: string
|
|
8
|
+
push:
|
|
9
|
+
bindings: ~ # only for internal use
|
|
10
|
+
input:
|
|
11
|
+
key*: string
|
|
12
|
+
event*: string
|
|
13
|
+
data: ~
|
|
14
|
+
|
|
15
|
+
state: ~
|
|
16
|
+
|
|
17
|
+
exposition:
|
|
18
|
+
/:key:
|
|
19
|
+
auth:id: key
|
|
20
|
+
GET: create
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { type Readable } from 'node:stream'
|
|
2
|
+
import { type Context } from './types'
|
|
3
|
+
import { addStream } from './lib/streams'
|
|
4
|
+
|
|
5
|
+
export async function effect (input: Input, context: Context): Promise<Readable> {
|
|
6
|
+
const key = input.key
|
|
7
|
+
|
|
8
|
+
if (!(key in context.state))
|
|
9
|
+
addStream(key, context.state)
|
|
10
|
+
|
|
11
|
+
return context.state[key].fork()
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
interface Input {
|
|
15
|
+
key: string
|
|
16
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { PassThrough, Readable } from 'node:stream'
|
|
2
|
+
|
|
3
|
+
export function addStream (key: string, map: Record<string, Stream>): void {
|
|
4
|
+
const stream = new Stream()
|
|
5
|
+
|
|
6
|
+
map[key] = stream
|
|
7
|
+
|
|
8
|
+
stream.once('close', () => delete map[key])
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export class Stream extends Readable {
|
|
12
|
+
private forks: number = 0
|
|
13
|
+
|
|
14
|
+
public constructor () {
|
|
15
|
+
super(objectMode)
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
public fork (): PassThrough {
|
|
19
|
+
const through = new PassThrough(objectMode)
|
|
20
|
+
|
|
21
|
+
through.once('close', this.decrement.bind(this))
|
|
22
|
+
|
|
23
|
+
this.increment()
|
|
24
|
+
this.pipe(through)
|
|
25
|
+
|
|
26
|
+
return through
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// has to be here
|
|
30
|
+
public override _read (): void {
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
private increment (): void {
|
|
34
|
+
this.forks++
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
private decrement (): void {
|
|
38
|
+
this.forks--
|
|
39
|
+
|
|
40
|
+
if (this.forks === 0)
|
|
41
|
+
this.destroy()
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const objectMode = { objectMode: true }
|
package/cucumber.js
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
Feature: Static routes
|
|
2
|
+
|
|
3
|
+
Scenario: Routing an event
|
|
4
|
+
Given the `messages` component is running with routes:
|
|
5
|
+
"""yaml
|
|
6
|
+
created: [sender, recipient]
|
|
7
|
+
"""
|
|
8
|
+
And the stream `004e02a959c04cecaf111827f91caa36` is consumed
|
|
9
|
+
And the stream `96db5a47a8244eb3b21820781b7d596e` is consumed
|
|
10
|
+
When the `messages.create` is called with:
|
|
11
|
+
"""yaml
|
|
12
|
+
input:
|
|
13
|
+
sender: 96db5a47a8244eb3b21820781b7d596e
|
|
14
|
+
recipient: 004e02a959c04cecaf111827f91caa36
|
|
15
|
+
text: Hello!
|
|
16
|
+
"""
|
|
17
|
+
Then an event is received from the stream `004e02a959c04cecaf111827f91caa36`:
|
|
18
|
+
"""yaml
|
|
19
|
+
event: default.messages.created
|
|
20
|
+
data:
|
|
21
|
+
sender: 96db5a47a8244eb3b21820781b7d596e
|
|
22
|
+
recipient: 004e02a959c04cecaf111827f91caa36
|
|
23
|
+
text: Hello!
|
|
24
|
+
"""
|
|
25
|
+
And an event is received from the stream `96db5a47a8244eb3b21820781b7d596e`:
|
|
26
|
+
"""yaml
|
|
27
|
+
event: default.messages.created
|
|
28
|
+
data:
|
|
29
|
+
sender: 96db5a47a8244eb3b21820781b7d596e
|
|
30
|
+
recipient: 004e02a959c04cecaf111827f91caa36
|
|
31
|
+
text: Hello!
|
|
32
|
+
"""
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { resolve } from 'node:path'
|
|
2
|
+
import { readdirSync } from 'node:fs'
|
|
3
|
+
import { after, before, binding, given, when } from 'cucumber-tsflow'
|
|
4
|
+
import { parse } from '@toa.io/yaml'
|
|
5
|
+
import * as stage from '@toa.io/userland/stage'
|
|
6
|
+
import { type Component, type Request } from '@toa.io/core'
|
|
7
|
+
import { timeout } from '@toa.io/generic'
|
|
8
|
+
import { Realtime } from './Realtime'
|
|
9
|
+
|
|
10
|
+
@binding([Realtime])
|
|
11
|
+
export class Components {
|
|
12
|
+
private readonly realtime: Realtime
|
|
13
|
+
private remotes: Record<string, Component> = {}
|
|
14
|
+
|
|
15
|
+
public constructor (realtime: Realtime) {
|
|
16
|
+
this.realtime = realtime
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
@given('the `{word}` component is running with routes:')
|
|
20
|
+
public async run (component: string, yaml: string): Promise<void> {
|
|
21
|
+
const routes = parse<Manifest>(yaml)
|
|
22
|
+
const [name, namespace = 'default'] = component.split('.').reverse()
|
|
23
|
+
|
|
24
|
+
for (const [event, value] of Object.entries(routes)) {
|
|
25
|
+
const label = `${namespace}.${name}.${event}`
|
|
26
|
+
const properties = Array.isArray(value) ? value : [value]
|
|
27
|
+
|
|
28
|
+
this.realtime.declare(label, properties)
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
@when('the `{word}` is called with:')
|
|
33
|
+
public async call (endpoint: string, yaml: string): Promise<void> {
|
|
34
|
+
const request = parse<Request>(yaml)
|
|
35
|
+
const [operation, component, namespace = 'default'] = endpoint.split('.').reverse()
|
|
36
|
+
const id = `${namespace}.${component}`
|
|
37
|
+
|
|
38
|
+
this.remotes[id] ??= await stage.remote(id)
|
|
39
|
+
|
|
40
|
+
await this.remotes[id].invoke(operation, request)
|
|
41
|
+
await timeout(500)
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
@before()
|
|
45
|
+
private async compose (): Promise<void> {
|
|
46
|
+
const paths = componentPaths()
|
|
47
|
+
|
|
48
|
+
await stage.compose(paths)
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
@after()
|
|
52
|
+
private async shutdown (): Promise<void> {
|
|
53
|
+
this.remotes = {}
|
|
54
|
+
|
|
55
|
+
await stage.shutdown()
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function componentPaths (): string[] {
|
|
60
|
+
const entries = readdirSync(ROOT, { withFileTypes: true })
|
|
61
|
+
|
|
62
|
+
return entries
|
|
63
|
+
.filter((entry) => entry.isDirectory())
|
|
64
|
+
.map((entry) => resolve(ROOT, entry.name))
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const ROOT = resolve(__dirname, 'components')
|
|
68
|
+
|
|
69
|
+
interface Manifest {
|
|
70
|
+
realtime: Map<string, string | string[]>
|
|
71
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import * as boot from '@toa.io/boot'
|
|
2
|
+
import { encode } from '@toa.io/generic'
|
|
3
|
+
import { type Connector } from '@toa.io/core'
|
|
4
|
+
import { after, binding } from 'cucumber-tsflow'
|
|
5
|
+
import { Factory } from '../../source/Factory'
|
|
6
|
+
|
|
7
|
+
@binding()
|
|
8
|
+
export class Realtime {
|
|
9
|
+
private readonly routes: Route[] = []
|
|
10
|
+
private readonly service: Connector
|
|
11
|
+
private connected = false
|
|
12
|
+
|
|
13
|
+
public constructor () {
|
|
14
|
+
this.service = new Factory(boot).service()
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
@after()
|
|
18
|
+
private async shutdown (): Promise<void> {
|
|
19
|
+
this.connected = false
|
|
20
|
+
|
|
21
|
+
await this.service.disconnect()
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
public declare (event: string, properties: string[]): void {
|
|
25
|
+
this.routes.push({ event, properties })
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
public async serve (): Promise<void> {
|
|
29
|
+
if (this.connected)
|
|
30
|
+
return
|
|
31
|
+
|
|
32
|
+
process.env.TOA_REALTIME = encode(this.routes)
|
|
33
|
+
|
|
34
|
+
this.connected = true
|
|
35
|
+
|
|
36
|
+
await this.service.connect()
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
interface Route {
|
|
41
|
+
event: string
|
|
42
|
+
properties: string[]
|
|
43
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { type Readable } from 'node:stream'
|
|
2
|
+
import { after, binding, given, then } from 'cucumber-tsflow'
|
|
3
|
+
import { match } from '@toa.io/generic'
|
|
4
|
+
|
|
5
|
+
import { type Component } from '@toa.io/core'
|
|
6
|
+
import { parse } from '@toa.io/yaml'
|
|
7
|
+
import * as stage from '@toa.io/userland/stage'
|
|
8
|
+
import { Realtime } from './Realtime'
|
|
9
|
+
|
|
10
|
+
@binding([Realtime])
|
|
11
|
+
export class Streams {
|
|
12
|
+
private readonly realtime: Realtime
|
|
13
|
+
private remote: Component | null = null
|
|
14
|
+
private streams: Record<string, Readable> = {}
|
|
15
|
+
private events: Record<string, object[]> = {}
|
|
16
|
+
|
|
17
|
+
public constructor (realtime: Realtime) {
|
|
18
|
+
this.realtime = realtime
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
@given('the stream `{word}` is consumed')
|
|
22
|
+
public async consume (key: string): Promise<void> {
|
|
23
|
+
await this.realtime.serve()
|
|
24
|
+
|
|
25
|
+
this.remote ??= await stage.remote('realtime.streams')
|
|
26
|
+
this.events[key] = []
|
|
27
|
+
this.streams[key] = await this.remote.invoke('create', { input: { key } })
|
|
28
|
+
this.streams[key].on('data', (data: object) => this.events[key].push(data))
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
@then('an event is received from the stream `{word}`:')
|
|
32
|
+
public received (key: string, yaml: string): void {
|
|
33
|
+
const expected = parse<object>(yaml)
|
|
34
|
+
|
|
35
|
+
for (const event of this.events[key])
|
|
36
|
+
if (match(event, expected))
|
|
37
|
+
return
|
|
38
|
+
|
|
39
|
+
throw new Error('No matching event received')
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
@after()
|
|
43
|
+
private shutdown (): void {
|
|
44
|
+
for (const stream of Object.values(this.streams))
|
|
45
|
+
stream.destroy()
|
|
46
|
+
|
|
47
|
+
this.streams = {}
|
|
48
|
+
this.events = {}
|
|
49
|
+
}
|
|
50
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
process.env.TOA_DEV = '1'
|
package/package.json
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@toa.io/extensions.realtime",
|
|
3
|
+
"version": "0.0.0-alpha.2",
|
|
4
|
+
"description": "Toa Realtime",
|
|
5
|
+
"author": "temich <tema.gurtovoy@gmail.com>",
|
|
6
|
+
"homepage": "https://github.com/toa-io/toa#readme",
|
|
7
|
+
"main": "transpiled/index.js",
|
|
8
|
+
"types": "transpiled/index.d.ts",
|
|
9
|
+
"repository": {
|
|
10
|
+
"type": "git",
|
|
11
|
+
"url": "git+https://github.com/toa-io/toa.git"
|
|
12
|
+
},
|
|
13
|
+
"bugs": {
|
|
14
|
+
"url": "https://github.com/toa-io/toa/issues"
|
|
15
|
+
},
|
|
16
|
+
"publishConfig": {
|
|
17
|
+
"access": "public"
|
|
18
|
+
},
|
|
19
|
+
"peerDependencies": {
|
|
20
|
+
"nopeable": "*"
|
|
21
|
+
},
|
|
22
|
+
"jest": {
|
|
23
|
+
"preset": "ts-jest",
|
|
24
|
+
"testEnvironment": "node"
|
|
25
|
+
},
|
|
26
|
+
"scripts": {
|
|
27
|
+
"transpile": "npx tsc && npx tsc -p ./components/streams",
|
|
28
|
+
"features": "npx cucumber-js"
|
|
29
|
+
},
|
|
30
|
+
"gitHead": "d6e8c5bdae26e2beb66f4d7f37f71a3494fe9fcb"
|
|
31
|
+
}
|
package/readme.md
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
# Toa Realtime
|
|
2
|
+
|
|
3
|
+
## Overview
|
|
4
|
+
|
|
5
|
+
<a href="https://miro.com/app/board/uXjVOoy0ImU=/?moveToWidget=3458764566111478378&cot=14">
|
|
6
|
+
<picture>
|
|
7
|
+
<source media="(prefers-color-scheme: dark)" srcset=".readme/overview-dark.jpg">
|
|
8
|
+
<img alt="Realtime" width="700" height="202" src=".readme/overview-light.jpg">
|
|
9
|
+
</picture>
|
|
10
|
+
</a>
|
|
11
|
+
|
|
12
|
+
Realtime extension combines application events into streams according to defined routes.
|
|
13
|
+
Clients may consume these streams [via Exposition](#exposition).
|
|
14
|
+
|
|
15
|
+
## Static routes
|
|
16
|
+
|
|
17
|
+
Static route specifies an event that should be combined into a stream using specified property of
|
|
18
|
+
event's payload as a stream key.
|
|
19
|
+
|
|
20
|
+
Static routes may be defined in Component manifest or the Context annotation.
|
|
21
|
+
|
|
22
|
+
```yaml
|
|
23
|
+
# manifest.toa.yaml
|
|
24
|
+
name: users
|
|
25
|
+
|
|
26
|
+
realtime:
|
|
27
|
+
updated: id
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
```yaml
|
|
31
|
+
# context.toa.yaml
|
|
32
|
+
realtime:
|
|
33
|
+
users.updated: id
|
|
34
|
+
orders.created: custromer_id
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
In case of conflict, the Context annotation takes precedence.
|
|
38
|
+
|
|
39
|
+
### Static route examples
|
|
40
|
+
|
|
41
|
+
Given two rules: `users.updated: id` and `orders.created: customer_id`,
|
|
42
|
+
the following events will be routed into a stream with `a4b8e7e8` key:
|
|
43
|
+
|
|
44
|
+
```yaml
|
|
45
|
+
# users.updated
|
|
46
|
+
id: a4b8e7e8 # id property is used as a stream key
|
|
47
|
+
name: John Doe
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
```yaml
|
|
51
|
+
# orders.created
|
|
52
|
+
id: 1
|
|
53
|
+
customer_id: a4b8e7e8 # customer_id property is used as a stream key
|
|
54
|
+
amount: 100
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
## Dynamic routes
|
|
58
|
+
|
|
59
|
+

|
|
60
|
+
|
|
61
|
+
Dynamic routes address the cases when a stream key is not a property of an event.
|
|
62
|
+
|
|
63
|
+
Among with an `event` and a `stream` key, a dynamic route has `property` and `value` properties,
|
|
64
|
+
which define a condition that should be met for an event to be combined into a stream with the
|
|
65
|
+
specified key.
|
|
66
|
+
|
|
67
|
+
### Dynamic route example
|
|
68
|
+
|
|
69
|
+
For instance, when there are chat rooms with a list of users, and a user joins or leaves the room.
|
|
70
|
+
When a message is sent to a room, an event will have a `room_id` property, but not a `user_id`.
|
|
71
|
+
In this case, when the user `a4b8e7e8` enters the room with id `general`,
|
|
72
|
+
a dynamic route may be created as follows:
|
|
73
|
+
|
|
74
|
+
```yaml
|
|
75
|
+
event: message.sent
|
|
76
|
+
property: room_id
|
|
77
|
+
value: general
|
|
78
|
+
stream: a4b8e7e8
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
Each time the message is sent to the room with id `general`, the event will be routed into a
|
|
82
|
+
stream with `a4b8e7e8` key.
|
|
83
|
+
|
|
84
|
+
### Managing dynamic routes
|
|
85
|
+
|
|
86
|
+
Dynamic routes are managed by the `realtime.routes` component, running in the Realtime extension.
|
|
87
|
+
|
|
88
|
+
## Exposition
|
|
89
|
+
|
|
90
|
+
Streams are exposed by the [`realtime.streams`](components/streams) component, running in the
|
|
91
|
+
Realtime extension, and are
|
|
92
|
+
accessible via the `/realtime/streams/:key/` resource with
|
|
93
|
+
the [`auth:id: key`](/extensions/exposition/documentation/access.md#id) authorization rule.
|
|
94
|
+
|
|
95
|
+
Refer to the [Exposition extension](/extensions/exposition) for more
|
|
96
|
+
details:
|
|
97
|
+
|
|
98
|
+
- [Streams](/extensions/exposition/documentation/protocol.md#streams)
|
|
99
|
+
- [Access authorization](/extensions/exposition/documentation/access.md)
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { readdirSync, type Dirent } from 'node:fs'
|
|
2
|
+
import { resolve } from 'node:path'
|
|
3
|
+
import { Connector } from '@toa.io/core'
|
|
4
|
+
import { type Bootloader } from './Factory'
|
|
5
|
+
|
|
6
|
+
export class Composition extends Connector {
|
|
7
|
+
private readonly boot: Bootloader
|
|
8
|
+
|
|
9
|
+
public constructor (boot: Bootloader) {
|
|
10
|
+
super()
|
|
11
|
+
this.boot = boot
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
protected override async open (): Promise<void> {
|
|
15
|
+
const paths = find()
|
|
16
|
+
const composition = await this.boot.composition(paths)
|
|
17
|
+
|
|
18
|
+
await composition.connect()
|
|
19
|
+
|
|
20
|
+
this.depends(composition)
|
|
21
|
+
|
|
22
|
+
console.info('Composition complete.')
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
protected override dispose (): void {
|
|
26
|
+
console.info('Composition shutdown complete.')
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function find (): string[] {
|
|
31
|
+
return entries().map((entry) => resolve(ROOT, entry.name))
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function entries (): Dirent[] {
|
|
35
|
+
const entries = readdirSync(ROOT, { withFileTypes: true })
|
|
36
|
+
|
|
37
|
+
return entries.filter((entry) => entry.isDirectory())
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const ROOT = resolve(__dirname, '../components/')
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { type Component, type extensions, Locator } from '@toa.io/core'
|
|
2
|
+
import { Realtime } from './Realtime'
|
|
3
|
+
import { Composition } from './Composition'
|
|
4
|
+
import { Routes } from './Routes'
|
|
5
|
+
|
|
6
|
+
export class Factory implements extensions.Factory {
|
|
7
|
+
private readonly boot: Bootloader
|
|
8
|
+
|
|
9
|
+
public constructor (boot: Bootloader) {
|
|
10
|
+
this.boot = boot
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
public service (): Realtime {
|
|
14
|
+
const discovery = this.discovery()
|
|
15
|
+
const routes = new Routes(this.boot)
|
|
16
|
+
const composition = new Composition(this.boot)
|
|
17
|
+
const realtime = new Realtime(routes, discovery)
|
|
18
|
+
|
|
19
|
+
realtime.depends(routes)
|
|
20
|
+
realtime.depends(composition)
|
|
21
|
+
|
|
22
|
+
return realtime
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
private async discovery (): Promise<Component> {
|
|
26
|
+
const locator = new Locator('streams', 'realtime')
|
|
27
|
+
|
|
28
|
+
return await this.boot.remote(locator)
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// eslint-disable-next-line @typescript-eslint/consistent-type-imports
|
|
33
|
+
export type Bootloader = typeof import('@toa.io/boot')
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { type Component, Connector } from '@toa.io/core'
|
|
2
|
+
import { type Routes } from './Routes'
|
|
3
|
+
|
|
4
|
+
export class Realtime extends Connector {
|
|
5
|
+
private readonly discovery: Promise<Component>
|
|
6
|
+
private streams: Component | null = null
|
|
7
|
+
|
|
8
|
+
public constructor (routes: Routes, discovery: Promise<Component>) {
|
|
9
|
+
super()
|
|
10
|
+
|
|
11
|
+
this.discovery = discovery
|
|
12
|
+
|
|
13
|
+
routes.events.on('data', this.push.bind(this))
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
protected override async open (): Promise<void> {
|
|
17
|
+
this.streams = await this.discovery
|
|
18
|
+
this.depends(this.streams)
|
|
19
|
+
|
|
20
|
+
await this.streams.connect()
|
|
21
|
+
|
|
22
|
+
console.log('Realtime has started.')
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
protected override dispose (): void {
|
|
26
|
+
console.log('Realtime shutdown complete.')
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
private push (event: Event): void {
|
|
30
|
+
void this.streams?.invoke('push', { input: event })
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
interface Event {
|
|
35
|
+
key: string
|
|
36
|
+
event: string
|
|
37
|
+
data: string
|
|
38
|
+
}
|
package/source/Routes.ts
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { Readable } from 'node:stream'
|
|
2
|
+
import { Connector, type Message } from '@toa.io/core'
|
|
3
|
+
import { decode } from '@toa.io/generic'
|
|
4
|
+
import { type Bootloader } from './Factory'
|
|
5
|
+
|
|
6
|
+
export class Routes extends Connector {
|
|
7
|
+
public events = new Events()
|
|
8
|
+
|
|
9
|
+
private readonly boot: Bootloader
|
|
10
|
+
|
|
11
|
+
public constructor (boot: Bootloader) {
|
|
12
|
+
super()
|
|
13
|
+
|
|
14
|
+
this.boot = boot
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
private static read (): Route[] {
|
|
18
|
+
if (process.env.TOA_REALTIME === undefined)
|
|
19
|
+
throw new Error('TOA_REALTIME is not defined')
|
|
20
|
+
|
|
21
|
+
return decode<Route[]>(process.env.TOA_REALTIME)
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
public override async open (): Promise<void> {
|
|
25
|
+
const routes = Routes.read()
|
|
26
|
+
const creating = []
|
|
27
|
+
|
|
28
|
+
for (const { event, properties } of routes) {
|
|
29
|
+
const consumer = this.boot.receive(event, this.getReceiver(event, properties))
|
|
30
|
+
|
|
31
|
+
creating.push(consumer)
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const consumers = await Promise.all(creating)
|
|
35
|
+
|
|
36
|
+
// eslint-disable-next-line @typescript-eslint/promise-function-async
|
|
37
|
+
const connecting = consumers.map((consumer) => consumer.connect())
|
|
38
|
+
|
|
39
|
+
await Promise.all(connecting)
|
|
40
|
+
this.depends(consumers)
|
|
41
|
+
|
|
42
|
+
console.log(`Event sources (${creating.length}) connected.`)
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
public override async close (): Promise<void> {
|
|
46
|
+
console.log('Event sources disconnected.')
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
private getReceiver (event: string, properties: string[]): Receiver {
|
|
50
|
+
return {
|
|
51
|
+
receive: (message: Message<Record<string, string>>) => {
|
|
52
|
+
for (const property of properties) {
|
|
53
|
+
const key = message.payload[property]
|
|
54
|
+
|
|
55
|
+
if (key === undefined) {
|
|
56
|
+
console.error(`Event '${event}' does not contain the '${property}' property.`)
|
|
57
|
+
|
|
58
|
+
return
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
this.events.push({ key, event, data: message.payload })
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
class Events extends Readable {
|
|
69
|
+
public constructor () {
|
|
70
|
+
super({ objectMode: true })
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
public override _read (): void {
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export interface Route {
|
|
78
|
+
event: string
|
|
79
|
+
properties: string[]
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
interface Receiver {
|
|
83
|
+
receive: (message: Message<Record<string, string>>) => void
|
|
84
|
+
}
|
package/source/index.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { Factory } from './Factory'
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import { resolve } from 'node:path'
|
|
2
|
+
import { type Readable } from 'node:stream'
|
|
3
|
+
import { generate } from 'randomstring'
|
|
4
|
+
import * as stage from '@toa.io/userland/stage'
|
|
5
|
+
import { type Component } from '@toa.io/core'
|
|
6
|
+
import { newid, timeout } from '@toa.io/generic'
|
|
7
|
+
|
|
8
|
+
let component: Component
|
|
9
|
+
let stream: Readable
|
|
10
|
+
let events: Record<string, any[]> = {}
|
|
11
|
+
|
|
12
|
+
beforeEach(async () => {
|
|
13
|
+
await run()
|
|
14
|
+
})
|
|
15
|
+
|
|
16
|
+
afterEach(async () => {
|
|
17
|
+
stream?.destroy()
|
|
18
|
+
await stop()
|
|
19
|
+
events = {}
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
it('should route events', async () => {
|
|
23
|
+
const event = generate()
|
|
24
|
+
const data = generate()
|
|
25
|
+
const key1 = newid()
|
|
26
|
+
const key2 = newid()
|
|
27
|
+
|
|
28
|
+
await create(key1)
|
|
29
|
+
await create(key2)
|
|
30
|
+
await push(key1, event, data)
|
|
31
|
+
await push(key2, event, data)
|
|
32
|
+
|
|
33
|
+
expect(events[key1]).toEqual([{ event, data }])
|
|
34
|
+
expect(events[key2]).toEqual([{ event, data }])
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
it('should create fresh stream', async () => {
|
|
38
|
+
const data = { foo: generate() }
|
|
39
|
+
const key = newid()
|
|
40
|
+
|
|
41
|
+
await create(key)
|
|
42
|
+
await push(key, 'first', data)
|
|
43
|
+
|
|
44
|
+
expect(events[key]).toEqual([{ event: 'first', data }])
|
|
45
|
+
|
|
46
|
+
await create(key)
|
|
47
|
+
await timeout(0)
|
|
48
|
+
|
|
49
|
+
expect(events[key]).toEqual([])
|
|
50
|
+
|
|
51
|
+
await push(key, 'second', data)
|
|
52
|
+
|
|
53
|
+
expect(events[key]).toEqual([{ event: 'second', data }])
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
it('should ignore pushes to non-created streams', async () => {
|
|
57
|
+
const data = { foo: generate() }
|
|
58
|
+
const key = newid()
|
|
59
|
+
|
|
60
|
+
await push(key, 'first', data)
|
|
61
|
+
await create(key)
|
|
62
|
+
|
|
63
|
+
expect(events[key]).toEqual([])
|
|
64
|
+
|
|
65
|
+
await push(key, 'second', data)
|
|
66
|
+
|
|
67
|
+
expect(events[key]).toEqual([{ event: 'second', data }])
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
it('should not destroy source stream if one of the forks is destroyed', async () => {
|
|
71
|
+
const data = generate()
|
|
72
|
+
const key = newid()
|
|
73
|
+
|
|
74
|
+
await create(key)
|
|
75
|
+
await push(key, 'first', data)
|
|
76
|
+
|
|
77
|
+
const firstStream = stream
|
|
78
|
+
const firstStreamEvents = events[key]
|
|
79
|
+
|
|
80
|
+
await create(key)
|
|
81
|
+
await push(key, 'second', data)
|
|
82
|
+
|
|
83
|
+
expect(firstStreamEvents).toEqual([{ event: 'first', data }, { event: 'second', data }])
|
|
84
|
+
expect(stream === firstStream).toBe(false)
|
|
85
|
+
|
|
86
|
+
stream.destroy()
|
|
87
|
+
|
|
88
|
+
await push(key, 'third', data)
|
|
89
|
+
|
|
90
|
+
expect(firstStreamEvents).toEqual([
|
|
91
|
+
{ event: 'first', data },
|
|
92
|
+
{ event: 'second', data },
|
|
93
|
+
{ event: 'third', data }
|
|
94
|
+
])
|
|
95
|
+
|
|
96
|
+
firstStream.destroy()
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
/// region component
|
|
100
|
+
|
|
101
|
+
async function run (name: string = 'streams'): Promise<void> {
|
|
102
|
+
const path = resolve(__dirname, `../components/${name}`)
|
|
103
|
+
|
|
104
|
+
component = await stage.component(path)
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
async function create (key: string): Promise<void> {
|
|
108
|
+
const chunks: any[] = []
|
|
109
|
+
|
|
110
|
+
events[key] = chunks
|
|
111
|
+
|
|
112
|
+
stream = await component.invoke('create', { input: { key } })
|
|
113
|
+
stream.on('data', (chunk) => chunks.push(chunk))
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
async function stop (): Promise<void> {
|
|
117
|
+
await component?.disconnect()
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
async function push (key: string, event: string, data: any): Promise<void> {
|
|
121
|
+
const reply = await component.invoke<any>('push', { input: { key, event, data } })
|
|
122
|
+
|
|
123
|
+
expect(reply?.exception).toBeUndefined()
|
|
124
|
+
|
|
125
|
+
await timeout(0) // wait for stream internals
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/// endregion
|