portless-studio 0.1.1 → 0.1.2
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/mcp/studio-tools.js +20 -7
- package/dist/cli/studio-items.js +5 -2
- package/dist/core/api/contract.js +17 -1
- package/dist/core/config/studio-config.js +4 -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/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 +49 -6
- package/dist/server/application/short-urls.js +136 -0
- package/dist/server/application/studio-runtime.js +46 -7
- 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/port-forwarder.js +78 -0
- package/dist/server/infrastructure/system/process-runner.js +2 -1
- 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 +41 -6
- package/dist/web/assets/index-BDEwEBPS.js +15 -0
- package/dist/web/assets/index-C9wkL1J8.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
|
@@ -10,11 +10,13 @@ export function diagnose(facts) {
|
|
|
10
10
|
pushIf(checks, proxyCountCheck(facts));
|
|
11
11
|
pushIf(checks, httpsTrustCheck(facts));
|
|
12
12
|
pushIf(checks, routesFileCheck(facts));
|
|
13
|
+
pushIf(checks, shorterUrlsCheck(facts));
|
|
13
14
|
}
|
|
14
15
|
checks.push(studioPortCheck(facts));
|
|
15
16
|
pushIf(checks, loginServiceCheck(facts));
|
|
16
17
|
pushIf(checks, safariCheck(facts));
|
|
17
18
|
pushIf(checks, notificationsCheck(facts));
|
|
19
|
+
pushIf(checks, dockerCheck(facts));
|
|
18
20
|
return checks;
|
|
19
21
|
}
|
|
20
22
|
export function createDoctorService(deps) {
|
|
@@ -42,15 +44,59 @@ async function runAction(action, facts, deps) {
|
|
|
42
44
|
return deps.controlProxy("start");
|
|
43
45
|
if (action === "restart-proxy")
|
|
44
46
|
return deps.controlProxy("restart");
|
|
45
|
-
|
|
47
|
+
if (action === "use-short-urls")
|
|
48
|
+
return turnOnShortUrls(facts, deps);
|
|
49
|
+
const http = { ...facts.config, proxyPort: 1355, proxyHttps: false };
|
|
50
|
+
return applyProxyConfig(http, "HTTP on port 1355", facts, deps);
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* Turns `shortUrls` on and checks that Studio really listens on port 80. The proxy keeps its
|
|
54
|
+
* port and keeps running; when the listen fails, the previous config comes back.
|
|
55
|
+
*/
|
|
56
|
+
async function turnOnShortUrls(facts, deps) {
|
|
57
|
+
const config = { ...facts.config, shortUrls: true };
|
|
58
|
+
const saved = await deps.updateConfig(config);
|
|
46
59
|
if (!saved.ok)
|
|
47
60
|
return saved;
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
61
|
+
const status = await deps.syncShortUrls(config);
|
|
62
|
+
if (status.state === "listening") {
|
|
63
|
+
return ok("Names now open as http://name.localhost: Studio forwards port " +
|
|
64
|
+
`${status.port} to the proxy on port ${status.target}.`);
|
|
65
|
+
}
|
|
66
|
+
await deps.updateConfig(facts.config);
|
|
67
|
+
await deps.syncShortUrls(facts.config);
|
|
68
|
+
const why = status.state === "failed" ? `${status.message} ` : "";
|
|
69
|
+
const code = status.state === "failed" && status.reason === "in-use" ? "port_in_use" : "command_failed";
|
|
70
|
+
return err(appError(code, `${why}Studio kept the previous settings, so names still open with ` +
|
|
71
|
+
`:${facts.config.proxyPort}.`));
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* Saves the proxy settings, then starts or restarts the proxy with them when Studio may. When
|
|
75
|
+
* that start fails, the previous settings come back and a proxy that was running starts again on
|
|
76
|
+
* them, so names keep working.
|
|
77
|
+
*/
|
|
78
|
+
async function applyProxyConfig(config, what, facts, deps) {
|
|
79
|
+
const saved = await deps.updateConfig(config);
|
|
80
|
+
if (!saved.ok)
|
|
81
|
+
return saved;
|
|
82
|
+
if (facts.proxy.running && facts.proxyOwner !== "you") {
|
|
83
|
+
return ok(`Saved ${what}. Stop the current proxy with \`${stopProxyAsAdminCommand(facts.platform)}\` ` +
|
|
84
|
+
`${adminWords(facts.platform)}, then start the new one from the proxy menu.`);
|
|
85
|
+
}
|
|
86
|
+
const started = await deps.controlProxy(facts.proxy.running ? "restart" : "start");
|
|
87
|
+
if (started.ok)
|
|
88
|
+
return started;
|
|
89
|
+
await deps.updateConfig(facts.config);
|
|
90
|
+
let back = "Studio went back to the previous settings.";
|
|
91
|
+
if (facts.proxy.running) {
|
|
92
|
+
const again = await deps.controlProxy("start");
|
|
93
|
+
if (again.ok) {
|
|
94
|
+
back =
|
|
95
|
+
"Studio went back to the previous settings and started the proxy on port " +
|
|
96
|
+
`${facts.config.proxyPort} again.`;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
return err({ ...started.error, message: `${started.error.message} ${back}` });
|
|
54
100
|
}
|
|
55
101
|
function portlessCheck({ portless }) {
|
|
56
102
|
if (portless.installed) {
|
|
@@ -106,8 +152,9 @@ function proxyRunningCheck(facts) {
|
|
|
106
152
|
status: "warn",
|
|
107
153
|
title: "The proxy is not running",
|
|
108
154
|
detail: needsSudo
|
|
109
|
-
? `Names do not open until it runs.
|
|
110
|
-
|
|
155
|
+
? `Names do not open until it runs. portless asks for sudo below port 1024, so Studio ` +
|
|
156
|
+
`cannot start it on port ${config.proxyPort}; HTTP on port 1355 needs nothing, and short ` +
|
|
157
|
+
"URLs drop the port from names."
|
|
111
158
|
: "Names do not open until it runs.",
|
|
112
159
|
fix: needsSudo
|
|
113
160
|
? { label: "Use HTTP on port 1355", action: "use-http-1355" }
|
|
@@ -327,6 +374,149 @@ function notificationsCheck({ config, systemNotifier }) {
|
|
|
327
374
|
fix: { label: "Install notify-send", command: "sudo apt install libnotify-bin" },
|
|
328
375
|
};
|
|
329
376
|
}
|
|
377
|
+
const UNPRIVILEGED_PORT_SETTING = "net.ipv4.ip_unprivileged_port_start=80";
|
|
378
|
+
/**
|
|
379
|
+
* Short URLs drop the port number from every name (`http://name.localhost`). Studio itself listens
|
|
380
|
+
* on port 80 and forwards to the proxy, only when a test bind shows this user may listen there
|
|
381
|
+
* without sudo; it never changes the system.
|
|
382
|
+
*/
|
|
383
|
+
function shorterUrlsCheck(facts) {
|
|
384
|
+
const { proxy, config, platform, port80, shortUrls } = facts;
|
|
385
|
+
const https = proxy.running ? proxy.https : config.proxyHttps;
|
|
386
|
+
if (https) {
|
|
387
|
+
if (!config.shortUrls)
|
|
388
|
+
return null;
|
|
389
|
+
return {
|
|
390
|
+
id: "shorter-urls",
|
|
391
|
+
status: "ok",
|
|
392
|
+
title: "Short URLs work over HTTP only",
|
|
393
|
+
detail: "The proxy serves HTTPS, so names keep their port. Switch the proxy to HTTP to open " +
|
|
394
|
+
"them as http://name.localhost.",
|
|
395
|
+
};
|
|
396
|
+
}
|
|
397
|
+
const port = (proxy.running ? proxy.port : null) ?? config.proxyPort;
|
|
398
|
+
if (port === 80) {
|
|
399
|
+
return {
|
|
400
|
+
id: "shorter-urls",
|
|
401
|
+
status: "ok",
|
|
402
|
+
title: "Names open without a port number",
|
|
403
|
+
detail: "The proxy uses port 80, so names open as http://name.localhost.",
|
|
404
|
+
};
|
|
405
|
+
}
|
|
406
|
+
if (shortUrls?.state === "listening") {
|
|
407
|
+
return {
|
|
408
|
+
id: "shorter-urls",
|
|
409
|
+
status: "ok",
|
|
410
|
+
title: "Names open without a port number",
|
|
411
|
+
detail: `Studio listens on port ${shortUrls.port} and forwards to the proxy on port ` +
|
|
412
|
+
`${shortUrls.target}, so names open as http://name.localhost.`,
|
|
413
|
+
};
|
|
414
|
+
}
|
|
415
|
+
const now = `Names open as http://name.localhost:${port}`;
|
|
416
|
+
if (config.shortUrls)
|
|
417
|
+
return failedShortUrlsCheck(facts, now);
|
|
418
|
+
if (port80 === undefined)
|
|
419
|
+
return null;
|
|
420
|
+
if (port80 === "free") {
|
|
421
|
+
return {
|
|
422
|
+
id: "shorter-urls",
|
|
423
|
+
status: "ok",
|
|
424
|
+
title: "Shorter URLs: port 80 is available",
|
|
425
|
+
detail: `${now}. This user can listen on port 80 without sudo, so Studio can listen there and ` +
|
|
426
|
+
`forward to the proxy, and names open as http://name.localhost. The proxy stays on ` +
|
|
427
|
+
`port ${port}.`,
|
|
428
|
+
fix: { label: "Use short URLs", action: "use-short-urls" },
|
|
429
|
+
};
|
|
430
|
+
}
|
|
431
|
+
if (port80 === "in-use") {
|
|
432
|
+
return {
|
|
433
|
+
id: "shorter-urls",
|
|
434
|
+
status: "ok",
|
|
435
|
+
title: "Port 80 is taken by another program",
|
|
436
|
+
detail: `${now}. Stop the program on port 80 to drop the port number from names.`,
|
|
437
|
+
};
|
|
438
|
+
}
|
|
439
|
+
if (platform !== "linux") {
|
|
440
|
+
return {
|
|
441
|
+
id: "shorter-urls",
|
|
442
|
+
status: "ok",
|
|
443
|
+
title: "Shorter URLs: this system does not allow port 80",
|
|
444
|
+
detail: `${now}. This user cannot listen on port 80 here, so names keep the port.`,
|
|
445
|
+
};
|
|
446
|
+
}
|
|
447
|
+
return {
|
|
448
|
+
id: "shorter-urls",
|
|
449
|
+
status: "ok",
|
|
450
|
+
title: "Shorter URLs need a one-time system setting",
|
|
451
|
+
detail: `${now}. ${SYSCTL_ADVICE} Then run the checks again and choose Use short URLs.`,
|
|
452
|
+
fix: SYSCTL_FIX,
|
|
453
|
+
};
|
|
454
|
+
}
|
|
455
|
+
/** `shortUrls` is on and Studio does not listen: names still work, with the port. */
|
|
456
|
+
function failedShortUrlsCheck(facts, now) {
|
|
457
|
+
const { platform, port80, shortUrls } = facts;
|
|
458
|
+
const reason = shortUrls?.state === "failed" ? shortUrls.reason : port80;
|
|
459
|
+
const retry = port80 === "free"
|
|
460
|
+
? { fix: { label: "Try port 80 again", action: "use-short-urls" } }
|
|
461
|
+
: {};
|
|
462
|
+
if (reason === "in-use") {
|
|
463
|
+
return {
|
|
464
|
+
id: "shorter-urls",
|
|
465
|
+
status: "warn",
|
|
466
|
+
title: "Short URLs are on, but port 80 is taken by another program",
|
|
467
|
+
detail: `${now}. Stop the program on port 80, then run the checks again.`,
|
|
468
|
+
...retry,
|
|
469
|
+
};
|
|
470
|
+
}
|
|
471
|
+
if (reason === "denied" && platform === "linux" && port80 !== "free") {
|
|
472
|
+
return {
|
|
473
|
+
id: "shorter-urls",
|
|
474
|
+
status: "warn",
|
|
475
|
+
title: "Short URLs are on, but Linux does not let this user listen on port 80",
|
|
476
|
+
detail: `${now}. ${SYSCTL_ADVICE} Then run the checks again and choose Try port 80 again.`,
|
|
477
|
+
fix: SYSCTL_FIX,
|
|
478
|
+
};
|
|
479
|
+
}
|
|
480
|
+
return {
|
|
481
|
+
id: "shorter-urls",
|
|
482
|
+
status: "warn",
|
|
483
|
+
title: "Short URLs are on, but Studio does not listen on port 80",
|
|
484
|
+
detail: shortUrls?.state === "failed"
|
|
485
|
+
? `${now}. Studio could not listen there: ${shortUrls.message}`
|
|
486
|
+
: `${now}. Studio is not running, or has not tried port 80 yet.`,
|
|
487
|
+
...retry,
|
|
488
|
+
};
|
|
489
|
+
}
|
|
490
|
+
const SYSCTL_ADVICE = "Linux lets only root listen on ports below 1024. The command lets every user on this machine " +
|
|
491
|
+
"listen on port 80 and up until the next restart; Studio never runs it. To keep it after a " +
|
|
492
|
+
`restart, also run \`echo '${UNPRIVILEGED_PORT_SETTING}' | sudo tee ` +
|
|
493
|
+
"/etc/sysctl.d/99-portless-studio.conf`.";
|
|
494
|
+
const SYSCTL_FIX = {
|
|
495
|
+
label: "Allow port 80 until the next restart",
|
|
496
|
+
command: `sudo sysctl -w ${UNPRIVILEGED_PORT_SETTING}`,
|
|
497
|
+
};
|
|
498
|
+
/** Information only: without Docker, container ports simply do not show. */
|
|
499
|
+
function dockerCheck({ docker }) {
|
|
500
|
+
if (docker === undefined || docker.state === "missing")
|
|
501
|
+
return null;
|
|
502
|
+
if (docker.state === "ok") {
|
|
503
|
+
const count = docker.containers === 1 ? "1 running container" : `${docker.containers} running containers`;
|
|
504
|
+
return {
|
|
505
|
+
id: "docker",
|
|
506
|
+
status: "ok",
|
|
507
|
+
title: "Studio reads Docker containers",
|
|
508
|
+
detail: `docker ps lists ${count}; the ports they publish show under Detected.`,
|
|
509
|
+
};
|
|
510
|
+
}
|
|
511
|
+
return {
|
|
512
|
+
id: "docker",
|
|
513
|
+
status: "ok",
|
|
514
|
+
title: "Studio cannot read Docker containers",
|
|
515
|
+
detail: "The docker command is installed, but docker ps did not answer: Docker is not running, " +
|
|
516
|
+
"or this user may not use it (on Linux, the docker group). Ports of containers do not " +
|
|
517
|
+
"show under Detected until it answers.",
|
|
518
|
+
};
|
|
519
|
+
}
|
|
330
520
|
function startCommand(config) {
|
|
331
521
|
const tls = config.proxyHttps ? "--https" : "--no-tls";
|
|
332
522
|
return `portless proxy start --port ${config.proxyPort} ${tls}`;
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { projectOf, suggestName, } from "../../core/ports/detected-port.js";
|
|
2
|
+
import { containerProjectInfo } from "../../core/ports/docker-container.js";
|
|
2
3
|
/**
|
|
3
4
|
* Listening ports a person would want to name: they answer HTTP, have no portless route, and are
|
|
4
5
|
* not Studio, the proxy or something the config ignores. Sorted by port.
|
|
@@ -10,17 +11,59 @@ export async function discoverPorts(scanner, projects, context) {
|
|
|
10
11
|
...context.config.ignoredPorts,
|
|
11
12
|
]);
|
|
12
13
|
const ignored = context.config.ignoredProcesses.map((name) => name.toLowerCase());
|
|
14
|
+
const published = containerPorts(context.containers ?? [], skippedPorts, ignored);
|
|
13
15
|
// A port whose process we cannot read belongs to another user or the system (printing, DNS),
|
|
14
|
-
// never to a dev server this person started.
|
|
16
|
+
// never to a dev server this person started. A container's port is shown as the container.
|
|
15
17
|
const candidates = (await scanner.scan()).filter((port) => port.pid !== null &&
|
|
16
18
|
!skippedPorts.has(port.port) &&
|
|
19
|
+
!published.has(port.port) &&
|
|
17
20
|
!isIgnoredProcess(port.processName, ignored));
|
|
21
|
+
const fromContainers = [...published];
|
|
18
22
|
// Probes run in parallel; a port that never answers costs its timeout once, not per port.
|
|
19
|
-
const answers = await Promise.all(
|
|
20
|
-
|
|
21
|
-
.
|
|
22
|
-
|
|
23
|
-
|
|
23
|
+
const [answers, containerAnswers] = await Promise.all([
|
|
24
|
+
Promise.all(candidates.map((port) => scanner.answersHttp(port.port))),
|
|
25
|
+
Promise.all(fromContainers.map(([port]) => scanner.answersHttp(port))),
|
|
26
|
+
]);
|
|
27
|
+
const template = context.config.nameTemplate;
|
|
28
|
+
return [
|
|
29
|
+
...candidates
|
|
30
|
+
.filter((port, index) => answers[index] && isDevServer(port, projects))
|
|
31
|
+
.map((port) => toDetected(port, projects, template)),
|
|
32
|
+
...fromContainers
|
|
33
|
+
.filter((_, index) => containerAnswers[index])
|
|
34
|
+
.map(([port, container]) => containerDetected(port, container, template)),
|
|
35
|
+
].sort((a, b) => a.port - b.port);
|
|
36
|
+
}
|
|
37
|
+
/** Published port -> container, without the ports the filters skip. */
|
|
38
|
+
function containerPorts(containers, skippedPorts, ignored) {
|
|
39
|
+
const ports = new Map();
|
|
40
|
+
if (isIgnoredProcess(CONTAINER_PROCESS, ignored))
|
|
41
|
+
return ports;
|
|
42
|
+
for (const container of containers) {
|
|
43
|
+
for (const port of container.ports) {
|
|
44
|
+
if (!skippedPorts.has(port) && !ports.has(port))
|
|
45
|
+
ports.set(port, container);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
return ports;
|
|
49
|
+
}
|
|
50
|
+
/** What a container's row shows as its process, and what `ignoredProcesses` matches. */
|
|
51
|
+
const CONTAINER_PROCESS = "docker";
|
|
52
|
+
function containerDetected(port, container, nameTemplate) {
|
|
53
|
+
const project = container.composeProject;
|
|
54
|
+
return {
|
|
55
|
+
port,
|
|
56
|
+
pid: null,
|
|
57
|
+
processName: CONTAINER_PROCESS,
|
|
58
|
+
cwd: null,
|
|
59
|
+
suggestedName: suggestName(containerProjectInfo(container), nameTemplate, port),
|
|
60
|
+
...(project === null ? {} : { project: { name: project, worktree: null } }),
|
|
61
|
+
container: {
|
|
62
|
+
name: container.name,
|
|
63
|
+
image: container.image,
|
|
64
|
+
composeProject: project,
|
|
65
|
+
},
|
|
66
|
+
};
|
|
24
67
|
}
|
|
25
68
|
// Interpreters dev servers run on. Anything else counts only when it was built inside the
|
|
26
69
|
// project, like `target/debug/api`, so editors, chat apps and background tools stay out.
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import { withPublicPort } from "../../core/routes/route.js";
|
|
2
|
+
/** The port browsers use when a URL has none. */
|
|
3
|
+
export const SHORT_URL_PORT = 80;
|
|
4
|
+
/**
|
|
5
|
+
* Keeps Studio's port-80 forwarder in line with the config. portless asks for sudo to start its
|
|
6
|
+
* proxy below port 1024 on Linux and macOS even where the system allows the bind, so the proxy
|
|
7
|
+
* stays on `proxyPort` and Studio listens on port 80 itself, as the current user, never with sudo.
|
|
8
|
+
*/
|
|
9
|
+
export class ShortUrls {
|
|
10
|
+
#forwarding;
|
|
11
|
+
#port;
|
|
12
|
+
#log;
|
|
13
|
+
#reachesProxy;
|
|
14
|
+
#watchers = new Set();
|
|
15
|
+
#running = null;
|
|
16
|
+
#status = { state: "off" };
|
|
17
|
+
#queue = Promise.resolve();
|
|
18
|
+
#synced = false;
|
|
19
|
+
constructor(options) {
|
|
20
|
+
this.#forwarding = options.forwarding;
|
|
21
|
+
this.#port = options.port ?? SHORT_URL_PORT;
|
|
22
|
+
this.#log = options.log;
|
|
23
|
+
this.#reachesProxy = options.reachesProxy ?? null;
|
|
24
|
+
}
|
|
25
|
+
/** The port Studio listens on for short URLs, whether or not it listens right now. */
|
|
26
|
+
get port() {
|
|
27
|
+
return this.#port;
|
|
28
|
+
}
|
|
29
|
+
/** True once a sync ran in this process; a CLI command that never syncs has to ask elsewhere. */
|
|
30
|
+
get synced() {
|
|
31
|
+
return this.#synced;
|
|
32
|
+
}
|
|
33
|
+
/** The port in route URLs while the forwarder listens; null keeps the proxy port. */
|
|
34
|
+
publicPort() {
|
|
35
|
+
return this.#running?.port ?? null;
|
|
36
|
+
}
|
|
37
|
+
status() {
|
|
38
|
+
return this.#status;
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* The status for the doctor. In the server it is this process's own; elsewhere Studio may run
|
|
42
|
+
* in another process, so port 80 is asked whether it reaches the proxy. Undefined when unknown.
|
|
43
|
+
*/
|
|
44
|
+
async inspect(config) {
|
|
45
|
+
if (this.#synced)
|
|
46
|
+
return this.#status;
|
|
47
|
+
const wanted = this.#wanted(config);
|
|
48
|
+
if (wanted.state !== "listening")
|
|
49
|
+
return wanted;
|
|
50
|
+
const reached = await (this.#reachesProxy?.(this.#port) ?? Promise.resolve(false));
|
|
51
|
+
return reached ? wanted : undefined;
|
|
52
|
+
}
|
|
53
|
+
/** Calls `onChange` when the public port changes. Returns an unsubscribe. */
|
|
54
|
+
onChange(onChange) {
|
|
55
|
+
this.#watchers.add(onChange);
|
|
56
|
+
return () => this.#watchers.delete(onChange);
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Listens, moves or stops to match the config, one sync at a time. A failed listen is tried
|
|
60
|
+
* again on the next sync, so a doctor fix or a config save after `sysctl` picks it up.
|
|
61
|
+
*/
|
|
62
|
+
sync(config) {
|
|
63
|
+
const next = this.#queue.then(() => this.#sync(config));
|
|
64
|
+
this.#queue = next.catch(() => { });
|
|
65
|
+
return next;
|
|
66
|
+
}
|
|
67
|
+
close() {
|
|
68
|
+
const next = this.#queue.then(async () => {
|
|
69
|
+
await this.#stop();
|
|
70
|
+
this.#status = { state: "off" };
|
|
71
|
+
});
|
|
72
|
+
this.#queue = next.catch(() => { });
|
|
73
|
+
return next;
|
|
74
|
+
}
|
|
75
|
+
async #sync(config) {
|
|
76
|
+
this.#synced = true;
|
|
77
|
+
const wanted = this.#wanted(config);
|
|
78
|
+
if (wanted.state !== "listening") {
|
|
79
|
+
await this.#stop();
|
|
80
|
+
this.#status = wanted;
|
|
81
|
+
return wanted;
|
|
82
|
+
}
|
|
83
|
+
const target = config.proxyPort;
|
|
84
|
+
if (this.#running?.target === target)
|
|
85
|
+
return this.#status;
|
|
86
|
+
await this.#stop();
|
|
87
|
+
const started = await this.#forwarding.listen(this.#port, target);
|
|
88
|
+
if (!started.ok) {
|
|
89
|
+
this.#status = { state: "failed", port: this.#port, ...started.error };
|
|
90
|
+
this.#log(`short URLs are off: ${started.error.message} Names keep :${target}.`);
|
|
91
|
+
return this.#status;
|
|
92
|
+
}
|
|
93
|
+
this.#running = { target, port: started.value.port, close: started.value.close };
|
|
94
|
+
this.#status = { state: "listening", port: started.value.port, target };
|
|
95
|
+
this.#log(`short URLs: port ${started.value.port} forwards to the proxy on port ${target}`);
|
|
96
|
+
this.#changed();
|
|
97
|
+
return this.#status;
|
|
98
|
+
}
|
|
99
|
+
#wanted(config) {
|
|
100
|
+
if (!config.shortUrls)
|
|
101
|
+
return { state: "off" };
|
|
102
|
+
if (config.proxyHttps)
|
|
103
|
+
return { state: "https" };
|
|
104
|
+
if (config.proxyPort === this.#port)
|
|
105
|
+
return { state: "proxy-on-port" };
|
|
106
|
+
return { state: "listening", port: this.#port, target: config.proxyPort };
|
|
107
|
+
}
|
|
108
|
+
async #stop() {
|
|
109
|
+
const running = this.#running;
|
|
110
|
+
if (!running)
|
|
111
|
+
return;
|
|
112
|
+
this.#running = null;
|
|
113
|
+
await running.close();
|
|
114
|
+
this.#changed();
|
|
115
|
+
}
|
|
116
|
+
#changed() {
|
|
117
|
+
for (const watcher of this.#watchers)
|
|
118
|
+
watcher();
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
/**
|
|
122
|
+
* The registry the rest of Studio reads: the same routes, with URLs on the public port while the
|
|
123
|
+
* forwarder listens, so the page, the CLI, MCP and notifications all show the URL that opens.
|
|
124
|
+
*/
|
|
125
|
+
export function publicUrls(registry, publicPort) {
|
|
126
|
+
return {
|
|
127
|
+
readSnapshot: async () => withPublicPort(await registry.readSnapshot(), publicPort()),
|
|
128
|
+
watch: (onChange) => registry.watch(onChange),
|
|
129
|
+
createAlias: (name, port, force) => registry.createAlias(name, port, force),
|
|
130
|
+
removeAlias: (name) => registry.removeAlias(name),
|
|
131
|
+
installPortless: () => registry.installPortless(),
|
|
132
|
+
startProxy: (port, https) => registry.startProxy(port, https),
|
|
133
|
+
stopProxy: () => registry.stopProxy(),
|
|
134
|
+
proxyPid: () => registry.proxyPid(),
|
|
135
|
+
};
|
|
136
|
+
}
|
|
@@ -42,6 +42,9 @@ export class StudioRuntime {
|
|
|
42
42
|
#events = new EventLog();
|
|
43
43
|
#icons;
|
|
44
44
|
#routeProjectStore;
|
|
45
|
+
#containers;
|
|
46
|
+
#shortUrls;
|
|
47
|
+
#ready = Promise.resolve();
|
|
45
48
|
/** Last project seen per route name; null until read from the store. */
|
|
46
49
|
#routeProjects = null;
|
|
47
50
|
/** What the store last got, so an unchanged map is not written again. */
|
|
@@ -81,6 +84,9 @@ export class StudioRuntime {
|
|
|
81
84
|
this.#autoNamedStore = options.autoNamedStore ?? memoryAutoNamedStore();
|
|
82
85
|
this.#notifier = options.notifier ?? null;
|
|
83
86
|
this.#routeProjectStore = options.routeProjectStore ?? memoryRouteProjectStore();
|
|
87
|
+
this.#containers = options.containers ?? null;
|
|
88
|
+
this.#shortUrls = options.shortUrls ?? null;
|
|
89
|
+
this.#shortUrls?.onChange(() => this.#notify());
|
|
84
90
|
const noIcons = {
|
|
85
91
|
served: async () => null,
|
|
86
92
|
remote: async () => null,
|
|
@@ -98,11 +104,16 @@ export class StudioRuntime {
|
|
|
98
104
|
return;
|
|
99
105
|
this.#started = true;
|
|
100
106
|
this.#stopConfigWatch = this.#store.watch(() => void this.#reloadConfig());
|
|
101
|
-
|
|
107
|
+
this.#ready = Promise.all([this.#reloadConfig(), this.#loadAutoNamed()]).then(async () => {
|
|
102
108
|
this.#reschedule();
|
|
103
109
|
void this.ensureProxy();
|
|
110
|
+
await this.#shortUrls?.sync(this.#config);
|
|
104
111
|
});
|
|
105
112
|
}
|
|
113
|
+
/** Resolves once `start` read the config and the short-URL forwarder settled. */
|
|
114
|
+
ready() {
|
|
115
|
+
return this.#ready;
|
|
116
|
+
}
|
|
106
117
|
/**
|
|
107
118
|
* Starts the portless proxy when the config asks for it and it is not running, so names work
|
|
108
119
|
* without anyone running portless by hand. Called at start and after portless gets installed.
|
|
@@ -128,11 +139,12 @@ export class StudioRuntime {
|
|
|
128
139
|
this.#stopRegistryWatch?.();
|
|
129
140
|
this.#stopRegistryWatch = undefined;
|
|
130
141
|
this.#watchers.clear();
|
|
142
|
+
void this.#shortUrls?.close();
|
|
131
143
|
}
|
|
132
144
|
setStudioPort(port) {
|
|
133
145
|
this.#studioPort = port;
|
|
134
146
|
}
|
|
135
|
-
async readSnapshot() {
|
|
147
|
+
async readSnapshot(options = {}) {
|
|
136
148
|
const state = await this.#registry.readSnapshot();
|
|
137
149
|
const config = await this.#currentConfig();
|
|
138
150
|
const detected = await this.#detect(state, config, false);
|
|
@@ -147,7 +159,18 @@ export class StudioRuntime {
|
|
|
147
159
|
readAt: state.readAt,
|
|
148
160
|
};
|
|
149
161
|
this.#observe(snapshot, config);
|
|
150
|
-
|
|
162
|
+
const shown = options.includeStudio ? routes : this.#listedRoutes(routes, config);
|
|
163
|
+
return this.#withIcons({ ...snapshot, routes: shown }, config);
|
|
164
|
+
}
|
|
165
|
+
/**
|
|
166
|
+
* The routes people see: without Studio's own (`studio`, on Studio's port or its short-URL
|
|
167
|
+
* port) unless `showStudioRoute` is on. It only takes room, and the page is already open.
|
|
168
|
+
*/
|
|
169
|
+
#listedRoutes(routes, config) {
|
|
170
|
+
if (config.showStudioRoute)
|
|
171
|
+
return routes;
|
|
172
|
+
const own = new Set([this.#studioPort, this.#shortUrls?.publicPort() ?? null]);
|
|
173
|
+
return routes.filter((route) => !own.has(route.port));
|
|
151
174
|
}
|
|
152
175
|
/** The last notable changes of this run, oldest first, for a page that connects later. */
|
|
153
176
|
recentEvents() {
|
|
@@ -259,11 +282,18 @@ export class StudioRuntime {
|
|
|
259
282
|
scan: async () => scan.ports,
|
|
260
283
|
answersHttp: (port) => this.#scanner.answersHttp(port),
|
|
261
284
|
};
|
|
262
|
-
const
|
|
285
|
+
const forwarderPort = this.#shortUrls?.publicPort() ?? null;
|
|
286
|
+
const excludedPorts = [
|
|
287
|
+
this.#studioPort,
|
|
288
|
+
forwarderPort,
|
|
289
|
+
state.proxy.port,
|
|
290
|
+
...scan.proxyPorts,
|
|
291
|
+
].filter((port) => port !== null);
|
|
263
292
|
const found = await discoverPorts(cached, this.#projects, {
|
|
264
293
|
config,
|
|
265
294
|
routes: state.routes,
|
|
266
295
|
excludedPorts,
|
|
296
|
+
containers: scan.containers,
|
|
267
297
|
});
|
|
268
298
|
return found.map((item) => {
|
|
269
299
|
if (item.cwd === null || !item.project)
|
|
@@ -276,14 +306,21 @@ export class StudioRuntime {
|
|
|
276
306
|
#scanPorts(maxAgeMs) {
|
|
277
307
|
if (this.#scan && Date.now() - this.#scan.at < maxAgeMs)
|
|
278
308
|
return Promise.resolve(this.#scan);
|
|
279
|
-
this.#scanning ??= this.#scanner.scan().then(async (ports) => {
|
|
309
|
+
this.#scanning ??= Promise.all([this.#scanner.scan(), this.#readContainers()]).then(async ([ports, containers]) => {
|
|
280
310
|
const proxyPorts = await this.#proxyPortsAmong(ports).catch(() => []);
|
|
281
|
-
this.#scan = { ports, proxyPorts, at: Date.now() };
|
|
311
|
+
this.#scan = { ports, proxyPorts, containers, at: Date.now() };
|
|
282
312
|
this.#scanning = null;
|
|
283
313
|
return this.#scan;
|
|
284
314
|
});
|
|
285
315
|
return this.#scanning;
|
|
286
316
|
}
|
|
317
|
+
/** Docker is optional: any failure to read it means no containers, never a failed scan. */
|
|
318
|
+
async #readContainers() {
|
|
319
|
+
if (!this.#containers)
|
|
320
|
+
return [];
|
|
321
|
+
const reading = await this.#containers.read().catch(() => null);
|
|
322
|
+
return reading?.state === "ok" ? reading.containers : [];
|
|
323
|
+
}
|
|
287
324
|
#reschedule() {
|
|
288
325
|
clearTimeout(this.#timer);
|
|
289
326
|
this.#timer = undefined;
|
|
@@ -444,7 +481,7 @@ export class StudioRuntime {
|
|
|
444
481
|
/** Turns what changed since the last look into events, and tells the OS when nobody watches. */
|
|
445
482
|
#observe(snapshot, config) {
|
|
446
483
|
const next = {
|
|
447
|
-
routes: snapshot.routes,
|
|
484
|
+
routes: this.#listedRoutes(snapshot.routes, config),
|
|
448
485
|
detected: snapshot.detected,
|
|
449
486
|
proxy: snapshot.proxy,
|
|
450
487
|
};
|
|
@@ -573,6 +610,8 @@ export class StudioRuntime {
|
|
|
573
610
|
this.#config = config;
|
|
574
611
|
this.#reschedule();
|
|
575
612
|
this.#notify();
|
|
613
|
+
if (this.#started)
|
|
614
|
+
void this.#shortUrls?.sync(config);
|
|
576
615
|
}
|
|
577
616
|
#notify() {
|
|
578
617
|
for (const watcher of this.#watchers)
|