@reventlessdev/reventless-local 3.0.0-alpha.317 → 3.0.0-alpha.318
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/CHANGELOG.md +8 -0
- package/package.json +10 -10
- package/src/LocalPlatformRegistry.res +19 -0
- package/src/LocalPlatformRegistry.res.mjs +19 -1
- package/src/Platform.res +24 -2
- package/src/Platform.res.mjs +22 -2
- package/src/adapter/DcbEventLog/DcbEventLogStorage_InMemory.res +38 -0
- package/src/adapter/DcbEventLog/DcbEventLogStorage_InMemory.res.mjs +29 -0
- package/src/adapter/DcbEventLog/DcbEventLogStorage_Sqlite.res +87 -0
- package/src/adapter/DcbEventLog/DcbEventLogStorage_Sqlite.res.mjs +76 -0
- package/src/adapter/DcbEventLog/LocalDcbEventLogStorage.res +8 -1
- package/src/adapter/DcbEventLog/LocalDcbEventLogStorage.res.mjs +17 -4
- package/src/adapter/EventLog/EventLogStorage_InMemory.res +49 -2
- package/src/adapter/EventLog/EventLogStorage_InMemory.res.mjs +57 -21
- package/src/adapter/EventLog/EventLogStorage_Sqlite.res +85 -0
- package/src/adapter/EventLog/EventLogStorage_Sqlite.res.mjs +50 -0
- package/src/adapter/EventLog/LocalEventLogStorage.res +3 -0
- package/src/adapter/EventLog/LocalEventLogStorage.res.mjs +6 -2
- package/src/adapter/LocalBus.res +32 -5
- package/src/adapter/LocalBus.res.mjs +27 -7
- package/src/adapter/LocalEventLogRead.res +477 -0
- package/src/adapter/LocalEventLogRead.res.mjs +507 -0
- package/src/adapter/LocalEventTap.res +18 -80
- package/src/adapter/LocalEventTap.res.mjs +19 -108
- package/src/adapter/LocalFeed.res +433 -0
- package/src/adapter/LocalFeed.res.mjs +419 -0
- package/src/adapter/LocalLogFeed.res +217 -0
- package/src/adapter/LocalLogFeed.res.mjs +295 -0
- package/src/adapter/LocalPgEventLogRead.res +85 -0
- package/src/adapter/LocalPgEventLogRead.res.mjs +91 -0
- package/src/adapter/LocalPlatformObserve.res +456 -0
- package/src/adapter/LocalPlatformObserve.res.mjs +505 -0
- package/src/adapter/LocalTapLines.res +147 -0
- package/src/adapter/LocalTapLines.res.mjs +229 -0
- package/src/adapter/PlatformGraphQL_Server.res +36 -3
- package/src/adapter/PlatformGraphQL_Server.res.mjs +15 -1
- package/src/reexport/CommandTopic.res.mjs +5 -2
- package/tests/adapter/GraphQLWsFixtures.mjs +46 -0
- package/tests/adapter/LocalEventLogReadTest.res +539 -0
- package/tests/adapter/LocalEventLogReadTest.res.mjs +466 -0
- package/tests/adapter/LocalFeedTest.res +314 -0
- package/tests/adapter/LocalFeedTest.res.mjs +329 -0
- package/tests/adapter/LocalPlatformObserveTest.res +165 -0
- package/tests/adapter/LocalPlatformObserveTest.res.mjs +183 -0
- package/tests/adapter/LocalTapLinesTest.res +182 -0
- package/tests/adapter/LocalTapLinesTest.res.mjs +242 -0
- package/tests/adapter/PlatformSubscriptionsTest.res +123 -0
- package/tests/adapter/PlatformSubscriptionsTest.res.mjs +132 -0
- package/tests/components/automationslice/AutomationSliceCallbackTest.res +80 -0
- package/tests/components/automationslice/AutomationSliceCallbackTest.res.mjs +81 -0
- package/tests/components/automationslice/AutomationSliceSelfDeadlockFixtures.res +17 -0
- package/tests/components/automationslice/AutomationSliceSelfDeadlockFixtures.res.mjs +24 -0
- package/tests/components/automationslice/AutomationSliceSelfDeadlockTest.res +30 -0
- package/tests/components/automationslice/AutomationSliceSelfDeadlockTest.res.mjs +28 -0
- package/tests/components/outboundtranslationslice/OutboundTranslationSliceCallbackTest.res +58 -0
- package/tests/components/outboundtranslationslice/OutboundTranslationSliceCallbackTest.res.mjs +71 -19
- package/tests/components/outboundtranslationslice/OutboundTranslationSlicePiiTest.res.mjs +7 -7
|
@@ -1,13 +1,7 @@
|
|
|
1
1
|
// Generated by ReScript, PLEASE EDIT WITH CARE
|
|
2
2
|
|
|
3
|
-
import * as Nodenet from "node:net";
|
|
4
|
-
import * as Stdlib_Int from "@rescript/runtime/lib/es6/Stdlib_Int.js";
|
|
5
|
-
import * as Stdlib_JsExn from "@rescript/runtime/lib/es6/Stdlib_JsExn.js";
|
|
6
3
|
import * as Stdlib_Option from "@rescript/runtime/lib/es6/Stdlib_Option.js";
|
|
7
|
-
import * as
|
|
8
|
-
import * as Logger$ReventlessCore from "@reventlessdev/reventless-core/src/util/Logger.res.mjs";
|
|
9
|
-
|
|
10
|
-
let log = Logger$ReventlessCore.fromEnv();
|
|
4
|
+
import * as LocalFeed$ReventlessLocal from "./LocalFeed.res.mjs";
|
|
11
5
|
|
|
12
6
|
let envVar = "REVENTLESS_EVENT_TAP";
|
|
13
7
|
|
|
@@ -17,26 +11,7 @@ function envValue(env) {
|
|
|
17
11
|
|
|
18
12
|
function settingFromEnv(envOpt) {
|
|
19
13
|
let env = envOpt !== undefined ? envOpt : process.env;
|
|
20
|
-
|
|
21
|
-
if (v === undefined) {
|
|
22
|
-
return "Ephemeral";
|
|
23
|
-
}
|
|
24
|
-
switch (v) {
|
|
25
|
-
case "false" :
|
|
26
|
-
case "none" :
|
|
27
|
-
case "off" :
|
|
28
|
-
return "Off";
|
|
29
|
-
default:
|
|
30
|
-
let p = Stdlib_Int.fromString(v, undefined);
|
|
31
|
-
if (p !== undefined && p >= 1024 && p <= 65535) {
|
|
32
|
-
return {
|
|
33
|
-
TAG: "Fixed",
|
|
34
|
-
_0: p
|
|
35
|
-
};
|
|
36
|
-
} else {
|
|
37
|
-
return "Ephemeral";
|
|
38
|
-
}
|
|
39
|
-
}
|
|
14
|
+
return LocalFeed$ReventlessLocal.settingOf(envValue(env));
|
|
40
15
|
}
|
|
41
16
|
|
|
42
17
|
function stdoutEnabled(envOpt) {
|
|
@@ -55,123 +30,59 @@ function stdoutEnabled(envOpt) {
|
|
|
55
30
|
}
|
|
56
31
|
}
|
|
57
32
|
|
|
58
|
-
let
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
contents: undefined
|
|
64
|
-
};
|
|
65
|
-
|
|
66
|
-
let listeningPort = {
|
|
67
|
-
contents: undefined
|
|
68
|
-
};
|
|
33
|
+
let feed = LocalFeed$ReventlessLocal.make("EventTap", [
|
|
34
|
+
"Plugins",
|
|
35
|
+
"EventTypes",
|
|
36
|
+
"CorrelationId"
|
|
37
|
+
], undefined, undefined, undefined, undefined, undefined);
|
|
69
38
|
|
|
70
39
|
function port() {
|
|
71
|
-
return
|
|
40
|
+
return LocalFeed$ReventlessLocal.port(feed);
|
|
72
41
|
}
|
|
73
42
|
|
|
74
43
|
function hasReaders() {
|
|
75
|
-
return
|
|
44
|
+
return LocalFeed$ReventlessLocal.hasReaders(feed);
|
|
76
45
|
}
|
|
77
46
|
|
|
78
|
-
function
|
|
79
|
-
|
|
47
|
+
function publish(item) {
|
|
48
|
+
LocalFeed$ReventlessLocal.publish(feed, item);
|
|
80
49
|
}
|
|
81
50
|
|
|
82
51
|
function broadcast(line) {
|
|
83
|
-
|
|
84
|
-
connections.contents.forEach(socket => {
|
|
85
|
-
try {
|
|
86
|
-
socket.write(line + "\n");
|
|
87
|
-
return;
|
|
88
|
-
} catch (exn) {
|
|
89
|
-
return drop(socket);
|
|
90
|
-
}
|
|
91
|
-
});
|
|
92
|
-
return;
|
|
93
|
-
}
|
|
52
|
+
LocalFeed$ReventlessLocal.broadcast(feed, line);
|
|
94
53
|
}
|
|
95
54
|
|
|
96
55
|
function start(envOpt, onBoundOpt, param) {
|
|
97
56
|
let env = envOpt !== undefined ? envOpt : process.env;
|
|
98
57
|
let onBound = onBoundOpt !== undefined ? onBoundOpt : param => {};
|
|
99
|
-
|
|
100
|
-
let match$1 = server.contents;
|
|
101
|
-
if (typeof match !== "object" && match === "Off") {
|
|
102
|
-
return;
|
|
103
|
-
}
|
|
104
|
-
if (match$1 !== undefined) {
|
|
105
|
-
return;
|
|
106
|
-
}
|
|
107
|
-
let requested;
|
|
108
|
-
requested = typeof match !== "object" ? 0 : match._0;
|
|
109
|
-
let s = Nodenet.createServer(socket => {
|
|
110
|
-
socket.setEncoding("utf8");
|
|
111
|
-
socket.on("error", param => drop(socket));
|
|
112
|
-
socket.on("close", () => drop(socket));
|
|
113
|
-
connections.contents = connections.contents.concat([socket]);
|
|
114
|
-
});
|
|
115
|
-
s.on("error", err => {
|
|
116
|
-
log.warn("EventTap", undefined, `could not listen: ` + Stdlib_Option.getOr(Stdlib_JsExn.message(err), "unknown error"));
|
|
117
|
-
server.contents = undefined;
|
|
118
|
-
listeningPort.contents = undefined;
|
|
119
|
-
});
|
|
120
|
-
s.unref();
|
|
121
|
-
s.listen(requested, "127.0.0.1", () => {
|
|
122
|
-
let addr = s.address();
|
|
123
|
-
if (addr == null) {
|
|
124
|
-
return;
|
|
125
|
-
}
|
|
126
|
-
let bound = addr.port;
|
|
127
|
-
listeningPort.contents = bound;
|
|
128
|
-
log.info("EventTap", undefined, `listening on 127.0.0.1:` + bound.toString());
|
|
129
|
-
onBound(bound);
|
|
130
|
-
});
|
|
131
|
-
server.contents = Primitive_option.some(s);
|
|
58
|
+
LocalFeed$ReventlessLocal.start(feed, settingFromEnv(env), onBound, undefined);
|
|
132
59
|
}
|
|
133
60
|
|
|
134
61
|
function resetForTests() {
|
|
135
|
-
|
|
136
|
-
server.contents = undefined;
|
|
137
|
-
listeningPort.contents = undefined;
|
|
62
|
+
LocalFeed$ReventlessLocal.resetForTests(feed);
|
|
138
63
|
}
|
|
139
64
|
|
|
140
65
|
function addConnectionForTests(socket) {
|
|
141
|
-
|
|
66
|
+
LocalFeed$ReventlessLocal.addConnectionForTests(feed, socket);
|
|
142
67
|
}
|
|
143
68
|
|
|
144
69
|
function stopForTests() {
|
|
145
|
-
|
|
146
|
-
if (s === undefined) {
|
|
147
|
-
return Promise.resolve();
|
|
148
|
-
}
|
|
149
|
-
let s$1 = Primitive_option.valFromOption(s);
|
|
150
|
-
connections.contents.forEach(prim => {
|
|
151
|
-
prim.destroy();
|
|
152
|
-
});
|
|
153
|
-
resetForTests();
|
|
154
|
-
return new Promise((resolve, _reject) => {
|
|
155
|
-
s$1.close(() => resolve());
|
|
156
|
-
});
|
|
70
|
+
return LocalFeed$ReventlessLocal.stopForTests(feed);
|
|
157
71
|
}
|
|
158
72
|
|
|
159
73
|
export {
|
|
160
|
-
log,
|
|
161
74
|
envVar,
|
|
162
75
|
envValue,
|
|
163
76
|
settingFromEnv,
|
|
164
77
|
stdoutEnabled,
|
|
165
|
-
|
|
166
|
-
server,
|
|
167
|
-
listeningPort,
|
|
78
|
+
feed,
|
|
168
79
|
port,
|
|
169
80
|
hasReaders,
|
|
170
|
-
|
|
81
|
+
publish,
|
|
171
82
|
broadcast,
|
|
172
83
|
start,
|
|
173
84
|
resetForTests,
|
|
174
85
|
addConnectionForTests,
|
|
175
86
|
stopForTests,
|
|
176
87
|
}
|
|
177
|
-
/*
|
|
88
|
+
/* feed Not a pure module */
|
|
@@ -0,0 +1,433 @@
|
|
|
1
|
+
// A line feed on a loopback socket, for a tool that watches a running platform
|
|
2
|
+
// from outside it. The event tap and the log feed are two of these.
|
|
3
|
+
//
|
|
4
|
+
// Watching must never change what is watched, so each listener gets its own
|
|
5
|
+
// bounded queue (by count and by bytes). When a listener cannot keep up, its
|
|
6
|
+
// oldest lines are dropped and one `{"kind":"skipped","count":N}` line is sent in
|
|
7
|
+
// their place. Writes honour the socket's backpressure: once `write` returns
|
|
8
|
+
// false, nothing more is written to that socket until it drains. The publishing
|
|
9
|
+
// side never waits.
|
|
10
|
+
//
|
|
11
|
+
// On connect a listener may send one filter line (JSON): plugins, a minimum
|
|
12
|
+
// level, components, event types, a correlation id. Only matching lines are sent
|
|
13
|
+
// to it. The feed starts when that line arrives, or after a short wait without
|
|
14
|
+
// one, in which case the listener gets every line, as readers that never send a
|
|
15
|
+
// filter always have.
|
|
16
|
+
|
|
17
|
+
let log = ReventlessCore.Logger.fromEnv()
|
|
18
|
+
|
|
19
|
+
type setting =
|
|
20
|
+
| Off
|
|
21
|
+
| Ephemeral
|
|
22
|
+
| Fixed(int)
|
|
23
|
+
|
|
24
|
+
/** A port only when the value IS one in the unprivileged range; `off`, `false`
|
|
25
|
+
and `none` switch the socket off; anything else, unset included, takes an
|
|
26
|
+
ephemeral port. */
|
|
27
|
+
let settingOf = (value: option<string>): setting =>
|
|
28
|
+
switch value->Option.map(String.trim)->Option.filter(v => v != "") {
|
|
29
|
+
| Some("off") | Some("false") | Some("none") => Off
|
|
30
|
+
| Some(v) =>
|
|
31
|
+
switch Int.fromString(v) {
|
|
32
|
+
| Some(p) if p >= 1024 && p <= 65535 => Fixed(p)
|
|
33
|
+
| _ => Ephemeral
|
|
34
|
+
}
|
|
35
|
+
| None => Ephemeral
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// ─── Filter ──────────────────────────────────────────────────────────────────
|
|
39
|
+
|
|
40
|
+
/** What a listener asked for. A field that is absent does not narrow. */
|
|
41
|
+
type filter = {
|
|
42
|
+
plugins?: array<string>,
|
|
43
|
+
level?: ReventlessCore.Logger.level,
|
|
44
|
+
components?: array<string>,
|
|
45
|
+
eventTypes?: array<string>,
|
|
46
|
+
correlationId?: string,
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** The fields of a filter a feed applies. A field the feed does not honour is
|
|
50
|
+
ignored, so one filter line can be sent to every feed. */
|
|
51
|
+
type filterField =
|
|
52
|
+
| Plugins
|
|
53
|
+
| Level
|
|
54
|
+
| Components
|
|
55
|
+
| EventTypes
|
|
56
|
+
| CorrelationId
|
|
57
|
+
|
|
58
|
+
/** What a line is about, as far as a filter can ask. */
|
|
59
|
+
type facets = {
|
|
60
|
+
plugin?: string,
|
|
61
|
+
level?: ReventlessCore.Logger.level,
|
|
62
|
+
comp?: string,
|
|
63
|
+
eventType?: string,
|
|
64
|
+
correlationId?: string,
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
type item = {line: string, facets: facets}
|
|
68
|
+
|
|
69
|
+
let levelOfString = (s: string): option<ReventlessCore.Logger.level> =>
|
|
70
|
+
switch s->String.toLowerCase {
|
|
71
|
+
| "debug" => Some(Debug)
|
|
72
|
+
| "info" => Some(Info)
|
|
73
|
+
| "warn" | "warning" => Some(Warn)
|
|
74
|
+
| "error" => Some(Error)
|
|
75
|
+
| _ => None
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Reads a filter line. `None` for a line that is not a JSON object; unknown
|
|
79
|
+
keys are ignored. */
|
|
80
|
+
let parseFilter = (line: string): option<filter> =>
|
|
81
|
+
switch line->JSON.parseOrThrow->JSON.Decode.object {
|
|
82
|
+
| Some(d) =>
|
|
83
|
+
let strings = k =>
|
|
84
|
+
d
|
|
85
|
+
->Dict.get(k)
|
|
86
|
+
->Option.flatMap(JSON.Decode.array)
|
|
87
|
+
->Option.map(a => a->Array.filterMap(JSON.Decode.string))
|
|
88
|
+
let string = k => d->Dict.get(k)->Option.flatMap(JSON.Decode.string)
|
|
89
|
+
Some({
|
|
90
|
+
plugins: ?strings("plugins"),
|
|
91
|
+
level: ?(string("level")->Option.flatMap(levelOfString)),
|
|
92
|
+
components: ?strings("components"),
|
|
93
|
+
eventTypes: ?strings("eventTypes"),
|
|
94
|
+
correlationId: ?string("correlationId"),
|
|
95
|
+
})
|
|
96
|
+
| None => None
|
|
97
|
+
| exception _ => None
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Whether a line passes a filter. A filter field the feed honours narrows to
|
|
101
|
+
lines that carry that facet and match it; a line without the facet (a log
|
|
102
|
+
line with no plugin, say) does not pass a filter that names one. */
|
|
103
|
+
let matches = (~honours: array<filterField>, filter: filter, facets: facets): bool => {
|
|
104
|
+
let applies = field => honours->Array.includes(field)
|
|
105
|
+
let oneOf = (wanted: option<array<string>>, field, value: option<string>) =>
|
|
106
|
+
switch wanted {
|
|
107
|
+
| Some(list) if applies(field) => value->Option.mapOr(false, v => list->Array.includes(v))
|
|
108
|
+
| _ => true
|
|
109
|
+
}
|
|
110
|
+
oneOf(filter.plugins, Plugins, facets.plugin) &&
|
|
111
|
+
oneOf(filter.components, Components, facets.comp) &&
|
|
112
|
+
oneOf(filter.eventTypes, EventTypes, facets.eventType) &&
|
|
113
|
+
switch filter.correlationId {
|
|
114
|
+
| Some(id) if applies(CorrelationId) => facets.correlationId == Some(id)
|
|
115
|
+
| _ => true
|
|
116
|
+
} &&
|
|
117
|
+
switch filter.level {
|
|
118
|
+
| Some(min) if applies(Level) =>
|
|
119
|
+
facets.level->Option.mapOr(false, l =>
|
|
120
|
+
ReventlessCore.Logger.levelToInt(l) >= ReventlessCore.Logger.levelToInt(min)
|
|
121
|
+
)
|
|
122
|
+
| _ => true
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
let skippedLine = (count: int): string =>
|
|
127
|
+
Dict.fromArray([("kind", JSON.Encode.string("skipped")), ("count", JSON.Encode.int(count))])
|
|
128
|
+
->JSON.Encode.object
|
|
129
|
+
->JSON.stringify
|
|
130
|
+
|
|
131
|
+
// ─── Listeners ───────────────────────────────────────────────────────────────
|
|
132
|
+
|
|
133
|
+
type phase =
|
|
134
|
+
// Connected; waiting for a filter line, or for the wait to end.
|
|
135
|
+
| Waiting
|
|
136
|
+
| Live
|
|
137
|
+
|
|
138
|
+
/** Where a listener's lines go: a socket, or a subscription on the platform API.
|
|
139
|
+
`write` returns false when the reader asks to wait; `onceDrain` calls back
|
|
140
|
+
when it can take more. */
|
|
141
|
+
type sink = {
|
|
142
|
+
write: string => bool,
|
|
143
|
+
onceDrain: (unit => unit) => unit,
|
|
144
|
+
// Bytes the sink itself holds, not yet taken by the reader.
|
|
145
|
+
buffered: unit => int,
|
|
146
|
+
close: unit => unit,
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
@get external writableLengthOf: NodeNet.socket => Nullable.t<int> = "writableLength"
|
|
150
|
+
|
|
151
|
+
let socketSink = (socket: NodeNet.socket): sink => {
|
|
152
|
+
write: line => socket->NodeNet.write(line ++ "\n"),
|
|
153
|
+
onceDrain: f => socket->NodeNet.onceSocketDrain(f),
|
|
154
|
+
buffered: () => socket->writableLengthOf->Nullable.toOption->Option.getOr(0),
|
|
155
|
+
close: () => NodeNet.destroySocket(socket),
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
type listener = {
|
|
159
|
+
sink: sink,
|
|
160
|
+
mutable phase: phase,
|
|
161
|
+
mutable filter: filter,
|
|
162
|
+
// Lines not yet written, oldest first; `head` is the index of the oldest.
|
|
163
|
+
mutable queue: array<string>,
|
|
164
|
+
mutable head: int,
|
|
165
|
+
mutable queuedBytes: int,
|
|
166
|
+
// Lines dropped since the last `skipped` line was sent.
|
|
167
|
+
mutable skipped: int,
|
|
168
|
+
// `write` returned false; nothing more is written until `drain`.
|
|
169
|
+
mutable blocked: bool,
|
|
170
|
+
mutable inbound: string,
|
|
171
|
+
mutable timer: option<timeoutId>,
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
type t = {
|
|
175
|
+
name: string,
|
|
176
|
+
honours: array<filterField>,
|
|
177
|
+
maxLines: int,
|
|
178
|
+
maxBytes: int,
|
|
179
|
+
filterWaitMs: int,
|
|
180
|
+
// Sent first to a listener that starts, filtered: what happened before it
|
|
181
|
+
// connected (the log feed's recent records).
|
|
182
|
+
backlog: option<unit => array<item>>,
|
|
183
|
+
mutable listeners: array<listener>,
|
|
184
|
+
mutable server: option<NodeNet.server>,
|
|
185
|
+
mutable port: option<int>,
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
let make = (
|
|
189
|
+
~name: string,
|
|
190
|
+
~honours: array<filterField>,
|
|
191
|
+
~backlog: option<unit => array<item>>=?,
|
|
192
|
+
~maxLines: int=10_000,
|
|
193
|
+
~maxBytes: int=8 * 1024 * 1024,
|
|
194
|
+
~filterWaitMs: int=100,
|
|
195
|
+
(),
|
|
196
|
+
): t => {
|
|
197
|
+
name,
|
|
198
|
+
honours,
|
|
199
|
+
maxLines,
|
|
200
|
+
maxBytes,
|
|
201
|
+
filterWaitMs,
|
|
202
|
+
backlog,
|
|
203
|
+
listeners: [],
|
|
204
|
+
server: None,
|
|
205
|
+
port: None,
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
let port = (feed: t) => feed.port
|
|
209
|
+
|
|
210
|
+
let isLive = (l: listener) =>
|
|
211
|
+
switch l.phase {
|
|
212
|
+
| Live => true
|
|
213
|
+
| Waiting => false
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/** Whether any listener is being sent lines, so a publisher can skip building a
|
|
217
|
+
line nobody gets. */
|
|
218
|
+
let hasReaders = (feed: t) => feed.listeners->Array.some(isLive)
|
|
219
|
+
|
|
220
|
+
let queuedLines = (l: listener) => l.queue->Array.length - l.head
|
|
221
|
+
|
|
222
|
+
/** Bytes held for listeners, all of them: what watching costs this process. */
|
|
223
|
+
let queuedBytes = (feed: t) => feed.listeners->Array.reduce(0, (sum, l) => sum + l.queuedBytes)
|
|
224
|
+
|
|
225
|
+
/** Bytes held for listeners, queued here and buffered by their sinks: what
|
|
226
|
+
watching costs this process at most. */
|
|
227
|
+
let heldBytes = (feed: t) =>
|
|
228
|
+
feed.listeners->Array.reduce(0, (sum, l) => sum + l.queuedBytes + l.sink.buffered())
|
|
229
|
+
|
|
230
|
+
let drop = (feed: t, l: listener) => {
|
|
231
|
+
l.timer->Option.forEach(clearTimeout)
|
|
232
|
+
feed.listeners = feed.listeners->Array.filter(other => other !== l)
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
let dequeue = (l: listener): option<string> =>
|
|
236
|
+
if queuedLines(l) == 0 {
|
|
237
|
+
None
|
|
238
|
+
} else {
|
|
239
|
+
let line = l.queue->Array.getUnsafe(l.head)
|
|
240
|
+
l.head = l.head + 1
|
|
241
|
+
l.queuedBytes = l.queuedBytes - line->String.length
|
|
242
|
+
|
|
243
|
+
// Compact once the consumed prefix is most of the array.
|
|
244
|
+
if l.head > 1024 && l.head * 2 > l.queue->Array.length {
|
|
245
|
+
l.queue = l.queue->Array.slice(~start=l.head, ~end=l.queue->Array.length)
|
|
246
|
+
l.head = 0
|
|
247
|
+
}
|
|
248
|
+
Some(line)
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/** Writes until the queue is empty or the socket asks to wait. A write that
|
|
252
|
+
throws (a closed peer) drops the listener and keeps the others. */
|
|
253
|
+
let rec pump = (feed: t, l: listener) => {
|
|
254
|
+
let continue = ref(true)
|
|
255
|
+
while continue.contents && !l.blocked {
|
|
256
|
+
let next = if l.skipped > 0 {
|
|
257
|
+
let line = skippedLine(l.skipped)
|
|
258
|
+
l.skipped = 0
|
|
259
|
+
Some(line)
|
|
260
|
+
} else {
|
|
261
|
+
dequeue(l)
|
|
262
|
+
}
|
|
263
|
+
switch next {
|
|
264
|
+
| None => continue := false
|
|
265
|
+
| Some(line) =>
|
|
266
|
+
switch l.sink.write(line) {
|
|
267
|
+
| true => ()
|
|
268
|
+
| false =>
|
|
269
|
+
l.blocked = true
|
|
270
|
+
l.sink.onceDrain(() => {
|
|
271
|
+
l.blocked = false
|
|
272
|
+
pump(feed, l)
|
|
273
|
+
})
|
|
274
|
+
| exception _ =>
|
|
275
|
+
drop(feed, l)
|
|
276
|
+
continue := false
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
let enqueue = (feed: t, l: listener, line: string) => {
|
|
283
|
+
l.queue->Array.push(line)
|
|
284
|
+
l.queuedBytes = l.queuedBytes + line->String.length
|
|
285
|
+
// Oldest first, so what is kept is the most recent.
|
|
286
|
+
while queuedLines(l) > 0 && (queuedLines(l) > feed.maxLines || l.queuedBytes > feed.maxBytes) {
|
|
287
|
+
let _ = dequeue(l)
|
|
288
|
+
l.skipped = l.skipped + 1
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
let offer = (feed: t, l: listener, item: item) =>
|
|
293
|
+
if isLive(l) && matches(~honours=feed.honours, l.filter, item.facets) {
|
|
294
|
+
enqueue(feed, l, item.line)
|
|
295
|
+
pump(feed, l)
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/** Sends a line to every listener whose filter it passes. Never waits. */
|
|
299
|
+
let publish = (feed: t, item: item): unit =>
|
|
300
|
+
feed.listeners->Array.forEach(l => offer(feed, l, item))
|
|
301
|
+
|
|
302
|
+
/** A line with nothing a filter can ask about: every unfiltered listener gets
|
|
303
|
+
it, and no filtered one does. */
|
|
304
|
+
let broadcast = (feed: t, line: string): unit => publish(feed, {line, facets: {}})
|
|
305
|
+
|
|
306
|
+
let start_ = (feed: t, l: listener, filter: option<filter>) =>
|
|
307
|
+
if !isLive(l) {
|
|
308
|
+
l.timer->Option.forEach(clearTimeout)
|
|
309
|
+
l.timer = None
|
|
310
|
+
filter->Option.forEach(f => l.filter = f)
|
|
311
|
+
l.phase = Live
|
|
312
|
+
feed.backlog->Option.forEach(backlog =>
|
|
313
|
+
backlog()->Array.forEach(item =>
|
|
314
|
+
if matches(~honours=feed.honours, l.filter, item.facets) {
|
|
315
|
+
enqueue(feed, l, item.line)
|
|
316
|
+
}
|
|
317
|
+
)
|
|
318
|
+
)
|
|
319
|
+
pump(feed, l)
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
let newListener = (sink: sink, phase: phase): listener => {
|
|
323
|
+
sink,
|
|
324
|
+
phase,
|
|
325
|
+
filter: {},
|
|
326
|
+
queue: [],
|
|
327
|
+
head: 0,
|
|
328
|
+
queuedBytes: 0,
|
|
329
|
+
skipped: 0,
|
|
330
|
+
blocked: false,
|
|
331
|
+
inbound: "",
|
|
332
|
+
timer: None,
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
let accept = (feed: t, socket: NodeNet.socket) => {
|
|
336
|
+
let l = newListener(socketSink(socket), Waiting)
|
|
337
|
+
socket->NodeNet.setEncoding("utf8")
|
|
338
|
+
socket->NodeNet.onSocketError(_ => drop(feed, l))
|
|
339
|
+
socket->NodeNet.onSocketClose(() => drop(feed, l))
|
|
340
|
+
socket->NodeNet.onSocketData(chunk =>
|
|
341
|
+
if !isLive(l) {
|
|
342
|
+
l.inbound = l.inbound ++ chunk
|
|
343
|
+
switch l.inbound->String.indexOf("\n") {
|
|
344
|
+
| -1 =>
|
|
345
|
+
// A listener that writes without ever ending a line is not given a
|
|
346
|
+
// buffer to grow without bound.
|
|
347
|
+
if l.inbound->String.length > 64 * 1024 {
|
|
348
|
+
l.inbound = ""
|
|
349
|
+
start_(feed, l, None)
|
|
350
|
+
}
|
|
351
|
+
| i =>
|
|
352
|
+
let line = l.inbound->String.slice(~start=0, ~end=i)
|
|
353
|
+
l.inbound = ""
|
|
354
|
+
switch parseFilter(line) {
|
|
355
|
+
| Some(_) as f => start_(feed, l, f)
|
|
356
|
+
| None =>
|
|
357
|
+
log.warn(~comp=feed.name, `ignored a filter line that is not a JSON object`)
|
|
358
|
+
start_(feed, l, None)
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
)
|
|
363
|
+
l.timer = Some(setTimeout(() => start_(feed, l, None), feed.filterWaitMs))
|
|
364
|
+
feed.listeners = feed.listeners->Array.concat([l])
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
/** Binds the socket and reports the port it got, through `~onBound`, once it is
|
|
368
|
+
listening. Best-effort: a platform whose feed cannot bind still serves. */
|
|
369
|
+
let start = (feed: t, ~setting: setting, ~onBound: int => unit=_ => (), ()): unit =>
|
|
370
|
+
switch (setting, feed.server) {
|
|
371
|
+
| (Off, _) | (_, Some(_)) => ()
|
|
372
|
+
| (setting, None) =>
|
|
373
|
+
let requested = switch setting {
|
|
374
|
+
| Fixed(p) => p
|
|
375
|
+
// 0 lets the OS pick, so parallel platforms in one directory never collide.
|
|
376
|
+
| Off | Ephemeral => 0
|
|
377
|
+
}
|
|
378
|
+
let s = NodeNet.createServer(socket => accept(feed, socket))
|
|
379
|
+
s->NodeNet.onServerError(err => {
|
|
380
|
+
log.warn(
|
|
381
|
+
~comp=feed.name,
|
|
382
|
+
`could not listen: ${err->JsExn.message->Option.getOr("unknown error")}`,
|
|
383
|
+
)
|
|
384
|
+
feed.server = None
|
|
385
|
+
feed.port = None
|
|
386
|
+
})
|
|
387
|
+
// A diagnostic listener must never be why the process stays alive.
|
|
388
|
+
s->NodeNet.unref
|
|
389
|
+
s->NodeNet.listen(requested, "127.0.0.1", () =>
|
|
390
|
+
switch s->NodeNet.address->Nullable.toOption {
|
|
391
|
+
| Some(addr) =>
|
|
392
|
+
let bound = addr["port"]
|
|
393
|
+
feed.port = Some(bound)
|
|
394
|
+
log.info(~comp=feed.name, `listening on 127.0.0.1:${bound->Int.toString}`)
|
|
395
|
+
onBound(bound)
|
|
396
|
+
| None => ()
|
|
397
|
+
}
|
|
398
|
+
)
|
|
399
|
+
feed.server = Some(s)
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
/** Test seam: forgets listeners and server without closing anything. */
|
|
403
|
+
let resetForTests = (feed: t) => {
|
|
404
|
+
feed.listeners->Array.forEach(l => l.timer->Option.forEach(clearTimeout))
|
|
405
|
+
feed.listeners = []
|
|
406
|
+
feed.server = None
|
|
407
|
+
feed.port = None
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
/** Test seam: a listener that is sent every line from now on. */
|
|
411
|
+
let addConnectionForTests = (feed: t, socket: NodeNet.socket) =>
|
|
412
|
+
feed.listeners = feed.listeners->Array.concat([newListener(socketSink(socket), Live)])
|
|
413
|
+
|
|
414
|
+
/** A listener that is not a socket (a subscription on the platform API), started
|
|
415
|
+
at once with its filter. Returns the function that removes it. */
|
|
416
|
+
let subscribe = (feed: t, ~filter: filter, sink: sink): (unit => unit) => {
|
|
417
|
+
let l = newListener(sink, Waiting)
|
|
418
|
+
feed.listeners = feed.listeners->Array.concat([l])
|
|
419
|
+
start_(feed, l, Some(filter))
|
|
420
|
+
() => drop(feed, l)
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
/** Test seam: releases the port, so a suite can bind it again. */
|
|
424
|
+
let stopForTests = (feed: t): promise<unit> =>
|
|
425
|
+
switch feed.server {
|
|
426
|
+
| None =>
|
|
427
|
+
resetForTests(feed)
|
|
428
|
+
Promise.resolve()
|
|
429
|
+
| Some(s) =>
|
|
430
|
+
feed.listeners->Array.forEach(l => l.sink.close())
|
|
431
|
+
resetForTests(feed)
|
|
432
|
+
Promise.make((resolve, _reject) => s->NodeNet.closeServer(() => resolve()))
|
|
433
|
+
}
|