fix(cli): unblock make dev on WSL2 mirrored networking (#924)

This commit is contained in:
Carlos Florêncio
2026-05-17 12:29:44 +01:00
committed by GitHub
parent 0b2085688b
commit fd433626ae
6 changed files with 578 additions and 30 deletions
+1
View File
@@ -33,6 +33,7 @@ export async function runDev({ repoRoot, backendPort, webPort }: DevOptions): Pr
logStartupInfo({
header: "dev mode: using local repo",
ports,
primary: "web",
dbPath,
logLevel,
});
+80 -2
View File
@@ -1,6 +1,8 @@
import { describe, expect, it } from "vitest";
import net from "node:net";
import { ensureValidPort } from "./ports";
import { afterEach, describe, expect, it } from "vitest";
import { ensureValidPort, pickAvailablePort, __testing } from "./ports";
describe("ensureValidPort", () => {
it("returns undefined for undefined input", () => {
@@ -43,3 +45,79 @@ describe("ensureValidPort", () => {
expect(() => ensureValidPort(NaN, "test")).toThrow();
});
});
function listenOn(host: string): Promise<{ server: net.Server; port: number }> {
return new Promise((resolve, reject) => {
const server = net.createServer();
server.on("error", reject);
server.listen(0, host, () => {
const addr = server.address();
if (addr && typeof addr === "object") {
resolve({ server, port: addr.port });
} else {
reject(new Error("no address"));
}
});
});
}
function closeServer(server: net.Server): Promise<void> {
return new Promise((resolve) => server.close(() => resolve()));
}
describe("isPortAvailable", () => {
const servers: net.Server[] = [];
afterEach(async () => {
while (servers.length) {
const s = servers.pop();
if (s) await closeServer(s);
}
});
it("returns false when a server is listening on the IPv4 loopback", async () => {
const { server, port } = await listenOn("127.0.0.1");
servers.push(server);
expect(await __testing.isPortAvailable(port)).toBe(false);
});
it("returns true for a port that nothing is bound to", async () => {
const { server, port } = await listenOn("127.0.0.1");
await closeServer(server);
// Port may be in TIME_WAIT briefly, but bind-probe with SO_REUSEADDR
// (Node default on POSIX) treats that as available.
expect(await __testing.isPortAvailable(port)).toBe(true);
});
});
describe("isPortInUse", () => {
it("returns false within the timeout when the host black-holes packets", async () => {
// 192.0.2.0/24 is TEST-NET-1 (RFC 5737): traffic to it is reliably
// dropped, simulating the WSL2 mirrored-mode SYN black-hole. Without the
// timeout in isPortInUse, this connect would hang for ~75 seconds.
const start = Date.now();
const result = await __testing.isPortInUse(38429, "192.0.2.1", 200);
const elapsed = Date.now() - start;
expect(result).toBe(false);
expect(elapsed).toBeLessThan(1000);
});
});
describe("pickAvailablePort", () => {
it("returns the preferred port when it is free", async () => {
const { server, port } = await listenOn("127.0.0.1");
await closeServer(server);
expect(await pickAvailablePort(port)).toBe(port);
});
it("returns a fallback port when the preferred port is taken", async () => {
const { server, port } = await listenOn("127.0.0.1");
try {
const picked = await pickAvailablePort(port, 5);
expect(picked).not.toBe(port);
expect(picked).toBeGreaterThan(0);
} finally {
await closeServer(server);
}
});
});
+27 -11
View File
@@ -3,6 +3,8 @@ import net from "node:net";
import { RANDOM_PORT_MAX, RANDOM_PORT_MIN, RANDOM_PORT_RETRIES } from "./constants";
const CONNECT_PROBE_TIMEOUT_MS = 500;
export function ensureValidPort(port: number | undefined, name: string): number | undefined {
if (port === undefined) {
return undefined;
@@ -15,22 +17,33 @@ export function ensureValidPort(port: number | undefined, name: string): number
/**
* Tries to connect to a port on the given host. Returns true if something
* is already listening (i.e. the port is in use).
* is already listening (i.e. the port is in use). Returns false on connection
* refused, on any other socket error, or when the probe takes longer than
* `timeoutMs` — under WSL2 mirrored networking the kernel can silently drop
* the SYN to an unbound loopback port, so an unbounded connect would hang
* forever and deadlock isPortAvailable.
*
* This is more reliable than a bind-based check on macOS where
* SO_REUSEADDR (set by default in Node.js) can allow a bind to succeed
* even when another process is already listening on the same port.
* Used alongside canBindPort because it detects ports where a listener is
* bound with SO_REUSEADDR (Node's default on macOS), which a bind-only check
* would miss.
*/
function isPortInUse(port: number, host: string): Promise<boolean> {
function isPortInUse(
port: number,
host: string,
timeoutMs = CONNECT_PROBE_TIMEOUT_MS,
): Promise<boolean> {
return new Promise((resolve) => {
const socket = net.createConnection({ port, host });
socket.once("connect", () => {
let settled = false;
const done = (v: boolean) => {
if (settled) return;
settled = true;
socket.destroy();
resolve(true);
});
socket.once("error", () => {
resolve(false);
});
resolve(v);
};
socket.once("connect", () => done(true));
socket.once("error", () => done(false));
setTimeout(() => done(false), timeoutMs).unref();
});
}
@@ -134,3 +147,6 @@ export async function pickAndReservePort(
throw new Error(`Unable to reserve a free port after ${retries + 1} attempts`);
}
// Exported for tests only.
export const __testing = { isPortInUse, isPortAvailable };
+369 -2
View File
@@ -1,6 +1,14 @@
import { afterEach, describe, expect, it } from "vitest";
import os from "node:os";
import { buildWebEnv, type PortConfig } from "./shared";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
buildWebEnv,
listHostNetworkAddresses,
logStartupInfo,
networkUrlsForPort,
type PortConfig,
} from "./shared";
const ports: PortConfig = {
backendPort: 38429,
@@ -59,3 +67,362 @@ describe("buildWebEnv", () => {
expect(buildWebEnv({ ports }).NEXT_PUBLIC_KANDEV_DEBUG).toBeUndefined();
});
});
describe("buildWebEnv allowedDevOrigins", () => {
const originalAllowed = process.env.NEXT_ALLOWED_DEV_ORIGINS;
beforeEach(() => {
vi.spyOn(os, "networkInterfaces").mockReturnValue({
lo: [
{
address: "127.0.0.1",
netmask: "255.0.0.0",
family: "IPv4",
mac: "00:00:00:00:00:00",
internal: true,
cidr: "127.0.0.1/8",
},
],
eth0: [
{
address: "192.168.1.34",
netmask: "255.255.252.0",
family: "IPv4",
mac: "aa:bb:cc:dd:ee:ff",
internal: false,
cidr: "192.168.1.34/22",
},
{
address: "fe80::1",
netmask: "ffff:ffff:ffff:ffff::",
family: "IPv6",
mac: "aa:bb:cc:dd:ee:ff",
internal: false,
cidr: "fe80::1/64",
scopeid: 2,
},
],
tailscale0: [
{
address: "100.94.173.104",
netmask: "255.255.255.255",
family: "IPv4",
mac: "00:00:00:00:00:00",
internal: false,
cidr: "100.94.173.104/32",
},
],
});
});
afterEach(() => {
vi.restoreAllMocks();
if (originalAllowed === undefined) {
delete process.env.NEXT_ALLOWED_DEV_ORIGINS;
} else {
process.env.NEXT_ALLOWED_DEV_ORIGINS = originalAllowed;
}
});
it("populates NEXT_ALLOWED_DEV_ORIGINS with LAN + Tailscale IPs in dev mode", () => {
delete process.env.NEXT_ALLOWED_DEV_ORIGINS;
const env = buildWebEnv({ ports });
const origins = (env.NEXT_ALLOWED_DEV_ORIGINS ?? "").split(",");
expect(origins).toContain("192.168.1.34");
expect(origins).toContain("100.94.173.104");
});
it("excludes loopback and link-local IPv6 addresses", () => {
delete process.env.NEXT_ALLOWED_DEV_ORIGINS;
const env = buildWebEnv({ ports });
const origins = (env.NEXT_ALLOWED_DEV_ORIGINS ?? "").split(",");
expect(origins).not.toContain("127.0.0.1");
expect(origins).not.toContain("fe80::1");
});
it("merges with a user-supplied NEXT_ALLOWED_DEV_ORIGINS, deduped", () => {
process.env.NEXT_ALLOWED_DEV_ORIGINS = "dev.example, 192.168.1.34";
const env = buildWebEnv({ ports });
const origins = (env.NEXT_ALLOWED_DEV_ORIGINS ?? "").split(",");
expect(origins).toContain("dev.example");
expect(origins).toContain("192.168.1.34");
expect(origins).toContain("100.94.173.104");
expect(origins.filter((s) => s === "192.168.1.34")).toHaveLength(1);
});
it("omits NEXT_ALLOWED_DEV_ORIGINS entirely when there's nothing to allow", () => {
delete process.env.NEXT_ALLOWED_DEV_ORIGINS;
vi.restoreAllMocks();
vi.spyOn(os, "networkInterfaces").mockReturnValue({
lo: [
{
address: "127.0.0.1",
netmask: "255.0.0.0",
family: "IPv4",
mac: "00:00:00:00:00:00",
internal: true,
cidr: "127.0.0.1/8",
},
],
});
const env = buildWebEnv({ ports });
// Better than setting NEXT_ALLOWED_DEV_ORIGINS="" — keeps the env clean for
// the loopback-only case so a downstream consumer that distinguishes
// "unset" from "empty" doesn't get confused.
expect(env.NEXT_ALLOWED_DEV_ORIGINS).toBeUndefined();
});
it("does not set NEXT_ALLOWED_DEV_ORIGINS in production", () => {
delete process.env.NEXT_ALLOWED_DEV_ORIGINS;
const env = buildWebEnv({ ports, production: true });
expect(env.NEXT_ALLOWED_DEV_ORIGINS).toBeUndefined();
});
});
describe("listHostNetworkAddresses", () => {
afterEach(() => {
vi.restoreAllMocks();
});
it("returns a deduplicated list of non-internal addresses", () => {
vi.spyOn(os, "networkInterfaces").mockReturnValue({
eth0: [
{
address: "10.0.0.1",
netmask: "255.0.0.0",
family: "IPv4",
mac: "aa:bb:cc:dd:ee:ff",
internal: false,
cidr: "10.0.0.1/8",
},
],
eth1: [
{
address: "10.0.0.1",
netmask: "255.0.0.0",
family: "IPv4",
mac: "11:22:33:44:55:66",
internal: false,
cidr: "10.0.0.1/8",
},
],
});
expect(listHostNetworkAddresses()).toEqual(["10.0.0.1"]);
});
it("excludes internal loopback addresses", () => {
vi.spyOn(os, "networkInterfaces").mockReturnValue({
lo: [
{
address: "127.0.0.1",
netmask: "255.0.0.0",
family: "IPv4",
mac: "00:00:00:00:00:00",
internal: true,
cidr: "127.0.0.1/8",
},
],
eth0: [
{
address: "10.0.0.5",
netmask: "255.0.0.0",
family: "IPv4",
mac: "aa:bb:cc:dd:ee:ff",
internal: false,
cidr: "10.0.0.5/8",
},
],
});
expect(listHostNetworkAddresses()).toEqual(["10.0.0.5"]);
});
it("excludes IPv4 link-local (169.254.0.0/16) addresses", () => {
vi.spyOn(os, "networkInterfaces").mockReturnValue({
eth0: [
{
address: "169.254.83.107",
netmask: "255.255.0.0",
family: "IPv4",
mac: "aa:bb:cc:dd:ee:ff",
internal: false,
cidr: "169.254.83.107/16",
},
{
address: "192.168.1.34",
netmask: "255.255.252.0",
family: "IPv4",
mac: "aa:bb:cc:dd:ee:ff",
internal: false,
cidr: "192.168.1.34/22",
},
],
});
expect(listHostNetworkAddresses()).toEqual(["192.168.1.34"]);
});
it("excludes the full IPv6 link-local range (fe80::/10, covers fe80–febf)", () => {
// In practice OS stacks only assign fe80::/64, but per RFC 4291 the link-
// local range is fe80::/10 — anything from fe80:: through febf:ffff:... is
// link-local and never reachable from a remote machine.
vi.spyOn(os, "networkInterfaces").mockReturnValue({
eth0: [
{
address: "fe80::abcd",
netmask: "ffff:ffff:ffff:ffff::",
family: "IPv6",
mac: "aa:bb:cc:dd:ee:ff",
internal: false,
cidr: "fe80::abcd/64",
scopeid: 2,
},
{
address: "fea0::1",
netmask: "ffff:ffff:ffff:ffff::",
family: "IPv6",
mac: "aa:bb:cc:dd:ee:ff",
internal: false,
cidr: "fea0::1/64",
scopeid: 2,
},
{
address: "FEBF::1",
netmask: "ffff:ffff:ffff:ffff::",
family: "IPv6",
mac: "aa:bb:cc:dd:ee:ff",
internal: false,
cidr: "FEBF::1/64",
scopeid: 2,
},
{
address: "fec0::1",
netmask: "ffff:ffff:ffff:ffff::",
family: "IPv6",
mac: "aa:bb:cc:dd:ee:ff",
internal: false,
cidr: "fec0::1/64",
scopeid: 0,
},
{
address: "2001:db8::1",
netmask: "ffff:ffff:ffff:ffff::",
family: "IPv6",
mac: "aa:bb:cc:dd:ee:ff",
internal: false,
cidr: "2001:db8::1/64",
scopeid: 0,
},
],
});
// fe80::abcd, fea0::1, FEBF::1 are link-local (excluded).
// fec0::1 was deprecated site-local (kept — outside fe80::/10).
// 2001:db8::1 is global (kept).
expect(listHostNetworkAddresses()).toEqual(["fec0::1", "2001:db8::1"]);
});
});
describe("networkUrlsForPort", () => {
it("formats IPv4 hosts without brackets", () => {
expect(networkUrlsForPort(38429, ["192.168.1.34", "100.94.173.104"])).toEqual([
"http://192.168.1.34:38429",
"http://100.94.173.104:38429",
]);
});
it("wraps IPv6 hosts in brackets per RFC 3986", () => {
expect(networkUrlsForPort(38429, ["2001:db8::1"])).toEqual(["http://[2001:db8::1]:38429"]);
});
it("returns an empty list when there are no hosts", () => {
expect(networkUrlsForPort(38429, [])).toEqual([]);
});
});
describe("logStartupInfo", () => {
let log: ReturnType<typeof vi.spyOn<typeof console, "log">>;
beforeEach(() => {
log = vi.spyOn(console, "log").mockImplementation(() => {});
});
afterEach(() => {
vi.restoreAllMocks();
});
function mockTwoInterfaces() {
vi.spyOn(os, "networkInterfaces").mockReturnValue({
eth0: [
{
address: "192.168.1.34",
netmask: "255.255.252.0",
family: "IPv4",
mac: "aa:bb:cc:dd:ee:ff",
internal: false,
cidr: "192.168.1.34/22",
},
],
tailscale0: [
{
address: "100.94.173.104",
netmask: "255.255.255.255",
family: "IPv4",
mac: "00:00:00:00:00:00",
internal: false,
cidr: "100.94.173.104/32",
},
],
});
}
it("prints the backend URL and network URLs by default (start/run mode)", () => {
mockTwoInterfaces();
logStartupInfo({ header: "test", ports });
const lines = log.mock.calls.map((c) => c.join(" "));
expect(lines).toContain("[kandev] url: http://localhost:38429");
expect(lines).toContain("[kandev] network: http://192.168.1.34:38429");
expect(lines).toContain("[kandev] network: http://100.94.173.104:38429");
// Internal-only ports must not appear — the user opens exactly one URL.
expect(lines.some((l) => l.includes(":37429"))).toBe(false);
expect(lines.some((l) => l.includes("agentctl"))).toBe(false);
});
it('prints the web URL and network URLs when primary="web" (dev mode)', () => {
mockTwoInterfaces();
logStartupInfo({ header: "test", ports, primary: "web" });
const lines = log.mock.calls.map((c) => c.join(" "));
expect(lines).toContain("[kandev] url: http://localhost:37429");
expect(lines).toContain("[kandev] network: http://192.168.1.34:37429");
expect(lines).toContain("[kandev] network: http://100.94.173.104:37429");
// The backend port is internal in dev mode (only the browser's API calls
// hit it, via NEXT_PUBLIC_KANDEV_API_PORT), so it doesn't show up as a URL
// for the user. The mcp endpoint still appears because MCP clients use it.
expect(lines.some((l) => l === "[kandev] url: http://localhost:38429")).toBe(false);
expect(lines.some((l) => l.includes("network:") && l.includes(":38429"))).toBe(false);
});
it("still emits the mcp endpoint pointing at the backend port", () => {
vi.spyOn(os, "networkInterfaces").mockReturnValue({});
logStartupInfo({ header: "test", ports, primary: "web" });
const lines = log.mock.calls.map((c) => c.join(" "));
expect(lines).toContain("[kandev] mcp: http://localhost:38429/mcp");
});
it("emits no network lines when only loopback interfaces exist", () => {
vi.spyOn(os, "networkInterfaces").mockReturnValue({
lo: [
{
address: "127.0.0.1",
netmask: "255.0.0.0",
family: "IPv4",
mac: "00:00:00:00:00:00",
internal: true,
cidr: "127.0.0.1/8",
},
],
});
logStartupInfo({ header: "test", ports });
const lines = log.mock.calls.map((c) => c.join(" "));
expect(lines.some((l) => l.includes("network:"))).toBe(false);
});
});
+93 -8
View File
@@ -6,6 +6,7 @@
*/
import type { ChildProcess } from "node:child_process";
import os from "node:os";
import { DEFAULT_AGENTCTL_PORT, DEFAULT_BACKEND_PORT, DEFAULT_WEB_PORT } from "./constants";
import { pickAvailablePort } from "./ports";
@@ -105,6 +106,18 @@ export function buildWebEnv(options: WebEnvOptions): NodeJS.ProcessEnv {
// URLs like `https://host:38429/...` that aren't reachable behind a
// reverse proxy / ingress / Cloudflare tunnel.
env.NEXT_PUBLIC_KANDEV_API_PORT = String(ports.backendPort);
// Auto-allow the host's own LAN / VPN addresses so a dev hitting the dev
// server from another device on the same network (Tailscale, LAN IP, WSL
// mirrored mode, etc.) passes Next.js's allowedDevOrigins check and HMR
// works. The user can still extend the list via NEXT_ALLOWED_DEV_ORIGINS.
// Skip the assignment when there's nothing to add — keeps the env clean
// for the loopback-only case.
const merged = mergeAllowedDevOrigins(
process.env.NEXT_ALLOWED_DEV_ORIGINS,
listHostNetworkAddresses(),
);
if (merged) env.NEXT_ALLOWED_DEV_ORIGINS = merged;
}
if (debug) {
@@ -114,10 +127,61 @@ export function buildWebEnv(options: WebEnvOptions): NodeJS.ProcessEnv {
return env;
}
/**
* Returns the host's non-loopback, non-internal IPv4/IPv6 addresses. Used to
* auto-populate Next.js `allowedDevOrigins` so the dev server accepts
* connections from LAN / Tailscale / SSH-forwarded clients.
*/
export function listHostNetworkAddresses(): string[] {
const v4: string[] = [];
const v6: string[] = [];
const seen = new Set<string>();
const interfaces = os.networkInterfaces();
for (const addrs of Object.values(interfaces)) {
if (!addrs) continue;
for (const addr of addrs) {
if (addr.internal) continue;
// Skip link-local IPv6 (fe80::/10) and link-local IPv4 (169.254.0.0/16,
// RFC 3927) — neither is reachable from a remote machine, and the
// 169.254 range in particular is what Hyper-V assigns to its phantom
// WSL adapter, which clutters the startup output. The regex covers the
// full /10 (fe80::–febf::); OS stacks only assign fe80::/64 in practice
// but a stricter check is the same effort and removes the surprise.
if (addr.family === "IPv6" && /^fe[89ab]/i.test(addr.address)) continue;
if (addr.family === "IPv4" && addr.address.startsWith("169.254.")) continue;
if (seen.has(addr.address)) continue;
seen.add(addr.address);
if (addr.family === "IPv4") v4.push(addr.address);
else v6.push(addr.address);
}
}
// IPv4 first — LAN + Tailscale IPv4 are what people usually want.
return [...v4, ...v6];
}
function mergeAllowedDevOrigins(existing: string | undefined, extra: string[]): string {
const set = new Set<string>();
if (existing) {
for (const s of existing.split(",")) {
const trimmed = s.trim();
if (trimmed) set.add(trimmed);
}
}
for (const s of extra) set.add(s);
return [...set].join(",");
}
export type StartupInfoOptions = {
/** Mode header line, e.g. "dev mode: using local repo" or "release: v0.0.12 (github latest)" */
header: string;
ports: PortConfig;
/**
* Which port a user actually opens in a browser. In `start`/`run` the Go
* backend reverse-proxies Next.js so the entry point is the backend port;
* in `dev` the browser hits Next.js directly. Network URLs are only listed
* under this port — the other one is internal-only. Defaults to "backend".
*/
primary?: "backend" | "web";
/** Database file path */
dbPath?: string;
/** Log level being used */
@@ -126,24 +190,45 @@ export type StartupInfoOptions = {
/**
* Logs a unified startup info block to the console.
*
* Shows only the URL the user actually opens — start/run modes have the Go
* backend reverse-proxy Next.js on a single port, dev mode hits Next.js
* directly. The other port and the agentctl port are internal plumbing and
* would only mislead. Below the URL, lists the same port on each non-loopback
* interface (LAN, Tailscale) so a user opening the app remotely sees the
* right address.
*/
export function logStartupInfo(options: StartupInfoOptions): void {
const { header, ports, dbPath, logLevel } = options;
const backendUrl = ports.backendUrl;
const webUrl = `http://localhost:${ports.webPort}`;
const { header, ports, primary = "backend", dbPath, logLevel } = options;
const primaryPort = primary === "web" ? ports.webPort : ports.backendPort;
const primaryUrl = `http://localhost:${primaryPort}`;
const networkHosts = listHostNetworkAddresses();
console.log(`[kandev] ${header}`);
console.log("[kandev] backend:", backendUrl);
console.log("[kandev] web:", webUrl);
console.log("[kandev] agentctl port:", ports.agentctlPort);
console.log("[kandev] mcp url:", `${backendUrl}/mcp`);
console.log("[kandev] url:", primaryUrl);
for (const url of networkUrlsForPort(primaryPort, networkHosts)) {
console.log("[kandev] network:", url);
}
console.log("[kandev] mcp:", `${ports.backendUrl}/mcp`);
if (dbPath) {
console.log("[kandev] db path:", dbPath);
console.log("[kandev] db:", dbPath);
}
if (logLevel) {
console.log("[kandev] log level:", logLevel);
}
}
/**
* Builds `http://<host>:<port>` URLs from a list of host addresses, wrapping
* IPv6 addresses in brackets per RFC 3986.
*/
export function networkUrlsForPort(port: number, hosts: string[]): string[] {
return hosts.map((host) => {
const formatted = host.includes(":") ? `[${host}]` : host;
return `http://${formatted}:${port}`;
});
}
/**
* Attaches a standardized exit handler to a backend process.
*
+8 -7
View File
@@ -1,13 +1,14 @@
import type { NextConfig } from "next";
const defaultDevOrigins = [
"localhost",
"localhost:37429",
"192.168.1.116", // Maintainer dev server
"100.105.155.17", // Maintainer dev server (Tailscale)
];
// The kandev CLI auto-populates NEXT_ALLOWED_DEV_ORIGINS with the host's
// non-loopback addresses (see apps/cli/src/shared.ts) so LAN / Tailscale /
// SSH-forwarded clients pass Next.js's allowedDevOrigins check. Users running
// `next dev` directly can still extend the list manually via that env var.
const defaultDevOrigins = ["localhost", "localhost:37429"];
const extraDevOrigins = process.env.NEXT_ALLOWED_DEV_ORIGINS
? process.env.NEXT_ALLOWED_DEV_ORIGINS.split(",").map((s) => s.trim())
? process.env.NEXT_ALLOWED_DEV_ORIGINS.split(",")
.map((s) => s.trim())
.filter(Boolean)
: [];
const nextConfig: NextConfig = {