@reventlessdev/reventless-local 3.0.0-alpha.316 → 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.
Files changed (57) hide show
  1. package/CHANGELOG.md +16 -0
  2. package/package.json +11 -11
  3. package/src/LocalPlatformRegistry.res +19 -0
  4. package/src/LocalPlatformRegistry.res.mjs +19 -1
  5. package/src/Platform.res +24 -2
  6. package/src/Platform.res.mjs +22 -2
  7. package/src/adapter/DcbEventLog/DcbEventLogStorage_InMemory.res +38 -0
  8. package/src/adapter/DcbEventLog/DcbEventLogStorage_InMemory.res.mjs +29 -0
  9. package/src/adapter/DcbEventLog/DcbEventLogStorage_Sqlite.res +87 -0
  10. package/src/adapter/DcbEventLog/DcbEventLogStorage_Sqlite.res.mjs +76 -0
  11. package/src/adapter/DcbEventLog/LocalDcbEventLogStorage.res +8 -1
  12. package/src/adapter/DcbEventLog/LocalDcbEventLogStorage.res.mjs +17 -4
  13. package/src/adapter/EventLog/EventLogStorage_InMemory.res +49 -2
  14. package/src/adapter/EventLog/EventLogStorage_InMemory.res.mjs +57 -21
  15. package/src/adapter/EventLog/EventLogStorage_Sqlite.res +85 -0
  16. package/src/adapter/EventLog/EventLogStorage_Sqlite.res.mjs +50 -0
  17. package/src/adapter/EventLog/LocalEventLogStorage.res +3 -0
  18. package/src/adapter/EventLog/LocalEventLogStorage.res.mjs +6 -2
  19. package/src/adapter/LocalBus.res +32 -5
  20. package/src/adapter/LocalBus.res.mjs +27 -7
  21. package/src/adapter/LocalEventLogRead.res +477 -0
  22. package/src/adapter/LocalEventLogRead.res.mjs +507 -0
  23. package/src/adapter/LocalEventTap.res +18 -80
  24. package/src/adapter/LocalEventTap.res.mjs +19 -108
  25. package/src/adapter/LocalFeed.res +433 -0
  26. package/src/adapter/LocalFeed.res.mjs +419 -0
  27. package/src/adapter/LocalLogFeed.res +217 -0
  28. package/src/adapter/LocalLogFeed.res.mjs +295 -0
  29. package/src/adapter/LocalPgEventLogRead.res +85 -0
  30. package/src/adapter/LocalPgEventLogRead.res.mjs +91 -0
  31. package/src/adapter/LocalPlatformObserve.res +456 -0
  32. package/src/adapter/LocalPlatformObserve.res.mjs +505 -0
  33. package/src/adapter/LocalTapLines.res +147 -0
  34. package/src/adapter/LocalTapLines.res.mjs +229 -0
  35. package/src/adapter/PlatformGraphQL_Server.res +36 -3
  36. package/src/adapter/PlatformGraphQL_Server.res.mjs +15 -1
  37. package/src/reexport/CommandTopic.res.mjs +5 -2
  38. package/tests/adapter/GraphQLWsFixtures.mjs +46 -0
  39. package/tests/adapter/LocalEventLogReadTest.res +539 -0
  40. package/tests/adapter/LocalEventLogReadTest.res.mjs +466 -0
  41. package/tests/adapter/LocalFeedTest.res +314 -0
  42. package/tests/adapter/LocalFeedTest.res.mjs +329 -0
  43. package/tests/adapter/LocalPlatformObserveTest.res +165 -0
  44. package/tests/adapter/LocalPlatformObserveTest.res.mjs +183 -0
  45. package/tests/adapter/LocalTapLinesTest.res +182 -0
  46. package/tests/adapter/LocalTapLinesTest.res.mjs +242 -0
  47. package/tests/adapter/PlatformSubscriptionsTest.res +123 -0
  48. package/tests/adapter/PlatformSubscriptionsTest.res.mjs +132 -0
  49. package/tests/components/automationslice/AutomationSliceCallbackTest.res +80 -0
  50. package/tests/components/automationslice/AutomationSliceCallbackTest.res.mjs +81 -0
  51. package/tests/components/automationslice/AutomationSliceSelfDeadlockFixtures.res +17 -0
  52. package/tests/components/automationslice/AutomationSliceSelfDeadlockFixtures.res.mjs +24 -0
  53. package/tests/components/automationslice/AutomationSliceSelfDeadlockTest.res +30 -0
  54. package/tests/components/automationslice/AutomationSliceSelfDeadlockTest.res.mjs +28 -0
  55. package/tests/components/outboundtranslationslice/OutboundTranslationSliceCallbackTest.res +58 -0
  56. package/tests/components/outboundtranslationslice/OutboundTranslationSliceCallbackTest.res.mjs +71 -19
  57. 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 Primitive_option from "@rescript/runtime/lib/es6/Primitive_option.js";
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
- let v = envValue(env);
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 connections = {
59
- contents: []
60
- };
61
-
62
- let server = {
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 listeningPort.contents;
40
+ return LocalFeed$ReventlessLocal.port(feed);
72
41
  }
73
42
 
74
43
  function hasReaders() {
75
- return connections.contents.length !== 0;
44
+ return LocalFeed$ReventlessLocal.hasReaders(feed);
76
45
  }
77
46
 
78
- function drop(socket) {
79
- connections.contents = connections.contents.filter(s => s !== socket);
47
+ function publish(item) {
48
+ LocalFeed$ReventlessLocal.publish(feed, item);
80
49
  }
81
50
 
82
51
  function broadcast(line) {
83
- if (connections.contents.length !== 0) {
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
- let match = settingFromEnv(env);
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
- connections.contents = [];
136
- server.contents = undefined;
137
- listeningPort.contents = undefined;
62
+ LocalFeed$ReventlessLocal.resetForTests(feed);
138
63
  }
139
64
 
140
65
  function addConnectionForTests(socket) {
141
- connections.contents = connections.contents.concat([socket]);
66
+ LocalFeed$ReventlessLocal.addConnectionForTests(feed, socket);
142
67
  }
143
68
 
144
69
  function stopForTests() {
145
- let s = server.contents;
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
- connections,
166
- server,
167
- listeningPort,
78
+ feed,
168
79
  port,
169
80
  hasReaders,
170
- drop,
81
+ publish,
171
82
  broadcast,
172
83
  start,
173
84
  resetForTests,
174
85
  addConnectionForTests,
175
86
  stopForTests,
176
87
  }
177
- /* log Not a pure module */
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
+ }