@reventlessdev/reventless-local 3.0.0-alpha.243 → 3.0.0-alpha.245
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 +19 -0
- package/package.json +11 -11
- package/src/LocalPlatformRegistry.res +54 -63
- package/src/LocalPlatformRegistry.res.mjs +24 -5
- package/src/LocalPlatformStart.res +84 -0
- package/src/LocalPlatformStart.res.mjs +99 -0
- package/src/Platform.res +16 -0
- package/src/Platform.res.mjs +11 -2
- package/src/adapter/LocalBus.res +30 -27
- package/src/adapter/LocalBus.res.mjs +14 -18
- package/src/adapter/LocalEventTap.res +139 -0
- package/src/adapter/LocalEventTap.res.mjs +177 -0
- package/tests/EventTapSocketFixtures.mjs +17 -0
- package/tests/LocalEventTapTest.res +154 -0
- package/tests/LocalEventTapTest.res.mjs +140 -0
- package/tests/LocalPlatformRegistryTest.res +61 -0
- package/tests/LocalPlatformRegistryTest.res.mjs +41 -1
- package/tests/LocalPlatformStartTest.res +176 -0
- package/tests/LocalPlatformStartTest.res.mjs +210 -0
- package/tests/adapter/EventTapTest.res +16 -6
- package/tests/adapter/EventTapTest.res.mjs +5 -1
package/src/adapter/LocalBus.res
CHANGED
|
@@ -51,34 +51,39 @@ type queuedEvent = {
|
|
|
51
51
|
// it is one of three implementations of a shared wire format and is covered by
|
|
52
52
|
// its own parity test, so it stays out of the bus.
|
|
53
53
|
|
|
54
|
-
//
|
|
55
|
-
//
|
|
56
|
-
//
|
|
57
|
-
//
|
|
58
|
-
//
|
|
59
|
-
//
|
|
60
|
-
//
|
|
61
|
-
// subscriber-countdown delivery semantics.
|
|
62
|
-
// Read per publish (not once at import) so the runner can toggle it live and so
|
|
63
|
-
// hermetic tests can flip it between cases. The cost is one dict lookup per event.
|
|
64
|
-
let eventTapEnabled = () => NodeProcess.env->Dict.get("REVENTLESS_EVENT_TAP")->Option.isSome
|
|
54
|
+
// NDJSON domain-event tap, emitted from publishEvent so each line carries the real
|
|
55
|
+
// topic name, sentinel-prefixed so a parser can pick it out of the log noise.
|
|
56
|
+
//
|
|
57
|
+
// Two sinks with different costs, so different defaults: stdout is opt-in via
|
|
58
|
+
// REVENTLESS_EVENT_TAP (a line per event would drown `pnpm run serve`), the socket
|
|
59
|
+
// is on by default (see LocalEventTap). Read per publish, not at import, so it can
|
|
60
|
+
// be toggled live.
|
|
65
61
|
let eventTapSeq = ref(0)
|
|
66
|
-
//
|
|
67
|
-
// so the timeline's #N continues across process restarts / app switches instead
|
|
68
|
-
// of restarting at 1. Called once at platform startup for the sqlite backend.
|
|
62
|
+
// Continues the timeline's #N across restarts for a persistent store.
|
|
69
63
|
let seedEventTapSeq = (n: int) => eventTapSeq := n
|
|
70
64
|
let emitEventTap = (~topic: string, ~service: string, ~payload: JSON.t) => {
|
|
65
|
+
// Counted even when nothing is listening, so the sequence a reader that connects
|
|
66
|
+
// later sees is the event's ordinal rather than the ordinal of its own arrival.
|
|
71
67
|
eventTapSeq := eventTapSeq.contents + 1
|
|
72
|
-
let
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
68
|
+
let toStdout = LocalEventTap.stdoutEnabled()
|
|
69
|
+
// The line is built only for somebody: serialising every event for no reader is
|
|
70
|
+
// pure cost on the publish path.
|
|
71
|
+
if toStdout || LocalEventTap.hasReaders() {
|
|
72
|
+
let line =
|
|
73
|
+
Dict.fromArray([
|
|
74
|
+
("event", JSON.Encode.string("domainEvent")),
|
|
75
|
+
("seq", JSON.Encode.int(eventTapSeq.contents)),
|
|
76
|
+
("topic", JSON.Encode.string(topic)),
|
|
77
|
+
("service", JSON.Encode.string(service)),
|
|
78
|
+
("payload", payload),
|
|
79
|
+
("ts", JSON.Encode.string(Date.make()->Date.toISOString)),
|
|
80
|
+
])->JSON.Encode.object
|
|
81
|
+
let tapLine = "@@RVLESS_EVT@@ " ++ line->JSON.stringify
|
|
82
|
+
if toStdout {
|
|
83
|
+
Console.log(tapLine)
|
|
84
|
+
}
|
|
85
|
+
LocalEventTap.broadcast(tapLine)
|
|
86
|
+
}
|
|
82
87
|
}
|
|
83
88
|
|
|
84
89
|
module type T = {
|
|
@@ -327,9 +332,7 @@ module Impl = (C: BusConfig): T => {
|
|
|
327
332
|
}
|
|
328
333
|
|
|
329
334
|
let publishEvent = async (topicName, service, meta, json) => {
|
|
330
|
-
|
|
331
|
-
emitEventTap(~topic=topicName, ~service, ~payload=json)
|
|
332
|
-
}
|
|
335
|
+
emitEventTap(~topic=topicName, ~service, ~payload=json)
|
|
333
336
|
switch eventHubs.contents->Dict.get(topicName) {
|
|
334
337
|
| None => ()
|
|
335
338
|
| Some(hub) =>
|
|
@@ -10,10 +10,7 @@ import * as Stream from "effect/Stream";
|
|
|
10
10
|
import * as Stdlib_Promise from "@rescript/runtime/lib/es6/Stdlib_Promise.js";
|
|
11
11
|
import * as Deferred from "effect/Deferred";
|
|
12
12
|
import * as Primitive_option from "@rescript/runtime/lib/es6/Primitive_option.js";
|
|
13
|
-
|
|
14
|
-
function eventTapEnabled() {
|
|
15
|
-
return Stdlib_Option.isSome(process.env["REVENTLESS_EVENT_TAP"]);
|
|
16
|
-
}
|
|
13
|
+
import * as LocalEventTap$ReventlessLocal from "./LocalEventTap.res.mjs";
|
|
17
14
|
|
|
18
15
|
let eventTapSeq = {
|
|
19
16
|
contents: 0
|
|
@@ -25,6 +22,10 @@ function seedEventTapSeq(n) {
|
|
|
25
22
|
|
|
26
23
|
function emitEventTap(topic, service, payload) {
|
|
27
24
|
eventTapSeq.contents = eventTapSeq.contents + 1 | 0;
|
|
25
|
+
let toStdout = LocalEventTap$ReventlessLocal.stdoutEnabled(undefined);
|
|
26
|
+
if (!(toStdout || LocalEventTap$ReventlessLocal.hasReaders())) {
|
|
27
|
+
return;
|
|
28
|
+
}
|
|
28
29
|
let line = Object.fromEntries([
|
|
29
30
|
[
|
|
30
31
|
"event",
|
|
@@ -51,7 +52,11 @@ function emitEventTap(topic, service, payload) {
|
|
|
51
52
|
new Date().toISOString()
|
|
52
53
|
]
|
|
53
54
|
]);
|
|
54
|
-
|
|
55
|
+
let tapLine = "@@RVLESS_EVT@@ " + JSON.stringify(line);
|
|
56
|
+
if (toStdout) {
|
|
57
|
+
console.log(tapLine);
|
|
58
|
+
}
|
|
59
|
+
LocalEventTap$ReventlessLocal.broadcast(tapLine);
|
|
55
60
|
}
|
|
56
61
|
|
|
57
62
|
function Impl(C) {
|
|
@@ -132,9 +137,7 @@ function Impl(C) {
|
|
|
132
137
|
}), Queue.shutdown(queue))), queue => Stream.fromQueue(queue));
|
|
133
138
|
};
|
|
134
139
|
let publishEvent = async (topicName, service, meta, json) => {
|
|
135
|
-
|
|
136
|
-
emitEventTap(topicName, service, json);
|
|
137
|
-
}
|
|
140
|
+
emitEventTap(topicName, service, json);
|
|
138
141
|
let hub = eventHubs.contents[topicName];
|
|
139
142
|
if (hub === undefined) {
|
|
140
143
|
return;
|
|
@@ -407,9 +410,7 @@ function Make($star) {
|
|
|
407
410
|
}), Queue.shutdown(queue))), queue => Stream.fromQueue(queue));
|
|
408
411
|
};
|
|
409
412
|
let publishEvent = async (topicName, service, meta, json) => {
|
|
410
|
-
|
|
411
|
-
emitEventTap(topicName, service, json);
|
|
412
|
-
}
|
|
413
|
+
emitEventTap(topicName, service, json);
|
|
413
414
|
let hub = eventHubs.contents[topicName];
|
|
414
415
|
if (hub === undefined) {
|
|
415
416
|
return;
|
|
@@ -682,9 +683,7 @@ function MakeSilent($star) {
|
|
|
682
683
|
}), Queue.shutdown(queue))), queue => Stream.fromQueue(queue));
|
|
683
684
|
};
|
|
684
685
|
let publishEvent = async (topicName, service, meta, json) => {
|
|
685
|
-
|
|
686
|
-
emitEventTap(topicName, service, json);
|
|
687
|
-
}
|
|
686
|
+
emitEventTap(topicName, service, json);
|
|
688
687
|
let hub = eventHubs.contents[topicName];
|
|
689
688
|
if (hub === undefined) {
|
|
690
689
|
return;
|
|
@@ -958,9 +957,7 @@ function MakeBounded(C) {
|
|
|
958
957
|
}), Queue.shutdown(queue))), queue => Stream.fromQueue(queue));
|
|
959
958
|
};
|
|
960
959
|
let publishEvent = async (topicName, service, meta, json) => {
|
|
961
|
-
|
|
962
|
-
emitEventTap(topicName, service, json);
|
|
963
|
-
}
|
|
960
|
+
emitEventTap(topicName, service, json);
|
|
964
961
|
let hub = eventHubs.contents[topicName];
|
|
965
962
|
if (hub === undefined) {
|
|
966
963
|
return;
|
|
@@ -1160,7 +1157,6 @@ function MakeBounded(C) {
|
|
|
1160
1157
|
}
|
|
1161
1158
|
|
|
1162
1159
|
export {
|
|
1163
|
-
eventTapEnabled,
|
|
1164
1160
|
eventTapSeq,
|
|
1165
1161
|
seedEventTapSeq,
|
|
1166
1162
|
emitEventTap,
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
// The event tap's second sink: LocalBus's NDJSON lines, unchanged, over a loopback
|
|
2
|
+
// socket — so a tool that did not spawn this platform can read its timeline.
|
|
3
|
+
//
|
|
4
|
+
// On by default, on an ephemeral port published in the registry entry. A socket
|
|
5
|
+
// nobody connects to costs nothing visible, and the registry is already how a
|
|
6
|
+
// reader finds this platform — so the port never needed to be well-known, which is
|
|
7
|
+
// what let it stop being configured. The noisy half (a line per event on stdout)
|
|
8
|
+
// stays opt-in. `REVENTLESS_EVENT_TAP=off` disables the socket too.
|
|
9
|
+
// See docs/plans/done/one-local-platform-one-store.md.
|
|
10
|
+
|
|
11
|
+
let log = ReventlessCore.Logger.fromEnv()
|
|
12
|
+
|
|
13
|
+
let envVar = "REVENTLESS_EVENT_TAP"
|
|
14
|
+
|
|
15
|
+
type setting =
|
|
16
|
+
| Off
|
|
17
|
+
| Ephemeral
|
|
18
|
+
| Fixed(int)
|
|
19
|
+
|
|
20
|
+
let envValue = (~env: dict<string>): option<string> =>
|
|
21
|
+
env->Dict.get(envVar)->Option.map(String.trim)->Option.filter(v => v != "")
|
|
22
|
+
|
|
23
|
+
/** What the env var asks the socket to do.
|
|
24
|
+
|
|
25
|
+
A port only when the value IS one in the unprivileged range: the runner has
|
|
26
|
+
passed `ndjson` since the tap existed, and someone writing `=1` means "on", not
|
|
27
|
+
port 1. Everything else — unset included — takes the ephemeral default. */
|
|
28
|
+
let settingFromEnv = (~env=NodeProcess.env): setting =>
|
|
29
|
+
switch envValue(~env) {
|
|
30
|
+
| Some("off") | Some("false") | Some("none") => Off
|
|
31
|
+
| Some(v) =>
|
|
32
|
+
switch Int.fromString(v) {
|
|
33
|
+
| Some(p) if p >= 1024 && p <= 65535 => Fixed(p)
|
|
34
|
+
| _ => Ephemeral
|
|
35
|
+
}
|
|
36
|
+
| None => Ephemeral
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Whether to ALSO write each line to stdout. Opt-in and unchanged: this is the
|
|
40
|
+
half with a visible cost, and it exists for the child whose stdout the runner
|
|
41
|
+
reads. `off` silences both. */
|
|
42
|
+
let stdoutEnabled = (~env=NodeProcess.env): bool =>
|
|
43
|
+
switch envValue(~env) {
|
|
44
|
+
| Some("off") | Some("false") | Some("none") | None => false
|
|
45
|
+
| Some(_) => true
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
let connections: ref<array<NodeNet.socket>> = ref([])
|
|
49
|
+
let server: ref<option<NodeNet.server>> = ref(None)
|
|
50
|
+
let listeningPort: ref<option<int>> = ref(None)
|
|
51
|
+
|
|
52
|
+
/** The port the socket is actually bound to, known only once `listen` calls back —
|
|
53
|
+
which is why {!start} takes `~onBound` rather than the caller reading this
|
|
54
|
+
straight after. `None` until then, and if the bind fails. */
|
|
55
|
+
let port = () => listeningPort.contents
|
|
56
|
+
|
|
57
|
+
/** Whether anyone is reading, so the hot path can skip building a line nobody
|
|
58
|
+
wants. */
|
|
59
|
+
let hasReaders = () => connections.contents->Array.length > 0
|
|
60
|
+
|
|
61
|
+
let drop = (socket: NodeNet.socket) =>
|
|
62
|
+
connections := connections.contents->Array.filter(s => s !== socket)
|
|
63
|
+
|
|
64
|
+
/** Guarded per socket: a write to a closed peer throws, and one dead reader must
|
|
65
|
+
not cost the others their events or the platform its run. */
|
|
66
|
+
let broadcast = (line: string): unit =>
|
|
67
|
+
if hasReaders() {
|
|
68
|
+
connections.contents->Array.forEach(socket =>
|
|
69
|
+
switch socket->NodeNet.write(line ++ "\n") {
|
|
70
|
+
| _ => ()
|
|
71
|
+
| exception _ => drop(socket)
|
|
72
|
+
}
|
|
73
|
+
)
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Binds the tap socket and reports the port it got.
|
|
77
|
+
|
|
78
|
+
`~onBound` fires only after a successful bind, so a caller publishes a port
|
|
79
|
+
that is genuinely listening rather than one it hoped for. Best-effort in every
|
|
80
|
+
failure mode: a platform whose diagnostic socket cannot bind must still serve,
|
|
81
|
+
and simply carries no tap port. */
|
|
82
|
+
let start = (~env=NodeProcess.env, ~onBound: int => unit=_ => (), ()): unit =>
|
|
83
|
+
switch (settingFromEnv(~env), server.contents) {
|
|
84
|
+
| (Off, _) | (_, Some(_)) => ()
|
|
85
|
+
| (setting, None) =>
|
|
86
|
+
let requested = switch setting {
|
|
87
|
+
| Fixed(p) => p
|
|
88
|
+
// 0 lets the OS pick, so parallel platforms in one directory never collide and
|
|
89
|
+
// no unrelated service can be advertised as ours.
|
|
90
|
+
| Off | Ephemeral => 0
|
|
91
|
+
}
|
|
92
|
+
let s = NodeNet.createServer(socket => {
|
|
93
|
+
socket->NodeNet.setEncoding("utf8")
|
|
94
|
+
socket->NodeNet.onSocketError(_ => drop(socket))
|
|
95
|
+
socket->NodeNet.onSocketClose(() => drop(socket))
|
|
96
|
+
connections := connections.contents->Array.concat([socket])
|
|
97
|
+
})
|
|
98
|
+
s->NodeNet.onServerError(err => {
|
|
99
|
+
log.warn(
|
|
100
|
+
~comp="EventTap",
|
|
101
|
+
`could not listen: ${err->JsExn.message->Option.getOr("unknown error")}`,
|
|
102
|
+
)
|
|
103
|
+
server := None
|
|
104
|
+
listeningPort := None
|
|
105
|
+
})
|
|
106
|
+
// A diagnostic listener must never be why the process stays alive.
|
|
107
|
+
s->NodeNet.unref
|
|
108
|
+
s->NodeNet.listen(requested, "127.0.0.1", () =>
|
|
109
|
+
switch s->NodeNet.address->Nullable.toOption {
|
|
110
|
+
| Some(addr) =>
|
|
111
|
+
let bound = addr["port"]
|
|
112
|
+
listeningPort := Some(bound)
|
|
113
|
+
log.info(~comp="EventTap", `listening on 127.0.0.1:${bound->Int.toString}`)
|
|
114
|
+
onBound(bound)
|
|
115
|
+
| None => ()
|
|
116
|
+
}
|
|
117
|
+
)
|
|
118
|
+
server := Some(s)
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** Test seam: forgets connections and server without closing anything. */
|
|
122
|
+
let resetForTests = () => {
|
|
123
|
+
connections := []
|
|
124
|
+
server := None
|
|
125
|
+
listeningPort := None
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
let addConnectionForTests = (socket: NodeNet.socket) =>
|
|
129
|
+
connections := connections.contents->Array.concat([socket])
|
|
130
|
+
|
|
131
|
+
/** Test seam: actually releases the port, so a suite can bind it again. */
|
|
132
|
+
let stopForTests = (): promise<unit> =>
|
|
133
|
+
switch server.contents {
|
|
134
|
+
| None => Promise.resolve()
|
|
135
|
+
| Some(s) =>
|
|
136
|
+
connections.contents->Array.forEach(NodeNet.destroySocket)
|
|
137
|
+
resetForTests()
|
|
138
|
+
Promise.make((resolve, _reject) => s->NodeNet.closeServer(() => resolve()))
|
|
139
|
+
}
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
// Generated by ReScript, PLEASE EDIT WITH CARE
|
|
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
|
+
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();
|
|
11
|
+
|
|
12
|
+
let envVar = "REVENTLESS_EVENT_TAP";
|
|
13
|
+
|
|
14
|
+
function envValue(env) {
|
|
15
|
+
return Stdlib_Option.filter(Stdlib_Option.map(env[envVar], prim => prim.trim()), v => v !== "");
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function settingFromEnv(envOpt) {
|
|
19
|
+
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
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function stdoutEnabled(envOpt) {
|
|
43
|
+
let env = envOpt !== undefined ? envOpt : process.env;
|
|
44
|
+
let match = envValue(env);
|
|
45
|
+
if (match === undefined) {
|
|
46
|
+
return false;
|
|
47
|
+
}
|
|
48
|
+
switch (match) {
|
|
49
|
+
case "false" :
|
|
50
|
+
case "none" :
|
|
51
|
+
case "off" :
|
|
52
|
+
return false;
|
|
53
|
+
default:
|
|
54
|
+
return true;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
let connections = {
|
|
59
|
+
contents: []
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
let server = {
|
|
63
|
+
contents: undefined
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
let listeningPort = {
|
|
67
|
+
contents: undefined
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
function port() {
|
|
71
|
+
return listeningPort.contents;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function hasReaders() {
|
|
75
|
+
return connections.contents.length !== 0;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function drop(socket) {
|
|
79
|
+
connections.contents = connections.contents.filter(s => s !== socket);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
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
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function start(envOpt, onBoundOpt, param) {
|
|
97
|
+
let env = envOpt !== undefined ? envOpt : process.env;
|
|
98
|
+
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);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function resetForTests() {
|
|
135
|
+
connections.contents = [];
|
|
136
|
+
server.contents = undefined;
|
|
137
|
+
listeningPort.contents = undefined;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function addConnectionForTests(socket) {
|
|
141
|
+
connections.contents = connections.contents.concat([socket]);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
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
|
+
});
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
export {
|
|
160
|
+
log,
|
|
161
|
+
envVar,
|
|
162
|
+
envValue,
|
|
163
|
+
settingFromEnv,
|
|
164
|
+
stdoutEnabled,
|
|
165
|
+
connections,
|
|
166
|
+
server,
|
|
167
|
+
listeningPort,
|
|
168
|
+
port,
|
|
169
|
+
hasReaders,
|
|
170
|
+
drop,
|
|
171
|
+
broadcast,
|
|
172
|
+
start,
|
|
173
|
+
resetForTests,
|
|
174
|
+
addConnectionForTests,
|
|
175
|
+
stopForTests,
|
|
176
|
+
}
|
|
177
|
+
/* log Not a pure module */
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
// Stand-in sockets for the tap's broadcast, which touches nothing on a socket but
|
|
2
|
+
// `write`. Here rather than in an `Obj.magic` inside the test, per the repo
|
|
3
|
+
// convention that untyped reflection lives in a companion `.mjs`.
|
|
4
|
+
|
|
5
|
+
/** Records every line written, so a test can assert what a reader received. */
|
|
6
|
+
export const recordingSocket = () => {
|
|
7
|
+
const written = []
|
|
8
|
+
return { socket: { write: line => written.push(line) }, written }
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/** A reader whose peer is gone: `write` throws, the way it does on a closed pipe.
|
|
12
|
+
Broadcast has to survive this without losing the other readers. */
|
|
13
|
+
export const throwingSocket = () => ({
|
|
14
|
+
write: () => {
|
|
15
|
+
throw new Error('EPIPE')
|
|
16
|
+
},
|
|
17
|
+
})
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
// The tap's second sink. A tool that did not spawn the platform has no stdout to
|
|
2
|
+
// read, so an attached runner's timeline is dark without this.
|
|
3
|
+
|
|
4
|
+
@@warning("-44")
|
|
5
|
+
|
|
6
|
+
open JestGlobals
|
|
7
|
+
|
|
8
|
+
type recorder = {socket: NodeNet.socket, written: array<string>}
|
|
9
|
+
|
|
10
|
+
@module("./EventTapSocketFixtures.mjs")
|
|
11
|
+
external recordingSocket: unit => recorder = "recordingSocket"
|
|
12
|
+
|
|
13
|
+
@module("./EventTapSocketFixtures.mjs")
|
|
14
|
+
external throwingSocket: unit => NodeNet.socket = "throwingSocket"
|
|
15
|
+
|
|
16
|
+
let envWith = (value: option<string>): dict<string> =>
|
|
17
|
+
switch value {
|
|
18
|
+
| Some(v) => Dict.fromArray([(LocalEventTap.envVar, v)])
|
|
19
|
+
| None => Dict.make()
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
describe("LocalEventTap.settingFromEnv", () => {
|
|
23
|
+
// The default is the point: the runner attaches to whatever `pnpm run serve`
|
|
24
|
+
// started, and a tap nobody remembered to switch on is a dark timeline.
|
|
25
|
+
testSync("defaults to an ephemeral socket when the var is unset", () => {
|
|
26
|
+
expect(LocalEventTap.settingFromEnv(~env=envWith(None)))->toEqual(LocalEventTap.Ephemeral)
|
|
27
|
+
expect(LocalEventTap.settingFromEnv(~env=envWith(Some(""))))->toEqual(LocalEventTap.Ephemeral)
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
testSync("takes a port when the value is one", () =>
|
|
31
|
+
expect(LocalEventTap.settingFromEnv(~env=envWith(Some("4100"))))->toEqual(
|
|
32
|
+
LocalEventTap.Fixed(4100),
|
|
33
|
+
)
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
// `ndjson` is what the runner has passed since the tap existed, and `=1` means
|
|
37
|
+
// "on" to anyone who writes it — never port 1. Both take the default socket.
|
|
38
|
+
testSync("treats a non-port value as the default, not as a port", () => {
|
|
39
|
+
expect(LocalEventTap.settingFromEnv(~env=envWith(Some("ndjson"))))->toEqual(
|
|
40
|
+
LocalEventTap.Ephemeral,
|
|
41
|
+
)
|
|
42
|
+
expect(LocalEventTap.settingFromEnv(~env=envWith(Some("1"))))->toEqual(LocalEventTap.Ephemeral)
|
|
43
|
+
expect(LocalEventTap.settingFromEnv(~env=envWith(Some("80"))))->toEqual(LocalEventTap.Ephemeral)
|
|
44
|
+
expect(LocalEventTap.settingFromEnv(~env=envWith(Some("70000"))))->toEqual(
|
|
45
|
+
LocalEventTap.Ephemeral,
|
|
46
|
+
)
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
testSync("switches the socket off on request", () =>
|
|
50
|
+
["off", "false", "none"]->Array.forEach(v =>
|
|
51
|
+
expect(LocalEventTap.settingFromEnv(~env=envWith(Some(v))))->toEqual(LocalEventTap.Off)
|
|
52
|
+
)
|
|
53
|
+
)
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
describe("LocalEventTap.stdoutEnabled", () => {
|
|
57
|
+
// The half with a visible cost keeps its opt-in: a line per event would drown
|
|
58
|
+
// `pnpm run serve`, which is the command the socket default exists to serve.
|
|
59
|
+
testSync("stays off unless the var is set", () => {
|
|
60
|
+
expect(LocalEventTap.stdoutEnabled(~env=envWith(None)))->toEqual(false)
|
|
61
|
+
expect(LocalEventTap.stdoutEnabled(~env=envWith(Some("ndjson"))))->toEqual(true)
|
|
62
|
+
expect(LocalEventTap.stdoutEnabled(~env=envWith(Some("4100"))))->toEqual(true)
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
testSync("is silenced by off, along with the socket", () =>
|
|
66
|
+
expect(LocalEventTap.stdoutEnabled(~env=envWith(Some("off"))))->toEqual(false)
|
|
67
|
+
)
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
describe("LocalEventTap.broadcast", () => {
|
|
71
|
+
testSync("hands every reader the line, newline-terminated", () => {
|
|
72
|
+
LocalEventTap.resetForTests()
|
|
73
|
+
let a = recordingSocket()
|
|
74
|
+
let b = recordingSocket()
|
|
75
|
+
LocalEventTap.addConnectionForTests(a.socket)
|
|
76
|
+
LocalEventTap.addConnectionForTests(b.socket)
|
|
77
|
+
|
|
78
|
+
LocalEventTap.broadcast(`@@RVLESS_EVT@@ {"seq":1}`)
|
|
79
|
+
|
|
80
|
+
expect(a.written)->toEqual([`@@RVLESS_EVT@@ {"seq":1}\n`])
|
|
81
|
+
expect(b.written)->toEqual([`@@RVLESS_EVT@@ {"seq":1}\n`])
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
// A write to a closed peer throws. One dead reader must not cost the others
|
|
85
|
+
// their events, or the platform its run.
|
|
86
|
+
testSync("drops a reader whose write throws, and keeps serving the rest", () => {
|
|
87
|
+
LocalEventTap.resetForTests()
|
|
88
|
+
let live = recordingSocket()
|
|
89
|
+
LocalEventTap.addConnectionForTests(throwingSocket())
|
|
90
|
+
LocalEventTap.addConnectionForTests(live.socket)
|
|
91
|
+
|
|
92
|
+
LocalEventTap.broadcast("first")
|
|
93
|
+
LocalEventTap.broadcast("second")
|
|
94
|
+
|
|
95
|
+
expect(live.written)->toEqual(["first\n", "second\n"])
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
testSync("is inert with no readers", () => {
|
|
99
|
+
LocalEventTap.resetForTests()
|
|
100
|
+
LocalEventTap.broadcast("nobody is listening")
|
|
101
|
+
expect(LocalEventTap.port())->toEqual(None)
|
|
102
|
+
})
|
|
103
|
+
})
|
|
104
|
+
|
|
105
|
+
describe("LocalEventTap.start", () => {
|
|
106
|
+
testSync("does not listen when the socket is switched off", () => {
|
|
107
|
+
LocalEventTap.resetForTests()
|
|
108
|
+
LocalEventTap.start(~env=envWith(Some("off")), ())
|
|
109
|
+
expect(LocalEventTap.port())->toEqual(None)
|
|
110
|
+
})
|
|
111
|
+
|
|
112
|
+
// The default path, and the one that matters: no env var at all, a port the OS
|
|
113
|
+
// picks, reported only once it is actually bound.
|
|
114
|
+
test("binds an ephemeral port by default and reports the one it got", async () => {
|
|
115
|
+
LocalEventTap.resetForTests()
|
|
116
|
+
let bound = await Promise.make((resolve, _reject) =>
|
|
117
|
+
LocalEventTap.start(~env=envWith(None), ~onBound=p => resolve(p), ())
|
|
118
|
+
)
|
|
119
|
+
expect(bound > 0)->toEqual(true)
|
|
120
|
+
// What the registry entry will carry — the real port, not a hoped-for one.
|
|
121
|
+
expect(LocalEventTap.port())->toEqual(Some(bound))
|
|
122
|
+
await LocalEventTap.stopForTests()
|
|
123
|
+
})
|
|
124
|
+
|
|
125
|
+
// The round trip the runner will make: connect to the advertised port, read
|
|
126
|
+
// the same sentinel-prefixed lines it reads off stdout today.
|
|
127
|
+
test("serves the lines it is sent on the port it was named", async () => {
|
|
128
|
+
LocalEventTap.resetForTests()
|
|
129
|
+
let port = 47311
|
|
130
|
+
let bound = await Promise.make((resolve, _reject) =>
|
|
131
|
+
LocalEventTap.start(~env=envWith(Some(port->Int.toString)), ~onBound=p => resolve(p), ())
|
|
132
|
+
)
|
|
133
|
+
expect(bound)->toEqual(port)
|
|
134
|
+
|
|
135
|
+
let received = await Promise.make((resolve, _reject) => {
|
|
136
|
+
let socket = NodeNet.connect(port, "127.0.0.1", () => ())
|
|
137
|
+
socket->NodeNet.setEncoding("utf8")
|
|
138
|
+
socket->NodeNet.onSocketError(e =>
|
|
139
|
+
resolve("connect failed: " ++ e->JsExn.message->Option.getOr("unknown"))
|
|
140
|
+
)
|
|
141
|
+
// Re-sent until it lands: the server's accept and the client's connect are
|
|
142
|
+
// separate events, so a single broadcast could beat the connection.
|
|
143
|
+
let ticker = ref(None)
|
|
144
|
+
socket->NodeNet.onSocketData(chunk => {
|
|
145
|
+
ticker.contents->Option.forEach(clearInterval)
|
|
146
|
+
resolve(chunk)
|
|
147
|
+
})
|
|
148
|
+
ticker := Some(setInterval(() => LocalEventTap.broadcast(`@@RVLESS_EVT@@ {"seq":7}`), 5))
|
|
149
|
+
})
|
|
150
|
+
|
|
151
|
+
expect(received)->toEqual(`@@RVLESS_EVT@@ {"seq":7}\n`)
|
|
152
|
+
await LocalEventTap.stopForTests()
|
|
153
|
+
})
|
|
154
|
+
})
|