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.
Files changed (29) hide show
  1. package/README.md +60 -218
  2. package/dist/cli/mcp/studio-tools.js +20 -7
  3. package/dist/cli/studio-items.js +5 -2
  4. package/dist/core/api/contract.js +17 -1
  5. package/dist/core/config/studio-config.js +4 -0
  6. package/dist/core/doctor/platform-commands.js +5 -2
  7. package/dist/core/ports/direct-url.js +7 -0
  8. package/dist/core/ports/docker-container.js +85 -0
  9. package/dist/core/routes/route.js +14 -2
  10. package/dist/server/application/container-source.js +19 -0
  11. package/dist/server/application/doctor.js +199 -9
  12. package/dist/server/application/port-discovery.js +49 -6
  13. package/dist/server/application/short-urls.js +136 -0
  14. package/dist/server/application/studio-runtime.js +46 -7
  15. package/dist/server/application/terminals.js +142 -15
  16. package/dist/server/infrastructure/system/docker-containers.js +64 -0
  17. package/dist/server/infrastructure/system/doctor-facts.js +43 -1
  18. package/dist/server/infrastructure/system/port-forwarder.js +78 -0
  19. package/dist/server/infrastructure/system/process-runner.js +2 -1
  20. package/dist/server/infrastructure/terminal/process-tree.js +71 -0
  21. package/dist/server/infrastructure/terminal/terminal-spawner.js +44 -3
  22. package/dist/server/interface/http/http-server.js +38 -2
  23. package/dist/server/main.js +41 -6
  24. package/dist/web/assets/index-BDEwEBPS.js +15 -0
  25. package/dist/web/assets/index-C9wkL1J8.css +1 -0
  26. package/dist/web/index.html +2 -2
  27. package/package.json +1 -1
  28. package/dist/web/assets/index-B3KMqQ-l.js +0 -15
  29. 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
- const saved = await deps.updateConfig({ ...facts.config, proxyPort: 1355, proxyHttps: false });
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
- if (!facts.proxy.running)
49
- return deps.controlProxy("start");
50
- if (facts.proxyOwner === "you")
51
- return deps.controlProxy("restart");
52
- return ok(`Saved HTTP on port 1355. Stop the current proxy with \`${stopProxyAsAdminCommand(facts.platform)}\` ` +
53
- `${adminWords(facts.platform)}, then start the new one from the proxy menu.`);
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. Port ${config.proxyPort} needs sudo, so Studio cannot ` +
110
- "start it; HTTP on port 1355 needs nothing."
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(candidates.map((port) => scanner.answersHttp(port.port)));
20
- return candidates
21
- .filter((port, index) => answers[index] && isDevServer(port, projects))
22
- .map((port) => toDetected(port, projects, context.config.nameTemplate))
23
- .sort((a, b) => a.port - b.port);
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
- void Promise.all([this.#reloadConfig(), this.#loadAutoNamed()]).then(() => {
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
- return this.#withIcons(snapshot, config);
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 excludedPorts = [this.#studioPort, state.proxy.port, ...scan.proxyPorts].filter((port) => port !== null);
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)