@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
package/CHANGELOG.md ADDED
@@ -0,0 +1,35 @@
1
+ # Change Log
2
+
3
+ All notable changes to this project will be documented in this file.
4
+ See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
5
+
6
+ # [1.0.0-alpha.291](https://github.com/toa-io/toa/compare/v1.0.0-alpha.290...v1.0.0-alpha.291) (2026-09-07)
7
+
8
+ **Note:** Version bump only for package @toa.io/extensions.realtime
9
+
10
+
11
+
12
+
13
+
14
+ # [1.0.0-alpha.289](https://github.com/toa-io/toa/compare/v1.0.0-alpha.288...v1.0.0-alpha.289) (2026-09-07)
15
+
16
+ * A deploy moves only what changed, and a component sees none of the runtime's environment (#1073) ([f38e3db](https://github.com/toa-io/toa/commit/f38e3db533f24db866c57bfa1ef294eff1eaf499)), closes [#1073](https://github.com/toa-io/toa/issues/1073) [#1064](https://github.com/toa-io/toa/issues/1064) [#1066](https://github.com/toa-io/toa/issues/1066) [#1067](https://github.com/toa-io/toa/issues/1067) [#1068](https://github.com/toa-io/toa/issues/1068) [#1069](https://github.com/toa-io/toa/issues/1069) [#1071](https://github.com/toa-io/toa/issues/1071) [#1070](https://github.com/toa-io/toa/issues/1070) [#1072](https://github.com/toa-io/toa/issues/1072)
17
+
18
+ ### BREAKING CHANGES
19
+
20
+ * a component that read `process.env.TOA_*` reads `context` instead;
21
+ `echo(input)` no longer substitutes from the environment; a bash operation sees no
22
+ `TOA_*`; images no longer set `USER node` — see migrations/289.md.
23
+
24
+
25
+ # [1.0.0-alpha.288](https://github.com/toa-io/toa/compare/v1.0.0-alpha.287...v1.0.0-alpha.288) (2026-09-06)
26
+
27
+ **Note:** Version bump only for package @toa.io/extensions.realtime
28
+
29
+
30
+
31
+
32
+
33
+ # [1.0.0-alpha.287](https://github.com/toa-io/toa/compare/v1.0.0-alpha.286...v1.0.0-alpha.287) (2026-09-06)
34
+
35
+ **Note:** Version bump only for package @toa.io/extensions.realtime
@@ -0,0 +1,49 @@
1
+ namespace: realtime
2
+ name: streams
3
+
4
+ operations:
5
+ create:
6
+ input:
7
+ properties:
8
+ key:
9
+ type: string
10
+ token:
11
+ type: string
12
+ type: object
13
+ push:
14
+ output: { type: 'null' }
15
+ bindings: ~ # only for internal use
16
+ input:
17
+ properties:
18
+ data: {}
19
+ key:
20
+ type: string
21
+ event:
22
+ type: string
23
+ type: object
24
+ required:
25
+ - key
26
+ - event
27
+
28
+ configuration:
29
+ schema:
30
+ properties:
31
+ maxlen:
32
+ type: number
33
+ expire:
34
+ type: number
35
+ type: object
36
+ # both have defaults, so the component reads them without checking
37
+ required: [maxlen, expire]
38
+
39
+ defaults:
40
+ maxlen: 1000
41
+ expire: 300
42
+
43
+ state: ~
44
+ stash: ~
45
+
46
+ exposition:
47
+ /:key:
48
+ auth:id: key
49
+ GET: create
@@ -0,0 +1,65 @@
1
+ import { Stream, Stash } from './lib/index.js';
2
+ export class Effect {
3
+ streams = new Map();
4
+ stash;
5
+ logs;
6
+ mount(context) {
7
+ context.state.streams = this.streams;
8
+ context.state.stash = new Stash(context.stash, context.configuration, context.logs);
9
+ this.logs = context.logs;
10
+ this.stash = context.state.stash;
11
+ }
12
+ unmount() {
13
+ this.logs.info('Destroying streams', { count: this.streams.size });
14
+ // closed, not destroyed: destroying a stream that is still piped to a response
15
+ // makes end-of-stream report a premature close, which reaches no one and takes
16
+ // the process down.
17
+ for (const stream of this.streams.values())
18
+ stream.close();
19
+ }
20
+ async execute(input) {
21
+ const key = input.key;
22
+ if (!this.streams.has(key)) {
23
+ const stream = this.createStream(key);
24
+ this.streams.set(key, stream);
25
+ this.logs.debug('Stream created', { key });
26
+ }
27
+ // welcome
28
+ setTimeout(() => this.streams.get(key)?.heartbeat(), 1000);
29
+ if (input.token === undefined)
30
+ void this.stash.connect(key).then((token) => {
31
+ if (token instanceof Error)
32
+ this.logs.error('Failed to connect to stash', { key, error: token });
33
+ else
34
+ this.streams.get(key)?.push({ event: 'token', data: token });
35
+ });
36
+ else
37
+ void this.stash.pop(key, input.token).then((result) => {
38
+ if (result === null)
39
+ return;
40
+ if ('code' in result && result.code === 'NO_RESULTS')
41
+ return;
42
+ if (result instanceof Error) {
43
+ this.logs.error('Failed to pop from stash', { key, error: result });
44
+ return;
45
+ }
46
+ const stream = this.streams.get(key);
47
+ if (stream === undefined)
48
+ return;
49
+ const [token, events] = result;
50
+ for (const event of events)
51
+ stream.push({ event: event.event, data: event.data });
52
+ stream.push({ event: 'token', data: token });
53
+ });
54
+ return this.streams.get(key);
55
+ }
56
+ createStream(key) {
57
+ const stream = new Stream();
58
+ stream.events.once('destroy', () => {
59
+ this.logs.debug('Stream destroyed', { key });
60
+ this.streams.delete(key);
61
+ });
62
+ return stream;
63
+ }
64
+ }
65
+ //# sourceMappingURL=data:application/json;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoiY3JlYXRlLmpzIiwic291cmNlUm9vdCI6IiIsInNvdXJjZXMiOlsiLi4vc291cmNlL2NyZWF0ZS50cyJdLCJuYW1lcyI6W10sIm1hcHBpbmdzIjoiQUFHQSxPQUFPLEVBQUUsTUFBTSxFQUFFLEtBQUssRUFBRSxNQUFNLGdCQUFnQixDQUFBO0FBRTlDLE1BQU0sT0FBTyxNQUFNO0lBQ0EsT0FBTyxHQUFHLElBQUksR0FBRyxFQUFrQixDQUFBO0lBQzVDLEtBQUssQ0FBUTtJQUNiLElBQUksQ0FBSztJQUVWLEtBQUssQ0FBQyxPQUFnQjtRQUMzQixPQUFPLENBQUMsS0FBSyxDQUFDLE9BQU8sR0FBRyxJQUFJLENBQUMsT0FBTyxDQUFBO1FBQ3BDLE9BQU8sQ0FBQyxLQUFLLENBQUMsS0FBSyxHQUFHLElBQUksS0FBSyxDQUFDLE9BQU8sQ0FBQyxLQUFLLEVBQUUsT0FBTyxDQUFDLGFBQWEsRUFBRSxPQUFPLENBQUMsSUFBSSxDQUFDLENBQUE7UUFFbkYsSUFBSSxDQUFDLElBQUksR0FBRyxPQUFPLENBQUMsSUFBSSxDQUFBO1FBQ3hCLElBQUksQ0FBQyxLQUFLLEdBQUcsT0FBTyxDQUFDLEtBQUssQ0FBQyxLQUFLLENBQUE7SUFDbEMsQ0FBQztJQUVNLE9BQU87UUFDWixJQUFJLENBQUMsSUFBSSxDQUFDLElBQUksQ0FBQyxvQkFBb0IsRUFBRSxFQUFFLEtBQUssRUFBRSxJQUFJLENBQUMsT0FBTyxDQUFDLElBQUksRUFBRSxDQUFDLENBQUE7UUFFbEUsK0VBQStFO1FBQy9FLCtFQUErRTtRQUMvRSxvQkFBb0I7UUFDcEIsS0FBSyxNQUFNLE1BQU0sSUFBSSxJQUFJLENBQUMsT0FBTyxDQUFDLE1BQU0sRUFBRTtZQUFFLE1BQU0sQ0FBQyxLQUFLLEVBQUUsQ0FBQTtJQUM1RCxDQUFDO0lBRU0sS0FBSyxDQUFDLE9BQU8sQ0FBQyxLQUFZO1FBQy9CLE1BQU0sR0FBRyxHQUFHLEtBQUssQ0FBQyxHQUFHLENBQUE7UUFFckIsSUFBSSxDQUFDLElBQUksQ0FBQyxPQUFP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
@@ -0,0 +1,86 @@
1
+ export class Stash {
2
+ stash;
3
+ configuration;
4
+ logs;
5
+ constructor(stash, configuration, logs) {
6
+ this.stash = stash;
7
+ this.configuration = configuration;
8
+ this.logs = logs;
9
+ }
10
+ async connect(key) {
11
+ return await this.xadd(key, 'connect');
12
+ }
13
+ async push(key, event, data) {
14
+ return await this.xadd(key, event, data);
15
+ }
16
+ async pop(key, token) {
17
+ const stamp = this.decode(token);
18
+ const results = await this.stash
19
+ .xread('STREAMS', key, stamp)
20
+ .catch((error) => error);
21
+ if (results === null)
22
+ return ERR_NO_RESULTS;
23
+ if (results instanceof Error)
24
+ return results;
25
+ const items = entries(results);
26
+ if (items === null)
27
+ return null;
28
+ const events = [];
29
+ let lastStamp = null;
30
+ for (const item of items) {
31
+ const [, event, , json] = item[1];
32
+ lastStamp = item[0];
33
+ const data = json === undefined ? undefined : JSON.parse(json);
34
+ if (data === undefined)
35
+ this.logs.debug('Undefined event payload', { key, event });
36
+ events.push({ event, data });
37
+ }
38
+ if (lastStamp === null)
39
+ return null;
40
+ return [this.encode(lastStamp), events];
41
+ }
42
+ async xadd(key, event, data) {
43
+ const args = ['MAXLEN', '~', this.configuration.maxlen, '*', 'type', event];
44
+ if (data !== undefined)
45
+ args.push('data', JSON.stringify(data));
46
+ const results = await this.stash
47
+ .multi()
48
+ .xadd(key, ...args)
49
+ .expire(key, this.configuration.expire)
50
+ .exec()
51
+ .catch((error) => error);
52
+ if (results === null)
53
+ return ERR_NO_RESULTS;
54
+ if (results instanceof Error)
55
+ return results;
56
+ const [[error, stamp]] = results;
57
+ if (error !== null)
58
+ return error;
59
+ return this.encode(stamp);
60
+ }
61
+ encode(token) {
62
+ return Buffer.from(token).toString('base64url');
63
+ }
64
+ decode(token) {
65
+ return Buffer.from(token, 'base64url').toString();
66
+ }
67
+ }
68
+ /**
69
+ * `xread` answers with an array of `[stream, entries]` pairs over RESP2 and with an
70
+ * object keyed by stream name over RESP3. One stream is read, so either way it is the
71
+ * first, and an empty answer means nothing has been added since the token.
72
+ */
73
+ function entries(results) {
74
+ if (Array.isArray(results))
75
+ return results.length === 0 ? null : results[0][1];
76
+ if (typeof results === 'object' && results !== null) {
77
+ const streams = Object.values(results);
78
+ return streams.length === 0 ? null : streams[0];
79
+ }
80
+ return null;
81
+ }
82
+ class NoResultsError extends Error {
83
+ code = 'NO_RESULTS';
84
+ }
85
+ const ERR_NO_RESULTS = new NoResultsError();
86
+ //# sourceMappingURL=data:application/json;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoiU3Rhc2guanMiLCJzb3VyY2VSb290IjoiIiwic291cmNlcyI6WyIuLi8uLi9zb3VyY2UvbGliL1N0YXNoLnRzIl0sIm5hbWVzIjpbXSwibWFwcGluZ3MiOiJBQUVBLE1BQU0sT0FBTyxLQUFLO0lBQ0MsS0FBSyxDQUFPO0lBQ1osYUFBYSxDQUFlO0lBQzVCLElBQUksQ0FBSztJQUUxQixZQUFtQixLQUFVLEVBQUUsYUFBNEIsRUFBRSxJQUFTO1FBQ3BFLElBQUksQ0FBQyxLQUFLLEdBQUcsS0FBSyxDQUFBO1FBQ2xCLElBQUksQ0FBQyxhQUFhLEdBQUcsYUFBYSxDQUFBO1FBQ2xDLElBQUksQ0FBQyxJQUFJLEdBQUcsSUFBSSxDQUFBO0lBQ2xCLENBQUM7SUFFTSxLQUFLLENBQUMsT0FBTyxDQUFDLEdBQVc7UUFDOUIsT0FBTyxNQUFNLElBQUksQ0FBQyxJQUFJLENBQUMsR0FBRyxFQUFFLFNBQVMsQ0FBQyxDQUFBO0lBQ3hDLENBQUM7SUFFTSxLQUFLLENBQUMsSUFBSSxDQUFDLEdBQVcsRUFBRSxLQUFhLEVBQUUsSUFBYTtRQUN6RCxPQUFPLE1BQU0sSUFBSSxDQUFDLElBQUksQ0FBQyxHQUFHLEVBQUUsS0FBSyxFQUFFLElBQUksQ0FBQyxDQUFBO0lBQzFDLENBQUM7SUFFTSxLQUFLLENBQUMsR0FBRyxDQUNkLEdBQVcsRUFDWCxLQUFhO1FBRWIsTUFBTSxLQUFLLEdBQUcsSUFBSSxDQUFDLE1BQU0sQ0FBQyxLQUFLLENBQUMsQ0FBQTtRQUVoQyxNQUFNLE9BQU8sR0FBRyxNQUFNLElBQUksQ0FBQyxLQUFLO2FBQzdCLEtBQUssQ0FBQyxTQUFTLEVBQUUsR0FBRyxFQUFFLEtBQUssQ0FBQzthQUM1QixLQUFLLENBQUMsQ0FBQyxLQUFZLEVBQUUsRUFBRSxDQUFDLEtBQUssQ0FBQyxDQUFBO1FBRWpDLElBQUksT0FBTyxLQUFLLElBQUk7WUFBRSxPQUFPLGNBQWMsQ0FBQTtRQUUzQyxJQUFJLE9BQU8sWUFBWSxLQUFLO1lBQUUsT0FBTyxPQUFPLENBQUE7UUFF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
@@ -0,0 +1,43 @@
1
+ import { EventEmitter } from 'node:events';
2
+ import { Readable } from 'node:stream';
3
+ export class Stream extends Readable {
4
+ events = new EventEmitter();
5
+ interval = null;
6
+ ended = false;
7
+ constructor() {
8
+ super(objectMode);
9
+ }
10
+ // has to be here
11
+ _read() {
12
+ if (this.interval === null)
13
+ this.interval = setInterval(() => this.heartbeat(), HEARTBEAT_INTERVAL);
14
+ }
15
+ _destroy(error, callback) {
16
+ if (this.interval !== null)
17
+ clearInterval(this.interval);
18
+ this.events.emit('destroy');
19
+ super._destroy(error, callback);
20
+ }
21
+ /** Ends the stream: whoever reads it gets EOF rather than a broken pipe. */
22
+ close() {
23
+ this.ended = true;
24
+ if (this.interval !== null) {
25
+ clearInterval(this.interval);
26
+ this.interval = null;
27
+ }
28
+ this.push(null);
29
+ }
30
+ heartbeat(stream = this) {
31
+ if (this.ended)
32
+ return false;
33
+ const resume = stream.push('heartbeat ' + Date.now());
34
+ if (!resume && this.interval !== null) {
35
+ clearInterval(this.interval);
36
+ this.interval = null;
37
+ }
38
+ return resume;
39
+ }
40
+ }
41
+ const HEARTBEAT_INTERVAL = 16_000; // why?
42
+ const objectMode = { objectMode: true };
43
+ //# sourceMappingURL=data:application/json;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoiU3RyZWFtLmpzIiwic291cmNlUm9vdCI6IiIsInNvdXJjZXMiOlsiLi4vLi4vc291cmNlL2xpYi9TdHJlYW0udHMiXSwibmFtZXMiOltdLCJtYXBwaW5ncyI6IkFBQUEsT0FBTyxFQUFFLFlBQVksRUFBRSxNQUFNLGFBQWEsQ0FBQTtBQUMxQyxPQUFPLEVBQUUsUUFBUSxFQUFFLE1BQU0sYUFBYSxDQUFBO0FBRXRDLE1BQU0sT0FBTyxNQUFPLFNBQVEsUUFBUTtJQUMzQixNQUFNLEdBQUcsSUFBSSxZQUFZLEVBQUUsQ0FBQTtJQUUxQixRQUFRLEdBQTBCLElBQUksQ0FBQTtJQUN0QyxLQUFLLEdBQUcsS0FBSyxDQUFBO0lBRXJCO1FBQ0UsS0FBSyxDQUFDLFVBQVUsQ0FBQyxDQUFBO0lBQ25CLENBQUM7SUFFRCxpQkFBaUI7SUFDRCxLQUFLO1FBQ25CLElBQUksSUFBSSxDQUFDLFFBQVEsS0FBSyxJQUFJO1lBQ3hCLElBQUksQ0FBQyxRQUFRLEdBQUcsV0FBVyxDQUFDLEdBQUcsRUFBRSxDQUFDLElBQUksQ0FBQyxTQUFTLEVBQUUsRUFBRSxrQkFBa0IsQ0FBQyxDQUFBO0lBQzNFLENBQUM7SUFFZSxRQUFRLENBQ3RCLEtBQW1CLEVBQ25CLFFBQXdDO1FBRXhDLElBQUksSUFBSSxDQUFDLFFBQVEsS0FBSyxJQUFJO1lBQUUsYUFBYSxDQUFDLElBQUksQ0FBQyxRQUFRLENBQUMsQ0FBQTtRQUV4RCxJQUFJLENBQUMsTUFBTSxDQUFDLElBQUksQ0FBQyxTQUFTLENBQUMsQ0FBQTtRQUUzQixLQUFLLENBQUMsUUFBUSxDQUFDLEtBQUssRUFBRSxRQUFRLENBQUMsQ0FBQTtJQUNqQyxDQUFDO0lBRUQsNEVBQTRFO0lBQ3JFLEtBQUs7UUFDVixJQUFJLENBQUMsS0FBSyxHQUFHLElBQUksQ0FBQTtRQUVqQixJQUFJLElBQUksQ0FBQyxRQUFRLEtBQUssSUFBSSxFQUFFLENBQUM7WUFDM0IsYUFBYSxDQUFDLElBQUksQ0FBQyxRQUFRLENBQUMsQ0FBQTtZQUM1QixJQUFJLENBQUMsUUFBUSxHQUFHLElBQUksQ0FBQTtRQUN0QixDQUFDO1FBRUQsSUFBSSxDQUFDLElBQUksQ0FBQyxJQUFJLENBQUMsQ0FBQTtJQUNqQixDQUFDO0lBRU0sU0FBUyxDQUFDLE1BQU0sR0FBYSxJQUFJO1FBQ3RDLElBQUksSUFBSSxDQUFDLEtBQUs7WUFBRSxPQUFPLEtBQUssQ0FBQTtRQUU1QixNQUFNLE1BQU0sR0FBRyxNQUFNLENBQUMsSUFBSSxDQUFDLFlBQVksR0FBRyxJQUFJLENBQUMsR0FBRyxFQUFFLENBQUMsQ0FBQTtRQUVyRCxJQUFJLENBQUMsTUFBTSxJQUFJLElBQUksQ0FBQyxRQUFRLEtBQUssSUFBSSxFQUFFLENBQUM7WUFDdEMsYUFBYSxDQUFDLElBQUksQ0FBQyxRQUFRLENBQUMsQ0FBQTtZQUM1QixJQUFJLENBQUMsUUFBUSxHQUFHLElBQUksQ0FBQTtRQUN0QixDQUFDO1FBRUQsT0FBTyxNQUFNLENBQUE7SUFDZixDQUFDO0NBQ0Y7QUFFRCxNQUFNLGtCQUFrQixHQUFHLE1BQU0sQ0FBQSxDQUFDLE9BQU87QUFDekMsTUFBTSxVQUFVLEdBQUcsRUFBRSxVQUFVLEVBQUUsSUFBSSxFQUFFLENBQUEifQ==
@@ -0,0 +1,3 @@
1
+ export { Stash } from './Stash.js';
2
+ export { Stream } from './Stream.js';
3
+ //# sourceMappingURL=data:application/json;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoiaW5kZXguanMiLCJzb3VyY2VSb290IjoiIiwic291cmNlcyI6WyIuLi8uLi9zb3VyY2UvbGliL2luZGV4LnRzIl0sIm5hbWVzIjpbXSwibWFwcGluZ3MiOiJBQUFBLE9BQU8sRUFBRSxLQUFLLEVBQUUsTUFBTSxZQUFZLENBQUE7QUFDbEMsT0FBTyxFQUFFLE1BQU0sRUFBRSxNQUFNLGFBQWEsQ0FBQSJ9
@@ -0,0 +1,10 @@
1
+ export async function effect({ key, event, data }, context) {
2
+ context.state.streams.get(key)?.push({ event, data });
3
+ void context.state.stash.push(key, event, data).then((token) => {
4
+ if (token instanceof Error)
5
+ context.logs.error('Failed to push to stash', { key, error: token });
6
+ else
7
+ context.state.streams.get(key)?.push({ event: 'token', data: token });
8
+ });
9
+ }
10
+ //# sourceMappingURL=data:application/json;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoicHVzaC5qcyIsInNvdXJjZVJvb3QiOiIiLCJzb3VyY2VzIjpbIi4uL3NvdXJjZS9wdXNoLnRzIl0sIm5hbWVzIjpbXSwibWFwcGluZ3MiOiJBQUVBLE1BQU0sQ0FBQyxLQUFLLFVBQVUsTUFBTSxDQUMxQixFQUFFLEdBQUcsRUFBRSxLQUFLLEVBQUUsSUFBSSxFQUFhLEVBQy9CLE9BQWdCO0lBRWhCLE9BQU8sQ0FBQyxLQUFLLENBQUMsT0FBTyxDQUFDLEdBQUcsQ0FBQyxHQUFHLENBQUMsRUFBRSxJQUFJLENBQUMsRUFBRSxLQUFLLEVBQUUsSUFBSSxFQUFFLENBQUMsQ0FBQTtJQUVyRCxLQUFLLE9BQU8sQ0FBQyxLQUFLLENBQUMsS0FBSyxDQUFDLElBQUksQ0FBQyxHQUFHLEVBQUUsS0FBSyxFQUFFLElBQUksQ0FBQyxDQUFDLElBQUksQ0FBQyxDQUFDLEtBQUssRUFBRSxFQUFFO1FBQzdELElBQUksS0FBSyxZQUFZLEtBQUs7WUFDeEIsT0FBTyxDQUFDLElBQUksQ0FBQyxLQUFLLENBQUMseUJBQXlCLEVBQUUsRUFBRSxHQUFHLEVBQUUsS0FBSyxFQUFFLEtBQUssRUFBRSxDQUFDLENBQUE7O1lBQ2pFLE9BQU8sQ0FBQyxLQUFLLENBQUMsT0FBTyxDQUFDLEdBQUcsQ0FBQyxHQUFHLENBQUMsRUFBRSxJQUFJLENBQUMsRUFBRSxLQUFLLEVBQUUsT0FBTyxFQUFFLElBQUksRUFBRSxLQUFLLEVBQUUsQ0FBQyxDQUFBO0lBQzVFLENBQUMsQ0FBQyxDQUFBO0FBQ0osQ0FBQyJ9
@@ -0,0 +1,94 @@
1
+ import { type Readable } from 'node:stream'
2
+ import { type Operation } from '@toa.io/bridges.node'
3
+ import { type Context } from '../types/index.js'
4
+ import { Stream, Stash } from './lib/index.js'
5
+
6
+ export class Effect implements Operation {
7
+ private readonly streams = new Map<string, Stream>()
8
+ private stash!: Stash
9
+ private logs: any
10
+
11
+ public mount(context: Context): void {
12
+ context.state.streams = this.streams
13
+ context.state.stash = new Stash(context.stash, context.configuration, context.logs)
14
+
15
+ this.logs = context.logs
16
+ this.stash = context.state.stash
17
+ }
18
+
19
+ public unmount(): void {
20
+ this.logs.info('Destroying streams', { count: this.streams.size })
21
+
22
+ // closed, not destroyed: destroying a stream that is still piped to a response
23
+ // makes end-of-stream report a premature close, which reaches no one and takes
24
+ // the process down.
25
+ for (const stream of this.streams.values()) stream.close()
26
+ }
27
+
28
+ public async execute(input: Input): Promise<Readable> {
29
+ const key = input.key
30
+
31
+ if (!this.streams.has(key)) {
32
+ const stream = this.createStream(key)
33
+
34
+ this.streams.set(key, stream)
35
+ this.logs.debug('Stream created', { key })
36
+ }
37
+
38
+ // welcome
39
+ setTimeout(() => this.streams.get(key)?.heartbeat(), 1000)
40
+
41
+ if (input.token === undefined)
42
+ void this.stash.connect(key).then((token) => {
43
+ if (token instanceof Error)
44
+ this.logs.error('Failed to connect to stash', { key, error: token })
45
+ else this.streams.get(key)?.push({ event: 'token', data: token })
46
+ })
47
+ else
48
+ void this.stash.pop(key, input.token).then((result) => {
49
+ if (result === null) return
50
+
51
+ if ('code' in result && result.code === 'NO_RESULTS') return
52
+
53
+ if (result instanceof Error) {
54
+ this.logs.error('Failed to pop from stash', { key, error: result })
55
+
56
+ return
57
+ }
58
+
59
+ const stream = this.streams.get(key)
60
+
61
+ if (stream === undefined) return
62
+
63
+ const [token, events] = result
64
+
65
+ for (const event of events as Event[])
66
+ stream.push({ event: event.event, data: event.data })
67
+
68
+ stream.push({ event: 'token', data: token })
69
+ })
70
+
71
+ return this.streams.get(key)!
72
+ }
73
+
74
+ private createStream(key: string): Stream {
75
+ const stream = new Stream()
76
+
77
+ stream.events.once('destroy', () => {
78
+ this.logs.debug('Stream destroyed', { key })
79
+ this.streams.delete(key)
80
+ })
81
+
82
+ return stream
83
+ }
84
+ }
85
+
86
+ interface Input {
87
+ key: string
88
+ token?: string
89
+ }
90
+
91
+ interface Event {
92
+ event: string
93
+ data: unknown
94
+ }
@@ -0,0 +1,131 @@
1
+ import type { Redis } from 'ioredis'
2
+
3
+ export class Stash {
4
+ private readonly stash: Redis
5
+ private readonly configuration: Configuration
6
+ private readonly logs: any
7
+
8
+ public constructor(stash: any, configuration: Configuration, logs: any) {
9
+ this.stash = stash
10
+ this.configuration = configuration
11
+ this.logs = logs
12
+ }
13
+
14
+ public async connect(key: string): Promise<string | Error> {
15
+ return await this.xadd(key, 'connect')
16
+ }
17
+
18
+ public async push(key: string, event: string, data: unknown): Promise<string | Error> {
19
+ return await this.xadd(key, event, data)
20
+ }
21
+
22
+ public async pop(
23
+ key: string,
24
+ token: string
25
+ ): Promise<[string, Event[]] | null | Error> {
26
+ const stamp = this.decode(token)
27
+
28
+ const results = await this.stash
29
+ .xread('STREAMS', key, stamp)
30
+ .catch((error: Error) => error)
31
+
32
+ if (results === null) return ERR_NO_RESULTS
33
+
34
+ if (results instanceof Error) return results
35
+
36
+ const items = entries(results)
37
+
38
+ if (items === null) return null
39
+
40
+ const events: Event[] = []
41
+
42
+ let lastStamp: string | null = null
43
+
44
+ for (const item of items) {
45
+ const [, event, , json] = item[1]
46
+
47
+ lastStamp = item[0]
48
+
49
+ const data = json === undefined ? undefined : JSON.parse(json)
50
+
51
+ if (data === undefined) this.logs.debug('Undefined event payload', { key, event })
52
+
53
+ events.push({ event, data })
54
+ }
55
+
56
+ if (lastStamp === null) return null
57
+
58
+ return [this.encode(lastStamp), events]
59
+ }
60
+
61
+ private async xadd(
62
+ key: string,
63
+ event: string,
64
+ data?: unknown
65
+ ): Promise<string | Error> {
66
+ const args = ['MAXLEN', '~', this.configuration.maxlen, '*', 'type', event]
67
+
68
+ if (data !== undefined) args.push('data', JSON.stringify(data))
69
+
70
+ const results = await this.stash
71
+ .multi()
72
+ .xadd(key, ...args)
73
+ .expire(key, this.configuration.expire)
74
+ .exec()
75
+ .catch((error: Error) => error)
76
+
77
+ if (results === null) return ERR_NO_RESULTS
78
+
79
+ if (results instanceof Error) return results
80
+
81
+ const [[error, stamp]] = results
82
+
83
+ if (error !== null) return error
84
+
85
+ return this.encode(stamp as string)
86
+ }
87
+
88
+ private encode(token: string): string {
89
+ return Buffer.from(token).toString('base64url')
90
+ }
91
+
92
+ private decode(token: string): string {
93
+ return Buffer.from(token, 'base64url').toString()
94
+ }
95
+ }
96
+
97
+ /**
98
+ * `xread` answers with an array of `[stream, entries]` pairs over RESP2 and with an
99
+ * object keyed by stream name over RESP3. One stream is read, so either way it is the
100
+ * first, and an empty answer means nothing has been added since the token.
101
+ */
102
+ function entries(results: unknown): Entry[] | null {
103
+ if (Array.isArray(results))
104
+ return results.length === 0 ? null : (results[0] as [string, Entry[]])[1]
105
+
106
+ if (typeof results === 'object' && results !== null) {
107
+ const streams = Object.values(results as Record<string, Entry[]>)
108
+
109
+ return streams.length === 0 ? null : streams[0]
110
+ }
111
+
112
+ return null
113
+ }
114
+
115
+ type Entry = [stamp: string, fields: string[]]
116
+
117
+ interface Configuration {
118
+ maxlen: number
119
+ expire: number
120
+ }
121
+
122
+ interface Event {
123
+ event: string
124
+ data: unknown
125
+ }
126
+
127
+ class NoResultsError extends Error {
128
+ public readonly code = 'NO_RESULTS'
129
+ }
130
+
131
+ const ERR_NO_RESULTS = new NoResultsError()
@@ -0,0 +1,58 @@
1
+ import { EventEmitter } from 'node:events'
2
+ import { Readable } from 'node:stream'
3
+
4
+ export class Stream extends Readable {
5
+ public events = new EventEmitter()
6
+
7
+ private interval: NodeJS.Timeout | null = null
8
+ private ended = false
9
+
10
+ public constructor() {
11
+ super(objectMode)
12
+ }
13
+
14
+ // has to be here
15
+ public override _read(): void {
16
+ if (this.interval === null)
17
+ this.interval = setInterval(() => this.heartbeat(), HEARTBEAT_INTERVAL)
18
+ }
19
+
20
+ public override _destroy(
21
+ error: Error | null,
22
+ callback: (error?: Error | null) => void
23
+ ): void {
24
+ if (this.interval !== null) clearInterval(this.interval)
25
+
26
+ this.events.emit('destroy')
27
+
28
+ super._destroy(error, callback)
29
+ }
30
+
31
+ /** Ends the stream: whoever reads it gets EOF rather than a broken pipe. */
32
+ public close(): void {
33
+ this.ended = true
34
+
35
+ if (this.interval !== null) {
36
+ clearInterval(this.interval)
37
+ this.interval = null
38
+ }
39
+
40
+ this.push(null)
41
+ }
42
+
43
+ public heartbeat(stream: Readable = this): boolean {
44
+ if (this.ended) return false
45
+
46
+ const resume = stream.push('heartbeat ' + Date.now())
47
+
48
+ if (!resume && this.interval !== null) {
49
+ clearInterval(this.interval)
50
+ this.interval = null
51
+ }
52
+
53
+ return resume
54
+ }
55
+ }
56
+
57
+ const HEARTBEAT_INTERVAL = 16_000 // why?
58
+ const objectMode = { objectMode: true }
@@ -0,0 +1,2 @@
1
+ export { Stash } from './Stash.js'
2
+ export { Stream } from './Stream.js'
@@ -0,0 +1,14 @@
1
+ import { type Context, type PushInput } from '../types/index.js'
2
+
3
+ export async function effect(
4
+ { key, event, data }: PushInput,
5
+ context: Context
6
+ ): Promise<void> {
7
+ context.state.streams.get(key)?.push({ event, data })
8
+
9
+ void context.state.stash.push(key, event, data).then((token) => {
10
+ if (token instanceof Error)
11
+ context.logs.error('Failed to push to stash', { key, error: token })
12
+ else context.state.streams.get(key)?.push({ event: 'token', data: token })
13
+ })
14
+ }
@@ -1,7 +1,8 @@
1
1
  {
2
- "extends": "../../tsconfig.json",
2
+ "extends": "../../../../tsconfig.component.json",
3
3
  "compilerOptions": {
4
4
  "outDir": "./operations",
5
+ "rootDir": "./source"
5
6
  },
6
7
  "include": [
7
8
  "source"