fix(cli): unblock make dev on WSL2 mirrored networking (#924)
This commit is contained in:
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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.
|
||||
*
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
Reference in New Issue
Block a user