@toa.io/extensions.realtime 1.0.0-alpha.30 → 1.0.0-alpha.300
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 +49 -0
- package/components/realtime.streams/operations/create.js +65 -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 +10 -0
- package/components/realtime.streams/source/create.ts +94 -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 +14 -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 +33 -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 +5 -5
- package/transpiled/Factory.js +13 -18
- package/transpiled/Factory.js.map +1 -1
- package/transpiled/Realtime.d.ts +3 -2
- package/transpiled/Realtime.js +36 -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 +5 -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/.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,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,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
|
+
}
|
|
@@ -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.js'
|
|
8
|
+
import type { Stream } from '../source/lib/Stream.js'
|
|
9
|
+
import type { Stash } from '../source/lib/Stash.js'
|
|
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, Stream>
|
|
16
|
+
stash: Stash
|
|
17
|
+
}
|
|
18
|
+
logs: Logs
|
|
19
|
+
configuration: Configuration
|
|
20
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
// Written by `toa types`. Every run rewrites it.
|
|
2
|
+
// What a manifest does not state belongs in a file of your own.
|
|
3
|
+
|
|
4
|
+
import type { Options } from '@toa.io/core/types'
|
|
5
|
+
import type { FetchInit } from '@toa.io/extensions.fetch'
|
|
6
|
+
import type { Stash } from '@toa.io/extensions.stash'
|
|
7
|
+
import type { Logs, Span } from '@toa.io/extensions.telemetry'
|
|
8
|
+
|
|
9
|
+
export type CreateInput = {
|
|
10
|
+
key?: string
|
|
11
|
+
token?: string
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export type PushInput = {
|
|
15
|
+
data?: unknown
|
|
16
|
+
key: string
|
|
17
|
+
event: string
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export type PushOutput = null
|
|
21
|
+
|
|
22
|
+
export interface Component {
|
|
23
|
+
create: (request: { input: CreateInput, task?: boolean }, options?: Options) => Promise<unknown>
|
|
24
|
+
push: (request: { input: PushInput, task?: boolean }, options?: Options) => Promise<PushOutput>
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface Configuration {
|
|
28
|
+
maxlen: number
|
|
29
|
+
expire: number
|
|
30
|
+
}
|