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.
Files changed (36) hide show
  1. package/README.md +60 -218
  2. package/dist/cli/cli-args.js +41 -12
  3. package/dist/cli/env-command.js +34 -0
  4. package/dist/cli/index.js +19 -0
  5. package/dist/cli/mcp/studio-tools.js +70 -7
  6. package/dist/cli/studio-items.js +5 -2
  7. package/dist/core/api/contract.js +17 -1
  8. package/dist/core/config/studio-config.js +6 -0
  9. package/dist/core/doctor/platform-commands.js +5 -2
  10. package/dist/core/ports/direct-url.js +7 -0
  11. package/dist/core/ports/docker-container.js +85 -0
  12. package/dist/core/ports/helper-ports.js +86 -0
  13. package/dist/core/routes/env-lines.js +72 -0
  14. package/dist/core/routes/route.js +14 -2
  15. package/dist/server/application/container-source.js +19 -0
  16. package/dist/server/application/doctor.js +199 -9
  17. package/dist/server/application/port-discovery.js +84 -9
  18. package/dist/server/application/short-urls.js +136 -0
  19. package/dist/server/application/studio-runtime.js +82 -24
  20. package/dist/server/application/terminals.js +142 -15
  21. package/dist/server/infrastructure/system/docker-containers.js +64 -0
  22. package/dist/server/infrastructure/system/doctor-facts.js +43 -1
  23. package/dist/server/infrastructure/system/ephemeral-ports.js +62 -0
  24. package/dist/server/infrastructure/system/port-forwarder.js +78 -0
  25. package/dist/server/infrastructure/system/process-runner.js +2 -1
  26. package/dist/server/infrastructure/system/system-port-scanner.js +64 -4
  27. package/dist/server/infrastructure/terminal/process-tree.js +71 -0
  28. package/dist/server/infrastructure/terminal/terminal-spawner.js +44 -3
  29. package/dist/server/interface/http/http-server.js +38 -2
  30. package/dist/server/main.js +43 -6
  31. package/dist/web/assets/index-9WPxv6pu.js +17 -0
  32. package/dist/web/assets/index-Bckc4cIr.css +1 -0
  33. package/dist/web/index.html +2 -2
  34. package/package.json +1 -1
  35. package/dist/web/assets/index-B3KMqQ-l.js +0 -15
  36. 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
- return () => this.#watchers.delete(onChange);
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 title = (request.title?.trim() || command.value || "shell").slice(0, MAX_TITLE_CHARS);
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: await this.#projectOf(cwd.value),
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.#updateNamingWatch();
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 = { name: item.project.name, length: path.length };
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.#updateNamingWatch();
449
+ this.#updateWorkspaceWatch();
334
450
  }
335
451
  #waitingEntries() {
336
452
  return [...this.#entries.values()].filter((entry) => entry.info.naming?.state === "waiting");
337
453
  }
338
- /** Watches the workspace, which keeps Studio scanning, only while a terminal waits for a port. */
339
- #updateNamingWatch() {
340
- const waiting = this.#waitingEntries().length > 0;
341
- if (waiting && !this.#stopWorkspaceWatch) {
342
- this.#stopWorkspaceWatch = this.#workspace.watch(() => void this.#nameNewPorts());
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 (!waiting && this.#stopWorkspaceWatch) {
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.#updateNamingWatch();
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
- function nodeWindowsLookup() {
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 key of this.#answers.keys()) {
48
- const [port, pid] = key.split(":");
49
- if (String(this.#pidByPort.get(Number(port))) !== pid)
50
- this.#answers.delete(key);
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.