portless-studio 0.1.2 → 0.1.3
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/dist/cli/cli-args.js +41 -12
- package/dist/cli/env-command.js +34 -0
- package/dist/cli/index.js +19 -0
- package/dist/cli/mcp/studio-tools.js +50 -0
- package/dist/core/config/studio-config.js +2 -0
- package/dist/core/ports/helper-ports.js +86 -0
- package/dist/core/routes/env-lines.js +72 -0
- package/dist/server/application/port-discovery.js +36 -4
- package/dist/server/application/studio-runtime.js +36 -17
- package/dist/server/infrastructure/system/ephemeral-ports.js +62 -0
- package/dist/server/infrastructure/system/system-port-scanner.js +64 -4
- package/dist/server/main.js +2 -0
- package/dist/web/assets/index-9WPxv6pu.js +17 -0
- package/dist/web/assets/{index-C9wkL1J8.css → index-Bckc4cIr.css} +1 -1
- package/dist/web/index.html +2 -2
- package/package.json +1 -1
- package/dist/web/assets/index-BDEwEBPS.js +0 -15
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { DEFAULT_EPHEMERAL_RANGES } from "../../../core/ports/helper-ports.js";
|
|
2
|
+
/** Reads `/proc/sys/net/ipv4/ip_local_port_range`: two numbers separated by whitespace. */
|
|
3
|
+
export function parseLinuxPortRange(text) {
|
|
4
|
+
const [first, last] = text.trim().split(/\s+/).map(Number);
|
|
5
|
+
return validRange(first, last);
|
|
6
|
+
}
|
|
7
|
+
/** Reads `sysctl -n net.inet.ip.portrange.first net.inet.ip.portrange.last`: one number per line. */
|
|
8
|
+
export function parseSysctlPortRange(text) {
|
|
9
|
+
return parseLinuxPortRange(text);
|
|
10
|
+
}
|
|
11
|
+
/** Reads `netsh int ipv4 show dynamicport tcp`: a start port and a number of ports. */
|
|
12
|
+
export function parseNetshDynamicPorts(text) {
|
|
13
|
+
const numbers = [...text.matchAll(/:\s*(\d+)/g)].map((match) => Number(match[1]));
|
|
14
|
+
const [start, count] = numbers;
|
|
15
|
+
if (start === undefined || count === undefined || count < 1)
|
|
16
|
+
return null;
|
|
17
|
+
return validRange(start, start + count - 1);
|
|
18
|
+
}
|
|
19
|
+
function validRange(first, last) {
|
|
20
|
+
if (first === undefined || last === undefined)
|
|
21
|
+
return null;
|
|
22
|
+
if (!Number.isInteger(first) || !Number.isInteger(last))
|
|
23
|
+
return null;
|
|
24
|
+
if (first < 1 || last > 65535 || first > last)
|
|
25
|
+
return null;
|
|
26
|
+
return { first, last };
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* The range the system hands out for port 0, read once and kept: it only changes when someone
|
|
30
|
+
* edits a kernel setting. Falls back to the platform default when it cannot be read.
|
|
31
|
+
*/
|
|
32
|
+
export function ephemeralRangeReader(platform, io) {
|
|
33
|
+
let reading = null;
|
|
34
|
+
return () => {
|
|
35
|
+
reading ??= readEphemeralRange(platform, io).catch(() => fallbackRange(platform));
|
|
36
|
+
return reading;
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
async function readEphemeralRange(platform, io) {
|
|
40
|
+
let range = null;
|
|
41
|
+
if (platform === "linux") {
|
|
42
|
+
range = parseLinuxPortRange(io.readFile("/proc/sys/net/ipv4/ip_local_port_range") ?? "");
|
|
43
|
+
}
|
|
44
|
+
else if (platform === "darwin") {
|
|
45
|
+
const out = await io.run("sysctl", [
|
|
46
|
+
"-n",
|
|
47
|
+
"net.inet.ip.portrange.first",
|
|
48
|
+
"net.inet.ip.portrange.last",
|
|
49
|
+
]);
|
|
50
|
+
range = parseSysctlPortRange(out.stdout);
|
|
51
|
+
}
|
|
52
|
+
else if (platform === "win32") {
|
|
53
|
+
const out = await io.run("netsh", ["int", "ipv4", "show", "dynamicport", "tcp"]);
|
|
54
|
+
range = parseNetshDynamicPorts(out.stdout);
|
|
55
|
+
}
|
|
56
|
+
return range ?? fallbackRange(platform);
|
|
57
|
+
}
|
|
58
|
+
function fallbackRange(platform) {
|
|
59
|
+
if (platform === "darwin" || platform === "win32")
|
|
60
|
+
return DEFAULT_EPHEMERAL_RANGES[platform];
|
|
61
|
+
return DEFAULT_EPHEMERAL_RANGES.linux;
|
|
62
|
+
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { request } from "node:http";
|
|
2
2
|
import { STUDIO_HEADER } from "../../../core/api/contract.js";
|
|
3
|
+
import { isDebuggerVersionInfo } from "../../../core/ports/helper-ports.js";
|
|
3
4
|
import { listLinuxPorts } from "./linux-ports.js";
|
|
4
5
|
import { listMacosPorts } from "./macos-ports.js";
|
|
5
6
|
import { nodeSystemIo } from "./system-io.js";
|
|
@@ -30,6 +31,7 @@ export class SystemPortScanner {
|
|
|
30
31
|
#timeoutMs;
|
|
31
32
|
#pidByPort = new Map();
|
|
32
33
|
#answers = new Map();
|
|
34
|
+
#debuggers = new Map();
|
|
33
35
|
constructor(list, timeoutMs = PROBE_TIMEOUT_MS) {
|
|
34
36
|
this.#list = list;
|
|
35
37
|
this.#timeoutMs = timeoutMs;
|
|
@@ -44,13 +46,28 @@ export class SystemPortScanner {
|
|
|
44
46
|
}
|
|
45
47
|
this.#pidByPort = new Map(ports.map((port) => [port.port, port.pid]));
|
|
46
48
|
// Forget answers for processes that are gone, so the cache does not grow forever.
|
|
47
|
-
for (const
|
|
48
|
-
const
|
|
49
|
-
|
|
50
|
-
this.#
|
|
49
|
+
for (const cache of [this.#answers, this.#debuggers]) {
|
|
50
|
+
for (const key of cache.keys()) {
|
|
51
|
+
const [port, pid] = key.split(":");
|
|
52
|
+
if (String(this.#pidByPort.get(Number(port))) !== pid)
|
|
53
|
+
cache.delete(key);
|
|
54
|
+
}
|
|
51
55
|
}
|
|
52
56
|
return ports;
|
|
53
57
|
}
|
|
58
|
+
/** Asked once per port and process, like `answersHttp`. */
|
|
59
|
+
probeDebugger(port) {
|
|
60
|
+
const pid = this.#pidByPort.get(port) ?? null;
|
|
61
|
+
if (pid === null)
|
|
62
|
+
return probeDebuggerPort(port, this.#timeoutMs);
|
|
63
|
+
const key = `${port}:${pid}`;
|
|
64
|
+
let answer = this.#debuggers.get(key);
|
|
65
|
+
if (!answer) {
|
|
66
|
+
answer = probeDebuggerPort(port, this.#timeoutMs);
|
|
67
|
+
this.#debuggers.set(key, answer);
|
|
68
|
+
}
|
|
69
|
+
return answer;
|
|
70
|
+
}
|
|
54
71
|
answersHttp(port) {
|
|
55
72
|
const pid = this.#pidByPort.get(port) ?? null;
|
|
56
73
|
// Without a pid we cannot tell a restarted process apart, so the answer is not kept.
|
|
@@ -65,6 +82,49 @@ export class SystemPortScanner {
|
|
|
65
82
|
return answer;
|
|
66
83
|
}
|
|
67
84
|
}
|
|
85
|
+
/** A `/json/version` answer is a few hundred bytes; anything longer is not a debugger. */
|
|
86
|
+
const MAX_VERSION_BODY = 16_384;
|
|
87
|
+
/**
|
|
88
|
+
* Asks `GET /json/version`, which Node's inspector and Chrome DevTools answer with JSON naming
|
|
89
|
+
* the browser and protocol. `other` when the port answered something else, `unknown` when
|
|
90
|
+
* nothing answered in time.
|
|
91
|
+
*/
|
|
92
|
+
export async function probeDebuggerPort(port, timeoutMs = PROBE_TIMEOUT_MS) {
|
|
93
|
+
for (const host of LOOPBACK_HOSTS) {
|
|
94
|
+
const body = await getText(host, port, "/json/version", timeoutMs);
|
|
95
|
+
if (body === null)
|
|
96
|
+
continue;
|
|
97
|
+
try {
|
|
98
|
+
return isDebuggerVersionInfo(JSON.parse(body)) ? "debugger" : "other";
|
|
99
|
+
}
|
|
100
|
+
catch {
|
|
101
|
+
return "other";
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
return "unknown";
|
|
105
|
+
}
|
|
106
|
+
function getText(host, port, path, timeoutMs) {
|
|
107
|
+
return new Promise((resolve) => {
|
|
108
|
+
const options = { host, port, method: "GET", path, timeout: timeoutMs, agent: false };
|
|
109
|
+
const req = request(options, (res) => {
|
|
110
|
+
let body = "";
|
|
111
|
+
res.setEncoding("utf8");
|
|
112
|
+
res.on("data", (chunk) => {
|
|
113
|
+
body += chunk;
|
|
114
|
+
if (body.length <= MAX_VERSION_BODY)
|
|
115
|
+
return;
|
|
116
|
+
resolve("");
|
|
117
|
+
req.destroy();
|
|
118
|
+
});
|
|
119
|
+
res.on("end", () => resolve(body));
|
|
120
|
+
res.on("error", () => resolve(body));
|
|
121
|
+
});
|
|
122
|
+
req.on("timeout", () => req.destroy());
|
|
123
|
+
req.on("error", () => resolve(null));
|
|
124
|
+
req.on("close", () => resolve(null));
|
|
125
|
+
req.end();
|
|
126
|
+
});
|
|
127
|
+
}
|
|
68
128
|
/**
|
|
69
129
|
* True when anything on loopback answers `HEAD /` with an HTTP response, whatever its status,
|
|
70
130
|
* unless the response comes from a Portless Studio.
|
package/dist/server/main.js
CHANGED
|
@@ -24,6 +24,7 @@ import { PortlessRegistry } from "./infrastructure/portless/portless-registry.js
|
|
|
24
24
|
import { inspectRoutesFile, resolveStateDir } from "./infrastructure/portless/state-files.js";
|
|
25
25
|
import { DockerContainers } from "./infrastructure/system/docker-containers.js";
|
|
26
26
|
import { collectDoctorFacts, proxyPidsAmong, reachesPortless, testBind, } from "./infrastructure/system/doctor-facts.js";
|
|
27
|
+
import { ephemeralRangeReader } from "./infrastructure/system/ephemeral-ports.js";
|
|
27
28
|
import { FaviconFetcher } from "./infrastructure/system/favicon-fetcher.js";
|
|
28
29
|
import { fetchLatestVersion } from "./infrastructure/system/npm-registry.js";
|
|
29
30
|
import { OsNotifier } from "./infrastructure/system/os-notifier.js";
|
|
@@ -82,6 +83,7 @@ export function createStudioServices(options = {}) {
|
|
|
82
83
|
routeProjectStore: new JsonRouteProjectStore(routeProjectsPathFor(configPath)),
|
|
83
84
|
containers,
|
|
84
85
|
shortUrls,
|
|
86
|
+
ephemeralRange: ephemeralRangeReader(process.platform, nodeSystemIo),
|
|
85
87
|
});
|
|
86
88
|
const openWith = new OpenWith({
|
|
87
89
|
catalog: systemAppCatalog(),
|