mulmoterminal 0.1.0 → 0.1.1
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/bin/mulmoterminal.js +107 -38
- package/package.json +1 -1
- package/server/index.ts +15 -0
package/bin/mulmoterminal.js
CHANGED
|
@@ -18,7 +18,10 @@ const PKG_DIR = join(__dirname, "..");
|
|
|
18
18
|
const SERVER_ENTRY = join(PKG_DIR, "server", "index.ts");
|
|
19
19
|
const DEFAULT_PORT = 3456;
|
|
20
20
|
const READY_TIMEOUT_MS = 15_000;
|
|
21
|
-
const
|
|
21
|
+
const MAX_BIND_RETRIES = 5;
|
|
22
|
+
// Server exit code meaning "port taken at bind time" — keep in sync with
|
|
23
|
+
// server/index.ts (PORT_IN_USE_EXIT_CODE).
|
|
24
|
+
const PORT_IN_USE_EXIT_CODE = 75;
|
|
22
25
|
|
|
23
26
|
// Single source of truth: read the version from the shipped package.json so
|
|
24
27
|
// `--version` never drifts from the published version.
|
|
@@ -45,31 +48,48 @@ function pickOpenCommand() {
|
|
|
45
48
|
return "xdg-open";
|
|
46
49
|
}
|
|
47
50
|
|
|
48
|
-
// Resolve with true if nothing is listening on `port`, false otherwise.
|
|
51
|
+
// Resolve with true if nothing is listening on `port`, false otherwise. Binds
|
|
52
|
+
// without a host — same as the server's `server.listen(port)` (the `::`
|
|
53
|
+
// dual-stack address) — so the probe and the real bind agree on availability.
|
|
54
|
+
// Probing 127.0.0.1 here let a port held only on `::` slip through as "free".
|
|
49
55
|
function isPortFree(port) {
|
|
50
56
|
return new Promise((resolve) => {
|
|
51
57
|
const probe = createServer();
|
|
52
58
|
probe.once("error", () => resolve(false));
|
|
53
59
|
probe.once("listening", () => probe.close(() => resolve(true)));
|
|
54
|
-
probe.listen(port
|
|
60
|
+
probe.listen(port);
|
|
55
61
|
});
|
|
56
62
|
}
|
|
57
63
|
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
64
|
+
// Ask the OS for a free port (listen on 0) and return the one it assigned, or
|
|
65
|
+
// null. An effectively-random fallback when the preferred port is taken; the
|
|
66
|
+
// bind-retry in main() closes the small probe-to-bind race so concurrent starts
|
|
67
|
+
// don't clash.
|
|
68
|
+
function findEphemeralPort() {
|
|
69
|
+
return new Promise((resolve) => {
|
|
70
|
+
const probe = createServer();
|
|
71
|
+
probe.once("error", () => resolve(null));
|
|
72
|
+
probe.once("listening", () => {
|
|
73
|
+
const addr = probe.address();
|
|
74
|
+
const assigned = addr && typeof addr === "object" ? addr.port : null;
|
|
75
|
+
probe.close(() => resolve(assigned));
|
|
76
|
+
});
|
|
77
|
+
probe.listen(0);
|
|
78
|
+
});
|
|
63
79
|
}
|
|
64
80
|
|
|
65
81
|
// Poll the server until it answers, then call onReady; give up after the timeout
|
|
66
|
-
// so the launcher never hangs on a crash loop.
|
|
82
|
+
// so the launcher never hangs on a crash loop. Returns a cancel function — a
|
|
83
|
+
// raced/abandoned attempt stops polling so it can't fire a stale banner.
|
|
67
84
|
function waitUntilReady(port, onReady) {
|
|
68
85
|
const startedAt = Date.now();
|
|
86
|
+
let timer = null;
|
|
87
|
+
let cancelled = false;
|
|
69
88
|
const attempt = () => {
|
|
89
|
+
if (cancelled) return;
|
|
70
90
|
const req = httpGet({ host: "127.0.0.1", port, path: "/", timeout: 1000 }, (res) => {
|
|
71
91
|
res.resume();
|
|
72
|
-
onReady();
|
|
92
|
+
if (!cancelled) onReady();
|
|
73
93
|
});
|
|
74
94
|
req.on("error", retry);
|
|
75
95
|
req.on("timeout", () => {
|
|
@@ -78,10 +98,14 @@ function waitUntilReady(port, onReady) {
|
|
|
78
98
|
});
|
|
79
99
|
};
|
|
80
100
|
const retry = () => {
|
|
81
|
-
if (Date.now() - startedAt > READY_TIMEOUT_MS) return;
|
|
82
|
-
setTimeout(attempt, 300);
|
|
101
|
+
if (cancelled || Date.now() - startedAt > READY_TIMEOUT_MS) return;
|
|
102
|
+
timer = setTimeout(attempt, 300);
|
|
83
103
|
};
|
|
84
104
|
attempt();
|
|
105
|
+
return () => {
|
|
106
|
+
cancelled = true;
|
|
107
|
+
if (timer) clearTimeout(timer);
|
|
108
|
+
};
|
|
85
109
|
}
|
|
86
110
|
|
|
87
111
|
function printReadyBanner(url) {
|
|
@@ -111,15 +135,57 @@ async function choosePort(requested, explicit) {
|
|
|
111
135
|
error(`Port ${requested} is already in use. Stop the other process or pick a different --port.`);
|
|
112
136
|
process.exit(1);
|
|
113
137
|
}
|
|
114
|
-
const fallback = await
|
|
138
|
+
const fallback = await findEphemeralPort();
|
|
115
139
|
if (fallback === null) {
|
|
116
|
-
error(`Port ${requested} is in use and no free port found
|
|
140
|
+
error(`Port ${requested} is in use and no free port could be found.`);
|
|
117
141
|
process.exit(1);
|
|
118
142
|
}
|
|
119
143
|
log(`Port ${requested} busy → using ${fallback} instead. (Pass --port <N> to pin.)`);
|
|
120
144
|
return fallback;
|
|
121
145
|
}
|
|
122
146
|
|
|
147
|
+
// Spawn the server on `port` and report the child via `onChild` (so signal
|
|
148
|
+
// handlers target the live process). Resolves only when the server exits because
|
|
149
|
+
// the port was taken at bind time before it became ready — the caller then
|
|
150
|
+
// retries on a fresh port. In every other case (clean shutdown, fatal error,
|
|
151
|
+
// or the server simply running) the process exits with the server's code.
|
|
152
|
+
function runServer(port, noOpen, onChild) {
|
|
153
|
+
return new Promise((resolve) => {
|
|
154
|
+
log(`Starting MulmoTerminal on port ${port}...`);
|
|
155
|
+
const server = spawn(process.execPath, ["--import", "tsx", SERVER_ENTRY], {
|
|
156
|
+
cwd: PKG_DIR,
|
|
157
|
+
env: { ...process.env, NODE_ENV: "production", PORT: String(port) },
|
|
158
|
+
stdio: "inherit",
|
|
159
|
+
});
|
|
160
|
+
onChild(server);
|
|
161
|
+
|
|
162
|
+
const url = `http://localhost:${port}`;
|
|
163
|
+
const cancelReady = waitUntilReady(port, () => {
|
|
164
|
+
printReadyBanner(url);
|
|
165
|
+
if (noOpen) return;
|
|
166
|
+
try {
|
|
167
|
+
// The command is a hardcoded literal; url is http://localhost:<numeric port>.
|
|
168
|
+
// eslint-disable-next-line sonarjs/os-command
|
|
169
|
+
execSync(`${pickOpenCommand()} ${url}`, { stdio: "pipe" });
|
|
170
|
+
} catch {
|
|
171
|
+
log(`Open your browser: ${url}`);
|
|
172
|
+
}
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
server.on("exit", (code) => {
|
|
176
|
+
cancelReady();
|
|
177
|
+
// Exit code 75 means this child failed to bind (EADDRINUSE) and never
|
|
178
|
+
// served — always retriable, regardless of what a probe to the port saw
|
|
179
|
+
// (another process could have answered it). Other exits are terminal.
|
|
180
|
+
if (code === PORT_IN_USE_EXIT_CODE) {
|
|
181
|
+
resolve();
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
process.exit(code ?? 1);
|
|
185
|
+
});
|
|
186
|
+
});
|
|
187
|
+
}
|
|
188
|
+
|
|
123
189
|
async function main() {
|
|
124
190
|
const args = process.argv.slice(2);
|
|
125
191
|
|
|
@@ -128,7 +194,7 @@ async function main() {
|
|
|
128
194
|
Usage: npx mulmoterminal [options]
|
|
129
195
|
|
|
130
196
|
Options:
|
|
131
|
-
--port <number> Server port (default: ${DEFAULT_PORT})
|
|
197
|
+
--port <number> Server port (default: ${DEFAULT_PORT}; a free port is chosen if it's busy)
|
|
132
198
|
--no-open Don't open the browser automatically
|
|
133
199
|
--version Show version
|
|
134
200
|
--help Show this help
|
|
@@ -153,36 +219,39 @@ Options:
|
|
|
153
219
|
}
|
|
154
220
|
|
|
155
221
|
const { requestedPort, portExplicit } = parsePortArg(args);
|
|
156
|
-
const port = await choosePort(requestedPort, portExplicit);
|
|
157
|
-
|
|
158
|
-
log(`Starting MulmoTerminal on port ${port}...`);
|
|
159
|
-
const server = spawn(process.execPath, ["--import", "tsx", SERVER_ENTRY], {
|
|
160
|
-
cwd: PKG_DIR,
|
|
161
|
-
env: { ...process.env, NODE_ENV: "production", PORT: String(port) },
|
|
162
|
-
stdio: "inherit",
|
|
163
|
-
});
|
|
164
|
-
|
|
165
|
-
const url = `http://localhost:${port}`;
|
|
166
222
|
const noOpen = args.includes("--no-open");
|
|
167
|
-
waitUntilReady(port, () => {
|
|
168
|
-
printReadyBanner(url);
|
|
169
|
-
if (noOpen) return;
|
|
170
|
-
try {
|
|
171
|
-
// The command is a hardcoded literal; url is http://localhost:<numeric port>.
|
|
172
|
-
// eslint-disable-next-line sonarjs/os-command
|
|
173
|
-
execSync(`${pickOpenCommand()} ${url}`, { stdio: "pipe" });
|
|
174
|
-
} catch {
|
|
175
|
-
log(`Open your browser: ${url}`);
|
|
176
|
-
}
|
|
177
|
-
});
|
|
178
223
|
|
|
224
|
+
// Registered once; always targets the live child across bind-retries.
|
|
225
|
+
let child = null;
|
|
179
226
|
const shutdown = () => {
|
|
180
|
-
|
|
227
|
+
child?.kill("SIGTERM");
|
|
181
228
|
process.exit(0);
|
|
182
229
|
};
|
|
183
230
|
process.on("SIGINT", shutdown);
|
|
184
231
|
process.on("SIGTERM", shutdown);
|
|
185
|
-
|
|
232
|
+
|
|
233
|
+
// Start on the chosen port; if the server loses the rare probe-to-bind race,
|
|
234
|
+
// fall back to a fresh OS-assigned port and retry. An explicit --port is not
|
|
235
|
+
// second-guessed. `runServer` only returns when the port was raced.
|
|
236
|
+
let port = await choosePort(requestedPort, portExplicit);
|
|
237
|
+
for (let attempt = 0; attempt <= MAX_BIND_RETRIES; attempt++) {
|
|
238
|
+
await runServer(port, noOpen, (c) => {
|
|
239
|
+
child = c;
|
|
240
|
+
});
|
|
241
|
+
if (portExplicit) {
|
|
242
|
+
error(`Port ${port} is already in use. Stop the other process or pick a different --port.`);
|
|
243
|
+
process.exit(1);
|
|
244
|
+
}
|
|
245
|
+
const next = await findEphemeralPort();
|
|
246
|
+
if (next === null) {
|
|
247
|
+
error("No free port available to retry on.");
|
|
248
|
+
process.exit(1);
|
|
249
|
+
}
|
|
250
|
+
log(`Port ${port} was taken at bind time → retrying on ${next}.`);
|
|
251
|
+
port = next;
|
|
252
|
+
}
|
|
253
|
+
error(`Could not bind a free port after ${MAX_BIND_RETRIES + 1} attempts.`);
|
|
254
|
+
process.exit(1);
|
|
186
255
|
}
|
|
187
256
|
|
|
188
257
|
main();
|
package/package.json
CHANGED
package/server/index.ts
CHANGED
|
@@ -906,6 +906,21 @@ wss.on("connection", (ws, req) => {
|
|
|
906
906
|
ws.on("close", () => handleClientClose(entry, ws, sessionId));
|
|
907
907
|
});
|
|
908
908
|
|
|
909
|
+
// Exit code the launcher (bin/mulmoterminal.js) treats as "port was taken at
|
|
910
|
+
// bind time" so it can retry on a fresh port. Keep in sync with the launcher.
|
|
911
|
+
const PORT_IN_USE_EXIT_CODE = 75;
|
|
912
|
+
|
|
913
|
+
// A bind failure (most often the port already in use) must not surface as an
|
|
914
|
+
// unhandled 'error' event / stack trace — exit with a clear message instead.
|
|
915
|
+
server.on("error", (err) => {
|
|
916
|
+
if (hasErrnoCode(err) && err.code === "EADDRINUSE") {
|
|
917
|
+
console.error(`[mulmoterminal] Port ${PORT} is already in use — set PORT=<n> or pass --port <n>.`);
|
|
918
|
+
process.exit(PORT_IN_USE_EXIT_CODE);
|
|
919
|
+
}
|
|
920
|
+
console.error(`[mulmoterminal] server error: ${messageOf(err)}`);
|
|
921
|
+
process.exit(1);
|
|
922
|
+
});
|
|
923
|
+
|
|
909
924
|
server.listen(PORT, () => {
|
|
910
925
|
console.log(`mulmoterminal running at http://localhost:${PORT}`);
|
|
911
926
|
});
|