@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.
- package/components/realtime.streams/manifest.toa.yaml +52 -0
- package/components/realtime.streams/operations/create.js +77 -0
- package/components/realtime.streams/operations/lib/Stash.js +86 -0
- package/components/realtime.streams/operations/lib/Stream.js +43 -0
- package/components/realtime.streams/operations/lib/index.js +3 -0
- package/components/realtime.streams/operations/push.js +18 -0
- package/components/realtime.streams/source/create.ts +107 -0
- package/components/realtime.streams/source/lib/Stash.ts +131 -0
- package/components/realtime.streams/source/lib/Stream.ts +58 -0
- package/components/realtime.streams/source/lib/index.ts +2 -0
- package/components/realtime.streams/source/push.ts +23 -0
- package/components/{streams → realtime.streams}/tsconfig.json +2 -1
- package/components/realtime.streams/types/index.d.ts +20 -0
- package/components/realtime.streams/types/toa.d.ts +30 -0
- package/package.json +18 -7
- package/readme.md +41 -7
- package/transpiled/Composition.d.ts +10 -4
- package/transpiled/Composition.js +21 -20
- package/transpiled/Composition.js.map +1 -1
- package/transpiled/Factory.d.ts +6 -6
- package/transpiled/Factory.js +19 -21
- package/transpiled/Factory.js.map +1 -1
- package/transpiled/Realtime.d.ts +3 -2
- package/transpiled/Realtime.js +41 -11
- package/transpiled/Realtime.js.map +1 -1
- package/transpiled/Receiver.d.ts +26 -0
- package/transpiled/Receiver.js +57 -0
- package/transpiled/Receiver.js.map +1 -0
- package/transpiled/Routes.d.ts +4 -10
- package/transpiled/Routes.js +18 -34
- package/transpiled/Routes.js.map +1 -1
- package/transpiled/index.d.ts +2 -1
- package/transpiled/index.js +2 -5
- package/transpiled/index.js.map +1 -1
- package/transpiled/measurements.d.ts +2 -0
- package/transpiled/measurements.js +18 -0
- package/transpiled/measurements.js.map +1 -0
- package/.readme/overview-dark.jpg +0 -0
- package/.readme/overview-light.jpg +0 -0
- package/components/context.toa.yaml +0 -11
- package/components/streams/manifest.toa.yaml +0 -21
- package/components/streams/operations/create.d.ts +0 -14
- package/components/streams/operations/create.js +0 -29
- package/components/streams/operations/create.js.map +0 -1
- package/components/streams/operations/lib/stream.d.ts +0 -10
- package/components/streams/operations/lib/stream.js +0 -31
- package/components/streams/operations/lib/stream.js.map +0 -1
- package/components/streams/operations/push.d.ts +0 -2
- package/components/streams/operations/push.js +0 -8
- package/components/streams/operations/push.js.map +0 -1
- package/components/streams/operations/tsconfig.tsbuildinfo +0 -1
- package/components/streams/operations/types.d.ts +0 -11
- package/components/streams/operations/types.js +0 -3
- package/components/streams/operations/types.js.map +0 -1
- package/components/streams/source/create.ts +0 -38
- package/components/streams/source/lib/stream.ts +0 -37
- package/components/streams/source/push.ts +0 -5
- package/components/streams/source/types.ts +0 -13
- package/cucumber.js +0 -8
- package/features/static.feature +0 -32
- package/features/steps/Components.ts +0 -71
- package/features/steps/Realtime.ts +0 -43
- package/features/steps/Streams.ts +0 -50
- package/features/steps/components/messages/manifest.toa.yaml +0 -16
- package/features/steps/config.ts +0 -1
- package/features/steps/tsconfig.json +0 -9
- package/source/Composition.ts +0 -40
- package/source/Factory.ts +0 -33
- package/source/Realtime.ts +0 -38
- package/source/Routes.ts +0 -84
- package/source/index.ts +0 -1
- package/stage/streams.test.ts +0 -128
- package/transpiled/tsconfig.tsbuildinfo +0 -1
- 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,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
|
+
}
|
|
@@ -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
|
+
}
|