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
|
@@ -1,26 +1,101 @@
|
|
|
1
1
|
import { projectOf, suggestName, } from "../../core/ports/detected-port.js";
|
|
2
|
+
import { containerProjectInfo } from "../../core/ports/docker-container.js";
|
|
3
|
+
import { arrangeDetected, } from "../../core/ports/helper-ports.js";
|
|
4
|
+
/** Only the main Detected list of `discoverPortGroups`. */
|
|
5
|
+
export async function discoverPorts(scanner, projects, context) {
|
|
6
|
+
return (await discoverPortGroups(scanner, projects, context)).detected;
|
|
7
|
+
}
|
|
2
8
|
/**
|
|
3
9
|
* Listening ports a person would want to name: they answer HTTP, have no portless route, and are
|
|
4
|
-
* not Studio, the proxy or something the config ignores.
|
|
10
|
+
* not Studio, the proxy or something the config ignores. Debuggers, ephemeral ports and ports of a
|
|
11
|
+
* server that already has a name go to `otherPorts` (see `arrangeDetected`). Sorted by port.
|
|
5
12
|
*/
|
|
6
|
-
export async function
|
|
13
|
+
export async function discoverPortGroups(scanner, projects, context) {
|
|
7
14
|
const skippedPorts = new Set([
|
|
8
15
|
...context.routes.map((route) => route.port),
|
|
9
16
|
...context.excludedPorts,
|
|
10
17
|
...context.config.ignoredPorts,
|
|
11
18
|
]);
|
|
12
19
|
const ignored = context.config.ignoredProcesses.map((name) => name.toLowerCase());
|
|
20
|
+
const published = containerPorts(context.containers ?? [], skippedPorts, ignored);
|
|
13
21
|
// 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.
|
|
15
|
-
const
|
|
22
|
+
// never to a dev server this person started. A container's port is shown as the container.
|
|
23
|
+
const scanned = await scanner.scan();
|
|
24
|
+
const candidates = scanned.filter((port) => port.pid !== null &&
|
|
16
25
|
!skippedPorts.has(port.port) &&
|
|
26
|
+
!published.has(port.port) &&
|
|
17
27
|
!isIgnoredProcess(port.processName, ignored));
|
|
28
|
+
const fromContainers = [...published];
|
|
18
29
|
// 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
|
-
|
|
30
|
+
const [answers, containerAnswers] = await Promise.all([
|
|
31
|
+
Promise.all(candidates.map((port) => scanner.answersHttp(port.port))),
|
|
32
|
+
Promise.all(fromContainers.map(([port]) => scanner.answersHttp(port))),
|
|
33
|
+
]);
|
|
34
|
+
const template = context.config.nameTemplate;
|
|
35
|
+
const found = [
|
|
36
|
+
...candidates
|
|
37
|
+
.filter((port, index) => answers[index] && isDevServer(port, projects))
|
|
38
|
+
.map((port) => toDetected(port, projects, template)),
|
|
39
|
+
...fromContainers
|
|
40
|
+
.filter((_, index) => containerAnswers[index])
|
|
41
|
+
.map(([port, container]) => containerDetected(port, container, template)),
|
|
42
|
+
].sort((a, b) => a.port - b.port);
|
|
43
|
+
return arrangeDetected({
|
|
44
|
+
detected: found,
|
|
45
|
+
debuggers: await debuggerProbes(scanner, found),
|
|
46
|
+
ephemeralRange: context.ephemeralRange ?? null,
|
|
47
|
+
showEphemeral: context.config.showEphemeralPorts,
|
|
48
|
+
routes: listeningRoutes(context.routes, scanned),
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
const UNKNOWN = "unknown";
|
|
52
|
+
async function debuggerProbes(scanner, found) {
|
|
53
|
+
const probe = scanner.probeDebugger?.bind(scanner);
|
|
54
|
+
if (!probe)
|
|
55
|
+
return new Map();
|
|
56
|
+
const answers = await Promise.all(found.map((item) => probe(item.port).catch(() => UNKNOWN)));
|
|
57
|
+
return new Map(found.map((item, index) => [item.port, answers[index] ?? UNKNOWN]));
|
|
58
|
+
}
|
|
59
|
+
/** Routes whose port a readable process listens on now; a closed route claims no siblings. */
|
|
60
|
+
function listeningRoutes(routes, scanned) {
|
|
61
|
+
const byPort = new Map(scanned.map((port) => [port.port, port]));
|
|
62
|
+
return routes.flatMap((route) => {
|
|
63
|
+
const listener = byPort.get(route.port);
|
|
64
|
+
if (!listener || listener.pid === null)
|
|
65
|
+
return [];
|
|
66
|
+
return [{ name: route.name, port: route.port, pid: listener.pid, cwd: listener.cwd }];
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
/** Published port -> container, without the ports the filters skip. */
|
|
70
|
+
function containerPorts(containers, skippedPorts, ignored) {
|
|
71
|
+
const ports = new Map();
|
|
72
|
+
if (isIgnoredProcess(CONTAINER_PROCESS, ignored))
|
|
73
|
+
return ports;
|
|
74
|
+
for (const container of containers) {
|
|
75
|
+
for (const port of container.ports) {
|
|
76
|
+
if (!skippedPorts.has(port) && !ports.has(port))
|
|
77
|
+
ports.set(port, container);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
return ports;
|
|
81
|
+
}
|
|
82
|
+
/** What a container's row shows as its process, and what `ignoredProcesses` matches. */
|
|
83
|
+
const CONTAINER_PROCESS = "docker";
|
|
84
|
+
function containerDetected(port, container, nameTemplate) {
|
|
85
|
+
const project = container.composeProject;
|
|
86
|
+
return {
|
|
87
|
+
port,
|
|
88
|
+
pid: null,
|
|
89
|
+
processName: CONTAINER_PROCESS,
|
|
90
|
+
cwd: null,
|
|
91
|
+
suggestedName: suggestName(containerProjectInfo(container), nameTemplate, port),
|
|
92
|
+
...(project === null ? {} : { project: { name: project, worktree: null } }),
|
|
93
|
+
container: {
|
|
94
|
+
name: container.name,
|
|
95
|
+
image: container.image,
|
|
96
|
+
composeProject: project,
|
|
97
|
+
},
|
|
98
|
+
};
|
|
24
99
|
}
|
|
25
100
|
// Interpreters dev servers run on. Anything else counts only when it was built inside the
|
|
26
101
|
// 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
|
+
}
|
|
@@ -8,7 +8,7 @@ import { appError } from "./app-error.js";
|
|
|
8
8
|
import { memoryAutoNamedStore, } from "./auto-named-store.js";
|
|
9
9
|
import { AutoNamer } from "./auto-namer.js";
|
|
10
10
|
import { IconResolver } from "./icon-resolver.js";
|
|
11
|
-
import {
|
|
11
|
+
import { discoverPortGroups } from "./port-discovery.js";
|
|
12
12
|
import { EventLog, eventsBetween } from "./studio-events.js";
|
|
13
13
|
import { SYSTEM_NOTIFICATION_KINDS } from "./system-notifier.js";
|
|
14
14
|
/** Keeps the projects in memory only; for tests and for `init`, which runs once. */
|
|
@@ -42,6 +42,10 @@ export class StudioRuntime {
|
|
|
42
42
|
#events = new EventLog();
|
|
43
43
|
#icons;
|
|
44
44
|
#routeProjectStore;
|
|
45
|
+
#containers;
|
|
46
|
+
#ephemeralRange;
|
|
47
|
+
#shortUrls;
|
|
48
|
+
#ready = Promise.resolve();
|
|
45
49
|
/** Last project seen per route name; null until read from the store. */
|
|
46
50
|
#routeProjects = null;
|
|
47
51
|
/** What the store last got, so an unchanged map is not written again. */
|
|
@@ -81,6 +85,10 @@ export class StudioRuntime {
|
|
|
81
85
|
this.#autoNamedStore = options.autoNamedStore ?? memoryAutoNamedStore();
|
|
82
86
|
this.#notifier = options.notifier ?? null;
|
|
83
87
|
this.#routeProjectStore = options.routeProjectStore ?? memoryRouteProjectStore();
|
|
88
|
+
this.#containers = options.containers ?? null;
|
|
89
|
+
this.#ephemeralRange = options.ephemeralRange ?? null;
|
|
90
|
+
this.#shortUrls = options.shortUrls ?? null;
|
|
91
|
+
this.#shortUrls?.onChange(() => this.#notify());
|
|
84
92
|
const noIcons = {
|
|
85
93
|
served: async () => null,
|
|
86
94
|
remote: async () => null,
|
|
@@ -98,11 +106,16 @@ export class StudioRuntime {
|
|
|
98
106
|
return;
|
|
99
107
|
this.#started = true;
|
|
100
108
|
this.#stopConfigWatch = this.#store.watch(() => void this.#reloadConfig());
|
|
101
|
-
|
|
109
|
+
this.#ready = Promise.all([this.#reloadConfig(), this.#loadAutoNamed()]).then(async () => {
|
|
102
110
|
this.#reschedule();
|
|
103
111
|
void this.ensureProxy();
|
|
112
|
+
await this.#shortUrls?.sync(this.#config);
|
|
104
113
|
});
|
|
105
114
|
}
|
|
115
|
+
/** Resolves once `start` read the config and the short-URL forwarder settled. */
|
|
116
|
+
ready() {
|
|
117
|
+
return this.#ready;
|
|
118
|
+
}
|
|
106
119
|
/**
|
|
107
120
|
* Starts the portless proxy when the config asks for it and it is not running, so names work
|
|
108
121
|
* without anyone running portless by hand. Called at start and after portless gets installed.
|
|
@@ -128,26 +141,39 @@ export class StudioRuntime {
|
|
|
128
141
|
this.#stopRegistryWatch?.();
|
|
129
142
|
this.#stopRegistryWatch = undefined;
|
|
130
143
|
this.#watchers.clear();
|
|
144
|
+
void this.#shortUrls?.close();
|
|
131
145
|
}
|
|
132
146
|
setStudioPort(port) {
|
|
133
147
|
this.#studioPort = port;
|
|
134
148
|
}
|
|
135
|
-
async readSnapshot() {
|
|
149
|
+
async readSnapshot(options = {}) {
|
|
136
150
|
const state = await this.#registry.readSnapshot();
|
|
137
151
|
const config = await this.#currentConfig();
|
|
138
|
-
const
|
|
139
|
-
this.#lastDetected = JSON.stringify(
|
|
152
|
+
const found = await this.#detect(state, config, false);
|
|
153
|
+
this.#lastDetected = JSON.stringify(found);
|
|
140
154
|
const routes = await this.#detailedRoutes(state.routes);
|
|
141
155
|
const snapshot = {
|
|
142
156
|
proxy: state.proxy,
|
|
143
157
|
portless: state.portless,
|
|
144
158
|
routes,
|
|
145
|
-
detected,
|
|
159
|
+
detected: found.detected,
|
|
160
|
+
otherPorts: found.otherPorts,
|
|
146
161
|
autoName: config.autoName,
|
|
147
162
|
readAt: state.readAt,
|
|
148
163
|
};
|
|
149
164
|
this.#observe(snapshot, config);
|
|
150
|
-
|
|
165
|
+
const shown = options.includeStudio ? routes : this.#listedRoutes(routes, config);
|
|
166
|
+
return this.#withIcons({ ...snapshot, routes: shown }, config);
|
|
167
|
+
}
|
|
168
|
+
/**
|
|
169
|
+
* The routes people see: without Studio's own (`studio`, on Studio's port or its short-URL
|
|
170
|
+
* port) unless `showStudioRoute` is on. It only takes room, and the page is already open.
|
|
171
|
+
*/
|
|
172
|
+
#listedRoutes(routes, config) {
|
|
173
|
+
if (config.showStudioRoute)
|
|
174
|
+
return routes;
|
|
175
|
+
const own = new Set([this.#studioPort, this.#shortUrls?.publicPort() ?? null]);
|
|
176
|
+
return routes.filter((route) => !own.has(route.port));
|
|
151
177
|
}
|
|
152
178
|
/** The last notable changes of this run, oldest first, for a page that connects later. */
|
|
153
179
|
recentEvents() {
|
|
@@ -160,7 +186,7 @@ export class StudioRuntime {
|
|
|
160
186
|
/** One fresh scan with the current config, filtered the same way as the snapshot. */
|
|
161
187
|
async detectPorts() {
|
|
162
188
|
const state = await this.#registry.readSnapshot();
|
|
163
|
-
return this.#detect(state, await this.#currentConfig(), true);
|
|
189
|
+
return (await this.#detect(state, await this.#currentConfig(), true)).detected;
|
|
164
190
|
}
|
|
165
191
|
/** The config in effect: the file as last read, or the defaults while it is broken. */
|
|
166
192
|
settings() {
|
|
@@ -172,11 +198,12 @@ export class StudioRuntime {
|
|
|
172
198
|
*/
|
|
173
199
|
async stoppablePids() {
|
|
174
200
|
const state = await this.#registry.readSnapshot();
|
|
175
|
-
const
|
|
201
|
+
const found = await this.#detect(state, await this.#currentConfig(), true);
|
|
176
202
|
const pids = new Map();
|
|
177
|
-
for (const item of detected)
|
|
203
|
+
for (const item of [...found.detected, ...found.otherPorts]) {
|
|
178
204
|
if (item.pid !== null)
|
|
179
205
|
pids.set(item.pid, item.port);
|
|
206
|
+
}
|
|
180
207
|
const pidByPort = this.#pidByPort();
|
|
181
208
|
for (const route of state.routes) {
|
|
182
209
|
const pid = pidByPort.get(route.port);
|
|
@@ -192,7 +219,8 @@ export class StudioRuntime {
|
|
|
192
219
|
*/
|
|
193
220
|
async openableFolders() {
|
|
194
221
|
const state = await this.#registry.readSnapshot();
|
|
195
|
-
const
|
|
222
|
+
const found = await this.#detect(state, await this.#currentConfig(), false);
|
|
223
|
+
const detected = [...found.detected, ...found.otherPorts];
|
|
196
224
|
const routes = await this.#detailedRoutes(state.routes);
|
|
197
225
|
const folders = new Set();
|
|
198
226
|
for (const item of [...routes, ...detected]) {
|
|
@@ -255,35 +283,62 @@ export class StudioRuntime {
|
|
|
255
283
|
}
|
|
256
284
|
async #detect(state, config, fresh) {
|
|
257
285
|
const scan = await this.#scanPorts(fresh ? 0 : config.scanIntervalMs);
|
|
286
|
+
const probeDebugger = this.#scanner.probeDebugger?.bind(this.#scanner);
|
|
258
287
|
const cached = {
|
|
259
288
|
scan: async () => scan.ports,
|
|
260
289
|
answersHttp: (port) => this.#scanner.answersHttp(port),
|
|
290
|
+
...(probeDebugger ? { probeDebugger } : {}),
|
|
261
291
|
};
|
|
262
|
-
const
|
|
263
|
-
const
|
|
292
|
+
const forwarderPort = this.#shortUrls?.publicPort() ?? null;
|
|
293
|
+
const excludedPorts = [
|
|
294
|
+
this.#studioPort,
|
|
295
|
+
forwarderPort,
|
|
296
|
+
state.proxy.port,
|
|
297
|
+
...scan.proxyPorts,
|
|
298
|
+
].filter((port) => port !== null);
|
|
299
|
+
const found = await discoverPortGroups(cached, this.#projects, {
|
|
264
300
|
config,
|
|
265
301
|
routes: state.routes,
|
|
266
302
|
excludedPorts,
|
|
303
|
+
containers: scan.containers,
|
|
304
|
+
ephemeralRange: await this.#readEphemeralRange(),
|
|
267
305
|
});
|
|
268
|
-
|
|
306
|
+
const withProject = (item) => {
|
|
269
307
|
if (item.cwd === null || !item.project)
|
|
270
308
|
return item;
|
|
271
309
|
const project = itemProjectAt(this.#projects.read(item.cwd), item.cwd);
|
|
272
310
|
return project === null ? item : { ...item, project };
|
|
273
|
-
}
|
|
311
|
+
};
|
|
312
|
+
return {
|
|
313
|
+
detected: found.detected.map(withProject),
|
|
314
|
+
otherPorts: found.otherPorts.map(withProject),
|
|
315
|
+
};
|
|
274
316
|
}
|
|
275
317
|
/** Reuses a scan younger than `maxAgeMs`, and shares one scan between concurrent callers. */
|
|
276
318
|
#scanPorts(maxAgeMs) {
|
|
277
319
|
if (this.#scan && Date.now() - this.#scan.at < maxAgeMs)
|
|
278
320
|
return Promise.resolve(this.#scan);
|
|
279
|
-
this.#scanning ??= this.#scanner.scan().then(async (ports) => {
|
|
321
|
+
this.#scanning ??= Promise.all([this.#scanner.scan(), this.#readContainers()]).then(async ([ports, containers]) => {
|
|
280
322
|
const proxyPorts = await this.#proxyPortsAmong(ports).catch(() => []);
|
|
281
|
-
this.#scan = { ports, proxyPorts, at: Date.now() };
|
|
323
|
+
this.#scan = { ports, proxyPorts, containers, at: Date.now() };
|
|
282
324
|
this.#scanning = null;
|
|
283
325
|
return this.#scan;
|
|
284
326
|
});
|
|
285
327
|
return this.#scanning;
|
|
286
328
|
}
|
|
329
|
+
/** A range that cannot be read turns the ephemeral rule off rather than failing the scan. */
|
|
330
|
+
async #readEphemeralRange() {
|
|
331
|
+
if (!this.#ephemeralRange)
|
|
332
|
+
return null;
|
|
333
|
+
return this.#ephemeralRange().catch(() => null);
|
|
334
|
+
}
|
|
335
|
+
/** Docker is optional: any failure to read it means no containers, never a failed scan. */
|
|
336
|
+
async #readContainers() {
|
|
337
|
+
if (!this.#containers)
|
|
338
|
+
return [];
|
|
339
|
+
const reading = await this.#containers.read().catch(() => null);
|
|
340
|
+
return reading?.state === "ok" ? reading.containers : [];
|
|
341
|
+
}
|
|
287
342
|
#reschedule() {
|
|
288
343
|
clearTimeout(this.#timer);
|
|
289
344
|
this.#timer = undefined;
|
|
@@ -303,19 +358,20 @@ export class StudioRuntime {
|
|
|
303
358
|
try {
|
|
304
359
|
let state = await this.#registry.readSnapshot();
|
|
305
360
|
const config = await this.#currentConfig();
|
|
306
|
-
|
|
307
|
-
// Each step reads the routes again after the previous one changed them.
|
|
308
|
-
|
|
361
|
+
const first = await this.#detect(state, config, true);
|
|
362
|
+
// Each step reads the routes again after the previous one changed them. Other ports are
|
|
363
|
+
// helpers of a named server, debuggers or temporary ports: auto mode never names them.
|
|
364
|
+
if (config.autoName && (await this.#namer.name(first.detected)) > 0) {
|
|
309
365
|
state = await this.#registry.readSnapshot();
|
|
310
366
|
}
|
|
311
367
|
if (await this.#removeAbandonedAutoNames(state, config)) {
|
|
312
368
|
state = await this.#registry.readSnapshot();
|
|
313
369
|
}
|
|
314
|
-
|
|
370
|
+
const found = await this.#detect(state, config, false);
|
|
315
371
|
const healthChanged = await this.#probeRoutes(state.routes);
|
|
316
372
|
const routes = await this.#detailedRoutes(state.routes);
|
|
317
|
-
this.#observe({ ...state, routes, detected }, config);
|
|
318
|
-
if (healthChanged || JSON.stringify(
|
|
373
|
+
this.#observe({ ...state, routes, detected: found.detected }, config);
|
|
374
|
+
if (healthChanged || JSON.stringify(found) !== this.#lastDetected)
|
|
319
375
|
this.#notify();
|
|
320
376
|
}
|
|
321
377
|
catch (error) {
|
|
@@ -444,7 +500,7 @@ export class StudioRuntime {
|
|
|
444
500
|
/** Turns what changed since the last look into events, and tells the OS when nobody watches. */
|
|
445
501
|
#observe(snapshot, config) {
|
|
446
502
|
const next = {
|
|
447
|
-
routes: snapshot.routes,
|
|
503
|
+
routes: this.#listedRoutes(snapshot.routes, config),
|
|
448
504
|
detected: snapshot.detected,
|
|
449
505
|
proxy: snapshot.proxy,
|
|
450
506
|
};
|
|
@@ -573,6 +629,8 @@ export class StudioRuntime {
|
|
|
573
629
|
this.#config = config;
|
|
574
630
|
this.#reschedule();
|
|
575
631
|
this.#notify();
|
|
632
|
+
if (this.#started)
|
|
633
|
+
void this.#shortUrls?.sync(config);
|
|
576
634
|
}
|
|
577
635
|
#notify() {
|
|
578
636
|
for (const watcher of this.#watchers)
|