@reventlessdev/reventless-gwt 1.0.0-alpha.115 → 1.0.0-alpha.116

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 (91) hide show
  1. package/CHANGELOG.md +11 -0
  2. package/package.json +5 -12
  3. package/rescript.json +0 -1
  4. package/bin/reventless-dev.mjs +0 -21
  5. package/src/Bind.res +0 -29
  6. package/src/Bind.res.mjs +0 -22
  7. package/src/BuildClassifier.res +0 -147
  8. package/src/BuildClassifier.res.mjs +0 -136
  9. package/src/Cancellation.res +0 -37
  10. package/src/Cancellation.res.mjs +0 -46
  11. package/src/ChildProcess.res +0 -102
  12. package/src/ChildProcess.res.mjs +0 -107
  13. package/src/Cli.res +0 -889
  14. package/src/Cli.res.mjs +0 -944
  15. package/src/Collector.res +0 -222
  16. package/src/Collector.res.mjs +0 -228
  17. package/src/ComponentMeta.res +0 -77
  18. package/src/ComponentMeta.res.mjs +0 -91
  19. package/src/ComponentScan.res +0 -101
  20. package/src/ComponentScan.res.mjs +0 -92
  21. package/src/Discovery.res +0 -102
  22. package/src/Discovery.res.mjs +0 -86
  23. package/src/DomainDeadCode.res +0 -98
  24. package/src/DomainDeadCode.res.mjs +0 -72
  25. package/src/DomainGraph.res +0 -0
  26. package/src/DomainGraph.res.mjs +0 -0
  27. package/src/ExnMessage.res +0 -14
  28. package/src/ExnMessage.res.mjs +0 -17
  29. package/src/ExnReflect.mjs +0 -24
  30. package/src/Filter.res +0 -45
  31. package/src/Filter.res.mjs +0 -47
  32. package/src/FormatterHuman.res +0 -109
  33. package/src/FormatterHuman.res.mjs +0 -115
  34. package/src/FormatterJson.res +0 -264
  35. package/src/FormatterJson.res.mjs +0 -322
  36. package/src/FormatterJunit.res +0 -93
  37. package/src/FormatterJunit.res.mjs +0 -90
  38. package/src/FormatterTap.res +0 -100
  39. package/src/FormatterTap.res.mjs +0 -131
  40. package/src/FormatterVsCode.res +0 -352
  41. package/src/FormatterVsCode.res.mjs +0 -546
  42. package/src/Loader.res +0 -24
  43. package/src/Loader.res.mjs +0 -23
  44. package/src/PackageScan.res +0 -80
  45. package/src/PackageScan.res.mjs +0 -81
  46. package/src/PlatformRunner.res +0 -191
  47. package/src/PlatformRunner.res.mjs +0 -178
  48. package/src/PlatformScan.res +0 -141
  49. package/src/PlatformScan.res.mjs +0 -124
  50. package/src/ProcessManager.res +0 -135
  51. package/src/ProcessManager.res.mjs +0 -119
  52. package/src/RunWorker.res +0 -24
  53. package/src/RunWorker.res.mjs +0 -16
  54. package/src/RunnerTypes.res +0 -68
  55. package/src/RunnerTypes.res.mjs +0 -60
  56. package/src/ScanIgnore.res +0 -11
  57. package/src/ScanIgnore.res.mjs +0 -23
  58. package/src/Watch.res +0 -102
  59. package/src/Watch.res.mjs +0 -80
  60. package/src/WatcherProbe.res +0 -56
  61. package/src/WatcherProbe.res.mjs +0 -59
  62. package/src/Worker.res +0 -24
  63. package/src/Worker.res.mjs +0 -9
  64. package/tests/BuildClassifierTest.res +0 -133
  65. package/tests/BuildClassifierTest.res.mjs +0 -176
  66. package/tests/CliRunEntryTest.res +0 -57
  67. package/tests/CliRunEntryTest.res.mjs +0 -65
  68. package/tests/CliWatchScopeTest.res +0 -62
  69. package/tests/CliWatchScopeTest.res.mjs +0 -95
  70. package/tests/ComponentMetaTest.res +0 -67
  71. package/tests/ComponentMetaTest.res.mjs +0 -71
  72. package/tests/DiscoveryTest.res +0 -42
  73. package/tests/DiscoveryTest.res.mjs +0 -43
  74. package/tests/DomainDeadCodeTest.res +0 -119
  75. package/tests/DomainDeadCodeTest.res.mjs +0 -158
  76. package/tests/DomainGraphTest.res +0 -448
  77. package/tests/DomainGraphTest.res.mjs +0 -505
  78. package/tests/ExnMessageTest.res +0 -42
  79. package/tests/ExnMessageTest.res.mjs +0 -64
  80. package/tests/FormatterGoldenTest.res +0 -125
  81. package/tests/FormatterGoldenTest.res.mjs +0 -162
  82. package/tests/FormatterVsCodeMessageTest.res +0 -41
  83. package/tests/FormatterVsCodeMessageTest.res.mjs +0 -42
  84. package/tests/PlatformRunnerTest.res +0 -66
  85. package/tests/PlatformRunnerTest.res.mjs +0 -85
  86. package/tests/PlatformScanTest.res +0 -61
  87. package/tests/PlatformScanTest.res.mjs +0 -56
  88. package/tests/RunnerUnitTest.res +0 -219
  89. package/tests/RunnerUnitTest.res.mjs +0 -244
  90. package/tests/WatchDebounceTest.res +0 -106
  91. package/tests/WatchDebounceTest.res.mjs +0 -128
@@ -1,81 +0,0 @@
1
- // Generated by ReScript, PLEASE EDIT WITH CARE
2
-
3
- import * as Nodefs from "node:fs";
4
- import * as Nodepath from "node:path";
5
-
6
- function readPackage(pkgJsonPath) {
7
- try {
8
- let json = JSON.parse(Nodefs.readFileSync(pkgJsonPath, "utf8"));
9
- if (typeof json !== "object" || json === null || Array.isArray(json)) {
10
- return;
11
- }
12
- let match = json["name"];
13
- let name = typeof match === "string" ? match : "";
14
- let match$1 = json["scripts"];
15
- if (match$1 === undefined) {
16
- return;
17
- }
18
- if (typeof match$1 !== "object" || match$1 === null || Array.isArray(match$1)) {
19
- return;
20
- }
21
- let match$2 = match$1["start"];
22
- if (typeof match$2 === "string") {
23
- return [
24
- name,
25
- match$2
26
- ];
27
- } else {
28
- return;
29
- }
30
- } catch (exn) {
31
- return;
32
- }
33
- }
34
-
35
- function findOwning(_dir) {
36
- while (true) {
37
- let dir = _dir;
38
- let pkgJson = Nodepath.join(dir, "package.json");
39
- let here = Nodefs.existsSync(pkgJson) ? readPackage(pkgJson) : undefined;
40
- if (here !== undefined) {
41
- return {
42
- name: here[0],
43
- dir: dir,
44
- build: here[1]
45
- };
46
- }
47
- let parent = Nodepath.dirname(dir);
48
- if (parent === dir) {
49
- return;
50
- }
51
- _dir = parent;
52
- continue;
53
- };
54
- }
55
-
56
- function scan(testFiles) {
57
- let seen = {};
58
- let out = [];
59
- testFiles.forEach(file => {
60
- let pkg = findOwning(Nodepath.dirname(file));
61
- if (pkg === undefined) {
62
- return;
63
- }
64
- let match = seen[pkg.dir];
65
- if (match !== undefined) {
66
- return;
67
- } else {
68
- seen[pkg.dir] = true;
69
- out.push(pkg);
70
- return;
71
- }
72
- });
73
- return out;
74
- }
75
-
76
- export {
77
- readPackage,
78
- findOwning,
79
- scan,
80
- }
81
- /* node:fs Not a pure module */
@@ -1,191 +0,0 @@
1
- // Local platform runner (features plan Phase 9). Spawns an app's reventless-local
2
- // platform as a managed child with the LocalBus NDJSON event tap enabled, on
3
- // per-session free ports, classifies its stdout into structured lifecycle/event
4
- // callbacks, and tears the child down on cancellation. The CLI (`reventless-gwt
5
- // platform`) wires the callbacks to FormatterVsCode; tests drive `classifyLine`
6
- // directly.
7
- //
8
- // Channels (Spike A/B): commands in / state out go to the platform's GraphQL
9
- // server (the runner just reports its endpoint); the live event stream comes from
10
- // the tap on stdout — one sentinel-prefixed JSON line per published event.
11
-
12
- let tapSentinel = "@@RVLESS_EVT@@ "
13
-
14
- @val external _envLogLevel: option<string> = "process.env.LOG_LEVEL"
15
-
16
- type lineClass =
17
- | Domain(JSON.t) // a tap line; the parsed `{event:"domainEvent",…}` payload
18
- | Ready // the Domain GraphQL server's "listening on …" line
19
- | Log(string) // any other stdout/stderr line (the platform's human logs)
20
-
21
- // Pure line classifier — the unit-tested core. ANSI escapes around bracketed
22
- // component tags survive substring checks, so readiness is detected structurally.
23
- // The human-log payload is passed through **with its ANSI intact**: the extension
24
- // renders it in a VS Code Pseudoterminal (xterm), which interprets colour/bold so
25
- // the runner log reads exactly as on the command line. (Plain non-terminal sinks
26
- // that want stripped text — CloudWatch etc. — are the framework's concern; see
27
- // docs/plans/logging-output-optimization.md.)
28
- let classifyLine = (line: string): lineClass =>
29
- if line->String.startsWith(tapSentinel) {
30
- let jsonStr = line->String.slice(~start=tapSentinel->String.length, ~end=line->String.length)
31
- switch JSON.parseOrThrow(jsonStr) {
32
- | exception _ => Log(line)
33
- | json => Domain(json)
34
- }
35
- } else if line->String.includes("GraphQL:Domain") && line->String.includes("listening on") {
36
- Ready
37
- } else {
38
- Log(line)
39
- }
40
-
41
- type callbacks = {
42
- onStart: (~package: string, ~dir: string, ~domainPort: int, ~platformPort: int) => unit,
43
- onReady: (~domainEndpoint: string) => unit,
44
- onDomainEvent: JSON.t => unit,
45
- onLog: string => unit,
46
- onStop: option<int> => unit,
47
- }
48
-
49
- // ── Free-port allocation ────────────────────────────────────────────────────
50
- // One platform child per app folder; default ports (4000/4001/3001/3002) collide
51
- // with a developer's own `dev:full`, so the runner binds ephemeral ports and
52
- // passes them to the child via the env overrides added in Phase 1.
53
- type netServer
54
- @module("node:net") external _createServer: unit => netServer = "createServer"
55
- @send external _listen: (netServer, int, string, unit => unit) => unit = "listen"
56
- @send external _address: netServer => {"port": int} = "address"
57
- @send external _close: (netServer, unit => unit) => unit = "close"
58
-
59
- let openEphemeral = (): promise<(netServer, int)> =>
60
- Promise.make((resolve, _reject) => {
61
- let s = _createServer()
62
- s->_listen(0, "127.0.0.1", () => resolve((s, (s->_address)["port"])))
63
- })
64
-
65
- let closeServer = (s: netServer): promise<unit> =>
66
- Promise.make((resolve, _reject) => s->_close(() => resolve()))
67
-
68
- // Hold all n open while collecting ports so they're guaranteed distinct, then
69
- // release them just before the child binds.
70
- let allocFreePorts = async (n: int): array<int> => {
71
- let servers = []
72
- let ports = []
73
- for _i in 1 to n {
74
- let (s, p) = await openEphemeral()
75
- servers->Array.push(s)
76
- ports->Array.push(p)
77
- }
78
- // Await every close so the ports are actually released before the child binds
79
- // them. Closing fire-and-forget returned the ports while the sockets were
80
- // still bound, racing the child's bind into EADDRINUSE.
81
- let _ = await Promise.all(servers->Array.map(closeServer))
82
- ports
83
- }
84
-
85
- // The platform child's default ports (Platform.startServers): domain / platform /
86
- // domain-MCP / platform-MCP. `~fixedPorts` pins to these instead of allocating
87
- // ephemeral ones — the VS Code extension uses it when launching the host-shell UI,
88
- // whose Vite proxy hardcodes localhost:4000/4001. The caller is responsible for
89
- // ensuring they're free (it pre-checks and toasts on conflict).
90
- let fixedPlatformPorts = [4000, 4001, 3001, 3002]
91
-
92
- // Discover the platform package under `roots`, spawn it with the tap + per-session
93
- // ports + in-memory backend, stream its events through `callbacks`, and stay alive
94
- // until cancelled (the child is killed via its process group on SIGTERM/SIGINT).
95
- let run = async (
96
- ~roots: array<string>,
97
- ~backend: string,
98
- ~fixedPorts: bool=false,
99
- ~callbacks: callbacks,
100
- ): int => {
101
- let pkgs = await PlatformScan.scan(roots)
102
- switch pkgs->Array.get(0) {
103
- | None =>
104
- callbacks.onLog(
105
- "no reventless-local platform package (src/Main.res.mjs + @reventlessdev/reventless-local) found under: " ++
106
- roots->Array.join(", "),
107
- )
108
- 1
109
- | Some(pkg) =>
110
- let ports = fixedPorts ? fixedPlatformPorts : await allocFreePorts(4)
111
- let dPort = ports->Array.getUnsafe(0)
112
- let pPort = ports->Array.getUnsafe(1)
113
- let dMcp = ports->Array.getUnsafe(2)
114
- let pMcp = ports->Array.getUnsafe(3)
115
- // The gwt bin defaults LOG_LEVEL=silent so its OWN stdout stays pure NDJSON;
116
- // the platform child is a separate process whose stdout we parse, so it needs
117
- // real logs — the "listening on" line drives readiness and the rest becomes
118
- // platformLog. Default it to the local platform's own verbosity (debug — same
119
- // as `pnpm run serve`, which relies on reventless-local's Debug default), so
120
- // projection/action debug lines surface in the runner log. An explicit
121
- // LOG_LEVEL the developer set (anything but the bin's silent default) is
122
- // honored, so `LOG_LEVEL=info` still quietens it.
123
- let childLogLevel = switch _envLogLevel {
124
- | Some(l) if l != "silent" && l != "" => l
125
- | _ => "debug"
126
- }
127
- let env = Dict.fromArray([
128
- ("REVENTLESS_EVENT_TAP", "ndjson"),
129
- ("REVENTLESS_LOCAL_BACKEND", backend),
130
- ("REVENTLESS_DOMAIN_PORT", Int.toString(dPort)),
131
- ("REVENTLESS_PLATFORM_PORT", Int.toString(pPort)),
132
- ("REVENTLESS_DOMAIN_MCP_PORT", Int.toString(dMcp)),
133
- ("REVENTLESS_PLATFORM_MCP_PORT", Int.toString(pMcp)),
134
- ("NODE_OPTIONS", "--disable-warning=ExperimentalWarning"),
135
- ("LOG_LEVEL", childLogLevel),
136
- // The child's stdout is a pipe (we line-parse it), so the framework's sink
137
- // detection would auto-pick JSON (non-TTY). But every consumer of `onLog`
138
- // renders ANSI — the CLI forwards to the developer's terminal, the extension
139
- // to an xterm Pseudoterminal — so force human text mode for bold/colour.
140
- // (Tap event lines stay JSON regardless: they're sentinel-prefixed.)
141
- ("REVENTLESS_LOG_FORMAT", "text"),
142
- ])
143
-
144
- callbacks.onStart(~package=pkg.name, ~dir=pkg.dir, ~domainPort=dPort, ~platformPort=pPort)
145
-
146
- // Run the compiled entrypoint directly with `node`. `src/Main.res.mjs` is plain
147
- // ESM, so it needs no tsx/loader, and spawning a single process (vs `pnpm run
148
- // serve` → `pnpm run serve` → `npx tsx`) keeps the child's stdout piped straight
149
- // to us — nested pnpm buffers a long-running grandchild's output, so the event
150
- // tap never streams through it. cwd = the package dir so `PackageVersion.fromCwd`
151
- // and relative paths resolve. We set the backend + tap + ports via env directly,
152
- // so the package's own serve script (which would otherwise pick them) is bypassed.
153
- let proc = ChildProcess.spawn("node", ["src/Main.res.mjs"], ~cwd=pkg.dir, ~env)
154
- Cancellation.onCancel(() => ChildProcess.killTree(proc, "SIGTERM"))
155
-
156
- let readyEmitted = ref(false)
157
- let handle = line =>
158
- switch classifyLine(line) {
159
- | Domain(json) => callbacks.onDomainEvent(json)
160
- | Ready =>
161
- if !readyEmitted.contents {
162
- readyEmitted := true
163
- callbacks.onReady(~domainEndpoint="http://localhost:" ++ Int.toString(dPort) ++ "/graphql")
164
- }
165
- | Log(l) => callbacks.onLog(l)
166
- }
167
- ChildProcess.onStdoutLine(proc, handle)
168
- ChildProcess.onStderrLine(proc, handle)
169
-
170
- // Keep the parent alive until either the operator cancels or the child exits.
171
- // The previous shape awaited cancellation *forever* and always returned 0, so
172
- // a crashed platform child left a zombie parent that reported success. Resolve
173
- // on child close (or a spawn error) too, and surface the child's exit code.
174
- let exitCode = ref(0)
175
- await Promise.make((resolve, _reject) => {
176
- Cancellation.onCancel(() => resolve())
177
- ChildProcess.onError(proc, msg => {
178
- callbacks.onLog("platform spawn error: " ++ msg)
179
- callbacks.onStop(None)
180
- exitCode := 1
181
- resolve()
182
- })
183
- ChildProcess.onClose(proc, code => {
184
- callbacks.onStop(code)
185
- exitCode := code->Option.getOr(0)
186
- resolve()
187
- })
188
- })
189
- exitCode.contents
190
- }
191
- }
@@ -1,178 +0,0 @@
1
- // Generated by ReScript, PLEASE EDIT WITH CARE
2
-
3
- import * as Nodenet from "node:net";
4
- import * as Stdlib_Option from "@rescript/runtime/lib/es6/Stdlib_Option.js";
5
- import * as Cancellation$ReventlessGwt from "./Cancellation.res.mjs";
6
- import * as ChildProcess$ReventlessGwt from "./ChildProcess.res.mjs";
7
- import * as PlatformScan$ReventlessGwt from "./PlatformScan.res.mjs";
8
-
9
- let tapSentinel = "@@RVLESS_EVT@@ ";
10
-
11
- function classifyLine(line) {
12
- if (!line.startsWith(tapSentinel)) {
13
- if (line.includes("GraphQL:Domain") && line.includes("listening on")) {
14
- return "Ready";
15
- } else {
16
- return {
17
- TAG: "Log",
18
- _0: line
19
- };
20
- }
21
- }
22
- let jsonStr = line.slice(tapSentinel.length, line.length);
23
- let json;
24
- try {
25
- json = JSON.parse(jsonStr);
26
- } catch (exn) {
27
- return {
28
- TAG: "Log",
29
- _0: line
30
- };
31
- }
32
- return {
33
- TAG: "Domain",
34
- _0: json
35
- };
36
- }
37
-
38
- function openEphemeral() {
39
- return new Promise((resolve, _reject) => {
40
- let s = Nodenet.createServer();
41
- s.listen(0, "127.0.0.1", () => resolve([
42
- s,
43
- s.address().port
44
- ]));
45
- });
46
- }
47
-
48
- function closeServer(s) {
49
- return new Promise((resolve, _reject) => {
50
- s.close(() => resolve());
51
- });
52
- }
53
-
54
- async function allocFreePorts(n) {
55
- let servers = [];
56
- let ports = [];
57
- for (let _i = 1; _i <= n; ++_i) {
58
- let match = await openEphemeral();
59
- servers.push(match[0]);
60
- ports.push(match[1]);
61
- }
62
- await Promise.all(servers.map(closeServer));
63
- return ports;
64
- }
65
-
66
- let fixedPlatformPorts = [
67
- 4000,
68
- 4001,
69
- 3001,
70
- 3002
71
- ];
72
-
73
- async function run(roots, backend, fixedPortsOpt, callbacks) {
74
- let fixedPorts = fixedPortsOpt !== undefined ? fixedPortsOpt : false;
75
- let pkgs = await PlatformScan$ReventlessGwt.scan(roots);
76
- let pkg = pkgs[0];
77
- if (pkg !== undefined) {
78
- let ports = fixedPorts ? fixedPlatformPorts : await allocFreePorts(4);
79
- let dPort = ports[0];
80
- let pPort = ports[1];
81
- let dMcp = ports[2];
82
- let pMcp = ports[3];
83
- let l = process.env.LOG_LEVEL;
84
- let childLogLevel = l !== undefined && l !== "silent" && l !== "" ? l : "debug";
85
- let env = Object.fromEntries([
86
- [
87
- "REVENTLESS_EVENT_TAP",
88
- "ndjson"
89
- ],
90
- [
91
- "REVENTLESS_LOCAL_BACKEND",
92
- backend
93
- ],
94
- [
95
- "REVENTLESS_DOMAIN_PORT",
96
- dPort.toString()
97
- ],
98
- [
99
- "REVENTLESS_PLATFORM_PORT",
100
- pPort.toString()
101
- ],
102
- [
103
- "REVENTLESS_DOMAIN_MCP_PORT",
104
- dMcp.toString()
105
- ],
106
- [
107
- "REVENTLESS_PLATFORM_MCP_PORT",
108
- pMcp.toString()
109
- ],
110
- [
111
- "NODE_OPTIONS",
112
- "--disable-warning=ExperimentalWarning"
113
- ],
114
- [
115
- "LOG_LEVEL",
116
- childLogLevel
117
- ],
118
- [
119
- "REVENTLESS_LOG_FORMAT",
120
- "text"
121
- ]
122
- ]);
123
- callbacks.onStart(pkg.name, pkg.dir, dPort, pPort);
124
- let proc = ChildProcess$ReventlessGwt.spawn("node", ["src/Main.res.mjs"], pkg.dir, env);
125
- Cancellation$ReventlessGwt.onCancel(() => ChildProcess$ReventlessGwt.killTree(proc, "SIGTERM"));
126
- let readyEmitted = {
127
- contents: false
128
- };
129
- let handle = line => {
130
- let json = classifyLine(line);
131
- if (typeof json !== "object") {
132
- if (!readyEmitted.contents) {
133
- readyEmitted.contents = true;
134
- return callbacks.onReady("http://localhost:" + dPort.toString() + "/graphql");
135
- } else {
136
- return;
137
- }
138
- } else if (json.TAG === "Domain") {
139
- return callbacks.onDomainEvent(json._0);
140
- } else {
141
- return callbacks.onLog(json._0);
142
- }
143
- };
144
- ChildProcess$ReventlessGwt.onStdoutLine(proc, handle);
145
- ChildProcess$ReventlessGwt.onStderrLine(proc, handle);
146
- let exitCode = {
147
- contents: 0
148
- };
149
- await new Promise((resolve, _reject) => {
150
- Cancellation$ReventlessGwt.onCancel(() => resolve());
151
- ChildProcess$ReventlessGwt.onError(proc, msg => {
152
- callbacks.onLog("platform spawn error: " + msg);
153
- callbacks.onStop(undefined);
154
- exitCode.contents = 1;
155
- resolve();
156
- });
157
- ChildProcess$ReventlessGwt.onClose(proc, code => {
158
- callbacks.onStop(code);
159
- exitCode.contents = Stdlib_Option.getOr(code, 0);
160
- resolve();
161
- });
162
- });
163
- return exitCode.contents;
164
- }
165
- callbacks.onLog("no reventless-local platform package (src/Main.res.mjs + @reventlessdev/reventless-local) found under: " + roots.join(", "));
166
- return 1;
167
- }
168
-
169
- export {
170
- tapSentinel,
171
- classifyLine,
172
- openEphemeral,
173
- closeServer,
174
- allocFreePorts,
175
- fixedPlatformPorts,
176
- run,
177
- }
178
- /* node:net Not a pure module */
@@ -1,141 +0,0 @@
1
- // Discovers the local-platform package(s) the runner can launch (features plan
2
- // Phase 9). A platform package is one that (a) depends on
3
- // `@reventlessdev/reventless-local` and (b) carries a compiled run entrypoint at
4
- // `src/Main.res.mjs` (what `tsx`/`node` execute). This mirrors PackageScan's
5
- // role for the watch session, but keys off the run entrypoint rather than test
6
- // files. The matching predicate is pure so it can be unit-tested without a real
7
- // workspace tree.
8
-
9
- type platformPkg = {
10
- // Package name from package.json (may be "" if absent).
11
- name: string,
12
- // Absolute package directory (the one containing package.json + src/).
13
- dir: string,
14
- // Absolute path to the compiled run entrypoint (src/Main.res.mjs).
15
- mainPath: string,
16
- // The npm-script name to launch, preferring an in-memory variant: "serve:memory"
17
- // if declared, else "serve". None ⇒ no serve script (runner falls back to running
18
- // src/Main.res.mjs via tsx directly).
19
- serveScript: option<string>,
20
- }
21
-
22
- type dirent = {
23
- name: string,
24
- @as("isDirectory") _isDirectory: unit => bool,
25
- }
26
- type readdirOpts = {withFileTypes: bool}
27
- @module("node:fs/promises")
28
- external _readdir: (string, readdirOpts) => promise<array<dirent>> = "readdir"
29
- @module("node:fs") external _existsSync: string => bool = "existsSync"
30
- @module("node:fs") external _readFileSync: (string, string) => string = "readFileSync"
31
- @module("node:path") external join: (string, string) => string = "join"
32
-
33
- let localPlatformDep = "@reventlessdev/reventless-local"
34
-
35
- // Pure predicate: given a package.json's text and whether src/Main.res.mjs exists,
36
- // decide whether this is a launchable platform package and surface its name +
37
- // serve script. Depends on reventless-local appearing in any dependency block.
38
- let matchPlatform = (
39
- ~pkgJsonText: string,
40
- ~mainExists: bool,
41
- ): option<(string, option<string>)> => {
42
- if !mainExists {
43
- None
44
- } else {
45
- switch JSON.parseOrThrow(pkgJsonText) {
46
- | exception _ => None
47
- | json =>
48
- switch json->JSON.Decode.object {
49
- | None => None
50
- | Some(obj) =>
51
- let name = switch obj->Dict.get("name") {
52
- | Some(String(n)) => n
53
- | _ => ""
54
- }
55
- let depsHave = key =>
56
- switch obj->Dict.get(key)->Option.flatMap(JSON.Decode.object) {
57
- | Some(deps) => deps->Dict.get(localPlatformDep)->Option.isSome
58
- | None => false
59
- }
60
- let dependsOnLocal =
61
- depsHave("dependencies") ||
62
- depsHave("devDependencies") ||
63
- depsHave("optionalDependencies")
64
- if dependsOnLocal {
65
- let serveScript =
66
- switch obj->Dict.get("scripts")->Option.flatMap(JSON.Decode.object) {
67
- | Some(scripts) =>
68
- if scripts->Dict.get("serve:memory")->Option.isSome {
69
- Some("serve:memory")
70
- } else if scripts->Dict.get("serve")->Option.isSome {
71
- Some("serve")
72
- } else {
73
- None
74
- }
75
- | None => None
76
- }
77
- Some((name, serveScript))
78
- } else {
79
- None
80
- }
81
- }
82
- }
83
- }
84
- }
85
-
86
- // Inspect a single directory: is it a platform package?
87
- let inspectDir = (dir: string): option<platformPkg> => {
88
- let pkgJson = join(dir, "package.json")
89
- let mainPath = join(join(dir, "src"), "Main.res.mjs")
90
- if !_existsSync(pkgJson) {
91
- None
92
- } else {
93
- let text = try _readFileSync(pkgJson, "utf8") catch {
94
- | _ => ""
95
- }
96
- switch matchPlatform(~pkgJsonText=text, ~mainExists=_existsSync(mainPath)) {
97
- | Some((name, serveScript)) => Some({name, dir, mainPath, serveScript})
98
- | None => None
99
- }
100
- }
101
- }
102
-
103
- // Walk the roots, returning every platform package found (deduped by dir,
104
- // declaration order preserved). Prunes node_modules/lib/.git like Discovery.
105
- let rec walk = async (dir: string, acc: array<platformPkg>): array<platformPkg> => {
106
- switch inspectDir(dir) {
107
- // A platform package — record it and stop: descending into its own subtree
108
- // (src/, generated code, nested workspace copies) can only waste I/O or turn
109
- // up spurious inner matches. This is what the "don't descend" comment always
110
- // intended; the recursion below used to run regardless.
111
- | Some(pkg) => acc->Array.push(pkg)
112
- | None =>
113
- let entries = try await _readdir(dir, {withFileTypes: true}) catch {
114
- | _ => []
115
- }
116
- for i in 0 to entries->Array.length - 1 {
117
- let entry = entries->Array.getUnsafe(i)
118
- if entry._isDirectory() && !ScanIgnore.shouldIgnore(entry.name) {
119
- let _ = await walk(join(dir, entry.name), acc)
120
- }
121
- }
122
- }
123
- acc
124
- }
125
-
126
- let scan = async (roots: array<string>): array<platformPkg> => {
127
- let seen = Dict.make()
128
- let out = []
129
- for i in 0 to roots->Array.length - 1 {
130
- let found = await walk(roots->Array.getUnsafe(i), [])
131
- found->Array.forEach(pkg =>
132
- switch seen->Dict.get(pkg.dir) {
133
- | Some(_) => ()
134
- | None =>
135
- seen->Dict.set(pkg.dir, true)
136
- out->Array.push(pkg)
137
- }
138
- )
139
- }
140
- out
141
- }