@toa.io/extensions.realtime 1.0.0-alpha.31 → 1.0.0-alpha.310

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 (74) hide show
  1. package/components/realtime.streams/manifest.toa.yaml +52 -0
  2. package/components/realtime.streams/operations/create.js +77 -0
  3. package/components/realtime.streams/operations/lib/Stash.js +86 -0
  4. package/components/realtime.streams/operations/lib/Stream.js +43 -0
  5. package/components/realtime.streams/operations/lib/index.js +3 -0
  6. package/components/realtime.streams/operations/push.js +18 -0
  7. package/components/realtime.streams/source/create.ts +107 -0
  8. package/components/realtime.streams/source/lib/Stash.ts +131 -0
  9. package/components/realtime.streams/source/lib/Stream.ts +58 -0
  10. package/components/realtime.streams/source/lib/index.ts +2 -0
  11. package/components/realtime.streams/source/push.ts +23 -0
  12. package/components/{streams → realtime.streams}/tsconfig.json +2 -1
  13. package/components/realtime.streams/types/index.d.ts +20 -0
  14. package/components/realtime.streams/types/toa.d.ts +30 -0
  15. package/package.json +18 -7
  16. package/readme.md +41 -7
  17. package/transpiled/Composition.d.ts +10 -4
  18. package/transpiled/Composition.js +21 -20
  19. package/transpiled/Composition.js.map +1 -1
  20. package/transpiled/Factory.d.ts +6 -6
  21. package/transpiled/Factory.js +19 -21
  22. package/transpiled/Factory.js.map +1 -1
  23. package/transpiled/Realtime.d.ts +3 -2
  24. package/transpiled/Realtime.js +41 -11
  25. package/transpiled/Realtime.js.map +1 -1
  26. package/transpiled/Receiver.d.ts +26 -0
  27. package/transpiled/Receiver.js +57 -0
  28. package/transpiled/Receiver.js.map +1 -0
  29. package/transpiled/Routes.d.ts +4 -10
  30. package/transpiled/Routes.js +18 -34
  31. package/transpiled/Routes.js.map +1 -1
  32. package/transpiled/index.d.ts +2 -1
  33. package/transpiled/index.js +2 -5
  34. package/transpiled/index.js.map +1 -1
  35. package/transpiled/measurements.d.ts +2 -0
  36. package/transpiled/measurements.js +18 -0
  37. package/transpiled/measurements.js.map +1 -0
  38. package/.readme/overview-dark.jpg +0 -0
  39. package/.readme/overview-light.jpg +0 -0
  40. package/components/context.toa.yaml +0 -11
  41. package/components/streams/manifest.toa.yaml +0 -21
  42. package/components/streams/operations/create.d.ts +0 -14
  43. package/components/streams/operations/create.js +0 -29
  44. package/components/streams/operations/create.js.map +0 -1
  45. package/components/streams/operations/lib/stream.d.ts +0 -10
  46. package/components/streams/operations/lib/stream.js +0 -31
  47. package/components/streams/operations/lib/stream.js.map +0 -1
  48. package/components/streams/operations/push.d.ts +0 -2
  49. package/components/streams/operations/push.js +0 -8
  50. package/components/streams/operations/push.js.map +0 -1
  51. package/components/streams/operations/tsconfig.tsbuildinfo +0 -1
  52. package/components/streams/operations/types.d.ts +0 -11
  53. package/components/streams/operations/types.js +0 -3
  54. package/components/streams/operations/types.js.map +0 -1
  55. package/components/streams/source/create.ts +0 -38
  56. package/components/streams/source/lib/stream.ts +0 -37
  57. package/components/streams/source/push.ts +0 -5
  58. package/components/streams/source/types.ts +0 -13
  59. package/cucumber.js +0 -8
  60. package/features/static.feature +0 -32
  61. package/features/steps/Components.ts +0 -71
  62. package/features/steps/Realtime.ts +0 -43
  63. package/features/steps/Streams.ts +0 -50
  64. package/features/steps/components/messages/manifest.toa.yaml +0 -16
  65. package/features/steps/config.ts +0 -1
  66. package/features/steps/tsconfig.json +0 -9
  67. package/source/Composition.ts +0 -40
  68. package/source/Factory.ts +0 -33
  69. package/source/Realtime.ts +0 -38
  70. package/source/Routes.ts +0 -84
  71. package/source/index.ts +0 -1
  72. package/stage/streams.test.ts +0 -128
  73. package/transpiled/tsconfig.tsbuildinfo +0 -1
  74. package/tsconfig.json +0 -12
@@ -0,0 +1,52 @@
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:
50
+ endpoint: create
51
+ # an effect: opening a stream is not a read, and `GET` is the method a client opens one with
52
+ io:readonly: false
@@ -0,0 +1,77 @@
1
+ import { Stream, Stash } from './lib/index.js';
2
+ /**
3
+ * A stream is opened per call, and a key has as many of them as it has consumers: one stream read
4
+ * by several would hand each of its values to one of them only, and a consumer leaving would
5
+ * destroy it under the others. What arrives under a key is pushed to every stream of it; what
6
+ * belongs to a connection — the welcome, the token, the events missed since a token — to that
7
+ * connection's stream alone.
8
+ */
9
+ export class Effect {
10
+ streams = new Map();
11
+ stash;
12
+ logs;
13
+ mount(context) {
14
+ context.state.streams = this.streams;
15
+ context.state.stash = new Stash(context.stash, context.configuration, context.logs);
16
+ this.logs = context.logs;
17
+ this.stash = context.state.stash;
18
+ }
19
+ unmount() {
20
+ const streams = [...this.streams.values()].flatMap((set) => [...set]);
21
+ this.logs.info('Destroying streams', { count: streams.length });
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 streams)
26
+ stream.close();
27
+ }
28
+ async execute(input) {
29
+ const key = input.key;
30
+ const stream = this.createStream(key);
31
+ // welcome
32
+ setTimeout(() => stream.heartbeat(), 1000);
33
+ if (input.token === undefined)
34
+ void this.stash.connect(key).then((token) => {
35
+ if (token instanceof Error)
36
+ this.logs.error('Failed to connect to stash', { key, error: token });
37
+ else
38
+ stream.push({ event: 'token', data: token });
39
+ });
40
+ else
41
+ void this.stash.pop(key, input.token).then((result) => {
42
+ if (result === null)
43
+ return;
44
+ if ('code' in result && result.code === 'NO_RESULTS')
45
+ return;
46
+ if (result instanceof Error) {
47
+ this.logs.error('Failed to pop from stash', { key, error: result });
48
+ return;
49
+ }
50
+ if (stream.destroyed)
51
+ return;
52
+ const [token, events] = result;
53
+ for (const event of events)
54
+ stream.push({ event: event.event, data: event.data });
55
+ stream.push({ event: 'token', data: token });
56
+ });
57
+ return stream;
58
+ }
59
+ createStream(key) {
60
+ const stream = new Stream();
61
+ let set = this.streams.get(key);
62
+ if (set === undefined) {
63
+ set = new Set();
64
+ this.streams.set(key, set);
65
+ }
66
+ set.add(stream);
67
+ this.logs.debug('Stream created', { key, count: set.size });
68
+ stream.events.once('destroy', () => {
69
+ set.delete(stream);
70
+ if (set.size === 0)
71
+ this.streams.delete(key);
72
+ this.logs.debug('Stream destroyed', { key, count: set.size });
73
+ });
74
+ return stream;
75
+ }
76
+ }
77
+ //# sourceMappingURL=data:application/json;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoiY3JlYXRlLmpzIiwic291cmNlUm9vdCI6IiIsInNvdXJjZXMiOlsiLi4vc291cmNlL2NyZWF0ZS50cyJdLCJuYW1lcyI6W10sIm1hcHBpbmdzIjoiQUFHQSxPQUFPLEVBQUUsTUFBTSxFQUFFLEtBQUssRUFBRSxNQUFNLGdCQUFnQixDQUFBO0FBRTlDOzs7Ozs7R0FNRztBQUNILE1BQU0sT0FBTyxNQUFNO0lBQ0EsT0FBTyxHQUFHLElBQUksR0FBRyxFQUF1QixDQUFBO0lBQ2pELEtBQUssQ0FBUTtJQUNiLElBQUksQ0FBSztJQUVWLEtBQUssQ0FBQyxPQUFnQjtRQUMzQixPQUFPLENBQUMsS0FBSyxDQUFDLE9BQU8sR0FBRyxJQUFJLENBQUMsT0FBTyxDQUFBO1FBQ3BDLE9BQU8sQ0FBQyxLQUFLLENBQUMsS0FBSyxHQUFHLElBQUksS0FBSyxDQUFDLE9BQU8sQ0FBQyxLQUFLLEVBQUUsT0FBTyxDQUFDLGFBQWEsRUFBRSxPQUFPLENBQUMsSUFBSSxDQUFDLENBQUE7UUFFbkYsSUFBSSxDQUFDLElBQUksR0FBRyxPQUFPLENBQUMsSUFBSSxDQUFBO1FBQ3hCLElBQUksQ0FBQyxLQUFLLEdBQUcsT0FBTyxDQUFDLEtBQUssQ0FBQyxLQUFLLENBQUE7SUFDbEMsQ0FBQztJQUVNLE9BQU87UUFDWixNQUFNLE9BQU8sR0FBRyxDQUFDLEdBQUcsSUFBSSxDQUFDLE9BQU8sQ0FBQyxNQUFNLEVBQUUsQ0FBQyxDQUFDLE9BQU8sQ0FBQyxDQUFDLEdBQUcsRUFBRSxFQUFFLENBQUMsQ0FBQyxHQUFHLEdBQUcsQ0FBQyxDQUFDLENBQUE7UUFFckUsSUFBSSxDQUFDLElBQUksQ0FBQyxJQUFJLENBQUMsb0JBQW9CLEVBQUUsRUFBRSxLQUFLLEVBQUUsT0FBTyxDQUFDLE1BQU0sRUFBRSxDQUFDLENBQUE7UUFFL0QsK0VBQStFO1FBQy9FLCtFQUErRTtRQUMvRSxvQkFBb0I7UUFDcEIsS0FBSyxNQUFNLE1BQU0sSUFBSSxPQUFPO1lBQUUsTUFBTSxDQUFDLEtBQUssRUFBRSxD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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,18 @@
1
+ export async function effect({ key, event, data }, context) {
2
+ broadcast(context, key, { 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
+ broadcast(context, key, { event: 'token', data: token });
8
+ });
9
+ }
10
+ /** To every stream the key has: each is a consumer of its own. */
11
+ function broadcast(context, key, message) {
12
+ const streams = context.state.streams.get(key);
13
+ if (streams === undefined)
14
+ return;
15
+ for (const stream of streams)
16
+ stream.push(message);
17
+ }
18
+ //# sourceMappingURL=data:application/json;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoicHVzaC5qcyIsInNvdXJjZVJvb3QiOiIiLCJzb3VyY2VzIjpbIi4uL3NvdXJjZS9wdXNoLnRzIl0sIm5hbWVzIjpbXSwibWFwcGluZ3MiOiJBQUVBLE1BQU0sQ0FBQyxLQUFLLFVBQVUsTUFBTSxDQUMxQixFQUFFLEdBQUcsRUFBRSxLQUFLLEVBQUUsSUFBSSxFQUFhLEVBQy9CLE9BQWdCO0lBRWhCLFNBQVMsQ0FBQyxPQUFPLEVBQUUsR0FBRyxFQUFFLEVBQUUsS0FBSyxFQUFFLElBQUksRUFBRSxDQUFDLENBQUE7SUFFeEMsS0FBSyxPQUFPLENBQUMsS0FBSyxDQUFDLEtBQUssQ0FBQyxJQUFJLENBQUMsR0FBRyxFQUFFLEtBQUssRUFBRSxJQUFJLENBQUMsQ0FBQyxJQUFJLENBQUMsQ0FBQyxLQUFLLEVBQUUsRUFBRTtRQUM3RCxJQUFJLEtBQUssWUFBWSxLQUFLO1lBQ3hCLE9BQU8sQ0FBQyxJQUFJLENBQUMsS0FBSyxDQUFDLHlCQUF5QixFQUFFLEVBQUUsR0FBRyxFQUFFLEtBQUssRUFBRSxLQUFLLEVBQUUsQ0FBQyxDQUFBOztZQUNqRSxTQUFTLENBQUMsT0FBTyxFQUFFLEdBQUcsRUFBRSxFQUFFLEtBQUssRUFBRSxPQUFPLEVBQUUsSUFBSSxFQUFFLEtBQUssRUFBRSxDQUFDLENBQUE7SUFDL0QsQ0FBQyxDQUFDLENBQUE7QUFDSixDQUFDO0FBRUQsa0VBQWtFO0FBQ2xFLFNBQVMsU0FBUyxDQUFDLE9BQWdCLEVBQUUsR0FBVyxFQUFFLE9BQWU7SUFDL0QsTUFBTSxPQUFPLEdBQUcsT0FBTyxDQUFDLEtBQUssQ0FBQyxPQUFPLENBQUMsR0FBRyxDQUFDLEdBQUcsQ0FBQyxDQUFBO0lBRTlDLElBQUksT0FBTyxLQUFLLFNBQVM7UUFBRSxPQUFNO0lBRWpDLEtBQUssTUFBTSxNQUFNLElBQUksT0FBTztRQUFFLE1BQU0sQ0FBQyxJQUFJLENBQUMsT0FBTyxDQUFDLENBQUE7QUFDcEQsQ0FBQyJ9
@@ -0,0 +1,107 @@
1
+ import { type Readable } from 'node:stream'
2
+ import { type Operation } from '@toa.io/bridges.node'
3
+ import type { Context } from '../types/index.d.ts'
4
+ import { Stream, Stash } from './lib/index.ts'
5
+
6
+ /**
7
+ * A stream is opened per call, and a key has as many of them as it has consumers: one stream read
8
+ * by several would hand each of its values to one of them only, and a consumer leaving would
9
+ * destroy it under the others. What arrives under a key is pushed to every stream of it; what
10
+ * belongs to a connection — the welcome, the token, the events missed since a token — to that
11
+ * connection's stream alone.
12
+ */
13
+ export class Effect implements Operation {
14
+ private readonly streams = new Map<string, Set<Stream>>()
15
+ private stash!: Stash
16
+ private logs: any
17
+
18
+ public mount(context: Context): void {
19
+ context.state.streams = this.streams
20
+ context.state.stash = new Stash(context.stash, context.configuration, context.logs)
21
+
22
+ this.logs = context.logs
23
+ this.stash = context.state.stash
24
+ }
25
+
26
+ public unmount(): void {
27
+ const streams = [...this.streams.values()].flatMap((set) => [...set])
28
+
29
+ this.logs.info('Destroying streams', { count: streams.length })
30
+
31
+ // closed, not destroyed: destroying a stream that is still piped to a response
32
+ // makes end-of-stream report a premature close, which reaches no one and takes
33
+ // the process down.
34
+ for (const stream of streams) stream.close()
35
+ }
36
+
37
+ public async execute(input: Input): Promise<Readable> {
38
+ const key = input.key
39
+ const stream = this.createStream(key)
40
+
41
+ // welcome
42
+ setTimeout(() => stream.heartbeat(), 1000)
43
+
44
+ if (input.token === undefined)
45
+ void this.stash.connect(key).then((token) => {
46
+ if (token instanceof Error)
47
+ this.logs.error('Failed to connect to stash', { key, error: token })
48
+ else stream.push({ event: 'token', data: token })
49
+ })
50
+ else
51
+ void this.stash.pop(key, input.token).then((result) => {
52
+ if (result === null) return
53
+
54
+ if ('code' in result && result.code === 'NO_RESULTS') return
55
+
56
+ if (result instanceof Error) {
57
+ this.logs.error('Failed to pop from stash', { key, error: result })
58
+
59
+ return
60
+ }
61
+
62
+ if (stream.destroyed) return
63
+
64
+ const [token, events] = result
65
+
66
+ for (const event of events as Event[])
67
+ stream.push({ event: event.event, data: event.data })
68
+
69
+ stream.push({ event: 'token', data: token })
70
+ })
71
+
72
+ return stream
73
+ }
74
+
75
+ private createStream(key: string): Stream {
76
+ const stream = new Stream()
77
+ let set = this.streams.get(key)
78
+
79
+ if (set === undefined) {
80
+ set = new Set()
81
+ this.streams.set(key, set)
82
+ }
83
+
84
+ set.add(stream)
85
+ this.logs.debug('Stream created', { key, count: set.size })
86
+
87
+ stream.events.once('destroy', () => {
88
+ set.delete(stream)
89
+
90
+ if (set.size === 0) this.streams.delete(key)
91
+
92
+ this.logs.debug('Stream destroyed', { key, count: set.size })
93
+ })
94
+
95
+ return stream
96
+ }
97
+ }
98
+
99
+ interface Input {
100
+ key: string
101
+ token?: string
102
+ }
103
+
104
+ interface Event {
105
+ event: string
106
+ data: unknown
107
+ }
@@ -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.ts'
2
+ export { Stream } from './Stream.ts'
@@ -0,0 +1,23 @@
1
+ import type { Context, PushInput } from '../types/index.d.ts'
2
+
3
+ export async function effect(
4
+ { key, event, data }: PushInput,
5
+ context: Context
6
+ ): Promise<void> {
7
+ broadcast(context, key, { 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 broadcast(context, key, { event: 'token', data: token })
13
+ })
14
+ }
15
+
16
+ /** To every stream the key has: each is a consumer of its own. */
17
+ function broadcast(context: Context, key: string, message: object): void {
18
+ const streams = context.state.streams.get(key)
19
+
20
+ if (streams === undefined) return
21
+
22
+ for (const stream of streams) stream.push(message)
23
+ }
@@ -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"
@@ -0,0 +1,20 @@
1
+ export * from './toa.d.ts'
2
+
3
+ // What a manifest does not state belongs here, and every run keeps it.
4
+
5
+ import type { Logs } from '@toa.io/extensions.telemetry'
6
+ import type { Stash as Redis } from '@toa.io/extensions.stash'
7
+ import type { Configuration } from './toa.d.ts'
8
+ import type { Stream } from '../source/lib/Stream.ts'
9
+ import type { Stash } from '../source/lib/Stash.ts'
10
+
11
+ /** What this component keeps between calls: the streams it serves, and what backs them. */
12
+ export interface Context {
13
+ stash: Redis
14
+ state: {
15
+ streams: Map<string, Set<Stream>>
16
+ stash: Stash
17
+ }
18
+ logs: Logs
19
+ configuration: Configuration
20
+ }