@reventlessdev/reventless-local 3.0.0-alpha.244 → 3.0.0-alpha.246
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 +22 -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 +33 -0
- package/src/Platform.res.mjs +18 -4
- package/src/ShellConfig.res +20 -2
- package/src/ShellConfig.res.mjs +9 -4
- package/src/UiSlots.res +191 -0
- package/src/UiSlots.res.mjs +91 -0
- package/src/adapter/Api/LocalEvents_Server.res +18 -0
- package/src/adapter/Api/LocalEvents_Server.res.mjs +11 -0
- package/src/adapter/EventHistory/EventHistoryResolvers_GraphQL.res +50 -3
- package/src/adapter/EventHistory/EventHistoryResolvers_GraphQL.res.mjs +62 -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/ShellConfigTest.res +41 -0
- package/tests/ShellConfigTest.res.mjs +40 -17
- package/tests/UiSlotsTest.res +197 -0
- package/tests/UiSlotsTest.res.mjs +196 -0
- package/tests/adapter/EventHistoryResolverTest.res +46 -0
- package/tests/adapter/EventHistoryResolverTest.res.mjs +41 -0
- package/tests/adapter/EventTapTest.res +16 -6
- package/tests/adapter/EventTapTest.res.mjs +5 -1
- package/tests/components/aggregate/AggregateFixtures.res.mjs +1 -1
- package/tests/components/automationslice/AutomationSliceFixtures.res.mjs +2 -2
- package/tests/components/automationslice/AutomationSliceSelfDeadlockFixtures.res.mjs +3 -3
- package/tests/components/automationslice/MixedSourceAutomationSliceFixtures.res.mjs +1 -1
- package/tests/components/commandgenerator/CommandGeneratorFixtures.res.mjs +1 -1
- package/tests/components/commandtopic/CommandTopicFixtures.res.mjs +1 -1
- package/tests/components/commandtopic/CommandTopicStreamFixtures.res.mjs +1 -1
- package/tests/components/dcb/DcbCrossPartitionFixtures.res.mjs +1 -1
- package/tests/components/dcb/DcbFixtures.res.mjs +1 -1
- package/tests/components/extensionpoint/ExtensionPointFixtures.res.mjs +2 -2
- package/tests/components/inboundtranslationslice/InboundTranslationSliceFixtures.res.mjs +1 -1
- package/tests/components/outboundtranslationslice/OutboundTranslationSlicePlatformFixtures.res.mjs +1 -1
- package/tests/components/readmodel/DcbReadModelE2EFixtures.res.mjs +1 -1
|
@@ -8,6 +8,7 @@ import * as Primitive_object from "@rescript/runtime/lib/es6/Primitive_object.js
|
|
|
8
8
|
import * as Message$Reventless from "@reventlessdev/reventless-spec/src/types/Message.res.mjs";
|
|
9
9
|
import * as Logger$ReventlessCore from "@reventlessdev/reventless-core/src/util/Logger.res.mjs";
|
|
10
10
|
import * as QueryDbListQuery$ReventlessCore from "@reventlessdev/reventless-core/src/components/Api/QueryDbListQuery.res.mjs";
|
|
11
|
+
import * as Auth_GraphqlContext$ReventlessLocal from "../Auth/Auth_GraphqlContext.res.mjs";
|
|
11
12
|
import * as Plugin_EventQuerySchema$ReventlessCore from "@reventlessdev/reventless-core/src/plugin/component/Plugin_EventQuerySchema.res.mjs";
|
|
12
13
|
|
|
13
14
|
function comparePosition(a, b) {
|
|
@@ -153,6 +154,62 @@ function readFilter(args) {
|
|
|
153
154
|
};
|
|
154
155
|
}
|
|
155
156
|
|
|
157
|
+
function suppliedFilterKeys(f) {
|
|
158
|
+
return Stdlib_Array.filterMap([
|
|
159
|
+
[
|
|
160
|
+
"entityId",
|
|
161
|
+
Stdlib_Option.isSome(f.entityId)
|
|
162
|
+
],
|
|
163
|
+
[
|
|
164
|
+
"tagKey",
|
|
165
|
+
Stdlib_Option.isSome(f.tagKey)
|
|
166
|
+
],
|
|
167
|
+
[
|
|
168
|
+
"tagValue",
|
|
169
|
+
Stdlib_Option.isSome(f.tagValue)
|
|
170
|
+
],
|
|
171
|
+
[
|
|
172
|
+
"eventTypes",
|
|
173
|
+
Stdlib_Option.isSome(f.eventTypes)
|
|
174
|
+
],
|
|
175
|
+
[
|
|
176
|
+
"user",
|
|
177
|
+
Stdlib_Option.isSome(f.user)
|
|
178
|
+
],
|
|
179
|
+
[
|
|
180
|
+
"timeFrom",
|
|
181
|
+
Stdlib_Option.isSome(f.timeFrom)
|
|
182
|
+
],
|
|
183
|
+
[
|
|
184
|
+
"timeTo",
|
|
185
|
+
Stdlib_Option.isSome(f.timeTo)
|
|
186
|
+
]
|
|
187
|
+
], param => {
|
|
188
|
+
if (param[1]) {
|
|
189
|
+
return param[0];
|
|
190
|
+
}
|
|
191
|
+
});
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function callerOf(ctx) {
|
|
195
|
+
let operation = Stdlib_Option.flatMap(Stdlib_Option.flatMap(Stdlib_Option.flatMap(Stdlib_Option.flatMap(Stdlib_JSON.Decode.object(ctx), d => d["params"]), Stdlib_JSON.Decode.object), d => d["operationName"]), Stdlib_JSON.Decode.string);
|
|
196
|
+
let user = Auth_GraphqlContext$ReventlessLocal.extractIdentity(ctx).userId;
|
|
197
|
+
if (operation !== undefined) {
|
|
198
|
+
return `operation "` + operation + `" as ` + user;
|
|
199
|
+
} else {
|
|
200
|
+
return `an unnamed operation as ` + user;
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function describeCaller(ctx, filter) {
|
|
205
|
+
let keys = suppliedFilterKeys(filter);
|
|
206
|
+
if (keys.length !== 0) {
|
|
207
|
+
return callerOf(ctx) + `, with filter [` + keys.join(", ") + `]`;
|
|
208
|
+
} else {
|
|
209
|
+
return callerOf(ctx) + `, with no filter`;
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
156
213
|
function matchesTag(r, f) {
|
|
157
214
|
let match = f.tagKey;
|
|
158
215
|
let match$1 = f.tagValue;
|
|
@@ -305,7 +362,7 @@ function Make(Bus) {
|
|
|
305
362
|
}
|
|
306
363
|
seen.add(displayName);
|
|
307
364
|
let fieldName = Plugin_EventQuerySchema$ReventlessCore.historyFieldName(params.pluginName, displayName);
|
|
308
|
-
let resolver = async (_root, args,
|
|
365
|
+
let resolver = async (_root, args, ctx) => {
|
|
309
366
|
let f = readFilter(args);
|
|
310
367
|
let replay = Bus.getEventLogReplay(entry.busKey);
|
|
311
368
|
let records;
|
|
@@ -314,7 +371,7 @@ function Make(Bus) {
|
|
|
314
371
|
if (entityId !== undefined) {
|
|
315
372
|
records = await readAggregate(replay, entityId);
|
|
316
373
|
} else {
|
|
317
|
-
log.warn("EventHistoryResolvers_GraphQL", undefined, fieldName + `: ` + displayName + ` is an aggregate event log — it can only be read per entity. Supply filter.entityId
|
|
374
|
+
log.warn("EventHistoryResolvers_GraphQL", undefined, fieldName + `: ` + displayName + ` is an aggregate event log — it can only be read per entity. ` + (`Supply filter.entityId. Asked by ` + describeCaller(ctx, f) + `.`));
|
|
318
375
|
records = [];
|
|
319
376
|
}
|
|
320
377
|
} else {
|
|
@@ -353,6 +410,9 @@ export {
|
|
|
353
410
|
metaJsonFromEnvelope,
|
|
354
411
|
recordJson,
|
|
355
412
|
readFilter,
|
|
413
|
+
suppliedFilterKeys,
|
|
414
|
+
callerOf,
|
|
415
|
+
describeCaller,
|
|
356
416
|
matchesTag,
|
|
357
417
|
matchesFilter,
|
|
358
418
|
paginate,
|
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
|
+
})
|