@toa.io/extensions.realtime 1.0.0-alpha.29 → 1.0.0-alpha.291

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.
Files changed (77) hide show
  1. package/CHANGELOG.md +35 -0
  2. package/components/realtime.streams/manifest.toa.yaml +49 -0
  3. package/components/realtime.streams/operations/create.js +65 -0
  4. package/components/realtime.streams/operations/lib/Stash.js +86 -0
  5. package/components/realtime.streams/operations/lib/Stream.js +43 -0
  6. package/components/realtime.streams/operations/lib/index.js +3 -0
  7. package/components/realtime.streams/operations/push.js +10 -0
  8. package/components/realtime.streams/source/create.ts +94 -0
  9. package/components/realtime.streams/source/lib/Stash.ts +131 -0
  10. package/components/realtime.streams/source/lib/Stream.ts +58 -0
  11. package/components/realtime.streams/source/lib/index.ts +2 -0
  12. package/components/realtime.streams/source/push.ts +14 -0
  13. package/components/{streams → realtime.streams}/tsconfig.json +2 -1
  14. package/components/realtime.streams/tsconfig.tsbuildinfo +1 -0
  15. package/components/realtime.streams/types/index.d.ts +20 -0
  16. package/components/realtime.streams/types/toa.d.ts +29 -0
  17. package/{components/context.toa.yaml → context.toa.yaml} +2 -3
  18. package/cucumber.mjs +8 -0
  19. package/features/static.feature +120 -0
  20. package/features/steps/Components.ts +22 -21
  21. package/features/steps/Realtime.ts +18 -21
  22. package/features/steps/Streams.ts +55 -20
  23. package/features/steps/components/messages/manifest.toa.yaml +36 -7
  24. package/features/steps/config.ts +15 -1
  25. package/package.json +9 -7
  26. package/readme.md +33 -7
  27. package/source/Composition.ts +26 -15
  28. package/source/Factory.ts +15 -16
  29. package/source/Realtime.ts +48 -15
  30. package/source/Receiver.ts +95 -0
  31. package/source/Routes.ts +26 -45
  32. package/source/extension.ts +92 -0
  33. package/source/index.ts +2 -1
  34. package/transpiled/Composition.d.ts +10 -4
  35. package/transpiled/Composition.js +21 -20
  36. package/transpiled/Composition.js.map +1 -1
  37. package/transpiled/Factory.d.ts +5 -5
  38. package/transpiled/Factory.js +13 -18
  39. package/transpiled/Factory.js.map +1 -1
  40. package/transpiled/Realtime.d.ts +3 -2
  41. package/transpiled/Realtime.js +36 -11
  42. package/transpiled/Realtime.js.map +1 -1
  43. package/transpiled/Receiver.d.ts +26 -0
  44. package/transpiled/Receiver.js +57 -0
  45. package/transpiled/Receiver.js.map +1 -0
  46. package/transpiled/Routes.d.ts +5 -10
  47. package/transpiled/Routes.js +18 -34
  48. package/transpiled/Routes.js.map +1 -1
  49. package/transpiled/extension.d.ts +22 -0
  50. package/transpiled/extension.js +55 -0
  51. package/transpiled/extension.js.map +1 -0
  52. package/transpiled/index.d.ts +2 -1
  53. package/transpiled/index.js +2 -5
  54. package/transpiled/index.js.map +1 -1
  55. package/tsconfig.json +2 -1
  56. package/tsconfig.tsbuildinfo +1 -0
  57. package/components/streams/manifest.toa.yaml +0 -21
  58. package/components/streams/operations/create.d.ts +0 -14
  59. package/components/streams/operations/create.js +0 -29
  60. package/components/streams/operations/create.js.map +0 -1
  61. package/components/streams/operations/lib/stream.d.ts +0 -10
  62. package/components/streams/operations/lib/stream.js +0 -31
  63. package/components/streams/operations/lib/stream.js.map +0 -1
  64. package/components/streams/operations/push.d.ts +0 -2
  65. package/components/streams/operations/push.js +0 -8
  66. package/components/streams/operations/push.js.map +0 -1
  67. package/components/streams/operations/tsconfig.tsbuildinfo +0 -1
  68. package/components/streams/operations/types.d.ts +0 -11
  69. package/components/streams/operations/types.js +0 -3
  70. package/components/streams/operations/types.js.map +0 -1
  71. package/components/streams/source/create.ts +0 -38
  72. package/components/streams/source/lib/stream.ts +0 -37
  73. package/components/streams/source/push.ts +0 -5
  74. package/components/streams/source/types.ts +0 -13
  75. package/cucumber.js +0 -8
  76. package/stage/streams.test.ts +0 -128
  77. package/transpiled/tsconfig.tsbuildinfo +0 -1
@@ -1,43 +1,40 @@
1
1
  import * as boot from '@toa.io/boot'
2
- import { encode } from '@toa.io/generic'
3
2
  import { type Connector } from '@toa.io/core'
4
- import { after, binding } from 'cucumber-tsflow'
5
- import { Factory } from '../../source/Factory'
3
+ import { environment } from '@toa.io/generic'
4
+
5
+ import tsflow from 'cucumber-tsflow'
6
+
7
+ import { Factory } from '../../source/index.js'
8
+ import type { Route } from '../../source/extension.js'
9
+
10
+ const { after, binding } = tsflow
6
11
 
7
12
  @binding()
8
13
  export class Realtime {
9
14
  private readonly routes: Route[] = []
10
- private readonly service: Connector
15
+ // the factory loads its binding, so the service is made when it is first served
16
+ private service: Connector | undefined
11
17
  private connected = false
12
18
 
13
- public constructor () {
14
- this.service = new Factory(boot).service()
15
- }
16
-
17
19
  @after()
18
- private async shutdown (): Promise<void> {
20
+ public async shutdown(): Promise<void> {
19
21
  this.connected = false
20
22
 
21
- await this.service.disconnect()
23
+ await this.service?.disconnect()
22
24
  }
23
25
 
24
- public declare (event: string, properties: string[]): void {
25
- this.routes.push({ event, properties })
26
+ public declare(event: string, properties: string[], expose?: string[]): void {
27
+ this.routes.push({ event, properties, expose })
26
28
  }
27
29
 
28
- public async serve (): Promise<void> {
29
- if (this.connected)
30
- return
30
+ public async serve(): Promise<void> {
31
+ if (this.connected) return
31
32
 
32
- process.env.TOA_REALTIME = encode(this.routes)
33
+ environment.set('TOA_REALTIME', JSON.stringify(this.routes))
33
34
 
34
35
  this.connected = true
36
+ this.service = await new Factory(boot.host()).service()
35
37
 
36
38
  await this.service.connect()
37
39
  }
38
40
  }
39
-
40
- interface Route {
41
- event: string
42
- properties: string[]
43
- }
@@ -1,50 +1,85 @@
1
- import { type Readable } from 'node:stream'
2
- import { after, binding, given, then } from 'cucumber-tsflow'
3
- import { match } from '@toa.io/generic'
1
+ import * as assert from 'node:assert'
2
+ import { setTimeout } from 'node:timers/promises'
3
+ import tsflow from 'cucumber-tsflow'
4
4
 
5
- import { type Component } from '@toa.io/core'
6
- import { parse } from '@toa.io/yaml'
5
+ import { match } from '@toa.io/generic'
6
+ import { load as parse } from 'js-yaml'
7
7
  import * as stage from '@toa.io/userland/stage'
8
- import { Realtime } from './Realtime'
8
+ import { Realtime } from './Realtime.js'
9
+ import type { Readable } from 'node:stream'
10
+ import type { Component } from '@toa.io/core'
11
+
12
+ const { after, binding, given, then } = tsflow
9
13
 
10
14
  @binding([Realtime])
11
15
  export class Streams {
12
16
  private readonly realtime: Realtime
13
17
  private remote: Component | null = null
14
18
  private streams: Record<string, Readable> = {}
15
- private events: Record<string, object[]> = {}
19
+ private events: Record<string, Event[]> = {}
16
20
 
17
- public constructor (realtime: Realtime) {
21
+ public constructor(realtime: Realtime) {
18
22
  this.realtime = realtime
19
23
  }
20
24
 
21
- @given('the stream `{word}` is consumed')
22
- public async consume (key: string): Promise<void> {
25
+ @given('the stream `{word}` is consumed', { timeout: 30_000 })
26
+ public async consume(key: string): Promise<void> {
23
27
  await this.realtime.serve()
24
28
 
25
29
  this.remote ??= await stage.remote('realtime.streams')
26
30
  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))
31
+
32
+ await this.createStream(key)
29
33
  }
30
34
 
31
35
  @then('an event is received from the stream `{word}`:')
32
- public received (key: string, yaml: string): void {
33
- const expected = parse<object>(yaml)
36
+ public async received(key: string, yaml: string): Promise<void> {
37
+ await setTimeout(100)
34
38
 
35
- for (const event of this.events[key])
36
- if (match(event, expected))
37
- return
39
+ const expected = parse(yaml) as object
40
+
41
+ for (const event of this.events[key]) if (match(event, expected)) return
38
42
 
39
43
  throw new Error('No matching event received')
40
44
  }
41
45
 
46
+ @then('the consumer `{word}` is disconnected')
47
+ public disconnected(key: string): void {
48
+ this.streams[key]?.destroy()
49
+ delete this.streams[key]
50
+ }
51
+
52
+ @then('the consumer `{word}` is reconnected')
53
+ public async reconnected(key: string): Promise<void> {
54
+ const last = this.events[key].findLast((event) => event.event === 'token')
55
+
56
+ assert.ok(last, `No last event found for stream ${key}`)
57
+
58
+ await this.createStream(key, last.data as string)
59
+ }
60
+
42
61
  @after()
43
- private shutdown (): void {
44
- for (const stream of Object.values(this.streams))
45
- stream.destroy()
62
+ public async shutdown(): Promise<void> {
63
+ for (const stream of Object.values(this.streams)) stream.destroy()
46
64
 
47
65
  this.streams = {}
48
66
  this.events = {}
67
+
68
+ await setTimeout(100)
49
69
  }
70
+
71
+ private async createStream(key: string, token?: string): Promise<void> {
72
+ this.streams[key] = await this.remote!.invoke('create', { input: { key, token } })
73
+ this.streams[key].on('data', (event: Event) => {
74
+ console.log('[TEST] Received event', event)
75
+ this.events[key]?.push(event)
76
+ })
77
+ }
78
+ }
79
+
80
+ interface Event {
81
+ key: string
82
+ token: string
83
+ event: string
84
+ data?: unknown
50
85
  }
@@ -1,16 +1,45 @@
1
1
  name: messages
2
2
 
3
3
  entity:
4
- schema:
5
- sender*: string(32)
6
- recipient*: string(32)
7
- text*: string
4
+ properties:
5
+ watchers:
6
+ type: array
7
+ items:
8
+ type: string
9
+ maxLength: 32
10
+ sender:
11
+ type: string
12
+ maxLength: 32
13
+ recipient:
14
+ type: string
15
+ maxLength: 32
16
+ text:
17
+ type: string
18
+ required:
19
+ - sender
20
+ - recipient
21
+ - text
8
22
 
9
23
  operations:
10
24
  create:
11
25
  query: false
12
26
  forward: transit
13
27
  input:
14
- sender*: .
15
- recipient*: .
16
- text*: .
28
+ properties:
29
+ watchers:
30
+ type: string
31
+ default: .
32
+ sender:
33
+ type: string
34
+ default: .
35
+ recipient:
36
+ type: string
37
+ default: .
38
+ text:
39
+ type: string
40
+ default: .
41
+ type: object
42
+ required:
43
+ - sender
44
+ - recipient
45
+ - text
@@ -1 +1,15 @@
1
- process.env.TOA_DEV = '1'
1
+ import { setDefaultTimeout } from '@cucumber/cucumber'
2
+ import { environment } from '@toa.io/generic'
3
+
4
+ environment.set('TOA_DEV', '1')
5
+
6
+ // a reply is checked against what the operation declares, so the suite runs Toa under the
7
+ // contract it asks applications to keep
8
+ if (!environment.has('TOA_ENV')) environment.set('TOA_ENV', 'local')
9
+
10
+ // the streams component boots inside the service, and without a variable it would wait
11
+ // for the values service, which these features do not run
12
+ if (!environment.has('TOA_CONFIGURATION_REALTIME_STREAMS'))
13
+ environment.set('TOA_CONFIGURATION_REALTIME_STREAMS', '{}')
14
+
15
+ setDefaultTimeout(60 * 1000)
package/package.json CHANGED
@@ -1,6 +1,7 @@
1
1
  {
2
2
  "name": "@toa.io/extensions.realtime",
3
- "version": "1.0.0-alpha.29",
3
+ "version": "1.0.0-alpha.291",
4
+ "type": "module",
4
5
  "description": "Toa Realtime",
5
6
  "author": "temich <tema.gurtovoy@gmail.com>",
6
7
  "homepage": "https://github.com/toa-io/toa#readme",
@@ -16,13 +17,14 @@
16
17
  "publishConfig": {
17
18
  "access": "public"
18
19
  },
19
- "jest": {
20
- "preset": "ts-jest",
21
- "testEnvironment": "node"
20
+ "dependencies": {
21
+ "@toa.io/core": "1.0.0-alpha.291",
22
+ "@toa.io/generic": "1.0.0-alpha.291",
23
+ "openspan": "1.0.0-alpha.286"
22
24
  },
23
25
  "scripts": {
24
- "transpile": "npx tsc && npx tsc -p ./components/streams",
25
- "features": "npx cucumber-js"
26
+ "transpile": "npx tsc && npx tsc -p ./components/realtime.streams",
27
+ "features": "TSX_TSCONFIG_PATH=features/steps/tsconfig.json NODE_OPTIONS=--import=tsx cucumber-js"
26
28
  },
27
- "gitHead": "186d0e17c12a9016b8076edf925963cbb32b9219"
29
+ "gitHead": "bcd8d8d07bd3e64fc826af8719472404662cc37f"
28
30
  }
package/readme.md CHANGED
@@ -10,17 +10,20 @@
10
10
  </a>
11
11
 
12
12
  Realtime extension combines application events into streams according to defined routes.
13
- Clients may consume these streams [via Exposition](#exposition).
13
+ Clients may consume these streams [via Exposition](/extensions/exposition).
14
+
15
+ If stream is idle for 16 seconds, a `heartbeat` message is sent.
14
16
 
15
17
  ## Static routes
16
18
 
17
19
  Static route specifies an event that should be combined into a stream using specified property of
18
- event's payload as a stream key.
20
+ event's payload as a stream key or an array of stream keys.
19
21
 
20
22
  Static routes may be defined in Component manifest or the Context annotation.
21
23
 
22
24
  ```yaml
23
25
  # manifest.toa.yaml
26
+
24
27
  name: users
25
28
 
26
29
  realtime:
@@ -29,13 +32,24 @@ realtime:
29
32
 
30
33
  ```yaml
31
34
  # context.toa.yaml
35
+
32
36
  realtime:
33
37
  users.updated: id
34
- orders.created: custromer_id
38
+ orders.created: customer_id
35
39
  ```
36
40
 
37
41
  In case of conflict, the Context annotation takes precedence.
38
42
 
43
+ Multiple stream keys may be defined for a single event.
44
+
45
+ ```yaml
46
+ # manifest.toa.yaml
47
+ name: messages
48
+
49
+ realtime:
50
+ updated: [sender_id, recipient_id]
51
+ ```
52
+
39
53
  ### Static route examples
40
54
 
41
55
  Given two rules: `users.updated: id` and `orders.created: customer_id`,
@@ -87,13 +101,25 @@ Dynamic routes are managed by the `realtime.routes` component, running in the Re
87
101
 
88
102
  ## Exposition
89
103
 
90
- Streams are exposed by the [`realtime.streams`](components/streams) component, running in the
104
+ Streams are exposed by the [`realtime.streams`](components/realtime.streams) component, running in the
91
105
  Realtime extension, and are
92
106
  accessible via the `/realtime/streams/:key/` resource with
93
107
  the [`auth:id: key`](/extensions/exposition/documentation/access.md#id) authorization rule.
94
108
 
95
- Refer to the [Exposition extension](/extensions/exposition) for more
96
- details:
109
+ Refer to the [Exposition extension](/extensions/exposition) for more details:
97
110
 
98
- - [Streams](/extensions/exposition/documentation/protocol.md#streams)
111
+ - [Multipart responses](/extensions/exposition/documentation/protocol.md#multipart-types)
99
112
  - [Access authorization](/extensions/exposition/documentation/access.md)
113
+
114
+ ## Resources management
115
+
116
+ Resource requests and limits can be specified by `resources` annotation:
117
+
118
+ ```yaml
119
+ # context.toa.yaml
120
+
121
+ realtime:
122
+ resources:
123
+ cpu: [100m, 500m]
124
+ memory: [100Mi, 200Mi]
125
+ ```
@@ -1,40 +1,51 @@
1
1
  import { readdirSync, type Dirent } from 'node:fs'
2
2
  import { resolve } from 'node:path'
3
3
  import { Connector } from '@toa.io/core'
4
- import { type Bootloader } from './Factory'
4
+ import { type Host } from './Factory.js'
5
5
 
6
6
  export class Composition extends Connector {
7
- private readonly boot: Bootloader
7
+ private readonly host: Host
8
8
 
9
- public constructor (boot: Bootloader) {
9
+ public constructor(host: Host) {
10
10
  super()
11
- this.boot = boot
11
+ this.host = host
12
12
  }
13
13
 
14
- protected override async open (): Promise<void> {
14
+ protected override async open(): Promise<void> {
15
15
  const paths = find()
16
- const composition = await this.boot.composition(paths)
16
+ const composition = await this.host.composition(paths)
17
17
 
18
18
  await composition.connect()
19
19
 
20
20
  this.depends(composition)
21
-
22
- console.info('Composition complete.')
23
- }
24
-
25
- protected override dispose (): void {
26
- console.info('Composition shutdown complete.')
27
21
  }
28
22
  }
29
23
 
30
- function find (): string[] {
24
+ export function find(): string[] {
31
25
  return entries().map((entry) => resolve(ROOT, entry.name))
32
26
  }
33
27
 
34
- function entries (): Dirent[] {
28
+ function entries(): Dirent[] {
35
29
  const entries = readdirSync(ROOT, { withFileTypes: true })
36
30
 
37
31
  return entries.filter((entry) => entry.isDirectory())
38
32
  }
39
33
 
40
- const ROOT = resolve(__dirname, '../components/')
34
+ export function components(): Components {
35
+ const labels: string[] = []
36
+ const paths: string[] = []
37
+
38
+ for (const entry of entries()) {
39
+ labels.push(entry.name.replace('.', '-'))
40
+ paths.push(resolve(ROOT, entry.name))
41
+ }
42
+
43
+ return { labels, paths }
44
+ }
45
+
46
+ interface Components {
47
+ labels: string[]
48
+ paths: string[]
49
+ }
50
+
51
+ const ROOT = resolve(import.meta.dirname, '../components/')
package/source/Factory.ts CHANGED
@@ -1,20 +1,20 @@
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'
1
+ import { type Component, Locator } from '@toa.io/core'
2
+ import type { extensions } from '@toa.io/core/types'
3
+ import { Realtime } from './Realtime.js'
4
+ import { Composition } from './Composition.js'
5
+ import { Routes } from './Routes.js'
5
6
 
6
7
  export class Factory implements extensions.Factory {
7
- private readonly boot: Bootloader
8
+ private readonly host: Host
8
9
 
9
- public constructor (boot: Bootloader) {
10
- this.boot = boot
10
+ public constructor(host: Host) {
11
+ this.host = host
11
12
  }
12
13
 
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)
14
+ public service(): Realtime {
15
+ const routes = new Routes(this.host)
16
+ const composition = new Composition(this.host)
17
+ const realtime = new Realtime(routes, async () => await this.discovery())
18
18
 
19
19
  realtime.depends(routes)
20
20
  realtime.depends(composition)
@@ -22,12 +22,11 @@ export class Factory implements extensions.Factory {
22
22
  return realtime
23
23
  }
24
24
 
25
- private async discovery (): Promise<Component> {
25
+ private async discovery(): Promise<Component> {
26
26
  const locator = new Locator('streams', 'realtime')
27
27
 
28
- return await this.boot.remote(locator)
28
+ return await this.host.remote(locator, { service: 'realtime' })
29
29
  }
30
30
  }
31
31
 
32
- // eslint-disable-next-line @typescript-eslint/consistent-type-imports
33
- export type Bootloader = typeof import('@toa.io/boot')
32
+ export type Host = extensions.Host
@@ -1,11 +1,13 @@
1
+ import { console, run } from 'openspan'
1
2
  import { type Component, Connector } from '@toa.io/core'
2
- import { type Routes } from './Routes'
3
+ import { type Routes } from './Routes.js'
4
+ import type { Push } from './Receiver.js'
3
5
 
4
6
  export class Realtime extends Connector {
5
- private readonly discovery: Promise<Component>
7
+ private readonly discovery: () => Promise<Component>
6
8
  private streams: Component | null = null
7
9
 
8
- public constructor (routes: Routes, discovery: Promise<Component>) {
10
+ public constructor(routes: Routes, discovery: () => Promise<Component>) {
9
11
  super()
10
12
 
11
13
  this.discovery = discovery
@@ -13,26 +15,57 @@ export class Realtime extends Connector {
13
15
  routes.events.on('data', this.push.bind(this))
14
16
  }
15
17
 
16
- protected override async open (): Promise<void> {
17
- this.streams = await this.discovery
18
+ protected override async open(): Promise<void> {
19
+ // the lookup belongs here, not in the constructor: dependencies connect before
20
+ // open, so the composition that serves realtime.streams is up to answer it. Asked
21
+ // any earlier the lookup goes unanswered, and it waits without a bound.
22
+ this.streams = await this.discovery()
18
23
  this.depends(this.streams)
19
24
 
20
25
  await this.streams.connect()
21
26
 
22
- console.log('Realtime has started.')
27
+ console.info('Realtime service started')
23
28
  }
24
29
 
25
- protected override dispose (): void {
26
- console.log('Realtime shutdown complete.')
30
+ protected override dispose(): void {
31
+ console.info('Realtime service shutdown complete')
27
32
  }
28
33
 
29
- private push (event: Event): void {
30
- void this.streams?.invoke('push', { input: event })
34
+ private push({ telemetry, ...event }: Push): void {
35
+ const processing =
36
+ telemetry === null
37
+ ? this.deliver(event)
38
+ : run(telemetry, async () => await this.deliver(event))
39
+
40
+ void processing.catch((error) => console.error('Realtime push failed', error))
31
41
  }
32
- }
33
42
 
34
- interface Event {
35
- key: string
36
- event: string
37
- data: string
43
+ private async deliver(event: Omit<Push, 'telemetry'>): Promise<void> {
44
+ /*
45
+ * The delivery span is created on behalf of the messaging destination
46
+ * (same as the core Receiver), so that service graphs display the fan-out:
47
+ * producer -> destination -> realtime
48
+ */
49
+ const delivery = {
50
+ name: `${event.event} deliver`,
51
+ kind: 'producer' as const,
52
+ service: event.event,
53
+ attributes: { 'messaging.destination.name': event.event }
54
+ }
55
+
56
+ const options = {
57
+ name: `${event.event} push`,
58
+ kind: 'consumer' as const,
59
+ service: 'realtime',
60
+ attributes: { 'messaging.destination.name': event.event }
61
+ }
62
+
63
+ await console.span(
64
+ delivery,
65
+ async () =>
66
+ await console.span(options, async () => {
67
+ await this.streams?.invoke('push', { input: event })
68
+ })
69
+ )
70
+ }
38
71
  }
@@ -0,0 +1,95 @@
1
+ import { console, decode, run } from 'openspan'
2
+ import { Connector } from '@toa.io/core'
3
+ import type { Message } from '@toa.io/core/types'
4
+ import type { SpanContext } from 'openspan'
5
+ import type { Readable } from 'node:stream'
6
+
7
+ export class Receiver extends Connector {
8
+ private readonly event: string
9
+ private readonly properties: string[]
10
+ private readonly expose?: string[]
11
+ private readonly stream: Readable
12
+
13
+ public constructor({
14
+ event,
15
+ properties,
16
+ stream,
17
+ expose
18
+ }: {
19
+ event: string
20
+ properties: string[]
21
+ stream: Readable
22
+ expose?: string[]
23
+ }) {
24
+ super()
25
+
26
+ this.event = event
27
+ this.properties = properties
28
+ this.expose = expose
29
+ this.stream = stream
30
+ }
31
+
32
+ public async receive(message: Message<Record<string, string>>): Promise<void> {
33
+ // the push continues the trace from the producer
34
+ const telemetry = message.telemetry === undefined ? null : decode(message.telemetry)
35
+
36
+ if (telemetry === null) this.process(message, telemetry)
37
+ else run(telemetry, () => this.process(message, telemetry))
38
+ }
39
+
40
+ private process(
41
+ message: Message<Record<string, string>>,
42
+ telemetry: SpanContext | null
43
+ ): void {
44
+ const data = this.fit(message.payload)
45
+
46
+ for (const property of this.properties) {
47
+ const key = message.payload[property]
48
+
49
+ if (key === undefined) {
50
+ console.debug('Event does not contain key property', {
51
+ property,
52
+ event: this.event
53
+ })
54
+
55
+ continue
56
+ }
57
+
58
+ if (Array.isArray(key))
59
+ // eslint-disable-next-line max-depth
60
+ for (const k of key as string[]) this.push(k, data, telemetry)
61
+ else this.push(key, data, telemetry)
62
+ }
63
+ }
64
+
65
+ private fit(payload: Record<string, string>): Record<string, string> {
66
+ if (this.expose === undefined) return payload
67
+
68
+ const entries = Object.entries(payload).filter(([key]) => this.expose!.includes(key))
69
+
70
+ return Object.fromEntries(entries)
71
+ }
72
+
73
+ private push(
74
+ key: string | null,
75
+ data: Record<string, string>,
76
+ telemetry: SpanContext | null
77
+ ): void {
78
+ if (key === null || typeof key === 'undefined') {
79
+ console.debug('Key is null or undefined, skipping', { key, event: this.event })
80
+
81
+ return
82
+ }
83
+
84
+ console.debug('Pushing event to stream', { key, event: this.event, data })
85
+
86
+ this.stream.push({ key, event: this.event, data, telemetry } satisfies Push)
87
+ }
88
+ }
89
+
90
+ export interface Push {
91
+ key: string
92
+ event: string
93
+ data: Record<string, string>
94
+ telemetry: SpanContext | null
95
+ }