portless-studio 0.1.1 → 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/README.md +60 -218
- 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 +70 -7
- package/dist/cli/studio-items.js +5 -2
- package/dist/core/api/contract.js +17 -1
- package/dist/core/config/studio-config.js +6 -0
- package/dist/core/doctor/platform-commands.js +5 -2
- package/dist/core/ports/direct-url.js +7 -0
- package/dist/core/ports/docker-container.js +85 -0
- package/dist/core/ports/helper-ports.js +86 -0
- package/dist/core/routes/env-lines.js +72 -0
- package/dist/core/routes/route.js +14 -2
- package/dist/server/application/container-source.js +19 -0
- package/dist/server/application/doctor.js +199 -9
- package/dist/server/application/port-discovery.js +84 -9
- package/dist/server/application/short-urls.js +136 -0
- package/dist/server/application/studio-runtime.js +82 -24
- package/dist/server/application/terminals.js +142 -15
- package/dist/server/infrastructure/system/docker-containers.js +64 -0
- package/dist/server/infrastructure/system/doctor-facts.js +43 -1
- package/dist/server/infrastructure/system/ephemeral-ports.js +62 -0
- package/dist/server/infrastructure/system/port-forwarder.js +78 -0
- package/dist/server/infrastructure/system/process-runner.js +2 -1
- package/dist/server/infrastructure/system/system-port-scanner.js +64 -4
- package/dist/server/infrastructure/terminal/process-tree.js +71 -0
- package/dist/server/infrastructure/terminal/terminal-spawner.js +44 -3
- package/dist/server/interface/http/http-server.js +38 -2
- package/dist/server/main.js +43 -6
- package/dist/web/assets/index-9WPxv6pu.js +17 -0
- package/dist/web/assets/index-Bckc4cIr.css +1 -0
- package/dist/web/index.html +2 -2
- package/package.json +1 -1
- package/dist/web/assets/index-B3KMqQ-l.js +0 -15
- package/dist/web/assets/index-CWujmTe4.css +0 -1
|
@@ -23,6 +23,8 @@ export class TerminalManager {
|
|
|
23
23
|
#graceMs;
|
|
24
24
|
#maxTerminals;
|
|
25
25
|
#namingTimeoutMs;
|
|
26
|
+
#processTree;
|
|
27
|
+
#homeDir;
|
|
26
28
|
#entries = new Map();
|
|
27
29
|
#watchers = new Set();
|
|
28
30
|
/** Folders a local program ran a terminal in. */
|
|
@@ -30,6 +32,8 @@ export class TerminalManager {
|
|
|
30
32
|
#nextId = 1;
|
|
31
33
|
#stopWorkspaceWatch;
|
|
32
34
|
#naming = false;
|
|
35
|
+
#refreshingPorts = false;
|
|
36
|
+
#portsAgain = false;
|
|
33
37
|
constructor(options) {
|
|
34
38
|
this.#spawner = options.spawner;
|
|
35
39
|
this.#workspace = options.workspace;
|
|
@@ -39,6 +43,8 @@ export class TerminalManager {
|
|
|
39
43
|
this.#graceMs = options.graceMs ?? DEFAULT_GRACE_MS;
|
|
40
44
|
this.#maxTerminals = options.maxTerminals ?? DEFAULT_MAX_TERMINALS;
|
|
41
45
|
this.#namingTimeoutMs = options.namingTimeoutMs ?? DEFAULT_NAMING_TIMEOUT_MS;
|
|
46
|
+
this.#processTree = options.processTree;
|
|
47
|
+
this.#homeDir = options.homeDir;
|
|
42
48
|
}
|
|
43
49
|
get mode() {
|
|
44
50
|
return this.#spawner.mode;
|
|
@@ -53,7 +59,21 @@ export class TerminalManager {
|
|
|
53
59
|
/** Calls `onChange` when a terminal starts, ends, is named or closes. */
|
|
54
60
|
watch(onChange) {
|
|
55
61
|
this.#watchers.add(onChange);
|
|
56
|
-
|
|
62
|
+
this.#updateWorkspaceWatch();
|
|
63
|
+
return () => {
|
|
64
|
+
this.#watchers.delete(onChange);
|
|
65
|
+
this.#updateWorkspaceWatch();
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
/** Gives the terminal a name of its own; an empty one gives back the default title. */
|
|
69
|
+
rename(id, title) {
|
|
70
|
+
const entry = this.#entries.get(id);
|
|
71
|
+
if (!entry)
|
|
72
|
+
return notFound(id);
|
|
73
|
+
const wanted = title.trim().slice(0, MAX_TITLE_CHARS);
|
|
74
|
+
entry.info = { ...entry.info, title: wanted || entry.defaultTitle };
|
|
75
|
+
this.#publishStatus(entry);
|
|
76
|
+
return ok({ ...entry.info });
|
|
57
77
|
}
|
|
58
78
|
async create(request, origin) {
|
|
59
79
|
if (this.#entries.size >= this.#maxTerminals) {
|
|
@@ -71,14 +91,20 @@ export class TerminalManager {
|
|
|
71
91
|
const cols = readSize(request.cols, DEFAULT_COLS);
|
|
72
92
|
const rows = readSize(request.rows, DEFAULT_ROWS);
|
|
73
93
|
const naming = readNaming(request.name);
|
|
74
|
-
const
|
|
94
|
+
const defaultTitle = (command.value || this.#spawner.shellName || "shell").slice(0, MAX_TITLE_CHARS);
|
|
95
|
+
const title = request.title?.trim().slice(0, MAX_TITLE_CHARS) || defaultTitle;
|
|
96
|
+
const project = await this.#projectOf(cwd.value);
|
|
75
97
|
const entry = {
|
|
98
|
+
defaultTitle,
|
|
76
99
|
info: {
|
|
77
100
|
id,
|
|
78
101
|
title,
|
|
79
102
|
cwd: cwd.value,
|
|
80
103
|
command: command.value ?? "",
|
|
81
|
-
project:
|
|
104
|
+
project: project?.name ?? null,
|
|
105
|
+
worktree: project?.worktree ?? null,
|
|
106
|
+
branch: project?.branch ?? null,
|
|
107
|
+
ports: [],
|
|
82
108
|
status: "running",
|
|
83
109
|
exitCode: null,
|
|
84
110
|
startedAt: this.#now(),
|
|
@@ -117,7 +143,11 @@ export class TerminalManager {
|
|
|
117
143
|
this.#entries.set(id, entry);
|
|
118
144
|
if (naming)
|
|
119
145
|
this.#startNaming(entry);
|
|
146
|
+
else
|
|
147
|
+
this.#updateWorkspaceWatch();
|
|
120
148
|
this.#changed();
|
|
149
|
+
if (this.#watchers.size > 0)
|
|
150
|
+
void this.#refreshPorts();
|
|
121
151
|
return ok({ ...entry.info });
|
|
122
152
|
}
|
|
123
153
|
input(id, text) {
|
|
@@ -157,6 +187,9 @@ export class TerminalManager {
|
|
|
157
187
|
entry.info = { ...entry.info, status: "running", exitCode: null, endedAt: null };
|
|
158
188
|
entry.info.startedAt = this.#now();
|
|
159
189
|
this.#publishStatus(entry);
|
|
190
|
+
this.#updateWorkspaceWatch();
|
|
191
|
+
if (this.#watchers.size > 0)
|
|
192
|
+
void this.#refreshPorts();
|
|
160
193
|
return ok({ ...entry.info });
|
|
161
194
|
}
|
|
162
195
|
/** Closes the terminal; its command, if still running, is killed. */
|
|
@@ -170,7 +203,7 @@ export class TerminalManager {
|
|
|
170
203
|
entry.process.kill();
|
|
171
204
|
entry.process = null;
|
|
172
205
|
entry.watchers.clear();
|
|
173
|
-
this.#
|
|
206
|
+
this.#updateWorkspaceWatch();
|
|
174
207
|
this.#changed();
|
|
175
208
|
return ok(undefined);
|
|
176
209
|
}
|
|
@@ -232,7 +265,9 @@ export class TerminalManager {
|
|
|
232
265
|
message: "The command ended before it opened a port.",
|
|
233
266
|
});
|
|
234
267
|
}
|
|
268
|
+
entry.info.ports = [];
|
|
235
269
|
this.#publishStatus(entry);
|
|
270
|
+
this.#updateWorkspaceWatch();
|
|
236
271
|
});
|
|
237
272
|
return ok(undefined);
|
|
238
273
|
}
|
|
@@ -270,6 +305,8 @@ export class TerminalManager {
|
|
|
270
305
|
watcher();
|
|
271
306
|
}
|
|
272
307
|
async #resolveFolder(request, origin) {
|
|
308
|
+
if (origin.picked)
|
|
309
|
+
return this.#pickedFolder(request.cwd);
|
|
273
310
|
if (typeof request.project === "string" && request.project !== "" && !request.cwd) {
|
|
274
311
|
const folder = await this.#projectFolder(request.project);
|
|
275
312
|
if (folder === null) {
|
|
@@ -294,6 +331,24 @@ export class TerminalManager {
|
|
|
294
331
|
return ok(folder);
|
|
295
332
|
return err(appError("path_not_allowed", "Terminals run only in project folders Studio shows. Start it from the CLI to use another folder."));
|
|
296
333
|
}
|
|
334
|
+
/** A folder typed in the page's picker: any existing folder, with `~` as the home folder. */
|
|
335
|
+
#pickedFolder(typed) {
|
|
336
|
+
if (typeof typed !== "string" || typed.trim() === "") {
|
|
337
|
+
return err(appError("invalid_request", "Give the folder to open."));
|
|
338
|
+
}
|
|
339
|
+
let wanted = typed.trim();
|
|
340
|
+
if (this.#homeDir && (wanted === "~" || /^~[\\/]/.test(wanted))) {
|
|
341
|
+
wanted = `${this.#homeDir}${wanted.slice(1)}`;
|
|
342
|
+
}
|
|
343
|
+
if (!isAbsolute(wanted)) {
|
|
344
|
+
return err(appError("invalid_request", "The folder must be an absolute path or start with ~."));
|
|
345
|
+
}
|
|
346
|
+
const folder = this.#folders.directory(wanted);
|
|
347
|
+
if (folder === null) {
|
|
348
|
+
return err(appError("path_not_allowed", `${wanted} is not a folder on this machine.`));
|
|
349
|
+
}
|
|
350
|
+
return ok(folder);
|
|
351
|
+
}
|
|
297
352
|
async #projectFolder(name) {
|
|
298
353
|
const { routes, detected } = await this.#workspace.snapshot();
|
|
299
354
|
for (const item of [...routes, ...detected]) {
|
|
@@ -303,6 +358,7 @@ export class TerminalManager {
|
|
|
303
358
|
}
|
|
304
359
|
return null;
|
|
305
360
|
}
|
|
361
|
+
/** The project whose folder holds `cwd`, the innermost one when several do. */
|
|
306
362
|
async #projectOf(cwd) {
|
|
307
363
|
const { routes, detected } = await this.#workspace.snapshot();
|
|
308
364
|
let best = null;
|
|
@@ -310,10 +366,70 @@ export class TerminalManager {
|
|
|
310
366
|
const path = item.project?.path;
|
|
311
367
|
if (!item.project || !path || !this.#folders.contains(path, cwd))
|
|
312
368
|
continue;
|
|
313
|
-
if (!best || path.length > best.length)
|
|
314
|
-
best =
|
|
369
|
+
if (!best?.path || path.length > best.path.length)
|
|
370
|
+
best = item.project;
|
|
371
|
+
}
|
|
372
|
+
return best;
|
|
373
|
+
}
|
|
374
|
+
/**
|
|
375
|
+
* Links every listening process to the running terminal it descends from, and publishes the
|
|
376
|
+
* terminals whose ports changed. Calls that arrive while one runs make it run once more.
|
|
377
|
+
*/
|
|
378
|
+
async #refreshPorts() {
|
|
379
|
+
const tree = this.#processTree;
|
|
380
|
+
if (!tree)
|
|
381
|
+
return;
|
|
382
|
+
if (this.#refreshingPorts) {
|
|
383
|
+
this.#portsAgain = true;
|
|
384
|
+
return;
|
|
385
|
+
}
|
|
386
|
+
this.#refreshingPorts = true;
|
|
387
|
+
try {
|
|
388
|
+
do {
|
|
389
|
+
this.#portsAgain = false;
|
|
390
|
+
await this.#linkPorts(tree);
|
|
391
|
+
} while (this.#portsAgain);
|
|
392
|
+
}
|
|
393
|
+
finally {
|
|
394
|
+
this.#refreshingPorts = false;
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
async #linkPorts(tree) {
|
|
398
|
+
const running = [...this.#entries.values()].filter((entry) => entry.info.status === "running" && entry.info.pid !== null);
|
|
399
|
+
if (running.length === 0)
|
|
400
|
+
return;
|
|
401
|
+
const { routes, detected } = await this.#workspace.snapshot();
|
|
402
|
+
const listeners = new Map();
|
|
403
|
+
for (const item of detected) {
|
|
404
|
+
if (item.pid !== null)
|
|
405
|
+
listeners.set(item.port, { port: item.port, route: null, url: null });
|
|
406
|
+
}
|
|
407
|
+
const pids = new Map();
|
|
408
|
+
for (const item of detected)
|
|
409
|
+
if (item.pid !== null)
|
|
410
|
+
pids.set(item.port, item.pid);
|
|
411
|
+
for (const item of routes) {
|
|
412
|
+
if (item.listenerPid === undefined)
|
|
413
|
+
continue;
|
|
414
|
+
pids.set(item.port, item.listenerPid);
|
|
415
|
+
listeners.set(item.port, { port: item.port, route: item.name, url: item.url });
|
|
416
|
+
}
|
|
417
|
+
const ancestry = new Map();
|
|
418
|
+
for (const pid of new Set(pids.values())) {
|
|
419
|
+
ancestry.set(pid, await tree.ancestors(pid).catch(() => []));
|
|
420
|
+
}
|
|
421
|
+
for (const entry of running) {
|
|
422
|
+
const pid = entry.info.pid;
|
|
423
|
+
const ports = [...pids]
|
|
424
|
+
.filter(([, listener]) => listener === pid || ancestry.get(listener)?.includes(pid ?? -1))
|
|
425
|
+
.map(([port]) => listeners.get(port))
|
|
426
|
+
.filter((port) => port !== undefined)
|
|
427
|
+
.sort((a, b) => a.port - b.port);
|
|
428
|
+
if (JSON.stringify(ports) === JSON.stringify(entry.info.ports))
|
|
429
|
+
continue;
|
|
430
|
+
entry.info = { ...entry.info, ports };
|
|
431
|
+
this.#publishStatus(entry);
|
|
315
432
|
}
|
|
316
|
-
return best?.name ?? null;
|
|
317
433
|
}
|
|
318
434
|
async #portsInUse() {
|
|
319
435
|
const { routes, detected } = await this.#workspace.snapshot();
|
|
@@ -330,18 +446,29 @@ export class TerminalManager {
|
|
|
330
446
|
this.#publishStatus(entry);
|
|
331
447
|
}, this.#namingTimeoutMs);
|
|
332
448
|
entry.namingTimer.unref?.();
|
|
333
|
-
this.#
|
|
449
|
+
this.#updateWorkspaceWatch();
|
|
334
450
|
}
|
|
335
451
|
#waitingEntries() {
|
|
336
452
|
return [...this.#entries.values()].filter((entry) => entry.info.naming?.state === "waiting");
|
|
337
453
|
}
|
|
338
|
-
/**
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
454
|
+
/**
|
|
455
|
+
* Watches the workspace, which keeps Studio scanning, only while a terminal waits for a port,
|
|
456
|
+
* or while someone watches the list and a terminal runs, so the ports it opened stay current.
|
|
457
|
+
*/
|
|
458
|
+
#updateWorkspaceWatch() {
|
|
459
|
+
const linking = this.#processTree !== undefined &&
|
|
460
|
+
this.#watchers.size > 0 &&
|
|
461
|
+
[...this.#entries.values()].some((entry) => entry.info.status === "running");
|
|
462
|
+
const needed = linking || this.#waitingEntries().length > 0;
|
|
463
|
+
if (needed && !this.#stopWorkspaceWatch) {
|
|
464
|
+
this.#stopWorkspaceWatch = this.#workspace.watch(() => {
|
|
465
|
+
if (this.#waitingEntries().length > 0)
|
|
466
|
+
void this.#nameNewPorts();
|
|
467
|
+
if (this.#watchers.size > 0)
|
|
468
|
+
void this.#refreshPorts();
|
|
469
|
+
});
|
|
343
470
|
}
|
|
344
|
-
else if (!
|
|
471
|
+
else if (!needed && this.#stopWorkspaceWatch) {
|
|
345
472
|
this.#stopWorkspaceWatch();
|
|
346
473
|
this.#stopWorkspaceWatch = undefined;
|
|
347
474
|
}
|
|
@@ -357,7 +484,7 @@ export class TerminalManager {
|
|
|
357
484
|
}
|
|
358
485
|
finally {
|
|
359
486
|
this.#naming = false;
|
|
360
|
-
this.#
|
|
487
|
+
this.#updateWorkspaceWatch();
|
|
361
488
|
}
|
|
362
489
|
}
|
|
363
490
|
async #nameFor(entry, routes, detected) {
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { parseDockerPs } from "../../../core/ports/docker-container.js";
|
|
2
|
+
import { err, ok } from "../../../core/result.js";
|
|
3
|
+
import { appError } from "../../application/app-error.js";
|
|
4
|
+
import { execute } from "../portless/portless-cli.js";
|
|
5
|
+
/** A scan runs every few seconds; one `docker ps` serves the scans in between. */
|
|
6
|
+
const FRESH_MS = 3000;
|
|
7
|
+
/** Docker that is stopped or refuses this user is asked again less often. */
|
|
8
|
+
const UNAVAILABLE_MS = 15_000;
|
|
9
|
+
/** No `docker` on PATH: asked again in case it gets installed. */
|
|
10
|
+
const MISSING_MS = 60_000;
|
|
11
|
+
const LIST_TIMEOUT_MS = 2000;
|
|
12
|
+
/** `docker stop` waits up to 10 seconds for the container before it kills it. */
|
|
13
|
+
const STOP_TIMEOUT_MS = 30_000;
|
|
14
|
+
const LIST_ARGS = ["ps", "--format", "{{json .}}"];
|
|
15
|
+
const defaultRun = (args, timeoutMs) => execute("docker", args, { timeoutMs });
|
|
16
|
+
/**
|
|
17
|
+
* Reads running containers with `docker ps`, which works the same with Docker Engine on Linux and
|
|
18
|
+
* with Docker Desktop, OrbStack or Colima on macOS and Windows. Any failure reads as no containers.
|
|
19
|
+
*/
|
|
20
|
+
export class DockerContainers {
|
|
21
|
+
#run;
|
|
22
|
+
#now;
|
|
23
|
+
#last = null;
|
|
24
|
+
#reading = null;
|
|
25
|
+
constructor(options = {}) {
|
|
26
|
+
this.#run = options.run ?? defaultRun;
|
|
27
|
+
this.#now = options.now ?? Date.now;
|
|
28
|
+
}
|
|
29
|
+
read() {
|
|
30
|
+
const last = this.#last;
|
|
31
|
+
if (last && this.#now() - last.at < maxAge(last.reading))
|
|
32
|
+
return Promise.resolve(last.reading);
|
|
33
|
+
this.#reading ??= this.#list().then((reading) => {
|
|
34
|
+
this.#last = { reading, at: this.#now() };
|
|
35
|
+
this.#reading = null;
|
|
36
|
+
return reading;
|
|
37
|
+
});
|
|
38
|
+
return this.#reading;
|
|
39
|
+
}
|
|
40
|
+
async stop(name) {
|
|
41
|
+
const outcome = await this.#run(["stop", name], STOP_TIMEOUT_MS).catch(() => null);
|
|
42
|
+
// The next read lists the containers again instead of showing the stopped one.
|
|
43
|
+
this.#last = null;
|
|
44
|
+
if (outcome?.exitCode === 0)
|
|
45
|
+
return ok(undefined);
|
|
46
|
+
const printed = outcome?.combined.trim() ?? "";
|
|
47
|
+
return err(appError("command_failed", printed || `docker stop ${name} did not finish.`, `docker stop ${name}`));
|
|
48
|
+
}
|
|
49
|
+
async #list() {
|
|
50
|
+
const outcome = await this.#run(LIST_ARGS, LIST_TIMEOUT_MS).catch(() => null);
|
|
51
|
+
if (outcome === null)
|
|
52
|
+
return { state: "unavailable" };
|
|
53
|
+
if (outcome.missing)
|
|
54
|
+
return { state: "missing" };
|
|
55
|
+
if (outcome.exitCode !== 0)
|
|
56
|
+
return { state: "unavailable" };
|
|
57
|
+
return { state: "ok", containers: parseDockerPs(outcome.stdout) };
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
function maxAge(reading) {
|
|
61
|
+
if (reading.state === "ok")
|
|
62
|
+
return FRESH_MS;
|
|
63
|
+
return reading.state === "missing" ? MISSING_MS : UNAVAILABLE_MS;
|
|
64
|
+
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { request } from "node:http";
|
|
1
2
|
import { createServer } from "node:net";
|
|
2
3
|
import * as path from "node:path";
|
|
3
4
|
import { DEFAULT_CONFIG } from "../../../core/config/studio-config.js";
|
|
@@ -99,19 +100,22 @@ export async function proxyPidsAmong(platform, io, pids) {
|
|
|
99
100
|
/** Gathers the facts the doctor checks. Each part is read once; nothing is changed. */
|
|
100
101
|
export async function collectDoctorFacts(options) {
|
|
101
102
|
const { platform, registry, configStore } = options;
|
|
102
|
-
const [state, proxyPid, stored, proxyProcesses, loginService, systemNotifier] = await Promise.all([
|
|
103
|
+
const [state, proxyPid, stored, proxyProcesses, loginService, systemNotifier, docker, port80] = await Promise.all([
|
|
103
104
|
registry.readSnapshot(),
|
|
104
105
|
registry.proxyPid(),
|
|
105
106
|
configStore.read(),
|
|
106
107
|
listProxyProcesses(platform, options.io, options.uid),
|
|
107
108
|
options.loginService ? options.loginService() : Promise.resolve(null),
|
|
108
109
|
options.systemNotifier ? options.systemNotifier() : Promise.resolve(undefined),
|
|
110
|
+
options.docker ? dockerFacts(options.docker) : Promise.resolve(undefined),
|
|
111
|
+
options.lowPort ? options.lowPort(80) : Promise.resolve(undefined),
|
|
109
112
|
]);
|
|
110
113
|
const config = stored.ok ? stored.value : DEFAULT_CONFIG;
|
|
111
114
|
const proxyOwner = state.proxy.running && proxyPid !== null
|
|
112
115
|
? await options.processes.ownerOf(proxyPid)
|
|
113
116
|
: "unknown";
|
|
114
117
|
const https = state.proxy.running ? state.proxy.https : config.proxyHttps;
|
|
118
|
+
const shortUrls = await options.shortUrls?.(config).catch(() => undefined);
|
|
115
119
|
return {
|
|
116
120
|
platform,
|
|
117
121
|
portless: state.portless,
|
|
@@ -128,8 +132,46 @@ export async function collectDoctorFacts(options) {
|
|
|
128
132
|
loginService,
|
|
129
133
|
...(systemNotifier === undefined ? {} : { systemNotifier }),
|
|
130
134
|
...(options.routesFile ? { routesFile: options.routesFile() } : {}),
|
|
135
|
+
...(docker === undefined ? {} : { docker }),
|
|
136
|
+
...(port80 === undefined ? {} : { port80 }),
|
|
137
|
+
...(shortUrls === undefined ? {} : { shortUrls }),
|
|
131
138
|
};
|
|
132
139
|
}
|
|
140
|
+
/** Only how many containers run reaches the doctor, never their names. */
|
|
141
|
+
async function dockerFacts(read) {
|
|
142
|
+
const reading = await read().catch(() => ({ state: "unavailable" }));
|
|
143
|
+
return reading.state === "ok"
|
|
144
|
+
? { state: "ok", containers: reading.containers.length }
|
|
145
|
+
: { state: reading.state };
|
|
146
|
+
}
|
|
147
|
+
/**
|
|
148
|
+
* Tries to listen on a loopback port and closes it at once, without sudo: `denied` when the system
|
|
149
|
+
* refuses this user (Linux below `ip_unprivileged_port_start`), `in-use` when a program holds it.
|
|
150
|
+
*/
|
|
151
|
+
export function testBind(port) {
|
|
152
|
+
return new Promise((resolve) => {
|
|
153
|
+
const server = createServer();
|
|
154
|
+
server.once("error", (error) => resolve(error.code === "EADDRINUSE" ? "in-use" : "denied"));
|
|
155
|
+
server.listen({ port, host: "127.0.0.1", exclusive: true }, () => server.close(() => resolve("free")));
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
/**
|
|
159
|
+
* Whether a request to this loopback port reaches a portless proxy (it sends `X-Portless`), as it
|
|
160
|
+
* does through Studio's short-URL forwarder.
|
|
161
|
+
*/
|
|
162
|
+
export function reachesPortless(port, timeoutMs = 1000) {
|
|
163
|
+
return new Promise((resolve) => {
|
|
164
|
+
const req = request({ host: "127.0.0.1", port, method: "HEAD", path: "/", timeout: timeoutMs, agent: false }, (res) => {
|
|
165
|
+
res.resume();
|
|
166
|
+
req.destroy();
|
|
167
|
+
resolve(res.headers["x-portless"] !== undefined);
|
|
168
|
+
});
|
|
169
|
+
req.on("timeout", () => req.destroy());
|
|
170
|
+
req.on("error", () => resolve(false));
|
|
171
|
+
req.on("close", () => resolve(false));
|
|
172
|
+
req.end();
|
|
173
|
+
});
|
|
174
|
+
}
|
|
133
175
|
/** Paths are built for the platform the facts describe, not the one running the code. */
|
|
134
176
|
function pathsOf(platform) {
|
|
135
177
|
return platform === "win32" ? path.win32 : path.posix;
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { createServer, connect as tcpConnect } from "node:net";
|
|
2
|
+
import { err, ok } from "../../../core/result.js";
|
|
3
|
+
/** Loopback on IPv4 and IPv6, because browsers may resolve `name.localhost` to either. */
|
|
4
|
+
export const LOOPBACK_HOSTS = ["127.0.0.1", "::1"];
|
|
5
|
+
/** A system without IPv6 cannot bind `::1`; that is not a failure when IPv4 works. */
|
|
6
|
+
const NO_ADDRESS = new Set(["EADDRNOTAVAIL", "EAFNOSUPPORT"]);
|
|
7
|
+
/**
|
|
8
|
+
* Listens on loopback and pipes every TCP connection to the target, byte for byte. Piping the
|
|
9
|
+
* connection rather than re-sending requests keeps the Host header, the method, streamed bodies,
|
|
10
|
+
* keep-alive and WebSocket upgrades exactly as the browser sent them; portless then adds its own
|
|
11
|
+
* `X-Forwarded-*` headers from that Host. Never asks for more rights: a refused bind is reported.
|
|
12
|
+
*/
|
|
13
|
+
export async function startForwarder(options) {
|
|
14
|
+
const hosts = options.hosts ?? LOOPBACK_HOSTS;
|
|
15
|
+
const targetHost = options.targetHost ?? "127.0.0.1";
|
|
16
|
+
const sockets = new Set();
|
|
17
|
+
const servers = [];
|
|
18
|
+
let port = options.port;
|
|
19
|
+
const handle = (client) => {
|
|
20
|
+
const upstream = tcpConnect({ host: targetHost, port: options.targetPort });
|
|
21
|
+
sockets.add(client);
|
|
22
|
+
sockets.add(upstream);
|
|
23
|
+
client.setNoDelay(true);
|
|
24
|
+
upstream.setNoDelay(true);
|
|
25
|
+
const finish = () => {
|
|
26
|
+
client.destroy();
|
|
27
|
+
upstream.destroy();
|
|
28
|
+
sockets.delete(client);
|
|
29
|
+
sockets.delete(upstream);
|
|
30
|
+
};
|
|
31
|
+
client.on("error", finish);
|
|
32
|
+
upstream.on("error", finish);
|
|
33
|
+
client.on("close", finish);
|
|
34
|
+
upstream.on("close", finish);
|
|
35
|
+
client.pipe(upstream);
|
|
36
|
+
upstream.pipe(client);
|
|
37
|
+
};
|
|
38
|
+
const close = async () => {
|
|
39
|
+
for (const socket of sockets)
|
|
40
|
+
socket.destroy();
|
|
41
|
+
sockets.clear();
|
|
42
|
+
await Promise.all(servers.map((server) => new Promise((resolve) => server.close(() => resolve()))));
|
|
43
|
+
};
|
|
44
|
+
for (const [index, host] of hosts.entries()) {
|
|
45
|
+
const server = createServer(handle);
|
|
46
|
+
const bound = await new Promise((resolve) => {
|
|
47
|
+
server.once("error", resolve);
|
|
48
|
+
server.listen({ port, host, exclusive: true }, () => {
|
|
49
|
+
server.off("error", resolve);
|
|
50
|
+
resolve(null);
|
|
51
|
+
});
|
|
52
|
+
});
|
|
53
|
+
if (bound === null) {
|
|
54
|
+
servers.push(server);
|
|
55
|
+
// Port 0 picks a free port on the first host; the others use that same one.
|
|
56
|
+
if (index === 0)
|
|
57
|
+
port = server.address().port;
|
|
58
|
+
continue;
|
|
59
|
+
}
|
|
60
|
+
if (index > 0 && NO_ADDRESS.has(bound.code ?? ""))
|
|
61
|
+
continue;
|
|
62
|
+
await close();
|
|
63
|
+
return err(failureOf(bound, port));
|
|
64
|
+
}
|
|
65
|
+
return ok({ port, close });
|
|
66
|
+
}
|
|
67
|
+
function failureOf(error, port) {
|
|
68
|
+
if (error.code === "EADDRINUSE") {
|
|
69
|
+
return { reason: "in-use", message: `port ${port} is in use by another program.` };
|
|
70
|
+
}
|
|
71
|
+
if (error.code === "EACCES" || error.code === "EPERM") {
|
|
72
|
+
return {
|
|
73
|
+
reason: "denied",
|
|
74
|
+
message: `this system does not let this user listen on port ${port} without sudo.`,
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
return { reason: "failed", message: `could not listen on port ${port}: ${error.message}` };
|
|
78
|
+
}
|
|
@@ -64,7 +64,8 @@ export function resolveWindowsCommand(bin, lookup) {
|
|
|
64
64
|
return null;
|
|
65
65
|
}
|
|
66
66
|
const found = new Map();
|
|
67
|
-
|
|
67
|
+
/** PATH, PATHEXT and file checks of this machine, for `resolveWindowsCommand`. */
|
|
68
|
+
export function nodeWindowsLookup() {
|
|
68
69
|
const pathValue = process.env.PATH ?? process.env.Path ?? "";
|
|
69
70
|
return {
|
|
70
71
|
pathDirs: pathValue
|
|
@@ -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.
|