@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.
@@ -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
- // Opt-in NDJSON domain-event tap for the VS Code local platform runner (features
55
- // plan Phase 9). Enabled when REVENTLESS_EVENT_TAP is set; off by default so normal
56
- // runs stay quiet. Emitted from publishEvent so every event is captured with its
57
- // real topic name (the EventTopic resource name consumers subscribe to) no need
58
- // to guess topic names from EventLog registry keys. Each line is sentinel-prefixed
59
- // so the runner's line parser can pick it out of the platform's ANSI log noise on
60
- // stdout. The emit is a plain Console.log, so it never perturbs publishEvent's
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
- // Seed the in-memory tap counter from a persistent store's existing event count
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 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
- Console.log("@@RVLESS_EVT@@ " ++ line->JSON.stringify)
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
- if eventTapEnabled() {
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
- console.log("@@RVLESS_EVT@@ " + JSON.stringify(line));
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
- if (Stdlib_Option.isSome(process.env["REVENTLESS_EVENT_TAP"])) {
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
- if (Stdlib_Option.isSome(process.env["REVENTLESS_EVENT_TAP"])) {
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
- if (Stdlib_Option.isSome(process.env["REVENTLESS_EVENT_TAP"])) {
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
- if (Stdlib_Option.isSome(process.env["REVENTLESS_EVENT_TAP"])) {
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
+ })