Skip to content

Commit 570ef9b

Browse files
committed
Support IPv6 local preview servers
- Probe listeners on the loopback address they actually bind to - Preserve detected server metadata while refreshing preview state - Cover IPv6 parsing and HTTP probing with tests
1 parent 91775ba commit 570ef9b

9 files changed

Lines changed: 435 additions & 50 deletions

File tree

apps/desktop/src/preview/LocalServers.ts

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -43,9 +43,16 @@ export const make = Effect.gen(function* LocalServersMake() {
4343
const probe =
4444
cached !== undefined && now - cached.at < PROBE_CACHE_MS
4545
? cached.result
46-
: await probeHttpServer(entry.port);
46+
: await probeHttpServer(entry.port, entry.probeHost);
4747
probeCache.set(key, { at: now, result: probe });
48-
return probe === null ? null : { ...entry, title: probe.title };
48+
return probe === null
49+
? null
50+
: {
51+
port: entry.port,
52+
processName: entry.processName,
53+
pid: entry.pid,
54+
title: probe.title,
55+
};
4956
}),
5057
);
5158
for (const [key, value] of probeCache) {

apps/desktop/src/preview/parseListeningPorts.test.ts

Lines changed: 19 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -8,15 +8,27 @@ describe("parseListeningPorts", () => {
88

99
// Sorted by port, so the lowest listener is first regardless of lsof order.
1010
expect(parseListeningPorts(output)).toEqual([
11-
{ port: 5173, processName: "node", pid: 123 },
12-
{ port: 8000, processName: "Python", pid: 456 },
11+
{ port: 5173, processName: "node", pid: 123, probeHost: "127.0.0.1" },
12+
{ port: 8000, processName: "Python", pid: 456, probeHost: "127.0.0.1" },
1313
]);
1414
});
1515

16-
it("collapses a port listed once per address family", () => {
17-
const output = ["p1", "cnode", "n*:3000", "n[::1]:3000"].join("\n");
16+
it("collapses a port listed once per address family, preferring IPv4", () => {
17+
const output = ["p1", "cnode", "n[::1]:3000", "n*:3000"].join("\n");
1818

19-
expect(parseListeningPorts(output)).toEqual([{ port: 3000, processName: "node", pid: 1 }]);
19+
expect(parseListeningPorts(output)).toEqual([
20+
{ port: 3000, processName: "node", pid: 1, probeHost: "127.0.0.1" },
21+
]);
22+
});
23+
24+
it("keeps the IPv6 loopback for a listener bound only to ::1", () => {
25+
// Node resolving "localhost" to ::1 produces exactly this: a dev server
26+
// that refuses 127.0.0.1 but serves [::1] fine.
27+
const output = ["p1", "cnode", "n[::1]:4321"].join("\n");
28+
29+
expect(parseListeningPorts(output)).toEqual([
30+
{ port: 4321, processName: "node", pid: 1, probeHost: "::1" },
31+
]);
2032
});
2133

2234
it("ignores addresses this machine's browser cannot reach", () => {
@@ -35,8 +47,8 @@ describe("parseListeningPorts", () => {
3547
const output = ["p1", "cnode", "n*:3000", "p2", "n*:4000"].join("\n");
3648

3749
expect(parseListeningPorts(output)).toEqual([
38-
{ port: 3000, processName: "node", pid: 1 },
39-
{ port: 4000, processName: "", pid: 2 },
50+
{ port: 3000, processName: "node", pid: 1, probeHost: "127.0.0.1" },
51+
{ port: 4000, processName: "", pid: 2, probeHost: "127.0.0.1" },
4052
]);
4153
});
4254
});

apps/desktop/src/preview/parseListeningPorts.ts

Lines changed: 32 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -12,21 +12,32 @@ export interface ListeningPort {
1212
port: number;
1313
processName: string;
1414
pid: number;
15+
/**
16+
* The loopback address the listener actually accepts connections on. A
17+
* server bound only to ::1 (Node resolves "localhost" that way on some
18+
* machines) refuses 127.0.0.1, so probing the wrong family reports a live
19+
* server as absent.
20+
*/
21+
probeHost: "127.0.0.1" | "::1";
1522
}
1623

17-
/** Addresses that a browser on this machine can actually reach. */
18-
function isLocallyReachable(address: string): boolean {
24+
/** Maps a bound address to the loopback it is reachable at, or null for none. */
25+
function loopbackProbeHost(address: string): "127.0.0.1" | "::1" | null {
1926
const host = address.slice(0, address.lastIndexOf(":"));
20-
return (
21-
host === "*" ||
22-
host === "" ||
23-
host === "127.0.0.1" ||
24-
host === "localhost" ||
25-
host === "[::1]" ||
26-
host === "::1" ||
27-
host === "0.0.0.0" ||
28-
host === "[::]"
29-
);
27+
switch (host) {
28+
case "*":
29+
case "":
30+
case "0.0.0.0":
31+
case "127.0.0.1":
32+
case "localhost":
33+
return "127.0.0.1";
34+
case "[::1]":
35+
case "::1":
36+
case "[::]":
37+
return "::1";
38+
default:
39+
return null;
40+
}
3041
}
3142

3243
export function parseListeningPorts(lsofOutput: string): ListeningPort[] {
@@ -54,16 +65,21 @@ export function parseListeningPorts(lsofOutput: string): ListeningPort[] {
5465
}
5566
// IPv6 arrives bracketed, and lsof writes "->" for established
5667
// connections; a listener has no peer.
57-
if (value.includes("->") || !isLocallyReachable(value)) {
68+
const probeHost = value.includes("->") ? null : loopbackProbeHost(value);
69+
if (probeHost === null) {
5870
continue;
5971
}
6072
const port = Number.parseInt(value.slice(value.lastIndexOf(":") + 1), 10);
6173
if (Number.isNaN(port) || port <= 0) {
6274
continue;
6375
}
64-
// The same port often appears twice, once per address family. One entry.
65-
if (!byPort.has(port)) {
66-
byPort.set(port, { port, processName, pid });
76+
// The same port often appears twice, once per address family: one entry,
77+
// probed over IPv4 when either family accepts it.
78+
const existing = byPort.get(port);
79+
if (existing === undefined) {
80+
byPort.set(port, { port, processName, pid, probeHost });
81+
} else if (probeHost === "127.0.0.1") {
82+
existing.probeHost = probeHost;
6783
}
6884
}
6985

apps/desktop/src/preview/probeHttpServer.test.ts

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
1+
import { createServer } from "node:http";
12
import { describe, expect, it } from "vite-plus/test";
23

3-
import { extractHtmlTitle, isHtmlContentType } from "./probeHttpServer.ts";
4+
import { extractHtmlTitle, isHtmlContentType, probeHttpServer } from "./probeHttpServer.ts";
45

56
describe("extractHtmlTitle", () => {
67
it("reads a title across attributes and newlines", () => {
@@ -15,6 +16,28 @@ describe("extractHtmlTitle", () => {
1516
});
1617
});
1718

19+
describe("probeHttpServer", () => {
20+
it("reaches a server bound only to the IPv6 loopback", async () => {
21+
// Node resolving "localhost" to ::1 leaves dev servers refusing 127.0.0.1;
22+
// the probe must connect to the address the listener is actually on.
23+
const server = createServer((_request, response) => {
24+
response.writeHead(200, { "content-type": "text/html" });
25+
response.end("<html><head><title>Dev App</title></head></html>");
26+
});
27+
await new Promise<void>((resolve) => server.listen(0, "::1", resolve));
28+
const address = server.address();
29+
if (address === null || typeof address === "string") {
30+
throw new Error("server did not report a port");
31+
}
32+
33+
try {
34+
expect(await probeHttpServer(address.port, "::1")).toEqual({ title: "Dev App" });
35+
} finally {
36+
await new Promise<void>((resolve) => server.close(() => resolve()));
37+
}
38+
});
39+
});
40+
1841
describe("isHtmlContentType", () => {
1942
it("accepts html with a charset", () => {
2043
expect(isHtmlContentType("text/html; charset=utf-8")).toBe(true);

apps/desktop/src/preview/probeHttpServer.ts

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -40,9 +40,15 @@ export interface HttpProbeResult {
4040
title: string | null;
4141
}
4242

43-
/** Resolves null for anything that is not an HTML-serving HTTP endpoint. */
43+
/**
44+
* Resolves null for anything that is not an HTML-serving HTTP endpoint.
45+
*
46+
* The host must be the loopback address the listener is bound to: a server on
47+
* ::1 refuses 127.0.0.1, and vice versa.
48+
*/
4449
export function probeHttpServer(
4550
port: number,
51+
host: string,
4652
redirectsLeft = MAX_REDIRECTS,
4753
): Promise<HttpProbeResult | null> {
4854
return new Promise((resolve) => {
@@ -55,7 +61,7 @@ export function probeHttpServer(
5561
};
5662

5763
const request = get(
58-
{ host: "127.0.0.1", port, path: "/", timeout: PROBE_TIMEOUT_MS },
64+
{ host, port, path: "/", timeout: PROBE_TIMEOUT_MS },
5965
(response: IncomingMessage) => {
6066
const status = response.statusCode ?? 0;
6167
const location = response.headers.location;
@@ -68,7 +74,7 @@ export function probeHttpServer(
6874
finish(null);
6975
return;
7076
}
71-
void probeHttpServer(port, redirectsLeft - 1).then(finish);
77+
void probeHttpServer(port, host, redirectsLeft - 1).then(finish);
7278
return;
7379
}
7480
if (status >= 400 || !isHtmlContentType(response.headers["content-type"])) {

0 commit comments

Comments
 (0)