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
@@ -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. Sorted by port.
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 discoverPorts(scanner, projects, context) {
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 candidates = (await scanner.scan()).filter((port) => port.pid !== null &&
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(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);
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 { discoverPorts } from "./port-discovery.js";
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
- void Promise.all([this.#reloadConfig(), this.#loadAutoNamed()]).then(() => {
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 detected = await this.#detect(state, config, false);
139
- this.#lastDetected = JSON.stringify(detected);
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
- return this.#withIcons(snapshot, config);
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 detected = await this.#detect(state, await this.#currentConfig(), true);
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 detected = await this.#detect(state, await this.#currentConfig(), false);
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 excludedPorts = [this.#studioPort, state.proxy.port, ...scan.proxyPorts].filter((port) => port !== null);
263
- const found = await discoverPorts(cached, this.#projects, {
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
- return found.map((item) => {
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
- let detected = await this.#detect(state, config, true);
307
- // Each step reads the routes again after the previous one changed them.
308
- if (config.autoName && (await this.#namer.name(detected)) > 0) {
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
- detected = await this.#detect(state, config, false);
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(detected) !== this.#lastDetected)
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)