@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.
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,11 @@
1
+ # used for development only
2
+
3
+ name: realtime
4
+ packages: '*'
5
+ registry: localhost
6
+
7
+ amqp: amqp://localhost
8
+ mongodb: mongodb://localhost
9
+
10
+ realtime:
11
+ messages.created: [sender, recipient]
@@ -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 }
@@ -0,0 +1,5 @@
1
+ import { type Context, type PushInput } from './types'
2
+
3
+ export async function effect ({ key, event, data }: PushInput, context: Context): Promise<void> {
4
+ context.state[key]?.push({ event, data })
5
+ }
@@ -0,0 +1,11 @@
1
+ import { type Stream } from './lib/streams'
2
+
3
+ export interface Context {
4
+ state: Record<string, Stream>
5
+ }
6
+
7
+ export interface PushInput {
8
+ key: string
9
+ event: string
10
+ data: unknown
11
+ }
@@ -0,0 +1,9 @@
1
+ {
2
+ "extends": "../../tsconfig.json",
3
+ "compilerOptions": {
4
+ "outDir": "./operations",
5
+ },
6
+ "include": [
7
+ "source"
8
+ ]
9
+ }
package/cucumber.js ADDED
@@ -0,0 +1,9 @@
1
+ module.exports = {
2
+ default: {
3
+ paths: ['features/**/*.feature'],
4
+ requireModule: ['ts-node/register'],
5
+ require: ['./features/**/*.ts'],
6
+ publishQuiet: true,
7
+ failFast: true
8
+ }
9
+ }
@@ -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,16 @@
1
+ name: messages
2
+
3
+ entity:
4
+ schema:
5
+ sender*: string(32)
6
+ recipient*: string(32)
7
+ text*: string
8
+
9
+ operations:
10
+ create:
11
+ query: false
12
+ forward: transit
13
+ input:
14
+ sender*: .
15
+ recipient*: .
16
+ text*: .
@@ -0,0 +1 @@
1
+ process.env.TOA_DEV = '1'
@@ -0,0 +1,9 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ESNext",
4
+ "outDir": "/dev/null",
5
+ "moduleResolution": "node",
6
+ "experimentalDecorators": true,
7
+ "emitDecoratorMetadata": true
8
+ }
9
+ }
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
+ ![Not implemented](https://img.shields.io/badge/Not%20implemented-red)
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
+ }
@@ -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
+ }
@@ -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
package/tsconfig.json ADDED
@@ -0,0 +1,12 @@
1
+ {
2
+ "extends": "../../tsconfig.json",
3
+ "compilerOptions": {
4
+ "outDir": "./transpiled"
5
+ },
6
+ "include": [
7
+ "source"
8
+ ],
9
+ "exclude": [
10
+ "**/*.test.ts"
11
+ ]
12
+ }