From fd433626aeb15952cbb84bbc5722d6db8cb3d73f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Carlos=20Flor=C3=AAncio?= Date: Sun, 17 May 2026 12:29:44 +0100 Subject: [PATCH] fix(cli): unblock make dev on WSL2 mirrored networking (#924) --- apps/cli/src/dev.ts | 1 + apps/cli/src/ports.test.ts | 82 +++++++- apps/cli/src/ports.ts | 38 ++-- apps/cli/src/shared.test.ts | 371 +++++++++++++++++++++++++++++++++++- apps/cli/src/shared.ts | 101 +++++++++- apps/web/next.config.ts | 15 +- 6 files changed, 578 insertions(+), 30 deletions(-) diff --git a/apps/cli/src/dev.ts b/apps/cli/src/dev.ts index b768ae93c6..2a0d7ffae7 100644 --- a/apps/cli/src/dev.ts +++ b/apps/cli/src/dev.ts @@ -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, }); diff --git a/apps/cli/src/ports.test.ts b/apps/cli/src/ports.test.ts index 5b499a9cf7..0d06e7d664 100644 --- a/apps/cli/src/ports.test.ts +++ b/apps/cli/src/ports.test.ts @@ -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 { + 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); + } + }); +}); diff --git a/apps/cli/src/ports.ts b/apps/cli/src/ports.ts index 358d32ba2d..e1841a06b8 100644 --- a/apps/cli/src/ports.ts +++ b/apps/cli/src/ports.ts @@ -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 { +function isPortInUse( + port: number, + host: string, + timeoutMs = CONNECT_PROBE_TIMEOUT_MS, +): Promise { 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 }; diff --git a/apps/cli/src/shared.test.ts b/apps/cli/src/shared.test.ts index c3056356d8..d4390502ec 100644 --- a/apps/cli/src/shared.test.ts +++ b/apps/cli/src/shared.test.ts @@ -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>; + + 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); + }); +}); diff --git a/apps/cli/src/shared.ts b/apps/cli/src/shared.ts index 42c5a71170..ec139a5833 100644 --- a/apps/cli/src/shared.ts +++ b/apps/cli/src/shared.ts @@ -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(); + 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(); + 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://:` 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. * diff --git a/apps/web/next.config.ts b/apps/web/next.config.ts index f05130dd6f..478fa35c79 100644 --- a/apps/web/next.config.ts +++ b/apps/web/next.config.ts @@ -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 = {